diff --git a/README.md b/README.md index 93863f0..f358ae5 100644 --- a/README.md +++ b/README.md @@ -34,11 +34,22 @@ duel = 10.13.12.93:28961 ## Usage ```bash +qlpycon # pick a server from a menu qlpycon ffa # connect by name qlpycon --host tcp://10.13.12.93:28960 --password secret # connect directly 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:** - `--host URI` — ZMQ RCON endpoint - `--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 - Argument suggestions for 25+ commands (bot names, maps, gametypes) - Command history (↑/↓) +- Output scrollback (PgUp/PgDn) that survives terminal resizes ## Architecture diff --git a/lib/constants.py b/lib/constants.py index d219e5a..4abec15 100644 --- a/lib/constants.py +++ b/lib/constants.py @@ -5,7 +5,7 @@ Configuration and constants for QLPyCon import re -VERSION = "0.8.1" +VERSION = "0.9.0" # Pattern matching 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 # UI dimensions +MIN_ROWS = 20 +MIN_COLS = 80 INFO_WINDOW_HEIGHT = 12 INFO_WINDOW_Y = 2 OUTPUT_WINDOW_Y = 14 INPUT_WINDOW_HEIGHT = 2 +OUTPUT_SCROLLBACK_LINES = 2000 # Output lines kept for PgUp/PgDn and redraw after resize # Event deduplication MAX_RECENT_EVENTS = 10 diff --git a/lib/network.py b/lib/network.py index 3e16069..3644f34 100644 --- a/lib/network.py +++ b/lib/network.py @@ -89,10 +89,6 @@ class RconConnection: self.socket.send(command) logger.info(f'Sent command: {command}') - def poll(self, timeout): - """Poll for messages""" - return self.socket.poll(timeout) - def recv_message(self): """Receive a message (non-blocking)""" try: diff --git a/lib/settings.py b/lib/settings.py index f353a23..c7a3df1 100644 --- a/lib/settings.py +++ b/lib/settings.py @@ -173,6 +173,7 @@ def create_example_config(): config_content = """# qlpycon.conf # Edit this file as needed. # +# Pick from a menu: qlpycon # Connect by server name: qlpycon ffa # Connect directly: qlpycon --host tcp://1.2.3.4:28960 --password secret # List servers: qlpycon --list diff --git a/lib/ui.py b/lib/ui.py index a2002b8..cc2ebd8 100644 --- a/lib/ui.py +++ b/lib/ui.py @@ -2,671 +2,675 @@ """ Curses-based UI for QLPyCon Handles terminal display, windows, and color rendering + +Design rules that keep the UI stable: +- Every curses call happens on the main thread (curses is not thread-safe). +- Nothing lives only inside a curses window. Output lines, the input line + and the last game state are kept in Python objects, so a resize simply + recreates the windows and redraws them from that state. """ import curses -import curses.textpad -import threading -import queue +import itertools import logging +import re import time -from .constants import INFO_WINDOW_HEIGHT, INFO_WINDOW_Y, OUTPUT_WINDOW_Y, INPUT_WINDOW_HEIGHT, TEAM_MODES, MAX_COMMAND_HISTORY +from collections import deque + +from .constants import (INFO_WINDOW_HEIGHT, INFO_WINDOW_Y, OUTPUT_WINDOW_Y, INPUT_WINDOW_HEIGHT, + MIN_ROWS, MIN_COLS, OUTPUT_SCROLLBACK_LINES, TEAM_MODES, MAX_COMMAND_HISTORY) from .cvars import autocomplete, COMMAND_SIGNATURES, get_signature_with_highlight, get_argument_suggestions, COMMAND_ARGUMENTS +from .formatter import strip_color_codes logger = logging.getLogger('ui') +# Splits a message into tokens: a color code (^0-^9) or a single character. +# Example: '^1ab' -> ['^1', 'a', 'b'] +COLOR_TOKEN_PATTERN = re.compile(r'\^[0-9]|.', re.DOTALL) + +# Number of suggestions shown in the autocomplete hint line +MAX_HINT_SUGGESTIONS = 10 + +# Output rows scrolled per mouse wheel notch +MOUSE_WHEEL_ROWS = 3 + class CursesHandler(logging.Handler): - """Logging handler that outputs to curses window""" + """Logging handler that writes log records into the output window""" - def __init__(self, window, manager=None): + def __init__(self, manager): logging.Handler.__init__(self) - self.window = window self.manager = manager def emit(self, record): try: - if self.manager is not None and self.manager._too_small: - return - msg = self.format(record) - fs = "%s\n" - try: - print_colored(self.window, fs % msg, 0) - self.window.noutrefresh() - curses.doupdate() - except UnicodeError: - print_colored(self.window, fs % msg.encode("UTF-8"), 0) - self.window.noutrefresh() - curses.doupdate() + self.manager.print_message(self.format(record) + "\n") except (KeyboardInterrupt, SystemExit): raise except Exception: self.handleError(record) + +class ColorState: + """ + The Quake color codes (^N) currently in effect while printing a message. + ^1-^6 = color, ^7 = default color, ^8 = bold, ^9 = underline, ^0 = bold and underline off + """ + + def __init__(self): + self.color = 0 + self.bold = False + self.underline = False + + def apply(self, digit): + """Apply one color code digit ('0'-'9')""" + if digit == '8': + self.bold = True + elif digit == '9': + self.underline = True + elif digit == '0': + self.bold = False + self.underline = False + elif digit == '7': + self.color = 0 + else: + self.color = int(digit) + + def attributes(self, extra=0): + """Curses attributes for this state, combined with extra attributes""" + attributes = curses.color_pair(self.color) | extra + if self.bold: + attributes |= curses.A_BOLD + if self.underline: + attributes |= curses.A_UNDERLINE + return attributes + + def codes(self): + """Color codes that recreate this state at the start of a wrapped row""" + codes = '' + if self.color: + codes += f'^{self.color}' + if self.bold: + codes += '^8' + if self.underline: + codes += '^9' + return codes + + def print_colored(window, message, attributes=0): - """ - Print message with Quake color codes (^N) - ^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: + """Print a message at the window cursor, interpreting Quake color codes (^N)""" + state = ColorState() + for token in COLOR_TOKEN_PATTERN.findall(message): + if len(token) == 2: + state.apply(token[1]) + continue try: - window.addstr(message) + window.addch(token, state.attributes(attributes)) except curses.error: - pass + # The window is full (bottom-right corner reached) + return + + +def wrap_colored(line, width): + """ + Split one line into rows of at most `width` visible characters. + Color codes take no space and the active codes are repeated at the start + of each continuation row, so colors survive the wrap. + Example: wrap_colored('^1abcdef', 4) -> ['^1abcd', '^1ef'] + """ + rows = [] + state = ColorState() + row = '' + visible_chars = 0 + for token in COLOR_TOKEN_PATTERN.findall(line): + if len(token) == 2: + state.apply(token[1]) + row += token + continue + if visible_chars == width: + rows.append(row) + row = state.codes() + visible_chars = 0 + row += token + visible_chars += 1 + rows.append(row) + return rows + + +def safe_addstr(window, y, x, text, attributes=0): + """Write text at (y, x), truncated to the window; never raises""" + height, width = window.getmaxyx() + if y >= height or x >= width: return + try: + window.addstr(y, x, text[:width - x - 1], attributes) + except curses.error: + pass - color = 0 - bold = False - underline = False - parse_color = False - for ch in message: - val = ord(ch) - if parse_color: - if ch == '8': - bold = True - elif ch == '9': - underline = True - elif ch == '0': - bold = False - underline = False - elif ch == '7': - color = 0 - elif ord('1') <= val <= ord('6'): - color = val - ord('0') - 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(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 - elif ch == '^': - parse_color = True - else: - try: - 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 signature_hint(command, arg_position): + """Hint parts for a command signature with the current argument highlighted""" + parts = [] + for arg_text, is_current in get_signature_with_highlight(command, arg_position): + parts.append((arg_text, curses.A_REVERSE if is_current else curses.A_DIM)) + parts.append((' ', 0)) + return parts -def update_autocomplete_display(window, first_word, words, ends_with_space, player_names_provider=None): + +def suggestion_list_hint(suggestions, arg_type=None): + """One hint line listing the first suggestions, e.g. ': campgrounds bloodrun (+3 more)'""" + shown = ' '.join(suggestions[:MAX_HINT_SUGGESTIONS]) + hidden = len(suggestions) - MAX_HINT_SUGGESTIONS + more = f' (+{hidden} more)' if hidden > 0 else '' + if arg_type: + return f'<{arg_type}>: {shown}{more}' + return f'{shown}{more}' + + +def compute_autocomplete(words, ends_with_space, player_names_provider): """ - Update autocomplete display based on current input state. - Returns (suggestions, suggestion_index, original_word) tuple for Tab cycling. - - Handles three display modes: - 1. Command autocomplete (typing partial command) - 2. Signature display (command recognized, showing arguments) - 3. Argument value suggestions (typing argument values) + Work out what to suggest for the current input. + Returns (suggestions, original_word, hint_parts): + - suggestions: values cycled with Tab + - original_word: the word Tab replaces ('' means Tab appends a new word) + - hint_parts: [(text, attributes)] shown below the input line """ - suggestions = [] - suggestion_index = -1 - original_word = "" + first_word = words[0].lower() - # Check if this is a command with argument definitions if first_word in COMMAND_ARGUMENTS: - # Determine if user is typing arguments (not just the command) if len(words) == 1 and not ends_with_space: - # Just command, no space yet → show signature with first arg highlighted - sig_parts = get_signature_with_highlight(first_word, 0) - if sig_parts: - x_pos = 0 - for arg_text, is_current in sig_parts: - try: - if is_current: - window.addstr(1, x_pos, arg_text, curses.A_REVERSE) - else: - window.addstr(1, x_pos, arg_text, curses.A_DIM) - x_pos += len(arg_text) + 1 - except curses.error: - pass + # Just the command: show its signature with the first argument highlighted + return [], '', signature_hint(first_word, 0) + + if ends_with_space: + arg_position = len(words) - 1 + current_value = '' else: - # User is typing arguments - if ends_with_space: - # Starting new argument (empty so far) - arg_position = len(words) - 1 # -1 for command - current_value = '' - else: - # Typing current argument - arg_position = len(words) - 2 # -1 for command, -1 for 0-indexed - current_value = words[-1] + arg_position = len(words) - 2 + current_value = words[-1] - # Get argument suggestions - player_names = player_names_provider() if player_names_provider else None - arg_suggestions = get_argument_suggestions( - first_word, - arg_position, - current_value, - player_list=player_names - ) + player_names = player_names_provider() if player_names_provider else None + arg_suggestions = get_argument_suggestions(first_word, arg_position, current_value, player_list=player_names) + if arg_suggestions: + arg_type = COMMAND_ARGUMENTS[first_word][arg_position]['type'] + hint = suggestion_list_hint(arg_suggestions, arg_type) + return arg_suggestions, current_value, [(hint, curses.A_DIM)] - if arg_suggestions: - # Show argument value suggestions with label (limit to 10 for performance) + # No value suggestions (free text, no players known): show the signature + return [], '', signature_hint(first_word, arg_position) + + if COMMAND_SIGNATURES.get(first_word): + # Command with a signature but no argument definitions + return [], '', signature_hint(first_word, 0) + + # Not a recognized command: suggest commands matching the current word + current_word = words[-1] + if len(current_word) < 2: + return [], '', [] + suggestions = autocomplete(current_word, max_results=5) + if not suggestions: + return [], current_word, [] + return suggestions, current_word, [(' '.join(suggestions), curses.A_DIM)] + + +class InputLine: + """The command input line: text, cursor, history and autocomplete state""" + + def __init__(self, max_history, player_names_provider=None): + self.max_history = max_history + self.player_names_provider = player_names_provider + self.text = '' + self.cursor = 0 + self.history = [] + self.history_index = -1 # -1 = not browsing history + self.saved_text = '' # what was typed before browsing history + self.suggestions = [] + self.suggestion_index = -1 + self.original_word = '' + self.hint_parts = [] # [(text, attributes)] drawn below the input + + def handle_key(self, key): + """Apply one key press. Returns the submitted command on Enter, else None.""" + if key in (curses.KEY_ENTER, 10, 13): + return self._submit() + + if key == 9: # Tab + self._cycle_suggestion() + elif key == curses.KEY_UP: + self._history_previous() + elif key == curses.KEY_DOWN: + self._history_next() + elif key == curses.KEY_LEFT: + self.cursor = max(0, self.cursor - 1) + elif key == curses.KEY_RIGHT: + self.cursor = min(len(self.text), self.cursor + 1) + elif key == curses.KEY_HOME: + self.cursor = 0 + elif key == curses.KEY_END: + self.cursor = len(self.text) + elif key in (curses.KEY_BACKSPACE, 127, 8): + if self.cursor > 0: + self.text = self.text[:self.cursor - 1] + self.text[self.cursor:] + self.cursor -= 1 + self._text_changed() + elif key == curses.KEY_DC: + if self.cursor < len(self.text): + self.text = self.text[:self.cursor] + self.text[self.cursor + 1:] + self._text_changed() + elif 32 <= key <= 126: + self.text = self.text[:self.cursor] + chr(key) + self.text[self.cursor:] + self.cursor += 1 + self._text_changed() + return None + + def render(self, window): + """Draw text, autocomplete hint and cursor into the input window""" + window.erase() + height, width = window.getmaxyx() + usable = width - 1 + + # Show the part of the text around the cursor when it is wider than the window + start = max(0, self.cursor - usable) + safe_addstr(window, 0, 0, self.text[start:start + usable]) + + x = 0 + for text, attributes in self.hint_parts: + if x >= usable: + break + safe_addstr(window, 1, x, text, attributes) + x += len(text) + + window.move(0, self.cursor - start) + + def _submit(self): + command = self.text + if not command: + return None + self.history.append(command) + if len(self.history) > self.max_history: + self.history.pop(0) + self.text = '' + self.cursor = 0 + self.history_index = -1 + self.saved_text = '' + self._clear_suggestions() + return command + + def _clear_suggestions(self): + self.suggestions = [] + self.suggestion_index = -1 + self.original_word = '' + self.hint_parts = [] + + def _text_changed(self): + """Recompute autocomplete after an edit; editing leaves history browsing""" + self.history_index = -1 + words = self.text.split() + if not words: + self._clear_suggestions() + return + self.suggestions, self.original_word, self.hint_parts = compute_autocomplete( + words, self.text.endswith(' '), self.player_names_provider) + self.suggestion_index = -1 + + def _history_previous(self): + if not self.history: + return + if self.history_index == -1: + self.saved_text = self.text + self.history_index = len(self.history) + if self.history_index > 0: + self.history_index -= 1 + self._show_history_entry(self.history[self.history_index]) + + def _history_next(self): + if self.history_index == -1: + return + self.history_index += 1 + if self.history_index >= len(self.history): + self._show_history_entry(self.saved_text) + self.history_index = -1 + self.saved_text = '' + else: + self._show_history_entry(self.history[self.history_index]) + + def _show_history_entry(self, text): + self.text = text + self.cursor = len(text) + self._clear_suggestions() + + def _cycle_suggestion(self): + """Tab: put the next suggestion into the input line""" + if not self.suggestions: + return + words = self.text.split() + if not words: + return + + self.suggestion_index = (self.suggestion_index + 1) % len(self.suggestions) + selected = self.suggestions[self.suggestion_index] + + if self.original_word == '': + # The input ended with a space: append the suggestion as a new word + words.append(selected) + # From now on Tab replaces this word instead of appending another + self.original_word = selected + else: + words[-1] = selected + + self.text = ' '.join(words) + self.cursor = len(self.text) + self.hint_parts = [(self._cycle_hint(words, selected), curses.A_DIM)] + + def _cycle_hint(self, words, selected): + """Hint line while cycling: argument values with their type, or commands with signature""" + first_word = words[0].lower() + if first_word in COMMAND_ARGUMENTS and len(words) > 1: + # After ' '.join the text never ends with a space, so the last word is the argument + arg_position = len(words) - 2 + if arg_position < len(COMMAND_ARGUMENTS[first_word]): arg_type = COMMAND_ARGUMENTS[first_word][arg_position]['type'] - display_suggestions = arg_suggestions[:10] - more_indicator = f' (+{len(arg_suggestions)-10} more)' if len(arg_suggestions) > 10 else '' - match_line = f'<{arg_type}>: {" ".join(display_suggestions)}{more_indicator}' - try: - window.addstr(1, 0, match_line, curses.A_DIM) - except curses.error: - pass - suggestions = arg_suggestions # Store for Tab cycling - suggestion_index = -1 - original_word = current_value - else: - # No suggestions (freetext, player without list, etc.) → show signature - sig_parts = get_signature_with_highlight(first_word, arg_position) - if sig_parts: - x_pos = 0 - for arg_text, is_current in sig_parts: - try: - if is_current: - window.addstr(1, x_pos, arg_text, curses.A_REVERSE) - else: - window.addstr(1, x_pos, arg_text, curses.A_DIM) - x_pos += len(arg_text) + 1 - except curses.error: - pass + return suggestion_list_hint(self.suggestions, arg_type) + return suggestion_list_hint(self.suggestions) - elif first_word in COMMAND_SIGNATURES and COMMAND_SIGNATURES[first_word]: - # Command with signature but no argument definitions - sig_parts = get_signature_with_highlight(first_word, 0) - if sig_parts: - x_pos = 0 - for arg_text, is_current in sig_parts: - try: - if is_current: - window.addstr(1, x_pos, arg_text, curses.A_REVERSE) - else: - window.addstr(1, x_pos, arg_text, curses.A_DIM) - x_pos += len(arg_text) + 1 - except curses.error: - pass + hint = suggestion_list_hint(self.suggestions) + signature = COMMAND_SIGNATURES.get(selected) + if signature: + return f'{hint} → {signature}' + return hint - else: - # Not a recognized command → show command autocomplete - current_word = words[-1] - if len(current_word) >= 2: - suggestions = autocomplete(current_word, max_results=5) - suggestion_index = -1 - original_word = current_word - if suggestions: - match_line = ' '.join(suggestions) - try: - window.addstr(1, 0, match_line, curses.A_DIM) - except curses.error: - pass - return suggestions, suggestion_index, original_word +def select_server(screen, servers): + """ + Full-screen menu listing the configured servers. + servers: dict name -> host. Returns the chosen name, or None when the user quits. + Keys: Up/Down or j/k move, Enter connects, 1-9 connect directly, q or Esc quit. + """ + names = list(servers) + selected = 0 + screen.keypad(True) + screen.nodelay(False) + curses.curs_set(0) + + while True: + _draw_server_menu(screen, servers, names, selected) + try: + key = screen.getch() + except KeyboardInterrupt: + return None + + if key in (curses.KEY_UP, ord('k')): + selected = (selected - 1) % len(names) + elif key in (curses.KEY_DOWN, ord('j')): + selected = (selected + 1) % len(names) + elif key in (curses.KEY_ENTER, 10, 13): + return names[selected] + elif ord('1') <= key <= ord('9') and key - ord('1') < len(names): + return names[key - ord('1')] + elif key in (ord('q'), 27): # 27 = Escape + return None + # Any other key (including KEY_RESIZE) just redraws + + +def _draw_server_menu(screen, servers, names, selected): + screen.erase() + safe_addstr(screen, 0, 2, 'Quake Live PyCon - select a server', curses.A_BOLD) + for index, name in enumerate(names): + number = index + 1 + line = f'{number:>2}. {name:<12} {servers[name]}' + attributes = curses.A_REVERSE if index == selected else 0 + safe_addstr(screen, 2 + index, 2, line, attributes) + safe_addstr(screen, 3 + len(names), 2, + 'Up/Down or j/k: move Enter or number: connect q: quit', curses.A_DIM) + screen.refresh() class UIManager: """Manages curses windows and display""" - def __init__(self, screen, host, max_history=MAX_COMMAND_HISTORY): + def __init__(self, screen, title, max_history=MAX_COMMAND_HISTORY, player_names_provider=None): self.screen = screen - self.host = host + self.title = title 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.cursor_pos = 0 # Track cursor position in input - self.max_history = max_history - self._too_small = False + self.input_window = None + self.too_small = False + + # Output scrollback: one entry per line, color codes included + self.output_lines = deque(maxlen=OUTPUT_SCROLLBACK_LINES) + # True when the last message had no trailing newline: the next message continues that line + # (the server sends e.g. each column of a status row as its own message) + self.line_open = False + # Visual rows scrolled up from the newest output; 0 = follow new output + self.scroll_offset = 0 + + self.input_line = InputLine(max_history, player_names_provider) + self.game_state = None # last state shown in the info window self._init_curses() - if not self._create_windows(): - raise SystemExit('Terminal too small: minimum 20 rows x 80 columns required') + self._layout() + self.redraw() + + # --- setup and layout --- def _init_curses(self): - """Initialize curses settings""" - self.screen.nodelay(1) + self.screen.nodelay(True) + self.screen.keypad(True) curses.start_color() curses.use_default_colors() - curses.cbreak() curses.curs_set(1) # Show cursor in input window - self.screen.addstr(f"Quake Live PyCon: {self.host}") - self.screen.noutrefresh() + # Ask the terminal to report the mouse wheel so it scrolls the output + # (the terminal then needs Shift held for text selection) + curses.mousemask(curses.BUTTON4_PRESSED | curses.BUTTON5_PRESSED) - # Initialize color pairs + # Color pairs 1-6 on the terminal's default background for i in range(1, 7): - curses.init_pair(i, i, 0) + curses.init_pair(i, i, -1) # Swap cyan and magenta (5 and 6) - curses.init_pair(5, 6, 0) - curses.init_pair(6, 5, 0) + curses.init_pair(5, 6, -1) + curses.init_pair(6, 5, -1) - def _create_windows(self): - """Create all UI windows""" - maxy, maxx = self.screen.getmaxyx() + def _layout(self): + """Create the windows for the current terminal size, or show the too-small warning""" + rows, cols = self.screen.getmaxyx() + self.screen.erase() - # Minimum terminal size check - if maxy < 20 or maxx < 80: - return False - - # 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.noutrefresh() - - # Output window (middle - main display) - self.output_window = curses.newwin( - maxy - 17, - maxx - 4, - OUTPUT_WINDOW_Y, - 2 - ) - self.output_window.scrollok(True) - self.output_window.idlok(False) - self.output_window.idcok(False) - self.output_window.leaveok(True) - self.output_window.noutrefresh() - - # 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.scrollok(False) - self.divider_window.idlok(False) - self.divider_window.leaveok(True) - self.divider_window.noutrefresh() - - # 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.idcok(True) - self.input_window.leaveok(False) - self.input_window.noutrefresh() + if rows < MIN_ROWS or cols < MIN_COLS: + self.too_small = True + self.info_window = None + self.output_window = None + self.divider_window = None + self.input_window = None + warning = f'Terminal too small - resize to at least {MIN_ROWS}x{MIN_COLS}' + safe_addstr(self.screen, rows // 2, max(0, (cols - len(warning)) // 2), warning) + self.screen.noutrefresh() + curses.doupdate() + return + self.too_small = False + safe_addstr(self.screen, 0, 0, self.title) + safe_addstr(self.screen, rows - 2, 2, '$ ') self.screen.noutrefresh() - curses.doupdate() - return True + + panel_width = cols - 4 + output_height = rows - OUTPUT_WINDOW_Y - 3 # 3 = divider + input window + + self.info_window = curses.newwin(INFO_WINDOW_HEIGHT, panel_width, INFO_WINDOW_Y, 2) + self.info_window.leaveok(True) + + self.output_window = curses.newwin(output_height, panel_width, OUTPUT_WINDOW_Y, 2) + self.output_window.leaveok(True) + + self.divider_window = curses.newwin(1, panel_width, rows - 3, 2) + self.divider_window.leaveok(True) + + self.input_window = curses.newwin(INPUT_WINDOW_HEIGHT, cols - 6, rows - 2, 4) def handle_resize(self): - """Handle terminal resize event""" - try: - # Get new terminal dimensions - maxy, maxx = self.screen.getmaxyx() + curses.update_lines_cols() + self._layout() + self.redraw() - # Minimum size check - if maxy < 20 or maxx < 80: - if not self._too_small: - self._too_small = True - self._delete_windows() - self._draw_too_small_warning() - return False + # --- drawing --- - # Rebuild windows when returning from a too-small terminal - if self._too_small: - self._too_small = False - curses.update_lines_cols() - self.screen.clear() - self.screen.addstr(0, 0, f"Quake Live PyCon: {self.host}") - return self._create_windows() + def redraw(self): + """Redraw every window from state""" + if self.too_small: + return + self._draw_output() + self._draw_divider() + if self.game_state is not None: + self._draw_info() + self._flush() - # 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 _delete_windows(self): - """Delete all windows (terminal too small for the UI)""" - for window in (self.info_window, self.output_window, self.divider_window, self.input_window): - if window: - try: - window.erase() - window.noutrefresh() - except curses.error: - pass + def _flush(self): + """Push pending drawing to the terminal with the cursor in the input line""" + if self.too_small: + return + self.input_line.render(self.input_window) + self.input_window.noutrefresh() curses.doupdate() - self.info_window = None - self.output_window = None - self.divider_window = None - self.input_window = None - def _draw_too_small_warning(self): - """Draw centered warning on the bare screen""" + def _rows_newest_first(self, width): + """Yield the wrapped visual rows of the output, newest row first""" + for line in reversed(self.output_lines): + for row in reversed(wrap_colored(line, width)): + yield row + + def _draw_output(self): + height, width = self.output_window.getmaxyx() + wanted = height + self.scroll_offset + rows = list(itertools.islice(self._rows_newest_first(width), wanted)) + if len(rows) < wanted: + # Scrolled past the oldest line: clamp to the top + self.scroll_offset = max(0, len(rows) - height) + + visible = rows[self.scroll_offset:self.scroll_offset + height] + visible.reverse() + + self.output_window.erase() + for y, row in enumerate(visible): + self.output_window.move(y, 0) + print_colored(self.output_window, row) + self.output_window.noutrefresh() + + def _draw_divider(self): + _, width = self.divider_window.getmaxyx() + self.divider_window.erase() + self.divider_window.hline(0, 0, curses.ACS_HLINE, width) + if self.scroll_offset > 0: + marker = f' scrolled back {self.scroll_offset} rows - PgDn/End for latest ' + safe_addstr(self.divider_window, 0, max(0, width - len(marker) - 2), marker, curses.A_REVERSE) + self.divider_window.noutrefresh() + + # --- input --- + + def process_input(self): + """Read all pending keys from the terminal. Returns the commands the user submitted.""" + commands = [] + key_seen = False + while True: + key = self.screen.getch() + if key == -1: + break + key_seen = True + if key == curses.KEY_RESIZE: + self.handle_resize() + elif key == curses.KEY_PPAGE: + self.scroll_output(self._page_rows()) + elif key == curses.KEY_NPAGE: + self.scroll_output(-self._page_rows()) + elif key == curses.KEY_MOUSE: + self._handle_mouse() + else: + command = self.input_line.handle_key(key) + if command: + commands.append(command) + if key_seen: + self._flush() + return commands + + def _handle_mouse(self): + """Mouse wheel up scrolls the output back, wheel down scrolls forward""" try: - maxy, maxx = self.screen.getmaxyx() - warning = 'Terminal too small - resize to at least 20x80' - self.screen.erase() - self.screen.addstr(maxy // 2, max(0, (maxx - len(warning)) // 2), warning) - self.screen.noutrefresh() - curses.doupdate() + _, _, _, _, button_state = curses.getmouse() except curses.error: - pass + # Event already consumed or not a mouse event + return + if button_state & curses.BUTTON4_PRESSED: + self.scroll_output(MOUSE_WHEEL_ROWS) + elif button_state & curses.BUTTON5_PRESSED: + self.scroll_output(-MOUSE_WHEEL_ROWS) - def setup_input_queue(self, player_names_provider=None, max_history=None): - """Setup threaded input queue with command history and autocomplete""" - if max_history is None: - max_history = self.max_history + def _page_rows(self): + if self.output_window is None: + return 0 + height, _ = self.output_window.getmaxyx() + return max(1, height - 1) - def wait_stdin(q, window, manager): - current_input = "" - cursor_pos = 0 - manager.cursor_pos = 0 # Keep manager in sync - temp_history_index = -1 - temp_input = "" # Temp storage when navigating history - quit_confirm = False + def scroll_output(self, rows): + """Scroll the output view up (positive) or down (negative) by visual rows""" + self.scroll_offset = max(0, self.scroll_offset + rows) + if self.too_small: + return + self._draw_output() + self._draw_divider() - # Autocomplete state - suggestions = [] - suggestion_index = -1 - original_word = "" # Store original word before cycling - - while True: - try: - try: - key = window.getch() - except curses.error: - # Window may be deleted during a too-small resize - time.sleep(0.1) - continue - - if key == -1: # No input - continue - - # Handle terminal resize - if key == curses.KEY_RESIZE: - manager.handle_resize() - window = manager.input_window or window - if not manager._too_small: - # 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 - if key == ord('\t') or key == 9: - if suggestions: - # Cycle to next suggestion - suggestion_index = (suggestion_index + 1) % len(suggestions) - - # Replace or append suggestion - words = current_input.split() - if words: - # If original_word is empty, we had trailing space - append new word - # Otherwise, replace current word - if original_word == '': - words.append(suggestions[suggestion_index]) - # Update original_word so next Tab replaces instead of appending - original_word = suggestions[suggestion_index] - else: - words[-1] = suggestions[suggestion_index] - current_input = ' '.join(words) - cursor_pos = len(current_input) - manager.cursor_pos = cursor_pos - - # Update display - window.erase() - window.addstr(0, 0, current_input) - - # Determine display format - first_word = words[0].lower() - selected_value = suggestions[suggestion_index] - - # Check if we're cycling argument values or commands - if first_word in COMMAND_ARGUMENTS and len(words) > 1: - # Cycling argument values - show with label - ends_with_space = current_input.endswith(' ') - if ends_with_space: - arg_position = len(words) - 1 - else: - arg_position = len(words) - 2 - - # Bounds check to prevent index out of range - if arg_position < len(COMMAND_ARGUMENTS[first_word]): - arg_type = COMMAND_ARGUMENTS[first_word][arg_position]['type'] - # Show only first 10 suggestions for performance - display_suggestions = suggestions[:10] - more_indicator = f' (+{len(suggestions)-10} more)' if len(suggestions) > 10 else '' - display_line = f'<{arg_type}>: {" ".join(display_suggestions)}{more_indicator}' - else: - # Fallback if position out of bounds - display_line = ' '.join(suggestions[:10]) - else: - # Cycling commands - show with signature if available - display_suggestions = suggestions[:10] - match_line = ' '.join(display_suggestions) - if selected_value in COMMAND_SIGNATURES: - signature = COMMAND_SIGNATURES[selected_value] - if signature: - display_line = f"{match_line} → {signature}" - else: - display_line = match_line - else: - display_line = match_line - - try: - window.addstr(1, 0, display_line, curses.A_DIM) - except curses.error: - pass - - window.move(0, cursor_pos) - window.noutrefresh() - curses.doupdate() # Actually push the refresh to screen - 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) > max_history: - manager.command_history.pop(0) - - q.put(current_input) - current_input = "" - cursor_pos = 0 - manager.cursor_pos = cursor_pos - temp_history_index = -1 - temp_input = "" - suggestions = [] - suggestion_index = -1 - original_word = "" - window.erase() - window.noutrefresh() - - # 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) - manager.cursor_pos = cursor_pos - suggestions = [] - suggestion_index = -1 - original_word = "" - window.erase() - window.addstr(0, 0, current_input) - window.noutrefresh() - - # 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) - manager.cursor_pos = cursor_pos - suggestions = [] - suggestion_index = -1 - original_word = "" - window.erase() - window.addstr(0, 0, current_input) - window.noutrefresh() - - # Arrow LEFT - move cursor left - elif key == curses.KEY_LEFT: - if cursor_pos > 0: - cursor_pos -= 1 - manager.cursor_pos = cursor_pos - window.move(0, cursor_pos) - window.noutrefresh() - - # Arrow RIGHT - move cursor right - elif key == curses.KEY_RIGHT: - if cursor_pos < len(current_input): - cursor_pos += 1 - manager.cursor_pos = cursor_pos - window.move(0, cursor_pos) - window.noutrefresh() - - # 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 - manager.cursor_pos = cursor_pos - temp_history_index = -1 # Exit history mode - - window.erase() - window.addstr(0, 0, current_input) - - # Parse input and update autocomplete display - words = current_input.split() - ends_with_space = current_input.endswith(' ') - - if words and not manager._too_small: - first_word = words[0].lower() - suggestions, suggestion_index, original_word = update_autocomplete_display( - window, first_word, words, ends_with_space, player_names_provider - ) - - window.move(0, cursor_pos) - window.noutrefresh() - curses.doupdate() # Immediate screen update - - # Regular character - elif 32 <= key <= 126: - char = chr(key) - current_input = current_input[:cursor_pos] + char + current_input[cursor_pos:] - cursor_pos += 1 - manager.cursor_pos = cursor_pos - temp_history_index = -1 # Exit history mode - - window.erase() - window.addstr(0, 0, current_input) - - # Parse input and update autocomplete display - words = current_input.split() - ends_with_space = current_input.endswith(' ') - - if words and not manager._too_small: - first_word = words[0].lower() - suggestions, suggestion_index, original_word = update_autocomplete_display( - window, first_word, words, ends_with_space, player_names_provider - ) - - window.move(0, cursor_pos) - window.noutrefresh() - curses.doupdate() # Immediate screen update - - except Exception as e: - logger.error(f'Input error: {e}') - # Log but continue - input thread should stay alive - - window.move(0, cursor_pos) - curses.doupdate() - - 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 + # --- output --- def setup_logging(self): - """Setup logging handler for output window""" - handler = CursesHandler(self.output_window, self) + """Create the logging handler that writes into the output window""" + handler = CursesHandler(self) 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""" - if self._too_small: + def print_message(self, message): + """ + Append a message to the output. + Example: 'abc' then 'def\\n' gives one line 'abcdef'; 'x\\ny\\n' gives two lines. + """ + pieces = message.split('\n') + ends_with_newline = pieces[-1] == '' + if ends_with_newline: + pieces.pop() + + new_lines = [] + for piece in pieces: + if self.line_open: + self.output_lines[-1] += piece + self.line_open = False + else: + self.output_lines.append(piece) + new_lines.append(piece) + self.line_open = not ends_with_newline + + if self.too_small: return - print_colored(self.output_window, message, attributes) - self.output_window.noutrefresh() - # Restore cursor to input window at current position - self.input_window.move(0, self.cursor_pos) - self.input_window.noutrefresh() - curses.doupdate() + + if self.scroll_offset > 0: + # Keep the view still while the user reads old output + _, width = self.output_window.getmaxyx() + for line in new_lines: + self.scroll_offset += len(wrap_colored(line, width)) + + self._draw_output() + self._draw_divider() + self._flush() def update_server_info(self, game_state): """Update server info window""" - if self._too_small: + self.game_state = game_state + if self.too_small: return + self._draw_info() + self._flush() + + def _draw_info(self): + game_state = self.game_state self.info_window.erase() max_y, max_x = self.info_window.getmaxyx() @@ -796,7 +800,6 @@ class UIManager: red = f"{red_score:>3} {'^8^1X^7^0 ' if red_dead else ''}{red_name}" if red_name else '' blue = f"{blue_score:>3} {'^8^1X^7^0 ' if blue_dead else ''}{blue_name}" if blue_name else '' - from .formatter import strip_color_codes red_clean = strip_color_codes(red) blue_clean = strip_color_codes(blue) @@ -840,7 +843,6 @@ class UIManager: col1 = f"{col1_score:>3} {'^8^1X^7^0 ' if col1_dead else ''}{col1_name}" if col1_name else '' col2 = f"{col2_score:>3} {'^8^1X^7^0 ' if col2_dead else ''}{col2_name}" if col2_name else '' - from .formatter import strip_color_codes col1_clean = strip_color_codes(col1) col2_clean = strip_color_codes(col2) @@ -871,7 +873,3 @@ class UIManager: print_colored(self.info_window, separator, 0) 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() diff --git a/main.py b/main.py index 36bdb34..2d7943c 100644 --- a/main.py +++ b/main.py @@ -14,14 +14,13 @@ import zmq import signal import sys import os -import threading from lib.constants import VERSION, DEFAULT_HOST, POLL_TIMEOUT, QUIT_CONFIRM_TIMEOUT, RESPAWN_DELAY, MAX_COMMAND_HISTORY from lib.state import GameState from lib.network import RconConnection, StatsConnection from lib.parser import EventParser 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 # Pre-compiled regex patterns @@ -45,36 +44,42 @@ all_json_logger.setLevel(logging.DEBUG) unknown_json_logger = logging.getLogger('unknown_json') 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_lock = threading.Lock() # Configurable timeouts (set from config in __main__) _QUIT_CONFIRM_TIMEOUT = QUIT_CONFIRM_TIMEOUT _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 def signal_handler(sig, frame): - """Handle Ctrl+C with confirmation""" - global quit_confirm_time, shutdown_requested + """Record Ctrl+C; force exit if the graceful shutdown already started""" + 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() - - with quit_confirm_lock: - if shutdown_requested: - # Third Ctrl-C: last-resort forced exit - curses.endwin() - os._exit(0) - 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 + if quit_confirm_time is None or (current_time - quit_confirm_time) > _QUIT_CONFIRM_TIMEOUT: + logger.warning(f"^1^8Press Ctrl-C again within {_QUIT_CONFIRM_TIMEOUT:.0f} seconds to quit^0") + quit_confirm_time = current_time + else: + logger.warning("^1^8Quittin'^0") + shutdown_requested = True def parse_cvar_response(message, game_state, ui): """ @@ -130,10 +135,9 @@ def handle_stats_connection(message): return stats_port, stats_password -def handle_user_input(input_queue, rcon, ui): - """Process user command input""" - while not input_queue.empty(): - command = input_queue.get() +def handle_user_input(ui, rcon): + """Read pending keys and send the commands the user submitted""" + for command in ui.process_input(): logger.info(f'Sending command: {repr(command.strip())}') # Display command with timestamp @@ -310,7 +314,11 @@ def parse_player_events(message, game_state, ui): return False 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 signal.signal(signal.SIGINT, signal_handler) @@ -331,8 +339,9 @@ def main_loop(screen, args): unknown_json_logger.propagate = False # Initialize components - ui = UIManager(screen, args.host, args.max_history) 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 log_handler = ui.setup_logging() @@ -345,9 +354,6 @@ def main_loop(screen, args): lib_logger.addHandler(log_handler) 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 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") @@ -357,6 +363,12 @@ def main_loop(screen, args): rcon = RconConnection(args.host, args.password, args.identity) 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_port = None stats_password = None @@ -381,8 +393,10 @@ def main_loop(screen, args): # Main event loop with resource cleanup try: while not shutdown_requested: - # Poll RCON socket - event = rcon.poll(POLL_TIMEOUT) + # Wait for RCON data, a connection event, a stats event or a key press + ready = dict(poller.poll(POLL_TIMEOUT)) + + handle_ctrl_c() # Check monitor for connection events monitor_event = rcon.check_monitor() @@ -398,13 +412,14 @@ def main_loop(screen, args): timestamp = time.strftime('%H:%M:%S') ui.print_message(f"^3[^7{timestamp}^3] ^8^1Disconnected from server - waiting for reconnect...^0^7\n") if stats_conn is not None: + poller.unregister(stats_conn.socket) stats_conn.close() stats_conn = None stats_port = None stats_password = None # Handle user input - handle_user_input(input_queue, rcon, ui) + handle_user_input(ui, rcon) # Poll stats stream if connected 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) # Process RCON messages - if event > 0: + if rcon.socket in ready: logger.debug('Socket has data available') msg_count = 0 @@ -478,6 +493,7 @@ def main_loop(screen, args): stats_conn = StatsConnection(host_ip, stats_port, stats_password) stats_conn.connect() + poller.register(stats_conn.socket, zmq.POLLIN) ui.print_message("Stats stream connected - ready for game events\n") @@ -594,6 +610,14 @@ if __name__ == '__main__': print(f' {name:<12} {host}') 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 if args.server: host, password = config.get_server(args.server) @@ -603,10 +627,12 @@ if __name__ == '__main__': sys.exit(1) args.host = args.host or host args.password = args.password or password + args.title = f'Quake Live PyCon: {args.server} ({args.host})' else: args.host = args.host or config.get_host() or DEFAULT_HOST if not args.host.startswith('tcp://'): args.host = f'tcp://{args.host}' args.password = args.password or config.get_password() + args.title = f'Quake Live PyCon: {args.host}' curses.wrapper(main_loop, args) diff --git a/qlpycon.conf.example b/qlpycon.conf.example index 0a1b798..b8ff505 100644 --- a/qlpycon.conf.example +++ b/qlpycon.conf.example @@ -1,6 +1,7 @@ # qlpycon.conf # Edit this file as needed. # +# Pick from a menu: qlpycon # Connect by server name: qlpycon ffa # Connect directly: qlpycon --host tcp://1.2.3.4:28960 --password secret # List servers: qlpycon --list