Rework TUI: single-threaded curses, scrollback buffer, server menu

- All curses drawing on the main thread; stdin polled together with the
  ZMQ sockets, Ctrl-C only sets a flag
- Output kept in a 2000-line buffer and redrawn from it, so resizes keep
  the history; PgUp/PgDn and mouse wheel scroll it
- Messages without trailing newline continue the current line (status
  table columns arrive as separate messages)
- Too-small terminal shows a warning instead of exiting
- Start without arguments shows a server selection menu
- Colors on the terminal default background; version 0.9.0
This commit is contained in:
pfl
2026-09-16 17:01:51 +02:00
parent 0613d380a4
commit db123993c0
7 changed files with 658 additions and 621 deletions
+60 -34
View File
@@ -14,14 +14,13 @@ import zmq
import signal
import sys
import os
import threading
from lib.constants import VERSION, DEFAULT_HOST, POLL_TIMEOUT, QUIT_CONFIRM_TIMEOUT, RESPAWN_DELAY, MAX_COMMAND_HISTORY
from lib.state import GameState
from lib.network import RconConnection, StatsConnection
from lib.parser import EventParser
from lib.formatter import format_message, format_chat_message, format_powerup_message, strip_color_codes
from lib.ui import UIManager
from lib.ui import UIManager, select_server
from lib.settings import ConfigLoader
# Pre-compiled regex patterns
@@ -45,36 +44,42 @@ all_json_logger.setLevel(logging.DEBUG)
unknown_json_logger = logging.getLogger('unknown_json')
unknown_json_logger.setLevel(logging.DEBUG)
# Global flag for quit confirmation (thread-safe)
# Ctrl-C state: the signal handler only records the press, the main loop
# reacts to it, so no screen drawing happens inside a signal handler.
ctrl_c_pressed = False
quit_confirm_time = None
quit_confirm_lock = threading.Lock()
# Configurable timeouts (set from config in __main__)
_QUIT_CONFIRM_TIMEOUT = QUIT_CONFIRM_TIMEOUT
_RESPAWN_DELAY = RESPAWN_DELAY
# Global shutdown flag (set by signal_handler, checked by main_loop)
# Global shutdown flag (set by handle_ctrl_c, checked by main_loop)
shutdown_requested = False
def signal_handler(sig, frame):
"""Handle Ctrl+C with confirmation"""
global quit_confirm_time, shutdown_requested
"""Record Ctrl+C; force exit if the graceful shutdown already started"""
global ctrl_c_pressed
if shutdown_requested:
# Third Ctrl-C: last-resort forced exit
curses.endwin()
os._exit(0)
ctrl_c_pressed = True
def handle_ctrl_c():
"""Ask for confirmation on the first Ctrl-C, quit on the second one within the timeout"""
global ctrl_c_pressed, quit_confirm_time, shutdown_requested
if not ctrl_c_pressed:
return
ctrl_c_pressed = False
current_time = time.time()
with quit_confirm_lock:
if shutdown_requested:
# Third Ctrl-C: last-resort forced exit
curses.endwin()
os._exit(0)
elif quit_confirm_time is None or (current_time - quit_confirm_time) > _QUIT_CONFIRM_TIMEOUT:
# First Ctrl-C or timeout expired
logger.warning(f"^1^8Press Ctrl-C again within {_QUIT_CONFIRM_TIMEOUT:.0f} seconds to quit^0")
quit_confirm_time = current_time
else:
# Second Ctrl-C within timeout: request graceful shutdown
logger.warning("^1^8Quittin'^0")
shutdown_requested = True
if quit_confirm_time is None or (current_time - quit_confirm_time) > _QUIT_CONFIRM_TIMEOUT:
logger.warning(f"^1^8Press Ctrl-C again within {_QUIT_CONFIRM_TIMEOUT:.0f} seconds to quit^0")
quit_confirm_time = current_time
else:
logger.warning("^1^8Quittin'^0")
shutdown_requested = True
def parse_cvar_response(message, game_state, ui):
"""
@@ -130,10 +135,9 @@ def handle_stats_connection(message):
return stats_port, stats_password
def handle_user_input(input_queue, rcon, ui):
"""Process user command input"""
while not input_queue.empty():
command = input_queue.get()
def handle_user_input(ui, rcon):
"""Read pending keys and send the commands the user submitted"""
for command in ui.process_input():
logger.info(f'Sending command: {repr(command.strip())}')
# Display command with timestamp
@@ -310,7 +314,11 @@ def parse_player_events(message, game_state, ui):
return False
def main_loop(screen, args):
"""Main application loop"""
"""
Main application loop.
Runs single-threaded: ZMQ sockets and the terminal (stdin) are polled together,
so all curses drawing happens here and never from another thread.
"""
# Setup signal handler for Ctrl+C with confirmation
signal.signal(signal.SIGINT, signal_handler)
@@ -331,8 +339,9 @@ def main_loop(screen, args):
unknown_json_logger.propagate = False
# Initialize components
ui = UIManager(screen, args.host, args.max_history)
game_state = GameState()
ui = UIManager(screen, args.title, args.max_history,
player_names_provider=lambda: game_state.player_tracker.get_player_names())
# Setup logging to output window
log_handler = ui.setup_logging()
@@ -345,9 +354,6 @@ def main_loop(screen, args):
lib_logger.addHandler(log_handler)
lib_logger.setLevel(logger.level)
# Setup input queue
input_queue = ui.setup_input_queue(player_names_provider=lambda: game_state.player_tracker.get_player_names())
# Display startup messages
ui.print_message(f"*** QL pyCon Version {VERSION} starting ***\n")
ui.print_message(f"zmq python bindings {zmq.__version__}, libzmq version {zmq.zmq_version()}\n")
@@ -357,6 +363,12 @@ def main_loop(screen, args):
rcon = RconConnection(args.host, args.password, args.identity)
rcon.connect()
# One poller wakes the loop on RCON data, connection events or a key press
poller = zmq.Poller()
poller.register(rcon.socket, zmq.POLLIN)
poller.register(rcon.monitor, zmq.POLLIN)
poller.register(sys.stdin, zmq.POLLIN)
stats_conn = None
stats_port = None
stats_password = None
@@ -381,8 +393,10 @@ def main_loop(screen, args):
# Main event loop with resource cleanup
try:
while not shutdown_requested:
# Poll RCON socket
event = rcon.poll(POLL_TIMEOUT)
# Wait for RCON data, a connection event, a stats event or a key press
ready = dict(poller.poll(POLL_TIMEOUT))
handle_ctrl_c()
# Check monitor for connection events
monitor_event = rcon.check_monitor()
@@ -398,13 +412,14 @@ def main_loop(screen, args):
timestamp = time.strftime('%H:%M:%S')
ui.print_message(f"^3[^7{timestamp}^3] ^8^1Disconnected from server - waiting for reconnect...^0^7\n")
if stats_conn is not None:
poller.unregister(stats_conn.socket)
stats_conn.close()
stats_conn = None
stats_port = None
stats_password = None
# Handle user input
handle_user_input(input_queue, rcon, ui)
handle_user_input(ui, rcon)
# Poll stats stream if connected
stats_check_counter = handle_stats_stream(stats_conn, stats_check_counter, event_parser, ui, game_state)
@@ -413,7 +428,7 @@ def main_loop(screen, args):
handle_player_respawns(game_state, ui)
# Process RCON messages
if event > 0:
if rcon.socket in ready:
logger.debug('Socket has data available')
msg_count = 0
@@ -478,6 +493,7 @@ def main_loop(screen, args):
stats_conn = StatsConnection(host_ip, stats_port, stats_password)
stats_conn.connect()
poller.register(stats_conn.socket, zmq.POLLIN)
ui.print_message("Stats stream connected - ready for game events\n")
@@ -594,6 +610,14 @@ if __name__ == '__main__':
print(f' {name:<12} {host}')
sys.exit(0)
# No server name and no --host: let the user pick from the configured servers
if args.server is None and args.host is None:
servers = config.get_servers()
if servers:
args.server = curses.wrapper(select_server, servers)
if args.server is None:
sys.exit(0)
# Resolve host and password
if args.server:
host, password = config.get_server(args.server)
@@ -603,10 +627,12 @@ if __name__ == '__main__':
sys.exit(1)
args.host = args.host or host
args.password = args.password or password
args.title = f'Quake Live PyCon: {args.server} ({args.host})'
else:
args.host = args.host or config.get_host() or DEFAULT_HOST
if not args.host.startswith('tcp://'):
args.host = f'tcp://{args.host}'
args.password = args.password or config.get_password()
args.title = f'Quake Live PyCon: {args.host}'
curses.wrapper(main_loop, args)