74 Commits
Author SHA1 Message Date
Dash 7426924170 misc: release 1.0.4 2025-12-11 14:59:18 +01:00
Dash 9609e9a3f2 misc: fix style issues 2025-12-11 14:58:55 +01:00
Dash 7e52a74065 misc: update author info 2025-12-11 14:58:55 +01:00
Dash 4d5b070949 build: migrate to uv 2025-12-11 14:58:55 +01:00
Dash 326b9297c1 build: migrate to uv 2025-12-11 14:49:15 +01:00
Owen Cochell 7790e52a6d misc: fix crash on Linux due to zero length addnstr call 2025-04-10 08:20:21 +02:00
Owen Cochell e6d18352c5 misc: fix empty character sheet on Linux 2025-04-04 20:26:08 +02:00
dependabot[bot] 4122e936c8 build(deps-dev): bump setuptools from 69.5.1 to 70.0.0
Bumps [setuptools](https://github.com/pypa/setuptools) from 69.5.1 to 70.0.0.
- [Release notes](https://github.com/pypa/setuptools/releases)
- [Changelog](https://github.com/pypa/setuptools/blob/main/NEWS.rst)
- [Commits](https://github.com/pypa/setuptools/compare/v69.5.1...v70.0.0)

---
updated-dependencies:
- dependency-name: setuptools
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-07-15 21:57:21 +02:00
Marcin Kurczewski 803eba416e misc: fix xdg-base-dirs refactor 2024-07-02 15:41:50 +02:00
Marcin Kurczewski 07cae1a4e8 misc: use xdg-base-dirs
Resolves #26
2024-05-04 12:18:07 +02:00
Marcin Kurczewski 9eaf4397df misc: fix syntax warning 2024-04-26 01:27:33 +02:00
anakojm 077016f7a0 misc: fix seed type issue 2024-04-26 01:26:41 +02:00
txtsd e51a14c081 misc: Set correct python version in classifiers
Signed-off-by: txtsd <code@ihavea.quest>
2022-11-11 08:02:27 +01:00
txtsd 77391e7888 misc: Remove unused imports
Signed-off-by: txtsd <code@ihavea.quest>
2022-11-11 08:02:27 +01:00
txtsd 69ad5e7299 misc: Fix grammar
Signed-off-by: txtsd <code@ihavea.quest>
2022-11-11 08:02:27 +01:00
txtsd 04e0611d45 misc: Fix typos
Signed-off-by: txtsd <code@ihavea.quest>
2022-11-11 08:02:27 +01:00
txtsd c0395e9447 misc: Update .gitignore
Fixes directory format
Adds __pycache__

Signed-off-by: txtsd <code@ihavea.quest>
2022-11-11 08:02:27 +01:00
rr- 3b2d9db2e2 build: switch to Poetry 2021-12-20 23:29:37 +01:00
Marcin Kurczewski bbe3842a2f ui: allow loading characters directly from cli (#16) 2020-09-30 20:20:29 +02:00
rr- 1ad563ad93 misc: release 1.0.2 2020-08-17 09:12:56 +02:00
rr- 29207c1d4c misc: remove unused imports 2020-08-17 09:12:56 +02:00
CJ Kucera 67c3888f82 docs: add troubleshooting section 2020-06-22 21:57:43 +02:00
rr- d4675c747a misc: require windows-curses for Windows 2020-05-07 15:36:41 +02:00
Petr Gašparík c5f0c60ec8 roster: fix overwriting old saves on Windows 2020-05-07 15:27:56 +02:00
rr- fc4dcff9e3 ui/curses: fix division by zero 2020-05-07 15:04:58 +02:00
rr- f8a030a648 misc: release 1.0.1 2019-04-10 20:09:47 +02:00
rr- 82c8d21395 ui/curses: fix division by zero 2019-04-10 20:09:22 +02:00
rr- 746ca551f3 misc: release 1.0 2019-02-02 00:32:48 +01:00
rr- 5d47a0f44c mechanic: fix simulation stopping under certain conditions 2019-01-17 00:47:23 +01:00
rr- 555f386b24 ui/curses: highlight changed and added items 2019-01-14 16:10:26 +01:00
rr- 5f34a85c80 ui/curses: fix new quests and acts not appearing 2019-01-14 14:52:24 +01:00
rr- 97552a711a misc: don't use relative imports 2019-01-14 14:42:29 +01:00
rr- 561f28bd95 readme: add curses title screen 2019-01-13 22:25:47 +01:00
rr- 0bd4583c73 ui/curses: add swords to logo 2019-01-13 22:20:59 +01:00
rr- adc3c7914d ui/curses: use title case in menus 2019-01-13 21:44:26 +01:00
rr- d2c06b08bb ui/curses: cycle widgets on tab 2019-01-13 21:41:24 +01:00
rr- 4dc674816b ui/curses: add focus to character stats view 2019-01-13 21:41:24 +01:00
rr- 8c6b1a2504 ui/curses: add focus to character name view 2019-01-13 21:41:21 +01:00
rr- 8d385523ad ui/curses: split into files 2019-01-13 21:41:00 +01:00
rr- 05c9bcd0e6 ui/curses: improve margins 2019-01-13 20:53:18 +01:00
rr- 2c3da6484b ui/curses: align button hints to left 2019-01-13 20:50:05 +01:00
rr- d4f9326759 ui/curses: make escape work in game view 2019-01-13 20:48:33 +01:00
rr- e85a8d5826 ui/curses: improve character choice 2019-01-13 19:48:54 +01:00
rr- 2da46a44a4 ui/curses: implement cheats 2019-01-13 18:34:09 +01:00
rr- 0ecfc2c1fd ui/urwid: remove urwid ui
It was too similar and inferior to curses.
2019-01-13 18:26:12 +01:00
rr- ffe5734433 ui: default to curses user interface 2019-01-13 18:06:27 +01:00
rr- ae99f1f1a4 ui/urwid: respect --no-colors 2019-01-13 17:46:07 +01:00
rr- a47d6233be ui/urwid: colorize logo 2019-01-13 17:33:19 +01:00
rr- 1980049c6f ui/urwid: drop customizing color palettes 2019-01-13 17:33:11 +01:00
rr- 9fc078ad94 ui/curses: add colors 2019-01-13 17:33:11 +01:00
rr- 7a835c615e ui/urwid: split to files 2019-01-13 16:33:07 +01:00
rr- 9412a03da7 misc: add pyproject.toml 2019-01-04 20:49:33 +01:00
rr- 9012363437 misc: update readme, add new screenshots 2019-01-04 20:48:20 +01:00
rr- 021d7eb602 misc: improve mypy health 2019-01-04 20:13:52 +01:00
rr- 3d8034b285 ui/curses: cancel char creation one step at a time 2019-01-04 20:07:43 +01:00
rr- bd3f196f84 mechanic: fix stat unrolling 2019-01-04 19:49:50 +01:00
rr- 9a90b7b7b4 ui/curses: add character creation wizard 2019-01-04 19:49:50 +01:00
rr- e3ffe51979 ui/curses: let user scroll game view windows 2019-01-04 16:59:29 +01:00
rr- 0133802570 ui/curses: estimate time in progress bars 2019-01-04 16:40:30 +01:00
rr- b20c4b4b34 ui/curses: fix arrows not working in game view 2019-01-04 16:25:58 +01:00
rr- 5bdfc2ed25 ui/curses: fix scrollable visual glitches 2019-01-04 15:44:51 +01:00
rr- 8b38eeecac ui/curses: remove flickering 2019-01-04 00:56:55 +01:00
rr- ed9e513271 mechanic: emit bar signals if significant changes 2019-01-04 00:43:44 +01:00
rr- 97d958d2e7 pqcli: fix signals 2019-01-04 00:43:21 +01:00
rr- 9db61f7191 ui/curses: implement game view widget focusing 2019-01-04 00:22:29 +01:00
rr- 4230c57e73 ui/curses: remove redundant code 2019-01-04 00:22:29 +01:00
rr- 871144bf0c ui/curses: add scrollbars 2019-01-03 23:23:47 +01:00
rr- 3fdc2459ca sim: remove simulator script 2019-01-03 21:44:58 +01:00
rr- e008fc6048 ui: save periodically in every frontend 2019-01-03 21:44:23 +01:00
rr- ca7ab192c5 ui/curses: add curses interface draft 2019-01-03 21:38:19 +01:00
rr- 81f6dbede2 mechanic: remove urwid dependency 2019-01-03 21:32:55 +01:00
rr- 478075347c ui/basic: save periodically 2018-12-28 00:32:53 +01:00
rr- 595538cdca ui/basic: add basic user interface 2018-12-14 00:32:21 +01:00
rr- e27f3470a4 ui: rename Ui class to UrwidUserInterface 2018-12-14 00:32:21 +01:00
66 changed files with 3374 additions and 2095 deletions
+4
View File
@@ -0,0 +1,4 @@
dist/
build/
__pycache__
+11
View File
@@ -0,0 +1,11 @@
repos:
- repo: https://github.com/pycqa/isort
rev: 5.13.2
hooks:
- id: isort
additional_dependencies: [toml]
- repo: https://github.com/psf/black
rev: 24.4.2
hooks:
- id: black
+44 -7
View File
@@ -10,27 +10,64 @@ Relive the great adventure… this time in the terminal realm!
## Features ## Features
- Saves (with backups) to `$XDG_CONFIG_HOME/pqcli/save.dat`
- Clean terminal interface
- Smooth progress bars using UTF-8 block characters
- Scrollable list boxes
- Faithful port of the game logic - Faithful port of the game logic
- Saves (with backups) to `$XDG_CONFIG_HOME/pqcli/save.dat`
- Terminal interface that comes in 2 flavors:
- Rich and colorful (`--curses`, default)
- Minimal, suitable for raw grind (`--basic`)
- Ideal to run on your server - Ideal to run on your server
## How it looks like ## How it looks like
![Screenshot](screen.png) Curses interface:
![Screenshot](screen-curses-logo.png)
![Screenshot](screen-curses.png)
Basic interface:
![Screenshot](screen-basic.png)
## How to install ## How to install
If you have Python 3.7, just run `pip install --user pqcli` and you're good to go! If you have Python 3.7, just run `pip install --user pqcli` and you're good to go!
Then type `pqcli` to run the game. Then type `pqcli` to run the game.
In case if you want to use the git version, the process is just a little bit In case if you want to use the git version, the process is just a bit more complex:
more complex:
```console ```console
$ git clone https://github.com/rr-/pq-cli.git $ git clone https://github.com/rr-/pq-cli.git
$ cd pq-cli $ cd pq-cli
$ pip install --user . $ pip install --user .
``` ```
## Contributing
```sh
# Clone the repository:
git clone https://github.com/rr-/pqcli.git
cd pqcli
# Install to a local venv:
poetry install
# Install pre-commit hooks:
poetry run pre-commit install
# Enter the venv:
poetry shell
```
This project uses [poetry](https://python-poetry.org/) for packaging.
Install instructions are available at [poetry#installation](https://python-poetry.org/docs/#installation).
## Troubleshooting
### `_curses.error: init_pair() returned ERR`
If running on Linux and you get the error `_curses.error: init_pair() returned ERR`,
try making sure that your `$TERM` variable is set to a value which supports
256 colors, such as via the following:
TERM=xterm-256color pqcli
Generated
+298
View File
@@ -0,0 +1,298 @@
# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
[[package]]
name = "cfgv"
version = "3.4.0"
description = "Validate configuration and produce human readable error messages."
optional = false
python-versions = ">=3.8"
files = [
{file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"},
{file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"},
]
[[package]]
name = "distlib"
version = "0.3.8"
description = "Distribution utilities"
optional = false
python-versions = "*"
files = [
{file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"},
{file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"},
]
[[package]]
name = "filelock"
version = "3.14.0"
description = "A platform independent file lock."
optional = false
python-versions = ">=3.8"
files = [
{file = "filelock-3.14.0-py3-none-any.whl", hash = "sha256:43339835842f110ca7ae60f1e1c160714c5a6afd15a2873419ab185334975c0f"},
{file = "filelock-3.14.0.tar.gz", hash = "sha256:6ea72da3be9b8c82afd3edcf99f2fffbb5076335a5ae4d03248bb5b6c3eae78a"},
]
[package.extras]
docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"]
testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8.0.1)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"]
typing = ["typing-extensions (>=4.8)"]
[[package]]
name = "identify"
version = "2.5.36"
description = "File identification library for Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "identify-2.5.36-py2.py3-none-any.whl", hash = "sha256:37d93f380f4de590500d9dba7db359d0d3da95ffe7f9de1753faa159e71e7dfa"},
{file = "identify-2.5.36.tar.gz", hash = "sha256:e5e00f54165f9047fbebeb4a560f9acfb8af4c88232be60a488e9b68d122745d"},
]
[package.extras]
license = ["ukkonen"]
[[package]]
name = "nodeenv"
version = "1.8.0"
description = "Node.js virtual environment builder"
optional = false
python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*"
files = [
{file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"},
{file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"},
]
[package.dependencies]
setuptools = "*"
[[package]]
name = "platformdirs"
version = "4.2.1"
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
optional = false
python-versions = ">=3.8"
files = [
{file = "platformdirs-4.2.1-py3-none-any.whl", hash = "sha256:17d5a1161b3fd67b390023cb2d3b026bbd40abde6fdb052dfbd3a29c3ba22ee1"},
{file = "platformdirs-4.2.1.tar.gz", hash = "sha256:031cd18d4ec63ec53e82dceaac0417d218a6863f7745dfcc9efe7793b7039bdf"},
]
[package.extras]
docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"]
test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"]
type = ["mypy (>=1.8)"]
[[package]]
name = "pre-commit"
version = "2.21.0"
description = "A framework for managing and maintaining multi-language pre-commit hooks."
optional = false
python-versions = ">=3.7"
files = [
{file = "pre_commit-2.21.0-py2.py3-none-any.whl", hash = "sha256:e2f91727039fc39a92f58a588a25b87f936de6567eed4f0e673e0507edc75bad"},
{file = "pre_commit-2.21.0.tar.gz", hash = "sha256:31ef31af7e474a8d8995027fefdfcf509b5c913ff31f2015b4ec4beb26a6f658"},
]
[package.dependencies]
cfgv = ">=2.0.0"
identify = ">=1.0.0"
nodeenv = ">=0.11.1"
pyyaml = ">=5.1"
virtualenv = ">=20.10.0"
[[package]]
name = "pyyaml"
version = "6.0.1"
description = "YAML parser and emitter for Python"
optional = false
python-versions = ">=3.6"
files = [
{file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"},
{file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"},
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"},
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"},
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"},
{file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"},
{file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"},
{file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"},
{file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"},
{file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"},
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"},
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"},
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"},
{file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"},
{file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"},
{file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"},
{file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"},
{file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"},
{file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"},
{file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"},
{file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"},
{file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"},
{file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"},
{file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"},
{file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"},
{file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"},
{file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"},
{file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"},
{file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"},
{file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"},
{file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"},
{file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"},
{file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"},
{file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"},
{file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"},
{file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"},
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"},
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"},
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"},
{file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"},
{file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"},
{file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"},
{file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"},
{file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"},
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"},
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"},
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"},
{file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"},
{file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"},
{file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"},
{file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"},
]
[[package]]
name = "setuptools"
version = "70.0.0"
description = "Easily download, build, install, upgrade, and uninstall Python packages"
optional = false
python-versions = ">=3.8"
files = [
{file = "setuptools-70.0.0-py3-none-any.whl", hash = "sha256:54faa7f2e8d2d11bcd2c07bed282eef1046b5c080d1c32add737d7b5817b1ad4"},
{file = "setuptools-70.0.0.tar.gz", hash = "sha256:f211a66637b8fa059bb28183da127d4e86396c991a942b028c6650d4319c3fd0"},
]
[package.extras]
docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"]
testing = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mypy (==1.9)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.1)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"]
[[package]]
name = "typing-extensions"
version = "4.11.0"
description = "Backported and Experimental Type Hints for Python 3.8+"
optional = false
python-versions = ">=3.8"
files = [
{file = "typing_extensions-4.11.0-py3-none-any.whl", hash = "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a"},
{file = "typing_extensions-4.11.0.tar.gz", hash = "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0"},
]
[[package]]
name = "urwid"
version = "2.6.11"
description = "A full-featured console (xterm et al.) user interface library"
optional = false
python-versions = ">3.7"
files = [
{file = "urwid-2.6.11-py3-none-any.whl", hash = "sha256:7e18c34d416fffd4e39410450347f4637222bd99cb6fb1523ffa50d77a0e22cf"},
{file = "urwid-2.6.11.tar.gz", hash = "sha256:52770007d734d7387ae0421e7b7769c4c5ec67e91a5f4df54e858e314062e475"},
]
[package.dependencies]
typing-extensions = "*"
wcwidth = "*"
[package.extras]
curses = ["windows-curses"]
glib = ["PyGObject"]
lcd = ["pyserial"]
serial = ["pyserial"]
tornado = ["tornado (>=5.0)"]
trio = ["exceptiongroup", "trio (>=0.22.0)"]
twisted = ["twisted"]
zmq = ["zmq"]
[[package]]
name = "urwid-readline"
version = "0.13"
description = "A textbox edit widget for urwid that supports readline shortcuts"
optional = false
python-versions = "*"
files = [
{file = "urwid_readline-0.13.tar.gz", hash = "sha256:018020cbc864bb5ed87be17dc26b069eae2755cb29f3a9c569aac3bded1efaf4"},
]
[package.dependencies]
urwid = "*"
[package.extras]
dev = ["black", "pytest"]
[[package]]
name = "virtualenv"
version = "20.26.1"
description = "Virtual Python Environment builder"
optional = false
python-versions = ">=3.7"
files = [
{file = "virtualenv-20.26.1-py3-none-any.whl", hash = "sha256:7aa9982a728ae5892558bff6a2839c00b9ed145523ece2274fad6f414690ae75"},
{file = "virtualenv-20.26.1.tar.gz", hash = "sha256:604bfdceaeece392802e6ae48e69cec49168b9c5f4a44e483963f9242eb0e78b"},
]
[package.dependencies]
distlib = ">=0.3.7,<1"
filelock = ">=3.12.2,<4"
platformdirs = ">=3.9.1,<5"
[package.extras]
docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"]
[[package]]
name = "wcwidth"
version = "0.2.13"
description = "Measures the displayed width of unicode strings in a terminal"
optional = false
python-versions = "*"
files = [
{file = "wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859"},
{file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"},
]
[[package]]
name = "windows-curses"
version = "2.3.2"
description = "Support for the standard curses module on Windows"
optional = false
python-versions = "*"
files = [
{file = "windows_curses-2.3.2-cp310-cp310-win32.whl", hash = "sha256:0286d35c9a2589731af3cf2b1251635a400f4b61aef2b9c081f6c98e7887a170"},
{file = "windows_curses-2.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:b5e68520c7e92dff72914e4126cadb5b8eb3b6c968d955de6d35ff42306da8c0"},
{file = "windows_curses-2.3.2-cp311-cp311-win32.whl", hash = "sha256:95d2a288af6172270da5ca9225aa99eeae98595c6e90f3574aa9b9f2fc1d2619"},
{file = "windows_curses-2.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:9b7ecd0f21f643e22a979effff25f62200626bb9853ea8b9aacf0bbcaab0950d"},
{file = "windows_curses-2.3.2-cp312-cp312-win32.whl", hash = "sha256:4546122f5bec2fb46c1706c020d971bcfc4d9a5158372f25ba7472a834b0f165"},
{file = "windows_curses-2.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:e3f27939f39143c513f444a8c0419b2737e8de55fbe5d63d765992512125366d"},
{file = "windows_curses-2.3.2-cp36-cp36m-win32.whl", hash = "sha256:a570f744a62108d024a7775b3b156b2ae2380fc971237c3bd2742341e7151f22"},
{file = "windows_curses-2.3.2-cp36-cp36m-win_amd64.whl", hash = "sha256:f81294465a67e43ddc50c1b52711b100b002fa1238f87d84d0cf94b785c4fe75"},
{file = "windows_curses-2.3.2-cp37-cp37m-win32.whl", hash = "sha256:72ff5d8963fbb3aa662bfced2c5ea22dc3ed58bac827a3bff74a1de5eacdbe57"},
{file = "windows_curses-2.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:70d8cb4ddad43f695a266f79c7a31d40ac8aee2a17cf8e06ecfd4a71589ad30d"},
{file = "windows_curses-2.3.2-cp38-cp38-win32.whl", hash = "sha256:6bc698058081408685975256f46f570c32f8d7e1f4f82f9d6c66c300c6daff89"},
{file = "windows_curses-2.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:f73bfd283f86d3ac0a72b74307ccc99ea0fd008a732d80db95d31fbafeee3c66"},
{file = "windows_curses-2.3.2-cp39-cp39-win32.whl", hash = "sha256:e4ec5245f0c00ede45b033a885511eea80d5928c9bd3ceb523fbfb086370a4df"},
{file = "windows_curses-2.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:00d5d000b68db38bd97b6a5b90ee4b4c915a7cef7492fc5aa9ecc7794ee2ca93"},
]
[[package]]
name = "xdg-base-dirs"
version = "6.0.1"
description = "Variables defined by the XDG Base Directory Specification"
optional = false
python-versions = ">=3.10,<4.0"
files = [
{file = "xdg_base_dirs-6.0.1-py3-none-any.whl", hash = "sha256:63f6ebc1721ced2e86c340856e004ef829501a30a37e17079c52cfaf0e1741b9"},
{file = "xdg_base_dirs-6.0.1.tar.gz", hash = "sha256:b4c8f4ba72d1286018b25eea374ec6fbf4fddda3d4137edf50de95de53e195a6"},
]
[metadata]
lock-version = "2.0"
python-versions = ">=3.10,<4.0"
content-hash = "02215828ce6f456df6dadbcc5fedf206bae64c57f7eaedab471370d385470e88"
+62 -12
View File
@@ -1,25 +1,45 @@
import argparse import argparse
import sys
import typing as T
from pathlib import Path from pathlib import Path
import xdg from xdg_base_dirs import xdg_config_home
from pqcli.mechanic import Player
from pqcli.roster import Roster from pqcli.roster import Roster
from pqcli.ui import Ui from pqcli.ui.basic import BasicUserInterface
from pqcli.ui.curses import CursesUserInterface
SAVE_PATH = Path(xdg.XDG_CONFIG_HOME) / "pqcli" / "save.dat" SAVE_PATH = Path(xdg_config_home()) / "pqcli" / "save.dat"
def parse_args() -> argparse.Namespace: def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(prog="pqcli") parser = argparse.ArgumentParser(prog="pqcli")
parser.add_argument(
"--no-config", group = parser.add_mutually_exclusive_group()
dest="use_config", group.set_defaults(ui=CursesUserInterface)
action="store_false", group.add_argument(
help=( "--basic",
"Don't load or save configuration files from $XDG_CONFIG_HOME " dest="ui",
"(player data will still be saved)" action="store_const",
), const=BasicUserInterface,
help="Use basic user interface (very crude, but uses least CPU)",
) )
group.add_argument(
"--curses",
dest="ui",
action="store_const",
const=CursesUserInterface,
help="Use curses user interface (fast, but no colors output)",
)
parser.add_argument(
"--no-colors",
dest="colors",
action="store_false",
help="Disable color highlighting in curses interface",
)
parser.add_argument( parser.add_argument(
"--no-save", "--no-save",
dest="use_saves", dest="use_saves",
@@ -27,15 +47,45 @@ def parse_args() -> argparse.Namespace:
help=argparse.SUPPRESS, help=argparse.SUPPRESS,
) )
parser.add_argument("--cheats", action="store_true", help="???") parser.add_argument("--cheats", action="store_true", help="???")
parser.add_argument(
"--list-saves",
action="store_true",
help="list saved characters and exit",
)
parser.add_argument(
"--load-save",
type=int,
metavar="NUM",
help="play chosen character",
)
return parser.parse_args() return parser.parse_args()
def list_players(roster: Roster, file: T.Optional[T.Any] = None) -> None:
for i, player in enumerate(roster.players, start=1):
print(f"{i}. {player.name}", file=file)
def main() -> None: def main() -> None:
args = parse_args() args = parse_args()
roster = Roster.load(SAVE_PATH) roster = Roster.load(SAVE_PATH)
if args.list_saves:
list_players(roster)
exit(0)
player: T.Optional[Player] = None
if args.load_save:
try:
player = list(roster.players)[args.load_save - 1]
except IndexError:
print("Invalid player. Available players:", file=sys.stderr)
list_players(roster, file=sys.stderr)
exit(1)
try: try:
Ui(roster, args).run() ui = args.ui(roster, player, args)
ui.run()
finally: finally:
if args.use_saves: if args.use_saves:
roster.save() roster.save()
+5 -5
View File
@@ -48,7 +48,7 @@ SPELLS = [
"Sadness", "Sadness",
"Seasick", "Seasick",
"Shoelaces", "Shoelaces",
"Innoculate", "Inoculate",
"Cone of Annoyance", "Cone of Annoyance",
"Magnetic Orb", "Magnetic Orb",
"Invisible Hands", "Invisible Hands",
@@ -85,7 +85,7 @@ SPELLS = [
"Animate Tunic", "Animate Tunic",
"Ursine Armor", "Ursine Armor",
"Holy Roller", "Holy Roller",
"Tonsilectomy", "Tonsillectomy",
"Curse Family", "Curse Family",
"Infinite Confusion", "Infinite Confusion",
] ]
@@ -484,9 +484,9 @@ MONSTERS = [
Monster("Pentasaurus", 12, "head"), Monster("Pentasaurus", 12, "head"),
Monster("Stegosaurus", 18, "plate"), Monster("Stegosaurus", 18, "plate"),
Monster("Triceratops", 16, "horn"), Monster("Triceratops", 16, "horn"),
Monster("Tyranosauraus Rex", 18, "forearm"), Monster("Tyrannosaurus Rex", 18, "forearm"),
Monster("Djinn", 7, "lamp"), Monster("Djinn", 7, "lamp"),
Monster("Doppleganger", 4, "face"), Monster("Doppelganger", 4, "face"),
Monster("Black Dragon", 7, None), Monster("Black Dragon", 7, None),
Monster("Plaid Dragon", 7, "sporrin"), Monster("Plaid Dragon", 7, "sporrin"),
Monster("Blue Dragon", 9, None), Monster("Blue Dragon", 9, None),
@@ -555,7 +555,7 @@ MONSTERS = [
Monster("Hippocampus", 4, "mane"), Monster("Hippocampus", 4, "mane"),
Monster("Hippogriff", 3, "egg"), Monster("Hippogriff", 3, "egg"),
Monster("Hobgoblin", 1, "patella"), Monster("Hobgoblin", 1, "patella"),
Monster("Homonculus", 2, "fluid"), Monster("Homunculus", 2, "fluid"),
Monster("Hydra", 8, "gyrum"), Monster("Hydra", 8, "gyrum"),
Monster("Imp", 2, "tail"), Monster("Imp", 2, "tail"),
Monster("Invisible Stalker", 8, None), Monster("Invisible Stalker", 8, None),
+27
View File
@@ -1,8 +1,30 @@
import datetime
import typing as T import typing as T
from pqcli import random from pqcli import random
def format_float(num: float) -> str:
ret = f"{num:.01f}"
if ret.endswith("0"):
ret = ret[:-2]
return ret
def format_timespan(timespan: datetime.timedelta) -> str:
num = timespan.total_seconds()
if num < 60.0:
return f"~{int(num)}s"
num /= 60
if num < 60.0:
return f"~{int(num)}m"
num /= 60
if num < 24.0:
return f"~{format_float(num)}h"
num /= 24
return f"~{format_float(num)}d"
def generate_name() -> str: def generate_name() -> str:
parts = [ parts = [
"br|cr|dr|fr|gr|j|kr|l|m|n|pr||||r|sh|tr|v|wh|x|y|z".split("|"), "br|cr|dr|fr|gr|j|kr|l|m|n|pr||||r|sh|tr|v|wh|x|y|z".split("|"),
@@ -122,3 +144,8 @@ def special(m: int, subject: str) -> str:
return prefix( return prefix(
["Battle-", "cursed ", "Were-", "undead ", "demon "], m, subject, "" ["Battle-", "cursed ", "Were-", "undead ", "demon "], m, subject, ""
) )
def terminate_message(player_name: str) -> str:
adjective = random.choice(["faithful", "noble", "loyal", "brave"])
return f"Terminate {adjective} {player_name}?"
+85 -31
View File
@@ -1,35 +1,76 @@
import datetime import datetime
import itertools import itertools
import logging import logging
import math
import typing as T import typing as T
from collections import defaultdict
from dataclasses import dataclass from dataclasses import dataclass
import urwid
from pqcli import random from pqcli import random
from pqcli.config import * from pqcli.config import (
from pqcli.lingo import * ARMORS,
BORING_ITEMS,
CLASSES,
DEFENSE_ATTRIB,
DEFENSE_BAD,
IMPRESSIVE_TITLES,
ITEM_ATTRIB,
ITEM_OFS,
MONSTERS,
OFFENSE_ATTRIB,
OFFENSE_BAD,
PRIME_STATS,
RACES,
SHIELDS,
SPECIALS,
SPELLS,
TITLES,
WEAPONS,
Class,
EquipmentPreset,
EquipmentType,
Modifier,
Monster,
Race,
StatType,
)
from pqcli.lingo import (
act_name,
big,
definite,
generate_name,
indefinite,
sick,
special,
young,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_SIGNALS: T.Dict[
T.Tuple["SignalMixin", str], T.List[T.Callable[..., T.Any]]
] = defaultdict(list)
class SignalMixin: class SignalMixin:
def __init_subclass__(cls: T.Any, **kwargs: T.Any) -> None:
urwid.signals.register_signal(cls, cls.signals)
super().__init_subclass__(**kwargs)
def emit(self, signal_name: str, *user_data: T.Any) -> None: def emit(self, signal_name: str, *user_data: T.Any) -> None:
urwid.signals.emit_signal(self, signal_name, *user_data) for callback in _SIGNALS[self, signal_name]:
callback(*user_data)
def connect(self, signal_name: str, callback: T.Callable) -> None: def connect(
urwid.signals.connect_signal(self, signal_name, callback) self, signal_name: str, callback: T.Callable[..., T.Any]
) -> None:
_SIGNALS[self, signal_name].append(callback)
def __getstate__(self) -> T.Any: def disconnect(
return { self, signal_name: str, callback: T.Callable[..., T.Any]
key: value ) -> None:
for key, value in self.__dict__.items() try:
if "signal" not in key idx = _SIGNALS[self, signal_name].index(callback)
} except ValueError:
return
del _SIGNALS[self, signal_name][idx]
def level_up_time(level: int) -> int: def level_up_time(level: int) -> int:
@@ -46,9 +87,12 @@ class Bar(SignalMixin):
self.emit("change") self.emit("change")
def reset(self, new_max: int, position: float = 0.0) -> None: def reset(self, new_max: int, position: float = 0.0) -> None:
self._max = new_max if not math.isclose(new_max, self._max) or not math.isclose(
self._position = position position, self._position
self.emit("change") ):
self._max = new_max
self._position = position
self.emit("change")
def increment(self, inc: float) -> None: def increment(self, inc: float) -> None:
self.reposition(self._position + inc) self.reposition(self._position + inc)
@@ -66,8 +110,11 @@ class Bar(SignalMixin):
return self._position >= self.max_ return self._position >= self.max_
def reposition(self, new_pos: float) -> None: def reposition(self, new_pos: float) -> None:
self._position = float(min(new_pos, self._max)) old_pos = self._position
self.emit("change") new_pos = float(min(new_pos, self._max))
self._position = new_pos
if not math.isclose(new_pos, old_pos):
self.emit("change")
class Stats(SignalMixin): class Stats(SignalMixin):
@@ -170,7 +217,7 @@ class Inventory(SignalMixin):
def pop(self, index: int) -> None: def pop(self, index: int) -> None:
item = self._items[index] item = self._items[index]
logger.info("Lost %s (qty=%d)", item.name, item.quantity) logger.info("Lost %s", indefinite(item.name, item.quantity))
self._items.pop(index) self._items.pop(index)
self.sync_encumbrance() self.sync_encumbrance()
self.emit("item_del", item) self.emit("item_del", item)
@@ -237,6 +284,12 @@ class SpellBook(SignalMixin):
def __iter__(self) -> T.Iterator[Spell]: def __iter__(self) -> T.Iterator[Spell]:
return iter(self._spells) return iter(self._spells)
def __bool__(self) -> bool:
return bool(self._spells)
def __len__(self) -> int:
return len(self._spells)
def add(self, spell_name: str, level: int) -> None: def add(self, spell_name: str, level: int) -> None:
for spell in self._spells: for spell in self._spells:
if spell.name == spell_name: if spell.name == spell_name:
@@ -322,7 +375,7 @@ class Player(SignalMixin):
self.task: T.Optional[BaseTask] = None self.task: T.Optional[BaseTask] = None
self.queue: T.List[BaseTask] = [] self.queue: T.List[BaseTask] = []
def __setstate__(self, obj) -> None: def __setstate__(self, obj: T.Any) -> None:
self.__dict__.update(obj) self.__dict__.update(obj)
self.elapsed = obj.get("elapsed", 0.0) self.elapsed = obj.get("elapsed", 0.0)
@@ -333,7 +386,7 @@ class Player(SignalMixin):
self.emit("new_task") self.emit("new_task")
def equip_price(self) -> int: def equip_price(self) -> int:
return 5 * (self.level ** 2) + 10 * self.level + 20 return 5 * (self.level**2) + 10 * self.level + 20
def level_up(self) -> None: def level_up(self) -> None:
self.level += 1 self.level += 1
@@ -359,12 +412,12 @@ class Player(SignalMixin):
chosen_stat = random.choice(list(StatType)) chosen_stat = random.choice(list(StatType))
else: else:
# favor the best stat so it will tend to clump # favor the best stat so it will tend to clump
t = sum(value ** 2 for _stat, value in self.stats) t = sum(value**2 for _stat, value in self.stats)
t = random.below(t) t = random.below(t)
chosen_stat = None chosen_stat = None
for stat, value in self.stats: for stat, value in self.stats:
chosen_stat = stat chosen_stat = stat
t -= value ** 2 t -= value**2
if t < 0: if t < 0:
break break
@@ -696,7 +749,7 @@ class Simulation:
enqueue( enqueue(
RegularTask( RegularTask(
f"Victory! {nemesis} is slain! " f"Victory! {nemesis} is slain! "
"Exhausted, you lose conciousness", "Exhausted, you lose consciousness",
3000, 3000,
) )
) )
@@ -718,7 +771,7 @@ class Simulation:
enqueue( enqueue(
RegularTask( RegularTask(
"There is rejoicing, " "There is rejoicing, "
f"and an unnerving encouter with {nemesis} in private", f"and an unnerving encounter with {nemesis} in private",
3000, 3000,
) )
) )
@@ -899,7 +952,8 @@ class StatsBuilder:
return stats return stats
def unroll(self) -> Stats: def unroll(self) -> Stats:
self.history.pop() if len(self.history) > 1:
self.history.pop()
return self.history[-1] return self.history[-1]
@@ -907,7 +961,7 @@ def create_player(
name: str, race: Race, class_: Class, stats: Stats name: str, race: Race, class_: Class, stats: Stats
) -> Player: ) -> Player:
now = datetime.datetime.now() now = datetime.datetime.now()
random.seed(now) random.seed(str(now))
player = Player( player = Player(
birthday=now, name=name, race=race, class_=class_, stats=stats birthday=now, name=name, race=race, class_=class_, stats=stats
) )
+2 -2
View File
@@ -2,8 +2,8 @@ import random
import typing as T import typing as T
def seed(source: T.Any) -> None: def seed(source: str) -> None:
random.seed(source) random.seed(source.encode())
def choice(source: T.Sequence[T.Any]) -> T.Any: def choice(source: T.Sequence[T.Any]) -> T.Any:
+11
View File
@@ -1,5 +1,7 @@
import logging
import pickle import pickle
import typing as T import typing as T
from datetime import datetime
from pathlib import Path from pathlib import Path
from pqcli.mechanic import Player from pqcli.mechanic import Player
@@ -9,6 +11,7 @@ class Roster:
def __init__(self, path: Path, players: T.List[Player]) -> None: def __init__(self, path: Path, players: T.List[Player]) -> None:
self.path = path self.path = path
self.players = players self.players = players
self._last_save = datetime.now()
@staticmethod @staticmethod
def load(path: T.Union[str, Path]) -> "Roster": def load(path: T.Union[str, Path]) -> "Roster":
@@ -20,10 +23,18 @@ class Roster:
return Roster(real_path, players=[]) return Roster(real_path, players=[])
def save(self) -> None: def save(self) -> None:
self._last_save = datetime.now()
old_path = self.path.with_name(self.path.name + ".old") old_path = self.path.with_name(self.path.name + ".old")
tmp_path = self.path.with_name(self.path.name + ".new") tmp_path = self.path.with_name(self.path.name + ".new")
tmp_path.parent.mkdir(parents=True, exist_ok=True) tmp_path.parent.mkdir(parents=True, exist_ok=True)
tmp_path.write_bytes(pickle.dumps(self.players)) tmp_path.write_bytes(pickle.dumps(self.players))
if self.path.exists(): if self.path.exists():
if old_path.exists():
old_path.unlink()
self.path.rename(old_path) self.path.rename(old_path)
tmp_path.rename(self.path) tmp_path.rename(self.path)
def save_periodically(self) -> None:
if (datetime.now() - self._last_save).total_seconds() >= 300:
logging.info("Saving...")
self.save()
-115
View File
@@ -1,115 +0,0 @@
import argparse
import datetime
import logging
import sys
from pathlib import Path
from signal import SIG_DFL, SIGPIPE, signal
from pqcli import random
from pqcli.config import CLASSES, RACES
from pqcli.mechanic import (
Player,
Simulation,
StatsBuilder,
act_name,
create_player,
generate_name,
)
from pqcli.roster import Roster
signal(SIGPIPE, SIG_DFL)
logging.basicConfig(
stream=sys.stdout, level=logging.DEBUG, format="%(message)s"
)
# make things predictable
SEED_A = 1_664_525
SEED_B = 2141
SEED_C = 1_013_904_223
def custom_random(num: int) -> int:
global SEED_A, SEED_B, SEED_C
SEED_B = (SEED_A * SEED_B + SEED_C) % 0x100000000
return SEED_B % num
random.below = custom_random
def character_sheet(player: Player) -> None:
print(f"{player.name} the {player.race.name}")
print(f"Level {player.level} {player.class_.name}")
print(f"Plot stage: {act_name(player.quest_book.act)}")
print(f"Quest: {player.quest_book.current_quest}")
print()
print("Stats:")
for stat, value in player.stats:
print(f" {stat.value}: {value}")
print()
print(f"Equipment (best: {player.equipment.best}):")
for equipment, item_name in player.equipment:
print(f" {equipment.value}: {item_name}")
print()
print("Spell Book:")
for spell in player.spell_book:
print(f" {spell.name}: {spell.level}")
print()
print("Inventory:")
print(f" Gold: {player.inventory.gold}")
for item in player.inventory:
print(f" {item.name}: {item.quantity}")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("-r", "--roster", type=Path)
parser.add_argument("-l", "--max-level", type=int, default=5)
return parser.parse_args()
def random_player() -> Player:
return create_player(
stats=StatsBuilder().roll(),
name=generate_name(),
race=random.choice(RACES),
class_=random.choice(CLASSES),
)
def main() -> None:
args = parse_args()
if args.roster:
roster = Roster.load(args.roster)
if not roster.players:
roster.players.append(random_player())
roster.save()
player = roster.players[0]
else:
roster = None
player = random_player()
start = datetime.datetime.now()
elapsed = 0
last_level = 0
simulation = Simulation(player)
try:
while True:
if player.level != last_level:
last_level = player.level
character_sheet(player)
if player.level >= args.max_level:
break
simulation.tick()
elapsed += 100
finally:
print("real time: ", (datetime.datetime.now() - start))
print("virtual time: ", datetime.timedelta(milliseconds=elapsed))
if roster:
roster.save()
if __name__ == "__main__":
main()
-167
View File
@@ -1,167 +0,0 @@
import argparse
import json
import typing as T
from pathlib import Path
import urwid
import xdg
from pqcli import random
from pqcli.mechanic import Player
from pqcli.roster import Roster
from pqcli.ui.confirm_dialog import ConfirmDialog
from pqcli.ui.game_view import GameView
from pqcli.ui.new_game_view import NewGameView
from pqcli.ui.roster_view import RosterView
TICK_FREQ = 0.1
SAVE_FREQ = 60.0
PALETTE: T.List[T.Tuple[str, ...]] = [
("button", "", ""),
("button-focus", "light red", "black"),
("linebox", "", ""),
("linebox-focus", "light red", "black"),
("linebox-content", "", ""),
("linebox-content-focus", "", ""),
("progressbar-normal", "", "", "standout"),
("progressbar-done", "black", "light red"),
("progressbar-smooth", "light red", ""),
("scrollbar", "light gray", "black"),
("scrollbar-focus", "light red", "black"),
("scrollable", "", ""),
("scrollable-focus", "", ""),
]
PALETTE_PATH = Path(xdg.XDG_CONFIG_HOME) / "pqcli" / "palette.json"
def load_palette() -> bool:
global PALETTE
if PALETTE_PATH.exists():
PALETTE = json.loads(PALETTE_PATH.read_text())
return True
return False
def save_palette() -> None:
global PALETTE
PALETTE_PATH.parent.mkdir(exist_ok=True, parents=True)
PALETTE_PATH.write_text(json.dumps(PALETTE, indent=4))
class ConfirmExitDialog(ConfirmDialog):
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 Ui:
def __init__(self, roster: Roster, args: argparse.Namespace) -> None:
bind_commands()
if args.use_config:
if not load_palette():
save_palette() # create a file for the user to edit
self.roster = roster
self.args = args
self.loop = urwid.MainLoop(
None, PALETTE, unhandled_input=self.unhandled_input
)
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, player, self.args)
self._connect("cancel", self.switch_to_roster_view)
def switch_to_delete_player_view(self, player_idx: int) -> None:
adjective = random.choice(["faithful", "noble", "loyal", "brave"])
player_name = self.roster.players[player_idx].name
self.old_views.append(self.loop.widget)
self.loop.widget = ConfirmDialog(
f"Terminate {adjective} {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)
+22
View File
@@ -0,0 +1,22 @@
import abc
import argparse
import typing as T
from pqcli.mechanic import Player
from pqcli.roster import Roster
class BaseUserInterface(abc.ABC):
def __init__(
self,
roster: Roster,
player: T.Optional[Player],
args: argparse.Namespace,
) -> None:
self.roster = roster
self.args = args
self.player = player
@abc.abstractmethod
def run(self) -> None:
raise NotImplementedError("not implemented")
+229
View File
@@ -0,0 +1,229 @@
import argparse
import enum
import logging
import signal
import sys
import time
import typing as T
from datetime import datetime, timedelta
from pqcli import lingo
from pqcli.config import CLASSES, PRIME_STATS, RACES
from pqcli.mechanic import Player, Simulation, StatsBuilder, create_player
from pqcli.roster import Roster
from pqcli.ui.base import BaseUserInterface
LOGO = r"""
____ ___ _
| _ \ _ __ ___ __ _ _ __ ___ ___ ___ / _ \ _ _ ___ ___| |_
| |_) | '__/ _ \ / _` | '__/ _ \/ __/ __| | | | | | |/ _ \/ __| __|
| __/| | | (_) | (_| | | | __/\__ \__ \ |_| | |_| | __/\__ \ |_
|_| |_| \___/ \__, |_| \___||___/___/\__\_\\__,_|\___||___/\__|
|___/
"""
class MainMenu(enum.IntEnum):
create = 1
play = 2
info = 3
delete = 4
quit = 5
class BasicUserInterface(BaseUserInterface):
def __init__(
self,
roster: Roster,
player: T.Optional[Player],
args: argparse.Namespace,
) -> None:
super().__init__(roster, player, args)
def signal_handler(sig: T.Any, frame: T.Any) -> None:
logging.info("Quitting")
if self.args.use_saves:
self.roster.save()
sys.exit(0)
signal.signal(signal.SIGINT, signal_handler)
def run(self) -> None:
logging.basicConfig(
stream=sys.stdout,
level=logging.INFO,
format="[%(asctime)s] %(message)s",
)
self.logo()
if self.player:
self.play(self.player)
else:
self.main_menu()
def logo(self) -> None:
print(LOGO)
def main_menu(self) -> None:
while True:
choice = self.menu(
[
(MainMenu.create, "Create a new character"),
(MainMenu.play, "Play as character"),
(MainMenu.info, "Query character info"),
(MainMenu.delete, "Delete a character"),
(MainMenu.quit, "Quit"),
]
)
if choice == MainMenu.create:
player = self.create_player()
elif choice == MainMenu.play:
player = self.choose_player()
if not player:
continue
self.play(player)
elif choice == MainMenu.info:
player = self.choose_player()
if not player:
continue
self.print_player_info(player)
elif choice == MainMenu.delete:
player = self.choose_player()
if not player:
continue
self.delete_player(player)
elif choice == MainMenu.quit:
self.quit()
break
def create_player(self) -> T.Optional[Player]:
name = input("Name your new character: ")
if not name:
print("Cancelled.")
return None
race = self.menu([(race, race.name) for race in RACES])
class_ = self.menu([(class_, class_.name) for class_ in CLASSES])
stats_builder = StatsBuilder()
while True:
stats = stats_builder.roll()
for stat in PRIME_STATS:
print(f"{stat.value}: {stats[stat]}")
print(f"Total: {sum(stats[stat] for stat in PRIME_STATS)}")
if self.confirm("Is this okay?"):
break
player = create_player(
name=name, race=race, class_=class_, stats=stats
)
self.roster.players.append(player)
if self.confirm("Do you want to play as your new character?"):
self.play(player)
return player
def play(self, player: Player) -> None:
print(f"Playing as {player.name}")
simulation = Simulation(player)
last_tick = datetime.now()
last_level = 0
while True:
elapsed = datetime.now() - last_tick
simulation.tick(elapsed.total_seconds() * 1000)
last_tick = datetime.now()
wait = (timedelta(milliseconds=100) - elapsed).total_seconds()
wait = max(0, wait)
time.sleep(wait)
if player.level != last_level:
last_level = player.level
self.print_player_info(player)
if self.args.use_saves:
self.roster.save_periodically()
def print_player_info(self, player: Player) -> None:
print("--- Character Sheet ---")
print(f"Name: {player.name}")
print(f"Race: {player.race.name}")
print(f"Class: {player.class_.name}")
print(f"Level: {player.level}")
print()
for stat, value in player.stats:
print(f"{stat.value}: {value}")
print()
print("--- Spell Book ---")
if not player.spell_book:
print("No spells memorized yet.")
else:
for spell in player.spell_book:
print(f"{spell.name} {lingo.to_roman(spell.level)}")
print()
print("--- Equipment ---")
for equipment_type, name in player.equipment:
print(f"{equipment_type.value}: {name}")
print()
print("--- Inventory ---")
print(f"Gold: {player.inventory.gold}")
for item in player.inventory:
print(f"{item.name}: {item.quantity}")
print()
print("--- Plot ---")
print(f"Current act: {lingo.to_roman(player.quest_book.act)}")
print(f"Current quest: {player.quest_book.current_quest or '?'}")
print(
f"Current task: {player.task.description if player.task else '?'}"
)
def delete_player(self, player: Player) -> None:
if self.confirm(lingo.terminate_message(player.name)):
del self.roster.players[self.roster.players.index(player)]
def quit(self) -> None:
if self.args.use_saves:
self.roster.save()
def choose_player(self) -> T.Optional[Player]:
if not self.roster.players:
print("No characters to choose from!")
return None
return self.menu(
[(player, player.name) for player in self.roster.players],
title="Choose your character",
)
def confirm(self, message: str) -> bool:
while True:
choice = input(f"{message} [y/n] ")
if choice.lower() in {"y", "yes", "1"}:
return True
if choice.lower() in {"n", "no", "0"}:
return False
def menu(
self,
options: T.Sequence[T.Tuple[T.Any, str]],
title: T.Optional[str] = None,
) -> T.Any:
print()
if title:
print(f"{title}:")
for i, item in enumerate(options, 1):
_, description = item
print(f"{i}) {description}")
print()
while True:
try:
num = int(input("Your choice: "))
except ValueError:
print("Not a number")
continue
if num not in range(1, len(options) + 1):
print(f"Expected a number between 1..{len(options)}")
continue
return options[num - 1][0]
-47
View File
@@ -1,47 +0,0 @@
import typing as T
import urwid
from pqcli.ui.custom_button import CustomButton
from pqcli.ui.custom_line_box import CustomLineBox
from pqcli.ui.layout import NPile
class ConfirmDialog(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()
+203
View File
@@ -0,0 +1,203 @@
import argparse
import curses
import os
import signal
import time
import typing as T
from pqcli import lingo
from pqcli.config import Class, Race
from pqcli.mechanic import Player, Stats, create_player
from pqcli.roster import Roster
from pqcli.ui.base import BaseUserInterface
from pqcli.ui.curses.colors import (
COLOR_FOCUSED,
COLOR_HIGHLIGHT,
COLOR_LOGO,
COLOR_LOGO_ALT,
COLOR_PROGRESSBAR,
COLOR_SCROLLBAR_THUMB,
COLOR_SCROLLBAR_TRACK,
has_colors,
set_colors,
)
from pqcli.ui.curses.views import (
BaseView,
ChooseCharacterClassView,
ChooseCharacterNameView,
ChooseCharacterRaceView,
ChooseCharacterStatsView,
ChooseCharacterView,
ConfirmView,
GameView,
RosterView,
)
class StopMainLoop(Exception):
pass
class CursesUserInterface(BaseUserInterface):
def __init__(
self, roster: Roster, player: Player, args: argparse.Namespace
) -> None:
super().__init__(roster, player, args)
os.environ.setdefault("ESCDELAY", "25")
self._screen = curses.initscr()
self._screen.nodelay(True)
self._screen.keypad(True)
curses.noecho()
curses.curs_set(0)
set_colors(args.colors)
if has_colors():
curses.start_color()
curses.use_default_colors()
curses.init_pair(COLOR_LOGO, -1, -1)
curses.init_pair(COLOR_LOGO_ALT, -1, 9)
curses.init_pair(COLOR_FOCUSED, -1, 8)
curses.init_pair(COLOR_SCROLLBAR_THUMB, -1, 7)
curses.init_pair(COLOR_SCROLLBAR_TRACK, -1, 0)
curses.init_pair(COLOR_PROGRESSBAR, 0, 9)
curses.init_pair(COLOR_HIGHLIGHT, 3, -1)
def signal_handler(sig: T.Any, frame: T.Any) -> None:
if self.args.use_saves:
self.roster.save()
exit(0)
signal.signal(signal.SIGINT, signal_handler)
self._roster_view = RosterView(self._screen)
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_quit += self._quit
self._view: BaseView = self._roster_view
self._view.start()
def run(self) -> None:
try:
if self.player:
self._switch_to_game_view(self.player)
else:
self._switch_to_roster_view()
while True:
key = T.cast(int, self._screen.getch())
if key == curses.ERR:
time.sleep(0.1)
self._view.idle()
continue
if key == curses.KEY_RESIZE:
self._view.stop()
self._view.start()
else:
try:
self._view.keypress(key)
except StopMainLoop:
break
finally:
curses.endwin()
curses.curs_set(1)
def _switch_to_roster_view(self) -> None:
self._switch_view(self._roster_view)
def _switch_to_create_char_name_view(
self, player_name: T.Optional[str] = None
) -> None:
view = ChooseCharacterNameView(self._screen, player_name)
view.on_cancel += self._switch_to_roster_view
view.on_confirm += self._switch_to_create_char_race_view
self._switch_view(view)
def _switch_to_create_char_race_view(
self, player_name: str, race: T.Optional[Race] = None
) -> None:
view = ChooseCharacterRaceView(self._screen, race)
view.on_cancel += lambda: self._switch_to_create_char_name_view(
player_name
)
view.on_confirm += lambda race: self._switch_to_create_char_class_view(
player_name, race
)
self._switch_view(view)
def _switch_to_create_char_class_view(
self, player_name: str, race: Race, class_: T.Optional[Class] = None
) -> None:
view = ChooseCharacterClassView(self._screen, class_)
view.on_cancel += lambda: self._switch_to_create_char_race_view(
player_name, race
)
view.on_confirm += (
lambda class_: self._switch_to_create_char_stats_view(
player_name, race, class_
)
)
self._switch_view(view)
def _switch_to_create_char_stats_view(
self, player_name: str, race: Race, class_: Class
) -> None:
view = ChooseCharacterStatsView(self._screen)
view.on_cancel += lambda: self._switch_to_create_char_class_view(
player_name, race, class_
)
view.on_confirm += lambda stats: self._create_character(
player_name, race, class_, stats
)
self._switch_view(view)
def _switch_to_delete_char_view(self) -> None:
view = ChooseCharacterView(
self._screen, self.roster, "Choose character to delete"
)
view.on_cancel += self._switch_to_roster_view
view.on_confirm += self._switch_to_confirm_delete_char_view
self._switch_view(view)
def _switch_to_confirm_delete_char_view(self, player: Player) -> None:
view = ConfirmView(self._screen, lingo.terminate_message(player.name))
view.on_cancel += self._switch_to_delete_char_view
view.on_confirm += lambda: self._delete_character(player)
self._switch_view(view)
def _switch_to_play_view(self) -> None:
view = ChooseCharacterView(
self._screen, self.roster, "Choose character to play"
)
view.on_cancel += self._switch_to_roster_view
view.on_confirm += self._switch_to_game_view
self._switch_view(view)
def _switch_to_game_view(self, player: Player) -> None:
view = GameView(self._screen, self.roster, player, self.args)
view.on_exit += self._switch_to_roster_view
self._switch_view(view)
def _switch_view(self, view: BaseView) -> None:
self._view.stop()
self._view = view
self._view.start()
def _create_character(
self, name: str, race: Race, class_: Class, stats: Stats
) -> None:
player = create_player(name, race, class_, stats)
self.roster.players.append(player)
if self.args.use_saves:
self.roster.save()
self._switch_to_game_view(player)
def _delete_character(self, player: Player) -> None:
self.roster.players.remove(player)
self._switch_to_roster_view()
def _quit(self) -> None:
raise StopMainLoop
+18
View File
@@ -0,0 +1,18 @@
_HAS_COLORS = False
COLOR_LOGO = 1
COLOR_LOGO_ALT = 2
COLOR_FOCUSED = 3
COLOR_SCROLLBAR_TRACK = 4
COLOR_SCROLLBAR_THUMB = 5
COLOR_PROGRESSBAR = 6
COLOR_HIGHLIGHT = 7
def has_colors() -> bool:
return _HAS_COLORS
def set_colors(has_colors: bool) -> None:
global _HAS_COLORS
_HAS_COLORS = has_colors
+14
View File
@@ -0,0 +1,14 @@
import typing as T
class EventHandler:
def __init__(self) -> None:
self.callbacks: T.List[T.Callable[..., T.Any]] = []
def __call__(self, *args: T.Any, **kwargs: T.Any) -> None:
for callback in self.callbacks:
callback(*args, **kwargs)
def __iadd__(self, callback: T.Callable[..., T.Any]) -> "EventHandler":
self.callbacks.append(callback)
return self
+22
View File
@@ -0,0 +1,22 @@
import curses
import curses.ascii
import typing as T
from dataclasses import dataclass
KEYS_CYCLE = {curses.ascii.TAB}
KEYS_DOWN = set(map(ord, "jJ")) | {curses.KEY_DOWN}
KEYS_LEFT = set(map(ord, "hH")) | {curses.KEY_LEFT}
KEYS_RIGHT = set(map(ord, "lL")) | {curses.KEY_RIGHT}
KEYS_UP = set(map(ord, "kK")) | {curses.KEY_UP}
KEYS_CANCEL = set(map(ord, "qQ")) | {curses.ascii.ESC}
@dataclass
class Choice:
keys: T.List[int]
desc: str
callback: T.Callable[..., T.Any]
def first(source: T.Iterable[T.Any], default: T.Any = None) -> T.Any:
return next(iter(source), default)
+23
View File
@@ -0,0 +1,23 @@
from .base_view import BaseView
from .choose_character_view import ChooseCharacterView
from .confirm_view import ConfirmView
from .create_character_view import (
ChooseCharacterClassView,
ChooseCharacterNameView,
ChooseCharacterRaceView,
ChooseCharacterStatsView,
)
from .game_view import GameView
from .roster_view import RosterView
__all__ = [
"BaseView",
"ChooseCharacterView",
"ConfirmView",
"ChooseCharacterClassView",
"ChooseCharacterNameView",
"ChooseCharacterRaceView",
"ChooseCharacterStatsView",
"GameView",
"RosterView",
]
+18
View File
@@ -0,0 +1,18 @@
import typing as T
class BaseView:
def __init__(self, screen: T.Any) -> None:
self.screen = screen
def start(self) -> None:
pass
def stop(self) -> None:
pass
def idle(self) -> None:
pass
def keypress(self, key: int) -> None:
pass
@@ -0,0 +1,53 @@
import functools
import typing as T
from pqcli.lingo import act_name, to_roman
from pqcli.roster import Roster
from pqcli.ui.curses.util import KEYS_CANCEL, Choice
from pqcli.ui.curses.views.menu_view import MenuView
class ChooseCharacterView(MenuView):
def __init__(self, screen: T.Any, roster: Roster, title: str) -> None:
super().__init__(screen, title)
for y, player in enumerate(roster.players, 1):
key: T.Optional[str] = str(y % 10) if y <= 10 else None
best_equip = player.equipment.best
best_spell = player.spell_book.best
best_spell_name = (
(best_spell.name + " " + to_roman(best_spell.level))
if best_spell
else "-"
)
best_stat_name = (
f"{player.stats.best_prime.value} "
f"{player.stats[player.stats.best_prime]}"
)
label = (
f"[{key or '-'}] "
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}"
)
self._choices.append(
Choice(
keys=[ord(key)] if key is not None else [],
desc=label,
callback=functools.partial(self.on_confirm, player),
)
)
self._choices.append(
Choice(
keys=list(KEYS_CANCEL),
desc="[Q] Cancel",
callback=self.on_cancel,
)
)
+50
View File
@@ -0,0 +1,50 @@
import typing as T
from pqcli.ui.curses.event_handler import EventHandler
from pqcli.ui.curses.util import Choice
from pqcli.ui.curses.widgets import Menu
from .base_view import BaseView
class ConfirmView(BaseView):
def __init__(self, screen: T.Any, title: str) -> None:
super().__init__(screen)
self.title = f"{title}:"
self.on_cancel = EventHandler()
self.on_confirm = EventHandler()
self.choices: T.List[Choice] = [
Choice(
keys=list(map(ord, "yY")),
desc="[Y] Yes",
callback=self.on_confirm,
),
Choice(
keys=list(map(ord, "nNqQ\N{ESC}")),
desc="[N] No",
callback=self.on_cancel,
),
]
def start(self) -> None:
scr_height, scr_width = self.screen.getmaxyx()
self.screen.erase()
self.screen.noutrefresh()
self.main_menu = Menu(
header=self.title,
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()
@@ -0,0 +1,11 @@
from .choose_character_class_view import ChooseCharacterClassView
from .choose_character_name_view import ChooseCharacterNameView
from .choose_character_race_view import ChooseCharacterRaceView
from .choose_character_stats_view import ChooseCharacterStatsView
__all__ = [
"ChooseCharacterClassView",
"ChooseCharacterNameView",
"ChooseCharacterRaceView",
"ChooseCharacterStatsView",
]
@@ -0,0 +1,35 @@
import functools
import typing as T
from pqcli.config import CLASSES, Class
from pqcli.ui.curses.util import KEYS_CANCEL, Choice
from pqcli.ui.curses.views.menu_view import MenuView
class ChooseCharacterClassView(MenuView):
def __init__(
self, screen: T.Any, class_: T.Optional[Class] = None
) -> None:
super().__init__(
screen,
"Choose character class",
CLASSES.index(class_) if class_ is not None else 0,
)
for y, class_ in enumerate(CLASSES, 1):
key: T.Optional[str] = str(y % 10) if y <= 10 else None
self._choices.append(
Choice(
keys=[ord(key)] if key is not None else [],
desc=f"[{key or '-'}] {class_.name}",
callback=functools.partial(self.on_confirm, class_),
)
)
self._choices.append(
Choice(
keys=list(KEYS_CANCEL),
desc="[Q] Cancel",
callback=self.on_cancel,
)
)
@@ -0,0 +1,120 @@
import curses
import curses.ascii
import typing as T
from pqcli.mechanic import generate_name
from pqcli.ui.curses.event_handler import EventHandler
from pqcli.ui.curses.util import KEYS_CANCEL, KEYS_CYCLE, KEYS_DOWN, KEYS_UP
from pqcli.ui.curses.views.base_view import BaseView
from pqcli.ui.curses.widgets.focusable import focus_standout
class ChooseCharacterNameView(BaseView):
def __init__(
self, screen: T.Any, character_name: T.Optional[str] = None
) -> None:
super().__init__(screen)
self.on_cancel = EventHandler()
self.on_confirm = EventHandler()
self._active_widget = 0
self._win: T.Optional[T.Any] = None
self._text_win: T.Optional[T.Any] = None
self._text = character_name or generate_name()
def start(self) -> None:
self.screen.erase()
self.screen.noutrefresh()
scr_height, scr_width = self.screen.getmaxyx()
h = 8
w = 30
y = (scr_height - h - 2) // 2
x = (scr_width - w - 2) // 2
self._text_win = curses.newwin(3, w + 2, y + 2, x - 1)
self._win = curses.newwin(h + 2, w + 2, y, x)
self._render()
def stop(self) -> None:
del self._win
del self._text_win
self._win = None
self._text_win = None
def keypress(self, key: int) -> None:
if key == curses.KEY_F5:
self._text = generate_name()
elif key == curses.KEY_F10:
self._finish()
elif key in KEYS_CYCLE:
self._active_widget = (self._active_widget + 1) % 4
elif self._active_widget == 0:
if key == curses.ascii.ESC:
self.on_cancel()
elif curses.ascii.isprint(key):
self._text += chr(key)
elif key == curses.KEY_BACKSPACE or key == curses.ascii.DEL:
self._text = self._text[:-1]
elif key == curses.ascii.NL:
self._finish()
elif key == curses.ascii.ETB: # ^w
self._text = ""
elif key == curses.KEY_DOWN:
self._active_widget = 1
else:
if key == curses.ascii.NL:
if self._active_widget == 1:
self._text = generate_name()
elif self._active_widget == 2:
self._finish()
elif self._active_widget == 3:
self.on_cancel()
else:
assert False
elif key in KEYS_CANCEL:
self.on_cancel()
elif key in KEYS_DOWN and self._active_widget < 3:
self._active_widget += 1
elif key in KEYS_UP and self._active_widget > 0:
self._active_widget -= 1
self._render()
def _finish(self) -> None:
self._text = self._text.strip()
if self._text:
self.on_confirm(self._text)
else:
self.on_cancel()
def _render(self) -> None:
if not self._win or not self._text_win:
return
self._win.erase()
self._win.addstr(0, 0, "Choose character name:")
with focus_standout(self._active_widget == 1, self._win):
self._win.addstr(6, 0, "[F5 ] Generate random name")
with focus_standout(self._active_widget == 2, self._win):
self._win.addstr(7, 0, "[F10 ] Continue")
with focus_standout(self._active_widget == 3, self._win):
self._win.addstr(8, 0, "[Esc ] Cancel")
self._win.refresh()
self._text_win.erase()
with focus_standout(self._active_widget == 0, self._text_win):
self._text_win.box()
self._text_win.addnstr(
1, 1, self._text, self._text_win.getmaxyx()[1] - 2
)
self._text_win.refresh()
@@ -0,0 +1,33 @@
import functools
import typing as T
from pqcli.config import RACES, Race
from pqcli.ui.curses.util import KEYS_CANCEL, Choice
from pqcli.ui.curses.views.menu_view import MenuView
class ChooseCharacterRaceView(MenuView):
def __init__(self, screen: T.Any, race: T.Optional[Race] = None) -> None:
super().__init__(
screen,
"Choose character race",
RACES.index(race) if race is not None else 0,
)
for y, race in enumerate(RACES, 1):
key: T.Optional[str] = str(y % 10) if y <= 10 else None
self._choices.append(
Choice(
keys=[ord(key)] if key is not None else [],
desc=f"[{key or '-'}] {race.name}",
callback=functools.partial(self.on_confirm, race),
)
)
self._choices.append(
Choice(
keys=list(KEYS_CANCEL),
desc="[Q] Cancel",
callback=self.on_cancel,
)
)
@@ -0,0 +1,111 @@
import curses.ascii
import typing as T
from pqcli.config import PRIME_STATS
from pqcli.mechanic import StatsBuilder
from pqcli.ui.curses.event_handler import EventHandler
from pqcli.ui.curses.util import KEYS_CANCEL, KEYS_CYCLE, KEYS_DOWN, KEYS_UP
from pqcli.ui.curses.views.base_view import BaseView
from pqcli.ui.curses.widgets.focusable import focus_standout
class ChooseCharacterStatsView(BaseView):
def __init__(self, screen: T.Any) -> None:
super().__init__(screen)
self.on_cancel = EventHandler()
self.on_confirm = EventHandler()
self._active_widget = 0
self._win: T.Optional[T.Any] = None
self._stats_win: T.Optional[T.Any] = None
self._stats_builder = StatsBuilder()
self._stats = self._stats_builder.roll()
def start(self) -> None:
scr_height, scr_width = self.screen.getmaxyx()
h2 = len(PRIME_STATS) + 2
h = 7 + h2
w = 30
y = (scr_height - h - 2) // 2
x = (scr_width - w - 2) // 2
self.screen.erase()
self.screen.noutrefresh()
self._win = curses.newwin(h, w, y, x)
self._stats_win = curses.newwin(h2, w, y + 2, x - 1)
self._render()
def stop(self) -> None:
del self._win
del self._stats_win
self._win = None
self._stats_win = None
def keypress(self, key: int) -> None:
if key in KEYS_CANCEL:
self.on_cancel()
elif key == curses.ascii.NL:
if self._active_widget == 0:
self._stats = self._stats_builder.roll()
elif self._active_widget == 1:
self._stats = self._stats_builder.unroll()
elif self._active_widget == 2:
self.on_confirm(self._stats)
elif self._active_widget == 3:
self.on_cancel()
else:
assert False
elif key == curses.KEY_F5:
self._stats = self._stats_builder.roll()
elif key == curses.KEY_F6:
self._stats = self._stats_builder.unroll()
elif key == curses.KEY_F10:
self.on_confirm(self._stats)
elif key in KEYS_CYCLE:
self._active_widget = (self._active_widget + 1) % 4
elif key in KEYS_DOWN and self._active_widget < 3:
self._active_widget += 1
elif key in KEYS_UP and self._active_widget > 0:
self._active_widget -= 1
self._render()
def _render(self) -> None:
if not self._win or not self._stats_win:
return
y, x = self._win.getbegyx()
stats_h = self._stats_win.getmaxyx()[0]
self._win.erase()
self._win.addstr(0, 0, "Roll character stats:")
with focus_standout(self._active_widget == 0, self._win):
self._win.addstr(3 + stats_h + 0, 0, "[F5 ] Roll")
with focus_standout(self._active_widget == 1, self._win):
self._win.addstr(3 + stats_h + 1, 0, "[F6 ] Unroll")
with focus_standout(self._active_widget == 2, self._win):
self._win.addstr(3 + stats_h + 2, 0, "[F10 ] Continue")
with focus_standout(self._active_widget == 3, self._win):
self._win.addstr(3 + stats_h + 3, 0, "[Esc ] Cancel")
self._win.noutrefresh()
self._stats_win.erase()
for y, stat in enumerate(PRIME_STATS):
self._stats_win.addstr(
y + 1, 1, f"{stat.value}: {self._stats[stat]}"
)
self._stats_win.box()
self._stats_win.noutrefresh()
curses.doupdate()
+263
View File
@@ -0,0 +1,263 @@
import argparse
import curses
import datetime
import typing as T
from pqcli.mechanic import Player, SignalMixin, Simulation
from pqcli.roster import Roster
from pqcli.ui.curses.event_handler import EventHandler
from pqcli.ui.curses.util import (
KEYS_CANCEL,
KEYS_CYCLE,
KEYS_DOWN,
KEYS_LEFT,
KEYS_RIGHT,
KEYS_UP,
)
from pqcli.ui.curses.views.base_view import BaseView
from pqcli.ui.curses.widgets import Focusable, Widget
from .character_sheet_window import CharacterSheetWindow
from .equipment_window import EquipmentWindow
from .inventory_window import InventoryWindow
from .plot_window import PlotWindow
from .quest_book_window import QuestBookWindow
from .spell_book_window import SpellBookWindow
from .task_progress_window import TaskProgressWindow
class GameView(BaseView):
def __init__(
self,
screen: T.Any,
roster: Roster,
player: Player,
args: argparse.Namespace,
) -> None:
super().__init__(screen)
self.on_exit = EventHandler()
self._roster = roster
self._player = player
self._args = args
self._simulation = Simulation(player)
self._last_tick = datetime.datetime.now()
def keypress(self, key: int) -> None:
focused = self.focused
if key in KEYS_CANCEL:
self.on_exit()
elif key in {curses.KEY_PPAGE}:
if hasattr(focused, "scroll_page_up"):
focused.scroll_page_up()
elif key in {curses.KEY_NPAGE}:
if hasattr(focused, "scroll_page_down"):
focused.scroll_page_down()
elif key in KEYS_CYCLE:
if focused == self._char_sheet_win:
self.focus(self._spell_book_win)
elif focused == self._spell_book_win:
self.focus(self._equipment_win)
elif focused == self._equipment_win:
self.focus(self._inventory_win)
elif focused == self._inventory_win:
self.focus(self._plot_win)
elif focused == self._plot_win:
self.focus(self._quest_book_win)
elif focused == self._quest_book_win:
self.focus(self._char_sheet_win)
elif key in KEYS_DOWN:
if focused == self._char_sheet_win:
self.focus(self._spell_book_win)
elif focused == self._equipment_win:
self.focus(self._inventory_win)
elif focused == self._plot_win:
self.focus(self._quest_book_win)
elif key in KEYS_UP:
if focused == self._spell_book_win:
self.focus(self._char_sheet_win)
elif focused == self._inventory_win:
self.focus(self._equipment_win)
elif focused == self._quest_book_win:
self.focus(self._plot_win)
elif key in KEYS_LEFT:
if focused == self._equipment_win:
self.focus(self._char_sheet_win)
elif focused == self._inventory_win:
self.focus(self._spell_book_win)
elif focused == self._plot_win:
self.focus(self._equipment_win)
elif focused == self._quest_book_win:
self.focus(self._inventory_win)
elif key in KEYS_RIGHT:
if focused == self._char_sheet_win:
self.focus(self._equipment_win)
elif focused == self._spell_book_win:
self.focus(self._inventory_win)
elif focused == self._equipment_win:
self.focus(self._plot_win)
elif focused == self._inventory_win:
self.focus(self._quest_book_win)
elif self._args.cheats and key == ord("t"):
self._simulation.tick()
elif self._args.cheats and key == ord("T"):
for _ in range(100):
self._simulation.tick()
elif self._args.cheats and key == curses.ascii.DC4: # ^t
old = SignalMixin.emit
SignalMixin.emit = lambda *_: None
for _ in range(10000):
self._simulation.tick()
SignalMixin.emit = old
for child in self._children:
child.sync()
else:
super().keypress(key)
curses.doupdate()
def start(self) -> None:
scr_height, scr_width = self.screen.getmaxyx()
col1_width = int(scr_width * 0.3)
col3_width = col1_width
col2_width = scr_width - col1_width - col3_width
self.screen.erase()
self.screen.noutrefresh()
self._big_win = curses.newwin(scr_height, scr_width)
self._col1_win = self._big_win.derwin(scr_height - 2, col1_width, 0, 0)
self._col2_win = self._big_win.derwin(
self._col1_win.getmaxyx()[0],
col2_width,
0,
self._col1_win.getmaxyx()[1],
)
self._col3_win = self._big_win.derwin(
self._col1_win.getmaxyx()[0],
col3_width,
0,
self._col1_win.getmaxyx()[1] + self._col2_win.getmaxyx()[1],
)
self._char_sheet_win = CharacterSheetWindow(
player=self._player,
parent=self._col1_win,
h=min(17, self._col1_win.getmaxyx()[0]),
w=self._col1_win.getmaxyx()[1],
y=0,
x=0,
)
self._spell_book_win = SpellBookWindow(
player=self._player,
parent=self._col1_win,
h=self._col1_win.getmaxyx()[0]
- self._char_sheet_win.getmaxyx()[0],
w=self._col1_win.getmaxyx()[1],
y=self._char_sheet_win.getmaxyx()[0],
x=0,
)
self._equipment_win = EquipmentWindow(
player=self._player,
parent=self._col2_win,
h=min(15, self._col2_win.getmaxyx()[0]),
w=self._col2_win.getmaxyx()[1],
y=0,
x=0,
)
self._inventory_win = InventoryWindow(
player=self._player,
parent=self._col2_win,
h=max(
1,
self._col2_win.getmaxyx()[0]
- self._equipment_win.getmaxyx()[0],
),
w=self._col2_win.getmaxyx()[1],
y=self._equipment_win.getmaxyx()[0],
x=0,
)
self._plot_win = PlotWindow(
player=self._player,
parent=self._col3_win,
h=self._equipment_win.getmaxyx()[0],
w=self._col3_win.getmaxyx()[1],
y=0,
x=0,
)
self._quest_book_win = QuestBookWindow(
player=self._player,
parent=self._col3_win,
h=max(
1, self._col3_win.getmaxyx()[0] - self._plot_win.getmaxyx()[0]
),
w=self._col3_win.getmaxyx()[1],
y=self._plot_win.getmaxyx()[0],
x=0,
)
self._task_win = TaskProgressWindow(
player=self._player,
parent=self._big_win,
h=2,
w=scr_width,
y=scr_height - 2,
x=0,
)
self._focusable_children: T.List[Focusable] = [
self._char_sheet_win,
self._spell_book_win,
self._equipment_win,
self._inventory_win,
self._plot_win,
self._quest_book_win,
]
self._children: T.List[Widget] = self._focusable_children + [
self._task_win
]
def stop(self) -> None:
for child in self._children:
child.stop()
del self._col1_win
del self._col2_win
del self._col3_win
del self._big_win
def idle(self) -> None:
elapsed = datetime.datetime.now() - self._last_tick
self._simulation.tick(elapsed.total_seconds() * 1000)
self._last_tick = datetime.datetime.now()
if self._args.use_saves:
self._roster.save_periodically()
curses.doupdate()
@property
def focused(self) -> Focusable:
for widget in self._focusable_children:
if widget.focused:
return widget
raise AssertionError
def focus(self, widget: Focusable) -> None:
self.focused.focused = False
widget.focused = True
@@ -0,0 +1,66 @@
import typing as T
from pqcli.mechanic import Player, StatType
from pqcli.ui.curses.widgets import Focusable
from .progress_bar_window import DataTableProgressBarWindow
class CharacterSheetWindow(Focusable, DataTableProgressBarWindow):
def __init__(
self, player: Player, parent: T.Any, h: int, w: int, y: int, x: int
) -> None:
super().__init__(
parent,
h,
w,
y,
x,
" Character Sheet ",
align_right=False,
show_time=True,
)
self._on_focus_change += self._render
self._focused = True
self._player = player
self._player.connect("level_up", self._sync_traits)
self._player.stats.connect("change", self._sync_traits)
self._player.exp_bar.connect("change", self._sync_exp)
self.sync()
def stop(self) -> None:
super().stop()
self._player.disconnect("level_up", self._sync_traits)
self._player.stats.disconnect("change", self._sync_traits)
self._player.exp_bar.disconnect("change", self._sync_exp)
def sync(self) -> None:
self._sync_traits()
self._sync_exp()
def _sync_traits(self) -> None:
if not self._win:
return
self._data_table.clear()
self._data_table.add("Name", self._player.name)
self._data_table.add("Race", self._player.race.name)
self._data_table.add("Class", self._player.class_.name)
self._data_table.add("Level", str(self._player.level))
self._data_table.add(" " * 15, "")
for stat in StatType:
self._data_table.add(stat.value, str(self._player.stats[stat]))
self._render_data_table()
def _sync_exp(self) -> None:
self._cur_pos = self._player.exp_bar.position
self._max_pos = self._player.exp_bar.max_
self._progress_title = (
f"Experience ({self._max_pos-self._cur_pos:.0f} XP to go)"
)
self._render_progress_bar()
@@ -0,0 +1,59 @@
import typing as T
from pqcli.mechanic import EquipmentType, Player
from pqcli.ui.curses.widgets import DataTable, Focusable, WindowWrapper
class EquipmentWindow(Focusable, WindowWrapper):
def __init__(
self, player: Player, parent: T.Any, h: int, w: int, y: int, x: int
) -> None:
super().__init__(parent, h, w, y, x)
self._on_focus_change += self._render
self._data_table = DataTable(
self._win, h - 2, w - 2, 1, 1, align_right=False
)
self._player = player
self._player.equipment.connect("change", self._sync_equipment_change)
self.sync()
def stop(self) -> None:
super().stop()
self._data_table.stop()
self._player.equipment.disconnect(
"change", self._sync_equipment_change
)
def sync(self) -> None:
self._data_table.clear()
for equipment_type in EquipmentType:
self._data_table.add(
equipment_type.value.ljust(15),
self._player.equipment[equipment_type] or "",
)
self._data_table.select(None)
self._render()
def _sync_equipment_change(
self, equipment_type: EquipmentType, item_name: T.Optional[str]
) -> None:
self._data_table.set(equipment_type.value, item_name or "")
self._data_table.select(equipment_type.value)
self._render()
def _render(self) -> None:
if not self._win:
return
with self.focus_standout(self._win):
self._win.box()
text = " Equipment "
x = max(0, (self.getmaxyx()[1] - len(text)) // 2)
self._win.addnstr(0, x, text, min(len(text), self.getmaxyx()[1]))
self._win.noutrefresh()
self._data_table.render()
@@ -0,0 +1,106 @@
import typing as T
from pqcli.mechanic import InventoryItem, Player
from pqcli.ui.curses.widgets import Focusable
from .progress_bar_window import DataTableProgressBarWindow
class InventoryWindow(Focusable, DataTableProgressBarWindow):
def __init__(
self, player: Player, parent: T.Any, h: int, w: int, y: int, x: int
) -> None:
super().__init__(
parent,
h,
w,
y,
x,
" Inventory ",
align_right=True,
show_time=False,
)
self._on_focus_change += self._render
self._player = player
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
)
self.sync()
def stop(self) -> None:
super().stop()
self._player.disconnect("new_task", self._on_new_task)
self._player.inventory.disconnect("gold_change", self._sync_gold)
self._player.inventory.disconnect("item_add", self._sync_item_add)
self._player.inventory.disconnect("item_del", self._sync_item_del)
self._player.inventory.disconnect(
"item_change", self._sync_item_change
)
self._player.inventory.encum_bar.disconnect(
"change", self._sync_encumbrance
)
def scroll_page_up(self) -> None:
self._data_table.scroll_page_up()
self._render()
def scroll_page_down(self) -> None:
self._data_table.scroll_page_down()
self._render()
def sync(self) -> None:
self._sync_gold()
self._sync_items()
self._sync_encumbrance()
def _sync_encumbrance(self) -> None:
self._cur_pos = self._player.inventory.encum_bar.position
self._max_pos = self._player.inventory.encum_bar.max_
self._progress_title = (
f"Encumbrance ({self._cur_pos:.0f}/{self._max_pos} cubits)"
)
self._render_progress_bar()
def _sync_gold(self) -> None:
self._data_table.set("Gold", str(self._player.inventory.gold))
self._data_table.select("Gold")
self._render_data_table()
def _sync_items(self) -> None:
self._data_table.clear()
self._sync_gold()
for item in self._player.inventory:
self._data_table.add(item.name, str(item.quantity))
self._data_table.scroll_to_item(0)
self._data_table.select(None)
self._render_data_table()
def _sync_item_add(self, item: InventoryItem) -> None:
self._data_table.add(item.name, str(item.quantity))
self._data_table.select(item.name)
self._render_data_table()
def _sync_item_del(self, item: InventoryItem) -> None:
self._data_table.delete(item.name)
self._data_table.select(None)
self._render_data_table()
def _sync_item_change(self, item: InventoryItem) -> None:
self._data_table.set(item.name, str(item.quantity))
self._data_table.select(item.name)
self._render_data_table()
def _on_new_task(self) -> None:
description = (
self._player.task.description if self._player.task else "?"
)
if description.lower().startswith("sell"):
self._data_table.scroll_to_item(0)
@@ -0,0 +1,69 @@
import typing as T
from pqcli.lingo import act_name
from pqcli.mechanic import Player
from pqcli.ui.curses.widgets import Focusable
from .progress_bar_window import ListBoxProgressBarWindow
class PlotWindow(Focusable, ListBoxProgressBarWindow):
cutoff = 100
def __init__(
self, player: Player, parent: T.Any, h: int, w: int, y: int, x: int
) -> None:
super().__init__(
parent, h, w, y, x, " Plot Development ", show_time=True
)
self._on_focus_change += self._render
self._player = player
self._player.quest_book.connect("start_act", self._sync_act_add)
self._player.quest_book.plot_bar.connect("change", self._sync_position)
self.sync()
def stop(self) -> None:
super().stop()
self._list_box.stop()
self._player.quest_book.disconnect("start_act", self._sync_act_add)
self._player.quest_book.plot_bar.disconnect(
"change", self._sync_position
)
def scroll_page_up(self) -> None:
self._list_box.scroll_page_up()
self._render()
def scroll_page_down(self) -> None:
self._list_box.scroll_page_down()
self._render()
def sync(self) -> None:
self._sync_acts()
self._sync_position()
def _sync_acts(self) -> None:
self._list_box.clear()
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)
self._render_list_box()
def _sync_act_add(self, act_number: int) -> None:
self._list_box.delete(0, max(0, len(self._list_box) - self.cutoff))
prev = self._list_box.get(-1)
if prev is not None:
self._list_box.set(-1, "[X] " + prev[4:])
self._list_box.add("[ ] " + act_name(act_number))
self._list_box.select(-1)
self._render_list_box()
def _sync_position(self) -> None:
self._cur_pos = self._player.quest_book.plot_bar.position
self._max_pos = self._player.quest_book.plot_bar.max_
self._render_progress_bar()
@@ -0,0 +1,158 @@
import curses
import typing as T
from pqcli.ui.curses.widgets import (
DataTable,
Focusable,
ListBox,
ProgressBar,
WindowWrapper,
)
class ProgressBarWindow(WindowWrapper):
def __init__(
self,
parent: T.Any,
h: int,
w: int,
y: int,
x: int,
title: str,
show_time: bool,
) -> None:
super().__init__(parent, h, w, y, x)
self._title = title
self._cur_pos = 0.0
self._max_pos = 1.0
self._progress_title = ""
try:
self._progress_bar_win = (
self._win.derwin(3, w, h - 3, 0) if self._win else None
)
except curses.error:
self._progress_bar_win = None
self._progress_bar = ProgressBar(
self._progress_bar_win, 1, w - 2, 1, 1, show_time=show_time
)
def stop(self) -> None:
super().stop()
self._progress_bar.stop()
del self._progress_bar_win
self._progress_bar_win = None
def _render_progress_bar(self) -> None:
if not self._progress_bar_win:
return
self._progress_bar_win.erase()
with Focusable.focus_standout(self, self._progress_bar_win):
self._progress_bar_win.border(
curses.ACS_VLINE,
curses.ACS_VLINE,
curses.ACS_HLINE,
curses.ACS_HLINE,
curses.ACS_LTEE,
curses.ACS_RTEE,
curses.ACS_LLCORNER,
curses.ACS_LRCORNER,
)
text = self._progress_title
x = max(0, (self.getmaxyx()[1] - len(text)) // 2)
# Determine the number of characters to write
nwrite = min(len(text), self._progress_bar_win.getmaxyx()[1])
# The amount MUST be greater than zero to avoid linux errors
if nwrite > 0:
self._progress_bar_win.addnstr(
0,
x,
text,
nwrite,
)
self._progress_bar.set_position(self._cur_pos, self._max_pos)
self._progress_bar_win.noutrefresh()
class DataTableProgressBarWindow(ProgressBarWindow):
def __init__(
self,
parent: T.Any,
h: int,
w: int,
y: int,
x: int,
title: str,
align_right: bool,
show_time: bool,
) -> None:
super().__init__(parent, h, w, y, x, title, show_time)
self._data_table = DataTable(
self._win, h - 4, w - 2, 1, 1, align_right
)
def stop(self) -> None:
super().stop()
self._data_table.stop()
def _render_data_table(self) -> None:
if not self._win:
return
with Focusable.focus_standout(self, self._win):
self._win.box()
x = max(0, (self.getmaxyx()[1] - len(self._title)) // 2)
self._win.addnstr(
0, x, self._title, min(len(self._title), self.getmaxyx()[1])
)
self._win.noutrefresh()
self._data_table.render()
def _render(self) -> None:
self._render_data_table()
self._render_progress_bar()
class ListBoxProgressBarWindow(ProgressBarWindow):
def __init__(
self,
parent: T.Any,
h: int,
w: int,
y: int,
x: int,
title: str,
show_time: bool,
) -> None:
super().__init__(parent, h, w, y, x, title, show_time)
self._list_box = ListBox(self._win, h - 4, w - 2, 1, 1)
def stop(self) -> None:
super().stop()
self._list_box.stop()
def _render_list_box(self) -> None:
if not self._win:
return
with Focusable.focus_standout(self, self._win):
self._win.box()
x = max(0, (self.getmaxyx()[1] - len(self._title)) // 2)
self._win.addnstr(
0, x, self._title, min(len(self._title), self.getmaxyx()[1])
)
self._win.noutrefresh()
self._list_box.render()
def _render(self) -> None:
self._render_list_box()
self._render_progress_bar()
@@ -0,0 +1,66 @@
import typing as T
from pqcli.mechanic import Player
from pqcli.ui.curses.widgets import Focusable
from .progress_bar_window import ListBoxProgressBarWindow
class QuestBookWindow(Focusable, ListBoxProgressBarWindow):
cutoff = 100
def __init__(
self, player: Player, parent: T.Any, h: int, w: int, y: int, x: int
) -> None:
super().__init__(parent, h, w, y, x, " Quests ", show_time=True)
self._on_focus_change += self._render
self._player = player
self._player.quest_book.connect("start_quest", self._sync_quest_add)
self._player.quest_book.quest_bar.connect(
"change", self._sync_position
)
self.sync()
def stop(self) -> None:
super().stop()
self._list_box.stop()
self._player.quest_book.disconnect("start_quest", self._sync_quest_add)
self._player.quest_book.quest_bar.disconnect(
"change", self._sync_position
)
def scroll_page_up(self) -> None:
self._list_box.scroll_page_up()
self._render()
def scroll_page_down(self) -> None:
self._list_box.scroll_page_down()
self._render()
def sync(self) -> None:
self._sync_quests()
self._sync_position()
def _sync_quests(self) -> None:
self._list_box.clear()
for quest_name in self._player.quest_book.quests[-self.cutoff :]:
self._sync_quest_add(quest_name)
self._list_box.select(-1)
self._render_list_box()
def _sync_quest_add(self, quest_name: str) -> None:
self._list_box.delete(0, max(0, len(self._list_box) - self.cutoff))
prev = self._list_box.get(-1)
if prev is not None:
self._list_box.set(-1, "[X] " + prev[4:])
self._list_box.add("[ ] " + quest_name)
self._list_box.select(-1)
self._render_list_box()
def _sync_position(self) -> None:
self._cur_pos = self._player.quest_book.quest_bar.position
self._max_pos = self._player.quest_book.quest_bar.max_
self._render_progress_bar()
@@ -0,0 +1,68 @@
import typing as T
from pqcli.lingo import to_roman
from pqcli.mechanic import Player, Spell
from pqcli.ui.curses.widgets import DataTable, Focusable, WindowWrapper
class SpellBookWindow(Focusable, WindowWrapper):
def __init__(
self, player: Player, parent: T.Any, h: int, w: int, y: int, x: int
) -> None:
super().__init__(parent, h, w, y, x)
self._on_focus_change += self._render
self._data_table = DataTable(
self._win, h - 2, w - 2, 1, 1, align_right=True
)
self._player = player
self._player.spell_book.connect("add", self._sync_spell_add)
self._player.spell_book.connect("change", self._sync_spell_change)
self.sync()
def stop(self) -> None:
super().stop()
self._data_table.stop()
self._player.spell_book.disconnect("add", self._sync_spell_add)
self._player.spell_book.disconnect("change", self._sync_spell_change)
def scroll_page_up(self) -> None:
self._data_table.scroll_page_up()
self._render()
def scroll_page_down(self) -> None:
self._data_table.scroll_page_down()
self._render()
def sync(self) -> None:
self._data_table.clear()
for spell in self._player.spell_book:
self._data_table.add(spell.name, to_roman(spell.level))
self._data_table.select(None)
self._render()
def _sync_spell_add(self, spell: Spell) -> None:
self._data_table.add(spell.name, to_roman(spell.level))
self._data_table.select(spell.name)
self._render()
def _sync_spell_change(self, spell: Spell) -> None:
self._data_table.set(spell.name, to_roman(spell.level))
self._data_table.select(spell.name)
self._render()
def _render(self) -> None:
if not self._win:
return
with self.focus_standout(self._win):
self._win.box()
text = " Spell Book "
x = max(0, (self.getmaxyx()[1] - len(text)) // 2)
self._win.addnstr(0, x, text, min(len(text), self.getmaxyx()[1]))
self._win.noutrefresh()
self._data_table.render()
@@ -0,0 +1,50 @@
import typing as T
from pqcli.mechanic import Player
from pqcli.ui.curses.widgets import (
Focusable,
Label,
ProgressBar,
WindowWrapper,
)
class TaskProgressWindow(Focusable, WindowWrapper):
def __init__(
self, player: Player, parent: T.Any, h: int, w: int, y: int, x: int
) -> None:
super().__init__(parent, h, w, y, x)
self._desc_win = Label(self._win, 1, w, 0, 0)
self._progress_bar = ProgressBar(
self._win, 1, w, 1, 0, show_time=False
)
self._player = player
self._player.connect("new_task", self._sync_task_name)
self._player.task_bar.connect("change", self._sync_position)
self.sync()
def stop(self) -> None:
super().stop()
self._desc_win.stop()
self._progress_bar.stop()
self._player.disconnect("new_task", self._sync_task_name)
self._player.task_bar.disconnect("change", self._sync_position)
def sync(self) -> None:
self._sync_task_name()
self._sync_position()
def _sync_task_name(self) -> None:
self._desc_win.set_text(
f"{self._player.task.description}..." if self._player.task else "?"
)
def _sync_position(self) -> None:
self._progress_bar.set_position(
self._player.task_bar.position, self._player.task_bar.max_
)
+41
View File
@@ -0,0 +1,41 @@
import typing as T
from pqcli.ui.curses.event_handler import EventHandler
from pqcli.ui.curses.util import Choice
from pqcli.ui.curses.views.base_view import BaseView
from pqcli.ui.curses.widgets import Menu
class MenuView(BaseView):
def __init__(
self, screen: T.Any, title: str, active_choice: int = 0
) -> None:
super().__init__(screen)
self.on_cancel = EventHandler()
self.on_confirm = EventHandler()
self._title = f"{title}:"
self._choices: T.List[Choice] = []
self._active_choice = active_choice
def start(self) -> None:
scr_height, scr_width = self.screen.getmaxyx()
self.screen.erase()
self.screen.noutrefresh()
self.main_menu = Menu(
header=self._title,
choices=self._choices,
active_choice=self._active_choice,
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()
+99
View File
@@ -0,0 +1,99 @@
import curses
import typing as T
from pqcli.ui.curses.colors import COLOR_LOGO, COLOR_LOGO_ALT, has_colors
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
LOGO = """
█▀▀▄ ▄▀▀█
▀▄ ▀▄ ▄▀ ▄▀
▀▄ ▀▄ ▄▀ ▄▀
▀▄ ▄▀ ▄▀
▄▀ ▄▀▄
▄▄▄▄▀ ▄▀▄ ▀▄▄▄▄
▀▄ █▄▄▀ ▀▄▄█ ▄▀
▄▀█▄ █ █ ▄█▀▄
▄▄▀▄▀ ▀▀ ▀▀ ▀▄▀▄▄
█▄█ █▄█
█▀▀▄ ▄▀▀▄ █
█▄▄▀ █▀▀ ▄▀▀▄ ▄▀▀▄ █▀▀ ▄▀▀▄ ▄▀▀▀ ▄▀▀▀ █ █ █ █ ▄▀▀▄ ▄▀▀▀ ▀█▀
█ █ █ █ ▀▄▄█ █ █▀▀ ▀▀▄ ▀▀▄ █ ▌█ █ █ █▀▀ ▀▀▄ █
▀ ▀ ▀▀ ▄▄▀ ▀ ▀▀ ▀▀▀ ▀▀▀ ▀▀▌ ▀▀ ▀▀ ▀▀▀ ▀
""".strip(
"\n"
)
class MainMenu(Menu):
def __init__(
self, choices: T.List[Choice], scr_height: int, scr_width: int
) -> None:
super().__init__(
header=LOGO,
choices=choices,
active_choice=0,
scr_height=scr_height,
scr_width=scr_width,
)
if has_colors():
for y in range(len(self._header_lines)):
self._pad.chgat(y, 0, curses.color_pair(COLOR_LOGO))
self._pad.chgat(3, 29, 5, curses.color_pair(COLOR_LOGO_ALT))
self._pad.chgat(4, 29, 5, curses.color_pair(COLOR_LOGO_ALT))
self._pad.chgat(5, 32, 4, curses.color_pair(COLOR_LOGO_ALT))
self._pad.chgat(5, 27, 4, curses.color_pair(COLOR_LOGO_ALT))
self._pad.chgat(6, 34, 2, curses.color_pair(COLOR_LOGO_ALT))
self._pad.chgat(6, 27, 2, curses.color_pair(COLOR_LOGO_ALT))
class RosterView(BaseView):
def __init__(self, screen: T.Any) -> None:
super().__init__(screen)
self.on_create = EventHandler()
self.on_play = EventHandler()
self.on_delete = EventHandler()
self.on_quit = EventHandler()
self.choices: T.List[Choice] = [
Choice(
keys=list(map(ord, "cC")),
desc="[C] Create new character",
callback=self.on_create,
),
Choice(
keys=list(map(ord, "pP")),
desc="[P] Play",
callback=self.on_play,
),
Choice(
keys=list(map(ord, "dD")),
desc="[D] Delete character",
callback=self.on_delete,
),
Choice(
keys=list(KEYS_CANCEL), desc="[Q] Quit", callback=self.on_quit
),
]
def start(self) -> None:
scr_height, scr_width = self.screen.getmaxyx()
self.screen.erase()
self.screen.noutrefresh()
self.main_menu = MainMenu(
choices=self.choices, 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()
+20
View File
@@ -0,0 +1,20 @@
from .base import Widget, WindowWrapper
from .data_table import DataTable
from .focusable import Focusable
from .label import Label
from .list_box import ListBox
from .menu import Menu
from .progress_bar import ProgressBar
from .scrollable import Scrollable
__all__ = [
"Widget",
"WindowWrapper",
"DataTable",
"Focusable",
"Label",
"ListBox",
"Menu",
"ProgressBar",
"Scrollable",
]
+41
View File
@@ -0,0 +1,41 @@
import curses
import typing as T
class Widget:
def __del__(self) -> None:
self.stop()
def stop(self) -> None:
pass
class WindowWrapper(Widget):
def __init__(
self, parent: T.Optional[T.Any], h: int, w: int, y: int, x: int
) -> None:
self._parent = parent
self._win: T.Optional[T.Any]
if not parent:
self._win = None
else:
try:
self._win = parent.derwin(h, w, y, x)
self._win.noutrefresh()
except curses.error:
self._win = None
def stop(self) -> None:
del self._win
self._win = None
def getbegyx(self) -> T.Tuple[int, int]:
if not self._win:
return (0, 0)
return self._win.getbegyx()
def getmaxyx(self) -> T.Tuple[int, int]:
if not self._win:
return (0, 0)
return self._win.getmaxyx()
+81
View File
@@ -0,0 +1,81 @@
import curses
import typing as T
from pqcli.ui.curses.colors import COLOR_HIGHLIGHT, has_colors
from .scrollable import Scrollable
class DataTable(Scrollable):
def __init__(
self,
parent: T.Any,
h: int,
w: int,
y: int,
x: int,
align_right: bool = False,
) -> None:
super().__init__(parent, h, w, y, x)
self._align_right = align_right
self._items: T.List[T.Tuple[str, str]]
self._selected: T.Optional[int] = None
def add(self, text: str, value: str) -> None:
self._items.append((text, value))
self.scroll_to_item(-1)
def set(self, text: str, value: str) -> None:
idx = self.get_idx(text)
if idx is None:
self.add(text, value)
else:
self._items[idx] = (self._items[idx][0], value)
self.scroll_to_item(idx)
def delete(self, text: str) -> None:
idx = self.get_idx(text)
if idx is not None:
del self._items[idx]
self.scroll_to_item(idx)
def select(self, text: T.Optional[str]) -> None:
self._selected = None if text is None else self.get_idx(text)
def get_idx(self, text: str) -> T.Optional[int]:
for idx, row in enumerate(self._items):
if row[0].strip() == text.strip():
return idx
return None
def _render_impl(self, h: int, w: int) -> None:
assert self._pad
if self._align_right:
for y, row in enumerate(self._items):
if y == self._selected and has_colors():
self._pad.attron(curses.color_pair(COLOR_HIGHLIGHT))
self._pad.move(y, 0)
self._pad.addnstr(row[0], min(len(row[0]), w))
col2_x = max(0, w - len(row[1]))
if col2_x < w:
self._pad.move(y, col2_x)
self._pad.addstr(row[1])
if y == self._selected and has_colors():
self._pad.attroff(curses.color_pair(COLOR_HIGHLIGHT))
else:
col2_x = self._get_col_width(0)
for y, row in enumerate(self._items):
if y == self._selected and has_colors():
self._pad.attron(curses.color_pair(COLOR_HIGHLIGHT))
self._pad.addnstr(y, 0, row[0], min(len(row[0]), w))
# Determine the number of characters to write
nwrite = min(len(row[1]), w - col2_x)
# Ensure we have enough columns, and we are writing more than zero
if col2_x < w and nwrite > 0:
self._pad.addnstr(y, col2_x, row[1], nwrite)
if y == self._selected and has_colors():
self._pad.attroff(curses.color_pair(COLOR_HIGHLIGHT))
def _get_col_width(self, x: int) -> int:
return max([len(row[0]) for row in self._items] + [0])
+42
View File
@@ -0,0 +1,42 @@
import contextlib
import curses
import typing as T
from pqcli.ui.curses.colors import COLOR_FOCUSED, has_colors
from pqcli.ui.curses.event_handler import EventHandler
@contextlib.contextmanager
def focus_standout(focused: bool, win: T.Any) -> T.Generator:
if focused:
if has_colors():
win.attron(curses.color_pair(COLOR_FOCUSED))
else:
win.standout()
yield
if focused:
if has_colors():
win.attroff(curses.color_pair(COLOR_FOCUSED))
else:
win.standend()
class Focusable:
def __init__(self, *args: T.Any, **kwargs: T.Any) -> None:
self._focused = False
self._on_focus_change = EventHandler()
super().__init__(*args, **kwargs)
@property
def focused(self) -> bool:
return self._focused
@focused.setter
def focused(self, focused: bool) -> None:
self._focused = focused
self._on_focus_change()
@contextlib.contextmanager
def focus_standout(self, win: T.Any) -> T.Generator:
with focus_standout(self._focused, win):
yield
+10
View File
@@ -0,0 +1,10 @@
from .base import WindowWrapper
class Label(WindowWrapper):
def set_text(self, text: str) -> None:
if not self._win:
return
self._win.erase()
self._win.addnstr(text, min(len(text), self.getmaxyx()[1] - 1))
self._win.noutrefresh()
+50
View File
@@ -0,0 +1,50 @@
import curses
import typing as T
from pqcli.ui.curses.colors import COLOR_HIGHLIGHT, has_colors
from .scrollable import Scrollable
class ListBox(Scrollable):
_items: T.List[str]
def __init__(self, parent: T.Any, h: int, w: int, y: int, x: int) -> None:
super().__init__(parent, h, w, y, x)
self._selected: T.Optional[int] = None
def add(self, text: str) -> None:
self._items.append(text)
self.scroll_to_item(-1)
def set(self, idx: int, value: str) -> None:
if idx < 0:
idx %= len(self._items)
self._items[idx] = value
def get(self, idx: int) -> T.Optional[str]:
if not self._items:
return None
if idx < 0:
idx %= len(self._items)
try:
return self._items[idx]
except ValueError:
return None
def delete(self, idx: int, count: int = 1) -> None:
del self._items[idx : idx + count]
def select(self, idx: T.Optional[int]) -> None:
if idx < 0 and len(self._items):
idx %= len(self._items)
self._selected = idx
def _render_impl(self, h: int, w: int) -> None:
assert self._pad
for y, item in enumerate(self._items):
if y == self._selected and has_colors():
self._pad.attron(curses.color_pair(COLOR_HIGHLIGHT))
self._pad.addnstr(y, 0, item, min(len(item), w))
if y == self._selected and has_colors():
self._pad.attroff(curses.color_pair(COLOR_HIGHLIGHT))
+95
View File
@@ -0,0 +1,95 @@
import curses
import curses.ascii
import typing as T
from pqcli.ui.curses.util import KEYS_CYCLE, KEYS_DOWN, KEYS_UP, Choice
from pqcli.ui.curses.widgets.focusable import focus_standout
from .base import Widget
class Menu(Widget):
def __init__(
self,
header: str,
choices: T.List[Choice],
active_choice: int,
scr_height: int,
scr_width: int,
) -> None:
self._header_lines = header.split("\n")
self._choices = choices
self._scr_width = scr_width
self._scr_height = scr_height
all_lines = self._header_lines + sum(
[choice.desc.splitlines() for choice in choices], []
)
w = max(map(len, all_lines)) + 1
h = len(all_lines) + 1
self._pad: T.Optional[T.Any] = curses.newpad(h, w)
for y, line in enumerate(self._header_lines):
self._pad.move(y, 0)
self._pad.addstr(line)
self._active_choice = active_choice
def stop(self) -> None:
del self._pad
self._pad = None
def getmaxyx(self) -> T.Tuple[int, int]:
if not self._pad:
return (0, 0)
return self._pad.getmaxyx()
def next(self) -> None:
self._active_choice = min(
len(self._choices) - 1, self._active_choice + 1
)
def prev(self) -> None:
self._active_choice = max(0, self._active_choice - 1)
def cycle(self) -> None:
self._active_choice = (self._active_choice + 1) % len(self._choices)
def keypress(self, key: int) -> None:
for choice in self._choices:
if key in choice.keys:
choice.callback()
return
if key == curses.ascii.NL:
self._choices[self._active_choice].callback()
elif key in KEYS_DOWN:
self.next()
elif key in KEYS_UP:
self.prev()
elif key in KEYS_CYCLE:
self.cycle()
def render(self) -> None:
if not self._pad:
return
y = len(self._header_lines) + 1
for i, choice in enumerate(self._choices):
self._pad.move(y, 0)
with focus_standout(i == self._active_choice, self._pad):
lines = choice.desc.splitlines()
max_len = max(map(len, lines))
lines = [line.ljust(max_len) for line in lines]
self._pad.addstr("\n".join(lines))
y += len(choice.desc.splitlines())
self._pad.refresh(
0,
0,
(self._scr_height - self._pad.getmaxyx()[0]) // 2,
(self._scr_width - self._pad.getmaxyx()[1]) // 2,
self._scr_height - 1,
self._scr_width - 1,
)
+65
View File
@@ -0,0 +1,65 @@
import curses
import datetime
import typing as T
from pqcli.lingo import format_timespan
from pqcli.ui.curses.colors import COLOR_PROGRESSBAR, has_colors
from pqcli.ui.curses.widgets.base import WindowWrapper
class ProgressBar(WindowWrapper):
def __init__(
self, parent: T.Any, h: int, w: int, y: int, x: int, show_time: bool
) -> None:
super().__init__(parent, h, w, y, x)
self._cur_pos = 0.0
self._max_pos = 1.0
self._show_time = show_time
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_then, pos_then = self._last_tick
time_now, pos_now = datetime.datetime.now(), self._cur_pos
div = (time_now - time_then).total_seconds()
if div == 0:
return None
speed = (pos_now - pos_then) / div
if not speed:
return None
return datetime.timedelta(seconds=(self._max_pos - pos_now) / speed)
def set_position(self, cur_pos: float, max_pos: float) -> None:
if not self._win:
return
self._win.erase()
if self._last_tick is None or cur_pos == 0 or max_pos != self._max_pos:
self._last_tick = (datetime.datetime.now(), cur_pos)
self._cur_pos = cur_pos
self._max_pos = max_pos
text = f"{cur_pos / max_pos:.02%}"
if self.time_left and self._show_time:
text += f" ({format_timespan(self.time_left)})"
x = max(0, (self.getmaxyx()[1] - len(text)) // 2)
self._win.addnstr(0, x, text, min(len(text), self.getmaxyx()[1] - 1))
x = int(cur_pos * self.getmaxyx()[1] // max_pos)
if x > 0:
self._win.chgat(
0,
0,
(
curses.color_pair(COLOR_PROGRESSBAR)
if has_colors()
else curses.A_REVERSE
),
)
if x < self.getmaxyx()[1]:
self._win.chgat(0, x, curses.A_NORMAL)
self._win.noutrefresh()
+116
View File
@@ -0,0 +1,116 @@
import curses
import typing as T
from pqcli.ui.curses.colors import (
COLOR_SCROLLBAR_THUMB,
COLOR_SCROLLBAR_TRACK,
has_colors,
)
from .base import WindowWrapper
class Scrollable(WindowWrapper):
def __init__(self, parent: T.Any, h: int, w: int, y: int, x: int) -> None:
super().__init__(parent, h, w, y, x)
self._scroll_pos = 0
self._pad: T.Optional[T.Any] = curses.newpad(1, 1)
self._items: T.List[T.Any] = []
def clear(self) -> None:
self._items = []
self.scroll_to_item(0)
def scroll_page_up(self) -> None:
self.scroll_to_item(max(0, self._scroll_pos - self.getmaxyx()[0]))
def scroll_page_down(self) -> None:
self.scroll_to_item(
min(
len(self._items) - 1,
self._scroll_pos + self.getmaxyx()[0] * 2 - 1,
)
)
def scroll_to_item(self, y: int) -> None:
if not self._items or not self._win:
return
if y < 0:
y %= len(self._items)
h = self.getmaxyx()[0]
delta = 1 if self._scroll_pos < y else -1
while not self._scroll_pos <= y < self._scroll_pos + h:
self._scroll_pos += delta
def stop(self) -> None:
super().stop()
del self._pad
self._pad = None
def __len__(self) -> int:
return len(self._items)
def render(self) -> None:
if not self._pad or not self._win:
return
if self._items:
self._pad.resize(
max(1, len(self._items)), max(1, self.getmaxyx()[1] + 1)
)
else:
self._pad.resize(1, 1)
self._win.erase()
self._win.noutrefresh()
self._pad.erase()
h, w = self.getmaxyx()
y, x = self.getbegyx()
try:
if self._scroll_pos > 0 or self._scroll_pos + h < len(self._items):
y1 = self._scroll_pos
y2 = self._scroll_pos + h
thumb_y1 = int(y1 * h // len(self._items))
thumb_y2 = int(y2 * h // len(self._items))
attr_thumb = (
curses.color_pair(COLOR_SCROLLBAR_THUMB)
if has_colors()
else curses.A_REVERSE
)
attr_track = (
curses.color_pair(COLOR_SCROLLBAR_TRACK)
if has_colors()
else curses.A_NORMAL
)
for win_y in range(h):
self._win.chgat(
win_y,
w - 1,
(
attr_thumb
if thumb_y1 <= win_y <= thumb_y2
else attr_track
),
)
self._win.noutrefresh()
self._render_impl(h, w - 1)
self._pad.noutrefresh(
self._scroll_pos, 0, y, x, y + h - 1, x + w - 2
)
else:
self._render_impl(h, w)
self._pad.noutrefresh(
self._scroll_pos, 0, y, x, y + h - 1, x + w - 1
)
except curses.error:
pass
def _render_impl(self, h: int, w: int) -> None:
raise NotImplementedError("not implemented")
-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",
)
-119
View File
@@ -1,119 +0,0 @@
import datetime
import typing as T
import urwid
def format_float(num: float) -> str:
ret = f"{num:.01f}"
if ret.endswith("0"):
ret = ret[:-2]
return ret
def format_timespan(timespan: datetime.timedelta) -> str:
num = timespan.total_seconds()
if num < 60.0:
return f"~{int(num)}s"
num /= 60
if num < 60.0:
return f"~{int(num)}m"
num /= 60
if num < 24.0:
return f"~{format_float(num)}h"
num /= 24
return f"~{format_float(num)}d"
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[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.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.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)
-446
View File
@@ -1,446 +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.ui.custom_line_box import CustomLineBox
from pqcli.ui.custom_progress_bar import CustomProgressBar
from pqcli.ui.data_table import DataTable
from pqcli.ui.double_line_box import DoubleLineBox
from pqcli.ui.layout import NColumns, NPile
from pqcli.ui.read_only_check_box import ReadOnlyCheckBox
from pqcli.ui.scrollable import 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, player: Player, args: argparse.Namespace
) -> None:
self.loop = loop
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()
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
-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
-223
View File
@@ -1,223 +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.custom_button import CustomButton
from pqcli.ui.custom_line_box import CustomLineBox
from pqcli.ui.layout import 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:
try:
self.stats = self.stats_builder.unroll()
except IndexError:
return
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
-16
View File
@@ -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)
-115
View File
@@ -1,115 +0,0 @@
import typing as T
import urwid
from pqcli.lingo import act_name, to_roman
from pqcli.roster import Roster
from pqcli.ui.custom_button import CustomButton
from pqcli.ui.layout import 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.BigText("Progress Quest", urwid.HalfBlock5x4Font())
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
-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"
)
+50
View File
@@ -0,0 +1,50 @@
[project]
name = "pqcli"
version = "1.0.4"
description = "Progress Quest: the CLI edition"
authors = [{ name = "Laura Kurczewska", email = "dash@wind.garden" }]
license = "MIT"
readme = "README.md"
requires-python = ">=3.10,<4.0"
classifiers = [
"Environment :: Console",
"Development Status :: 4 - Beta",
"Intended Audience :: End Users/Desktop",
"License :: OSI Approved :: MIT License",
"Natural Language :: English",
"Operating System :: POSIX :: Linux",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.10",
"Topic :: Games/Entertainment :: Role-Playing",
"Topic :: Games/Entertainment :: Simulation",
]
dependencies = [
"xdg-base-dirs>=6.0.0,<7.0.0",
"urwid>=2.1.2,<3.0.0",
"urwid-readline>=0.13,<1.0.0",
]
[project.optional-dependencies]
windows = ["windows-curses>=2.3.0,<3.0.0"]
[project.urls]
Repository = "https://github.com/rr-/pq-cli.git"
[project.scripts]
pqcli = "pqcli.__main__:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.uv]
dev-dependencies = ["pre-commit>=2.16.0"]
[tool.black]
line-length = 79
[tool.isort]
known_first_party = "pqcli"
multi_line_output = 3
include_trailing_comma = true
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

-26
View File
@@ -1,26 +0,0 @@
from setuptools import find_packages, setup
setup(
author="rr-",
author_email="rr-@sakuya.pl",
name="pqcli",
long_description="Progress Quest: the CLI edition",
version="0.3",
url="https://github.com/rr-/pq-cli.git",
packages=find_packages(),
entry_points={"console_scripts": ["pqcli = pqcli.__main__:main"]},
package_dir={"pqcli": "pqcli"},
install_requires=["xdg", "urwid", "urwid_readline"],
classifiers=[
"Environment :: Console",
"Development Status :: 4 - Beta",
"Intended Audience :: End Users/Desktop",
"License :: OSI Approved :: MIT License",
"Natural Language :: English",
"Operating System :: POSIX :: Linux",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.7",
"Topic :: Games/Entertainment :: Role-Playing",
"Topic :: Games/Entertainment :: Simulation",
],
)
Generated
+248
View File
@@ -0,0 +1,248 @@
version = 1
revision = 1
requires-python = ">=3.10, <4.0"
[[package]]
name = "cfgv"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445 },
]
[[package]]
name = "distlib"
version = "0.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047 },
]
[[package]]
name = "filelock"
version = "3.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/46/0028a82567109b5ef6e4d2a1f04a583fb513e6cf9527fcdd09afd817deeb/filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4", size = 18922 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054 },
]
[[package]]
name = "identify"
version = "2.6.15"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183 },
]
[[package]]
name = "nodeenv"
version = "1.9.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 },
]
[[package]]
name = "platformdirs"
version = "4.5.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731 },
]
[[package]]
name = "pqcli"
version = "1.0.4"
source = { editable = "." }
dependencies = [
{ name = "urwid" },
{ name = "urwid-readline" },
{ name = "xdg-base-dirs" },
]
[package.optional-dependencies]
windows = [
{ name = "windows-curses" },
]
[package.dev-dependencies]
dev = [
{ name = "pre-commit" },
]
[package.metadata]
requires-dist = [
{ name = "urwid", specifier = ">=2.1.2,<3.0.0" },
{ name = "urwid-readline", specifier = ">=0.13,<1.0.0" },
{ name = "windows-curses", marker = "extra == 'windows'", specifier = ">=2.3.0,<3.0.0" },
{ name = "xdg-base-dirs", specifier = ">=6.0.0,<7.0.0" },
]
provides-extras = ["windows"]
[package.metadata.requires-dev]
dev = [{ name = "pre-commit", specifier = ">=2.16.0" }]
[[package]]
name = "pre-commit"
version = "4.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cfgv" },
{ name = "identify" },
{ name = "nodeenv" },
{ name = "pyyaml" },
{ name = "virtualenv" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f4/9b/6a4ffb4ed980519da959e1cf3122fc6cb41211daa58dbae1c73c0e519a37/pre_commit-4.5.0.tar.gz", hash = "sha256:dc5a065e932b19fc1d4c653c6939068fe54325af8e741e74e88db4d28a4dd66b", size = 198428 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/c4/b2d28e9d2edf4f1713eb3c29307f1a63f3d67cf09bdda29715a36a68921a/pre_commit-4.5.0-py2.py3-none-any.whl", hash = "sha256:25e2ce09595174d9c97860a95609f9f852c0614ba602de3561e267547f2335e1", size = 226429 },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227 },
{ url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019 },
{ url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646 },
{ url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793 },
{ url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293 },
{ url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872 },
{ url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828 },
{ url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415 },
{ url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561 },
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826 },
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577 },
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556 },
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114 },
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638 },
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463 },
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986 },
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543 },
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763 },
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063 },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973 },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116 },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011 },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870 },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089 },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181 },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658 },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003 },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344 },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669 },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252 },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081 },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159 },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626 },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613 },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115 },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427 },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090 },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246 },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814 },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809 },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454 },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355 },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175 },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228 },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194 },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429 },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912 },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108 },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641 },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901 },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132 },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261 },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272 },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923 },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062 },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 },
]
[[package]]
name = "urwid"
version = "2.6.16"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
{ name = "wcwidth" },
]
sdist = { url = "https://files.pythonhosted.org/packages/98/21/ad23c9e961b2d36d57c63686a6f86768dd945d406323fb58c84f09478530/urwid-2.6.16.tar.gz", hash = "sha256:93ad239939e44c385e64aa00027878b9e5c486d59e855ec8ab5b1e1adcdb32a2", size = 848179 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/cb/271a4f5a1bf4208dbdc96d85b9eae744cf4e5e11ac73eda76dc98c8fd2d7/urwid-2.6.16-py3-none-any.whl", hash = "sha256:de14896c6df9eb759ed1fd93e0384a5279e51e0dde8f621e4083f7a8368c0797", size = 297196 },
]
[[package]]
name = "urwid-readline"
version = "0.15.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "urwid" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ad/70/be318554495555eba7d8ff6e489f6f74ddb225b24086ba4af62a82e723fd/urwid_readline-0.15.1.tar.gz", hash = "sha256:9301444b86d58f7d26388506b704f142cefd193888488b4070d3a0fdfcfc0f84", size = 9007 }
[[package]]
name = "virtualenv"
version = "20.35.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "distlib" },
{ name = "filelock" },
{ name = "platformdirs" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095 },
]
[[package]]
name = "wcwidth"
version = "0.2.14"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc723187aeaf24c7f5459922d01e2f794277a3dfb90345/wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605", size = 102293 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286 },
]
[[package]]
name = "windows-curses"
version = "2.4.1"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/15/34/e70815e7ae6f157c00121180a607dbcdc82acfd949c70bba28c45d206dec/windows_curses-2.4.1-cp310-cp310-win32.whl", hash = "sha256:53d711e07194d0d3ff7ceff29e0955b35479bc01465d46c3041de67b8141db2f", size = 71136 },
{ url = "https://files.pythonhosted.org/packages/a6/1d/1bb512c445af24fb0a3dccf9537c8727bba5920e69539227b87f5351f608/windows_curses-2.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:325439cd4f37897a1de8a9c068a5b4c432f9244bf9c855ee2fbeb3fa721a770c", size = 81418 },
{ url = "https://files.pythonhosted.org/packages/74/b3/46a2508fff83b5affb5a311797c584c494b4b0c4c8796a1afa2c1b1e03ac/windows_curses-2.4.1-cp311-cp311-win32.whl", hash = "sha256:4fa1a176bfcf098d0c9bb7bc03dce6e83a4257fc0c66ad721f5745ebf0c00746", size = 71129 },
{ url = "https://files.pythonhosted.org/packages/50/29/fd7c0c80177d8df55f841504a5f332b95b0002c80f82055913b6caac94e6/windows_curses-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:fd7d7a9cf6c1758f46ed76b8c67f608bc5fcd5f0ca91f1580fd2d84cf41c7f4f", size = 81431 },
{ url = "https://files.pythonhosted.org/packages/f4/99/60f34e51514c82631aa5c6d21eab88ce1701562de9844608411e39462a46/windows_curses-2.4.1-cp312-cp312-win32.whl", hash = "sha256:bdbe7d58747408aef8a9128b2654acf6fbd11c821b91224b9a046faba8c6b6ca", size = 71489 },
{ url = "https://files.pythonhosted.org/packages/62/8f/d908bcab1954375b156a9300fa86ceccb110f39457cd6fd59c72777626ab/windows_curses-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:5c9c2635faf171a229caca80e1dd760ab00db078e2a285ba2f667bbfcc31777c", size = 81777 },
{ url = "https://files.pythonhosted.org/packages/25/a0/e8d074f013117633f6b502ca123ecfc377fe0bd36818fe65e8935c91ca9c/windows_curses-2.4.1-cp313-cp313-win32.whl", hash = "sha256:05d1ca01e5199a435ccb6c8c2978df4a169cdff1ec99ab15f11ded9de8e5be26", size = 71390 },
{ url = "https://files.pythonhosted.org/packages/2b/4b/2838a829b074a68c570d54ae0ae8539979657d3e619a4dc5a4b03eb69745/windows_curses-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8cf653f8928af19c103ae11cfed38124f418dcdd92643c4cd17239c0cec2f9da", size = 81636 },
]
[[package]]
name = "xdg-base-dirs"
version = "6.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bf/d0/bbe05a15347538aaf9fa5b51ac3b97075dfb834931fcb77d81fbdb69e8f6/xdg_base_dirs-6.0.2.tar.gz", hash = "sha256:950504e14d27cf3c9cb37744680a43bf0ac42efefc4ef4acf98dc736cab2bced", size = 4085 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fc/03/030b47fd46b60fc87af548e57ff59c2ca84b2a1dadbe721bb0ce33896b2e/xdg_base_dirs-6.0.2-py3-none-any.whl", hash = "sha256:3c01d1b758ed4ace150ac960ac0bd13ce4542b9e2cdf01312dcda5012cfebabe", size = 4747 },
]