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:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,36 +44,42 @@ 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
|
||||||
|
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()
|
current_time = time.time()
|
||||||
|
if quit_confirm_time is None or (current_time - quit_confirm_time) > _QUIT_CONFIRM_TIMEOUT:
|
||||||
with quit_confirm_lock:
|
logger.warning(f"^1^8Press Ctrl-C again within {_QUIT_CONFIRM_TIMEOUT:.0f} seconds to quit^0")
|
||||||
if shutdown_requested:
|
quit_confirm_time = current_time
|
||||||
# Third Ctrl-C: last-resort forced exit
|
else:
|
||||||
curses.endwin()
|
logger.warning("^1^8Quittin'^0")
|
||||||
os._exit(0)
|
shutdown_requested = True
|
||||||
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
|
|
||||||
|
|
||||||
def parse_cvar_response(message, game_state, ui):
|
def parse_cvar_response(message, game_state, ui):
|
||||||
"""
|
"""
|
||||||
@@ -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,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
|
||||||
|
|||||||
Reference in New Issue
Block a user