"""Base Dialog class for Winston.

This module provides the core Dialog class that all Winston dialogs inherit from.
Dialog manages elements, keyboard routing, cursor navigation, and speech feedback.

Key Class:
    Dialog: Base class for all dialogs, manages elements and keyboard routing

Dialog extends both KeyHandler (for keyboard handling) and list (for element storage).
All dialog subclasses are in the subdialogs module.
"""

from __future__ import annotations
from typing import Any, TYPE_CHECKING
from collections.abc import Callable, Generator, Iterable, Sequence
import sys
import os
import queue
import inspect
import time
import copy
import re
import pyperclip  # type: ignore[import]
from datetime import datetime as dt
from . import ordinal_date
from . import framework
from .framework import Anchor, beep, ModifiedKey, load, backup, WinQuit
from . import punctuator
from . import unspeakables
from bdb import BdbQuit
from .key_handler import KeyHandler, Selector, DialogException, ElementTemplate, get_commands
from .elements import Element, ValuesElement, AppletElement
from .form_elements import (FormElement, LabelElement, ListElement, BooleanElement,
                            ButtonElement, TextElement, SingleTextElement,
                            PermeableTextElement, SurrogateTextElement, NumberElement)
if TYPE_CHECKING:
    from queue import Queue
    from threading import Thread
    import types

class Dialog(KeyHandler, list):
    """Base class for all Winston dialogs.

    Manages elements, keyboard routing, cursor navigation, and speech feedback.
    Extends both KeyHandler (for keyboard handling) and list (for element storage).

    Attributes:
        name: Dialog name spoken when activated.
        parent: Parent dialog or Anchor.
        home_dialog: Root dialog of applet hierarchy.
        child: Current child dialog, or None.
        app: Winston application (Anchor).
        filler: Magic cookie for generate_content.
        revert_to: Dialog to return to on close.
        empty_value: Speech text for empty dialog.
        key_q: Input queue (own queue for applets, parent's for modals).
        cursor: Current element index.
        selection: Selector for text selection.
        dismiss: If set, get_input returns False to exit run loop.
        is_paused: True when paused for debugger.
        under_inspection: True when being debugged.
        config_section: Config section name for this dialog type.
    """

    @classmethod
    def instance_name(cls, app: Any, name_seed: str | None) -> str:
        """Generate instance name for dialog.

        Called before instance construction to determine whether to construct
        new instance or activate existing one. Override in subclasses to
        generate unique names (e.g., editor, explorer) vs singleton names.

        Base implementation returns singleton name (class name without 'Dialog').

        Args:
            app: Winston application (Anchor).
            name_seed: Suggested name, or None for class-based name.

        Returns:
            Instance name used as dialog.name.
        """
        return name_seed if name_seed else cls.__name__.split('Dialog')[0].lower()

    def __init__(
        self,
        parent: Dialog | Anchor,
        name_seed: str | None,
        element_type: type[Element] = Element,
        filler: Any = None,
        revert_to: Dialog | None = None,
        empty_value: str = 'empty'
    ) -> None:
        """Initialize Dialog.

        Args:
            parent: Parent dialog or Anchor (app root).
            name_seed: Base name for instance_name().
            element_type: Element type for content validation.
            filler: Magic cookie for generate_content (list, generator, etc).
            revert_to: Dialog to return to on close.
            empty_value: Speech text when dialog is empty.
        """
        #filler is a  magic cookie (tuple, list, set, generator, filename, etc, which the Dialog's generate_content method uses to yield a sequence of elements passed to the list constructor below
        self._name = type(self).instance_name(parent.app,name_seed)  # type: ignore[has-type]  #done before super as generate_content (passed to super as iterable to fill the list) also sometimes updates name
        self.parent = parent
        #all sub-dialogs of an applet have home_dialog pointing to the applet's main dialog and applets main dialogs home_dialog attribute points to themself
        self.home_dialog: Dialog = getattr(self.parent,'home_dialog',self)  # type: ignore[assignment]
        """
        get_input immediately returns False if dismiss is True, indicating that the dialog should exit it's run loop. 
        Dismiss is set by unwind() which happens when a child detects AdultContent and sets dismiss on all childish ancestors
        Dismiss is also set if app.quit_now is detected and percolates one level further back, including the applet home_dialog itself   
        """
        self.dismiss = None
        self.child = None #set by children when they enter their run() loop and unset when they exit. Used in Carousel.set_current to speak the breadcrumbs of the active Dialog hierarchy
        self.app: Anchor = parent.app  # type: ignore[assignment]
        assert self.app is not None, "app must be set"
        self.filler = filler
        self.revert_to=revert_to
        self.empty_value=empty_value
        self.super_dict: dict[str, Any] = {}
        """
        If parent is app (i.e. the anchor object, then parent.key_q will be None, indicating that the dialog object is an applet which runs on its own thread with its own key_q, otherwise it inherits the key_q of it's parent and is therefore modal
        """
        self.key_q =  parent.key_q if parent.key_q  else queue.SimpleQueue()  # type: ignore[has-type]
        """
        self.is_element is set when instantiated as an element in a FormDialog
        """
        self.is_element=False
        self.cursor = 0
        self.current_indents: str = ""
        self.previous_cursor = 0
        self.is_paused = False
        self.under_inspection = False
        self.selection: Selector = Selector(self)
        self.purge_speech=True #self.say() ignores any specified interrupt of speech if this is set to False
        c_name=self.__class__.__name__.lower()
        if c_name!='dialog':
            c_name = c_name.replace('dialog','') #strip 'dialog' off the end of the class name to get the section name (lower case)
        if c_name in self.app.config.sections():
            self.config_section = c_name
        else:
            self.config_section  = 'dialog' 
        self.say_line_numbers: bool =self.app.config[self.config_section].getboolean('say_lines') or False   #used in Element.prefix()  to determine whether to speak line number
        self.say_indents: bool  = self.app.config[self.config_section].getboolean('say_indents') or False  #used in Element.prefix() to determin whether to include Lxxx at start of speakable
        self.say_punctuation: bool = self.app.config[self.config_section].getboolean('say_punctuation') or False
        self.hide_blank_lines: bool = False  # If True, up/down navigation skips blank lines
        # Initialize _key_methods and _accelerator_keys before list.__init__ so they exist when generate_content calls _register_accelerator
        self._key_methods = {}
        self._accelerator_keys: set[str] = set()  # Track keys registered as element accelerators
        if filler is None:
            filler = []
        list.__init__(self,self.generate_valid_content(filler,element_type=element_type))
        KeyHandler.__init__(self)

    def __str__(self) -> str:
        """Return string representation of dialog.

        If dialog is element in FormDialog, returns current element's string.
        Otherwise returns dialog name.

        Returns:
            Dialog name or current element value.
        """
        if not self.is_element: #this dialog is not instantiated as an element of a FormDialog:
            return getattr(self,'name',f'un-named {self.__class__.__name__}')
        else: #instantiated by FormDialog as a form element so return the value of the current element of this dialog which gives best intuitive behaviour
            result = self.current().__str__()
            return result if result is not None else ''

    def __repr__(self) -> str:
        """Return unique representation of dialog.

        Override in dialogs where multiple instances may have indistinguishable
        str names.

        Returns:
            Dialog name or memory address.
        """
        result = str(self)
        return result if result is not None else f'<{self.__class__.__name__} at {hex(id(self))}>'

    # FormElement interface methods - allow Dialog to be field in FormDialog

    def get_hotkey_handler(self, modified_name: str) -> Callable[[Dialog, str], Any] | None:
        """Get hotkey handler for key.

        First checks current element, then checks filtered dialog methods.

        Args:
            modified_name: Key name with modifiers.

        Returns:
            Handler function, or None if no handler.
        """
        #print(f"Called get_hotkey_handler in {self.__class__.__name__} with value: {modified_name}")
        unshifted = modified_name.replace('Shift+','')
        handler =  self.current().get_hotkey_handler(unshifted)
        # if no handler on the element then try subset of own handlers
        if handler is None:
            handler = self.filtered_methods().get(unshifted,None)  # type: ignore[assignment]
        return handler  # type: ignore[return-value]

    def filtered_methods(self) -> dict[str, Callable[[Dialog, str], Any]]:
        """Get filtered hotkey methods for use as FormDialog field.

        Excludes keys that would dismiss dialog (Alt+Left, Escape, Alt+Right).
        Override in subclasses to filter additional subclass-specific keys.

        Returns:
            Dict mapping key names to handler functions.
        """
        exclusions=[
            "Alt+Left",
            "Escape",
            "Alt+Right"
        ]
        return {key: value for key, value in self._key_methods.items() if key not in exclusions}  # type: ignore[misc]
    @property
    def value(self) -> Any:
        """Get value of current element."""
        return self.current().value

    @value.setter
    def value(self, val: Any) -> None:
        """Set value (not yet supported)."""
        raise NotImplementedError('setting of value on Dialog not yet supported')

    @property
    def caret(self) -> int:
        """Get caret position in current element."""
        return self.current().caret

    @caret.setter
    def caret(self, val: int) -> None:
        """Set caret position in current element."""
        self.current().caret=val

    @property
    def dialog(self) -> Dialog:
        """Get parent dialog (when this dialog is FormDialog field)."""
        return self.parent  # type: ignore[return-value]

    def say_current_char(self) -> None:
        """Speak current character (proxy to current element)."""
        self.current().say_current_char()

    # End of FormElement interface methods

    @property
    def name(self) -> str:
        """Get dialog name."""
        return self._name

    @name.setter
    def name(self, val: str) -> None:
        """Set dialog name."""
        self._name=val

    @staticmethod
    def allow_as_element() -> bool:
        """Check if this dialog type can be FormDialog field."""
        return True

    @staticmethod
    def __inspection_class__() -> type[Dialog]:
        """Get inspection class for this dialog type."""
        return Dialog

    def element_attributes(self) -> set[str]:
        """Get required attributes for elements in this dialog type.

        Subclasses override to add requirements:
        return super().element_attributes().union({'additional', 'requirements'})

        Returns:
            Set of required attribute names.
        """
        return {'value', 'name', 'caret', 'dialog', 'get_hotkey_handler', 'say_current_char'}

    def generate_valid_content(
        self,
        filler: Any,
        element_type: type[Element] = Element
    ) -> Generator[Element, None, None]:
        """Validate elements have required attributes and yield them.

        Args:
            filler: Content source for generate_content.
            element_type: Element type for validation.

        Yields:
            Validated elements.

        Raises:
            TypeError: If element missing required attributes/methods.
        """
        required_attrs = self.element_attributes()
        for e in self.generate_content(filler, element_type=element_type):
            # Check if element has all required attributes/methods
            element_attrs = set(dir(e))
            missing_attrs = required_attrs - element_attrs
            if missing_attrs:
                raise TypeError(f"Element {e.__class__.__name__} missing required attributes: {missing_attrs}")
            # Additional check: ensure required methods are actually callable
            callable_methods = {'get_hotkey_handler', 'say_current_char', 'previous_word'}
            for attr_name in required_attrs.intersection(callable_methods):
                if not callable(getattr(e, attr_name)):
                    raise TypeError(f"Element {e.__class__.__name__}.{attr_name} must be callable")
            yield e

    def generate_content(
        self,
        filler: Any,
        element_type: type[Element] = Element
    ) -> Generator[Element, None, None]:
        """Generate elements from filler.

        Args:
            filler: Iterable of strings or ElementTemplates.
            element_type: Default element type for strings.

        Yields:
            Elements created from filler.

        Raises:
            TypeError: If filler contains unsupported types.
        """
        filler =['dialog with None filler'] if filler is None else filler
        for value in filler:
            if isinstance(value,str):
                e=element_type(self,'blank line',value)
            elif isinstance(value,ElementTemplate):
                #print('value kwargs',value.kwargs)
                e=value.type(self,value.name,value.value,**value.kwargs)
            else:
                raise TypeError(f'In Dialog.generate_content encountered {value.__class__.__name__} in filler; only support string or ElementTemplate')
            yield e

    def refill(
        self,
        filler: Any,
        position: tuple[int, int] | None = (0, 0),
        silent: bool = False
    ) -> None:
        """Refill dialog with new content.

        Args:
            filler: New content source.
            position: (cursor, caret) position after refill.
            silent: If True, don't speak after refill.
        """
        self.clear()
        self.cursor=0
        self.filler = filler
        for f in self.generate_valid_content(filler):
            self.append(f)
        if position is not None:
            self.move(*position,False,silent=silent)

    def empty_message(self) -> str:
        """Get message for empty dialog.

        Returns:
            Empty message string.
        """
        return f"empty {self.__class__.__name__.replace('Dialog','')}"

    def closing(self) -> None:
        """Clean up on dialog close.

        Override for cleanup (close files, connections, etc).
        Must not prompt for user input or be long-running.
        """
        pass

    def breakpoint(self, start_and_continue: bool = False) -> bool:
        """Set debugger breakpoint in this dialog.

        Args:
            start_and_continue: If True, continue after first break.

        Returns:
            True if breakpoint set, False if already debugging another applet.
        """
        if Anchor.debugger.startup_complete             and Anchor.debugger.applet and not (Anchor.debugger.applet is self.home_dialog):
            print(f'cannot debug {self.home_dialog} while already debugging  {Anchor.debugger.applet}')
            return False
        already_debugging=Anchor.debugger.startup_complete
        if not already_debugging:
            self.home_dialog.under_inspection = True
            debug_frame = sys._getframe(1)
            Anchor.debugger.set_trace(applet=self.home_dialog,frame=debug_frame,start_and_continue=(start_and_continue and not already_debugging),trap_type='break')
        else:
            debug_frame = sys._getframe()
            Anchor.debugger.set_return(debug_frame)
        return True

    def current(self) -> Element:
        """Get current element at cursor position.

        Returns:
            Current element.
        """
        return self.get_element(self.cursor)

    def get_element(self, cursor: int) -> Element:
        """Get element at cursor position.

        Args:
            cursor: Element index.

        Returns:
            Element at cursor.
        """
        if len(self) and not (0<=cursor<len(self)):
            print(f'cursor out of range, cursor: {cursor} and len(self): {len(self)}')
            cursor = 0 
            self.breakpoint()
        return self[cursor] if len(self) else Element(self,'null Element',value=self.empty_message())

    def can_wrap_forward(self) -> bool:
        """Check if cursor can wrap forward to start."""
        #beep()
        return False

    def can_wrap_backward(self) -> bool:
        """Check if cursor can wrap backward to end."""
        #beep()
        return False

    def supports_element_editing(self) -> bool:
        """Check if dialog supports element editing (find/replace)."""
        #override in subclasses that support replace on find command
        return False

    def pause_or_resume(self, pause: bool = True) -> None:
        """Pause or resume dialog for debugging.

        Args:
            pause: If True, pause dialog; if False, resume.
        """
        if pause:
            self.is_paused = True
            self.name = self.name + Anchor.debugger.paused_suffix
        else:
            self.name = self.name.replace(Anchor.debugger.paused_suffix,"")
            self.is_paused = False

    @KeyHandler.hotkey("Alt+Space")
    def command_view_log(self, modified_name: str) -> Any:
        """View output log (Shift for previous log)."""
        from . import debugging
        previous_log =  modified_name.startswith('Shift')
        if previous_log:
            prefix='$'
            log_name='previous output log'
        else:
            prefix = ''
            log_name='output log'
        rc =  debugging.LogDialog(self, log_name,filler=prefix+self.app.config['application'].get('output', 'output.txt')).run([log_name])
        self.say_current()
        return rc

    @KeyHandler.hotkey("Alt+D")
    def command_debug(self, modified_name: str) -> None:
        """Start debugging this dialog (Shift to break immediately)."""
        if self.breakpoint(start_and_continue=not ('Shift' in  modified_name)):
            self.say_strings(f'inspecting {self.home_dialog.name} with breakpoints set in {len(Anchor.debugger.get_all_breaks())} files')

    @KeyHandler.hotkey("Control+Q")
    def command_quit_debugging(self, modified_name: str) -> None:
        """Quit debugging current applet."""
        if (applet:=Anchor.debugger.applet):
            name=applet.name.replace('under inspection','')
            if (self.message(title=f'stop debugging {name}')=='Yes'):
                Anchor.debugger.set_quit()
                self.say_strings(f'terminated {name}')
            else:
                self.say_strings('cancelled quit debugger')
        else:
            self.say_strings('not currently debugging any applet')

    @KeyHandler.hotkey("Alt+K")
    def command_kill_applet(self, modified_name: str) -> None:
        """Kill selected applet."""
        applets= [a.name for a in self.app.applets]
        applet_name = self.message(title='Select applet to terminate',buttons = applets)
        if not applet_name:
            self.say_strings('no applet selected')
        else:
            applet= [a for a in self.app.applets if a.name==applet_name][0]
            applet.key_q.put('quit_now')
            self.say_strings(f'quit request posted to {applet_name}')
    @KeyHandler.hotkey("Alt+J")
    def command_trace_thread(self, modified_name: str) -> None:
        """Toggle call tracing for current thread."""
        self.app.tracer.toggle()

    def message(
        self,
        buttons: list[str] | None = None,
        title: str | None = None,
        select: str | None = None
    ) -> str | bool:
        """Show message dialog with buttons.

        Args:
            buttons: List of button labels. Defaults to ['Yes', 'No'].
            title: Dialog title. Defaults to comma-separated button names.
            select: Initially selected button.

        Returns:
            Selected button name, or False if cancelled.
        """
        if buttons is None:
            buttons = ['Yes', 'No']
        title = ', '.join(buttons) if not title else title
        if not len(buttons):
            i=0
            buttons=['Cancel']
            announce =title
        else:
            if select is None:
                i=0
            else:
                try:
                    i = buttons.index(select)
                except:
                    i=0
            announce = [title,buttons[i]]  # type: ignore[assignment]
        d = Dialog(self,name_seed=title,filler = buttons)
        d.move(i,0,False)
        return d.run(speak=announce)

    def input_text(
        self,
        prompt: str = 'enter text',
        default: str = "",
        title: str = 'input'
    ) -> str | bool:
        """Prompt for text input.

        Args:
            prompt: Input prompt text.
            default: Default value.
            title: Dialog title.

        Returns:
            Entered text, or False if cancelled.
        """
        from .subdialogs import InputDialog
        return InputDialog(parent=self,name_seed=title,element_type=None,filler=[(SingleTextElement,prompt, default),(ButtonElement,'ok'),(ButtonElement,'cancel')]).run(speak=[title,prompt,default])

    def input_number(
        self,
        prompt: str,
        default: int | None = None,
        title: str = 'input',
        min: int = 0,
        max: int = sys.maxsize
    ) -> int | bool:
        """Prompt for number input.

        Args:
            prompt: Input prompt text.
            default: Default value.
            title: Dialog title.
            min: Minimum value.
            max: Maximum value.

        Returns:
            Entered number, or False if cancelled.
        """
        from .subdialogs import InputDialog
        speak = [title,prompt] if default is None else [title,prompt,str(default)]
        return InputDialog(parent=self,name_seed=title,element_type=None,filler=[(NumberElement,prompt, default,min,max),(ButtonElement,'ok'),(ButtonElement,'cancel')]).run(speak=speak)

    def input_values(
        self,
        title: str = 'Confirmation',
        fields: Any = None,
        say_labels: bool = True,
        empty_value: str = "empty value",
        validate_func: Callable[[dict[str, Any]], bool | str] | None = None,
        default: Any = None,
        default_name: str | None = None,
        run_speak: Any = None
    ) -> dict[str, Any] | bool:
        """Show form dialog for multiple input values.

        Args:
            title: Dialog title.
            fields: Field definitions.
            say_labels: If True, speak field labels.
            empty_value: Text for empty fields.
            validate_func: Validation function.
            default: Default field (deprecated, use default_name).
            default_name: Name of initially selected field.
            run_speak: Custom speech on run.

        Returns:
            Dict of field values, or False if cancelled.
        """
        from .subdialogs import FormDialog
        i_dialog =  FormDialog(parent=self,name_seed=title,filler=fields,say_labels=say_labels,empty_value=empty_value)
        if validate_func:
            i_dialog.validate_dialog = validate_func  # type: ignore[method-assign,assignment]
        #default_name is preferred way to set the selection and will fix throughout then retire default
        if default_name is not None:
            i_dialog.set_from_name(default_name,silent=True)
        elif default is not None:
            try:
                i_dialog.set_current(fields.index(default))
            except:
                print(f'invalid default field {default}')
        return i_dialog.run(speak=run_speak) #if None then will speak name and current

    def file_selection(
        self,
        button: str = 'select',
        folder: str | None = None,
        filename: str | None = None
    ) -> str | None:
        """Show file or folder selection dialog.

        Args:
            button: Button label.
            folder: Initial folder path. Defaults to cwd.
            filename: Initial filename, or None for folder selection.

        Returns:
            Selected file/folder path, or None if cancelled.
        """
        if folder is None:
            folder = os.getcwd()
        from .applets.explorer import FileSelectionDialog #late import to avoid circular imports at start up
        f_dialog = FileSelectionDialog(parent=self,name_seed=button,filler=[folder,filename,button],say_labels=True)
        results = f_dialog.run(speak=f'{button}: {filename}') #run('')
        #print (f'results in file_selection method: {results}')
        if (not results) or (results['__focus__']['name'].lower()=='cancel'):
            return None
        if filename is not None:
            return os.path.join(results['folder path'],results['filename'])
        else:
            return results['folder path']

    def run(self, speak: Any = None) -> Any:
        """Run dialog event loop.

        Sets parent.child, runs inner_run loop, then unsets parent.child.

        Args:
            speak: Text to speak on dialog open, or None for default.

        Returns:
            Dialog result (close reason).
        """
        self.parent.child=self  # type: ignore[union-attr]
        res = self.inner_run(speak=speak)
        self.parent.child = None  # type: ignore[union-attr]
        return res

    def inner_run(self, speak: Any) -> Any:
        """Inner event loop for dialog.

        Calls pre_run() hook if exists, then enters main get_input/handle_key loop.
        Loop exits when close_reason is non-None.

        Args:
            speak: Text to speak on dialog open.

        Returns:
            Close reason (result from key handler or False for quit).
        """
        # Call pre_run hook if it exists (after __init__ complete, before main loop)
        if hasattr(self, 'pre_run'):
            result = self.pre_run()
            # If pre_run returns non-None, treat as early exit
            if result is not None:
                return result
        speak = [str(self),str(self.current())] if (speak is None) else speak
        self.say_strings(speak,interrupt=True)
        """
        This loop is what keeps a dialog alive and should only be modified with extreme caution. Do not override.
        The normal return from get_input is a ModifiedKey isntance, which is handled below and the handlers return None unless they wish the dialog to exit, in which case they return True, False or any other non-None result
        get_input can itself return False if the dialog should quit without calling a key handler , e.g. if app.quit_now is set
        get_input Also returns False if self.dismiss is True.
        self.dismiss is set if app_quit is detected by a child dialog
        self.dismiss can also be set by a child dialogs own get_input method after identifying and handling an AdultContent input internally within get_input by calling consume_adult_content on the child's home_dialog
        The initial detector of AdultContent sets dismiss to True on all 'childish' ancestors (i.e. up to but not including the home_dialog for this applet).
        """
        close_reason = None 
        while close_reason  is None:
            inp   =self.get_input()
            if isinstance(inp,framework.ModifiedKey):
                close_reason  =  self.handle_key(inp) 
            elif inp == 'release':
                #simply discard release indication put on queue by debugging UI which has not been consumed in debugging handshake, typically due to closing down sequence 
                pass
            elif not inp:
                """
                inp can be False to indicate close dialog or None. 
                Can be None if AdultContent has been consumed in get_input and this dialog should not close (e.g. because it is the home_dialog for the debugger ui and the input was DebugInfo from winbugger which has now been consumed within get_input
                Consumption handled inside get_input as there may have been child dialogs present when DebugInfo was received so this still gets the content into  the home_dialog and the children unwind 
                """
                close_reason = inp 
            else:
                raise TypeError(f'unexpected return from get_input in main loop: {inp.__class__.__name__}: {inp}, isinstance ModifiedKey {isinstance(inp,ModifiedKey)}, type of inp: {type(inp)}')
            if close_reason  is not None:
                #so long as not closing the whole app, give user a chance to cancel the close or save work 
                if (not self.app.quit_now) and hasattr(self,'on_close'):
                    close_reason  = self.on_close(close_reason)
            #end_while result is not None dialog loop, so now closing this dialog  
        #place applet specific short-running cleanup code in closing method which is called here
        self.closing()
        return close_reason

    def get_input(self) -> str | bool | ModifiedKey | None:
        """Get next input from queue.

        Returns:
            ModifiedKey for keyboard input, False to close dialog,
            None if AdultContent consumed, or 'release' string from debugger.
        """
        from . import debugging
        if self.dismiss:
            return False
        # Use timeout loop to allow asyncio to process during waits
        while True:
            try:
                inp = self.key_q.get(timeout=0.1)  # 100ms timeout
                break
            except queue.Empty:
                # Check if we should quit during the timeout
                if self.app.quit_now:
                    return False
                # Allow other threads/asyncio to process during timeout
                time.sleep(0.01)
                continue
        if self.app.quit_now or (inp=='quit_now'):
            # ... existing code unchanged
            if Anchor.debugger.applet is self:
                """
                if applet is under inspection  then quit_now invokes winbugger.set_quit() 
                An applet is under inspection either because user started debugging it or an exception cause debugging.exc_writer to invoke  winbugger.set_trace() 
                set_quit() raises a BdbQuit exception prior to calling the next user_xxx method. 
                This     should immediately kill any thread under inspection 
                """    
                Anchor.debugger.set_quit()
                raise WinQuit()
                print(f'unexpectedly executing this next statement after a bdb.set_quit() in get_input')
                result = False                
            else:
                #print('unwinding and returning False from get_input')
                #raise ValueError('unwinding unexpectedly')
                self.unwind(stop_dialog=None)
                result = False 
        elif isinstance(inp, debugging.AdultContent):
            self.home_dialog.consume_adult_content(inp)  # type: ignore[attr-defined]
            #unwind returns None if this is the stop_dialog otherwise False
            result = self.unwind(stop_dialog=self.home_dialog)  # type: ignore[assignment]
        else:
            result=inp 
        #result is False if this dialog should dismiss itself or None if it should keep running, or (usual case) it is the input key with modifiers  
        return result

    def unwind(self, stop_dialog: Dialog | None = None) -> bool | None:
        """Dismiss child dialogs up to stop_dialog.

        Args:
            stop_dialog: Dialog to stop at (not dismissed).

        Returns:
            False to dismiss self, None to keep running.
        """
        #dismiss self as well as children unless self is the stop_dialog
        #dismissing of self is achieved by returning False, which is passed on by get_input and causes running loop to exit
        get_input_return = None
        ancestor = self.parent
        while isinstance(ancestor, Dialog) and ancestor is not stop_dialog:
            get_input_return=False
            ancestor.dismiss  = True
            ancestor = ancestor.parent
        return get_input_return

    def handle_key(self, key: ModifiedKey) -> Any:
        """Handle keyboard input.

        If printable char without modifiers, call char_sink.
        Otherwise call handle_hotkey.

        Args:
            key: Modified key with ascii and modifiers.

        Returns:
            Result from char_sink or handle_hotkey.
        """
        #print(f'handling {key.modified_name}')
        self.result = None
        if (key.ascii is not None)  and (char:=chr(key.ascii)).isprintable() and not len(key.modifiers-set(['Lshift','Rshift'])):# final test is to see if any modifiers other than shift
            #char=framework.resync_caps_lock(chr(key.ascii), key.modified_name.startswith('Shift')) #this checks if the key ascii value is uppper case but no Shift in modified_name and GetKeyState says caps_lock off, in which case we simulate a single caps lock press/release to resync pyhook with the state as mucked around with by JAWS use of double caps lock to change state and return lowercase(char) otherwise return char
            self.result = self.char_sink(char)
        else:
            self.result  = self.handle_hotkey(key.modified_name)
        return self.result

    def handle_hotkey(self, modified_name: str) -> Any:
        """Invoke hotkey handler.

        Checks current element first, then own handlers.

        Args:
            modified_name: Key name with modifiers.

        Returns:
            Handler result.
        """
        unshifted = modified_name.replace('Shift+','')
        if self.app.break_on_hotkey:
            self.app.break_on_hotkey=False
            self.breakpoint()
        handler =  self.current().get_hotkey_handler(unshifted)
        # if no handler on the element then try own handlers
        if handler is None:
            handler = self._key_methods.get(unshifted,self.no_method)
        return handler(modified_name)

    def unmapped(self, modified_name: str) -> None:
        """Beep for unmapped key (not currently called)."""
        beep(tag=f"no mapping for {modified_name} on Element of type {self.current().__class__.__name__} on line  {self.cursor+1} in Dialog of type {self.__class__.__name__}")
        beep()

    def unimplemented(self, modified_name: str) -> None:
        """Beep for unimplemented mapping (not currently called)."""
        beep(tag=f"no {self.mapper[modified_name.replace('Shift+','')]} method implemented to satisfy the Dialog mapping for {modified_name}")  # type: ignore[attr-defined]

    def unimplemented_on_element(self, modified_name: str) -> None:
        """Beep for unimplemented element mapping (not currently called)."""
        beep(tag=f"no {self.current().mapper[modified_name.replace('Shift+','')]} method implemented to satisfy the   mapping for {modified_name} on {self.current().value} Element on line  {self.cursor+1} in Dialog {self.name}")  # type: ignore[attr-defined]

    def char_sink(self, char: str) -> Any:
        """Handle printable character input.

        Delegates to element's char_sink if available, otherwise searches
        for element starting with char.

        Args:
            char: Single character to handle.

        Returns:
            Result from element char_sink or set_from_value.
        """
        if hasattr(self.current(),'char_sink'):
            return  self.current().char_sink(char)  # type: ignore[attr-defined]
        else:
            self.set_from_value(char,match_case=False,match_type='startswith',interrupt=True)
            return None

    def lines_sink(self, multi_line: bool, prompt: str) -> str:
        """Capture text input for stdin redirection.

        Args:
            multi_line: If True, use EditDialog; if False, use TextElement.
            prompt: Prompt text for input.

        Returns:
            Entered text with newline appended.
        """
        from .applets.editor import ViewDialog, EditDialog
        from .subdialogs import StdinDialog
        if not multi_line:
            input_type=PermeableTextElement  # type: ignore[assignment]
        else:
            input_type=EditDialog  # type: ignore[assignment]
        fields=[
            (ViewDialog,'Output',Element,list(self.app.output_log)),
            (input_type,prompt,''),
        ]
        i_dialog =  StdinDialog(parent=self,name_seed=' ',filler=fields,say_labels=True,empty_value=' ')
        i_dialog.set_from_name(prompt,silent=True)
        results = i_dialog.run(speak='%current')
        return (results[prompt] if results else '')+'\n'

    def set_from_value(
        self,
        match_string: str,
        match_case: bool = False,
        match_type: str = 'startswith',
        interrupt: bool = False
    ) -> None:
        """Find and move to element matching string.

        Args:
            match_string: String to match against element values.
            match_case: If True, case-sensitive match.
            match_type: Match type ('startswith', 'contains', 'exact').
            interrupt: If True, interrupt speech when found.
        """
        matches = [e for e  in self[self.cursor+1:] + self[:self.cursor+1] if framework.test_match(e.value,match_string,match_case,match_type)]
        if len(matches):
            #print(f'found {len(matches)} matches')
            self.cursor =  self.index(matches[0])
            self.selection.reset(self.cursor,0)
            self.say_current(interrupt=interrupt)
        else:
            #print('no match')
            beep()

    def insert_text(self, s: str) -> bool | None:
        """Insert text at cursor, replacing selection if any.

        Args:
            s: Text to insert.

        Returns:
            True if inserted, None if failed.
        """
        if not self.selection.is_empty():
            self.excise_selection() #note this will update cursor and the then current Element's caret to where selection was unless the Element at start of selection is read only, in which case it just beeps
        if not len(s):
            return None #do nothing if inserting an empty string
        if  (s.find('\n')==-1): #this is a single line insert so invoke if  current line supports it
            if hasattr(self.current(),'insert'):
                self.current().insert(s)  # type: ignore[attr-defined]
                return True
            else:
                beep() #current line is read only
                return None
        elif hasattr(self,'insert_multi_line'):
            self.insert_multi_line(s)
            return True
        else: #beep if string contains newelines and   multi-line insert not supported by the subclass we are currently executing
            beep()
            return None

    def excise_selection(self) -> None:
        """Delete currently selected text."""
        if self.selection.is_empty():
            return
        if hasattr(self,'set_dirty'):
            self.set_dirty(True)  # type: ignore[attr-defined]
        # first line in selection becomes substring of first line before selection plus substring  after selection in last line of selection
        s= self.selection
        #print(f'in excise_selection with selection indices {(s.start, s.start_caret, s.end, s.end_caret)}')
        self.say_short_action(s.get(),'Deleted')
        self[s.start].value= self[s.start].value[:s.start_caret] + self[s.end].value[s.end_caret:]
        # now             delete all other lines  in selection
        #print(f'popping elements in range {s.start+1}, {s.end} out of {len(self)} elements')
        for cursor in range(s.start+1,s.end+1):
            self.pop(s.start+1) #do not increment the pop index as the poppable elements come to Jesus
        s.reset(s.start,s.start_caret) #clear the selection
        #can optimize below to only do refresh if removed at least one line, but think about unindenting which also needs covering
        if hasattr(self,'refresh_blocks'):
            self.refresh_blocks()
        self.move(s.start,s.start_caret,False) 
    @KeyHandler.hotkey("Escape")
    def cancel_wrapper(self, modified_name: str) -> bool | None:
        """Cancel dialog (Escape key handler)."""
        #subclasses should override wrapped method to get different behaviour but do not need to assign hotkey
        return self.cancel(modified_name)

    def cancel(self, modified_name: str) -> bool | None:
        """Cancel dialog if not home dialog.

        Returns:
            False to close dialog if not home_dialog, None otherwise.
        """
        if not self==self.home_dialog:
            return False
        else:
            beep()
            return None

    def get_result(self) -> str:
        """Get dialog result (override in subclasses).

        Returns:
            Current element value as string.
        """
        #should be overridden in any Dialog that does not want to return the value of the current Element
        return str(self.current())

    @KeyHandler.hotkey("Return")
    def ok_wrapper(self, modified_name: str) -> Any:
        """OK button handler (Return key)."""
        #subclasses should override wrapped method to get different behaviour but do not need to assign hotkey
        return self.ok(modified_name) #same idiom for passing hotkey behaviour on subclasses as used for any hotkeyed method that can be subclassed without adding the hotkey decorator on the subclass

    def ok(self, modified_name: str) -> Any:
        """Accept dialog and return result."""
        return self.get_result()

    @KeyHandler.hotkey("F10")
    def commands_list(self, modified_name: str) -> Any:
        """Show available commands list (F10)."""
        from .subdialogs import CommandDialog
        #the name of any method  that is to go in the F10 command list must start with 'command_'
        #result method of CommandDialog returns  False if cancelled as usual or the modified_name.modified_name  for the command to be executed which is the last part of self.current().value for onward return by ok method, and as usual self.cancel should return False
        c_list = [': '.join(c) for c in get_commands(self)]
        c_list.extend([': '.join(c) for c in get_commands(self.current())])
        c_list.sort()
        modified_name    = CommandDialog(self, 'commandsList',filler=c_list).run()
        if modified_name:
            return self.handle_hotkey(modified_name)
        else:
            self.say_current()
            return None

    @KeyHandler.hotkey("F8")
    def command_toggle_break_on_hotkey(self, modified_name: str) -> None:
        """Toggle break on next hotkey (F8)."""
        self.toggle_attribute('break_on_hotkey',self.app)

    @KeyHandler.hotkey("Control+Alt+Space")
    def command_say_selection(self, modified_name: str) -> None:
        """Say selection (Shift for all punctuation)."""
        #EITHER SAY THE SELECTION OR SAY THE selection with all punctuation  if shifted
        substitutions=punctuator.some if 'Shift' in modified_name else punctuator.all
        self.say_selection(substitutions=substitutions)

    @KeyHandler.hotkey("Control+W")
    def command_where(self, modified_name: str) -> None:
        """Speak line and column position (Shift for selection range)."""
        if modified_name.startswith('Shift'):
            s = self.selection
            self.say_strings(f'selection range line {s.start+1} columne {s.start_caret+1} to line {s.end+1} column {s.end_caret+1}')
        else:
            self.say_strings(f'line {self.cursor+1} column {self.current().caret+1}')

    @KeyHandler.hotkey("Control+Space")
    def command_say_line(self, modified_name: str) -> None:
        """Say current line (Shift for more punctuation)."""
        #either say the current Element or say the LINE with all punctuation if shifted
        punc=punctuator.some if not 'Shift' in modified_name else punctuator.most
        self.say_current(substitutions=punc)

    @KeyHandler.hotkey("Control+L")
    def command_say_line_numbers(self, modified_name: str) -> None:
        """Toggle line number announcement."""
        self.toggle_attribute('say_line_numbers')
        if 'Shift' in modified_name:
            1/0
            #example of how to start debugging here
            self.breakpoint()
            #pass #first line after starting debugger

    @KeyHandler.hotkey("Control+I")
    def command_say_indents(self, modified_name: str) -> None:
        """Toggle indent announcement (Shift to toggle hide blank lines)."""
        if 'Shift' in modified_name:
            self.toggle_attribute('hide_blank_lines')
        else:
            self.toggle_attribute('say_indents')

    @KeyHandler.hotkey("Alt+I")
    def command_toggle_interrupt_override(self, modified_name: str) -> None:
        """Toggle speech interrupt override for this applet."""
        self.home_dialog.purge_speech= not self.home_dialog.purge_speech
        self.say_strings(f"purge speech for this applet {'enabled' if self.home_dialog.purge_speech else 'disabled'}")

    @KeyHandler.hotkey("Control+P")
    def command_say_punctuation(self, modified_name: str) -> None:
        """Toggle punctuation announcement."""
        attr='say_punctuation'
        self.toggle_attribute(attr)

    def toggle_attribute(self, attribute: str, obj: Any = None) -> None:
        """Toggle boolean attribute on object.

        Args:
            attribute: Attribute name to toggle.
            obj: Object to toggle on (defaults to self).
        """
        obj = self if  obj is None else obj
        setattr(obj,attribute,not getattr(obj,attribute))  
        self.say_strings(f'{attribute} {"enabled" if getattr(obj,attribute) else "disabled"}')
    @KeyHandler.hotkey("Control+C")
    def command_copy(self, modified_name: str) -> None:
        """Copy selection to clipboard (Control+X for cut)."""
        s  = self.selection.get()
        if len(s):
            pyperclip.copy(s)
            #cut command on Editor Dialog calls this copy command to do work then excise  the selection
            if modified_name.endswith('X'):
                action = 'cut'
                self.excise_selection()
            else:
                action = 'copied'
            self.say_short_action(s, action)
        else:
            self.say_strings('Nothing to copy')

    @KeyHandler.hotkey("Control+F")
    def command_find(self, modified_name: str) -> None:
        """Find/replace dialog (Control+F)."""
        title = 'Find:'
        fields = [
            [SingleTextElement,'what',""],
            [BooleanElement,'match case','No'],
            [ListElement,'action','find',('find','find all')],
            ]
        if self.supports_element_editing():  #we can do a replace on elements in this Dialog
            fields.insert(1,[TextElement,'replace with',""])
            fields[-1][-1] = ('find','find all', 'replace', 'replace all')
        results = self.input_values(title=title,fields=fields,empty_value='empty string')
        if not results or not isinstance(results, dict):
            self.what=''
            self.say_strings('cancelled or empty find string')
            return
        if results['what']=="":
            self.what=''
            self.say_strings('cancelled or empty find string')
            return
        self.what = results['what']
        self.match_case = (results['match case']=='Yes')
        if results['action'] in ['find','replace']:
            self.find_or_replace(replacement = None if not (results['action']=='replace') else results['replace with'])
        elif results['action']=='find all':
            if self.match_case:
                n = sum([e.value.count(self.what) for e in self])
            else:
                n = sum([e.value.lower().count(self.what.lower()) for e in self])
            self.say_strings(f"found {n} occurrences of {results['what']}",raw=True)
        elif results['action']=='replace all':
            count=self.replace_all(results['replace with'])
            self.say_strings(f"replaced {count} occurrences of {self.what} with {results['replace with']}",raw=True)
        else:
            raise ValueError(f"unexpected action: {results['action']}")

    def replace_all(self, replacement: str) -> int:
        """Replace all occurrences of search string.

        Args:
            replacement: Replacement string.

        Returns:
            Number of replacements made.
        """
        if self.match_case:
            pattern = re.compile(re.escape(self.what))
        else:
            pattern = re.compile(re.escape(self.what), re.IGNORECASE)
        num_replacements=0
        for e in self:
            modified_value, e_replacements = pattern.subn(replacement, e.value)
            if e_replacements>0:
                e.value=modified_value
                num_replacements+=e_replacements
        return num_replacements

    def find_or_replace(self, replacement: str | None = None) -> bool:
        """Find next occurrence, optionally replacing.

        Args:
            replacement: Replacement string, or None for find only.

        Returns:
            True if found, False otherwise.
        """
        what =  self.what  if self.match_case else self.what.lower()
        #wrap around and as final iteration (i=len(self)) search in first part of current line
        for i in range(len(self)+1):
            cursor=(self.cursor+i)%len(self)
            e = self.get_element(cursor)
            find_in = e.value
            if not self.match_case:
                find_in = find_in.lower()
            if (found:=find_in.find(what,0 if i else self.current().caret))!=-1: #start at caret for first line in search then start at zero in all subsequent lines
                break
        if found== -1:
            self.say_strings('not found')
            rc=False
        else:
            rc=True
            self.move_to_found(cursor,found,False)
            if replacement is not None:
                self.set_dirty(True)  # type: ignore[attr-defined]
                self.current().value = self.current().value[:found] + replacement + self.current().value[found+len(what):]
                self.move_to_found(cursor,found+len(replacement),True)
                self.say_strings([f'{what} replaced on line {cursor+1} at offset {found+1}',self.current().value],raw=True)
            else:
                self.move_to_found(cursor,found+len(what),True)
                self.say_strings([f'{what} found on line {cursor+1} at offset {found+1} ',self.current().value],raw=True)
        return rc
    def move_to_found(self, cursor: int, caret: int, shifted: bool) -> None:
        """Move to found text (can be overridden for unfolding).

        Args:
            cursor: Target element index.
            caret: Caret position in element.
            shifted: If True, was shifted.
        """
        self.move(cursor,caret,shifted)

    @KeyHandler.hotkey("F3")
    def command_find_next(self, modified_name: str) -> None:
        """Find next occurrence (F3)."""
        if getattr(self,'what','')=='':
            beep(tag='nothing to find') #no self. what or self.what is empty
        else:
            self.find_or_replace(replacement=None)

    @KeyHandler.hotkey("Control+A")
    def command_select_all(self, modified_name: str, silent: bool = False) -> None:
        """Select all content (Control+A)."""
        self.top(modified_name,silent=silent)
        self.bottom('Shift+'+modified_name,silent=silent)
        if not silent:
            self.say_selection() 
    def move(
        self,
        cursor: int,
        caret: int,
        shift: bool,
        caret_only: bool = False,
        silent: bool = False,
        suppress_indent_speech: bool = False
    ) -> bool:
        """Move cursor and caret with optional speech.

        Args:
            cursor: Target element index.
            caret: Target caret position.
            shift: If True, extend selection.
            caret_only: If True, only move caret (wrapping allowed).
            silent: If True, don't speak after move.
            suppress_indent_speech: If True, suppress indent speech.

        Returns:
            True if move succeeded, False if blocked.
        """
        #returns False if not able to move, else True
        self.previous_cursor=self.cursor
        if caret_only and (caret==-1):
            #print('wrapping at left')
            if self.can_wrap_backward():
                cursor = cursor-1
                caret = len(self.get_element(cursor).value)
            else:
                beep()
                return False
        elif caret_only and (caret==(len(self.current().value)+1)):
            if self.can_wrap_forward():
                #print('wrapping at end of line')
                cursor = cursor+1
                caret = 0
            else:
                beep()
                return False
        #move cursor and caret within bounds after updating cursor and caret above to wrap at start and end of line as signified by left and right decrementing/incrementing caret beyond end_stop of the line (note that home, end, move_previous and move_next never try to set beyond end stops
        self.cursor = self.update_cursor(cursor,downwards=(cursor>self.cursor))
        self.current().caret = caret
        self.selection.update(cursor=self.cursor,caret = self.current().caret,shift=shift)
        if silent:
            return True
        previous_say_indents=self.say_indents
        self.say_indents=self.say_indents and not suppress_indent_speech
        #when have tested behaviour is acceptable when selecting then delete this clause and change the below elif to an if
        if False: #shift:
            if (self.selection.start==self.selection.end):
                self.say_selection()
            else:
                self.say_strings(self.current().value+' selected')
        elif caret_only:
            self.current().say_current_char()
        else:
            self.say_current()
        self.say_indents=previous_say_indents
        return True

    def update_cursor(self, cursor: int, downwards: bool = True) -> int:
        """Update cursor within valid bounds.

        Args:
            cursor: Target cursor position.
            downwards: Direction of movement (unused).

        Returns:
            Cursor clamped to [0, len-1].
        """
        return min(max(0,cursor),len(self)-1)
    @KeyHandler.hotkey("Alt+Down")
    def next_applet(self, modified_name: str) -> None:
        """Switch to next applet in carousel."""
        self.app.critical_keys.next_applet()

    @KeyHandler.hotkey("Alt+Up")
    def previous_applet(self, modified_name: str) -> None:
        """Switch to previous applet in carousel."""
        self.app.critical_keys.previous_applet()

    @KeyHandler.hotkey("Alt+Right")
    def inspect(self, modified_name: str) -> None:
        """Inspect this dialog (Shift for speech log)."""
        from . import debugging
        if 'Shift' in modified_name:
            obj=debugging.ValueObjectList(self.app.tts.speech_log)  # type: ignore[arg-type]
            inspectable='speech log'
        else:
            obj=self  # type: ignore[assignment]
            inspectable=self.name
        self.context = debugging.Context(inspectable,self.home_dialog,None,None)  # type: ignore[arg-type]
        debugging.inspect_object(dialog=self,object=obj,context=self.context,name=inspectable)

    @KeyHandler.hotkey("Up")
    def up(self, modified_name: str) -> None:
        """Move up one line (Shift to select)."""
        old_cursor = self.cursor
        self.move(self.cursor-1,self.current().caret,modified_name.startswith('Shift'))
        #disabled below code as was getting duplicate say current at top. could possibly just leave as a beep and remove the say _current
        if False: #self.cursor==old_cursor:
            #self.say_strings('top of dialog')
            beep()
            self.say_current()

    @KeyHandler.hotkey("Down")
    def down(self, modified_name: str) -> None:
        """Move down one line (Shift to select)."""
        old_cursor = self.cursor
        self.move(self.cursor+1,self.current().caret,modified_name.startswith('Shift'))
        #disabled below code as was getting duplicate say current at bottom. could possibly just leave as a beep and remove the say _current
        if False: #self.cursor==old_cursor:
            beep()
            self.say_current()

    @KeyHandler.hotkey("Control+Home")
    def top(self, modified_name: str, silent: bool = False) -> None:
        """Move to first line (Shift to select)."""
        old_cursor = self.cursor
        self.move(0,0,modified_name.startswith('Shift'),silent=silent)
        if not silent:
            if self.cursor==old_cursor:
                beep()
            self.say_current()

    @KeyHandler.hotkey("Control+End")
    def bottom(self, modified_name: str, silent: bool = False) -> None:
        """Move to last line (Shift to select)."""
        old_cursor = self.cursor
        if len(self):
            self.move(len(self)-1,len(self[-1].value),modified_name.startswith('Shift'),silent=silent)
        if not silent:
            if self.cursor==old_cursor:
                beep()
            self.say_current()

    def say_current(
        self,
        interrupt: bool = True,
        suppress_indent_speech: bool = False,
        substitutions: Any = punctuator.some
    ) -> None:
        """Speak current element.

        Args:
            interrupt: If True, interrupt previous speech.
            suppress_indent_speech: If True, suppress indent announcement.
            substitutions: Punctuation substitution rules.
        """
        self.say(str(self.current()),interrupt=interrupt,substitutions=substitutions)

    def say_selection(self, substitutions: Any = punctuator.some) -> None:
        """Speak current selection."""
        def position(cursor: int, caret: int) -> str:
            return f'line {cursor+1}, offset {caret+1}' #adjust to be 1-indexed
        if self.selection.line_count()<=1:
            s = self.selection.get() 
            self.say_short_action(s if len(self.selection.get()) else 'nothing','selected',substitutions=substitutions)
        else:
            if self.selection.is_inverted():
                self.selection.invert()
            start = (self.selection.start,self.selection.start_caret)
            end = (self.selection.end,self.selection.end_caret)
            self.say_strings([f'selected {self.selection.line_count()} lines','from',position(*start), 'to',position(*end)])
    def say_short_action(self, s: str, action: str, substitutions: Any = punctuator.some) -> None:
        """Say text and action (summarize if multi-line or long).

        Args:
            s: Text to speak.
            action: Action word (e.g., 'copied', 'deleted').
            substitutions: Punctuation substitution rules.
        """
        if count:=s.count('\n'):
            s=f"{count+1} lines"
        elif len(s)>200:
            s  = f"{len(s)} characters ending in {s[-10:]}"
        self.say_strings([s,action],substitutions=substitutions)

    def say_strings(
        self,
        speak: str | list[str],
        interrupt: bool = True,
        substitutions: Any = punctuator.some,
        raw: bool = False
    ) -> None:
        """Speak strings with template substitution.

        Args:
            speak: String or list of strings to speak.
            interrupt: If True, interrupt previous speech.
            substitutions: Punctuation substitution rules.
            raw: If True, skip %name and %current substitution.
        """
        if isinstance(speak,str):
            speak = [speak] #cope with arg being supplied as a string rather than a list of zero or more strings
        s = punctuator.zwnj.join(speak)
        if hasattr(self,'_name'):
            if not raw:
                s=s.replace('%name',str(self))
            current=str(self.current())
            if hasattr(self,'say_labels') and self.say_labels:
                current = punctuator.zwnj.join([self.current().name,current])
            if not raw:
                s=s.replace('%current',current)
                s=s.replace('%current_name',getattr(self.current(),'name',''))
        self.say(s,interrupt=interrupt,substitutions=substitutions)

    def say(self, s: str, interrupt: bool, substitutions: Any) -> None:
        """Send text to speech engine.

        Args:
            s: Text to speak.
            interrupt: If True (and purge_speech enabled), interrupt previous.
            substitutions: Punctuation substitution rules.
        """
        #the purge_speech flag on the applet's dialog object overrides the specified interrupt directive to force synchronous speech if set to False (default is True)
        self.app.tts.speak(s,interrupt=interrupt and self.home_dialog.purge_speech,substitutions=substitutions)

    @KeyHandler.hotkey("F12")
    def command_date_and_time(self, modified_name: str) -> None:
        """Speak time (Shift for date)."""
        if modified_name.startswith('Shift'):
            self.say_strings(ordinal_date.today())
        else:
            self.say_strings(dt.now().strftime('%H %M %S'))

    @KeyHandler.hotkey("Lcontrol")
    def stop_speech(self, modified_name: str) -> None:
        """Stop speech (Left Control key).

        Interrupts speech unless purge_speech is disabled.
        Force interrupt of speech if purge_speech has been disabled by re-enabling purge_speech with Alt+V
        """
        self.say_strings("")