Handle undersized terminals and wire autocomplete/history options
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <[email protected]>
This commit is contained in:
@@ -10,7 +10,7 @@ import threading
|
|||||||
import queue
|
import queue
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from .constants import COLOR_PAIRS, INFO_WINDOW_HEIGHT, INFO_WINDOW_Y, OUTPUT_WINDOW_Y, INPUT_WINDOW_HEIGHT, TEAM_MODES, MAX_COMMAND_HISTORY
|
from .constants import 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
|
||||||
|
|
||||||
logger = logging.getLogger('ui')
|
logger = logging.getLogger('ui')
|
||||||
@@ -19,12 +19,15 @@ logger = logging.getLogger('ui')
|
|||||||
class CursesHandler(logging.Handler):
|
class CursesHandler(logging.Handler):
|
||||||
"""Logging handler that outputs to curses window"""
|
"""Logging handler that outputs to curses window"""
|
||||||
|
|
||||||
def __init__(self, window):
|
def __init__(self, window, manager=None):
|
||||||
logging.Handler.__init__(self)
|
logging.Handler.__init__(self)
|
||||||
self.window = window
|
self.window = window
|
||||||
|
self.manager = manager
|
||||||
|
|
||||||
def emit(self, record):
|
def emit(self, record):
|
||||||
try:
|
try:
|
||||||
|
if self.manager is not None and self.manager._too_small:
|
||||||
|
return
|
||||||
msg = self.format(record)
|
msg = self.format(record)
|
||||||
fs = "%s\n"
|
fs = "%s\n"
|
||||||
try:
|
try:
|
||||||
@@ -86,7 +89,7 @@ def print_colored(window, message, attributes=0):
|
|||||||
except curses.error:
|
except curses.error:
|
||||||
return
|
return
|
||||||
|
|
||||||
def update_autocomplete_display(window, current_input, first_word, words, ends_with_space):
|
def update_autocomplete_display(window, first_word, words, ends_with_space, player_names_provider=None):
|
||||||
"""
|
"""
|
||||||
Update autocomplete display based on current input state.
|
Update autocomplete display based on current input state.
|
||||||
Returns (suggestions, suggestion_index, original_word) tuple for Tab cycling.
|
Returns (suggestions, suggestion_index, original_word) tuple for Tab cycling.
|
||||||
@@ -129,11 +132,12 @@ def update_autocomplete_display(window, current_input, first_word, words, ends_w
|
|||||||
current_value = words[-1]
|
current_value = words[-1]
|
||||||
|
|
||||||
# Get argument suggestions
|
# Get argument suggestions
|
||||||
|
player_names = player_names_provider() if player_names_provider else None
|
||||||
arg_suggestions = get_argument_suggestions(
|
arg_suggestions = get_argument_suggestions(
|
||||||
first_word,
|
first_word,
|
||||||
arg_position,
|
arg_position,
|
||||||
current_value,
|
current_value,
|
||||||
player_list=None # TODO: pass player list from game_state
|
player_list=player_names
|
||||||
)
|
)
|
||||||
|
|
||||||
if arg_suggestions:
|
if arg_suggestions:
|
||||||
@@ -199,7 +203,7 @@ def update_autocomplete_display(window, current_input, first_word, words, ends_w
|
|||||||
class UIManager:
|
class UIManager:
|
||||||
"""Manages curses windows and display"""
|
"""Manages curses windows and display"""
|
||||||
|
|
||||||
def __init__(self, screen, host):
|
def __init__(self, screen, host, max_history=MAX_COMMAND_HISTORY):
|
||||||
self.screen = screen
|
self.screen = screen
|
||||||
self.host = host
|
self.host = host
|
||||||
self.info_window = None
|
self.info_window = None
|
||||||
@@ -210,14 +214,15 @@ class UIManager:
|
|||||||
self.command_history = []
|
self.command_history = []
|
||||||
self.history_index = -1
|
self.history_index = -1
|
||||||
self.cursor_pos = 0 # Track cursor position in input
|
self.cursor_pos = 0 # Track cursor position in input
|
||||||
|
self.max_history = max_history
|
||||||
|
self._too_small = False
|
||||||
|
|
||||||
self._init_curses()
|
self._init_curses()
|
||||||
self._create_windows()
|
if not self._create_windows():
|
||||||
|
raise SystemExit('Terminal too small: minimum 20 rows x 80 columns required')
|
||||||
|
|
||||||
def _init_curses(self):
|
def _init_curses(self):
|
||||||
"""Initialize curses settings"""
|
"""Initialize curses settings"""
|
||||||
curses.endwin()
|
|
||||||
curses.initscr()
|
|
||||||
self.screen.nodelay(1)
|
self.screen.nodelay(1)
|
||||||
curses.start_color()
|
curses.start_color()
|
||||||
curses.use_default_colors()
|
curses.use_default_colors()
|
||||||
@@ -308,8 +313,20 @@ class UIManager:
|
|||||||
|
|
||||||
# Minimum size check
|
# Minimum size check
|
||||||
if maxy < 20 or maxx < 80:
|
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
|
return False
|
||||||
|
|
||||||
|
# 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()
|
||||||
|
|
||||||
# Update screen
|
# Update screen
|
||||||
curses.update_lines_cols()
|
curses.update_lines_cols()
|
||||||
self.screen.clear()
|
self.screen.clear()
|
||||||
@@ -346,8 +363,38 @@ class UIManager:
|
|||||||
except curses.error:
|
except curses.error:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def setup_input_queue(self):
|
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
|
||||||
|
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"""
|
||||||
|
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()
|
||||||
|
except curses.error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def setup_input_queue(self, player_names_provider=None, max_history=None):
|
||||||
"""Setup threaded input queue with command history and autocomplete"""
|
"""Setup threaded input queue with command history and autocomplete"""
|
||||||
|
if max_history is None:
|
||||||
|
max_history = self.max_history
|
||||||
|
|
||||||
def wait_stdin(q, window, manager):
|
def wait_stdin(q, window, manager):
|
||||||
current_input = ""
|
current_input = ""
|
||||||
cursor_pos = 0
|
cursor_pos = 0
|
||||||
@@ -363,7 +410,12 @@ class UIManager:
|
|||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
key = window.getch()
|
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
|
if key == -1: # No input
|
||||||
continue
|
continue
|
||||||
@@ -371,12 +423,14 @@ class UIManager:
|
|||||||
# Handle terminal resize
|
# Handle terminal resize
|
||||||
if key == curses.KEY_RESIZE:
|
if key == curses.KEY_RESIZE:
|
||||||
manager.handle_resize()
|
manager.handle_resize()
|
||||||
# Redraw input
|
window = manager.input_window or window
|
||||||
window.erase()
|
if not manager._too_small:
|
||||||
window.addstr(0, 0, current_input)
|
# Redraw input
|
||||||
window.move(0, cursor_pos)
|
window.erase()
|
||||||
window.noutrefresh()
|
window.addstr(0, 0, current_input)
|
||||||
curses.doupdate()
|
window.move(0, cursor_pos)
|
||||||
|
window.noutrefresh()
|
||||||
|
curses.doupdate()
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Tab key - cycle through suggestions
|
# Tab key - cycle through suggestions
|
||||||
@@ -455,7 +509,7 @@ class UIManager:
|
|||||||
if len(current_input) > 0:
|
if len(current_input) > 0:
|
||||||
# Add to history
|
# Add to history
|
||||||
manager.command_history.append(current_input)
|
manager.command_history.append(current_input)
|
||||||
if len(manager.command_history) > MAX_COMMAND_HISTORY:
|
if len(manager.command_history) > max_history:
|
||||||
manager.command_history.pop(0)
|
manager.command_history.pop(0)
|
||||||
|
|
||||||
q.put(current_input)
|
q.put(current_input)
|
||||||
@@ -542,10 +596,10 @@ class UIManager:
|
|||||||
words = current_input.split()
|
words = current_input.split()
|
||||||
ends_with_space = current_input.endswith(' ')
|
ends_with_space = current_input.endswith(' ')
|
||||||
|
|
||||||
if words:
|
if words and not manager._too_small:
|
||||||
first_word = words[0].lower()
|
first_word = words[0].lower()
|
||||||
suggestions, suggestion_index, original_word = update_autocomplete_display(
|
suggestions, suggestion_index, original_word = update_autocomplete_display(
|
||||||
window, current_input, first_word, words, ends_with_space
|
window, first_word, words, ends_with_space, player_names_provider
|
||||||
)
|
)
|
||||||
|
|
||||||
window.move(0, cursor_pos)
|
window.move(0, cursor_pos)
|
||||||
@@ -567,10 +621,10 @@ class UIManager:
|
|||||||
words = current_input.split()
|
words = current_input.split()
|
||||||
ends_with_space = current_input.endswith(' ')
|
ends_with_space = current_input.endswith(' ')
|
||||||
|
|
||||||
if words:
|
if words and not manager._too_small:
|
||||||
first_word = words[0].lower()
|
first_word = words[0].lower()
|
||||||
suggestions, suggestion_index, original_word = update_autocomplete_display(
|
suggestions, suggestion_index, original_word = update_autocomplete_display(
|
||||||
window, current_input, first_word, words, ends_with_space
|
window, first_word, words, ends_with_space, player_names_provider
|
||||||
)
|
)
|
||||||
|
|
||||||
window.move(0, cursor_pos)
|
window.move(0, cursor_pos)
|
||||||
@@ -593,13 +647,15 @@ class UIManager:
|
|||||||
|
|
||||||
def setup_logging(self):
|
def setup_logging(self):
|
||||||
"""Setup logging handler for output window"""
|
"""Setup logging handler for output window"""
|
||||||
handler = CursesHandler(self.output_window)
|
handler = CursesHandler(self.output_window, self)
|
||||||
formatter = logging.Formatter('%(asctime)-8s|%(name)-12s|%(levelname)-6s|%(message)-s', '%H:%M:%S')
|
formatter = logging.Formatter('%(asctime)-8s|%(name)-12s|%(levelname)-6s|%(message)-s', '%H:%M:%S')
|
||||||
handler.setFormatter(formatter)
|
handler.setFormatter(formatter)
|
||||||
return handler
|
return handler
|
||||||
|
|
||||||
def print_message(self, message, attributes=0):
|
def print_message(self, message, attributes=0):
|
||||||
"""Print formatted message to output window"""
|
"""Print formatted message to output window"""
|
||||||
|
if self._too_small:
|
||||||
|
return
|
||||||
print_colored(self.output_window, message, attributes)
|
print_colored(self.output_window, message, attributes)
|
||||||
self.output_window.noutrefresh()
|
self.output_window.noutrefresh()
|
||||||
# Restore cursor to input window at current position
|
# Restore cursor to input window at current position
|
||||||
@@ -609,6 +665,8 @@ class UIManager:
|
|||||||
|
|
||||||
def update_server_info(self, game_state):
|
def update_server_info(self, game_state):
|
||||||
"""Update server info window"""
|
"""Update server info window"""
|
||||||
|
if self._too_small:
|
||||||
|
return
|
||||||
self.info_window.erase()
|
self.info_window.erase()
|
||||||
|
|
||||||
max_y, max_x = self.info_window.getmaxyx()
|
max_y, max_x = self.info_window.getmaxyx()
|
||||||
@@ -680,7 +738,10 @@ class UIManager:
|
|||||||
blue_total = 0
|
blue_total = 0
|
||||||
for player_name, player_data in server_info.players.items():
|
for player_name, player_data in server_info.players.items():
|
||||||
team = game_state.player_tracker.get_team(player_name)
|
team = game_state.player_tracker.get_team(player_name)
|
||||||
score = int(player_data.get('score', 0))
|
try:
|
||||||
|
score = int(player_data.get('score', 0))
|
||||||
|
except ValueError:
|
||||||
|
score = 0
|
||||||
|
|
||||||
if team == 'RED':
|
if team == 'RED':
|
||||||
red_total += score
|
red_total += score
|
||||||
@@ -698,11 +759,17 @@ class UIManager:
|
|||||||
spec_players = []
|
spec_players = []
|
||||||
|
|
||||||
for player_name in teams['RED']:
|
for player_name in teams['RED']:
|
||||||
score = int(server_info.players.get(player_name, {}).get('score', 0))
|
try:
|
||||||
|
score = int(server_info.players.get(player_name, {}).get('score', 0))
|
||||||
|
except ValueError:
|
||||||
|
score = 0
|
||||||
red_players_with_scores.append((player_name, score))
|
red_players_with_scores.append((player_name, score))
|
||||||
|
|
||||||
for player_name in teams['BLUE']:
|
for player_name in teams['BLUE']:
|
||||||
score = int(server_info.players.get(player_name, {}).get('score', 0))
|
try:
|
||||||
|
score = int(server_info.players.get(player_name, {}).get('score', 0))
|
||||||
|
except ValueError:
|
||||||
|
score = 0
|
||||||
blue_players_with_scores.append((player_name, score))
|
blue_players_with_scores.append((player_name, score))
|
||||||
|
|
||||||
# Sort by score descending
|
# Sort by score descending
|
||||||
@@ -743,7 +810,10 @@ class UIManager:
|
|||||||
free_players = teams['FREE']
|
free_players = teams['FREE']
|
||||||
free_players_with_scores = []
|
free_players_with_scores = []
|
||||||
for player_name in free_players:
|
for player_name in free_players:
|
||||||
score = int(server_info.players.get(player_name, {}).get('score', 0))
|
try:
|
||||||
|
score = int(server_info.players.get(player_name, {}).get('score', 0))
|
||||||
|
except ValueError:
|
||||||
|
score = 0
|
||||||
free_players_with_scores.append((player_name, score))
|
free_players_with_scores.append((player_name, score))
|
||||||
|
|
||||||
# Sort by score descending
|
# Sort by score descending
|
||||||
@@ -797,7 +867,7 @@ class UIManager:
|
|||||||
pass
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user