Learn cvar and command names from the server for autocomplete

Send cvarlist and cmdlist on connect and merge the answer into the
autocomplete candidates. Number the server menu from 0, keep suggesting
sibling commands while the command name is typed, mask the password in
the startup line, ignore venv/, fix README claims.
This commit is contained in:
pfl
2026-09-16 18:25:48 +02:00
parent db123993c0
commit 3d1dc19d9b
6 changed files with 227 additions and 60 deletions
+28 -21
View File
@@ -167,21 +167,20 @@ def suggestion_list_hint(suggestions, arg_type=None):
return f'{shown}{more}'
def compute_autocomplete(words, ends_with_space, player_names_provider):
def compute_autocomplete(words, ends_with_space, player_names_provider, learned_names_provider=None):
"""
Work out what to suggest for the current input.
player_names_provider / learned_names_provider: callables returning the
current player names and the cvar/command names learned from the server.
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
"""
first_word = words[0].lower()
typing_command_name = len(words) == 1 and not ends_with_space
if first_word in COMMAND_ARGUMENTS:
if len(words) == 1 and not ends_with_space:
# Just the command: show its signature with the first argument highlighted
return [], '', signature_hint(first_word, 0)
if first_word in COMMAND_ARGUMENTS and not typing_command_name:
if ends_with_space:
arg_position = len(words) - 1
current_value = ''
@@ -199,26 +198,34 @@ def compute_autocomplete(words, ends_with_space, player_names_provider):
# No value suggestions (free text, no players known): show the signature
return [], '', signature_hint(first_word, arg_position)
if COMMAND_SIGNATURES.get(first_word):
if COMMAND_SIGNATURES.get(first_word) and not typing_command_name:
# Command with a signature but no argument definitions
return [], '', signature_hint(first_word, 0)
# Not a recognized command: suggest commands matching the current word
# Still typing the command name, or an unknown command: suggest names matching
# the current word. A complete name like `map` stays here on purpose because it
# can be the prefix of others (map_restart, maplist); its signature is shown too.
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)]
learned_names = learned_names_provider() if learned_names_provider else ()
suggestions = autocomplete(current_word, learned_names, max_results=5)
hint_parts = []
if suggestions:
hint_parts.append((' '.join(suggestions), curses.A_DIM))
if typing_command_name and COMMAND_SIGNATURES.get(first_word):
hint_parts.append((' ', 0))
hint_parts.extend(signature_hint(first_word, 0))
return suggestions, current_word, hint_parts
class InputLine:
"""The command input line: text, cursor, history and autocomplete state"""
def __init__(self, max_history, player_names_provider=None):
def __init__(self, max_history, player_names_provider=None, learned_names_provider=None):
self.max_history = max_history
self.player_names_provider = player_names_provider
self.learned_names_provider = learned_names_provider
self.text = ''
self.cursor = 0
self.history = []
@@ -310,7 +317,7 @@ class InputLine:
self._clear_suggestions()
return
self.suggestions, self.original_word, self.hint_parts = compute_autocomplete(
words, self.text.endswith(' '), self.player_names_provider)
words, self.text.endswith(' '), self.player_names_provider, self.learned_names_provider)
self.suggestion_index = -1
def _history_previous(self):
@@ -384,7 +391,7 @@ 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.
Keys: Up/Down or j/k move, Enter connects, 0-9 connect directly, q or Esc quit.
"""
names = list(servers)
selected = 0
@@ -405,8 +412,8 @@ def select_server(screen, servers):
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 ord('0') <= key <= ord('9') and key - ord('0') < len(names):
return names[key - ord('0')]
elif key in (ord('q'), 27): # 27 = Escape
return None
# Any other key (including KEY_RESIZE) just redraws
@@ -416,8 +423,7 @@ 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]}'
line = f'{index:>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,
@@ -428,7 +434,8 @@ def _draw_server_menu(screen, servers, names, selected):
class UIManager:
"""Manages curses windows and display"""
def __init__(self, screen, title, max_history=MAX_COMMAND_HISTORY, player_names_provider=None):
def __init__(self, screen, title, max_history=MAX_COMMAND_HISTORY,
player_names_provider=None, learned_names_provider=None):
self.screen = screen
self.title = title
self.info_window = None
@@ -445,7 +452,7 @@ class UIManager:
# 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.input_line = InputLine(max_history, player_names_provider, learned_names_provider)
self.game_state = None # last state shown in the info window
self._init_curses()