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
-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"
)