diff --git a/pqcli/__main__.py b/pqcli/__main__.py index dc966bc..169b10f 100644 --- a/pqcli/__main__.py +++ b/pqcli/__main__.py @@ -5,6 +5,7 @@ import xdg from pqcli.roster import Roster from pqcli.ui.basic import BasicUserInterface +from pqcli.ui.curses import CursesUserInterface from pqcli.ui.urwid import UrwidUserInterface SAVE_PATH = Path(xdg.XDG_CONFIG_HOME) / "pqcli" / "save.dat" @@ -12,9 +13,31 @@ SAVE_PATH = Path(xdg.XDG_CONFIG_HOME) / "pqcli" / "save.dat" def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(prog="pqcli") - parser.add_argument( - "--basic", action="store_true", help="Use basic user interface" + + group = parser.add_mutually_exclusive_group() + group.set_defaults(ui=UrwidUserInterface) + group.add_argument( + "--basic", + dest="ui", + action="store_const", + const=BasicUserInterface, + help="Use basic user interface (very crude, but uses least CPU)", ) + group.add_argument( + "--curses", + dest="ui", + action="store_const", + const=CursesUserInterface, + help="Use curses user interface (fast, but no colors output)", + ) + group.add_argument( + "--urwid", + dest="ui", + action="store_const", + const=UrwidUserInterface, + help="Use curses user interface (slow, but rich colors output)", + ) + parser.add_argument( "--no-config", dest="use_config", @@ -39,11 +62,7 @@ def main() -> None: roster = Roster.load(SAVE_PATH) try: - if args.basic: - ui = BasicUserInterface(roster, args) - else: - ui = UrwidUserInterface(roster, args) - + ui = args.ui(roster, args) ui.run() finally: if args.use_saves: diff --git a/pqcli/lingo.py b/pqcli/lingo.py index 25dae19..71500b8 100644 --- a/pqcli/lingo.py +++ b/pqcli/lingo.py @@ -124,6 +124,6 @@ def special(m: int, subject: str) -> str: ) -def terminate_message(player_name: str) -> None: +def terminate_message(player_name: str) -> str: adjective = random.choice(["faithful", "noble", "loyal", "brave"]) return f"Terminate {adjective} {player_name}?" diff --git a/pqcli/mechanic.py b/pqcli/mechanic.py index 4d88c67..bc07c7f 100644 --- a/pqcli/mechanic.py +++ b/pqcli/mechanic.py @@ -28,7 +28,12 @@ class SignalMixin: _SIGNALS[self.__class__.__name__, signal_name].append(callback) def disconnect(self, signal_name: str, callback: T.Callable) -> None: - idx = _SIGNALS[self.__class__.__name__, signal_name].index(callback) + try: + idx = _SIGNALS[self.__class__.__name__, signal_name].index( + callback + ) + except ValueError: + return del _SIGNALS[self.__class__.__name__, signal_name][idx] @@ -328,7 +333,7 @@ class Player(SignalMixin): self.task: T.Optional[BaseTask] = None self.queue: T.List[BaseTask] = [] - def __setstate__(self, obj) -> None: + def __setstate__(self, obj: T.Any) -> None: self.__dict__.update(obj) self.elapsed = obj.get("elapsed", 0.0) diff --git a/pqcli/ui/base.py b/pqcli/ui/base.py new file mode 100644 index 0000000..94816e7 --- /dev/null +++ b/pqcli/ui/base.py @@ -0,0 +1,14 @@ +import abc +import argparse + +from pqcli.roster import Roster + + +class BaseUserInterface(abc.ABC): + def __init__(self, roster: Roster, args: argparse.Namespace) -> None: + self.roster = roster + self.args = args + + @abc.abstractmethod + def run(self) -> None: + raise NotImplementedError("not implemented") diff --git a/pqcli/ui/basic/__init__.py b/pqcli/ui/basic/__init__.py index 4d96f87..93a938e 100644 --- a/pqcli/ui/basic/__init__.py +++ b/pqcli/ui/basic/__init__.py @@ -12,6 +12,7 @@ from pqcli.config import CLASSES, PRIME_STATS, RACES from pqcli.mechanic import Player, Simulation, StatsBuilder, create_player from pqcli.roster import Roster +from ..base import BaseUserInterface LOGO = """ ____ ___ _ @@ -31,10 +32,9 @@ class MainMenu(enum.IntEnum): quit = 5 -class BasicUserInterface: +class BasicUserInterface(BaseUserInterface): def __init__(self, roster: Roster, args: argparse.Namespace) -> None: - self.roster = roster - self.args = args + super().__init__(roster, args) def signal_handler(sig, frame): print("Quitting") diff --git a/pqcli/ui/curses/__init__.py b/pqcli/ui/curses/__init__.py new file mode 100644 index 0000000..1d2dc47 --- /dev/null +++ b/pqcli/ui/curses/__init__.py @@ -0,0 +1,124 @@ +import argparse +import signal +import curses +import os +import time +import typing as T + +from pqcli import lingo +from pqcli.mechanic import Player +from pqcli.roster import Roster +from pqcli.ui.curses.views import ( + BaseView, + ChooseCharacterView, + ConfirmView, + CreateCharacterView, + GameView, + RosterView, +) + +from ..base import BaseUserInterface + + +class StopMainLoop(Exception): + pass + + +class CursesUserInterface(BaseUserInterface): + def __init__(self, roster: Roster, args: argparse.Namespace) -> None: + super().__init__(roster, args) + os.environ.setdefault("ESCDELAY", "25") + self.screen = curses.initscr() + self.screen.nodelay(True) + self.screen.keypad(True) + curses.noecho() + curses.curs_set(0) + + def signal_handler(sig, frame): + print("Quitting") + 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_character_view + self.roster_view.on_delete += self.switch_to_delete_character_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: + self.switch_to_roster_view() + + while True: + key = T.cast(int, 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_character_view(self) -> None: + view = CreateCharacterView(self.screen) + view.on_cancel += self.switch_to_roster_view + view.on_confirm += self.switch_to_game_view + self.switch_view(view) + + def switch_to_delete_character_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_character_view + self.switch_view(view) + + def switch_to_confirm_delete_character_view(self, player: Player) -> None: + view = ConfirmView(self.screen, lingo.terminate_message(player.name)) + view.on_cancel += self.switch_to_delete_character_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, player) + 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 delete_character(self, player: Player) -> None: + self.roster.players.remove(player) + self.switch_to_roster_view() + + def quit(self) -> None: + raise StopMainLoop diff --git a/pqcli/ui/curses/event_handler.py b/pqcli/ui/curses/event_handler.py new file mode 100644 index 0000000..c26ede5 --- /dev/null +++ b/pqcli/ui/curses/event_handler.py @@ -0,0 +1,14 @@ +import typing as T + + +class EventHandler: + def __init__(self) -> None: + self.callbacks: T.List[T.Callable[..., T.Any]] = [] + + def __call__(self, *args: T.Any, **kwargs: T.Any) -> None: + for callback in self.callbacks: + callback(*args, **kwargs) + + def __iadd__(self, callback: T.Callable[..., T.Any]) -> "EventHandler": + self.callbacks.append(callback) + return self diff --git a/pqcli/ui/curses/util.py b/pqcli/ui/curses/util.py new file mode 100644 index 0000000..2e71bbe --- /dev/null +++ b/pqcli/ui/curses/util.py @@ -0,0 +1,13 @@ +import typing as T +from dataclasses import dataclass + + +@dataclass +class Choice: + keys: T.List[int] + desc: str + callback: T.Callable[..., T.Any] + + +def first(source: T.Iterable[T.Any], default: T.Any = None) -> T.Any: + return next(iter(source), default) diff --git a/pqcli/ui/curses/views/__init__.py b/pqcli/ui/curses/views/__init__.py new file mode 100644 index 0000000..4514486 --- /dev/null +++ b/pqcli/ui/curses/views/__init__.py @@ -0,0 +1,6 @@ +from .base_view import BaseView +from .choose_character_view import ChooseCharacterView +from .confirm_view import ConfirmView +from .create_character_view import CreateCharacterView +from .game_view import GameView +from .roster_view import RosterView diff --git a/pqcli/ui/curses/views/base_view.py b/pqcli/ui/curses/views/base_view.py new file mode 100644 index 0000000..027f760 --- /dev/null +++ b/pqcli/ui/curses/views/base_view.py @@ -0,0 +1,18 @@ +import typing as T + + +class BaseView: + def __init__(self, screen: T.Any) -> None: + self.screen = screen + + def start(self) -> None: + pass + + def stop(self) -> None: + pass + + def idle(self) -> None: + pass + + def keypress(self, key: int) -> None: + pass diff --git a/pqcli/ui/curses/views/choose_character_view.py b/pqcli/ui/curses/views/choose_character_view.py new file mode 100644 index 0000000..ac0d5db --- /dev/null +++ b/pqcli/ui/curses/views/choose_character_view.py @@ -0,0 +1,56 @@ +import functools +import typing as T + +from pqcli.roster import Roster + +from ..event_handler import EventHandler +from ..util import Choice +from ..widgets import Menu +from .base_view import BaseView + + +class ChooseCharacterView(BaseView): + def __init__(self, screen: T.Any, roster: Roster, title: str) -> None: + super().__init__(screen) + + self.title = f"{title}:" + self.on_cancel = EventHandler() + self.on_confirm = EventHandler() + + self.choices: T.List[Choice] = [] + for y, player in enumerate(roster.players, 1): + key: T.Optional[str] = str(y % 10) if y <= 10 else None + self.choices.append( + Choice( + keys=[ord(key)] if key is not None else [], + desc=f"[{key or '-'}] {player.name}", + callback=functools.partial(self.on_confirm, player), + ) + ) + self.choices.append( + Choice( + keys=list(map(ord, "qQ\N{ESC}")), + desc="[Q] Cancel", + callback=self.on_cancel, + ) + ) + + def start(self) -> None: + scr_height, scr_width = self.screen.getmaxyx() + self.screen.erase() + self.screen.refresh() + + self.main_menu = Menu( + header=self.title, + choices=self.choices, + scr_height=scr_height, + scr_width=scr_width, + ) + self.main_menu.render() + + def stop(self) -> None: + self.main_menu.stop() + + def keypress(self, key: int) -> None: + self.main_menu.keypress(key) + self.main_menu.render() diff --git a/pqcli/ui/curses/views/confirm_view.py b/pqcli/ui/curses/views/confirm_view.py new file mode 100644 index 0000000..3d7f8cc --- /dev/null +++ b/pqcli/ui/curses/views/confirm_view.py @@ -0,0 +1,48 @@ +import typing as T + +from ..event_handler import EventHandler +from ..util import Choice +from ..widgets import Menu +from .base_view import BaseView + + +class ConfirmView(BaseView): + def __init__(self, screen: T.Any, title: str) -> None: + super().__init__(screen) + + self.title = f"{title}:" + self.on_cancel = EventHandler() + self.on_confirm = EventHandler() + + self.choices: T.List[Choice] = [ + Choice( + keys=list(map(ord, "yY")), + desc=f"[y] Yes", + callback=self.on_confirm, + ), + Choice( + keys=list(map(ord, "nNqQ\N{ESC}")), + desc="[N] No", + callback=self.on_cancel, + ), + ] + + def start(self) -> None: + scr_height, scr_width = self.screen.getmaxyx() + self.screen.erase() + self.screen.refresh() + + self.main_menu = Menu( + header=self.title, + choices=self.choices, + scr_height=scr_height, + scr_width=scr_width, + ) + self.main_menu.render() + + def stop(self) -> None: + self.main_menu.stop() + + def keypress(self, key: int) -> None: + self.main_menu.keypress(key) + self.main_menu.render() diff --git a/pqcli/ui/curses/views/create_character_view.py b/pqcli/ui/curses/views/create_character_view.py new file mode 100644 index 0000000..36e35a0 --- /dev/null +++ b/pqcli/ui/curses/views/create_character_view.py @@ -0,0 +1,16 @@ +import typing as T + +from ..event_handler import EventHandler +from .base_view import BaseView + + +class CreateCharacterView(BaseView): + def __init__(self, screen: T.Any) -> None: + super().__init__(screen) + + self.on_cancel = EventHandler() + self.on_confirm = EventHandler() + + def keypress(self, key: int) -> None: + if key in map(ord, "qQ\N{ESC}"): + self.on_cancel() diff --git a/pqcli/ui/curses/views/game_view/__init__.py b/pqcli/ui/curses/views/game_view/__init__.py new file mode 100644 index 0000000..5ec750c --- /dev/null +++ b/pqcli/ui/curses/views/game_view/__init__.py @@ -0,0 +1,143 @@ +import curses +import datetime +import typing as T + +from pqcli.mechanic import Player, Simulation +from pqcli.ui.curses.event_handler import EventHandler + +from ..base_view import BaseView +from .character_sheet_window import CharacterSheetWindow +from .equipment_window import EquipmentWindow +from .inventory_window import InventoryWindow +from .plot_window import PlotWindow +from .quest_book_window import QuestBookWindow +from .spell_book_window import SpellBookWindow +from .task_progress_window import TaskProgressWindow + + +class GameView(BaseView): + def __init__(self, screen: T.Any, player: Player) -> None: + super().__init__(screen) + self.on_exit = EventHandler() + + self._player = player + self._simulation = Simulation(player) + self._last_tick = datetime.datetime.now() + + def keypress(self, key: int) -> None: + if key in map(ord, "qQ"): + self.on_exit() + else: + super().keypress(key) + + def start(self) -> None: + scr_height, scr_width = self.screen.getmaxyx() + col1_width = int(scr_width * 0.3) + col3_width = col1_width + col2_width = scr_width - col1_width - col3_width + + self.screen.erase() + self.screen.refresh() + + self._big_win = curses.newwin(scr_height, scr_width) + self._col1_win = self._big_win.derwin(scr_height - 2, col1_width, 0, 0) + self._col2_win = self._big_win.derwin( + self._col1_win.getmaxyx()[0], + col2_width, + 0, + self._col1_win.getmaxyx()[1], + ) + self._col3_win = self._big_win.derwin( + self._col1_win.getmaxyx()[0], + col3_width, + 0, + self._col1_win.getmaxyx()[1] + self._col2_win.getmaxyx()[1], + ) + + self._char_sheet_win = CharacterSheetWindow( + player=self._player, + parent=self._col1_win, + h=min(17, self._col1_win.getmaxyx()[0]), + w=self._col1_win.getmaxyx()[1], + y=0, + x=0, + ) + + self._spell_book_win = SpellBookWindow( + player=self._player, + parent=self._col1_win, + h=self._col1_win.getmaxyx()[0] + - self._char_sheet_win.getmaxyx()[0], + w=self._col1_win.getmaxyx()[1], + y=self._char_sheet_win.getmaxyx()[0], + x=0, + ) + + self._equipment_win = EquipmentWindow( + player=self._player, + parent=self._col2_win, + h=min(15, self._col2_win.getmaxyx()[0]), + w=self._col2_win.getmaxyx()[1], + y=0, + x=0, + ) + + self._inventory_win = InventoryWindow( + player=self._player, + parent=self._col2_win, + h=max( + 1, + self._col2_win.getmaxyx()[0] + - self._equipment_win.getmaxyx()[0], + ), + w=self._col2_win.getmaxyx()[1], + y=self._equipment_win.getmaxyx()[0], + x=0, + ) + + self._plot_win = PlotWindow( + player=self._player, + parent=self._col3_win, + h=self._equipment_win.getmaxyx()[0], + w=self._col3_win.getmaxyx()[1], + y=0, + x=0, + ) + + self._quest_book_win = QuestBookWindow( + player=self._player, + parent=self._col3_win, + h=max( + 1, self._col3_win.getmaxyx()[0] - self._plot_win.getmaxyx()[0] + ), + w=self._col3_win.getmaxyx()[1], + y=self._plot_win.getmaxyx()[0], + x=0, + ) + + self._task_win = TaskProgressWindow( + player=self._player, + parent=self._big_win, + h=2, + w=scr_width, + y=scr_height - 2, + x=0, + ) + + def stop(self) -> None: + self._char_sheet_win.stop() + self._spell_book_win.stop() + self._equipment_win.stop() + self._inventory_win.stop() + self._plot_win.stop() + self._quest_book_win.stop() + self._task_win.stop() + del self._col1_win + del self._col2_win + del self._col3_win + del self._big_win + + def idle(self) -> None: + elapsed = datetime.datetime.now() - self._last_tick + self._simulation.tick(elapsed.total_seconds() * 1000) + self._last_tick = datetime.datetime.now() diff --git a/pqcli/ui/curses/views/game_view/character_sheet_window.py b/pqcli/ui/curses/views/game_view/character_sheet_window.py new file mode 100644 index 0000000..99fbefe --- /dev/null +++ b/pqcli/ui/curses/views/game_view/character_sheet_window.py @@ -0,0 +1,53 @@ +import typing as T + +from pqcli.mechanic import Player, StatType + +from .progress_bar_window import DataTableProgressBarWindow + + +class CharacterSheetWindow(DataTableProgressBarWindow): + def __init__( + self, player: Player, parent: T.Any, h: int, w: int, y: int, x: int + ) -> None: + super().__init__( + parent, h, w, y, x, " Character Sheet ", align_right=False + ) + + self._player = player + self._player.connect("level_up", self._sync_traits) + self._player.stats.connect("change", self._sync_traits) + self._player.exp_bar.connect("change", self._sync_exp) + + self._sync() + + def stop(self) -> None: + super().stop() + + self._player.disconnect("level_up", self._sync_traits) + self._player.stats.disconnect("change", self._sync_traits) + self._player.exp_bar.disconnect("change", self._sync_exp) + + def _sync(self) -> None: + self._sync_traits() + self._sync_exp() + + def _sync_traits(self) -> None: + if not self._win: + return + + self._data_table.clear() + self._data_table.add("Name", self._player.name) + self._data_table.add("Race", self._player.race.name) + self._data_table.add("Class", self._player.class_.name) + self._data_table.add("Level", str(self._player.level)) + self._data_table.add(" " * 15, "") + for stat in StatType: + self._data_table.add(stat.value, str(self._player.stats[stat])) + + self._render_data_table() + + def _sync_exp(self) -> None: + cur = self._player.exp_bar.position + max_ = self._player.exp_bar.max_ + text = f"Experience ({max_-cur:.0f} XP to go)" + self._render_progress_bar(cur, max_, text) diff --git a/pqcli/ui/curses/views/game_view/equipment_window.py b/pqcli/ui/curses/views/game_view/equipment_window.py new file mode 100644 index 0000000..3f517fa --- /dev/null +++ b/pqcli/ui/curses/views/game_view/equipment_window.py @@ -0,0 +1,52 @@ +import typing as T + +from pqcli.mechanic import EquipmentType, Player +from pqcli.ui.curses.widgets import DataTable, WindowWrapper + + +class EquipmentWindow(WindowWrapper): + def __init__( + self, player: Player, parent: T.Any, h: int, w: int, y: int, x: int + ) -> None: + super().__init__(parent, h, w, y, x) + + self._data_table = DataTable( + self._win, h - 2, w - 2, 1, 1, align_right=False + ) + + self._player = player + self._player.equipment.connect("change", self.sync_equipment_change) + + self._sync() + + def stop(self) -> None: + super().stop() + self._data_table.stop() + + self._player.equipment.disconnect("change", self.sync_equipment_change) + + def _sync(self) -> None: + self._data_table.clear() + for equipment_type in EquipmentType: + self._data_table.add( + equipment_type.value.ljust(15), + self._player.equipment[equipment_type] or "", + ) + self._render() + + def sync_equipment_change( + self, equipment_type: EquipmentType, item_name: T.Optional[str] + ) -> None: + self._data_table.set(equipment_type.value, item_name or "") + self._render() + + def _render(self) -> None: + if not self._win: + return + + self._win.box() + text = " Equipment " + x = max(0, (self.getmaxyx()[1] - len(text)) // 2) + self._win.addnstr(0, x, text, min(len(text), self.getmaxyx()[1])) + self._win.refresh() + self._data_table.render() diff --git a/pqcli/ui/curses/views/game_view/inventory_window.py b/pqcli/ui/curses/views/game_view/inventory_window.py new file mode 100644 index 0000000..2f213e7 --- /dev/null +++ b/pqcli/ui/curses/views/game_view/inventory_window.py @@ -0,0 +1,83 @@ +import curses +import typing as T + +from pqcli.lingo import to_roman +from pqcli.mechanic import InventoryItem, Player +from pqcli.ui.curses.widgets import DataTable, ProgressBar, WindowWrapper + +from .progress_bar_window import DataTableProgressBarWindow + + +class InventoryWindow(DataTableProgressBarWindow): + def __init__( + self, player: Player, parent: T.Any, h: int, w: int, y: int, x: int + ) -> None: + super().__init__(parent, h, w, y, x, " Inventory ", align_right=True) + + self._player = player + self._player.connect("new_task", self._on_new_task) + self._player.inventory.connect("gold_change", self._sync_gold) + self._player.inventory.connect("item_add", self._sync_item_add) + self._player.inventory.connect("item_del", self._sync_item_del) + self._player.inventory.connect("item_change", self._sync_item_change) + self._player.inventory.encum_bar.connect( + "change", self._sync_encumbrance + ) + + self._sync() + + def stop(self) -> None: + super().stop() + + self._player.disconnect("new_task", self._on_new_task) + self._player.inventory.disconnect("gold_change", self._sync_gold) + self._player.inventory.disconnect("item_add", self._sync_item_add) + self._player.inventory.disconnect("item_del", self._sync_item_del) + self._player.inventory.disconnect( + "item_change", self._sync_item_change + ) + self._player.inventory.encum_bar.disconnect( + "change", self._sync_encumbrance + ) + + def _sync(self) -> None: + self._sync_gold() + self._sync_items() + self._sync_encumbrance() + + def _sync_encumbrance(self) -> None: + cur = self._player.inventory.encum_bar.position + max_ = self._player.inventory.encum_bar.max_ + text = f"Encumbrance ({cur:.0f}/{max_} cubits)" + self._render_progress_bar(cur, max_, text) + + def _sync_gold(self) -> None: + self._data_table.set("Gold", str(self._player.inventory.gold)) + self._render_data_table() + + def _sync_items(self) -> None: + self._data_table.clear() + self._sync_gold() + for item in self._player.inventory: + self._data_table.add(item.name, str(item.quantity)) + self._data_table.scroll_to_item(0) + self._render_data_table() + + def _sync_item_add(self, item: InventoryItem) -> None: + self._data_table.add(item.name, str(item.quantity)) + self._render_data_table() + + def _sync_item_del(self, item: InventoryItem) -> None: + self._data_table.delete(item.name) + self._render_data_table() + + def _sync_item_change(self, item: InventoryItem) -> None: + self._data_table.set(item.name, str(item.quantity)) + self._render_data_table() + + def _on_new_task(self) -> None: + description = ( + self._player.task.description if self._player.task else "?" + ) + if description.lower().startswith("sell"): + self._data_table.scroll_to_item(0) diff --git a/pqcli/ui/curses/views/game_view/plot_window.py b/pqcli/ui/curses/views/game_view/plot_window.py new file mode 100644 index 0000000..8dbcb46 --- /dev/null +++ b/pqcli/ui/curses/views/game_view/plot_window.py @@ -0,0 +1,69 @@ +import typing as T + +from pqcli.lingo import act_name +from pqcli.mechanic import Player +from pqcli.ui.curses.widgets import ListBox, WindowWrapper + +from .progress_bar_window import ListBoxProgressBarWindow + + +class PlotWindow(ListBoxProgressBarWindow): + cutoff = 100 + + def __init__( + self, player: Player, parent: T.Any, h: int, w: int, y: int, x: int + ) -> None: + super().__init__(parent, h, w, y, x, " Plot ") + + self._player = player + self._player.quest_book.connect("start_act", self._sync_act_add) + self._player.quest_book.plot_bar.connect("change", self._sync_position) + + self._sync() + + def stop(self) -> None: + super().stop() + self._list_box.stop() + + self._player.quest_book.disconnect("start_act", self._sync_act_add) + self._player.quest_book.plot_bar.disconnect( + "change", self._sync_position + ) + + def _sync(self) -> None: + self._sync_acts() + self._sync_position() + + def _sync_acts(self) -> None: + self._list_box.clear() + for act_number in range( + max(0, self._player.quest_book.act - self.cutoff), + self._player.quest_book.act + 1, + ): + self._sync_act_add(act_number) + self._render() + + def _sync_act_add(self, act_number: int) -> None: + self._list_box.delete(0, max(0, len(self._list_box) - self.cutoff)) + prev = self._list_box.get(-1) + if prev is not None: + self._list_box.set(-1, "[X] " + prev[4:]) + self._list_box.add("[ ] " + act_name(act_number)) + + def _sync_position(self) -> None: + self._render_progress_bar( + self._player.quest_book.plot_bar.position, + self._player.quest_book.plot_bar.max_, + "", + ) + + def _render(self) -> None: + if not self._win: + return + + self._win.box() + text = " Plot Development " + x = max(0, (self.getmaxyx()[1] - len(text)) // 2) + self._win.addnstr(0, x, text, min(len(text), self.getmaxyx()[1])) + self._win.refresh() + self._list_box.render() diff --git a/pqcli/ui/curses/views/game_view/progress_bar_window.py b/pqcli/ui/curses/views/game_view/progress_bar_window.py new file mode 100644 index 0000000..e2ac636 --- /dev/null +++ b/pqcli/ui/curses/views/game_view/progress_bar_window.py @@ -0,0 +1,117 @@ +import curses +import typing as T + +from pqcli.ui.curses.widgets import ( + DataTable, + ListBox, + ProgressBar, + WindowWrapper, +) + + +class ProgressBarWindow(WindowWrapper): + def __init__( + self, parent: T.Any, h: int, w: int, y: int, x: int, title: str + ) -> None: + super().__init__(parent, h, w, y, x) + + self._title = title + + try: + self._progress_bar_win = ( + self._win.derwin(3, w, h - 3, 0) if self._win else None + ) + except curses.error: + self._progress_bar_win = None + self._progress_bar = ProgressBar( + self._progress_bar_win, 1, w - 2, 1, 1 + ) + + def stop(self) -> None: + super().stop() + self._progress_bar.stop() + + del self._progress_bar_win + self._progress_bar_win = None + + def _render_progress_bar(self, cur: float, max_: float, text: str) -> None: + if not self._progress_bar_win: + return + + self._progress_bar_win.erase() + self._progress_bar_win.border( + curses.ACS_VLINE, + curses.ACS_VLINE, + curses.ACS_HLINE, + curses.ACS_HLINE, + curses.ACS_LTEE, + curses.ACS_RTEE, + curses.ACS_LLCORNER, + curses.ACS_LRCORNER, + ) + + x = max(0, (self.getmaxyx()[1] - len(text)) // 2) + self._progress_bar_win.addnstr( + 0, x, text, min(len(text), self._progress_bar_win.getmaxyx()[1]) + ) + self._progress_bar.set_position(cur, max_) + self._progress_bar_win.refresh() + + +class DataTableProgressBarWindow(ProgressBarWindow): + def __init__( + self, + parent: T.Any, + h: int, + w: int, + y: int, + x: int, + title: str, + align_right: bool, + ) -> None: + super().__init__(parent, h, w, y, x, title) + + self._data_table = DataTable( + self._win, h - 4, w - 2, 1, 1, align_right + ) + + def stop(self) -> None: + super().stop() + self._data_table.stop() + + def _render_data_table(self) -> None: + if not self._win: + return + + self._win.box() + x = max(0, (self.getmaxyx()[1] - len(self._title)) // 2) + self._win.addnstr( + 0, x, self._title, min(len(self._title), self.getmaxyx()[1]) + ) + self._win.refresh() + self._data_table.render() + + +class ListBoxProgressBarWindow(ProgressBarWindow): + def __init__( + self, parent: T.Any, h: int, w: int, y: int, x: int, title: str + ) -> None: + super().__init__(parent, h, w, y, x, title) + + self._list_box = ListBox(self._win, h - 4, w - 2, 1, 1) + + def stop(self) -> None: + super().stop() + self._list_box.stop() + + def _render_list_box(self) -> None: + if not self._win: + return + + self._win.box() + x = max(0, (self.getmaxyx()[1] - len(self._title)) // 2) + self._win.addnstr( + 0, x, self._title, min(len(self._title), self.getmaxyx()[1]) + ) + self._win.refresh() + self._list_box.render() diff --git a/pqcli/ui/curses/views/game_view/quest_book_window.py b/pqcli/ui/curses/views/game_view/quest_book_window.py new file mode 100644 index 0000000..d2cd083 --- /dev/null +++ b/pqcli/ui/curses/views/game_view/quest_book_window.py @@ -0,0 +1,67 @@ +import typing as T + +from pqcli.mechanic import Player +from pqcli.ui.curses.widgets import ListBox, WindowWrapper + +from .progress_bar_window import ListBoxProgressBarWindow + + +class QuestBookWindow(ListBoxProgressBarWindow): + cutoff = 100 + + def __init__( + self, player: Player, parent: T.Any, h: int, w: int, y: int, x: int + ) -> None: + super().__init__(parent, h, w, y, x, " Quests ") + + self._player = player + self._player.quest_book.connect("start_quest", self._sync_quest_add) + self._player.quest_book.quest_bar.connect( + "change", self._sync_position + ) + + self._sync() + + def stop(self) -> None: + super().stop() + self._list_box.stop() + + self._player.quest_book.disconnect("start_quest", self._sync_quest_add) + self._player.quest_book.quest_bar.disconnect( + "change", self._sync_position + ) + + def _sync(self) -> None: + self._sync_quests() + self._sync_position() + + def _sync_quests(self) -> None: + self._list_box.clear() + for quest_name in self._player.quest_book.quests[-self.cutoff :]: + self._sync_quest_add(quest_name) + self._render() + + def _sync_quest_add(self, quest_name: str) -> None: + self._list_box.delete(0, max(0, len(self._list_box) - self.cutoff)) + prev = self._list_box.get(-1) + if prev is not None: + self._list_box.set(-1, "[X] " + prev[4:]) + self._list_box.add("[ ] " + quest_name) + + def _sync_position(self) -> None: + self._render_progress_bar( + self._player.quest_book.quest_bar.position, + self._player.quest_book.quest_bar.max_, + "", + ) + + def _render(self) -> None: + if not self._win: + return + + self._win.box() + text = " Plot Development " + x = max(0, (self.getmaxyx()[1] - len(text)) // 2) + self._win.addnstr(0, x, text, min(len(text), self.getmaxyx()[1])) + self._win.refresh() + self._list_box.render() diff --git a/pqcli/ui/curses/views/game_view/spell_book_window.py b/pqcli/ui/curses/views/game_view/spell_book_window.py new file mode 100644 index 0000000..5a71928 --- /dev/null +++ b/pqcli/ui/curses/views/game_view/spell_book_window.py @@ -0,0 +1,54 @@ +import typing as T + +from pqcli.lingo import to_roman +from pqcli.mechanic import Player, Spell +from pqcli.ui.curses.widgets import DataTable, WindowWrapper + + +class SpellBookWindow(WindowWrapper): + def __init__( + self, player: Player, parent: T.Any, h: int, w: int, y: int, x: int + ) -> None: + super().__init__(parent, h, w, y, x) + + self._data_table = DataTable( + self._win, h - 2, w - 2, 1, 1, align_right=True + ) + + self._player = player + self._player.spell_book.connect("add", self._sync_spell_add) + self._player.spell_book.connect("change", self._sync_spell_change) + + self._sync() + + def stop(self) -> None: + super().stop() + self._data_table.stop() + + self._player.spell_book.disconnect("add", self._sync_spell_add) + self._player.spell_book.disconnect("change", self._sync_spell_change) + + def _sync(self) -> None: + self._data_table.clear() + for spell in self._player.spell_book: + self._data_table.add(spell.name, to_roman(spell.level)) + self._render() + + def _sync_spell_add(self, spell: Spell) -> None: + self._data_table.add(spell.name, to_roman(spell.level)) + self._render() + + def _sync_spell_change(self, spell: Spell) -> None: + self._data_table.set(spell.name, to_roman(spell.level)) + self._render() + + def _render(self) -> None: + if not self._win: + return + + self._win.box() + text = " Spell Book " + x = max(0, (self.getmaxyx()[1] - len(text)) // 2) + self._win.addnstr(0, x, text, min(len(text), self.getmaxyx()[1])) + self._win.refresh() + self._data_table.render() diff --git a/pqcli/ui/curses/views/game_view/task_progress_window.py b/pqcli/ui/curses/views/game_view/task_progress_window.py new file mode 100644 index 0000000..8e26754 --- /dev/null +++ b/pqcli/ui/curses/views/game_view/task_progress_window.py @@ -0,0 +1,40 @@ +import typing as T + +from pqcli.mechanic import Player +from pqcli.ui.curses.widgets import Label, ProgressBar, WindowWrapper + + +class TaskProgressWindow(WindowWrapper): + def __init__( + self, player: Player, parent: T.Any, h: int, w: int, y: int, x: int + ) -> None: + super().__init__(parent, h, w, y, x) + + self._desc_win = Label(self._win, 1, w, 0, 0) + self._progress_bar = ProgressBar(self._win, 1, w, 1, 0) + + self._player = player + self._player.connect("new_task", self._sync_task_name) + self._player.task_bar.connect("change", self._sync_position) + + self._sync_task_name() + self._sync_position() + + def stop(self) -> None: + super().stop() + + self._desc_win.stop() + self._progress_bar.stop() + + self._player.disconnect("new_task", self._sync_task_name) + self._player.task_bar.disconnect("change", self._sync_position) + + def _sync_task_name(self) -> None: + self._desc_win.set_text( + f"{self._player.task.description}..." if self._player.task else "?" + ) + + def _sync_position(self) -> None: + self._progress_bar.set_position( + self._player.task_bar.position, self._player.task_bar.max_ + ) diff --git a/pqcli/ui/curses/views/roster_view.py b/pqcli/ui/curses/views/roster_view.py new file mode 100644 index 0000000..35927c4 --- /dev/null +++ b/pqcli/ui/curses/views/roster_view.py @@ -0,0 +1,66 @@ +import typing as T + +from ..event_handler import EventHandler +from ..util import Choice +from ..widgets import Menu +from .base_view import BaseView + +LOGO = """ +█▀▀▄ ▄▀▀▄ █ +█▄▄▀ █▀▀ ▄▀▀▄ ▄▀▀▄ █▀▀ ▄▀▀▄ ▄▀▀▀ ▄▀▀▀ █ █ █ █ ▄▀▀▄ ▄▀▀▀ ▀█▀ +█ █ █ █ ▀▄▄█ █ █▀▀ ▀▀▄ ▀▀▄ █ ▌█ █ █ █▀▀ ▀▀▄ █ +▀ ▀ ▀▀ ▄▄▀ ▀ ▀▀ ▀▀▀ ▀▀▀ ▀▀▌ ▀▀ ▀▀ ▀▀▀ ▀ +""".strip() + + +class RosterView(BaseView): + def __init__(self, screen: T.Any) -> None: + super().__init__(screen) + + self.on_create = EventHandler() + self.on_play = EventHandler() + self.on_delete = EventHandler() + self.on_quit = EventHandler() + + self.choices: T.List[Choice] = [ + Choice( + keys=list(map(ord, "cC")), + desc="[C] Create new character", + callback=self.on_create, + ), + Choice( + keys=list(map(ord, "pP")), + desc="[P] Play", + callback=self.on_play, + ), + Choice( + keys=list(map(ord, "dD")), + desc="[D] Delete character", + callback=self.on_delete, + ), + Choice( + keys=list(map(ord, "qQ\N{ESC}")), + desc="[Q] Quit", + callback=self.on_quit, + ), + ] + + def start(self) -> None: + scr_height, scr_width = self.screen.getmaxyx() + self.screen.erase() + self.screen.refresh() + + self.main_menu = Menu( + header=LOGO, + choices=self.choices, + scr_height=scr_height, + scr_width=scr_width, + ) + self.main_menu.render() + + def stop(self) -> None: + self.main_menu.stop() + + def keypress(self, key: int) -> None: + self.main_menu.keypress(key) + self.main_menu.render() diff --git a/pqcli/ui/curses/widgets/__init__.py b/pqcli/ui/curses/widgets/__init__.py new file mode 100644 index 0000000..8f301cb --- /dev/null +++ b/pqcli/ui/curses/widgets/__init__.py @@ -0,0 +1,6 @@ +from .base import WindowWrapper +from .data_table import DataTable +from .label import Label +from .list_box import ListBox +from .menu import Menu +from .progress_bar import ProgressBar diff --git a/pqcli/ui/curses/widgets/base.py b/pqcli/ui/curses/widgets/base.py new file mode 100644 index 0000000..9a1937c --- /dev/null +++ b/pqcli/ui/curses/widgets/base.py @@ -0,0 +1,41 @@ +import curses +import typing as T + + +class Widget: + def __del__(self) -> None: + self.stop() + + def stop(self) -> None: + pass + + +class WindowWrapper(Widget): + def __init__( + self, parent: T.Optional[T.Any], h: int, w: int, y: int, x: int + ) -> None: + self._parent = parent + self._win: T.Optional[T.Any] + + if not parent: + self._win = None + else: + try: + self._win = parent.derwin(h, w, y, x) + self._win.refresh() + except curses.error: + self._win = None + + def stop(self) -> None: + del self._win + self._win = None + + def getbegyx(self) -> T.Tuple[int, int]: + if not self._win: + return (0, 0) + return self._win.getbegyx() + + def getmaxyx(self) -> T.Tuple[int, int]: + if not self._win: + return (0, 0) + return self._win.getmaxyx() diff --git a/pqcli/ui/curses/widgets/data_table.py b/pqcli/ui/curses/widgets/data_table.py new file mode 100644 index 0000000..31b06be --- /dev/null +++ b/pqcli/ui/curses/widgets/data_table.py @@ -0,0 +1,71 @@ +import curses +import enum +import typing as T + +from .scrollable import Scrollable + + +class DataTable(Scrollable): + def __init__( + self, + parent: T.Any, + h: int, + w: int, + y: int, + x: int, + align_right: bool = False, + ) -> None: + super().__init__(parent, h, w, y, x) + + self._align_right = align_right + self._items: T.List[T.Tuple[str, str]] + + def add(self, text: str, value: str) -> None: + self._items.append((text, value)) + self.scroll_to_item(-1) + + def set(self, text: str, value: str) -> None: + idx = self.get_idx(text) + if idx is None: + self.add(text, value) + else: + self._items[idx] = (self._items[idx][0], value) + self.scroll_to_item(idx) + + def delete(self, text: str) -> None: + idx = self.get_idx(text) + if idx is not None: + del self._items[idx] + self.scroll_to_item(idx) + + def get_idx(self, text: str) -> T.Optional[int]: + for idx, row in enumerate(self._items): + if row[0].strip() == text.strip(): + return idx + return None + + def _render_impl(self) -> None: + assert self._pad + h, w = self.getmaxyx() + if self._align_right: + for y, row in enumerate(self._items): + self._pad.move(y, 0) + self._pad.addnstr(row[0], min(len(row[0]), w)) + col2_x = max(0, w - len(row[1])) + if col2_x < w: + self._pad.move(y, col2_x) + self._pad.addstr(row[1]) + else: + col2_x = self._get_col_width(0) + for y, row in enumerate(self._items): + self._pad.addnstr(y, 0, row[0], min(len(row[0]), w)) + if col2_x < w: + self._pad.addnstr( + y, col2_x, row[1], min(len(row[1]), w - col2_x) + ) + + y, x = self.getbegyx() + self._pad.refresh(self._scroll_y, 0, y, x, y + h - 1, x + w - 1) + + def _get_col_width(self, x: int) -> int: + return max([len(row[0]) for row in self._items] + [0]) diff --git a/pqcli/ui/curses/widgets/label.py b/pqcli/ui/curses/widgets/label.py new file mode 100644 index 0000000..c00f2aa --- /dev/null +++ b/pqcli/ui/curses/widgets/label.py @@ -0,0 +1,10 @@ +from .base import WindowWrapper + + +class Label(WindowWrapper): + def set_text(self, text: str) -> None: + if not self._win: + return + self._win.erase() + self._win.addnstr(text, min(len(text), self.getmaxyx()[1] - 1)) + self._win.refresh() diff --git a/pqcli/ui/curses/widgets/list_box.py b/pqcli/ui/curses/widgets/list_box.py new file mode 100644 index 0000000..3abfe33 --- /dev/null +++ b/pqcli/ui/curses/widgets/list_box.py @@ -0,0 +1,40 @@ +import curses +import enum +import typing as T + +from .scrollable import Scrollable + + +class ListBox(Scrollable): + _items: T.List[str] + + def add(self, text: str) -> None: + self._items.append(text) + self.scroll_to_item(-1) + + def set(self, idx: int, value: str) -> None: + if idx < 0: + idx %= len(self._items) + self._items[idx] = value + + def get(self, idx: int) -> T.Optional[str]: + if not self._items: + return None + if idx < 0: + idx %= len(self._items) + try: + return self._items[idx] + except ValueError: + return None + + def delete(self, idx: int, count: int = 1) -> None: + del self._items[idx : idx + count] + + def _render_impl(self) -> None: + assert self._pad + h, w = self.getmaxyx() + for y, item in enumerate(self._items): + self._pad.addnstr(y, 0, item, min(len(item), w)) + + y, x = self.getbegyx() + self._pad.refresh(self._scroll_y, 0, y, x, y + h - 1, x + w - 1) diff --git a/pqcli/ui/curses/widgets/menu.py b/pqcli/ui/curses/widgets/menu.py new file mode 100644 index 0000000..a14bad5 --- /dev/null +++ b/pqcli/ui/curses/widgets/menu.py @@ -0,0 +1,79 @@ +import curses +import typing as T + +from ..util import Choice +from .base import Widget + + +class Menu(Widget): + def __init__( + self, + header: str, + choices: T.List[Choice], + scr_height: int, + scr_width: int, + ) -> None: + self.header_lines = header.split("\n") + self.choices = choices + self.scr_width = scr_width + self.scr_height = scr_height + + w = max(map(len, self.header_lines)) + h = len(self.header_lines) + 1 + len(choices) + self._pad: T.Optional[T.Any] = curses.newpad(h, w) + + for y, line in enumerate(self.header_lines): + self._pad.move(y, 0) + self._pad.addstr(line) + + self.active_choice = 0 + + def stop(self) -> None: + del self._pad + self._pad = None + + def getmaxyx(self) -> T.Tuple[int, int]: + if not self._pad: + return (0, 0) + return self._pad.getmaxyx() + + def next(self) -> None: + self.active_choice = min(len(self.choices) - 1, self.active_choice + 1) + + def prev(self) -> None: + self.active_choice = max(0, self.active_choice - 1) + + def keypress(self, key: int) -> None: + for choice in self.choices: + if key in choice.keys: + choice.callback() + return + + if key in set(map(ord, "jJ")) | {curses.KEY_DOWN}: + self.next() + return + + if key in set(map(ord, "kK")) | {curses.KEY_UP}: + self.prev() + return + + def render(self) -> None: + if not self._pad: + return + + for y, choice in enumerate(self.choices): + self._pad.move(len(self.header_lines) + 1 + y, 0) + if y == self.active_choice: + self._pad.standout() + self._pad.addstr(choice.desc) + if y == self.active_choice: + self._pad.standend() + + self._pad.refresh( + 0, + 0, + (self.scr_height - self._pad.getmaxyx()[0]) // 2, + (self.scr_width - self._pad.getmaxyx()[1]) // 2, + self.scr_height - 1, + self.scr_width - 1, + ) diff --git a/pqcli/ui/curses/widgets/progress_bar.py b/pqcli/ui/curses/widgets/progress_bar.py new file mode 100644 index 0000000..5cb9b0a --- /dev/null +++ b/pqcli/ui/curses/widgets/progress_bar.py @@ -0,0 +1,19 @@ +import curses + +from .base import WindowWrapper + + +class ProgressBar(WindowWrapper): + def set_position(self, position: float, max_: float) -> None: + if not self._win: + return + self._win.erase() + text = f"{position / max_:.02%}" + x = max(0, (self.getmaxyx()[1] - len(text)) // 2) + self._win.addnstr(0, x, text, min(len(text), self.getmaxyx()[1] - 1)) + x = int(position * self.getmaxyx()[1] // max_) + if x > 0: + self._win.chgat(0, 0, curses.A_REVERSE) + if x < self.getmaxyx()[1]: + self._win.chgat(0, x, curses.A_NORMAL) + self._win.refresh() diff --git a/pqcli/ui/curses/widgets/scrollable.py b/pqcli/ui/curses/widgets/scrollable.py new file mode 100644 index 0000000..f37652b --- /dev/null +++ b/pqcli/ui/curses/widgets/scrollable.py @@ -0,0 +1,52 @@ +import curses +import typing as T + +from .base import WindowWrapper + + +class Scrollable(WindowWrapper): + def __init__(self, *args: T.Any, **kwargs: T.Any) -> None: + super().__init__(*args, **kwargs) + self._scroll_y = 0 + self._pad: T.Optional[T.Any] = curses.newpad(1, 1) + self._items: T.List[T.Any] = [] + + def clear(self) -> None: + self._items = [] + self.scroll_to_item(0) + + def scroll_to_item(self, y: int) -> None: + if not self._items or not self._win: + return + if y < 0: + y %= len(self._items) + while not (self._scroll_y <= y < self._scroll_y + self.getmaxyx()[0]): + self._scroll_y += 1 if self._scroll_y < y else -1 + + def stop(self) -> None: + super().stop() + del self._pad + self._pad = None + + def __len__(self) -> int: + return len(self._items) + + def render(self) -> None: + if not self._pad: + return + + if self._items: + self._pad.resize( + max(1, len(self._items)), max(1, self.getmaxyx()[1] + 1) + ) + else: + self._pad.resize(1, 1) + + self._pad.erase() + try: + self._render_impl() + except curses.error: + pass + + def _render_impl(self) -> None: + raise NotImplementedError("not implemented") diff --git a/pqcli/ui/urwid/__init__.py b/pqcli/ui/urwid/__init__.py index e563845..45d8b5d 100644 --- a/pqcli/ui/urwid/__init__.py +++ b/pqcli/ui/urwid/__init__.py @@ -6,8 +6,7 @@ from pathlib import Path import urwid import xdg -from pqcli import random -from pqcli import lingo +from pqcli import lingo, random from pqcli.mechanic import Player from pqcli.roster import Roster from pqcli.ui.urwid.confirm_dialog import ConfirmDialog @@ -15,6 +14,7 @@ from pqcli.ui.urwid.game_view import GameView from pqcli.ui.urwid.new_game_view import NewGameView from pqcli.ui.urwid.roster_view import RosterView +from ..base import BaseUserInterface TICK_FREQ = 0.1 SAVE_FREQ = 60.0 @@ -66,16 +66,16 @@ def bind_commands() -> None: urwid.command_map[key_variant] = f"cursor {direction}" -class UrwidUserInterface: +class UrwidUserInterface(BaseUserInterface): def __init__(self, roster: Roster, args: argparse.Namespace) -> None: + super().__init__(roster, args) + bind_commands() if args.use_config: if not load_palette(): save_palette() # create a file for the user to edit - self.roster = roster - self.args = args self.loop = urwid.MainLoop( None, PALETTE, unhandled_input=self.unhandled_input )