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
```bash
qlpycon # pick a server from a menu
qlpycon ffa # connect by name
qlpycon --host tcp://10.13.12.93:28960 --password secret # connect directly
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:**
- `--host URI` — ZMQ RCON endpoint
- `--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
- Argument suggestions for 25+ commands (bot names, maps, gametypes)
- Command history (↑/↓)
- Output scrollback (PgUp/PgDn) that survives terminal resizes
## Architecture
+4 -1
View File
@@ -5,7 +5,7 @@ Configuration and constants for QLPyCon
import re
VERSION = "0.8.1"
VERSION = "0.9.0"
# Pattern matching
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
# UI dimensions
MIN_ROWS = 20
MIN_COLS = 80
INFO_WINDOW_HEIGHT = 12
INFO_WINDOW_Y = 2
OUTPUT_WINDOW_Y = 14
INPUT_WINDOW_HEIGHT = 2
OUTPUT_SCROLLBACK_LINES = 2000 # Output lines kept for PgUp/PgDn and redraw after resize
# Event deduplication
MAX_RECENT_EVENTS = 10
-4
View File
@@ -89,10 +89,6 @@ class RconConnection:
self.socket.send(command)
logger.info(f'Sent command: {command}')
def poll(self, timeout):
"""Poll for messages"""
return self.socket.poll(timeout)
def recv_message(self):
"""Receive a message (non-blocking)"""
try:
+1
View File
@@ -173,6 +173,7 @@ def create_example_config():
config_content = """# qlpycon.conf
# Edit this file as needed.
#
# Pick from a menu: qlpycon
# Connect by server name: qlpycon ffa
# Connect directly: qlpycon --host tcp://1.2.3.4:28960 --password secret
# 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 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,34 +44,40 @@ 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
current_time = time.time()
with quit_confirm_lock:
"""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)
elif quit_confirm_time is None or (current_time - quit_confirm_time) > _QUIT_CONFIRM_TIMEOUT:
# First Ctrl-C or timeout expired
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()
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:
# Second Ctrl-C within timeout: request graceful shutdown
logger.warning("^1^8Quittin'^0")
shutdown_requested = True
@@ -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)
+1
View File
@@ -1,6 +1,7 @@
# qlpycon.conf
# Edit this file as needed.
#
# Pick from a menu: qlpycon
# Connect by server name: qlpycon ffa
# Connect directly: qlpycon --host tcp://1.2.3.4:28960 --password secret
# List servers: qlpycon --list