ui/urwid: remove urwid ui

It was too similar and inferior to curses.
This commit is contained in:
rr-
2019-01-13 18:26:12 +01:00
parent ffe5734433
commit 0ecfc2c1fd
20 changed files with 5 additions and 1903 deletions
+4 -9
View File
@@ -12,23 +12,18 @@ Relive the great adventure… this time in the terminal realm!
- Faithful port of the game logic - Faithful port of the game logic
- Saves (with backups) to `$XDG_CONFIG_HOME/pqcli/save.dat` - Saves (with backups) to `$XDG_CONFIG_HOME/pqcli/save.dat`
- Terminal interface that comes in 3 flavors: - Terminal interface that comes in 2 flavors:
- Rich and colorful, but CPU intensive (`--urwid`) - Rich and colorful (`--curses`, default)
- Simple, but easy on CPU, friendly for embedded environments (`--curses`)
- Minimal, suitable for raw grind (`--basic`) - Minimal, suitable for raw grind (`--basic`)
- Ideal to run on your server - Ideal to run on your server
## How it looks like ## How it looks like
Urwid version: Curses interface:
![Screenshot](screen-urwid.png)
Curses version:
![Screenshot](screen-curses.png) ![Screenshot](screen-curses.png)
Basic version: Basic interface:
![Screenshot](screen-basic.png) ![Screenshot](screen-basic.png)
+1 -9
View File
@@ -6,7 +6,6 @@ import xdg
from pqcli.roster import Roster from pqcli.roster import Roster
from pqcli.ui.basic import BasicUserInterface from pqcli.ui.basic import BasicUserInterface
from pqcli.ui.curses import CursesUserInterface from pqcli.ui.curses import CursesUserInterface
from pqcli.ui.urwid import UrwidUserInterface
SAVE_PATH = Path(xdg.XDG_CONFIG_HOME) / "pqcli" / "save.dat" SAVE_PATH = Path(xdg.XDG_CONFIG_HOME) / "pqcli" / "save.dat"
@@ -30,19 +29,12 @@ def parse_args() -> argparse.Namespace:
const=CursesUserInterface, const=CursesUserInterface,
help="Use curses user interface (fast, but no colors output)", 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( parser.add_argument(
"--no-colors", "--no-colors",
dest="colors", dest="colors",
action="store_false", action="store_false",
help="Disable color highlighting in curses and urwid interface", help="Disable color highlighting in curses interface",
) )
parser.add_argument( parser.add_argument(
-155
View File
@@ -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)
-4
View File
@@ -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
-45
View File
@@ -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()
-459
View File
@@ -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
-218
View File
@@ -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
-116
View File
@@ -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
-8
View File
@@ -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
-31
View File
@@ -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",
)
-18
View File
@@ -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",
)
@@ -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
-64
View File
@@ -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),
)
-34
View File
@@ -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)
-119
View File
@@ -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
@@ -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)
-498
View File
@@ -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
# <number of visible lines> / <number of total lines>
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"
)
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

After

Width:  |  Height:  |  Size: 104 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 260 KiB

After

Width:  |  Height:  |  Size: 98 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB