qlpycon/ui.py
2025-12-23 22:32:49 +01:00

348 lines
13 KiB
Python

#!/usr/bin/env python3
"""
Curses-based UI for QLPyCon
Handles terminal display, windows, and color rendering
"""
import curses
import curses.textpad
import threading
import queue
import logging
from config import COLOR_PAIRS, INFO_WINDOW_HEIGHT, INFO_WINDOW_Y, OUTPUT_WINDOW_Y, INPUT_WINDOW_HEIGHT, TEAM_MODES
logger = logging.getLogger('ui')
class CursesHandler(logging.Handler):
"""Logging handler that outputs to curses window"""
def __init__(self, window):
logging.Handler.__init__(self)
self.window = window
def emit(self, record):
try:
msg = self.format(record)
fs = "%s\n"
try:
print_colored(self.window, fs % msg, 0)
self.window.refresh()
except UnicodeError:
print_colored(self.window, fs % msg.encode("UTF-8"), 0)
self.window.refresh()
except (KeyboardInterrupt, SystemExit):
raise
except:
self.handleError(record)
def print_colored(window, message, attributes=0):
"""
Print message with Quake color codes (^N)
^0 = bold, ^1 = red, ^2 = green, ^3 = yellow, ^4 = blue, ^5 = cyan, ^6 = magenta, ^7 = white, ^9 = reset
"""
if not curses.has_colors:
window.addstr(message)
return
color = 0
bold = False
parse_color = False
for ch in message:
val = ord(ch)
if parse_color:
if ch == '0':
bold = True
elif ch == '9':
bold = False
elif ch == '7':
color = 0
elif ord('1') <= val <= ord('6'):
color = val - ord('0')
else:
window.addch('^', curses.color_pair(color) | (curses.A_BOLD if bold else 0) | attributes)
window.addch(ch, curses.color_pair(color) | (curses.A_BOLD if bold else 0) | attributes)
parse_color = False
elif ch == '^':
parse_color = True
else:
window.addch(ch, curses.color_pair(color) | (curses.A_BOLD if bold else 0) | attributes)
window.refresh()
class UIManager:
"""Manages curses windows and display"""
def __init__(self, screen, host):
self.screen = screen
self.host = host
self.info_window = None
self.output_window = None
self.input_window = None
self.divider_window = None
self.input_queue = None
self.command_history = []
self.history_index = -1
self._init_curses()
self._create_windows()
def _init_curses(self):
"""Initialize curses settings"""
curses.endwin()
curses.initscr()
self.screen.nodelay(1)
curses.start_color()
curses.use_default_colors()
curses.cbreak()
curses.setsyx(-1, -1)
self.screen.addstr(f"Quake Live PyCon: {self.host}")
self.screen.refresh()
# Initialize color pairs
for i in range(1, 7):
curses.init_pair(i, i, 0)
# Swap cyan and magenta (5 and 6)
curses.init_pair(5, 6, 0)
curses.init_pair(6, 5, 0)
def _create_windows(self):
"""Create all UI windows"""
maxy, maxx = self.screen.getmaxyx()
# Server info window (top)
self.info_window = curses.newwin(
INFO_WINDOW_HEIGHT,
maxx - 4,
INFO_WINDOW_Y,
2
)
self.info_window.scrollok(False)
self.info_window.idlok(False)
self.info_window.leaveok(True)
self.info_window.refresh()
# Output window (middle - main display)
self.output_window = curses.newwin(
maxy - 15,
maxx - 4,
OUTPUT_WINDOW_Y,
2
)
self.output_window.scrollok(True)
self.output_window.idlok(True)
self.output_window.leaveok(True)
self.output_window.refresh()
# Input window (bottom)
self.input_window = curses.newwin(
INPUT_WINDOW_HEIGHT,
maxx - 6,
maxy - 2,
4
)
self.input_window.keypad(True)
self.input_window.nodelay(False)
self.screen.addstr(maxy - 2, 2, '$ ')
self.input_window.idlok(True)
self.input_window.leaveok(False)
self.input_window.refresh()
# Divider line
self.divider_window = curses.newwin(
1,
maxx - 4,
maxy - 3,
2
)
self.divider_window.hline(curses.ACS_HLINE, maxx - 4)
self.divider_window.refresh()
self.screen.refresh()
def setup_input_queue(self):
"""Setup threaded input queue with command history"""
def wait_stdin(q, window, manager):
current_input = ""
cursor_pos = 0
temp_history_index = -1
temp_input = "" # Temp storage when navigating history
while True:
try:
key = window.getch()
if key == -1: # No input
continue
# Enter key
if key in (curses.KEY_ENTER, 10, 13):
if len(current_input) > 0:
# Add to history
manager.command_history.append(current_input)
if len(manager.command_history) > 10:
manager.command_history.pop(0)
q.put(current_input)
current_input = ""
cursor_pos = 0
temp_history_index = -1
temp_input = ""
window.clear()
window.refresh()
# Arrow UP - previous command
elif key == curses.KEY_UP:
if len(manager.command_history) > 0:
# Save current input when first entering history
if temp_history_index == -1:
temp_input = current_input
temp_history_index = len(manager.command_history)
if temp_history_index > 0:
temp_history_index -= 1
current_input = manager.command_history[temp_history_index]
cursor_pos = len(current_input)
window.clear()
window.addstr(0, 0, current_input)
window.refresh()
# Arrow DOWN - next command
elif key == curses.KEY_DOWN:
if temp_history_index != -1:
temp_history_index += 1
if temp_history_index >= len(manager.command_history):
# Restore temp input
current_input = temp_input
temp_history_index = -1
temp_input = ""
else:
current_input = manager.command_history[temp_history_index]
cursor_pos = len(current_input)
window.clear()
window.addstr(0, 0, current_input)
window.refresh()
# Backspace
elif key in (curses.KEY_BACKSPACE, 127, 8):
if cursor_pos > 0:
current_input = current_input[:cursor_pos-1] + current_input[cursor_pos:]
cursor_pos -= 1
temp_history_index = -1 # Exit history mode
window.clear()
window.addstr(0, 0, current_input)
window.move(0, cursor_pos)
window.refresh()
# Regular character
elif 32 <= key <= 126:
char = chr(key)
current_input = current_input[:cursor_pos] + char + current_input[cursor_pos:]
cursor_pos += 1
temp_history_index = -1 # Exit history mode
window.clear()
window.addstr(0, 0, current_input)
window.move(0, cursor_pos)
window.refresh()
except Exception as e:
import logging
logging.getLogger('ui').error(f'Input error: {e}')
self.input_queue = queue.Queue()
t = threading.Thread(target=wait_stdin, args=(self.input_queue, self.input_window, self))
t.daemon = True
t.start()
return self.input_queue
def setup_logging(self):
"""Setup logging handler for output window"""
handler = CursesHandler(self.output_window)
formatter = logging.Formatter('%(asctime)-8s|%(name)-12s|%(levelname)-6s|%(message)-s', '%H:%M:%S')
handler.setFormatter(formatter)
return handler
def print_message(self, message, attributes=0):
"""Print formatted message to output window"""
y, x = curses.getsyx()
print_colored(self.output_window, message, attributes)
curses.setsyx(y, x)
curses.doupdate()
def update_server_info(self, game_state):
"""Update server info window"""
self.info_window.clear()
max_y, max_x = self.info_window.getmaxyx()
server_info = game_state.server_info
# Line 1: Hostname
hostname = server_info.hostname
print_colored(self.info_window, f"^3Name:^0 {hostname} ^7\n", 0)
# Line 2: Game info
gametype = server_info.gametype
mapname = server_info.map
timelimit = server_info.timelimit
fraglimit = server_info.fraglimit
roundlimit = server_info.roundlimit
caplimit = server_info.capturelimit
curclients = server_info.curclients
maxclients = server_info.maxclients
print_colored(self.info_window,
f"^3^9Type:^7^0 {gametype} ^9^3| Map:^7^0 {mapname} ^9^3| Players:^7^0 {curclients}/{maxclients} "
f"^3^9| Limits (T/F/R/C):^7^0 {timelimit}/{fraglimit}/{roundlimit}/{caplimit}^9\n", 0)
# Line 3: Team headers and player lists
teams = game_state.player_tracker.get_players_by_team()
if server_info.gametype in TEAM_MODES:
warmup_display = "^3Warmup: ^2YES^9" if server_info.warmup else "^3Warmup: ^1NO^9"
print_colored(self.info_window, f"^0^1(RED) ^4(BLUE) ^3(SPEC) {warmup_display}\n", 0)
red_players = teams['RED'][:4]
blue_players = teams['BLUE'][:4]
spec_players = teams['SPECTATOR'][:4]
for i in range(4):
red = red_players[i] if i < len(red_players) else ''
blue = blue_players[i] if i < len(blue_players) else ''
spec = spec_players[i] if i < len(spec_players) else ''
# Strip color codes for padding calculation
from formatter import strip_color_codes
red_clean = strip_color_codes(red)
blue_clean = strip_color_codes(blue)
spec_clean = strip_color_codes(spec)
# Calculate padding (24 chars per column)
red_pad = 24 - len(red_clean)
blue_pad = 24 - len(blue_clean)
line = f"^0{red}^9{' ' * red_pad}^0{blue}^9{' ' * blue_pad}^0{spec}^9\n"
print_colored(self.info_window, line, 0)
else:
warmup_display = "^3Warmup: ^2YES^9" if server_info.warmup else "^3Warmup: ^1NO^9"
print_colored(self.info_window, f"^0^3(FREE) {warmup_display}\n", 0)
free_players = teams['FREE'][:4]
for player in free_players:
print_colored(self.info_window, f"^0{player}^9\n", 0)
# Fill remaining lines
for i in range(4 - len(free_players)):
self.info_window.addstr("\n")
# Blank lines to fill
self.info_window.addstr("\n\n")
# Separator
separator = "^7" + "" * (max_x - 1) + "^7"
print_colored(self.info_window, separator, 0)
self.info_window.refresh()