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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user