From 804b0d8b38a6703fdcf809d8dab004d06a24162e Mon Sep 17 00:00:00 2001 From: Dash Date: Sat, 17 Oct 2026 11:46:23 +0200 Subject: [PATCH] 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 --- README.md | 21 +++++++ pqcli/ui/basic/__init__.py | 47 ++++++++++++++- pqcli/ui/curses/__init__.py | 59 ++++++++++++++++++- pqcli/ui/curses/views/__init__.py | 4 ++ pqcli/ui/curses/views/message_view.py | 53 +++++++++++++++++ pqcli/ui/curses/views/roster_view.py | 12 ++++ pqcli/ui/curses/views/text_input_view.py | 73 ++++++++++++++++++++++++ 7 files changed, 266 insertions(+), 3 deletions(-) create mode 100644 pqcli/ui/curses/views/message_view.py create mode 100644 pqcli/ui/curses/views/text_input_view.py diff --git a/README.md b/README.md index dd2d835..354c41f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/pqcli/ui/basic/__init__.py b/pqcli/ui/basic/__init__.py index d590941..eb48437 100644 --- a/pqcli/ui/basic/__init__.py +++ b/pqcli/ui/basic/__init__.py @@ -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!") diff --git a/pqcli/ui/curses/__init__.py b/pqcli/ui/curses/__init__.py index 45a09fa..e87be75 100644 --- a/pqcli/ui/curses/__init__.py +++ b/pqcli/ui/curses/__init__.py @@ -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 diff --git a/pqcli/ui/curses/views/__init__.py b/pqcli/ui/curses/views/__init__.py index e0a6ccb..1380119 100644 --- a/pqcli/ui/curses/views/__init__.py +++ b/pqcli/ui/curses/views/__init__.py @@ -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", ] diff --git a/pqcli/ui/curses/views/message_view.py b/pqcli/ui/curses/views/message_view.py new file mode 100644 index 0000000..dc978ad --- /dev/null +++ b/pqcli/ui/curses/views/message_view.py @@ -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() diff --git a/pqcli/ui/curses/views/roster_view.py b/pqcli/ui/curses/views/roster_view.py index 4c3040b..7b8d81a 100644 --- a/pqcli/ui/curses/views/roster_view.py +++ b/pqcli/ui/curses/views/roster_view.py @@ -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 ), diff --git a/pqcli/ui/curses/views/text_input_view.py b/pqcli/ui/curses/views/text_input_view.py new file mode 100644 index 0000000..3727512 --- /dev/null +++ b/pqcli/ui/curses/views/text_input_view.py @@ -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()