mirror of
https://github.com/rr-/pq-cli.git
synced 2026-11-12 22:40:36 -07:00
ui: import and export .pqw files from the menus
The basic interface gets two main menu entries that ask for a path. The curses roster gets [I] Import and [E] Export, with a new text input view for the path, prefilled with NAME.pqw on export, and a message view for the outcome and any warnings. The README explains how to move characters to and from the web version. Resolves #21
This commit is contained in:
7 files changed
+266
-3
No files matched your search
@@ -121,6 +121,27 @@ A few things to keep in mind:
|
|||||||
- Existing characters keep their race and class even if you remove them from
|
- Existing characters keep their race and class even if you remove them from
|
||||||
the lists.
|
the lists.
|
||||||
|
|
||||||
|
## Moving characters to and from the web version
|
||||||
|
|
||||||
|
pqcli reads and writes the `.pqw` files that the
|
||||||
|
[web version](http://progressquest.com/play/)'s roster page saves and
|
||||||
|
loads. A character keeps its random number generator state either way, so
|
||||||
|
it carries on exactly as it would have where it came from.
|
||||||
|
|
||||||
|
- In either interface, pick *Import* or *Export* from the main menu.
|
||||||
|
- From the command line:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ pqcli --import-save Grumdrig.pqw
|
||||||
|
$ pqcli --list-saves
|
||||||
|
1. Grumdrig
|
||||||
|
$ pqcli --export-save 1 Grumdrig.pqw
|
||||||
|
```
|
||||||
|
|
||||||
|
Export never overwrites an existing file. pqcli plays offline only, so
|
||||||
|
online realm, guild and motto settings are dropped on import, with a
|
||||||
|
warning.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ import sys
|
|||||||
import time
|
import time
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
from types import FrameType
|
from types import FrameType
|
||||||
from typing import TypeVar
|
from typing import TypeVar
|
||||||
|
|
||||||
from pqcli import lingo
|
from pqcli import lingo, pqw
|
||||||
from pqcli.config import PRIME_STATS
|
from pqcli.config import PRIME_STATS
|
||||||
from pqcli.game_data import GameData
|
from pqcli.game_data import GameData
|
||||||
from pqcli.mechanic import Player, Simulation, StatsBuilder, create_player
|
from pqcli.mechanic import Player, Simulation, StatsBuilder, create_player
|
||||||
@@ -34,7 +35,9 @@ class MainMenu(enum.IntEnum):
|
|||||||
play = 2
|
play = 2
|
||||||
info = 3
|
info = 3
|
||||||
delete = 4
|
delete = 4
|
||||||
quit = 5
|
import_ = 5
|
||||||
|
export = 6
|
||||||
|
quit = 7
|
||||||
|
|
||||||
|
|
||||||
class BasicUserInterface(BaseUserInterface):
|
class BasicUserInterface(BaseUserInterface):
|
||||||
@@ -79,6 +82,8 @@ class BasicUserInterface(BaseUserInterface):
|
|||||||
(MainMenu.play, "Play as character"),
|
(MainMenu.play, "Play as character"),
|
||||||
(MainMenu.info, "Query character info"),
|
(MainMenu.info, "Query character info"),
|
||||||
(MainMenu.delete, "Delete a character"),
|
(MainMenu.delete, "Delete a character"),
|
||||||
|
(MainMenu.import_, "Import a character (.pqw)"),
|
||||||
|
(MainMenu.export, "Export a character (.pqw)"),
|
||||||
(MainMenu.quit, "Quit"),
|
(MainMenu.quit, "Quit"),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -104,6 +109,15 @@ class BasicUserInterface(BaseUserInterface):
|
|||||||
continue
|
continue
|
||||||
self.delete_player(player)
|
self.delete_player(player)
|
||||||
|
|
||||||
|
elif choice == MainMenu.import_:
|
||||||
|
self.import_player()
|
||||||
|
|
||||||
|
elif choice == MainMenu.export:
|
||||||
|
player = self.choose_player()
|
||||||
|
if not player:
|
||||||
|
continue
|
||||||
|
self.export_player(player)
|
||||||
|
|
||||||
elif choice == MainMenu.quit:
|
elif choice == MainMenu.quit:
|
||||||
self.quit()
|
self.quit()
|
||||||
break
|
break
|
||||||
@@ -201,6 +215,35 @@ class BasicUserInterface(BaseUserInterface):
|
|||||||
if self.args.use_saves:
|
if self.args.use_saves:
|
||||||
self.roster.save()
|
self.roster.save()
|
||||||
|
|
||||||
|
def import_player(self) -> None:
|
||||||
|
path = input("Import from .pqw file: ").strip()
|
||||||
|
if not path:
|
||||||
|
print("Cancelled.")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
player, warnings = pqw.read_file(
|
||||||
|
Path(path).expanduser(), self.data
|
||||||
|
)
|
||||||
|
except pqw.PqwError as ex:
|
||||||
|
print(f"Cannot import: {ex}")
|
||||||
|
return
|
||||||
|
for warning in warnings:
|
||||||
|
print(f"Warning: {warning}")
|
||||||
|
self.roster.players.append(player)
|
||||||
|
if self.args.use_saves:
|
||||||
|
self.roster.save()
|
||||||
|
print(f"Imported {player.name}.")
|
||||||
|
|
||||||
|
def export_player(self, player: Player) -> None:
|
||||||
|
default = pqw.default_file_name(player)
|
||||||
|
path = input(f"Export to .pqw file [{default}]: ").strip() or default
|
||||||
|
try:
|
||||||
|
pqw.write_file(player, Path(path).expanduser(), self.data)
|
||||||
|
except pqw.PqwError as ex:
|
||||||
|
print(f"Cannot export: {ex}")
|
||||||
|
return
|
||||||
|
print(f"Exported {player.name} to {path}.")
|
||||||
|
|
||||||
def choose_player(self) -> Player | None:
|
def choose_player(self) -> Player | None:
|
||||||
if not self.roster.players:
|
if not self.roster.players:
|
||||||
print("No characters to choose from!")
|
print("No characters to choose from!")
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import curses
|
|||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
import time
|
import time
|
||||||
|
from pathlib import Path
|
||||||
from types import FrameType
|
from types import FrameType
|
||||||
|
|
||||||
from pqcli import lingo
|
from pqcli import lingo, pqw
|
||||||
from pqcli.config import Class, Race
|
from pqcli.config import Class, Race
|
||||||
from pqcli.game_data import GameData
|
from pqcli.game_data import GameData
|
||||||
from pqcli.mechanic import Player, Stats, create_player
|
from pqcli.mechanic import Player, Stats, create_player
|
||||||
@@ -32,7 +33,9 @@ from pqcli.ui.curses.views import (
|
|||||||
ChooseCharacterView,
|
ChooseCharacterView,
|
||||||
ConfirmView,
|
ConfirmView,
|
||||||
GameView,
|
GameView,
|
||||||
|
MessageView,
|
||||||
RosterView,
|
RosterView,
|
||||||
|
TextInputView,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -79,6 +82,8 @@ class CursesUserInterface(BaseUserInterface):
|
|||||||
self._roster_view.on_create += self._switch_to_create_char_name_view
|
self._roster_view.on_create += self._switch_to_create_char_name_view
|
||||||
self._roster_view.on_delete += self._switch_to_delete_char_view
|
self._roster_view.on_delete += self._switch_to_delete_char_view
|
||||||
self._roster_view.on_play += self._switch_to_play_view
|
self._roster_view.on_play += self._switch_to_play_view
|
||||||
|
self._roster_view.on_import += self._switch_to_import_view
|
||||||
|
self._roster_view.on_export += self._switch_to_export_char_view
|
||||||
self._roster_view.on_quit += self._quit
|
self._roster_view.on_quit += self._quit
|
||||||
self._view: BaseView = self._roster_view
|
self._view: BaseView = self._roster_view
|
||||||
self._view.start()
|
self._view.start()
|
||||||
@@ -182,6 +187,58 @@ class CursesUserInterface(BaseUserInterface):
|
|||||||
view.on_confirm += self._switch_to_game_view
|
view.on_confirm += self._switch_to_game_view
|
||||||
self._switch_view(view)
|
self._switch_view(view)
|
||||||
|
|
||||||
|
def _switch_to_import_view(self) -> None:
|
||||||
|
view = TextInputView(self._screen, "Import from .pqw file:", "")
|
||||||
|
view.on_cancel += self._switch_to_roster_view
|
||||||
|
view.on_confirm += self._import_character
|
||||||
|
self._switch_view(view)
|
||||||
|
|
||||||
|
def _switch_to_export_char_view(self) -> None:
|
||||||
|
view = ChooseCharacterView(
|
||||||
|
self._screen, self.roster, "Choose character to export"
|
||||||
|
)
|
||||||
|
view.on_cancel += self._switch_to_roster_view
|
||||||
|
view.on_confirm += self._switch_to_export_file_view
|
||||||
|
self._switch_view(view)
|
||||||
|
|
||||||
|
def _switch_to_export_file_view(self, player: Player) -> None:
|
||||||
|
view = TextInputView(
|
||||||
|
self._screen,
|
||||||
|
f"Export {player.name} to .pqw file:",
|
||||||
|
pqw.default_file_name(player),
|
||||||
|
)
|
||||||
|
view.on_cancel += self._switch_to_export_char_view
|
||||||
|
view.on_confirm += lambda path: self._export_character(player, path)
|
||||||
|
self._switch_view(view)
|
||||||
|
|
||||||
|
def _switch_to_message_view(self, message: str) -> None:
|
||||||
|
view = MessageView(self._screen, message)
|
||||||
|
view.on_close += self._switch_to_roster_view
|
||||||
|
self._switch_view(view)
|
||||||
|
|
||||||
|
def _import_character(self, path: str) -> None:
|
||||||
|
try:
|
||||||
|
player, warnings = pqw.read_file(
|
||||||
|
Path(path).expanduser(), self.data
|
||||||
|
)
|
||||||
|
except pqw.PqwError as ex:
|
||||||
|
self._switch_to_message_view(f"Cannot import: {ex}")
|
||||||
|
return
|
||||||
|
self.roster.players.append(player)
|
||||||
|
if self.args.use_saves:
|
||||||
|
self.roster.save()
|
||||||
|
self._switch_to_message_view(
|
||||||
|
"\n".join([f"Imported {player.name}.", *warnings])
|
||||||
|
)
|
||||||
|
|
||||||
|
def _export_character(self, player: Player, path: str) -> None:
|
||||||
|
try:
|
||||||
|
pqw.write_file(player, Path(path).expanduser(), self.data)
|
||||||
|
except pqw.PqwError as ex:
|
||||||
|
self._switch_to_message_view(f"Cannot export: {ex}")
|
||||||
|
return
|
||||||
|
self._switch_to_message_view(f"Exported {player.name} to {path}.")
|
||||||
|
|
||||||
def _switch_to_game_view(self, player: Player) -> None:
|
def _switch_to_game_view(self, player: Player) -> None:
|
||||||
view = GameView(
|
view = GameView(
|
||||||
self._screen, self.roster, player, self.args, self.data
|
self._screen, self.roster, player, self.args, self.data
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ from .create_character_view import (
|
|||||||
ChooseCharacterStatsView,
|
ChooseCharacterStatsView,
|
||||||
)
|
)
|
||||||
from .game_view import GameView
|
from .game_view import GameView
|
||||||
|
from .message_view import MessageView
|
||||||
from .roster_view import RosterView
|
from .roster_view import RosterView
|
||||||
|
from .text_input_view import TextInputView
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"BaseView",
|
"BaseView",
|
||||||
@@ -19,5 +21,7 @@ __all__ = [
|
|||||||
"ChooseCharacterRaceView",
|
"ChooseCharacterRaceView",
|
||||||
"ChooseCharacterStatsView",
|
"ChooseCharacterStatsView",
|
||||||
"GameView",
|
"GameView",
|
||||||
|
"MessageView",
|
||||||
"RosterView",
|
"RosterView",
|
||||||
|
"TextInputView",
|
||||||
]
|
]
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import curses
|
||||||
|
import curses.ascii
|
||||||
|
import textwrap
|
||||||
|
|
||||||
|
from pqcli.ui.curses.event_handler import EventHandler
|
||||||
|
from pqcli.ui.curses.util import KEYS_CANCEL, Choice
|
||||||
|
from pqcli.ui.curses.widgets import Menu
|
||||||
|
|
||||||
|
from .base_view import BaseView
|
||||||
|
|
||||||
|
|
||||||
|
class MessageView(BaseView):
|
||||||
|
"""Show a message until it is dismissed."""
|
||||||
|
|
||||||
|
def __init__(self, screen: curses.window, message: str) -> None:
|
||||||
|
super().__init__(screen)
|
||||||
|
|
||||||
|
self.message = message
|
||||||
|
self.on_close = EventHandler[[]]()
|
||||||
|
|
||||||
|
self.choices: list[Choice] = [
|
||||||
|
Choice(
|
||||||
|
keys=[curses.ascii.NL, *KEYS_CANCEL],
|
||||||
|
desc="[Enter] OK",
|
||||||
|
callback=self.on_close,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
scr_height, scr_width = self.screen.getmaxyx()
|
||||||
|
self.screen.erase()
|
||||||
|
self.screen.noutrefresh()
|
||||||
|
|
||||||
|
width = max(20, scr_width - 6)
|
||||||
|
header = "\n".join(
|
||||||
|
"\n".join(textwrap.wrap(line, width) or [""])
|
||||||
|
for line in self.message.split("\n")
|
||||||
|
)
|
||||||
|
self.main_menu = Menu(
|
||||||
|
header=header,
|
||||||
|
choices=self.choices,
|
||||||
|
active_choice=0,
|
||||||
|
scr_height=scr_height,
|
||||||
|
scr_width=scr_width,
|
||||||
|
)
|
||||||
|
self.main_menu.render()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self.main_menu.stop()
|
||||||
|
|
||||||
|
def keypress(self, key: int) -> None:
|
||||||
|
self.main_menu.keypress(key)
|
||||||
|
self.main_menu.render()
|
||||||
@@ -57,6 +57,8 @@ class RosterView(BaseView):
|
|||||||
self.on_create = EventHandler[[]]()
|
self.on_create = EventHandler[[]]()
|
||||||
self.on_play = EventHandler[[]]()
|
self.on_play = EventHandler[[]]()
|
||||||
self.on_delete = EventHandler[[]]()
|
self.on_delete = EventHandler[[]]()
|
||||||
|
self.on_import = EventHandler[[]]()
|
||||||
|
self.on_export = EventHandler[[]]()
|
||||||
self.on_quit = EventHandler[[]]()
|
self.on_quit = EventHandler[[]]()
|
||||||
|
|
||||||
self.choices: list[Choice] = [
|
self.choices: list[Choice] = [
|
||||||
@@ -75,6 +77,16 @@ class RosterView(BaseView):
|
|||||||
desc="[D] Delete character",
|
desc="[D] Delete character",
|
||||||
callback=self.on_delete,
|
callback=self.on_delete,
|
||||||
),
|
),
|
||||||
|
Choice(
|
||||||
|
keys=list(map(ord, "iI")),
|
||||||
|
desc="[I] Import character (.pqw)",
|
||||||
|
callback=self.on_import,
|
||||||
|
),
|
||||||
|
Choice(
|
||||||
|
keys=list(map(ord, "eE")),
|
||||||
|
desc="[E] Export character (.pqw)",
|
||||||
|
callback=self.on_export,
|
||||||
|
),
|
||||||
Choice(
|
Choice(
|
||||||
keys=list(KEYS_CANCEL), desc="[Q] Quit", callback=self.on_quit
|
keys=list(KEYS_CANCEL), desc="[Q] Quit", callback=self.on_quit
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import curses
|
||||||
|
import curses.ascii
|
||||||
|
|
||||||
|
from pqcli.ui.curses.event_handler import EventHandler
|
||||||
|
from pqcli.ui.curses.views.base_view import BaseView
|
||||||
|
|
||||||
|
|
||||||
|
class TextInputView(BaseView):
|
||||||
|
"""Ask for one line of text, such as a file name."""
|
||||||
|
|
||||||
|
def __init__(self, screen: curses.window, title: str, text: str) -> None:
|
||||||
|
super().__init__(screen)
|
||||||
|
|
||||||
|
self.on_cancel = EventHandler[[]]()
|
||||||
|
self.on_confirm = EventHandler[[str]]()
|
||||||
|
|
||||||
|
self._title = title
|
||||||
|
self._text = text
|
||||||
|
self._win: curses.window | None = None
|
||||||
|
self._text_win: curses.window | None = None
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
self.screen.erase()
|
||||||
|
self.screen.noutrefresh()
|
||||||
|
|
||||||
|
scr_height, scr_width = self.screen.getmaxyx()
|
||||||
|
h = 6
|
||||||
|
w = min(60, scr_width - 4)
|
||||||
|
y = (scr_height - h - 2) // 2
|
||||||
|
x = (scr_width - w - 2) // 2
|
||||||
|
self._win = curses.newwin(h + 2, w + 2, y, x)
|
||||||
|
self._text_win = curses.newwin(3, w + 2, y + 2, x - 1)
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._win = None
|
||||||
|
self._text_win = None
|
||||||
|
|
||||||
|
def keypress(self, key: int) -> None:
|
||||||
|
if key == curses.ascii.ESC:
|
||||||
|
self.on_cancel()
|
||||||
|
return
|
||||||
|
if key == curses.ascii.NL:
|
||||||
|
text = self._text.strip()
|
||||||
|
if text:
|
||||||
|
self.on_confirm(text)
|
||||||
|
else:
|
||||||
|
self.on_cancel()
|
||||||
|
return
|
||||||
|
if key in {curses.KEY_BACKSPACE, curses.ascii.DEL}:
|
||||||
|
self._text = self._text[:-1]
|
||||||
|
elif key == curses.ascii.ETB: # ^w
|
||||||
|
self._text = ""
|
||||||
|
elif curses.ascii.isprint(key):
|
||||||
|
self._text += chr(key)
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
def _render(self) -> None:
|
||||||
|
if not self._win or not self._text_win:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._win.erase()
|
||||||
|
self._win.addstr(0, 0, self._title)
|
||||||
|
self._win.addstr(5, 0, "[Enter] Continue")
|
||||||
|
self._win.addstr(6, 0, "[Esc ] Cancel")
|
||||||
|
self._win.refresh()
|
||||||
|
|
||||||
|
width = self._text_win.getmaxyx()[1] - 2
|
||||||
|
self._text_win.erase()
|
||||||
|
self._text_win.box()
|
||||||
|
# keep the end of long paths in view
|
||||||
|
self._text_win.addnstr(1, 1, self._text[-width + 1 :], width)
|
||||||
|
self._text_win.refresh()
|
||||||
Reference in new issue
Block a user