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:
Dash committed 2026-10-17 15:56:03 +02:00
1 parent 612b223a59
commit 804b0d8b38
7 files changed
+266 -3

No files matched your search

+21
View File
@@ -121,6 +121,27 @@ A few things to keep in mind:
- Existing characters keep their race and class even if you remove them from
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
```sh
+45 -2
View File
@@ -6,10 +6,11 @@ import sys
import time
from collections.abc import Sequence
from datetime import datetime, timedelta
from pathlib import Path
from types import FrameType
from typing import TypeVar
from pqcli import lingo
from pqcli import lingo, pqw
from pqcli.config import PRIME_STATS
from pqcli.game_data import GameData
from pqcli.mechanic import Player, Simulation, StatsBuilder, create_player
@@ -34,7 +35,9 @@ class MainMenu(enum.IntEnum):
play = 2
info = 3
delete = 4
quit = 5
import_ = 5
export = 6
quit = 7
class BasicUserInterface(BaseUserInterface):
@@ -79,6 +82,8 @@ class BasicUserInterface(BaseUserInterface):
(MainMenu.play, "Play as character"),
(MainMenu.info, "Query character info"),
(MainMenu.delete, "Delete a character"),
(MainMenu.import_, "Import a character (.pqw)"),
(MainMenu.export, "Export a character (.pqw)"),
(MainMenu.quit, "Quit"),
]
)
@@ -104,6 +109,15 @@ class BasicUserInterface(BaseUserInterface):
continue
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:
self.quit()
break
@@ -201,6 +215,35 @@ class BasicUserInterface(BaseUserInterface):
if self.args.use_saves:
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:
if not self.roster.players:
print("No characters to choose from!")
+58 -1
View File
@@ -3,9 +3,10 @@ import curses
import os
import signal
import time
from pathlib import Path
from types import FrameType
from pqcli import lingo
from pqcli import lingo, pqw
from pqcli.config import Class, Race
from pqcli.game_data import GameData
from pqcli.mechanic import Player, Stats, create_player
@@ -32,7 +33,9 @@ from pqcli.ui.curses.views import (
ChooseCharacterView,
ConfirmView,
GameView,
MessageView,
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_delete += self._switch_to_delete_char_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._view: BaseView = self._roster_view
self._view.start()
@@ -182,6 +187,58 @@ class CursesUserInterface(BaseUserInterface):
view.on_confirm += self._switch_to_game_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:
view = GameView(
self._screen, self.roster, player, self.args, self.data
+4
View File
@@ -8,7 +8,9 @@ from .create_character_view import (
ChooseCharacterStatsView,
)
from .game_view import GameView
from .message_view import MessageView
from .roster_view import RosterView
from .text_input_view import TextInputView
__all__ = [
"BaseView",
@@ -19,5 +21,7 @@ __all__ = [
"ChooseCharacterRaceView",
"ChooseCharacterStatsView",
"GameView",
"MessageView",
"RosterView",
"TextInputView",
]
+53
View File
@@ -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()
+12
View File
@@ -57,6 +57,8 @@ class RosterView(BaseView):
self.on_create = EventHandler[[]]()
self.on_play = EventHandler[[]]()
self.on_delete = EventHandler[[]]()
self.on_import = EventHandler[[]]()
self.on_export = EventHandler[[]]()
self.on_quit = EventHandler[[]]()
self.choices: list[Choice] = [
@@ -75,6 +77,16 @@ class RosterView(BaseView):
desc="[D] Delete character",
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(
keys=list(KEYS_CANCEL), desc="[Q] Quit", callback=self.on_quit
),
+73
View File
@@ -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()