Compare commits

..

9 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
pfl
a03b4b0b93 Add missing Quake Live commands to autocomplete
- Match control: readyall, allready, abort, pause, unpause, lock, unlock, timeout, timein
- Player management: shuffle, put, mute, unmute, slap, slay
- Server control: restart, endgame, nextmap, forcemap
- QLX commands: qlx, elo, balance, teams, scores
- Info commands: serverinfo, players, maplist, configstrings

Total commands increased from 18 to 47
2026-01-09 14:23:50 +01:00
pfl
e6e32033ad Add live match timer countdown
- Add match_time_last_sync timestamp to track server updates
- Update timestamp in parser when TIME events received
- Calculate elapsed time in UI for live countdown
- Timer now ticks in real-time between server events
2026-01-09 14:00:32 +01:00
pfl
16b5209338 Fix exception handling in ui.py
- Replace 6 bare except: with except curses.error:
- Remove inline import logging, use module-level logger
- Improves safety by not catching KeyboardInterrupt/SystemExit
2026-01-09 13:56:14 +01:00
7 changed files with 403 additions and 19 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

@ -119,6 +119,39 @@ COMMANDS = [
'killserver', 'killserver',
'quit', 'quit',
'team', 'team',
# Match control
'readyall',
'allready',
'abort',
'pause',
'unpause',
'lock',
'unlock',
'timeout',
'timein',
# Player management
'shuffle',
'put',
'mute',
'unmute',
'slap',
'slay',
# Server control
'restart',
'endgame',
'nextmap',
'forcemap',
# QLX commands
'qlx',
'elo',
'balance',
'teams',
'scores',
# Info commands
'serverinfo',
'players',
'maplist',
'configstrings',
] ]
# Bot names for addbot command (complete list) # Bot names for addbot command (complete list)

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...")

View File

@ -94,6 +94,7 @@ class EventParser:
# Get Match Time # Get Match Time
if 'TIME' in data: if 'TIME' in data:
self.game_state.server_info.match_time = int(data['TIME']) self.game_state.server_info.match_time = int(data['TIME'])
self.game_state.server_info.match_time_last_sync = time.time()
if 'KILLER' not in data: if 'KILLER' not in data:
return None return None
@ -138,6 +139,7 @@ class EventParser:
# Get Match Time # Get Match Time
if 'TIME' in data: if 'TIME' in data:
self.game_state.server_info.match_time = int(data['TIME']) self.game_state.server_info.match_time = int(data['TIME'])
self.game_state.server_info.match_time_last_sync = time.time()
if 'VICTIM' not in data: if 'VICTIM' not in data:
return None return None
@ -244,6 +246,7 @@ class EventParser:
# Get Match Time # Get Match Time
if 'TIME' in data: if 'TIME' in data:
self.game_state.server_info.match_time = int(data['TIME']) self.game_state.server_info.match_time = int(data['TIME'])
self.game_state.server_info.match_time_last_sync = time.time()
team_won = data.get('TEAM_WON') team_won = data.get('TEAM_WON')
round_num = data.get('ROUND', 0) round_num = data.get('ROUND', 0)
@ -265,6 +268,7 @@ class EventParser:
# Get Match Time # Get Match Time
if 'TIME' in data: if 'TIME' in data:
self.game_state.server_info.match_time = int(data['TIME']) self.game_state.server_info.match_time = int(data['TIME'])
self.game_state.server_info.match_time_last_sync = time.time()
name = data.get('NAME', 'Unknown') name = data.get('NAME', 'Unknown')
medal = data.get('MEDAL', 'UNKNOWN') medal = data.get('MEDAL', 'UNKNOWN')
@ -299,6 +303,7 @@ class EventParser:
# Get Match Time # Get Match Time
if 'TIME' in data: if 'TIME' in data:
self.game_state.server_info.match_time = int(data['TIME']) self.game_state.server_info.match_time = int(data['TIME'])
self.game_state.server_info.match_time_last_sync = time.time()
if self.game_state.server_info.is_team_mode(): if self.game_state.server_info.is_team_mode():
return f"^8^2[GAME ON]^0 ^7Match has started - ^1^8RED ^0^7vs. ^4^8BLUE\n" return f"^8^2[GAME ON]^0 ^7Match has started - ^1^8RED ^0^7vs. ^4^8BLUE\n"
@ -320,6 +325,7 @@ class EventParser:
# Get Match Time # Get Match Time
if 'TIME' in data: if 'TIME' in data:
self.game_state.server_info.match_time = int(data['TIME']) self.game_state.server_info.match_time = int(data['TIME'])
self.game_state.server_info.match_time_last_sync = time.time()
if not self.game_state.server_info.is_team_mode(): if not self.game_state.server_info.is_team_mode():
return None return None

View File

@ -34,6 +34,7 @@ class ServerInfo:
self.dead_players = {} self.dead_players = {}
self.round_end_time = None self.round_end_time = None
self.match_time = 0 self.match_time = 0
self.match_time_last_sync = 0 # Timestamp of last TIME update from server
def is_team_mode(self): def is_team_mode(self):
"""Check if current gametype is a team mode""" """Check if current gametype is a team mode"""

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.")

124
ui.py
View File

@ -9,6 +9,7 @@ import curses.textpad
import threading import threading
import queue import queue
import logging import logging
import time
from config import COLOR_PAIRS, INFO_WINDOW_HEIGHT, INFO_WINDOW_Y, OUTPUT_WINDOW_Y, INPUT_WINDOW_HEIGHT, TEAM_MODES, MAX_COMMAND_HISTORY from config import COLOR_PAIRS, INFO_WINDOW_HEIGHT, INFO_WINDOW_Y, OUTPUT_WINDOW_Y, INPUT_WINDOW_HEIGHT, TEAM_MODES, MAX_COMMAND_HISTORY
from cvars import autocomplete, COMMAND_SIGNATURES, get_signature_with_highlight, get_argument_suggestions, COMMAND_ARGUMENTS from cvars import autocomplete, COMMAND_SIGNATURES, get_signature_with_highlight, get_argument_suggestions, COMMAND_ARGUMENTS
@ -45,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
@ -68,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):
""" """
@ -105,7 +115,7 @@ def update_autocomplete_display(window, current_input, first_word, words, ends_w
else: else:
window.addstr(1, x_pos, arg_text, curses.A_DIM) window.addstr(1, x_pos, arg_text, curses.A_DIM)
x_pos += len(arg_text) + 1 x_pos += len(arg_text) + 1
except: except curses.error:
pass pass
else: else:
# User is typing arguments # User is typing arguments
@ -134,7 +144,7 @@ def update_autocomplete_display(window, current_input, first_word, words, ends_w
match_line = f'<{arg_type}>: {" ".join(display_suggestions)}{more_indicator}' match_line = f'<{arg_type}>: {" ".join(display_suggestions)}{more_indicator}'
try: try:
window.addstr(1, 0, match_line, curses.A_DIM) window.addstr(1, 0, match_line, curses.A_DIM)
except: except curses.error:
pass pass
suggestions = arg_suggestions # Store for Tab cycling suggestions = arg_suggestions # Store for Tab cycling
suggestion_index = -1 suggestion_index = -1
@ -151,7 +161,7 @@ def update_autocomplete_display(window, current_input, first_word, words, ends_w
else: else:
window.addstr(1, x_pos, arg_text, curses.A_DIM) window.addstr(1, x_pos, arg_text, curses.A_DIM)
x_pos += len(arg_text) + 1 x_pos += len(arg_text) + 1
except: except curses.error:
pass pass
elif first_word in COMMAND_SIGNATURES and COMMAND_SIGNATURES[first_word]: elif first_word in COMMAND_SIGNATURES and COMMAND_SIGNATURES[first_word]:
@ -166,7 +176,7 @@ def update_autocomplete_display(window, current_input, first_word, words, ends_w
else: else:
window.addstr(1, x_pos, arg_text, curses.A_DIM) window.addstr(1, x_pos, arg_text, curses.A_DIM)
x_pos += len(arg_text) + 1 x_pos += len(arg_text) + 1
except: except curses.error:
pass pass
else: else:
@ -180,7 +190,7 @@ def update_autocomplete_display(window, current_input, first_word, words, ends_w
match_line = ' '.join(suggestions) match_line = ' '.join(suggestions)
try: try:
window.addstr(1, 0, match_line, curses.A_DIM) window.addstr(1, 0, match_line, curses.A_DIM)
except: except curses.error:
pass pass
return suggestions, suggestion_index, original_word return suggestions, suggestion_index, original_word
@ -199,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()
@ -228,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,
@ -283,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
@ -305,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:
@ -324,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()
@ -367,7 +442,7 @@ class UIManager:
try: try:
window.addstr(1, 0, display_line, curses.A_DIM) window.addstr(1, 0, display_line, curses.A_DIM)
except: except curses.error:
pass pass
window.move(0, cursor_pos) window.move(0, cursor_pos)
@ -386,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 = []
@ -406,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 = ""
@ -426,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 = ""
@ -437,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()
@ -444,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()
@ -452,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()
@ -476,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()
@ -496,8 +578,7 @@ class UIManager:
curses.doupdate() # Immediate screen update curses.doupdate() # Immediate screen update
except Exception as e: except Exception as e:
import logging logger.error(f'Input error: {e}')
logging.getLogger('ui').error(f'Input error: {e}')
# Log but continue - input thread should stay alive # Log but continue - input thread should stay alive
window.move(0, cursor_pos) window.move(0, cursor_pos)
@ -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):
@ -536,8 +619,14 @@ class UIManager:
timer_display = "" timer_display = ""
if server_info.match_time > 0 and not server_info.warmup: if server_info.match_time > 0 and not server_info.warmup:
mins = server_info.match_time // 60 # Calculate live time: add elapsed seconds since last server update
secs = server_info.match_time % 60 current_time = server_info.match_time
if server_info.match_time_last_sync > 0:
elapsed = int(time.time() - server_info.match_time_last_sync)
current_time += elapsed
mins = current_time // 60
secs = current_time % 60
timer_display = f"^3^0Time:^8^7 {mins}:{secs:02d}^0" timer_display = f"^3^0Time:^8^7 {mins}:{secs:02d}^0"
else: else:
timer_display = "^3^0Time:^8^7 0:00^0" timer_display = "^3^0Time:^8^7 0:00^0"
@ -573,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()
@ -688,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)
@ -696,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()