ui/curses: add curses interface draft

This commit is contained in:
rr-
2019-01-03 21:38:19 +01:00
parent 81f6dbede2
commit ca7ab192c5
32 changed files with 1413 additions and 18 deletions
+26 -7
View File
@@ -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:
+1 -1
View File
@@ -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}?"
+7 -2
View File
@@ -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)
+14
View File
@@ -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")
+3 -3
View File
@@ -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")
+124
View File
@@ -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
+14
View File
@@ -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
+13
View File
@@ -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)
+6
View File
@@ -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
+18
View File
@@ -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
@@ -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()
+48
View File
@@ -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()
@@ -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()
+143
View File
@@ -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()
@@ -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)
@@ -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()
@@ -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)
@@ -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()
@@ -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()
@@ -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()
@@ -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()
@@ -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_
)
+66
View File
@@ -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()
+6
View File
@@ -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
+41
View File
@@ -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()
+71
View File
@@ -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])
+10
View File
@@ -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()
+40
View File
@@ -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)
+79
View File
@@ -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,
)
+19
View File
@@ -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()
+52
View File
@@ -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")
+5 -5
View File
@@ -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
)