Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 88 additions & 54 deletions sendspin/tui/keyboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import asyncio
import logging
import threading
from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING

Expand Down Expand Up @@ -165,63 +166,96 @@ async def keyboard_loop(
"[": ("group-down", lambda: handler.change_group_volume(-5)),
}

# Interactive mode with single keypress input using readchar
loop = asyncio.get_running_loop()
key_queue: asyncio.Queue[str] = asyncio.Queue()
stop_reader = threading.Event()

while True:
try:
# Run blocking readkey in executor to not block the event loop
key = await loop.run_in_executor(None, readchar.readkey)
except (asyncio.CancelledError, KeyboardInterrupt):
request_shutdown()
break

# Handle server selector mode
if ui.is_server_selector_visible():
if key in "rR":
show_server_selector()
continue
if key == readchar.key.UP:
ui.highlight_shortcut("selector-up")
ui.move_server_selection(-1)
def read_keys() -> None:
"""Read keys on a daemon thread and forward them to the event loop."""
while not stop_reader.is_set():
try:
key = readchar.readkey()
except KeyboardInterrupt:
key = "\x03"
except Exception: # noqa: BLE001
logger.exception("Keyboard input failed")
try:
loop.call_soon_threadsafe(request_shutdown)
loop.call_soon_threadsafe(key_queue.put_nowait, "\x03")
except RuntimeError:
pass
return

if stop_reader.is_set():
return

try:
loop.call_soon_threadsafe(key_queue.put_nowait, key)
except RuntimeError:
return

threading.Thread(target=read_keys, name="sendspin-keyboard", daemon=True).start()

try:
while True:
try:
key = await key_queue.get()
except (asyncio.CancelledError, KeyboardInterrupt):
request_shutdown()
break

if key == "\x03":
request_shutdown()
break

# Handle server selector mode
if ui.is_server_selector_visible():
if key in "rR":
show_server_selector()
continue
if key == readchar.key.UP:
ui.highlight_shortcut("selector-up")
ui.move_server_selection(-1)
continue
if key == readchar.key.DOWN:
ui.highlight_shortcut("selector-down")
ui.move_server_selection(1)
continue
if key in ("\r", "\n", readchar.key.ENTER):
ui.highlight_shortcut("selector-enter")
await on_server_selected()
continue
if key in "qQ":
ui.hide_server_selector()
continue
# Ignore other keys when selector is open
continue
if key == readchar.key.DOWN:
ui.highlight_shortcut("selector-down")
ui.move_server_selection(1)

# Handle quit
if key in "qQ":
ui.highlight_shortcut("quit")
request_shutdown()
break

# Handle 's' to open server selector
if key in "sS":
ui.highlight_shortcut("server")
show_server_selector()
continue
if key in ("\r", "\n", readchar.key.ENTER):
ui.highlight_shortcut("selector-enter")
await on_server_selected()

# Handle shortcuts via dispatch table (case-insensitive for letter keys)
action = shortcuts.get(key) or shortcuts.get(key.lower())
if action:
highlight_name, action_handler = action
if highlight_name and ui:
ui.highlight_shortcut(highlight_name)
result = action_handler()
if result is not None:
await result
continue
if key in "qQ":
ui.hide_server_selector()

# Ignore unhandled escape sequences
if key.startswith("\x1b"):
continue
# Ignore other keys when selector is open
continue

# Handle quit
if key in "qQ":
ui.highlight_shortcut("quit")
request_shutdown()
break

# Handle 's' to open server selector
if key in "sS":
ui.highlight_shortcut("server")
show_server_selector()
continue

# Handle shortcuts via dispatch table (case-insensitive for letter keys)
action = shortcuts.get(key) or shortcuts.get(key.lower())
if action:
highlight_name, action_handler = action
if highlight_name and ui:
ui.highlight_shortcut(highlight_name)
result = action_handler()
if result is not None:
await result
continue

# Ignore unhandled escape sequences
if key.startswith("\x1b"):
continue
finally:
stop_reader.set()