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
+12
View File
@@ -34,11 +34,22 @@ duel = 10.13.12.93:28961
## Usage ## Usage
```bash ```bash
qlpycon # pick a server from a menu
qlpycon ffa # connect by name qlpycon ffa # connect by name
qlpycon --host tcp://10.13.12.93:28960 --password secret # connect directly qlpycon --host tcp://10.13.12.93:28960 --password secret # connect directly
qlpycon --list # list configured servers qlpycon --list # list configured servers
``` ```
Without arguments, qlpycon shows the servers from `[servers]` and connects to the one you select (Up/Down or j/k, Enter or the number key; q quits). If no servers are configured it connects to `[connection] host`.
**Keys in the console:**
- `Enter`: send the command
- `Tab`: cycle autocomplete suggestions
- `Up`/`Down`: command history
- `PgUp`/`PgDn` or mouse wheel: scroll the output back and forth (the last 2000 lines are kept)
- Hold `Shift` while selecting text with the mouse (the wheel is reported to qlpycon)
- `Ctrl-C` twice: quit
**Options:** **Options:**
- `--host URI` — ZMQ RCON endpoint - `--host URI` — ZMQ RCON endpoint
- `--password PASS` — RCON password (or set `QLPYCON_PASSWORD` env var) - `--password PASS` — RCON password (or set `QLPYCON_PASSWORD` env var)
@@ -56,6 +67,7 @@ qlpycon --list # list configured servers
- Tab autocomplete for cvars and commands with fuzzy matching - Tab autocomplete for cvars and commands with fuzzy matching
- Argument suggestions for 25+ commands (bot names, maps, gametypes) - Argument suggestions for 25+ commands (bot names, maps, gametypes)
- Command history (↑/↓) - Command history (↑/↓)
- Output scrollback (PgUp/PgDn) that survives terminal resizes
## Architecture ## Architecture
+4 -1
View File
@@ -5,7 +5,7 @@ Configuration and constants for QLPyCon
import re import re
VERSION = "0.8.1" VERSION = "0.9.0"
# Pattern matching # Pattern matching
COLOR_CODE_PATTERN = re.compile(r'\^\d') # Quake color codes (^0-^9) COLOR_CODE_PATTERN = re.compile(r'\^\d') # Quake color codes (^0-^9)
@@ -21,10 +21,13 @@ RESPAWN_DELAY = 3.0 # Seconds before players respawn after death
STATS_CONNECTION_DELAY = 0.5 # Initial stats connection delay STATS_CONNECTION_DELAY = 0.5 # Initial stats connection delay
# UI dimensions # UI dimensions
MIN_ROWS = 20
MIN_COLS = 80
INFO_WINDOW_HEIGHT = 12 INFO_WINDOW_HEIGHT = 12
INFO_WINDOW_Y = 2 INFO_WINDOW_Y = 2
OUTPUT_WINDOW_Y = 14 OUTPUT_WINDOW_Y = 14
INPUT_WINDOW_HEIGHT = 2 INPUT_WINDOW_HEIGHT = 2
OUTPUT_SCROLLBACK_LINES = 2000 # Output lines kept for PgUp/PgDn and redraw after resize
# Event deduplication # Event deduplication
MAX_RECENT_EVENTS = 10 MAX_RECENT_EVENTS = 10
-4
View File
@@ -89,10 +89,6 @@ class RconConnection:
self.socket.send(command) self.socket.send(command)
logger.info(f'Sent command: {command}') logger.info(f'Sent command: {command}')
def poll(self, timeout):
"""Poll for messages"""
return self.socket.poll(timeout)
def recv_message(self): def recv_message(self):
"""Receive a message (non-blocking)""" """Receive a message (non-blocking)"""
try: try:
+1
View File
@@ -173,6 +173,7 @@ def create_example_config():
config_content = """# qlpycon.conf config_content = """# qlpycon.conf
# Edit this file as needed. # Edit this file as needed.
# #
# Pick from a menu: qlpycon
# Connect by server name: qlpycon ffa # Connect by server name: qlpycon ffa
# Connect directly: qlpycon --host tcp://1.2.3.4:28960 --password secret # Connect directly: qlpycon --host tcp://1.2.3.4:28960 --password secret
# List servers: qlpycon --list # List servers: qlpycon --list
+569 -571
View File
File diff suppressed because it is too large Load Diff
+53 -27
View File
@@ -14,14 +14,13 @@ import zmq
import signal import signal
import sys import sys
import os import os
import threading
from lib.constants import VERSION, DEFAULT_HOST, POLL_TIMEOUT, QUIT_CONFIRM_TIMEOUT, RESPAWN_DELAY, MAX_COMMAND_HISTORY from lib.constants import VERSION, DEFAULT_HOST, POLL_TIMEOUT, QUIT_CONFIRM_TIMEOUT, RESPAWN_DELAY, MAX_COMMAND_HISTORY
from lib.state import GameState from lib.state import GameState
from lib.network import RconConnection, StatsConnection from lib.network import RconConnection, StatsConnection
from lib.parser import EventParser from lib.parser import EventParser
from lib.formatter import format_message, format_chat_message, format_powerup_message, strip_color_codes 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 from lib.settings import ConfigLoader
# Pre-compiled regex patterns # Pre-compiled regex patterns
@@ -45,34 +44,40 @@ all_json_logger.setLevel(logging.DEBUG)
unknown_json_logger = logging.getLogger('unknown_json') unknown_json_logger = logging.getLogger('unknown_json')
unknown_json_logger.setLevel(logging.DEBUG) 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_time = None
quit_confirm_lock = threading.Lock()
# Configurable timeouts (set from config in __main__) # Configurable timeouts (set from config in __main__)
_QUIT_CONFIRM_TIMEOUT = QUIT_CONFIRM_TIMEOUT _QUIT_CONFIRM_TIMEOUT = QUIT_CONFIRM_TIMEOUT
_RESPAWN_DELAY = RESPAWN_DELAY _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 shutdown_requested = False
def signal_handler(sig, frame): def signal_handler(sig, frame):
"""Handle Ctrl+C with confirmation""" """Record Ctrl+C; force exit if the graceful shutdown already started"""
global quit_confirm_time, shutdown_requested global ctrl_c_pressed
current_time = time.time()
with quit_confirm_lock:
if shutdown_requested: if shutdown_requested:
# Third Ctrl-C: last-resort forced exit # Third Ctrl-C: last-resort forced exit
curses.endwin() curses.endwin()
os._exit(0) os._exit(0)
elif quit_confirm_time is None or (current_time - quit_confirm_time) > _QUIT_CONFIRM_TIMEOUT: ctrl_c_pressed = True
# First Ctrl-C or timeout expired
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()
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") logger.warning(f"^1^8Press Ctrl-C again within {_QUIT_CONFIRM_TIMEOUT:.0f} seconds to quit^0")
quit_confirm_time = current_time quit_confirm_time = current_time
else: else:
# Second Ctrl-C within timeout: request graceful shutdown
logger.warning("^1^8Quittin'^0") logger.warning("^1^8Quittin'^0")
shutdown_requested = True shutdown_requested = True
@@ -130,10 +135,9 @@ def handle_stats_connection(message):
return stats_port, stats_password return stats_port, stats_password
def handle_user_input(input_queue, rcon, ui): def handle_user_input(ui, rcon):
"""Process user command input""" """Read pending keys and send the commands the user submitted"""
while not input_queue.empty(): for command in ui.process_input():
command = input_queue.get()
logger.info(f'Sending command: {repr(command.strip())}') logger.info(f'Sending command: {repr(command.strip())}')
# Display command with timestamp # Display command with timestamp
@@ -310,7 +314,11 @@ def parse_player_events(message, game_state, ui):
return False return False
def main_loop(screen, args): 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 # Setup signal handler for Ctrl+C with confirmation
signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGINT, signal_handler)
@@ -331,8 +339,9 @@ def main_loop(screen, args):
unknown_json_logger.propagate = False unknown_json_logger.propagate = False
# Initialize components # Initialize components
ui = UIManager(screen, args.host, args.max_history)
game_state = GameState() 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 # Setup logging to output window
log_handler = ui.setup_logging() log_handler = ui.setup_logging()
@@ -345,9 +354,6 @@ def main_loop(screen, args):
lib_logger.addHandler(log_handler) lib_logger.addHandler(log_handler)
lib_logger.setLevel(logger.level) 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 # Display startup messages
ui.print_message(f"*** QL pyCon Version {VERSION} starting ***\n") 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") 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 = RconConnection(args.host, args.password, args.identity)
rcon.connect() 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_conn = None
stats_port = None stats_port = None
stats_password = None stats_password = None
@@ -381,8 +393,10 @@ def main_loop(screen, args):
# Main event loop with resource cleanup # Main event loop with resource cleanup
try: try:
while not shutdown_requested: while not shutdown_requested:
# Poll RCON socket # Wait for RCON data, a connection event, a stats event or a key press
event = rcon.poll(POLL_TIMEOUT) ready = dict(poller.poll(POLL_TIMEOUT))
handle_ctrl_c()
# Check monitor for connection events # Check monitor for connection events
monitor_event = rcon.check_monitor() monitor_event = rcon.check_monitor()
@@ -398,13 +412,14 @@ def main_loop(screen, args):
timestamp = time.strftime('%H:%M:%S') timestamp = time.strftime('%H:%M:%S')
ui.print_message(f"^3[^7{timestamp}^3] ^8^1Disconnected from server - waiting for reconnect...^0^7\n") ui.print_message(f"^3[^7{timestamp}^3] ^8^1Disconnected from server - waiting for reconnect...^0^7\n")
if stats_conn is not None: if stats_conn is not None:
poller.unregister(stats_conn.socket)
stats_conn.close() stats_conn.close()
stats_conn = None stats_conn = None
stats_port = None stats_port = None
stats_password = None stats_password = None
# Handle user input # Handle user input
handle_user_input(input_queue, rcon, ui) handle_user_input(ui, rcon)
# Poll stats stream if connected # Poll stats stream if connected
stats_check_counter = handle_stats_stream(stats_conn, stats_check_counter, event_parser, ui, game_state) 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) handle_player_respawns(game_state, ui)
# Process RCON messages # Process RCON messages
if event > 0: if rcon.socket in ready:
logger.debug('Socket has data available') logger.debug('Socket has data available')
msg_count = 0 msg_count = 0
@@ -478,6 +493,7 @@ def main_loop(screen, args):
stats_conn = StatsConnection(host_ip, stats_port, stats_password) stats_conn = StatsConnection(host_ip, stats_port, stats_password)
stats_conn.connect() stats_conn.connect()
poller.register(stats_conn.socket, zmq.POLLIN)
ui.print_message("Stats stream connected - ready for game events\n") ui.print_message("Stats stream connected - ready for game events\n")
@@ -594,6 +610,14 @@ if __name__ == '__main__':
print(f' {name:<12} {host}') print(f' {name:<12} {host}')
sys.exit(0) 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 # Resolve host and password
if args.server: if args.server:
host, password = config.get_server(args.server) host, password = config.get_server(args.server)
@@ -603,10 +627,12 @@ if __name__ == '__main__':
sys.exit(1) sys.exit(1)
args.host = args.host or host args.host = args.host or host
args.password = args.password or password args.password = args.password or password
args.title = f'Quake Live PyCon: {args.server} ({args.host})'
else: else:
args.host = args.host or config.get_host() or DEFAULT_HOST args.host = args.host or config.get_host() or DEFAULT_HOST
if not args.host.startswith('tcp://'): if not args.host.startswith('tcp://'):
args.host = f'tcp://{args.host}' args.host = f'tcp://{args.host}'
args.password = args.password or config.get_password() args.password = args.password or config.get_password()
args.title = f'Quake Live PyCon: {args.host}'
curses.wrapper(main_loop, args) curses.wrapper(main_loop, args)
+1
View File
@@ -1,6 +1,7 @@
# qlpycon.conf # qlpycon.conf
# Edit this file as needed. # Edit this file as needed.
# #
# Pick from a menu: qlpycon
# Connect by server name: qlpycon ffa # Connect by server name: qlpycon ffa
# Connect directly: qlpycon --host tcp://1.2.3.4:28960 --password secret # Connect directly: qlpycon --host tcp://1.2.3.4:28960 --password secret
# List servers: qlpycon --list # List servers: qlpycon --list