#!/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/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 == '7': color = 0 bold = False 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._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.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""" def wait_stdin(q, window): while True: line = curses.textpad.Textbox(window).edit() if len(line) > 0: q.put(line) window.clear() window.refresh() self.input_queue = queue.Queue() t = threading.Thread(target=wait_stdin, args=(self.input_queue, self.input_window)) 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"^6═══^0 {hostname} ^7^6═══^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"^3Type:^7^0 {gametype} ^7^3Map:^7^0 {mapname} ^7^3Players:^7^0 {curclients}/{maxclients} " f"^7^3Limits (T/F/R/C):^7 {timelimit}/{fraglimit}/{roundlimit}/{caplimit}\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: print_colored(self.info_window, f"^0^1(RED) ^4(BLUE) ^3(SPEC)\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"{red}{' ' * red_pad}{blue}{' ' * blue_pad}{spec}\n" print_colored(self.info_window, line, 0) else: print_colored(self.info_window, f"^0^3(FREE)\n", 0) free_players = teams['FREE'][:4] for player in free_players: print_colored(self.info_window, f"{player}\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 = "^6" + "═" * (max_x - 1) + "^7" print_colored(self.info_window, separator, 0) self.info_window.refresh()