Source code for subsequence.keystroke

"""Single-keystroke input listener for live compositions.

Provides a background thread that reads individual keystrokes from stdin
without requiring the user to press Enter.  Designed to work alongside
:class:`subsequence.display.Display` without conflicts — the display writes
to **stderr** while this module reads from **stdin**.

**Platform support:** Linux and macOS.  Requires :mod:`tty` and :mod:`termios`,
which are only available on POSIX systems.  On unsupported platforms (e.g.
Windows, or environments where stdin is not a real TTY), the listener starts
in a degraded mode and logs a warning instead of raising an exception.

Check :data:`HOTKEYS_SUPPORTED` at import time to know whether the current
platform can support hotkeys.

This module is used internally by :class:`subsequence.composition.Composition`
when hotkeys are enabled via ``composition.hotkeys()``.  You do not need to
import it directly.
"""

import logging
import queue
import select
import sys
import threading
import typing


logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Platform capability detection
# ---------------------------------------------------------------------------

#: ``True`` when the current platform supports single-keystroke input.
#:
#: Requires :mod:`tty` and :mod:`termios` (POSIX-only) and a real TTY on
#: stdin.  Check this before enabling hotkeys if you need to branch on
#: platform support:
#:
#: .. code-block:: python
#:
#:     from subsequence.keystroke import HOTKEYS_SUPPORTED
#:     if HOTKEYS_SUPPORTED:
#:         composition.hotkeys()
HOTKEYS_SUPPORTED: bool = False

#: Short human-readable explanation of why hotkeys are not supported, or
#: ``None`` when :data:`HOTKEYS_SUPPORTED` is ``True``.
HOTKEYS_UNAVAILABLE_REASON: typing.Optional[str] = None

try:
	import termios
	import tty

	if not sys.stdin.isatty():
		raise OSError("stdin is not a TTY (running in a pipe or non-interactive context)")

	# Quick sanity check — attempt to read and restore the current settings.
	_fd = sys.stdin.fileno()
	_saved = termios.tcgetattr(_fd)
	termios.tcsetattr(_fd, termios.TCSADRAIN, _saved)

	HOTKEYS_SUPPORTED = True

except ImportError:
	HOTKEYS_UNAVAILABLE_REASON = (
		"The 'tty' and 'termios' modules are not available on this platform. "
		"Hotkeys require a POSIX operating system (Linux or macOS)."
	)
except OSError as _e:
	HOTKEYS_UNAVAILABLE_REASON = (
		f"Hotkeys require an interactive terminal (TTY) on stdin. "
		f"Reason: {_e}"
	)
except Exception as _e:
	HOTKEYS_UNAVAILABLE_REASON = f"Hotkeys unavailable: {_e}"


# ---------------------------------------------------------------------------
# Listener class
# ---------------------------------------------------------------------------

[docs] class KeystrokeListener: """Background daemon thread that reads single keystrokes from stdin. Puts stdin into *cbreak* mode so each keypress is delivered immediately, without waiting for Enter. Keystrokes are placed in a thread-safe queue and retrieved by the caller via :meth:`drain`. Terminal settings are always restored on shutdown, even if an exception occurs, so a crashed listener will not leave the terminal in a broken state. If the current platform does not support hotkeys (:data:`HOTKEYS_SUPPORTED` is ``False``), :meth:`start` logs a warning and returns immediately without starting the thread. All other methods remain safe no-ops. Example:: listener = KeystrokeListener() listener.start() # ...later, from the event loop... for key in listener.drain(): handle(key) listener.stop() """ def __init__ (self) -> None: """Initialise the listener in a stopped state.""" self._queue: queue.Queue[str] = queue.Queue() self._thread: typing.Optional[threading.Thread] = None self._running: bool = False self._old_settings: typing.Optional[typing.List[typing.Any]] = None #: ``True`` after a successful :meth:`start` on a supported platform. self.active: bool = False
[docs] def start (self) -> None: """Start the background keystroke listener thread. Puts stdin into cbreak mode and begins reading. Call :meth:`stop` to restore normal terminal behaviour. Safe to call more than once — a second call while already running is a no-op. If :data:`HOTKEYS_SUPPORTED` is ``False``, logs a warning and returns without starting the thread. :attr:`active` will remain ``False``. """ if self._running: return # A previous listener may still be inside its ~0.1 s poll: wait for it, # or it would see the new _running=True, never exit, and the new thread # would snapshot CBREAK mode as the "original" terminal settings. if self._thread is not None and self._thread.is_alive(): self._thread.join(timeout=0.5) if not HOTKEYS_SUPPORTED: logger.warning( f"Hotkeys are not available on this system and will be disabled. " f"{HOTKEYS_UNAVAILABLE_REASON}" ) return self._running = True self.active = True self._thread = threading.Thread( target = self._listen, name = "subsequence-keystroke-listener", daemon = True, # Dies automatically when the main thread exits. ) self._thread.start()
[docs] def stop (self) -> None: """Signal the listener to stop and restore the terminal. Waits briefly for the background thread (it polls every ~0.1 s), then restores the terminal settings directly if the thread has not done so — a daemon thread killed at interpreter exit never runs its ``finally`` block, which used to leave the shell in cbreak mode (no echo) on most clean exits. Safe to call on an unsupported platform — it is a no-op. """ self._running = False self.active = False if self._thread is not None and self._thread.is_alive(): self._thread.join(timeout=0.5) # Belt and braces: if the thread is somehow still alive (blocked # read), restore the terminal from here - tcsetattr is idempotent. if self._thread is not None and self._thread.is_alive() and self._old_settings is not None: import termios # noqa: PLC0415 termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, self._old_settings)
[docs] def drain (self) -> typing.List[str]: """Return all keystrokes that have arrived since the last drain. Non-blocking. Returns an empty list if nothing has been pressed, or if the listener is not active. Safe to call at any time. Returns: A list of single-character strings, one per keypress, in order. """ keys: typing.List[str] = [] while True: try: keys.append(self._queue.get_nowait()) except queue.Empty: break return keys
def _listen (self) -> None: """Internal thread target. Runs until ``_running`` is set False. Uses :func:`select.select` with a short timeout so the thread can notice the ``_running = False`` signal without blocking indefinitely. Terminal settings are restored in the ``finally`` block so they are always cleaned up, even if an exception occurs. """ # These imports are guaranteed safe here — _listen is only called # when HOTKEYS_SUPPORTED is True, which already confirmed they exist. import termios # noqa: PLC0415 import tty # noqa: PLC0415 fd = sys.stdin.fileno() old_settings = termios.tcgetattr(fd) # Shared with stop() so it can restore the terminal if this thread is # killed before the finally block runs (daemon threads at exit). self._old_settings = old_settings try: # cbreak: one character at a time, no Enter required. # Differs from raw in that Ctrl+C / Ctrl+Z still work normally. tty.setcbreak(fd) while self._running: # Poll with a short timeout so we can check _running regularly. ready, _, _ = select.select([sys.stdin], [], [], 0.1) if ready: char = sys.stdin.read(1) if char: self._queue.put(char) except Exception: # Swallow unexpected errors — a broken listener should not # crash the composition. pass finally: # Always restore terminal, even after exceptions. termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) self.active = False