diff --git a/README.md b/README.md index 2669b59..57ed012 100644 --- a/README.md +++ b/README.md @@ -12,23 +12,18 @@ Relive the great adventure… this time in the terminal realm! - Faithful port of the game logic - Saves (with backups) to `$XDG_CONFIG_HOME/pqcli/save.dat` -- Terminal interface that comes in 3 flavors: - - Rich and colorful, but CPU intensive (`--urwid`) - - Simple, but easy on CPU, friendly for embedded environments (`--curses`) +- Terminal interface that comes in 2 flavors: + - Rich and colorful (`--curses`, default) - Minimal, suitable for raw grind (`--basic`) - Ideal to run on your server ## How it looks like -Urwid version: - -![Screenshot](screen-urwid.png) - -Curses version: +Curses interface: ![Screenshot](screen-curses.png) -Basic version: +Basic interface: ![Screenshot](screen-basic.png) diff --git a/pqcli/__main__.py b/pqcli/__main__.py index 9638aef..3ccf910 100644 --- a/pqcli/__main__.py +++ b/pqcli/__main__.py @@ -6,7 +6,6 @@ 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" @@ -30,19 +29,12 @@ def parse_args() -> argparse.Namespace: 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-colors", dest="colors", action="store_false", - help="Disable color highlighting in curses and urwid interface", + help="Disable color highlighting in curses interface", ) parser.add_argument( diff --git a/pqcli/ui/urwid/__init__.py b/pqcli/ui/urwid/__init__.py deleted file mode 100644 index fa19c21..0000000 --- a/pqcli/ui/urwid/__init__.py +++ /dev/null @@ -1,155 +0,0 @@ -import argparse -import json -import typing as T - -import urwid - -from pqcli import lingo, random -from pqcli.mechanic import Player -from pqcli.roster import Roster -from pqcli.ui.urwid.views import ConfirmView, GameView, NewGameView, RosterView - -from ..base import BaseUserInterface - -TICK_FREQ = 0.1 -SAVE_FREQ = 60.0 -PALETTE_COLORS: T.List[T.Tuple[str, ...]] = [ - ("logo", "light green", ""), - ("button", "", ""), - ("button-focus", "", "dark gray"), - ("linebox", "", ""), - ("linebox-focus", "", "dark gray"), - ("linebox-content", "", ""), - ("linebox-content-focus", "", ""), - ("progressbar-normal", "", "", "standout"), - ("progressbar-done", "black", "light red"), - ("progressbar-smooth", "light red", ""), - ("scrollbar", "dark gray", "black"), - ("scrollbar-focus", "dark gray", "black"), - ("scrollable", "", ""), - ("scrollable-focus", "", ""), -] -PALETTE_NO_COLORS: T.List[T.Tuple[str, ...]] = [ - ("button-focus", "", "", "standout"), - ("linebox-focus", "", "", "standout"), - ("progressbar-normal", "black", "white"), - ("progressbar-done", "black", "white", "standout"), - ("progressbar-smooth", "black", "white"), -] - - -class ConfirmExitDialog(ConfirmView): - def __init__(self, old_view: urwid.Widget) -> None: - super().__init__("Really quit?", old_view) - - -def bind_commands() -> None: - for key, direction in { - "k": "up", - "j": "down", - "h": "left", - "l": "right", - }.items(): - for key_variant in {key, key.upper()}: - urwid.command_map[key_variant] = f"cursor {direction}" - - -class UrwidUserInterface(BaseUserInterface): - def __init__(self, roster: Roster, args: argparse.Namespace) -> None: - super().__init__(roster, args) - - bind_commands() - - self.loop = urwid.MainLoop( - None, - PALETTE_COLORS if args.colors else PALETTE_NO_COLORS, - unhandled_input=self.unhandled_input, - ) - if not args.colors: - self.loop.screen.set_terminal_properties(1) - self.old_views: T.List[urwid.Widget] = [] - - self.switch_to_roster_view() - - def run(self) -> None: - self.loop.set_alarm_in(TICK_FREQ, self.on_tick) - self.loop.set_alarm_in(SAVE_FREQ, self.on_save) - self.loop.run() - - def unhandled_input(self, key: str) -> bool: - callback = getattr(self.loop.widget, "unhandled_input", None) - if callback and callback(key): - return True - - if key == "ctrl q": - self.switch_to_exit_view() - return True - - return False - - def switch_to_roster_view(self) -> None: - self.loop.widget = RosterView(self.roster) - self._connect("load_game", self.switch_to_game_view) - self._connect("new_game", self.switch_to_new_game_view) - self._connect("delete_game", self.switch_to_delete_player_view) - self._connect("exit", self.switch_to_exit_view) - - def switch_to_exit_view(self) -> None: - if isinstance(self.loop.widget, ConfirmExitDialog): - return - self.old_views.append(self.loop.widget) - self.loop.widget = ConfirmExitDialog(self.old_views[-1]) - self._connect("confirm", self.exit) - self._connect("cancel", self.cancel_dialog) - - def switch_to_new_game_view(self) -> None: - self.loop.widget = NewGameView() - self._connect("confirm", self.create_player) - self._connect("cancel", self.switch_to_roster_view) - - def create_player(self, player: Player) -> None: - self.roster.players.append(player) - self.switch_to_roster_view() - - def delete_player(self, player_idx: int) -> None: - del self.roster.players[player_idx] - self.switch_to_roster_view() - - def switch_to_game_view(self, player_idx: int) -> None: - player = self.roster.players[player_idx] - self.loop.widget = GameView(self.loop, self.roster, player, self.args) - self._connect("cancel", self.switch_to_roster_view) - - def switch_to_delete_player_view(self, player_idx: int) -> None: - player_name = self.roster.players[player_idx].name - - self.old_views.append(self.loop.widget) - self.loop.widget = ConfirmView( - lingo.terminate_message(player_name), self.old_views[-1] - ) - self._connect("confirm", lambda: self.delete_player(player_idx)) - self._connect("cancel", self.cancel_dialog) - - def exit(self) -> None: - raise urwid.ExitMainLoop() - - def cancel_dialog(self) -> None: - assert len(self.old_views) - self.loop.widget = self.old_views.pop() - - def _connect(self, signal_name: str, callback: T.Callable) -> None: - urwid.signals.connect_signal( - self.loop.widget, - signal_name, - lambda _sender, *args: callback(*args), - ) - - def on_tick(self, _loop: urwid.MainLoop, _user_data: T.Any) -> None: - if hasattr(self.loop.widget, "tick"): - self.loop.widget.tick() - self.loop.set_alarm_in(TICK_FREQ, self.on_tick) - - def on_save(self, _loop: urwid.MainLoop, _user_data: T.Any) -> None: - if self.args.use_saves: - self.roster.save() - self.loop.set_alarm_in(SAVE_FREQ, self.on_save) diff --git a/pqcli/ui/urwid/views/__init__.py b/pqcli/ui/urwid/views/__init__.py deleted file mode 100644 index aeeb41f..0000000 --- a/pqcli/ui/urwid/views/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .confirm_view import ConfirmView -from .game_view import GameView -from .new_game_view import NewGameView -from .roster_view import RosterView diff --git a/pqcli/ui/urwid/views/confirm_view.py b/pqcli/ui/urwid/views/confirm_view.py deleted file mode 100644 index 67173d4..0000000 --- a/pqcli/ui/urwid/views/confirm_view.py +++ /dev/null @@ -1,45 +0,0 @@ -import typing as T - -import urwid - -from pqcli.ui.urwid.widgets import CustomButton, CustomLineBox, NPile - - -class ConfirmView(urwid.Overlay): - signals = ["confirm", "cancel"] - - def __init__(self, label: str, parent: urwid.Widget) -> None: - question = urwid.Text(("bold", label), "center") - yes_btn = CustomButton( - "Yes", hint="Y", on_press=lambda _user_data: self.confirm() - ) - no_btn = CustomButton( - "No", hint="N", on_press=lambda _user_data: self.cancel() - ) - - line_box = CustomLineBox( - urwid.Filler(NPile([question, no_btn, yes_btn], outermost=True)) - ) - - super().__init__( - line_box, parent, "center", len(label) + 6, "middle", 5 - ) - - def keypress(self, size: T.Any, key: str) -> T.Optional[str]: - if key in {"y", "Y"}: - self.confirm() - return None - if key in {"n", "N"}: - self.cancel() - return None - return T.cast(T.Optional[str], super().keypress(size, key)) - - def confirm(self) -> None: - self._emit("confirm") - - def cancel(self) -> None: - self._emit("cancel") - - def tick(self) -> None: - if hasattr(self.bottom_w, "tick"): - self.bottom_w.tick() diff --git a/pqcli/ui/urwid/views/game_view.py b/pqcli/ui/urwid/views/game_view.py deleted file mode 100644 index 1815e2c..0000000 --- a/pqcli/ui/urwid/views/game_view.py +++ /dev/null @@ -1,459 +0,0 @@ -import argparse -import contextlib -import datetime -import typing as T - -import urwid - -from pqcli.config import EquipmentType, StatType -from pqcli.lingo import act_name, to_roman -from pqcli.mechanic import ( - InventoryItem, - Player, - SignalMixin, - Simulation, - Spell, -) -from pqcli.roster import Roster -from pqcli.ui.urwid.widgets import ( - CustomLineBox, - CustomProgressBar, - DataTable, - DoubleLineBox, - NColumns, - NPile, - ReadOnlyCheckBox, - Scrollable, - ScrollBar, -) - - -class CharacterSheetView(DoubleLineBox): - def __init__(self, player: Player) -> None: - self.player = player - - self.level_text = urwid.Text("") - self.stat_texts = {stat: urwid.Text("") for stat in list(StatType)} - self.exp_bar = CustomProgressBar(show_time=True) - - data_table = DataTable(columns=[(urwid.WEIGHT, 1), (urwid.WEIGHT, 2)]) - data_table.add_row(urwid.Text("Name"), urwid.Text(player.name)) - data_table.add_row(urwid.Text("Race"), urwid.Text(player.race.name)) - data_table.add_row(urwid.Text("Class"), urwid.Text(player.class_.name)) - data_table.add_row(urwid.Text("Level"), self.level_text) - data_table.add_row(urwid.Divider(), urwid.Divider()) - for stat in StatType: - data_table.add_row(urwid.Text(stat.value), self.stat_texts[stat]) - - self.player.connect("level_up", self.sync_level) - self.player.stats.connect("change", self.sync_stats) - self.player.exp_bar.connect("change", self.sync_exp) - - super().__init__( - top_widget=ScrollBar(Scrollable(data_table)), - bottom_widget=self.exp_bar, - top_title="Character Sheet", - bottom_title="Experience", - ) - self.sync() - - def sync(self) -> None: - self.sync_level() - self.sync_stats() - self.sync_exp() - - def sync_level(self) -> None: - self.level_text.set_text(str(self.player.level)) - - def sync_stats(self) -> None: - for stat in StatType: - self.stat_texts[stat].set_text(str(self.player.stats[stat])) - - def sync_exp(self) -> None: - cur = self.player.exp_bar.position - max_ = self.player.exp_bar.max_ - self.exp_bar.reset(cur, max_) - self.set_bottom_title(f"Experience ({max_-cur:.0f} XP to go)") - - -class SpellBookView(CustomLineBox): - def __init__(self, player: Player) -> None: - self.player = player - - self.data_table = DataTable(columns=[(urwid.WEIGHT, 3), (urwid.PACK,)]) - self.scrollable = Scrollable(self.data_table) - - self.player.spell_book.connect("add", self.sync_spell_add) - self.player.spell_book.connect("change", self.sync_spell_change) - - super().__init__(ScrollBar(self.scrollable), title="Spell Book") - self.sync() - - def sync(self) -> None: - self.data_table.delete_rows(0, len(self.data_table.data_rows)) - for spell in self.player.spell_book: - self.sync_spell_add(spell) - - def sync_spell_add(self, spell: Spell) -> None: - self.data_table.add_row( - urwid.Text(spell.name), urwid.Text(to_roman(spell.level)) - ) - self.scrollable.set_scrollpos(len(self.data_table.data_rows) - 1) - - def sync_spell_change(self, spell: Spell) -> None: - for row_widgets in self.data_table.data_rows: - if row_widgets[0].text == spell.name: - row_widgets[1].set_text(to_roman(spell.level)) - self.data_table.resize_columns() - - -class EquipmentView(CustomLineBox): - def __init__(self, player: Player) -> None: - self.player = player - - self.value_texts = { - equipment_type: urwid.Text("") for equipment_type in EquipmentType - } - data_table = DataTable(columns=[(urwid.WEIGHT, 1), (urwid.WEIGHT, 3)]) - for equipment_type in EquipmentType: - data_table.add_row( - urwid.Text(equipment_type.value), - self.value_texts[equipment_type], - ) - - self.player.equipment.connect("change", self.sync_equipment_change) - - super().__init__(ScrollBar(Scrollable(data_table)), title="Equipment") - self.sync() - - def sync(self) -> None: - for equipment_type in EquipmentType: - self.sync_equipment_change( - equipment_type, self.player.equipment[equipment_type] - ) - - def sync_equipment_change( - self, equipment_type: EquipmentType, item_name: T.Optional[str] - ) -> None: - self.value_texts[equipment_type].set_text(item_name or "") - - -class InventoryView(DoubleLineBox): - def __init__(self, player: Player) -> None: - self.player = player - - self.gold_text = urwid.Text("", align=urwid.RIGHT) - self.data_table = DataTable(columns=[(urwid.WEIGHT, 3), (urwid.PACK,)]) - self.data_table.add_row(urwid.Text("Gold"), self.gold_text) - self.scrollable = Scrollable(self.data_table) - self.encumbrance_bar = CustomProgressBar(show_time=False) - - 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_position - ) - - super().__init__( - top_widget=ScrollBar(self.scrollable), - top_title="Inventory", - bottom_widget=self.encumbrance_bar, - bottom_title="Encumbrance", - ) - self.sync() - - def sync(self) -> None: - self.sync_gold() - self.sync_items() - self.sync_encumbrance_position() - - def sync_gold(self) -> None: - self.gold_text.set_text(str(self.player.inventory.gold)) - - def sync_items(self) -> None: - self.data_table.delete_rows(1, len(self.data_table.data_rows) - 1) - for item in self.player.inventory: - self.sync_item_add(item) - - def sync_encumbrance_position(self) -> None: - cur = self.player.inventory.encum_bar.position - max_ = self.player.inventory.encum_bar.max_ - self.encumbrance_bar.reset(cur, max_) - self.set_bottom_title(f"Encumbrance ({cur:.0f}/{max_} cubits)") - - def sync_item_add(self, item: InventoryItem) -> None: - self.data_table.add_row( - urwid.Text(item.name), - urwid.Text(str(item.quantity), align=urwid.RIGHT), - ) - self.scrollable.set_scrollpos(self.scrollable.rows_max() - 1) - - def sync_item_del(self, item: InventoryItem) -> None: - idx = self.get_item_idx_by_name(item.name) - if idx is not None: - self.data_table.delete_row(idx) - - def sync_item_change(self, item: InventoryItem) -> None: - idx = self.get_item_idx_by_name(item.name) - if idx is not None: - self.data_table.data_rows[idx][1].set_text(str(item.quantity)) - - def get_item_idx_by_name(self, name: str) -> T.Optional[int]: - for i, row_widgets in enumerate(self.data_table.data_rows): - if row_widgets[0].text == name: - return i - return None - - def on_new_task(self) -> None: - description = self.player.task.description if self.player.task else "?" - if description.lower().startswith("sell"): - self.scrollable.set_scrollpos(0) - - -class PlotView(DoubleLineBox): - cutoff = 100 - - def __init__(self, player: Player) -> None: - self.player = player - - self.data_table = DataTable(columns=[(urwid.WEIGHT, 1)]) - self.scrollable = Scrollable(self.data_table) - self.plot_bar = CustomProgressBar(show_time=True) - - self.player.quest_book.connect("start_act", self.sync_act_add) - self.player.quest_book.plot_bar.connect("change", self.sync_position) - - super().__init__( - top_widget=ScrollBar(self.scrollable), - top_title="Plot Development", - bottom_widget=self.plot_bar, - ) - self.sync() - - def sync(self) -> None: - self.sync_acts() - self.sync_position() - - def sync_acts(self) -> None: - self.data_table.delete_rows(0, len(self.data_table.data_rows)) - 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) - - def sync_act_add(self, act_number: int) -> None: - self.data_table.delete_rows( - 0, max(0, self.data_table.row_count - self.cutoff) - ) - if self.data_table.data_rows: - self.data_table.data_rows[-1][0].set_state(True) - self.data_table.add_row( - ReadOnlyCheckBox(act_name(act_number), state=False) - ) - self.scrollable.set_scrollpos(self.scrollable.rows_max() - 1) - - def sync_position(self) -> None: - self.plot_bar.reset( - self.player.quest_book.plot_bar.position, - self.player.quest_book.plot_bar.max_, - ) - - -class QuestBookView(DoubleLineBox): - cutoff = 100 - - def __init__(self, player: Player) -> None: - self.player = player - - self.data_table = DataTable(columns=[(urwid.WEIGHT, 1)]) - self.quest_bar = CustomProgressBar(show_time=True) - self.scrollable = Scrollable(self.data_table) - - self.player.quest_book.connect("start_quest", self.sync_quest_add) - self.player.quest_book.quest_bar.connect("change", self.sync_position) - - super().__init__( - top_widget=ScrollBar(self.scrollable), - top_title="Quests", - bottom_widget=self.quest_bar, - ) - self.sync() - - def sync(self) -> None: - self.sync_quests() - self.sync_position() - - def sync_quests(self) -> None: - for quest_name in self.player.quest_book.quests[-self.cutoff :]: - self.sync_quest_add(quest_name) - - def sync_quest_add(self, quest_name: str) -> None: - self.data_table.delete_rows( - 0, max(0, self.data_table.row_count - self.cutoff) - ) - if self.data_table.data_rows: - self.data_table.data_rows[-1][0].set_state(True) - self.data_table.add_row(ReadOnlyCheckBox(quest_name, state=False)) - self.scrollable.set_scrollpos(self.scrollable.rows_max() - 1) - - def sync_position(self) -> None: - self.quest_bar.reset( - self.player.quest_book.quest_bar.position, - self.player.quest_book.quest_bar.max_, - ) - - -class TaskView(NPile): - def __init__(self, player: Player) -> None: - self.player = player - - self.current_task_text = urwid.Text("...") - self.current_task_bar = CustomProgressBar(show_time=False) - - self.player.connect("new_task", self.sync_task_name) - self.player.task_bar.connect("change", self.sync_position) - - super().__init__([self.current_task_text, self.current_task_bar]) - self.sync() - - def sync(self) -> None: - self.sync_task_name() - self.sync_position() - - def sync_task_name(self) -> None: - self.current_task_text.set_text( - f"{self.player.task.description}..." if self.player.task else "?" - ) - - def sync_position(self) -> None: - self.current_task_bar.reset( - self.player.task_bar.position, self.player.task_bar.max_ - ) - - def pack(self, size: T.Tuple[int, int]) -> T.Tuple[int, int]: - return (size[0], 2) - - -class GameView(NPile): - signals = ["cancel"] - - def __init__( - self, - loop: urwid.MainLoop, - roster: Roster, - player: Player, - args: argparse.Namespace, - ) -> None: - self.loop = loop - self.roster = roster - self.player = player - self.args = args - - self.simulation = Simulation(player) - self.last_tick = datetime.datetime.now() - - self.character_sheet_view = CharacterSheetView(player) - self.spell_book_view = SpellBookView(player) - self.equipment_view = EquipmentView(player) - self.inventory_view = InventoryView(player) - self.plot_view = PlotView(player) - self.quest_book_view = QuestBookView(player) - self.task_view = TaskView(player) - - self.columns = NColumns( - [ - ( - urwid.WEIGHT, - 4, - NPile( - [(19, self.character_sheet_view), self.spell_book_view] - ), - ), - ( - urwid.WEIGHT, - 5, - NPile([(15, self.equipment_view), self.inventory_view]), - ), - ( - urwid.WEIGHT, - 4, - NPile([(15, self.plot_view), self.quest_book_view]), - ), - ] - ) - - super().__init__( - [self.columns, (urwid.PACK, self.task_view)], outermost=True - ) - - def cancel(self) -> None: - self._emit("cancel") - - def tick(self) -> None: - now = datetime.datetime.now() - elapsed = (now - self.last_tick).total_seconds() - self.simulation.tick(elapsed * 1000) - self.last_tick = datetime.datetime.now() - if self.args.use_saves: - self.roster.save_periodically() - - def keypress(self, size: T.Tuple[int, int], key: str) -> bool: - if key == "esc": - self.cancel() - return None - - if self._command_map[key] == urwid.CURSOR_RIGHT: - old_focus_position = self.columns.focus.focus_position - self.columns.focus_position = min( - self.columns.focus_position + 1, len(self.columns.contents) - 1 - ) - self.columns.focus.focus_position = old_focus_position - return None - - if self._command_map[key] == urwid.CURSOR_LEFT: - old_focus_position = self.columns.focus.focus_position - self.columns.focus_position = max( - self.columns.focus_position - 1, 0 - ) - self.columns.focus.focus_position = old_focus_position - return None - - if self.args.cheats: - if key == "e": - self.simulation.player.exp_bar.increment(1) - return None - - fast_forward = {"t": 1, "T": 100, "ctrl t": 10000} - if key in fast_forward: - iterations = fast_forward[key] - ctx_mgr = ( - self.suppress_updates() - if iterations >= 1000 - else contextlib.suppress() - ) - - with ctx_mgr: - for _ in range(iterations): - self.simulation.tick() - - return None - - return super().keypress(size, key) - - @contextlib.contextmanager - def suppress_updates(self) -> T.Generator: - old = SignalMixin.emit - SignalMixin.emit = lambda *_: None - yield - self.character_sheet_view.sync() - self.spell_book_view.sync() - self.equipment_view.sync() - self.inventory_view.sync() - self.plot_view.sync() - self.quest_book_view.sync() - self.task_view.sync() - SignalMixin.emit = old diff --git a/pqcli/ui/urwid/views/new_game_view.py b/pqcli/ui/urwid/views/new_game_view.py deleted file mode 100644 index 0073b81..0000000 --- a/pqcli/ui/urwid/views/new_game_view.py +++ /dev/null @@ -1,218 +0,0 @@ -import random -import typing as T - -import urwid -import urwid_readline - -from pqcli.config import CLASSES, PRIME_STATS, RACES -from pqcli.mechanic import StatsBuilder, create_player, generate_name -from pqcli.ui.urwid.widgets import CustomButton, CustomLineBox, NColumns, NPile - - -class StatsBox(CustomLineBox): - def __init__(self) -> None: - self.stats_builder = StatsBuilder() - self.stats = self.stats_builder.roll() - self.stat_labels = {stat: urwid.Text("0") for stat in PRIME_STATS} - self.total_label = urwid.Text("0") - self.update_values() - - roll_button = CustomButton( - "Roll", hint="F5", on_press=self.on_roll_press - ) - unroll_button = CustomButton( - "Unroll", hint="F6", on_press=self.on_unroll_press - ) - - value_texts = list(self.stat_labels.values()) - label_texts = [urwid.Text(f"{stat.value}: ") for stat in PRIME_STATS] - - super().__init__( - urwid.Filler( - NPile( - [ - NColumns([NPile(label_texts), NPile(value_texts)]), - urwid.Divider(), - NColumns([urwid.Text("Total: "), self.total_label]), - urwid.Divider(), - roll_button, - unroll_button, - ] - ) - ), - title="Stats", - ) - - def roll(self) -> None: - self.stats = self.stats_builder.roll() - self.update_values() - - def unroll(self) -> None: - self.stats = self.stats_builder.unroll() - self.update_values() - - def on_roll_press(self, _user_data: T.Any) -> None: - self.roll() - - def on_unroll_press(self, _user_data: T.Any) -> None: - self.unroll() - - def update_values(self) -> None: - for stat, edit in self.stat_labels.items(): - edit.set_text(str(self.stats[stat])) - self.total_label.set_text( - str(sum(self.stats[stat] for stat in self.stat_labels.keys())) - ) - - -class RaceBox(CustomLineBox): - def __init__(self) -> None: - self.race = random.choice(RACES) - - radio_buttons: T.List[urwid.RadioButton] = [] - for race in RACES: - urwid.RadioButton( - group=radio_buttons, - label=race.name, - state=race.name == self.race.name, - on_state_change=self.on_state_change, - user_data=race, - ) - - super().__init__(urwid.ListBox(radio_buttons), title="Race") - - def on_state_change( - self, _widget: urwid.RadioButton, new_state: bool, user_data: T.Any - ) -> None: - if new_state: - self.race = user_data - - -class ClassBox(CustomLineBox): - def __init__(self) -> None: - self.class_ = random.choice(CLASSES) - - radio_buttons: T.List[urwid.RadioButton] = [] - for class_ in CLASSES: - urwid.RadioButton( - group=radio_buttons, - label=class_.name, - state=class_.name == self.class_.name, - on_state_change=self.on_state_change, - user_data=class_, - ) - - super().__init__(urwid.ListBox(radio_buttons), title="Class") - - def on_state_change( - self, _widget: urwid.RadioButton, new_state: bool, user_data: T.Any - ) -> None: - if new_state: - self.class_ = user_data - - -class NewGameView(NPile): - signals = ["confirm", "cancel"] - - def __init__(self) -> None: - self.race_box = RaceBox() - self.class_box = ClassBox() - self.stats_box = StatsBox() - - self.char_name_edit = urwid_readline.ReadlineEdit( - "Name: ", generate_name() - ) - generate_char_name_btn = CustomButton( - "Generate random name", - hint="F7", - on_press=self.on_generate_char_name_press, - ) - - buttons_box = urwid.Filler( - urwid.Padding( - NPile( - [ - CustomButton( - "Sold!", hint="F10", on_press=self.on_confirm_press - ), - CustomButton( - "Cancel", hint="Esc", on_press=self.on_cancel_press - ), - ] - ), - width=20, - align="center", - ) - ) - - super().__init__( - [ - (1, urwid.Filler(self.char_name_edit)), - ( - 1, - urwid.Filler( - urwid.Padding( - generate_char_name_btn, - left=len(self.char_name_edit.caption), - width=25, - ) - ), - ), - NColumns( - [ - (urwid.WEIGHT, 2, self.race_box), - (urwid.WEIGHT, 2, self.class_box), - NPile([(13, self.stats_box), buttons_box]), - ] - ), - ], - outermost=True, - ) - - def generate_random_char_name(self) -> None: - self.char_name_edit.edit_text = generate_name() - self.char_name_edit.edit_pos = len(self.char_name_edit.edit_text) - - def confirm(self) -> None: - player = create_player( - name=self.char_name_edit.edit_text, - race=self.race_box.race, - class_=self.class_box.class_, - stats=self.stats_box.stats, - ) - self._emit("confirm", player) - - def cancel(self) -> None: - self._emit("cancel") - - def on_generate_char_name_press(self, _user_data: T.Any) -> None: - self.generate_random_char_name() - - def on_confirm_press(self, _user_data: T.Any) -> None: - self.confirm() - - def on_cancel_press(self, _user_data: T.Any) -> None: - self.cancel() - - def unhandled_input(self, key: str) -> bool: - if key == "esc": - self.cancel() - return True - - if key == "f7": - self.generate_random_char_name() - return True - - if key == "f5": - self.stats_box.roll() - return True - - if key == "f6": - self.stats_box.unroll() - return True - - if key == "f10": - self.confirm() - return True - - return False diff --git a/pqcli/ui/urwid/views/roster_view.py b/pqcli/ui/urwid/views/roster_view.py deleted file mode 100644 index 3f39a01..0000000 --- a/pqcli/ui/urwid/views/roster_view.py +++ /dev/null @@ -1,116 +0,0 @@ -import typing as T - -import urwid - -from pqcli.lingo import act_name, to_roman -from pqcli.roster import Roster -from pqcli.ui.urwid.widgets import CustomButton, NColumns, NPile - - -class RosterView(urwid.Filler): - signals = ["load_game", "new_game", "delete_game", "exit"] - - def __init__(self, roster: Roster) -> None: - self.roster = roster - - logo = urwid.AttrMap( - urwid.BigText("Progress Quest", urwid.HalfBlock5x4Font()), "logo" - ) - - buttons = [] - for player_idx, player in enumerate(self.roster.players): - best_equip = player.equipment.best - - best_spell = player.spell_book.best - if best_spell: - best_spell_name = ( - best_spell.name + " " + to_roman(best_spell.level) - ) - else: - best_spell_name = "-" - - best_stat_name = ( - f"{player.stats.best_prime.value} " - f"{player.stats[player.stats.best_prime]}" - ) - - label = ( - f"{player.name} the {player.race.name} " - f"({act_name(player.quest_book.act)})\n" - f"Level {player.level} {player.class_.name}\n" - f"{best_equip} / {best_spell_name} / {best_stat_name}" - ) - - buttons.append( - NColumns( - [ - CustomButton( - label=label, - on_press=self.on_resume_game_press, - user_data=player_idx, - ), - ( - 10, - CustomButton( - label="Delete", - on_press=self.on_delete_game_press, - user_data=player_idx, - ), - ), - ] - ) - ) - buttons.append(urwid.Divider()) - - buttons.append( - CustomButton( - label="Create a new character", - hint="F1", - on_press=self.on_new_game_press, - ) - ) - - buttons.append( - CustomButton(label="Exit", hint="Esc", on_press=self.on_exit_press) - ) - - super().__init__( - urwid.Padding( - NPile( - [urwid.Padding(logo, width="clip"), urwid.Divider()] - + buttons, - outermost=True, - ), - align="center", - width=logo.pack()[0], - ) - ) - - def on_resume_game_press( - self, _widget: urwid.Widget, user_data: T.Any - ) -> None: - player_idx = T.cast(int, user_data) - self._emit("load_game", player_idx) - - def on_delete_game_press( - self, _widget: urwid.Widget, user_data: T.Any - ) -> None: - player_idx = T.cast(int, user_data) - self._emit("delete_game", player_idx) - - def on_new_game_press(self, _user_data: T.Any) -> None: - self._emit("new_game") - - def on_exit_press(self, _user_data: T.Any) -> None: - self._emit("exit") - - def unhandled_input(self, key: str) -> bool: - if key == "esc": - self._emit("exit") - return True - - if key == "f1": - self._emit("new_game") - return True - - return False diff --git a/pqcli/ui/urwid/widgets/__init__.py b/pqcli/ui/urwid/widgets/__init__.py deleted file mode 100644 index f1a6879..0000000 --- a/pqcli/ui/urwid/widgets/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -from .custom_button import CustomButton -from .custom_line_box import CustomLineBox -from .custom_progress_bar import CustomProgressBar -from .data_table import DataTable -from .double_line_box import DoubleLineBox -from .layout import NColumns, NPile -from .read_only_check_box import ReadOnlyCheckBox -from .scrollable import Scrollable, ScrollBar diff --git a/pqcli/ui/urwid/widgets/custom_button.py b/pqcli/ui/urwid/widgets/custom_button.py deleted file mode 100644 index 3642fe2..0000000 --- a/pqcli/ui/urwid/widgets/custom_button.py +++ /dev/null @@ -1,31 +0,0 @@ -import typing as T - -import urwid - -_MAGIC = 999 # where to display the cursor (high value = don't display it) - - -class CustomButton(urwid.Button): - def __init__( - self, - label: str, - *args: T.Any, - hint: T.Optional[str] = None, - **kwargs: T.Any, - ) -> None: - super().__init__("", *args, **kwargs) - self._w = urwid.AttrMap( - urwid.Columns( - [ - (urwid.PACK, urwid.Text("< ")), - urwid.SelectableIcon([label], _MAGIC), - ( - urwid.PACK, - urwid.Text(f"[{hint}]" if hint else "", align="right"), - ), - (urwid.PACK, urwid.Text(" >")), - ] - ), - "button", - "button-focus", - ) diff --git a/pqcli/ui/urwid/widgets/custom_line_box.py b/pqcli/ui/urwid/widgets/custom_line_box.py deleted file mode 100644 index 979b740..0000000 --- a/pqcli/ui/urwid/widgets/custom_line_box.py +++ /dev/null @@ -1,18 +0,0 @@ -import typing as T - -import urwid - - -class CustomLineBox(urwid.AttrMap): - def __init__( - self, widget: urwid.Widget, *args: T.Any, **kwargs: T.Any - ) -> None: - super().__init__( - urwid.LineBox( - urwid.AttrMap(widget, "linebox-content", "linebox-content"), - *args, - **kwargs, - ), - "linebox", - "linebox-focus", - ) diff --git a/pqcli/ui/urwid/widgets/custom_progress_bar.py b/pqcli/ui/urwid/widgets/custom_progress_bar.py deleted file mode 100644 index d555c4f..0000000 --- a/pqcli/ui/urwid/widgets/custom_progress_bar.py +++ /dev/null @@ -1,100 +0,0 @@ -import datetime -import typing as T - -import urwid - -from pqcli.lingo import format_timespan - - -class CustomProgressBar(urwid.Widget): - _sizing = frozenset(["flow"]) - text_align = "center" - eighths = " ▏▎▍▌▋▊▉" - - def __init__(self, show_time: bool) -> None: - self.show_time = show_time - self.position = 0.0 - self.max_ = 1.0 - self.normal = "progressbar-normal" - self.complete = "progressbar-done" - self.smooth = "progressbar-smooth" - self.last_tick: T.Optional[T.Tuple[datetime.datetime, float]] = None - - @property - def time_left(self) -> T.Optional[datetime.timedelta]: - if self.last_tick is None: - return None - time_now = datetime.datetime.now() - time_then = self.last_tick[0] - pos_now = self.position - pos_then = self.last_tick[1] - speed = (pos_now - pos_then) / (time_now - time_then).total_seconds() - if not speed: - return None - return datetime.timedelta(seconds=(self.max_ - pos_now) / speed) - - def set_completion(self, value: float) -> None: - if self.last_tick is None or value == 0: - self.last_tick = (datetime.datetime.now(), value) - self.position = value - self._invalidate() - - def set_max(self, value: T.Union[int, float]) -> None: - if self.max_ == value: - return - self.last_tick = None - self.max_ = value - self._invalidate() - - def reset(self, cur: float, max_: T.Union[int, float]) -> None: - if self.last_tick is None or cur == 0 or max_ != self.max_: - self.last_tick = (datetime.datetime.now(), cur) - self.position = cur - self.max_ = max_ - self._invalidate() - - def rows(self, size: T.Tuple[int, int], focus: bool = False) -> int: - return 1 - - def get_text(self) -> str: - percent = min(100.0, max(0.0, self.position * 100.0 / self.max_)) - ret = f"{percent:.02f}%" - if self.time_left and self.show_time: - ret += f" ({format_timespan(self.time_left)})" - return ret - - # workaround for https://github.com/urwid/urwid/issues/317 - def render(self, size: T.Tuple[int, int], focus: bool = False) -> T.Any: - (maxcol,) = size - txt = urwid.Text(self.get_text(), self.text_align, "clip") - c = txt.render((maxcol,)) - t = c._text[0] - - cf = self.position * maxcol / self.max_ - ccol_dirty = int(cf) - ccol = len( - c._text[0][:ccol_dirty].decode("utf-8", "ignore").encode("utf-8") - ) - - cs = 0 - cs = int((cf - ccol) * 8) - - if ccol < 0 or (ccol == 0 and cs == 0): - c._attr = [[(self.normal, maxcol)]] - elif ccol >= maxcol: - c._attr = [[(self.complete, maxcol)]] - elif cs and c._text[0][ccol] == 32: - t = c._text[0] - cenc = self.eighths[cs].encode("utf-8") - c._text[0] = t[:ccol] + cenc + t[ccol + 1 :] - a = [] - if ccol > 0: - a.append((self.complete, ccol)) - a.append((self.smooth, len(cenc))) - if maxcol - ccol - 1 > 0: - a.append((self.normal, maxcol - ccol - 1)) - c._attr = [a] - c._cs = [[(None, len(c._text[0]))]] - else: - c._attr = [[(self.complete, ccol), (self.normal, maxcol - ccol)]] - return c diff --git a/pqcli/ui/urwid/widgets/data_table.py b/pqcli/ui/urwid/widgets/data_table.py deleted file mode 100644 index a839f81..0000000 --- a/pqcli/ui/urwid/widgets/data_table.py +++ /dev/null @@ -1,64 +0,0 @@ -import typing as T - -import urwid - -from pqcli.ui.urwid.widgets.layout import NColumns, NPile - - -class DataTable(NPile): - def __init__(self, columns: T.Any) -> None: - self.column_count = len(columns) - self._columns = columns - self._rows: T.List[T.List[urwid.Widget]] = [] - super().__init__([]) - - @property - def row_count(self) -> int: - return len(self._rows) - - @property - def data_rows(self) -> T.Iterable[T.List[urwid.Widget]]: - # "rows" was already used by urwid - return self._rows - - def add_row(self, *widgets: T.List[urwid.Widget]) -> None: - if len(widgets) != self.column_count: - raise VauleError(f"Expected {self.column_count} widgets") - - self.contents.append( - ( - NColumns( - list((*x, y) for x, y in zip(self._columns, widgets)) - ), - (urwid.PACK, 1), - ) - ) - - self.resize_columns() - self._rows.append(widgets) - - def delete_row(self, idx: int) -> None: - del self.contents[idx] - del self._rows[idx] - self.resize_columns() - - def delete_rows(self, start_idx: int, end_idx: int) -> None: - del self.contents[start_idx:end_idx] - del self._rows[start_idx:end_idx] - self.resize_columns() - - def resize_columns(self) -> None: - if not self._rows: - return - - for i, sizing in enumerate(self._columns): - if isinstance(sizing, tuple) and sizing[0] == urwid.PACK: - width = max( - row_widgets[i].pack()[0] for row_widgets in self._rows - ) - for item in self.contents: - columns = item[0] - columns.contents[i] = ( - columns.contents[i][0], - (urwid.GIVEN, width, None), - ) diff --git a/pqcli/ui/urwid/widgets/double_line_box.py b/pqcli/ui/urwid/widgets/double_line_box.py deleted file mode 100644 index 4296588..0000000 --- a/pqcli/ui/urwid/widgets/double_line_box.py +++ /dev/null @@ -1,34 +0,0 @@ -import urwid - -from pqcli.ui.urwid.widgets.layout import NPile - - -class DoubleLineBox(urwid.AttrMap): - def __init__( - self, - top_widget: urwid.Widget, - bottom_widget: urwid.Widget, - top_title: str = "", - bottom_title: str = "", - ) -> None: - self.top_line_box = urwid.LineBox( - urwid.AttrMap(top_widget, "linebox-content", "linebox-content"), - title=top_title, - bline="", - ) - - self.bottom_line_box = urwid.LineBox( - urwid.AttrMap(bottom_widget, "linebox-content", "linebox-content"), - title=bottom_title, - tlcorner="├", - trcorner="┤", - ) - - super().__init__( - NPile([self.top_line_box, (urwid.PACK, self.bottom_line_box)]), - "linebox", - "linebox-focus", - ) - - def set_bottom_title(self, text: str) -> None: - self.bottom_line_box.set_title(text) diff --git a/pqcli/ui/urwid/widgets/layout.py b/pqcli/ui/urwid/widgets/layout.py deleted file mode 100644 index 6580fa0..0000000 --- a/pqcli/ui/urwid/widgets/layout.py +++ /dev/null @@ -1,119 +0,0 @@ -# Copyright (c) 2013-2016 Christian Geier et al. -# -# Permission is hereby granted, free of charge, to any person obtaining -# a copy of this software and associated documentation files (the -# "Software"), to deal in the Software without restriction, including -# without limitation the rights to use, copy, modify, merge, publish, -# distribute, sublicense, and/or sell copies of the Software, and to -# permit persons to whom the Software is furnished to do so, subject to -# the following conditions: -# -# The above copyright notice and this permission notice shall be -# included in all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -import typing as T - -import urwid - - -class SupportsNext: - """ - Classes inheriting from SupportsNext must implement the following methods: - _select_first_selectable - _select_last_selectable - """ - - def __init__(self, *args, **kwargs): - self.outermost = kwargs.get("outermost", False) - if "outermost" in kwargs: - kwargs.pop("outermost") - super().__init__(*args, **kwargs) - - -class NextMixin(SupportsNext): - """Implements SupportsNext for urwid.Pile and urwid.Columns""" - - def _select_first_selectable(self): - """ - select our first selectable item - (recursively if that item SupportsNext) - """ - i = self._first_selectable() - self.set_focus(i) - if isinstance(self.contents[i][0], SupportsNext): - self.contents[i][0]._select_first_selectable() - - def _select_last_selectable(self): - """ - select our last selectable item - (recursively if that item SupportsNext) - """ - i = self._last_selectable() - self.set_focus(i) - if isinstance(self._contents[i][0], SupportsNext): - self.contents[i][0]._select_last_selectable() - - def _first_selectable(self): - """return sequence number of self.contents last selectable item""" - for j in range(0, len(self._contents)): - if self._contents[j][0].selectable(): - return j - return False - - def _last_selectable(self): - """return sequence number of self._contents last selectable item""" - for j in range(len(self._contents) - 1, -1, -1): - if self._contents[j][0].selectable(): - return j - return False - - def keypress(self, size, key) -> T.Optional[str]: - key = super().keypress(size, key) - - if key == "tab": - if ( - self.outermost - and self.focus_position == self._last_selectable() - ): - self._select_first_selectable() - return None - - for i in range(self.focus_position + 1, len(self._contents)): - if self._contents[i][0].selectable(): - self.set_focus(i) - if isinstance(self._contents[i][0], SupportsNext): - self._contents[i][0]._select_first_selectable() - return None - - elif key == "shift tab": - if ( - self.outermost - and self.focus_position == self._first_selectable() - ): - self._select_last_selectable() - return None - - for i in range(self.focus_position - 1, 0 - 1, -1): - if self._contents[i][0].selectable(): - self.set_focus(i) - if isinstance(self._contents[i][0], SupportsNext): - self._contents[i][0]._select_last_selectable() - return None - - return key - - -class NPile(NextMixin, urwid.Pile): - pass - - -class NColumns(NextMixin, urwid.Columns): - pass diff --git a/pqcli/ui/urwid/widgets/read_only_check_box.py b/pqcli/ui/urwid/widgets/read_only_check_box.py deleted file mode 100644 index 94c9a55..0000000 --- a/pqcli/ui/urwid/widgets/read_only_check_box.py +++ /dev/null @@ -1,16 +0,0 @@ -import urwid - - -class ReadOnlyCheckBox(urwid.Text): - def __init__(self, text: str, state: bool = False) -> None: - self.icons = { - key: widget.text for key, widget in urwid.CheckBox.states.items() - } - self._caption = text - - super().__init__("") - - self.set_state(state) - - def set_state(self, state: bool) -> None: - self.set_text(self.icons[state] + " " + self._caption) diff --git a/pqcli/ui/urwid/widgets/scrollable.py b/pqcli/ui/urwid/widgets/scrollable.py deleted file mode 100644 index 9f22953..0000000 --- a/pqcli/ui/urwid/widgets/scrollable.py +++ /dev/null @@ -1,498 +0,0 @@ -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details -# http://www.gnu.org/licenses/gpl-3.0.txt - -# Taken from stig (stig.tui.scroll). -# Changes compared to upstream: -# - reformatted the code -# - added AttrMap wrapping -# - added padding support -# - removed up/down arrow navigation (for widget navigation) - -import typing as T - -import urwid -from urwid.widget import BOX, FIXED, FLOW - -# Scroll actions -SCROLL_LINE_UP = "line up" -SCROLL_LINE_DOWN = "line down" -SCROLL_PAGE_UP = "page up" -SCROLL_PAGE_DOWN = "page down" -SCROLL_TO_TOP = "to top" -SCROLL_TO_END = "to end" - -# Scrollbar positions -SCROLLBAR_LEFT = "left" -SCROLLBAR_RIGHT = "right" - - -class EmptyCanvas(urwid.Canvas): - def __init__(self, cols: int, rows: int) -> None: - super().__init__() - self._text, cs = urwid.apply_target_encoding(" ") - self._cs = cs[0][0] - self.size = cols, rows - self.cursor = None - - def cols(self) -> int: - return self.size[0] - - def rows(self) -> int: - return self.size[1] - - def content( - self, - trim_left: int = 0, - trim_top: int = 0, - cols: T.Optional[int] = None, - rows: T.Optional[int] = None, - attr: T.Optional[str] = None, - ) -> T.Generator: - if cols is None: - cols = self.size[0] - if rows is None: - rows = self.size[1] - - line = [(None, self._cs, self._text * cols)] - for i in range(rows): - yield line - - def content_delta(self, other: T.Any) -> T.Any: - if other is self: - return [self.cols()] * self.rows() - return self.content() - - -class _Scrollable(urwid.WidgetDecoration): - def __init__(self, widget): - """Box widget that makes a fixed or flow widget vertically scrollable - - TODO: Focusable widgets are handled, including switching focus, but - possibly not intuitively, depending on the arrangement of widgets. - When switching focus to a widget that is ouside of the visible part of - the original widget, the canvas scrolls up/down to the focused widget. - It would be better to scroll until the next focusable widget is in - sight first. But for that to work we must somehow obtain a list of - focusable rows in the original canvas. - """ - if not any(s in widget.sizing() for s in (FIXED, FLOW)): - raise ValueError("Not a fixed or flow widget: %r" % widget) - self._trim_top = 0 - self._scroll_action = None - self._forward_keypress = None - self._old_cursor_coords = None - self._rows_max_cached = 0 - super().__init__(widget) - - def sizing(self): - return frozenset([BOX]) - - def selectable(self): - return True - - def render(self, size, focus=False): - maxcol, maxrow = size - - # Render complete original widget - ow = self._original_widget - ow_size = self._get_original_widget_size(size) - canv_full = ow.render(ow_size, focus) - - # Make full canvas editable - canv = urwid.CompositeCanvas(canv_full) - canv_cols, canv_rows = canv.cols(), canv.rows() - - if canv_cols <= maxcol: - pad_width = maxcol - canv_cols - if pad_width > 0: - # Canvas is narrower than available horizontal space - canv.pad_trim_left_right(0, pad_width) - - if canv_rows <= maxrow: - fill_height = maxrow - canv_rows - if fill_height > 0: - # Canvas is lower than available vertical space - canv.pad_trim_top_bottom(0, fill_height) - - if canv_cols <= maxcol and canv_rows <= maxrow: - # Canvas is small enough to fit without trimming - return canv - - self._adjust_trim_top(canv, size) - - # Trim canvas if necessary - trim_top = self._trim_top - trim_end = canv_rows - maxrow - trim_top - trim_right = canv_cols - maxcol - if trim_top > 0: - canv.trim(trim_top) - if trim_end > 0: - canv.trim_end(trim_end) - if trim_right > 0: - canv.pad_trim_left_right(0, -trim_right) - - # Disable cursor display if cursor is outside of visible canvas parts - if canv.cursor is not None: - curscol, cursrow = canv.cursor - if cursrow >= maxrow or cursrow < 0: - canv.cursor = None - - # Figure out whether we should forward keypresses to original widget - if canv.cursor is not None: - # Trimmed canvas contains the cursor, e.g. in an Edit widget - self._forward_keypress = True - else: - if canv_full.cursor is not None: - # Full canvas contains the cursor, but scrolled out of view - self._forward_keypress = False - else: - # Original widget does not have a cursor, but may be selectable - - # FIXME: Using ow.selectable() is bad because the original - # widget may be selectable because it's a container widget with - # a key-grabbing widget that is scrolled out of view. - # ow.selectable() returns True anyway because it doesn't know - # how we trimmed our canvas. - # - # To fix this, we need to resolve ow.focus and somehow - # ask canv whether it contains bits of the focused widget. - # I can't see a way to do that. - if ow.selectable(): - self._forward_keypress = True - else: - self._forward_keypress = False - - return canv - - def keypress(self, size, key): - # Maybe offer key to original widget - if self._forward_keypress: - ow = self._original_widget - ow_size = self._get_original_widget_size(size) - - # Remember previous cursor position if possible - if hasattr(ow, "get_cursor_coords"): - self._old_cursor_coords = ow.get_cursor_coords(ow_size) - - key = ow.keypress(ow_size, key) - if key is None: - return None - - # Handle up/down, page up/down, etc - command_map = self._command_map - if command_map[key] == urwid.CURSOR_PAGE_UP: - self._scroll_action = SCROLL_PAGE_UP - elif command_map[key] == urwid.CURSOR_PAGE_DOWN: - self._scroll_action = SCROLL_PAGE_DOWN - - elif command_map[key] == urwid.CURSOR_MAX_LEFT: # 'home' - self._scroll_action = SCROLL_TO_TOP - elif command_map[key] == urwid.CURSOR_MAX_RIGHT: # 'end' - self._scroll_action = SCROLL_TO_END - - else: - return key - - self._invalidate() - - def mouse_event(self, size, event, button, col, row, focus): - ow = self._original_widget - if hasattr(ow, "mouse_event"): - ow_size = self._get_original_widget_size(size) - row += self._trim_top - return ow.mouse_event(ow_size, event, button, col, row, focus) - else: - return False - - def _adjust_trim_top(self, canv, size): - """Adjust self._trim_top according to self._scroll_action""" - action = self._scroll_action - self._scroll_action = None - - maxcol, maxrow = size - trim_top = self._trim_top - canv_rows = canv.rows() - - if trim_top < 0: - # Negative trim_top values use bottom of canvas as reference - trim_top = canv_rows - maxrow + trim_top + 1 - - if canv_rows <= maxrow: - self._trim_top = 0 # Reset scroll position - return - - def ensure_bounds(new_trim_top): - return max(0, min(canv_rows - maxrow, new_trim_top)) - - if action == SCROLL_LINE_UP: - self._trim_top = ensure_bounds(trim_top - 1) - elif action == SCROLL_LINE_DOWN: - self._trim_top = ensure_bounds(trim_top + 1) - - elif action == SCROLL_PAGE_UP: - self._trim_top = ensure_bounds(trim_top - maxrow + 1) - elif action == SCROLL_PAGE_DOWN: - self._trim_top = ensure_bounds(trim_top + maxrow - 1) - - elif action == SCROLL_TO_TOP: - self._trim_top = 0 - elif action == SCROLL_TO_END: - self._trim_top = canv_rows - maxrow - - else: - self._trim_top = ensure_bounds(trim_top) - - # If the cursor was moved by the most recent keypress, adjust trim_top - # so that the new cursor position is within the displayed canvas part. - # But don't do this if the cursor is at the top/bottom edge so we can still scroll out - if ( - self._old_cursor_coords is not None - and self._old_cursor_coords != canv.cursor - ): - self._old_cursor_coords = None - curscol, cursrow = canv.cursor - if cursrow < self._trim_top: - self._trim_top = cursrow - elif cursrow >= self._trim_top + maxrow: - self._trim_top = max(0, cursrow - maxrow + 1) - - def _get_original_widget_size(self, size): - ow = self._original_widget - sizing = ow.sizing() - if FIXED in sizing: - return () - elif FLOW in sizing: - return (size[0],) - - def get_scrollpos(self, size=None, focus=False): - """Current scrolling position - - Lower limit is 0, upper limit is the maximum number of rows with the - given maxcol minus maxrow. - - NOTE: The returned value may be too low or too high if the position has - changed but the widget wasn't rendered yet. - """ - return self._trim_top - - def set_scrollpos(self, position): - """Set scrolling position - - If `position` is positive it is interpreted as lines from the top. - If `position` is negative it is interpreted as lines from the bottom. - - Values that are too high or too low values are automatically adjusted - during rendering. - """ - self._trim_top = int(position) - self._invalidate() - - def rows_max(self, size=None, focus=False): - """Return the number of rows for `size` - - If `size` is not given, the currently rendered number of rows is returned. - """ - if size is not None: - ow = self._original_widget - ow_size = self._get_original_widget_size(size) - sizing = ow.sizing() - if FIXED in sizing: - self._rows_max_cached = ow.pack(ow_size, focus)[1] - elif FLOW in sizing: - self._rows_max_cached = ow.rows(ow_size, focus) - else: - raise RuntimeError( - "Not a flow/box widget: %r" % self._original_widget - ) - return self._rows_max_cached - - -class _ScrollBar(urwid.WidgetDecoration): - def __init__( - self, - widget, - thumb_char="\N{FULL BLOCK}", - trough_char=" ", - side=SCROLLBAR_RIGHT, - width=1, - padding=0, - ): - """Box widget that adds a scrollbar to `widget` - - `widget` must be a box widget with the following methods: - - `get_scrollpos` takes the arguments `size` and `focus` and returns - the index of the first visible row. - - `set_scrollpos` (optional; needed for mouse click support) takes the - index of the first visible row. - - `rows_max` takes `size` and `focus` and returns the total number of - rows `widget` can render. - - `thumb_char` is the character used for the scrollbar handle. - `trough_char` is used for the space above and below the handle. - `side` must be 'left' or 'right'. - `width` specifies the number of columns the scrollbar uses. - `padding` specifies the number of columns the scrollbar is separated - from the content by. - """ - if BOX not in widget.sizing(): - raise ValueError("Not a box widget: %r" % widget) - super().__init__(widget) - self._thumb_char = thumb_char - self._trough_char = trough_char - self._scrollbar_side = side - self._scrollbar_width = max(1, width) - self._padding_width = padding - self._original_widget_size = (0, 0) - - def sizing(self): - return frozenset((BOX,)) - - def selectable(self): - return True - - def render(self, size, focus=False): - maxcol, maxrow = size - sb_width = self._scrollbar_width - pad_width = self._padding_width - self._original_widget_size = ow_size = ( - maxcol - sb_width - pad_width, - maxrow, - ) - - ow = self._original_widget - ow_base = self.scrolling_base_widget - ow_rows_max = ow_base.rows_max(size, focus) - if ow_rows_max <= maxrow: - # Canvas fits without scrolling - no scrollbar needed - self._original_widget_size = size - return ow.render(size, focus) - ow_rows_max = ow_base.rows_max(ow_size, focus) - - ow_canv = ow.render(ow_size, focus) - - pos = ow_base.get_scrollpos(ow_size, focus) - posmax = ow_rows_max - maxrow - - # Thumb shrinks/grows according to the ratio of - # / - thumb_weight = min(1, maxrow / max(1, ow_rows_max)) - thumb_height = max(1, round(thumb_weight * maxrow)) - - # Thumb may only touch top/bottom if the first/last row is visible - top_weight = float(pos) / max(1, posmax) - top_height = int((maxrow - thumb_height) * top_weight) - if top_height == 0 and top_weight > 0: - top_height = 1 - - # Bottom part is remaining space - bottom_height = maxrow - thumb_height - top_height - assert thumb_height + top_height + bottom_height == maxrow - - # Create scrollbar canvas - top = urwid.SolidCanvas(self._trough_char, sb_width, 1) - thumb = urwid.SolidCanvas(self._thumb_char, sb_width, 1) - bottom = urwid.SolidCanvas(self._trough_char, sb_width, 1) - sb_canv = urwid.CanvasCombine( - [(top, None, False)] * top_height - + [(thumb, None, False)] * thumb_height - + [(bottom, None, False)] * bottom_height - ) - - # Create padding canvas - pad_canv = EmptyCanvas(pad_width, maxrow) - - combinelist = [ - (ow_canv, None, True, ow_size[0]), - (pad_canv, None, False, pad_width), - (sb_canv, None, False, sb_width), - ] - if self._scrollbar_side != SCROLLBAR_LEFT: - return urwid.CanvasJoin(combinelist) - else: - return urwid.CanvasJoin(reversed(combinelist)) - - @property - def scrollbar_width(self): - """Columns the scrollbar uses""" - return max(1, self._scrollbar_width) - - @scrollbar_width.setter - def scrollbar_width(self, width): - self._scrollbar_width = max(1, int(width)) - self._invalidate() - - @property - def scrollbar_side(self): - """Where to display the scrollbar; must be 'left' or 'right'""" - return self._scrollbar_side - - @scrollbar_side.setter - def scrollbar_side(self, side): - if side not in (SCROLLBAR_LEFT, SCROLLBAR_RIGHT): - raise ValueError( - 'scrollbar_side must be "left" or "right", not %r' % side - ) - self._scrollbar_side = side - self._invalidate() - - @property - def scrolling_base_widget(self): - """Nearest `original_widget` that is compatible with the scrolling API""" - - def orig_iter(w): - while hasattr(w, "original_widget"): - w = w.original_widget - yield w - yield w - - def is_scrolling_widget(w): - return hasattr(w, "get_scrollpos") and hasattr(w, "rows_max") - - for w in orig_iter(self): - if is_scrolling_widget(w): - return w - raise ValueError("Not compatible to be wrapped by ScrollBar: %r" % w) - - def keypress(self, size, key): - return self._original_widget.keypress(self._original_widget_size, key) - - def mouse_event(self, size, event, button, col, row, focus): - ow = self._original_widget - ow_size = self._original_widget_size - handled = False - if hasattr(ow, "mouse_event"): - handled = ow.mouse_event(ow_size, event, button, col, row, focus) - - if not handled and hasattr(ow, "set_scrollpos"): - if button == 4: # scroll wheel up - pos = ow.get_scrollpos(ow_size) - ow.set_scrollpos(pos - 1) - return True - elif button == 5: # scroll wheel down - pos = ow.get_scrollpos(ow_size) - ow.set_scrollpos(pos + 1) - return True - - return False - - -class Scrollable(urwid.AttrWrap): - def __init__(self, *args, **kwargs): - super().__init__( - _Scrollable(*args, **kwargs), "scrollable", "scrollable-focus" - ) - - -class ScrollBar(urwid.AttrWrap): - def __init__(self, *args, **kwargs): - super().__init__( - _ScrollBar(*args, **kwargs), "scrollbar", "scrollbar-focus" - ) diff --git a/screen-basic.png b/screen-basic.png index ae29708..564748d 100644 Binary files a/screen-basic.png and b/screen-basic.png differ diff --git a/screen-curses.png b/screen-curses.png index 7611540..6a26444 100644 Binary files a/screen-curses.png and b/screen-curses.png differ diff --git a/screen-urwid.png b/screen-urwid.png deleted file mode 100644 index 8c13349..0000000 Binary files a/screen-urwid.png and /dev/null differ