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

+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()