Files
ProgressQuest-cli/pqcli/ui/curses/__init__.py
T
Dash 9155ef418b misc: fix all ty diagnostics
ty reported 20 diagnostics, and 0.0.89 adds a few more; this clears
them all without suppressions.

- The quest monster is held in a local before being stored on the
  quest book, so its name no longer reads from an Optional.
- PgUp/PgDn narrow the focused window with isinstance against a
  runtime-checkable PageScrollable protocol instead of hasattr.
- The ^t cheat used to monkeypatch SignalMixin.emit. SignalMixin now
  has a muted() context manager that emit() checks, and it restores
  emission even if a tick raises.
- GameView keeps its children as GameWindow, a protocol declaring
  stop() and sync(), so the cheat's resync type-checks.
- curses.ascii is imported explicitly where it is used.
- ProgressBarWindow is now Focusable itself, instead of calling
  Focusable.focus_standout() on a class that is not Focusable. Its
  four subclasses drop the duplicate Focusable base; the MRO and
  initialisation order are unchanged.
- MainMenu narrows the Optional pad before colouring the logo.
- The tests that record tasks assert player.task is set.
- Context managers are annotated as Generator rather than Iterator,
  which ty flags as deprecated for @contextmanager.
- The redundant cast() around getch() is gone.
- The frozen GameData test assigns through setattr(), since the
  direct assignment is an error by design.

Bug fixes:

- ListBox.select(None) crashed on `None < 0`; it now clears the
  selection, as DataTable.select(None) already does.
- ListBox.get() caught ValueError, which indexing never raises; it
  now catches IndexError, so an out-of-range index returns None as
  intended.
2026-10-17 15:55:58 +02:00

217 lines
6.7 KiB
Python

import argparse
import curses
import os
import signal
import time
from typing import Any
from pqcli import lingo
from pqcli.config import Class, Race
from pqcli.game_data import GameData
from pqcli.mechanic import Player, Stats, create_player
from pqcli.random import RngState
from pqcli.roster import Roster
from pqcli.ui.base import BaseUserInterface
from pqcli.ui.curses.colors import (
COLOR_FOCUSED,
COLOR_HIGHLIGHT,
COLOR_LOGO,
COLOR_LOGO_ALT,
COLOR_PROGRESSBAR,
COLOR_SCROLLBAR_THUMB,
COLOR_SCROLLBAR_TRACK,
has_colors,
set_colors,
)
from pqcli.ui.curses.views import (
BaseView,
ChooseCharacterClassView,
ChooseCharacterNameView,
ChooseCharacterRaceView,
ChooseCharacterStatsView,
ChooseCharacterView,
ConfirmView,
GameView,
RosterView,
)
class StopMainLoop(Exception):
pass
class CursesUserInterface(BaseUserInterface):
def __init__(
self,
roster: Roster,
player: Player,
args: argparse.Namespace,
data: GameData,
) -> None:
super().__init__(roster, player, args, data)
os.environ.setdefault("ESCDELAY", "25")
self._screen = curses.initscr()
self._screen.nodelay(True)
self._screen.keypad(True)
curses.noecho()
curses.curs_set(0)
set_colors(args.colors)
if has_colors():
curses.start_color()
curses.use_default_colors()
curses.init_pair(COLOR_LOGO, -1, -1)
curses.init_pair(COLOR_LOGO_ALT, -1, 9)
curses.init_pair(COLOR_FOCUSED, -1, 8)
curses.init_pair(COLOR_SCROLLBAR_THUMB, -1, 7)
curses.init_pair(COLOR_SCROLLBAR_TRACK, -1, 0)
curses.init_pair(COLOR_PROGRESSBAR, 0, 9)
curses.init_pair(COLOR_HIGHLIGHT, 3, -1)
def signal_handler(sig: Any, frame: Any) -> None:
if self.args.use_saves:
self.roster.save()
exit(0)
signal.signal(signal.SIGINT, signal_handler)
self._roster_view = RosterView(self._screen)
self._roster_view.on_create += self._switch_to_create_char_name_view
self._roster_view.on_delete += self._switch_to_delete_char_view
self._roster_view.on_play += self._switch_to_play_view
self._roster_view.on_quit += self._quit
self._view: BaseView = self._roster_view
self._view.start()
def run(self) -> None:
try:
if self.player:
self._switch_to_game_view(self.player)
else:
self._switch_to_roster_view()
while True:
key = self._screen.getch()
if key == curses.ERR:
time.sleep(0.1)
self._view.idle()
continue
if key == curses.KEY_RESIZE:
self._view.stop()
self._view.start()
else:
try:
self._view.keypress(key)
except StopMainLoop:
break
finally:
curses.endwin()
curses.curs_set(1)
def _switch_to_roster_view(self) -> None:
self._switch_view(self._roster_view)
def _switch_to_create_char_name_view(
self, player_name: str | None = None
) -> None:
view = ChooseCharacterNameView(self._screen, player_name)
view.on_cancel += self._switch_to_roster_view
view.on_confirm += self._switch_to_create_char_race_view
self._switch_view(view)
def _switch_to_create_char_race_view(
self, player_name: str, race: Race | None = None
) -> None:
view = ChooseCharacterRaceView(self._screen, self.data.races, race)
view.on_cancel += lambda: self._switch_to_create_char_name_view(
player_name
)
view.on_confirm += lambda race: self._switch_to_create_char_class_view(
player_name, race
)
self._switch_view(view)
def _switch_to_create_char_class_view(
self, player_name: str, race: Race, class_: Class | None = None
) -> None:
view = ChooseCharacterClassView(
self._screen, self.data.classes, class_
)
view.on_cancel += lambda: self._switch_to_create_char_race_view(
player_name, race
)
view.on_confirm += lambda class_: (
self._switch_to_create_char_stats_view(player_name, race, class_)
)
self._switch_view(view)
def _switch_to_create_char_stats_view(
self, player_name: str, race: Race, class_: Class
) -> None:
view = ChooseCharacterStatsView(self._screen)
view.on_cancel += lambda: self._switch_to_create_char_class_view(
player_name, race, class_
)
view.on_confirm += lambda stats, seed: self._create_character(
player_name, race, class_, stats, seed
)
self._switch_view(view)
def _switch_to_delete_char_view(self) -> None:
view = ChooseCharacterView(
self._screen, self.roster, "Choose character to delete"
)
view.on_cancel += self._switch_to_roster_view
view.on_confirm += self._switch_to_confirm_delete_char_view
self._switch_view(view)
def _switch_to_confirm_delete_char_view(self, player: Player) -> None:
view = ConfirmView(self._screen, lingo.terminate_message(player.name))
view.on_cancel += self._switch_to_delete_char_view
view.on_confirm += lambda: self._delete_character(player)
self._switch_view(view)
def _switch_to_play_view(self) -> None:
view = ChooseCharacterView(
self._screen, self.roster, "Choose character to play"
)
view.on_cancel += self._switch_to_roster_view
view.on_confirm += self._switch_to_game_view
self._switch_view(view)
def _switch_to_game_view(self, player: Player) -> None:
view = GameView(
self._screen, self.roster, player, self.args, self.data
)
view.on_exit += self._switch_to_roster_view
self._switch_view(view)
def _switch_view(self, view: BaseView) -> None:
self._view.stop()
self._view = view
self._view.start()
def _create_character(
self,
name: str,
race: Race,
class_: Class,
stats: Stats,
seed: RngState,
) -> None:
player = create_player(name, race, class_, stats, seed)
self.roster.players.append(player)
if self.args.use_saves:
self.roster.save()
self._switch_to_game_view(player)
def _delete_character(self, player: Player) -> None:
self.roster.players.remove(player)
self._switch_to_roster_view()
def _quit(self) -> None:
raise StopMainLoop