Compare commits

...

6 Commits

Author SHA1 Message Date
pfl
530c12d9c0 Fix curses error in print_colored at window boundaries
- Wrap all addch calls in try/except blocks
- Return early when hitting window boundary
- Prevents crash when content exceeds window dimensions
- Gracefully truncates output instead of throwing error
2026-01-09 15:13:52 +01:00
pfl
993ed4f051 Fix curses error when info window is full
- Wrap addstr newlines in try/except blocks
- Prevents crash when writing to window boundary
- Info window has scrollok=False, so cursor can't move beyond bounds
2026-01-09 15:11:19 +01:00
pfl
42b958acdb Add terminal resize handling
- Detect KEY_RESIZE event in input thread
- Recreate windows with new dimensions on resize
- Redraw all content after resize
- Add minimum terminal size check (20x80)
- Handle both expansion and shrinking gracefully
2026-01-09 15:08:44 +01:00
pfl
f1ed9c3d2c Add UI test mode script 2026-01-09 15:02:03 +01:00
pfl
4ac9432db9 Ignore CLAUDE*.md files 2026-01-09 14:59:53 +01:00
pfl
fbaa4564e4 Fix cursor position and live timer updates
- Track cursor position in UIManager.cursor_pos
- Restore cursor after print_message() and update_server_info()
- Sync cursor_pos between wait_stdin thread and manager
- Update server info panel every second for live timer display
- Timer now ticks continuously instead of only on game events
2026-01-09 14:58:05 +01:00
4 changed files with 347 additions and 9 deletions

1
.gitignore vendored
View File

@ -5,3 +5,4 @@ venv3/
cvarlist.txt cvarlist.txt
curztest.py curztest.py
rconpw.txt rconpw.txt
CLAUDE*.md

View File

@ -367,6 +367,9 @@ def main_loop(screen):
# Shutdown flag # Shutdown flag
shutdown = False shutdown = False
# Timer refresh tracking (update UI once per second)
last_ui_update = 0
# Setup JSON logging if requested # Setup JSON logging if requested
json_logger = None json_logger = None
if args.json_log: if args.json_log:
@ -525,6 +528,12 @@ def main_loop(screen):
formatted_msg, attributes = format_message(message) formatted_msg, attributes = format_message(message)
ui.print_message(formatted_msg) ui.print_message(formatted_msg)
# Update server info panel every second (for live timer display)
current_time = time.time()
if current_time - last_ui_update >= 1.0:
ui.update_server_info(game_state)
last_ui_update = current_time
finally: finally:
# Clean up resources # Clean up resources
logger.info("Shutting down...") logger.info("Shutting down...")

234
test_ui.py Executable file
View File

@ -0,0 +1,234 @@
#!/usr/bin/env python3
"""
Test mode for QLPyCon - Simulates game events to test UI
Run with: python3 test_ui.py
"""
import curses
import time
import json
import logging
import queue
import threading
import sys
from config import VERSION
from state import GameState
from parser import EventParser
from ui import UIManager
logger = logging.getLogger('test_ui')
def simulate_game_events(game_state, parser, ui):
"""Simulate game events for testing"""
# Wait for UI to initialize
time.sleep(1)
ui.print_message("=== TEST MODE - SIMULATING GAME EVENTS ===\n")
ui.print_message(f"QLPyCon {VERSION} Test Mode\n\n")
# Event 1: Match start
time.sleep(2)
event = {
"TYPE": "MATCH_STARTED",
"DATA": {
"TIME": 0,
"PLAYERS": [
{"NAME": "^1RedWarrior"},
{"NAME": "^4BlueSniper"}
]
}
}
result = parser.parse_event(json.dumps(event))
if result:
ui.print_message(result)
ui.update_server_info(game_state)
# Event 2: Players join teams
time.sleep(2)
event = {
"TYPE": "PLAYER_SWITCHTEAM",
"DATA": {
"TIME": 5,
"KILLER": {
"NAME": "^1RedWarrior",
"TEAM": "RED",
"OLD_TEAM": "SPECTATOR"
}
}
}
result = parser.parse_event(json.dumps(event))
if result:
ui.print_message(result)
ui.update_server_info(game_state)
time.sleep(1)
event = {
"TYPE": "PLAYER_SWITCHTEAM",
"DATA": {
"TIME": 6,
"KILLER": {
"NAME": "^4BlueSniper",
"TEAM": "BLUE",
"OLD_TEAM": "SPECTATOR"
}
}
}
result = parser.parse_event(json.dumps(event))
if result:
ui.print_message(result)
ui.update_server_info(game_state)
# Event 3: Some kills
for i in range(10):
time.sleep(3)
match_time = 10 + (i * 5)
event = {
"TYPE": "PLAYER_DEATH",
"DATA": {
"TIME": match_time,
"KILLER": {
"NAME": "^1RedWarrior",
"TEAM": "RED",
"WEAPON": ["ROCKET", "RAILGUN", "LIGHTNING"][i % 3],
"HEALTH": 100 - (i * 10)
},
"VICTIM": {
"NAME": "^4BlueSniper",
"TEAM": "BLUE"
}
}
}
result = parser.parse_event(json.dumps(event))
if result:
ui.print_message(result)
ui.update_server_info(game_state)
# Counter-kill
time.sleep(2)
event = {
"TYPE": "PLAYER_DEATH",
"DATA": {
"TIME": match_time + 2,
"KILLER": {
"NAME": "^4BlueSniper",
"TEAM": "BLUE",
"WEAPON": ["PLASMA", "GRENADE", "SHOTGUN"][i % 3],
"HEALTH": 50
},
"VICTIM": {
"NAME": "^1RedWarrior",
"TEAM": "RED"
}
}
}
result = parser.parse_event(json.dumps(event))
if result:
ui.print_message(result)
ui.update_server_info(game_state)
# Event 4: Chat messages
time.sleep(2)
ui.print_message("^1RedWarrior^7: ^2gg!\n")
time.sleep(1)
ui.print_message("^4BlueSniper^7: ^2nice shots\n")
# Event 5: Medal
time.sleep(2)
event = {
"TYPE": "PLAYER_MEDAL",
"DATA": {
"TIME": 120,
"NAME": "^1RedWarrior",
"MEDAL": "EXCELLENT"
}
}
result = parser.parse_event(json.dumps(event))
if result:
ui.print_message(result)
# Event 6: Match end
time.sleep(3)
event = {
"TYPE": "MATCH_REPORT",
"DATA": {
"TIME": 180,
"TSCORE0": "12",
"TSCORE1": "8"
}
}
result = parser.parse_event(json.dumps(event))
if result:
ui.print_message(result)
ui.update_server_info(game_state)
ui.print_message("\n=== TEST COMPLETE ===\n")
ui.print_message("Press Ctrl-C twice to quit\n")
def test_main(screen):
"""Main test loop"""
# Initialize UI
ui = UIManager(screen, "tcp://127.0.0.1:27961 [TEST MODE]")
# Setup logging
log_handler = ui.setup_logging()
logger.addHandler(log_handler)
logger.setLevel(logging.INFO)
# Initialize game state
game_state = GameState()
# Set some initial server info
game_state.server_info.hostname = "^3Test Server [Demo Mode]"
game_state.server_info.map = "bloodrun"
game_state.server_info.gametype = "Team Deathmatch"
game_state.server_info.timelimit = "10"
game_state.server_info.fraglimit = "50"
game_state.server_info.maxclients = "16"
ui.update_server_info(game_state)
# Create parser
parser = EventParser(game_state)
# Display startup message
ui.print_message(f"*** QL pyCon Version {VERSION} - TEST MODE ***\n")
ui.print_message("This mode simulates game events to test the UI\n")
ui.print_message("Watch the timer tick in real-time!\n\n")
# Start event simulation in background thread
sim_thread = threading.Thread(
target=simulate_game_events,
args=(game_state, parser, ui),
daemon=True
)
sim_thread.start()
# Setup input queue (even though we won't use it much)
input_queue = ui.setup_input_queue()
# Main loop - just keep updating UI
try:
while True:
# Update server info display (this will show live timer)
ui.update_server_info(game_state)
# Process any user input
while not input_queue.empty():
command = input_queue.get()
ui.print_message(f">>> {command}\n")
ui.print_message("[Test mode: commands not sent to server]\n")
time.sleep(0.1) # Update 10 times per second
except KeyboardInterrupt:
pass
if __name__ == '__main__':
try:
curses.wrapper(test_main)
except KeyboardInterrupt:
print("\nTest mode exited.")

98
ui.py
View File

@ -46,7 +46,10 @@ def print_colored(window, message, attributes=0):
^0 = reset, ^1 = red, ^2 = green, ^3 = yellow, ^4 = blue, ^5 = cyan, ^6 = magenta, ^7 = white, ^8 = bold, ^9 = underline ^0 = reset, ^1 = red, ^2 = green, ^3 = yellow, ^4 = blue, ^5 = cyan, ^6 = magenta, ^7 = white, ^8 = bold, ^9 = underline
""" """
if not curses.has_colors: if not curses.has_colors:
try:
window.addstr(message) window.addstr(message)
except curses.error:
pass
return return
color = 0 color = 0
@ -69,13 +72,19 @@ def print_colored(window, message, attributes=0):
elif ord('1') <= val <= ord('6'): elif ord('1') <= val <= ord('6'):
color = val - ord('0') color = val - ord('0')
else: else:
try:
window.addch('^', curses.color_pair(color) | (curses.A_BOLD if bold else 0) | (curses.A_UNDERLINE if underline else 0) | attributes) window.addch('^', curses.color_pair(color) | (curses.A_BOLD if bold else 0) | (curses.A_UNDERLINE if underline else 0) | attributes)
window.addch(ch, curses.color_pair(color) | (curses.A_BOLD if bold else 0) | (curses.A_UNDERLINE if underline else 0) | attributes) window.addch(ch, curses.color_pair(color) | (curses.A_BOLD if bold else 0) | (curses.A_UNDERLINE if underline else 0) | attributes)
except curses.error:
return
parse_color = False parse_color = False
elif ch == '^': elif ch == '^':
parse_color = True parse_color = True
else: else:
try:
window.addch(ch, curses.color_pair(color) | (curses.A_BOLD if bold else 0) | (curses.A_UNDERLINE if underline else 0) | attributes) window.addch(ch, curses.color_pair(color) | (curses.A_BOLD if bold else 0) | (curses.A_UNDERLINE if underline else 0) | attributes)
except curses.error:
return
def update_autocomplete_display(window, current_input, first_word, words, ends_with_space): def update_autocomplete_display(window, current_input, first_word, words, ends_with_space):
""" """
@ -200,6 +209,7 @@ class UIManager:
self.input_queue = None self.input_queue = None
self.command_history = [] self.command_history = []
self.history_index = -1 self.history_index = -1
self.cursor_pos = 0 # Track cursor position in input
self._init_curses() self._init_curses()
self._create_windows() self._create_windows()
@ -229,6 +239,10 @@ class UIManager:
"""Create all UI windows""" """Create all UI windows"""
maxy, maxx = self.screen.getmaxyx() maxy, maxx = self.screen.getmaxyx()
# Minimum terminal size check
if maxy < 20 or maxx < 80:
return False
# Server info window (top) # Server info window (top)
self.info_window = curses.newwin( self.info_window = curses.newwin(
INFO_WINDOW_HEIGHT, INFO_WINDOW_HEIGHT,
@ -284,12 +298,60 @@ class UIManager:
self.screen.noutrefresh() self.screen.noutrefresh()
curses.doupdate() curses.doupdate()
return True
def handle_resize(self):
"""Handle terminal resize event"""
try:
# Get new terminal dimensions
maxy, maxx = self.screen.getmaxyx()
# Minimum size check
if maxy < 20 or maxx < 80:
return False
# Update screen
curses.update_lines_cols()
self.screen.clear()
self.screen.addstr(0, 0, f"Quake Live PyCon: {self.host}")
self.screen.noutrefresh()
# Recreate windows with new dimensions
self.info_window.resize(INFO_WINDOW_HEIGHT, maxx - 4)
self.info_window.mvwin(INFO_WINDOW_Y, 2)
self.output_window.resize(maxy - 17, maxx - 4)
self.output_window.mvwin(OUTPUT_WINDOW_Y, 2)
self.divider_window.resize(1, maxx - 4)
self.divider_window.mvwin(maxy - 3, 2)
self.divider_window.clear()
self.divider_window.hline(curses.ACS_HLINE, maxx - 4)
self.input_window.resize(INPUT_WINDOW_HEIGHT, maxx - 6)
self.input_window.mvwin(maxy - 2, 4)
self.screen.addstr(maxy - 2, 2, '$ ')
# Refresh all windows
self.info_window.noutrefresh()
self.output_window.noutrefresh()
self.divider_window.noutrefresh()
self.input_window.noutrefresh()
self.screen.noutrefresh()
curses.doupdate()
return True
except curses.error:
return False
def setup_input_queue(self): def setup_input_queue(self):
"""Setup threaded input queue with command history and autocomplete""" """Setup threaded input queue with command history and autocomplete"""
def wait_stdin(q, window, manager): def wait_stdin(q, window, manager):
current_input = "" current_input = ""
cursor_pos = 0 cursor_pos = 0
manager.cursor_pos = 0 # Keep manager in sync
temp_history_index = -1 temp_history_index = -1
temp_input = "" # Temp storage when navigating history temp_input = "" # Temp storage when navigating history
quit_confirm = False quit_confirm = False
@ -306,6 +368,17 @@ class UIManager:
if key == -1: # No input if key == -1: # No input
continue continue
# Handle terminal resize
if key == curses.KEY_RESIZE:
manager.handle_resize()
# Redraw input
window.erase()
window.addstr(0, 0, current_input)
window.move(0, cursor_pos)
window.noutrefresh()
curses.doupdate()
continue
# Tab key - cycle through suggestions # Tab key - cycle through suggestions
if key == ord('\t') or key == 9: if key == ord('\t') or key == 9:
if suggestions: if suggestions:
@ -325,6 +398,7 @@ class UIManager:
words[-1] = suggestions[suggestion_index] words[-1] = suggestions[suggestion_index]
current_input = ' '.join(words) current_input = ' '.join(words)
cursor_pos = len(current_input) cursor_pos = len(current_input)
manager.cursor_pos = cursor_pos
# Update display # Update display
window.erase() window.erase()
@ -387,6 +461,7 @@ class UIManager:
q.put(current_input) q.put(current_input)
current_input = "" current_input = ""
cursor_pos = 0 cursor_pos = 0
manager.cursor_pos = cursor_pos
temp_history_index = -1 temp_history_index = -1
temp_input = "" temp_input = ""
suggestions = [] suggestions = []
@ -407,6 +482,7 @@ class UIManager:
temp_history_index -= 1 temp_history_index -= 1
current_input = manager.command_history[temp_history_index] current_input = manager.command_history[temp_history_index]
cursor_pos = len(current_input) cursor_pos = len(current_input)
manager.cursor_pos = cursor_pos
suggestions = [] suggestions = []
suggestion_index = -1 suggestion_index = -1
original_word = "" original_word = ""
@ -427,6 +503,7 @@ class UIManager:
current_input = manager.command_history[temp_history_index] current_input = manager.command_history[temp_history_index]
cursor_pos = len(current_input) cursor_pos = len(current_input)
manager.cursor_pos = cursor_pos
suggestions = [] suggestions = []
suggestion_index = -1 suggestion_index = -1
original_word = "" original_word = ""
@ -438,6 +515,7 @@ class UIManager:
elif key == curses.KEY_LEFT: elif key == curses.KEY_LEFT:
if cursor_pos > 0: if cursor_pos > 0:
cursor_pos -= 1 cursor_pos -= 1
manager.cursor_pos = cursor_pos
window.move(0, cursor_pos) window.move(0, cursor_pos)
window.noutrefresh() window.noutrefresh()
@ -445,6 +523,7 @@ class UIManager:
elif key == curses.KEY_RIGHT: elif key == curses.KEY_RIGHT:
if cursor_pos < len(current_input): if cursor_pos < len(current_input):
cursor_pos += 1 cursor_pos += 1
manager.cursor_pos = cursor_pos
window.move(0, cursor_pos) window.move(0, cursor_pos)
window.noutrefresh() window.noutrefresh()
@ -453,6 +532,7 @@ class UIManager:
if cursor_pos > 0: if cursor_pos > 0:
current_input = current_input[:cursor_pos-1] + current_input[cursor_pos:] current_input = current_input[:cursor_pos-1] + current_input[cursor_pos:]
cursor_pos -= 1 cursor_pos -= 1
manager.cursor_pos = cursor_pos
temp_history_index = -1 # Exit history mode temp_history_index = -1 # Exit history mode
window.erase() window.erase()
@ -477,6 +557,7 @@ class UIManager:
char = chr(key) char = chr(key)
current_input = current_input[:cursor_pos] + char + current_input[cursor_pos:] current_input = current_input[:cursor_pos] + char + current_input[cursor_pos:]
cursor_pos += 1 cursor_pos += 1
manager.cursor_pos = cursor_pos
temp_history_index = -1 # Exit history mode temp_history_index = -1 # Exit history mode
window.erase() window.erase()
@ -521,7 +602,9 @@ class UIManager:
"""Print formatted message to output window""" """Print formatted message to output window"""
print_colored(self.output_window, message, attributes) print_colored(self.output_window, message, attributes)
self.output_window.noutrefresh() self.output_window.noutrefresh()
self.input_window.move(0, 0) # Restore cursor to input window at current position
self.input_window.move(0, self.cursor_pos)
self.input_window.noutrefresh()
curses.doupdate() curses.doupdate()
def update_server_info(self, game_state): def update_server_info(self, game_state):
@ -579,7 +662,10 @@ class UIManager:
f"{limit_display}^0\n", 0) f"{limit_display}^0\n", 0)
# Blank lines to fill # Blank lines to fill
try:
self.info_window.addstr("\n") self.info_window.addstr("\n")
except curses.error:
pass
# Line 3: Team headers and player lists # Line 3: Team headers and player lists
teams = game_state.player_tracker.get_players_by_team() teams = game_state.player_tracker.get_players_by_team()
@ -694,7 +780,10 @@ class UIManager:
print_colored(self.info_window, line, 0) print_colored(self.info_window, line, 0)
# Blank lines to fill # Blank lines to fill
try:
self.info_window.addstr("\n") self.info_window.addstr("\n")
except curses.error:
pass
# List spectators on one line # List spectators on one line
spec_list = " ".join(spec_players) spec_list = " ".join(spec_players)
@ -702,12 +791,17 @@ class UIManager:
print_colored(self.info_window, line, 0) print_colored(self.info_window, line, 0)
# Blank lines to fill # Blank lines to fill
try:
self.info_window.addstr("\n") self.info_window.addstr("\n")
except curses.error:
pass
# Separator # Separator
separator = "^7" + "" * (max_x - 1) + "^7" separator = "^7" + "" * (max_x - 1) + "^7"
print_colored(self.info_window, separator, 0) print_colored(self.info_window, separator, 0)
self.info_window.noutrefresh() self.info_window.noutrefresh()
# Restore cursor to input window at current position
self.input_window.move(0, self.cursor_pos)
self.input_window.noutrefresh()
curses.doupdate() curses.doupdate()