From 770ec4610489f2d22d335ee22aea06ac17214bf5 Mon Sep 17 00:00:00 2001 From: Christian Clauss Date: Mon, 3 Nov 2025 12:36:44 +0100 Subject: [PATCH 01/78] Keep GitHub Actions up to date with GitHub's Dependabot * [Keeping your software supply chain secure with Dependabot](https://docs.github.com/en/code-security/dependabot) * [Keeping your actions up to date with Dependabot](https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot) * [Configuration options for the `dependabot.yml` file - package-ecosystem](https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem) To see all GitHub Actions dependencies, type: % `git grep 'uses: ' .github/workflows/` --- .github/dependabot.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..be006de9 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +# Keep GitHub Actions up to date with GitHub's Dependabot... +# https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot +# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + groups: + github-actions: + patterns: + - "*" # Group all Actions updates into a single larger pull request + schedule: + interval: weekly From 5d3d85c0019ad2edf570f5f0024a05cccfad6670 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Nov 2025 12:23:53 +0000 Subject: [PATCH 02/78] Bump the github-actions group across 1 directory with 5 updates Bumps the github-actions group with 5 updates in the / directory: | Package | From | To | | --- | --- | --- | | [actions/checkout](https://github.com/actions/checkout) | `4` | `5` | | [actions/upload-artifact](https://github.com/actions/upload-artifact) | `4` | `5` | | [actions/setup-python](https://github.com/actions/setup-python) | `5` | `6` | | [codecov/codecov-action](https://github.com/codecov/codecov-action) | `4` | `5` | | [actions/download-artifact](https://github.com/actions/download-artifact) | `4` | `6` | Updates `actions/checkout` from 4 to 5 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4...v5) Updates `actions/upload-artifact` from 4 to 5 - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v4...v5) Updates `actions/setup-python` from 5 to 6 - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v5...v6) Updates `codecov/codecov-action` from 4 to 5 - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v4...v5) Updates `actions/download-artifact` from 4 to 6 - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/v4...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: actions/upload-artifact dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: actions/setup-python dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: codecov/codecov-action dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: actions/download-artifact dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] --- .github/workflows/python-package.yml | 44 ++++++++++++++-------------- .github/workflows/release-on-tag.yml | 2 +- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 229c44eb..d328cefd 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -20,7 +20,7 @@ jobs: ruff: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install Ruff run: pip install ruff - name: Ruff Check @@ -31,7 +31,7 @@ jobs: mypy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Checkout submodules run: git submodule update --init --recursive --depth 1 - name: Install typing dependencies @@ -50,7 +50,7 @@ jobs: install-linux-dependencies: true build-type: "Debug" version: ${{ env.sdl-version }} - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: ${{ env.git-depth }} - name: Checkout submodules @@ -59,7 +59,7 @@ jobs: run: pip install build - name: Build source distribution run: python -m build --sdist - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v5 with: name: sdist path: dist/tcod-*.tar.gz @@ -76,12 +76,12 @@ jobs: sdl-version: ["3.2.16"] fail-fast: true steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: ${{ env.git-depth }} - name: Checkout submodules run: git submodule update --init --recursive --depth 1 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: "3.x" - name: Install build dependencies @@ -106,14 +106,14 @@ jobs: fail-fast: false steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: ${{ env.git-depth }} - name: Checkout submodules run: | git submodule update --init --recursive --depth 1 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} architecture: ${{ matrix.architecture }} @@ -150,10 +150,10 @@ jobs: - name: Xvfb logs if: runner.os != 'Windows' run: cat /tmp/xvfb.log - - uses: codecov/codecov-action@v4 + - uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v5 if: runner.os == 'Windows' with: name: wheels-windows-${{ matrix.architecture }}-${{ matrix.python-version }} @@ -171,7 +171,7 @@ jobs: install-linux-dependencies: true build-type: "Debug" version: ${{ env.sdl-version }} - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: ${{ env.git-depth }} - name: Checkout submodules @@ -195,13 +195,13 @@ jobs: matrix: os: ["ubuntu-latest"] # "windows-latest" disabled due to free-threaded build issues steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: ${{ env.git-depth }} - name: Checkout submodules run: git submodule update --init --depth 1 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.x" - name: Install Python dependencies @@ -227,7 +227,7 @@ jobs: env: BUILD_DESC: "" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: ${{ env.git-depth }} - name: Checkout submodules @@ -265,7 +265,7 @@ jobs: BUILD_DESC=${BUILD_DESC//\*} echo BUILD_DESC=${BUILD_DESC} >> $GITHUB_ENV - name: Archive wheel - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: wheels-linux-${{ matrix.arch }}-${{ env.BUILD_DESC }} path: wheelhouse/*.whl @@ -282,12 +282,12 @@ jobs: env: PYTHON_DESC: "" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: ${{ env.git-depth }} - name: Checkout submodules run: git submodule update --init --recursive --depth 1 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: "3.x" - name: Install Python dependencies @@ -311,7 +311,7 @@ jobs: PYTHON_DESC=${PYTHON_DESC//\*/X} echo PYTHON_DESC=${PYTHON_DESC} >> $GITHUB_ENV - name: Archive wheel - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: wheels-macos-${{ env.PYTHON_DESC }} path: wheelhouse/*.whl @@ -322,7 +322,7 @@ jobs: needs: [ruff, mypy, sdist] runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: ${{ env.git-depth }} - name: Checkout submodules @@ -337,7 +337,7 @@ jobs: CIBW_BUILD: cp313-pyodide_wasm32 CIBW_PLATFORM: pyodide - name: Archive wheel - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: pyodide path: wheelhouse/*.whl @@ -354,11 +354,11 @@ jobs: permissions: id-token: write steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v6 with: name: sdist path: dist/ - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v6 with: pattern: wheels-* path: dist/ diff --git a/.github/workflows/release-on-tag.yml b/.github/workflows/release-on-tag.yml index 2171ddbf..87b42c39 100644 --- a/.github/workflows/release-on-tag.yml +++ b/.github/workflows/release-on-tag.yml @@ -13,7 +13,7 @@ jobs: contents: write steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Generate body run: | scripts/get_release_description.py | tee release_body.md From 4ccd7e1925c12972a482bfc4f2e3b00e199a5e74 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Sun, 23 Nov 2025 07:35:08 -0800 Subject: [PATCH 03/78] Fix docs implying the SDL ports were still on SDL2 --- tcod/sdl/audio.py | 2 +- tcod/sdl/render.py | 2 +- tcod/sdl/video.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tcod/sdl/audio.py b/tcod/sdl/audio.py index 390bc551..41329a61 100644 --- a/tcod/sdl/audio.py +++ b/tcod/sdl/audio.py @@ -1,4 +1,4 @@ -"""SDL2 audio playback and recording tools. +"""SDL audio playback and recording tools. This module includes SDL's low-level audio API and a naive implementation of an SDL mixer. If you have experience with audio mixing then you might be better off writing your own mixer or diff --git a/tcod/sdl/render.py b/tcod/sdl/render.py index f16714a2..f3f7edf3 100644 --- a/tcod/sdl/render.py +++ b/tcod/sdl/render.py @@ -1,4 +1,4 @@ -"""SDL2 Rendering functionality. +"""SDL Rendering functionality. .. versionadded:: 13.4 """ diff --git a/tcod/sdl/video.py b/tcod/sdl/video.py index 75844823..4895e0f7 100644 --- a/tcod/sdl/video.py +++ b/tcod/sdl/video.py @@ -1,4 +1,4 @@ -"""SDL2 Window and Display handling. +"""SDL Window and Display handling. There are two main ways to access the SDL window. Either you can use this module to open a window yourself bypassing libtcod's context, @@ -167,7 +167,7 @@ def __init__(self, pixels: ArrayLike) -> None: class Window: - """An SDL2 Window object. + """An SDL Window object. Created from :any:`tcod.sdl.video.new_window` when working with SDL directly. From 3ae49b4ad6183e762e8be1882b6df712d6cad393 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Sun, 23 Nov 2025 07:44:01 -0800 Subject: [PATCH 04/78] Fix code example directives Apparently `Example:` (single colon) works for doctests but `Example::` (2 colons) is required for non-doctest examples. --- tcod/sdl/audio.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tcod/sdl/audio.py b/tcod/sdl/audio.py index 41329a61..b404f94b 100644 --- a/tcod/sdl/audio.py +++ b/tcod/sdl/audio.py @@ -8,7 +8,8 @@ It leaves the loading to sound samples to other libraries like `SoundFile `_. -Example: +Example:: + # Synchronous audio example import time @@ -211,7 +212,8 @@ def convert_audio( class AudioDevice: """An SDL audio device. - Example: + Example:: + device = tcod.sdl.audio.get_default_playback().open() # Open a common audio device .. versionchanged:: 16.0 @@ -957,7 +959,8 @@ def get_capture_devices() -> dict[str, AudioDevice]: def get_default_playback() -> AudioDevice: """Return the default playback device. - Example: + Example:: + playback_device = tcod.sdl.audio.get_default_playback().open() .. versionadded:: 19.0 @@ -969,7 +972,8 @@ def get_default_playback() -> AudioDevice: def get_default_recording() -> AudioDevice: """Return the default recording device. - Example: + Example:: + recording_device = tcod.sdl.audio.get_default_recording().open() .. versionadded:: 19.0 From eb760a252990e0facab07720cee41b0efa96073b Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Sun, 23 Nov 2025 08:05:08 -0800 Subject: [PATCH 05/78] Fixed missing space in versionadded directives --- tcod/sdl/audio.py | 2 +- tcod/sdl/render.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tcod/sdl/audio.py b/tcod/sdl/audio.py index b404f94b..0a7b9ea7 100644 --- a/tcod/sdl/audio.py +++ b/tcod/sdl/audio.py @@ -489,7 +489,7 @@ class AudioStream: This class is commonly created with :any:`AudioDevice.new_stream` which creates a new stream bound to the device. - ..versionadded:: 19.0 + .. versionadded:: 19.0 """ __slots__ = ("__weakref__", "_stream_p") diff --git a/tcod/sdl/render.py b/tcod/sdl/render.py index f3f7edf3..6833330f 100644 --- a/tcod/sdl/render.py +++ b/tcod/sdl/render.py @@ -271,7 +271,7 @@ def color_mod(self, rgb: tuple[int, int, int]) -> None: def scale_mode(self) -> ScaleMode: """Get or set this textures :any:`ScaleMode`. - ..versionadded:: 19.3 + .. versionadded:: 19.3 """ mode = ffi.new("SDL_ScaleMode*") _check(lib.SDL_GetTextureScaleMode(self.p, mode)) From 2acfae20b94704f64a2a094e32a5c985e73eaf06 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Sun, 23 Nov 2025 08:26:23 -0800 Subject: [PATCH 06/78] Fix link to soundfile docs Old link was pointing to a broken URL, these docs were moved. --- tcod/sdl/audio.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tcod/sdl/audio.py b/tcod/sdl/audio.py index 0a7b9ea7..e4a84c96 100644 --- a/tcod/sdl/audio.py +++ b/tcod/sdl/audio.py @@ -5,8 +5,8 @@ modifying the existing one which was written using Python/Numpy. This module is designed to integrate with the wider Python ecosystem. -It leaves the loading to sound samples to other libraries like -`SoundFile `_. +It leaves the loading to sound samples to other libraries such as +`soundfile `_. Example:: From f32d376a24266a83395c2e4eb826d0abc2bf1bbd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Nov 2025 08:59:00 +0000 Subject: [PATCH 07/78] Bump actions/checkout from 5 to 6 in the github-actions group Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout). Updates `actions/checkout` from 5 to 6 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] --- .github/workflows/python-package.yml | 20 ++++++++++---------- .github/workflows/release-on-tag.yml | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index d328cefd..a553edf3 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -20,7 +20,7 @@ jobs: ruff: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Install Ruff run: pip install ruff - name: Ruff Check @@ -31,7 +31,7 @@ jobs: mypy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Checkout submodules run: git submodule update --init --recursive --depth 1 - name: Install typing dependencies @@ -50,7 +50,7 @@ jobs: install-linux-dependencies: true build-type: "Debug" version: ${{ env.sdl-version }} - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: fetch-depth: ${{ env.git-depth }} - name: Checkout submodules @@ -76,7 +76,7 @@ jobs: sdl-version: ["3.2.16"] fail-fast: true steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: fetch-depth: ${{ env.git-depth }} - name: Checkout submodules @@ -106,7 +106,7 @@ jobs: fail-fast: false steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: fetch-depth: ${{ env.git-depth }} - name: Checkout submodules @@ -171,7 +171,7 @@ jobs: install-linux-dependencies: true build-type: "Debug" version: ${{ env.sdl-version }} - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: fetch-depth: ${{ env.git-depth }} - name: Checkout submodules @@ -195,7 +195,7 @@ jobs: matrix: os: ["ubuntu-latest"] # "windows-latest" disabled due to free-threaded build issues steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: fetch-depth: ${{ env.git-depth }} - name: Checkout submodules @@ -227,7 +227,7 @@ jobs: env: BUILD_DESC: "" steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: fetch-depth: ${{ env.git-depth }} - name: Checkout submodules @@ -282,7 +282,7 @@ jobs: env: PYTHON_DESC: "" steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: fetch-depth: ${{ env.git-depth }} - name: Checkout submodules @@ -322,7 +322,7 @@ jobs: needs: [ruff, mypy, sdist] runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: fetch-depth: ${{ env.git-depth }} - name: Checkout submodules diff --git a/.github/workflows/release-on-tag.yml b/.github/workflows/release-on-tag.yml index 87b42c39..0e89b0c0 100644 --- a/.github/workflows/release-on-tag.yml +++ b/.github/workflows/release-on-tag.yml @@ -13,7 +13,7 @@ jobs: contents: write steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Generate body run: | scripts/get_release_description.py | tee release_body.md From ba9ca9664f77b886b90575358b432557dc3fd267 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Sun, 7 Dec 2025 22:31:00 -0800 Subject: [PATCH 08/78] Add cooldown to dependabot config Reduces risk of supply chain attacks --- .github/dependabot.yml | 4 +++- .vscode/settings.json | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index be006de9..2b2eb8c4 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,6 +8,8 @@ updates: groups: github-actions: patterns: - - "*" # Group all Actions updates into a single larger pull request + - "*" # Group all Actions updates into a single larger pull request schedule: interval: weekly + cooldown: + default-days: 7 diff --git a/.vscode/settings.json b/.vscode/settings.json index c4b4d82a..71b8b1cf 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -102,6 +102,7 @@ "CONTROLLERDEVICEADDED", "CONTROLLERDEVICEREMAPPED", "CONTROLLERDEVICEREMOVED", + "cooldown", "cplusplus", "CPLUSPLUS", "CRSEL", From ddbdc1aec1aebbb5510fabb24e9d874527a30e7d Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Sun, 7 Dec 2025 22:39:48 -0800 Subject: [PATCH 09/78] Update ignore tags on untyped decorators Changed from `misc` to `untyped-decorator` in the latest Mypy --- tcod/cffi.py | 2 +- tcod/context.py | 2 +- tcod/event.py | 2 +- tcod/libtcodpy.py | 10 +++++----- tcod/path.py | 8 ++++---- tcod/sdl/_internal.py | 2 +- tcod/sdl/audio.py | 2 +- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/tcod/cffi.py b/tcod/cffi.py index b0590d0e..97a37e44 100644 --- a/tcod/cffi.py +++ b/tcod/cffi.py @@ -60,7 +60,7 @@ def get_sdl_version() -> str: __sdl_version__ = get_sdl_version() -@ffi.def_extern() # type: ignore[misc] +@ffi.def_extern() # type: ignore[untyped-decorator] def _libtcod_log_watcher(message: Any, _userdata: None) -> None: # noqa: ANN401 text = str(ffi.string(message.message), encoding="utf-8") source = str(ffi.string(message.source), encoding="utf-8") diff --git a/tcod/context.py b/tcod/context.py index 769f84d2..2668a120 100644 --- a/tcod/context.py +++ b/tcod/context.py @@ -444,7 +444,7 @@ def __reduce__(self) -> NoReturn: raise pickle.PicklingError(msg) -@ffi.def_extern() # type: ignore[misc] +@ffi.def_extern() # type: ignore[untyped-decorator] def _pycall_cli_output(catch_reference: Any, output: Any) -> None: # noqa: ANN401 """Callback for the libtcod context CLI. diff --git a/tcod/event.py b/tcod/event.py index b7657eb2..f693291a 100644 --- a/tcod/event.py +++ b/tcod/event.py @@ -1564,7 +1564,7 @@ def get_mouse_state() -> MouseState: return MouseState((xy[0], xy[1]), (int(tile[0]), int(tile[1])), buttons) -@ffi.def_extern() # type: ignore[misc] +@ffi.def_extern() # type: ignore[untyped-decorator] def _sdl_event_watcher(userdata: Any, sdl_event: Any) -> int: callback: Callable[[Event], None] = ffi.from_handle(userdata) callback(_parse_event(sdl_event)) diff --git a/tcod/libtcodpy.py b/tcod/libtcodpy.py index fdfaeefe..c6b591cc 100644 --- a/tcod/libtcodpy.py +++ b/tcod/libtcodpy.py @@ -3607,27 +3607,27 @@ def parser_new_struct(parser: Any, name: str) -> Any: _parser_listener: Any = None -@ffi.def_extern() # type: ignore[misc] +@ffi.def_extern() # type: ignore[untyped-decorator] def _pycall_parser_new_struct(struct: Any, name: str) -> Any: return _parser_listener.new_struct(struct, _unpack_char_p(name)) -@ffi.def_extern() # type: ignore[misc] +@ffi.def_extern() # type: ignore[untyped-decorator] def _pycall_parser_new_flag(name: str) -> Any: return _parser_listener.new_flag(_unpack_char_p(name)) -@ffi.def_extern() # type: ignore[misc] +@ffi.def_extern() # type: ignore[untyped-decorator] def _pycall_parser_new_property(propname: Any, type: Any, value: Any) -> Any: return _parser_listener.new_property(_unpack_char_p(propname), type, _unpack_union(type, value)) -@ffi.def_extern() # type: ignore[misc] +@ffi.def_extern() # type: ignore[untyped-decorator] def _pycall_parser_end_struct(struct: Any, name: Any) -> Any: return _parser_listener.end_struct(struct, _unpack_char_p(name)) -@ffi.def_extern() # type: ignore[misc] +@ffi.def_extern() # type: ignore[untyped-decorator] def _pycall_parser_error(msg: Any) -> None: _parser_listener.error(_unpack_char_p(msg)) diff --git a/tcod/path.py b/tcod/path.py index 468bd95f..9d759371 100644 --- a/tcod/path.py +++ b/tcod/path.py @@ -35,26 +35,26 @@ from numpy.typing import ArrayLike, DTypeLike, NDArray -@ffi.def_extern() # type: ignore[misc] +@ffi.def_extern() # type: ignore[untyped-decorator] def _pycall_path_old(x1: int, y1: int, x2: int, y2: int, handle: Any) -> float: # noqa: ANN401 """Libtcodpy style callback, needs to preserve the old userdata issue.""" func, userdata = ffi.from_handle(handle) return func(x1, y1, x2, y2, userdata) # type: ignore[no-any-return] -@ffi.def_extern() # type: ignore[misc] +@ffi.def_extern() # type: ignore[untyped-decorator] def _pycall_path_simple(x1: int, y1: int, x2: int, y2: int, handle: Any) -> float: # noqa: ANN401 """Does less and should run faster, just calls the handle function.""" return ffi.from_handle(handle)(x1, y1, x2, y2) # type: ignore[no-any-return] -@ffi.def_extern() # type: ignore[misc] +@ffi.def_extern() # type: ignore[untyped-decorator] def _pycall_path_swap_src_dest(x1: int, y1: int, x2: int, y2: int, handle: Any) -> float: # noqa: ANN401 """A TDL function dest comes first to match up with a dest only call.""" return ffi.from_handle(handle)(x2, y2, x1, y1) # type: ignore[no-any-return] -@ffi.def_extern() # type: ignore[misc] +@ffi.def_extern() # type: ignore[untyped-decorator] def _pycall_path_dest_only(_x1: int, _y1: int, x2: int, y2: int, handle: Any) -> float: # noqa: ANN401 """A TDL function which samples the dest coordinate only.""" return ffi.from_handle(handle)(x2, y2) # type: ignore[no-any-return] diff --git a/tcod/sdl/_internal.py b/tcod/sdl/_internal.py index a671331e..4783d551 100644 --- a/tcod/sdl/_internal.py +++ b/tcod/sdl/_internal.py @@ -149,7 +149,7 @@ def __setitem__(self, key: tuple[str, type[T]], value: T, /) -> None: lib.SDL_SetPointerProperty(self.p, name, value._as_property_pointer()) -@ffi.def_extern() # type: ignore[misc] +@ffi.def_extern() # type: ignore[untyped-decorator] def _sdl_log_output_function(_userdata: None, category: int, priority: int, message_p: Any) -> None: # noqa: ANN401 """Pass logs sent by SDL to Python's logging system.""" message = str(ffi.string(message_p), encoding="utf-8") diff --git a/tcod/sdl/audio.py b/tcod/sdl/audio.py index e4a84c96..a2aa62d7 100644 --- a/tcod/sdl/audio.py +++ b/tcod/sdl/audio.py @@ -906,7 +906,7 @@ def _on_stream(self, audio_stream: AudioStream, data: AudioStreamCallbackData) - audio_stream.queue_audio(stream) -@ffi.def_extern() # type: ignore[misc] +@ffi.def_extern() # type: ignore[untyped-decorator] def _sdl_audio_stream_callback(userdata: Any, stream_p: Any, additional_amount: int, total_amount: int, /) -> None: # noqa: ANN401 """Handle audio device callbacks.""" stream = AudioStream(stream_p) From 5b86dfe6fdd76d50c2cfcb5cf23463eb0dee0b1d Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Sun, 7 Dec 2025 22:47:21 -0800 Subject: [PATCH 10/78] Remove codecov token Codecov configured to no longer require tokens on public repos --- .github/workflows/python-package.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index a553edf3..1188a77e 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -151,8 +151,6 @@ jobs: if: runner.os != 'Windows' run: cat /tmp/xvfb.log - uses: codecov/codecov-action@v5 - with: - token: ${{ secrets.CODECOV_TOKEN }} - uses: actions/upload-artifact@v5 if: runner.os == 'Windows' with: From 1c973756b9463035f4acac1717ae90058821112c Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Sun, 7 Dec 2025 22:54:18 -0800 Subject: [PATCH 11/78] Add workflow timeouts and concurrency settings Reduces unnecessary usage of public CI resources --- .github/workflows/python-package.yml | 15 +++++++++++++++ .github/workflows/release-on-tag.yml | 1 + 2 files changed, 16 insertions(+) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 1188a77e..c97b2c06 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -8,6 +8,10 @@ on: pull_request: types: [opened, reopened] +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + defaults: run: shell: bash @@ -19,6 +23,7 @@ env: jobs: ruff: runs-on: ubuntu-latest + timeout-minutes: 5 steps: - uses: actions/checkout@v6 - name: Install Ruff @@ -30,6 +35,7 @@ jobs: mypy: runs-on: ubuntu-latest + timeout-minutes: 5 steps: - uses: actions/checkout@v6 - name: Checkout submodules @@ -44,6 +50,7 @@ jobs: sdist: runs-on: ubuntu-latest + timeout-minutes: 5 steps: - uses: libsdl-org/setup-sdl@1894460666e4fe0c6603ea0cce5733f1cca56ba1 with: @@ -70,6 +77,7 @@ jobs: parse_sdl: needs: [ruff, mypy] runs-on: ${{ matrix.os }} + timeout-minutes: 5 strategy: matrix: os: ["windows-latest", "macos-latest"] @@ -94,6 +102,7 @@ jobs: build: needs: [ruff, mypy, sdist] runs-on: ${{ matrix.os }} + timeout-minutes: 15 strategy: matrix: os: ["ubuntu-latest", "windows-latest"] @@ -162,6 +171,7 @@ jobs: test-docs: needs: [ruff, mypy, sdist] runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: libsdl-org/setup-sdl@1894460666e4fe0c6603ea0cce5733f1cca56ba1 if: runner.os == 'Linux' @@ -189,6 +199,7 @@ jobs: tox: needs: [ruff, sdist] runs-on: ${{ matrix.os }} + timeout-minutes: 15 strategy: matrix: os: ["ubuntu-latest"] # "windows-latest" disabled due to free-threaded build issues @@ -218,6 +229,7 @@ jobs: linux-wheels: needs: [ruff, mypy, sdist] runs-on: ${{ matrix.arch == 'aarch64' && 'ubuntu-24.04-arm' || 'ubuntu-latest'}} + timeout-minutes: 15 strategy: matrix: arch: ["x86_64", "aarch64"] @@ -273,6 +285,7 @@ jobs: build-macos: needs: [ruff, mypy, sdist] runs-on: "macos-14" + timeout-minutes: 15 strategy: fail-fast: true matrix: @@ -319,6 +332,7 @@ jobs: pyodide: needs: [ruff, mypy, sdist] runs-on: ubuntu-24.04 + timeout-minutes: 15 steps: - uses: actions/checkout@v6 with: @@ -345,6 +359,7 @@ jobs: publish: needs: [sdist, build, build-macos, linux-wheels, pyodide] runs-on: ubuntu-latest + timeout-minutes: 5 if: github.ref_type == 'tag' environment: name: pypi diff --git a/.github/workflows/release-on-tag.yml b/.github/workflows/release-on-tag.yml index 0e89b0c0..921ac17d 100644 --- a/.github/workflows/release-on-tag.yml +++ b/.github/workflows/release-on-tag.yml @@ -9,6 +9,7 @@ jobs: build: name: Create Release runs-on: ubuntu-latest + timeout-minutes: 5 permissions: contents: write steps: From 2180a750372587716e1db984182e310849c6d6d5 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Sun, 14 Dec 2025 21:10:23 -0800 Subject: [PATCH 12/78] Fix _sdl_event_watcher cdef Type did not match the function def demanded by SDL for event watching and was crashing Add tests to ensure that this function works --- CHANGELOG.md | 4 ++++ build_sdl.py | 2 +- tests/test_tcod.py | 13 +++++++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23f96318..09a36973 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version ## [Unreleased] +### Fixed + +- `tcod.event.add_watch` was crashing due to a cdef type mismatch. + ## [19.6.0] - 2025-10-20 ### Added diff --git a/build_sdl.py b/build_sdl.py index aca9fc25..2fb39579 100755 --- a/build_sdl.py +++ b/build_sdl.py @@ -311,7 +311,7 @@ def get_emscripten_include_dir() -> Path: // SDL to Python log function. void _sdl_log_output_function(void *userdata, int category, SDL_LogPriority priority, const char *message); // Generic event watcher callback. -int _sdl_event_watcher(void* userdata, SDL_Event* event); +bool _sdl_event_watcher(void* userdata, SDL_Event* event); } """ diff --git a/tests/test_tcod.py b/tests/test_tcod.py index 23bb7d6b..c41da899 100644 --- a/tests/test_tcod.py +++ b/tests/test_tcod.py @@ -204,3 +204,16 @@ def test_context(uses_window: None) -> None: # noqa: ARG001 context.pixel_to_subtile(0, 0) with pytest.raises(RuntimeError, match=r".*context has been closed"): context.present(console) + + +def test_event_watch() -> None: + def handle_events(_event: tcod.event.Event) -> None: + pass + + tcod.event.add_watch(handle_events) + with pytest.warns(RuntimeWarning, match=r"nothing was added"): + tcod.event.add_watch(handle_events) + + tcod.event.remove_watch(handle_events) + with pytest.warns(RuntimeWarning, match=r"nothing was removed"): + tcod.event.remove_watch(handle_events) From 288eda47fffe3eb126ee7cbf84cf041acc77c155 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Sun, 14 Dec 2025 21:53:57 -0800 Subject: [PATCH 13/78] Update samples Keep window responsive during resize events via event watchers Workaround tile coords not respecting logical size --- examples/samples_tcod.py | 90 +++++++++++++++++++++++----------------- 1 file changed, 52 insertions(+), 38 deletions(-) diff --git a/examples/samples_tcod.py b/examples/samples_tcod.py index 0955f2a0..41cc1e05 100755 --- a/examples/samples_tcod.py +++ b/examples/samples_tcod.py @@ -1348,43 +1348,14 @@ def init_context(renderer: int) -> None: def main() -> None: - global context, tileset + global tileset tileset = tcod.tileset.load_tilesheet(FONT, 32, 8, tcod.tileset.CHARMAP_TCOD) init_context(libtcodpy.RENDERER_SDL2) try: SAMPLES[cur_sample].on_enter() while True: - root_console.clear() - draw_samples_menu() - draw_renderer_menu() - - # render the sample - SAMPLES[cur_sample].on_draw() - sample_console.blit(root_console, SAMPLE_SCREEN_X, SAMPLE_SCREEN_Y) - draw_stats() - if context.sdl_renderer: - # Clear the screen to ensure no garbage data outside of the logical area is displayed - context.sdl_renderer.draw_color = (0, 0, 0, 255) - context.sdl_renderer.clear() - # SDL renderer support, upload the sample console background to a minimap texture. - sample_minimap.update(sample_console.rgb.T["bg"]) - # Render the root_console normally, this is the drawing step of context.present without presenting. - context.sdl_renderer.copy(console_render.render(root_console)) - # Render the minimap to the screen. - context.sdl_renderer.copy( - sample_minimap, - dest=( - tileset.tile_width * 24, - tileset.tile_height * 36, - SAMPLE_SCREEN_WIDTH * 3, - SAMPLE_SCREEN_HEIGHT * 3, - ), - ) - context.sdl_renderer.present() - else: # No SDL renderer, just use plain context rendering. - context.present(root_console) - + redraw_display() handle_time() handle_events() finally: @@ -1394,6 +1365,39 @@ def main() -> None: context.close() +def redraw_display() -> None: + """Full clear-draw-present of the screen.""" + root_console.clear() + draw_samples_menu() + draw_renderer_menu() + + # render the sample + SAMPLES[cur_sample].on_draw() + sample_console.blit(root_console, SAMPLE_SCREEN_X, SAMPLE_SCREEN_Y) + draw_stats() + if context.sdl_renderer: + # Clear the screen to ensure no garbage data outside of the logical area is displayed + context.sdl_renderer.draw_color = (0, 0, 0, 255) + context.sdl_renderer.clear() + # SDL renderer support, upload the sample console background to a minimap texture. + sample_minimap.update(sample_console.rgb.T["bg"]) + # Render the root_console normally, this is the drawing step of context.present without presenting. + context.sdl_renderer.copy(console_render.render(root_console)) + # Render the minimap to the screen. + context.sdl_renderer.copy( + sample_minimap, + dest=( + tileset.tile_width * 24, + tileset.tile_height * 36, + SAMPLE_SCREEN_WIDTH * 3, + SAMPLE_SCREEN_HEIGHT * 3, + ), + ) + context.sdl_renderer.present() + else: # No SDL renderer, just use plain context rendering. + context.present(root_console) + + def handle_time() -> None: if len(frame_times) > 100: frame_times.pop(0) @@ -1404,16 +1408,17 @@ def handle_time() -> None: def handle_events() -> None: for event in tcod.event.get(): - if context.sdl_renderer: - # Manual handing of tile coordinates since context.present is skipped. + if context.sdl_renderer: # Manual handing of tile coordinates since context.present is skipped + assert context.sdl_window + tile_width = context.sdl_window.size[0] / root_console.width + tile_height = context.sdl_window.size[1] / root_console.height + if isinstance(event, (tcod.event.MouseState, tcod.event.MouseMotion)): - event.tile = tcod.event.Point( - event.position.x // tileset.tile_width, event.position.y // tileset.tile_height - ) + event.tile = tcod.event.Point(event.position.x // tile_width, event.position.y // tile_height) if isinstance(event, tcod.event.MouseMotion): prev_tile = ( - (event.position[0] - event.motion[0]) // tileset.tile_width, - (event.position[1] - event.motion[1]) // tileset.tile_height, + (event.position[0] - event.motion[0]) // tile_width, + (event.position[1] - event.motion[1]) // tile_height, ) event.tile_motion = tcod.event.Point(event.tile[0] - prev_tile[0], event.tile[1] - prev_tile[1]) else: @@ -1484,4 +1489,13 @@ def draw_renderer_menu() -> None: if __name__ == "__main__": + + @tcod.event.add_watch + def _handle_events(event: tcod.event.Event) -> None: + """Keep window responsive during resize events.""" + match event: + case tcod.event.WindowEvent(type="WindowExposed"): + redraw_display() + handle_time() + main() From cba0c75552ecec4b37180374ce469e353d50dc6b Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Sun, 14 Dec 2025 23:17:20 -0800 Subject: [PATCH 14/78] Prepare 19.6.1 release. --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09a36973..9836d283 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version ## [Unreleased] +## [19.6.1] - 2025-12-15 + ### Fixed - `tcod.event.add_watch` was crashing due to a cdef type mismatch. From 24882ffa780ff9f19e27719d733c8e304970b05e Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Mon, 15 Dec 2025 03:58:02 -0800 Subject: [PATCH 15/78] Refactor TrueColorSample Switch to a frame rate agnostic color sampler Move name attributes to be class variables Update deprecations on Console.print_box to be more clear Add missing deprecation directive --- examples/samples_tcod.py | 122 +++++++++++++++++++-------------------- tcod/console.py | 7 ++- 2 files changed, 67 insertions(+), 62 deletions(-) diff --git a/examples/samples_tcod.py b/examples/samples_tcod.py index 41cc1e05..d2d8f155 100755 --- a/examples/samples_tcod.py +++ b/examples/samples_tcod.py @@ -77,8 +77,7 @@ def _get_elapsed_time() -> float: class Sample(tcod.event.EventDispatch[None]): - def __init__(self, name: str = "") -> None: - self.name = name + name: str = "???" def on_enter(self) -> None: pass @@ -114,74 +113,73 @@ def ev_keydown(self, event: tcod.event.KeyDown) -> None: class TrueColorSample(Sample): + name = "True colors" + def __init__(self) -> None: - self.name = "True colors" - # corner colors - self.colors: NDArray[np.int16] = np.array( - [(50, 40, 150), (240, 85, 5), (50, 35, 240), (10, 200, 130)], - dtype=np.int16, - ) - # color shift direction - self.slide_dir: NDArray[np.int16] = np.array([[1, 1, 1], [-1, -1, 1], [1, -1, 1], [1, 1, -1]], dtype=np.int16) - # corner indexes - self.corners: NDArray[np.int16] = np.array([0, 1, 2, 3], dtype=np.int16) + self.noise = tcod.noise.Noise(2, tcod.noise.Algorithm.SIMPLEX) + """Noise for generating color.""" + + self.generator = np.random.default_rng() + """Numpy generator for random text.""" def on_draw(self) -> None: - self.slide_corner_colors() self.interpolate_corner_colors() self.darken_background_characters() self.randomize_sample_console() - self.print_banner() - - def slide_corner_colors(self) -> None: - # pick random RGB channels for each corner - rand_channels = np.random.randint(low=0, high=3, size=4) - - # shift picked color channels in the direction of slide_dir - self.colors[self.corners, rand_channels] += self.slide_dir[self.corners, rand_channels] * 5 + sample_console.print( + x=1, + y=5, + width=sample_console.width - 2, + height=sample_console.height - 1, + text="The Doryen library uses 24 bits colors, for both background and foreground.", + fg=WHITE, + bg=GREY, + bg_blend=libtcodpy.BKGND_MULTIPLY, + alignment=libtcodpy.CENTER, + ) - # reverse slide_dir values when limits are reached - self.slide_dir[self.colors[:] == 255] = -1 - self.slide_dir[self.colors[:] == 0] = 1 + def get_corner_colors(self) -> NDArray[np.uint8]: + """Return 4 random 8-bit colors, smoothed over time.""" + noise_samples_ij = ( + [ # i coordinates are per color channel per color + [0, 1, 2], + [3, 4, 5], + [6, 7, 8], + [9, 10, 11], + ], + time.perf_counter(), # j coordinate is time broadcast to all samples + ) + colors = self.noise[noise_samples_ij] + colors = ((colors + 1.0) * (0.5 * 255.0)).clip(min=0, max=255) # Convert -1..1 to 0..255 + return colors.astype(np.uint8) def interpolate_corner_colors(self) -> None: - # interpolate corner colors across the sample console - left = np.linspace(self.colors[0], self.colors[2], SAMPLE_SCREEN_HEIGHT) - right = np.linspace(self.colors[1], self.colors[3], SAMPLE_SCREEN_HEIGHT) + """Interpolate corner colors across the sample console.""" + colors = self.get_corner_colors() + left = np.linspace(colors[0], colors[2], SAMPLE_SCREEN_HEIGHT) + right = np.linspace(colors[1], colors[3], SAMPLE_SCREEN_HEIGHT) sample_console.bg[:] = np.linspace(left, right, SAMPLE_SCREEN_WIDTH) def darken_background_characters(self) -> None: - # darken background characters + """Darken background characters.""" sample_console.fg[:] = sample_console.bg[:] sample_console.fg[:] //= 2 def randomize_sample_console(self) -> None: - # randomize sample console characters - sample_console.ch[:] = np.random.randint( + """Randomize sample console characters.""" + sample_console.ch[:] = self.generator.integers( low=ord("a"), - high=ord("z") + 1, + high=ord("z"), + endpoint=True, size=sample_console.ch.size, dtype=np.intc, ).reshape(sample_console.ch.shape) - def print_banner(self) -> None: - # print text on top of samples - sample_console.print_box( - x=1, - y=5, - width=sample_console.width - 2, - height=sample_console.height - 1, - string="The Doryen library uses 24 bits colors, for both background and foreground.", - fg=WHITE, - bg=GREY, - bg_blend=libtcodpy.BKGND_MULTIPLY, - alignment=libtcodpy.CENTER, - ) - class OffscreenConsoleSample(Sample): + name = "Offscreen console" + def __init__(self) -> None: - self.name = "Offscreen console" self.secondary = tcod.console.Console(sample_console.width // 2, sample_console.height // 2) self.screenshot = tcod.console.Console(sample_console.width, sample_console.height) self.counter = 0.0 @@ -245,6 +243,8 @@ def on_draw(self) -> None: class LineDrawingSample(Sample): + name = "Line drawing" + FLAG_NAMES = ( "BKGND_NONE", "BKGND_SET", @@ -262,7 +262,6 @@ class LineDrawingSample(Sample): ) def __init__(self) -> None: - self.name = "Line drawing" self.mk_flag = libtcodpy.BKGND_SET self.bk_flag = libtcodpy.BKGND_SET @@ -322,6 +321,8 @@ def on_draw(self) -> None: class NoiseSample(Sample): + name = "Noise" + NOISE_OPTIONS = ( # (name, algorithm, implementation) ( "perlin noise", @@ -371,7 +372,6 @@ class NoiseSample(Sample): ) def __init__(self) -> None: - self.name = "Noise" self.func = 0 self.dx = 0.0 self.dy = 0.0 @@ -548,9 +548,9 @@ def ev_keydown(self, event: tcod.event.KeyDown) -> None: class FOVSample(Sample): - def __init__(self) -> None: - self.name = "Field of view" + name = "Field of view" + def __init__(self) -> None: self.player_x = 20 self.player_y = 10 self.torch = False @@ -674,10 +674,10 @@ def ev_keydown(self, event: tcod.event.KeyDown) -> None: class PathfindingSample(Sample): + name = "Path finding" + def __init__(self) -> None: """Initialize this sample.""" - self.name = "Path finding" - self.player_x = 20 self.player_y = 10 self.dest_x = 24 @@ -873,8 +873,9 @@ def traverse_node(bsp_map: NDArray[np.bool_], node: tcod.bsp.BSP) -> None: class BSPSample(Sample): + name = "Bsp toolkit" + def __init__(self) -> None: - self.name = "Bsp toolkit" self.bsp = tcod.bsp.BSP(1, 1, SAMPLE_SCREEN_WIDTH - 1, SAMPLE_SCREEN_HEIGHT - 1) self.bsp_map: NDArray[np.bool_] = np.zeros((SAMPLE_SCREEN_WIDTH, SAMPLE_SCREEN_HEIGHT), dtype=bool, order="F") self.bsp_generate() @@ -956,9 +957,9 @@ def ev_keydown(self, event: tcod.event.KeyDown) -> None: class ImageSample(Sample): - def __init__(self) -> None: - self.name = "Image toolkit" + name = "Image toolkit" + def __init__(self) -> None: self.img = tcod.image.Image.from_file(DATA_DIR / "img/skull.png") self.img.set_key_color(BLACK) self.circle = tcod.image.Image.from_file(DATA_DIR / "img/circle.png") @@ -990,9 +991,9 @@ def on_draw(self) -> None: class MouseSample(Sample): - def __init__(self) -> None: - self.name = "Mouse support" + name = "Mouse support" + def __init__(self) -> None: self.motion = tcod.event.MouseMotion() self.mouse_left = self.mouse_middle = self.mouse_right = 0 self.log: list[str] = [] @@ -1054,9 +1055,9 @@ def ev_keydown(self, event: tcod.event.KeyDown) -> None: class NameGeneratorSample(Sample): - def __init__(self) -> None: - self.name = "Name generator" + name = "Name generator" + def __init__(self) -> None: self.current_set = 0 self.delay = 0.0 self.names: list[str] = [] @@ -1160,8 +1161,7 @@ def __init__( class FastRenderSample(Sample): - def __init__(self) -> None: - self.name = "Python fast render" + name = "Python fast render" def on_enter(self) -> None: sample_console.clear() # render status message diff --git a/tcod/console.py b/tcod/console.py index 44ac3c61..b42c27a1 100644 --- a/tcod/console.py +++ b/tcod/console.py @@ -1182,7 +1182,9 @@ def print( # noqa: PLR0913 ) ) - @deprecated("Switch to using keywords and then replace with 'console.print(...)'") + @deprecated( + "Switch parameters to keywords, then replace method with 'console.print(...)', then replace 'string=' with 'text='" + ) def print_box( # noqa: PLR0913 self, x: int, @@ -1224,6 +1226,9 @@ def print_box( # noqa: PLR0913 .. versionchanged:: 13.0 `x` and `y` are now always used as an absolute position for negative values. + + .. deprecated:: 18.0 + This method was replaced by more functional :any:`Console.print` method. """ string_ = string.encode("utf-8") return int( From 0d7949178962027f881712947bea2f9cc1c13c9b Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Wed, 17 Dec 2025 17:42:00 -0800 Subject: [PATCH 16/78] Remove order="F" from samples --- examples/samples_tcod.py | 96 ++++++++++++++++++++-------------------- 1 file changed, 48 insertions(+), 48 deletions(-) diff --git a/examples/samples_tcod.py b/examples/samples_tcod.py index d2d8f155..a021cd14 100755 --- a/examples/samples_tcod.py +++ b/examples/samples_tcod.py @@ -63,8 +63,8 @@ tileset: tcod.tileset.Tileset console_render: tcod.render.SDLConsoleRender # Optional SDL renderer. sample_minimap: tcod.sdl.render.Texture # Optional minimap texture. -root_console = tcod.console.Console(80, 50, order="F") -sample_console = tcod.console.Console(SAMPLE_SCREEN_WIDTH, SAMPLE_SCREEN_HEIGHT, order="F") +root_console = tcod.console.Console(80, 50) +sample_console = tcod.console.Console(SAMPLE_SCREEN_WIDTH, SAMPLE_SCREEN_HEIGHT) cur_sample = 0 # Current selected sample. frame_times = [time.perf_counter()] frame_length = [0.0] @@ -156,9 +156,9 @@ def get_corner_colors(self) -> NDArray[np.uint8]: def interpolate_corner_colors(self) -> None: """Interpolate corner colors across the sample console.""" colors = self.get_corner_colors() - left = np.linspace(colors[0], colors[2], SAMPLE_SCREEN_HEIGHT) - right = np.linspace(colors[1], colors[3], SAMPLE_SCREEN_HEIGHT) - sample_console.bg[:] = np.linspace(left, right, SAMPLE_SCREEN_WIDTH) + top = np.linspace(colors[0], colors[1], SAMPLE_SCREEN_WIDTH) + bottom = np.linspace(colors[2], colors[3], SAMPLE_SCREEN_WIDTH) + sample_console.bg[:] = np.linspace(top, bottom, SAMPLE_SCREEN_HEIGHT) def darken_background_characters(self) -> None: """Darken background characters.""" @@ -265,12 +265,12 @@ def __init__(self) -> None: self.mk_flag = libtcodpy.BKGND_SET self.bk_flag = libtcodpy.BKGND_SET - self.bk = tcod.console.Console(sample_console.width, sample_console.height, order="F") + self.background = tcod.console.Console(sample_console.width, sample_console.height) # initialize the colored background - self.bk.bg[:, :, 0] = np.linspace(0, 255, self.bk.width)[:, np.newaxis] - self.bk.bg[:, :, 2] = np.linspace(0, 255, self.bk.height) - self.bk.bg[:, :, 1] = (self.bk.bg[:, :, 0].astype(int) + self.bk.bg[:, :, 2]) / 2 - self.bk.ch[:] = ord(" ") + self.background.bg[:, :, 0] = np.linspace(0, 255, self.background.width) + self.background.bg[:, :, 2] = np.linspace(0, 255, self.background.height)[:, np.newaxis] + self.background.bg[:, :, 1] = (self.background.bg[:, :, 0].astype(int) + self.background.bg[:, :, 2]) / 2 + self.background.ch[:] = ord(" ") def ev_keydown(self, event: tcod.event.KeyDown) -> None: if event.sym in (tcod.event.KeySym.RETURN, tcod.event.KeySym.KP_ENTER): @@ -291,7 +291,7 @@ def on_draw(self) -> None: alpha = (1.0 + math.cos(time.time() * 2)) / 2.0 self.bk_flag = libtcodpy.BKGND_ADDALPHA(int(alpha)) - self.bk.blit(sample_console) + self.background.blit(sample_console) rect_y = int((sample_console.height - 2) * ((1.0 + math.cos(time.time())) / 2.0)) for x in range(sample_console.width): value = x * 255 // sample_console.width @@ -429,8 +429,8 @@ def on_draw(self) -> None: bg=GREY, bg_blend=libtcodpy.BKGND_MULTIPLY, ) - sample_console.fg[2 : 2 + rect_w, 2 : 2 + rect_h] = ( - sample_console.fg[2 : 2 + rect_w, 2 : 2 + rect_h] * GREY / 255 + sample_console.fg[2 : 2 + rect_h, 2 : 2 + rect_w] = ( + sample_console.fg[2 : 2 + rect_h, 2 : 2 + rect_w] * GREY / 255 ) for cur_func in range(len(self.NOISE_OPTIONS)): @@ -524,7 +524,7 @@ def ev_keydown(self, event: tcod.event.KeyDown) -> None: "##############################################", ) -SAMPLE_MAP: NDArray[Any] = np.array([[ord(c) for c in line] for line in SAMPLE_MAP_]).transpose() +SAMPLE_MAP: NDArray[Any] = np.array([[ord(c) for c in line] for line in SAMPLE_MAP_]) FOV_ALGO_NAMES = ( "BASIC ", @@ -558,12 +558,12 @@ def __init__(self) -> None: self.algo_num = libtcodpy.FOV_SYMMETRIC_SHADOWCAST self.noise = tcod.noise.Noise(1) # 1D noise for the torch flickering. - map_shape = (SAMPLE_SCREEN_WIDTH, SAMPLE_SCREEN_HEIGHT) + map_shape = (SAMPLE_SCREEN_HEIGHT, SAMPLE_SCREEN_WIDTH) - self.walkable: NDArray[np.bool_] = np.zeros(map_shape, dtype=bool, order="F") + self.walkable: NDArray[np.bool_] = np.zeros(map_shape, dtype=bool) self.walkable[:] = SAMPLE_MAP[:] == ord(" ") - self.transparent: NDArray[np.bool_] = np.zeros(map_shape, dtype=bool, order="F") + self.transparent: NDArray[np.bool_] = np.zeros(map_shape, dtype=bool) self.transparent[:] = self.walkable[:] | (SAMPLE_MAP[:] == ord("=")) # Lit background colors for the map. @@ -598,7 +598,7 @@ def on_draw(self) -> None: # Get a 2D boolean array of visible cells. fov = tcod.map.compute_fov( transparency=self.transparent, - pov=(self.player_x, self.player_y), + pov=(self.player_y, self.player_x), radius=TORCH_RADIUS if self.torch else 0, light_walls=self.light_walls, algorithm=self.algo_num, @@ -614,7 +614,7 @@ def on_draw(self) -> None: brightness = 0.2 * self.noise.get_point(torch_t + 17) # Get the squared distance using a mesh grid. - x, y = np.mgrid[:SAMPLE_SCREEN_WIDTH, :SAMPLE_SCREEN_HEIGHT] + y, x = np.mgrid[:SAMPLE_SCREEN_HEIGHT, :SAMPLE_SCREEN_WIDTH] # Center the mesh grid on the torch position. x = x.astype(np.float32) - torch_x y = y.astype(np.float32) - torch_y @@ -659,7 +659,7 @@ def ev_keydown(self, event: tcod.event.KeyDown) -> None: } if event.sym in MOVE_KEYS: x, y = MOVE_KEYS[event.sym] - if self.walkable[self.player_x + x, self.player_y + y]: + if self.walkable[self.player_y + y, self.player_x + x]: self.player_x += x self.player_y += y elif event.sym == tcod.event.KeySym.T: @@ -684,7 +684,7 @@ def __init__(self) -> None: self.dest_y = 1 self.using_astar = True self.busy = 0.0 - self.cost = SAMPLE_MAP.T[:] == ord(" ") + self.cost = SAMPLE_MAP[:] == ord(" ") self.graph = tcod.path.SimpleGraph(cost=self.cost, cardinal=70, diagonal=99) self.pathfinder = tcod.path.Pathfinder(graph=self.graph) @@ -693,8 +693,8 @@ def __init__(self) -> None: # draw the dungeon self.background_console.rgb["fg"] = BLACK self.background_console.rgb["bg"] = DARK_GROUND - self.background_console.rgb["bg"][SAMPLE_MAP.T[:] == ord("#")] = DARK_WALL - self.background_console.rgb["ch"][SAMPLE_MAP.T[:] == ord("=")] = ord("═") + self.background_console.rgb["bg"][SAMPLE_MAP[:] == ord("#")] = DARK_WALL + self.background_console.rgb["ch"][SAMPLE_MAP[:] == ord("=")] = ord("═") def on_enter(self) -> None: """Do nothing.""" @@ -722,10 +722,10 @@ def on_draw(self) -> None: np.array(self.pathfinder.distance, copy=True, dtype=np.float32) interpolate = self.pathfinder.distance[reachable] * 0.9 / dijkstra_max_dist color_delta = (np.array(DARK_GROUND) - np.array(LIGHT_GROUND)).astype(np.float32) - sample_console.rgb.T["bg"][reachable] = np.array(LIGHT_GROUND) + interpolate[:, np.newaxis] * color_delta + sample_console.rgb["bg"][reachable] = np.array(LIGHT_GROUND) + interpolate[:, np.newaxis] * color_delta # draw the path - path = self.pathfinder.path_to((self.dest_y, self.dest_x))[1:, ::-1] + path = self.pathfinder.path_to((self.dest_y, self.dest_x))[1:] sample_console.rgb["bg"][tuple(path.T)] = LIGHT_GROUND # move the creature @@ -733,8 +733,8 @@ def on_draw(self) -> None: if self.busy <= 0.0: self.busy = 0.2 if len(path): - self.player_x = int(path.item(0, 0)) - self.player_y = int(path.item(0, 1)) + self.player_y = int(path.item(0, 0)) + self.player_x = int(path.item(0, 1)) def ev_keydown(self, event: tcod.event.KeyDown) -> None: """Handle movement and UI.""" @@ -772,46 +772,46 @@ def ev_mousemotion(self, event: tcod.event.MouseMotion) -> None: # draw a vertical line -def vline(m: NDArray[np.bool_], x: int, y1: int, y2: int) -> None: +def vline(map_: NDArray[np.bool_], x: int, y1: int, y2: int) -> None: if y1 > y2: y1, y2 = y2, y1 for y in range(y1, y2 + 1): - m[x, y] = True + map_[y, x] = True # draw a vertical line up until we reach an empty space -def vline_up(m: NDArray[np.bool_], x: int, y: int) -> None: - while y >= 0 and not m[x, y]: - m[x, y] = True +def vline_up(map_: NDArray[np.bool_], x: int, y: int) -> None: + while y >= 0 and not map_[y, x]: + map_[y, x] = True y -= 1 # draw a vertical line down until we reach an empty space -def vline_down(m: NDArray[np.bool_], x: int, y: int) -> None: - while y < SAMPLE_SCREEN_HEIGHT and not m[x, y]: - m[x, y] = True +def vline_down(map_: NDArray[np.bool_], x: int, y: int) -> None: + while y < SAMPLE_SCREEN_HEIGHT and not map_[y, x]: + map_[y, x] = True y += 1 # draw a horizontal line -def hline(m: NDArray[np.bool_], x1: int, y: int, x2: int) -> None: +def hline(map_: NDArray[np.bool_], x1: int, y: int, x2: int) -> None: if x1 > x2: x1, x2 = x2, x1 for x in range(x1, x2 + 1): - m[x, y] = True + map_[y, x] = True # draw a horizontal line left until we reach an empty space -def hline_left(m: NDArray[np.bool_], x: int, y: int) -> None: - while x >= 0 and not m[x, y]: - m[x, y] = True +def hline_left(map_: NDArray[np.bool_], x: int, y: int) -> None: + while x >= 0 and not map_[y, x]: + map_[y, x] = True x -= 1 # draw a horizontal line right until we reach an empty space -def hline_right(m: NDArray[np.bool_], x: int, y: int) -> None: - while x < SAMPLE_SCREEN_WIDTH and not m[x, y]: - m[x, y] = True +def hline_right(map_: NDArray[np.bool_], x: int, y: int) -> None: + while x < SAMPLE_SCREEN_WIDTH and not map_[y, x]: + map_[y, x] = True x += 1 @@ -829,7 +829,7 @@ def traverse_node(bsp_map: NDArray[np.bool_], node: tcod.bsp.BSP) -> None: node.y += random.randint(0, node.height - new_height) node.width, node.height = new_width, new_height # dig the room - bsp_map[node.x : node.x + node.width, node.y : node.y + node.height] = True + bsp_map[node.y : node.y + node.height, node.x : node.x + node.width] = True else: # resize the node to fit its sons left, right = node.children @@ -877,7 +877,7 @@ class BSPSample(Sample): def __init__(self) -> None: self.bsp = tcod.bsp.BSP(1, 1, SAMPLE_SCREEN_WIDTH - 1, SAMPLE_SCREEN_HEIGHT - 1) - self.bsp_map: NDArray[np.bool_] = np.zeros((SAMPLE_SCREEN_WIDTH, SAMPLE_SCREEN_HEIGHT), dtype=bool, order="F") + self.bsp_map: NDArray[np.bool_] = np.zeros((SAMPLE_SCREEN_HEIGHT, SAMPLE_SCREEN_WIDTH), dtype=bool) self.bsp_generate() def bsp_generate(self) -> None: @@ -923,7 +923,7 @@ def on_draw(self) -> None: # render the level for y in range(SAMPLE_SCREEN_HEIGHT): for x in range(SAMPLE_SCREEN_WIDTH): - color = DARK_GROUND if self.bsp_map[x][y] else DARK_WALL + color = DARK_GROUND if self.bsp_map[y, x] else DARK_WALL libtcodpy.console_set_char_background(sample_console, x, y, color, libtcodpy.BKGND_SET) def ev_keydown(self, event: tcod.event.KeyDown) -> None: @@ -1274,7 +1274,7 @@ def on_draw(self) -> None: bb = bb.clip(0, 255) # fill the screen with these background colors - sample_console.bg.transpose(2, 1, 0)[...] = (rr, gg, bb) + sample_console.bg.transpose(2, 0, 1)[...] = (rr, gg, bb) ############################################# @@ -1380,7 +1380,7 @@ def redraw_display() -> None: context.sdl_renderer.draw_color = (0, 0, 0, 255) context.sdl_renderer.clear() # SDL renderer support, upload the sample console background to a minimap texture. - sample_minimap.update(sample_console.rgb.T["bg"]) + sample_minimap.update(sample_console.rgb["bg"]) # Render the root_console normally, this is the drawing step of context.present without presenting. context.sdl_renderer.copy(console_render.render(root_console)) # Render the minimap to the screen. From c102de3c775a2146b248c4b1ae759d0e5f02bae6 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Wed, 17 Dec 2025 17:56:42 -0800 Subject: [PATCH 17/78] Clean up FastRenderSample attributes Move names for mutable variables into the class Refactor Light into a dataclass Numpy is not optional with latest tcod --- examples/samples_tcod.py | 58 +++++++++++++++++----------------------- 1 file changed, 25 insertions(+), 33 deletions(-) diff --git a/examples/samples_tcod.py b/examples/samples_tcod.py index a021cd14..bce9dda9 100755 --- a/examples/samples_tcod.py +++ b/examples/samples_tcod.py @@ -13,6 +13,7 @@ import sys import time import warnings +from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Any @@ -1111,9 +1112,6 @@ def ev_keydown(self, event: tcod.event.KeyDown) -> None: ############################################# # python fast render sample ############################################# -numpy_available = True - -use_numpy = numpy_available # default option SCREEN_W = SAMPLE_SCREEN_WIDTH SCREEN_H = SAMPLE_SCREEN_HEIGHT HALF_W = SCREEN_W // 2 @@ -1133,36 +1131,32 @@ def ev_keydown(self, event: tcod.event.KeyDown) -> None: # example: (4x3 pixels screen) # xc = [[1, 2, 3, 4], [1, 2, 3, 4], [1, 2, 3, 4]] # noqa: ERA001 # yc = [[1, 1, 1, 1], [2, 2, 2, 2], [3, 3, 3, 3]] # noqa: ERA001 -if numpy_available: - (xc, yc) = np.meshgrid(range(SCREEN_W), range(SCREEN_H)) - # translate coordinates of all pixels to center - xc = xc - HALF_W - yc = yc - HALF_H - -noise2d = tcod.noise.Noise(2, hurst=0.5, lacunarity=2.0) -if numpy_available: # the texture starts empty - texture = np.zeros((RES_U, RES_V)) +(xc, yc) = np.meshgrid(range(SCREEN_W), range(SCREEN_H)) +# translate coordinates of all pixels to center +xc = xc - HALF_W +yc = yc - HALF_H +@dataclass(frozen=False, slots=True) class Light: - def __init__( - self, - x: float, - y: float, - z: float, - r: int, - g: int, - b: int, - strength: float, - ) -> None: - self.x, self.y, self.z = x, y, z # pos. - self.r, self.g, self.b = r, g, b # color - self.strength = strength # between 0 and 1, defines brightness + """Lighting effect entity.""" + + x: float # pos + y: float + z: float + r: int # color + g: int + b: int + strength: float # between 0 and 1, defines brightness class FastRenderSample(Sample): name = "Python fast render" + def __init__(self) -> None: + self.texture = np.zeros((RES_U, RES_V)) + self.noise2d = tcod.noise.Noise(2, hurst=0.5, lacunarity=2.0) + def on_enter(self) -> None: sample_console.clear() # render status message sample_console.print(1, SCREEN_H - 3, "Renderer: NumPy", fg=WHITE, bg=None) @@ -1178,8 +1172,6 @@ def on_enter(self) -> None: self.tex_b = 0.0 def on_draw(self) -> None: - global texture - time_delta = frame_length[-1] * SPEED # advance time self.frac_t += time_delta # increase fractional (always < 1.0) time self.abs_t += time_delta # increase absolute elapsed time @@ -1207,14 +1199,14 @@ def on_draw(self) -> None: # new pixels are based on absolute elapsed time int_abs_t = int(self.abs_t) - texture = np.roll(texture, -int_t, 1) + self.texture = np.roll(self.texture, -int_t, 1) # replace new stretch of texture with new values for v in range(RES_V - int_t, RES_V): for u in range(RES_U): tex_v = (v + int_abs_t) / float(RES_V) - texture[u, v] = libtcodpy.noise_get_fbm( - noise2d, [u / float(RES_U), tex_v], 32.0 - ) + libtcodpy.noise_get_fbm(noise2d, [1 - u / float(RES_U), tex_v], 32.0) + self.texture[u, v] = libtcodpy.noise_get_fbm( + self.noise2d, [u / float(RES_U), tex_v], 32.0 + ) + libtcodpy.noise_get_fbm(self.noise2d, [1 - u / float(RES_U), tex_v], 32.0) # squared distance from center, # clipped to sensible minimum and maximum values @@ -1229,7 +1221,7 @@ def on_draw(self) -> None: uu = np.mod(RES_U * (np.arctan2(yc, xc) / (2 * np.pi) + 0.5), RES_U) # retrieve corresponding pixels from texture - brightness = texture[uu.astype(int), vv.astype(int)] / 4.0 + 0.5 + brightness = self.texture[uu.astype(int), vv.astype(int)] / 4.0 + 0.5 # use the brightness map to compose the final color of the tunnel rr = brightness * self.tex_r @@ -1253,7 +1245,7 @@ def on_draw(self) -> None: for light in self.lights: # render lights # move light's Z coordinate with time, then project its XYZ # coordinates to screen-space - light.z -= float(time_delta) / TEX_STRETCH + light.z -= time_delta / TEX_STRETCH xl = light.x / light.z * SCREEN_H yl = light.y / light.z * SCREEN_H From cb0d7d6cdce0f534c5f195288631083c35e4be7c Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Wed, 17 Dec 2025 18:13:16 -0800 Subject: [PATCH 18/78] Refactor eventget.py sample to use pattern matching --- examples/eventget.py | 40 +++++++++++++++++++--------------------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/examples/eventget.py b/examples/eventget.py index 7f4f9cb3..dd3e4058 100755 --- a/examples/eventget.py +++ b/examples/eventget.py @@ -8,12 +8,11 @@ import tcod.context import tcod.event import tcod.sdl.joystick -import tcod.sdl.sys -WIDTH, HEIGHT = 720, 480 +WIDTH, HEIGHT = 1280, 720 -def main() -> None: +def main() -> None: # noqa: C901, PLR0912 """Example program for tcod.event.""" event_log: list[str] = [] motion_desc = "" @@ -40,24 +39,23 @@ def main() -> None: for event in tcod.event.wait(): context.convert_event(event) # Set tile coordinates for event. print(repr(event)) - if isinstance(event, tcod.event.Quit): - raise SystemExit - if isinstance(event, tcod.event.WindowResized) and event.type == "WindowResized": - console = context.new_console() - if isinstance(event, tcod.event.ControllerDevice): - if event.type == "CONTROLLERDEVICEADDED": - controllers.add(event.controller) - elif event.type == "CONTROLLERDEVICEREMOVED": - controllers.remove(event.controller) - if isinstance(event, tcod.event.JoystickDevice): - if event.type == "JOYDEVICEADDED": - joysticks.add(event.joystick) - elif event.type == "JOYDEVICEREMOVED": - joysticks.remove(event.joystick) - if isinstance(event, tcod.event.MouseMotion): - motion_desc = str(event) - else: # Log all events other than MouseMotion. - event_log.append(str(event)) + match event: + case tcod.event.Quit(): + raise SystemExit + case tcod.event.WindowResized(type="WindowResized"): + console = context.new_console() + case tcod.event.ControllerDevice(type="CONTROLLERDEVICEADDED", controller=controller): + controllers.add(controller) + case tcod.event.ControllerDevice(type="CONTROLLERDEVICEREMOVED", controller=controller): + controllers.remove(controller) + case tcod.event.JoystickDevice(type="JOYDEVICEADDED", joystick=joystick): + joysticks.add(joystick) + case tcod.event.JoystickDevice(type="JOYDEVICEREMOVED", joystick=joystick): + joysticks.remove(joystick) + case tcod.event.MouseMotion(): + motion_desc = str(event) + case _: # Log all events other than MouseMotion. + event_log.append(repr(event)) if __name__ == "__main__": From 1b1e9f012d2f4077b778a737eccdaf35e0eed06b Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Sun, 21 Dec 2025 22:13:41 -0800 Subject: [PATCH 19/78] Remove deprecated EventDispatch from tcod samples Make BSP variables local --- examples/samples_tcod.py | 380 ++++++++++++++++++++------------------- 1 file changed, 195 insertions(+), 185 deletions(-) diff --git a/examples/samples_tcod.py b/examples/samples_tcod.py index bce9dda9..d8d79bea 100755 --- a/examples/samples_tcod.py +++ b/examples/samples_tcod.py @@ -34,14 +34,12 @@ import tcod.sdl.render import tcod.tileset from tcod import libtcodpy +from tcod.event import KeySym if TYPE_CHECKING: from numpy.typing import NDArray -if not sys.warnoptions: - warnings.simplefilter("default") # Show all warnings. - DATA_DIR = Path(__file__).parent / "../libtcod/data" """Path of the samples data directory.""" @@ -77,7 +75,7 @@ def _get_elapsed_time() -> float: return time.perf_counter() - START_TIME -class Sample(tcod.event.EventDispatch[None]): +class Sample: name: str = "???" def on_enter(self) -> None: @@ -86,31 +84,33 @@ def on_enter(self) -> None: def on_draw(self) -> None: pass - def ev_keydown(self, event: tcod.event.KeyDown) -> None: + def on_event(self, event: tcod.event.Event) -> None: global cur_sample - if event.sym == tcod.event.KeySym.DOWN: - cur_sample = (cur_sample + 1) % len(SAMPLES) - SAMPLES[cur_sample].on_enter() - draw_samples_menu() - elif event.sym == tcod.event.KeySym.UP: - cur_sample = (cur_sample - 1) % len(SAMPLES) - SAMPLES[cur_sample].on_enter() - draw_samples_menu() - elif event.sym == tcod.event.KeySym.RETURN and event.mod & tcod.event.Modifier.ALT: - sdl_window = context.sdl_window - if sdl_window: - sdl_window.fullscreen = not sdl_window.fullscreen - elif event.sym in (tcod.event.KeySym.PRINTSCREEN, tcod.event.KeySym.P): - print("screenshot") - if event.mod & tcod.event.Modifier.ALT: - libtcodpy.console_save_apf(root_console, "samples.apf") - print("apf") - else: - libtcodpy.sys_save_screenshot() - print("png") - elif event.sym in RENDERER_KEYS: - # Swap the active context for one with a different renderer. - init_context(RENDERER_KEYS[event.sym]) + match event: + case tcod.event.Quit() | tcod.event.KeyDown(sym=KeySym.ESCAPE): + raise SystemExit + case tcod.event.KeyDown(sym=KeySym.DOWN): + cur_sample = (cur_sample + 1) % len(SAMPLES) + SAMPLES[cur_sample].on_enter() + draw_samples_menu() + case tcod.event.KeyDown(sym=KeySym.UP): + cur_sample = (cur_sample - 1) % len(SAMPLES) + SAMPLES[cur_sample].on_enter() + draw_samples_menu() + case tcod.event.KeyDown(sym=KeySym.RETURN, mod=mod) if mod & tcod.event.Modifier.ALT: + sdl_window = context.sdl_window + if sdl_window: + sdl_window.fullscreen = not sdl_window.fullscreen + case tcod.event.KeyDown(sym=tcod.event.KeySym.PRINTSCREEN | tcod.event.KeySym.P): + print("screenshot") + if event.mod & tcod.event.Modifier.ALT: + libtcodpy.console_save_apf(root_console, "samples.apf") + print("apf") + else: + libtcodpy.sys_save_screenshot() + print("png") + case tcod.event.KeyDown(sym=sym) if sym in RENDERER_KEYS: + init_context(RENDERER_KEYS[sym]) # Swap the active context for one with a different renderer class TrueColorSample(Sample): @@ -273,13 +273,14 @@ def __init__(self) -> None: self.background.bg[:, :, 1] = (self.background.bg[:, :, 0].astype(int) + self.background.bg[:, :, 2]) / 2 self.background.ch[:] = ord(" ") - def ev_keydown(self, event: tcod.event.KeyDown) -> None: - if event.sym in (tcod.event.KeySym.RETURN, tcod.event.KeySym.KP_ENTER): - self.bk_flag += 1 - if (self.bk_flag & 0xFF) > libtcodpy.BKGND_ALPH: - self.bk_flag = libtcodpy.BKGND_NONE - else: - super().ev_keydown(event) + def on_event(self, event: tcod.event.Event) -> None: + match event: + case tcod.event.KeyDown(sym=KeySym.RETURN | KeySym.KP_ENTER): + self.bk_flag += 1 + if (self.bk_flag & 0xFF) > libtcodpy.BKGND_ALPH: + self.bk_flag = libtcodpy.BKGND_NONE + case _: + super().on_event(event) def on_draw(self) -> None: alpha = 0.0 @@ -464,34 +465,35 @@ def on_draw(self) -> None: bg=None, ) - def ev_keydown(self, event: tcod.event.KeyDown) -> None: - if tcod.event.KeySym.N9 >= event.sym >= tcod.event.KeySym.N1: - self.func = event.sym - tcod.event.KeySym.N1 - self.noise = self.get_noise() - elif event.sym == tcod.event.KeySym.E: - self.hurst += 0.1 - self.noise = self.get_noise() - elif event.sym == tcod.event.KeySym.D: - self.hurst -= 0.1 - self.noise = self.get_noise() - elif event.sym == tcod.event.KeySym.R: - self.lacunarity += 0.5 - self.noise = self.get_noise() - elif event.sym == tcod.event.KeySym.F: - self.lacunarity -= 0.5 - self.noise = self.get_noise() - elif event.sym == tcod.event.KeySym.T: - self.octaves += 0.5 - self.noise.octaves = self.octaves - elif event.sym == tcod.event.KeySym.G: - self.octaves -= 0.5 - self.noise.octaves = self.octaves - elif event.sym == tcod.event.KeySym.Y: - self.zoom += 0.2 - elif event.sym == tcod.event.KeySym.H: - self.zoom -= 0.2 - else: - super().ev_keydown(event) + def on_event(self, event: tcod.event.Event) -> None: + match event: + case tcod.event.KeyDown(sym=sym) if KeySym.N9 >= sym >= KeySym.N1: + self.func = sym - tcod.event.KeySym.N1 + self.noise = self.get_noise() + case tcod.event.KeyDown(sym=KeySym.E): + self.hurst += 0.1 + self.noise = self.get_noise() + case tcod.event.KeyDown(sym=KeySym.D): + self.hurst -= 0.1 + self.noise = self.get_noise() + case tcod.event.KeyDown(sym=KeySym.R): + self.lacunarity += 0.5 + self.noise = self.get_noise() + case tcod.event.KeyDown(sym=KeySym.F): + self.lacunarity -= 0.5 + self.noise = self.get_noise() + case tcod.event.KeyDown(sym=KeySym.T): + self.octaves += 0.5 + self.noise.octaves = self.octaves + case tcod.event.KeyDown(sym=KeySym.G): + self.octaves -= 0.5 + self.noise.octaves = self.octaves + case tcod.event.KeyDown(sym=KeySym.Y): + self.zoom += 0.2 + case tcod.event.KeyDown(sym=KeySym.H): + self.zoom -= 0.2 + case _: + super().on_event(event) ############################################# @@ -645,7 +647,7 @@ def on_draw(self) -> None: default=self.dark_map_bg, ) - def ev_keydown(self, event: tcod.event.KeyDown) -> None: + def on_event(self, event: tcod.event.Event) -> None: MOVE_KEYS = { # noqa: N806 tcod.event.KeySym.I: (0, -1), tcod.event.KeySym.J: (-1, 0), @@ -658,20 +660,21 @@ def ev_keydown(self, event: tcod.event.KeyDown) -> None: tcod.event.KeySym.KP_MINUS: -1, tcod.event.KeySym.KP_PLUS: 1, } - if event.sym in MOVE_KEYS: - x, y = MOVE_KEYS[event.sym] - if self.walkable[self.player_y + y, self.player_x + x]: - self.player_x += x - self.player_y += y - elif event.sym == tcod.event.KeySym.T: - self.torch = not self.torch - elif event.sym == tcod.event.KeySym.W: - self.light_walls = not self.light_walls - elif event.sym in FOV_SELECT_KEYS: - self.algo_num += FOV_SELECT_KEYS[event.sym] - self.algo_num %= len(FOV_ALGO_NAMES) - else: - super().ev_keydown(event) + match event: + case tcod.event.KeyDown(sym=sym) if sym in MOVE_KEYS: + x, y = MOVE_KEYS[sym] + if self.walkable[self.player_y + y, self.player_x + x]: + self.player_x += x + self.player_y += y + case tcod.event.KeyDown(sym=KeySym.T): + self.torch = not self.torch + case tcod.event.KeyDown(sym=KeySym.W): + self.light_walls = not self.light_walls + case tcod.event.KeyDown(sym=sym) if sym in FOV_SELECT_KEYS: + self.algo_num += FOV_SELECT_KEYS[sym] + self.algo_num %= len(FOV_ALGO_NAMES) + case _: + super().on_event(event) class PathfindingSample(Sample): @@ -737,39 +740,32 @@ def on_draw(self) -> None: self.player_y = int(path.item(0, 0)) self.player_x = int(path.item(0, 1)) - def ev_keydown(self, event: tcod.event.KeyDown) -> None: + def on_event(self, event: tcod.event.Event) -> None: """Handle movement and UI.""" - if event.sym == tcod.event.KeySym.I and self.dest_y > 0: # destination move north - self.dest_y -= 1 - elif event.sym == tcod.event.KeySym.K and self.dest_y < SAMPLE_SCREEN_HEIGHT - 1: # destination move south - self.dest_y += 1 - elif event.sym == tcod.event.KeySym.J and self.dest_x > 0: # destination move west - self.dest_x -= 1 - elif event.sym == tcod.event.KeySym.L and self.dest_x < SAMPLE_SCREEN_WIDTH - 1: # destination move east - self.dest_x += 1 - elif event.sym == tcod.event.KeySym.TAB: - self.using_astar = not self.using_astar - else: - super().ev_keydown(event) - - def ev_mousemotion(self, event: tcod.event.MouseMotion) -> None: - """Move destination via mouseover.""" - mx = event.tile.x - SAMPLE_SCREEN_X - my = event.tile.y - SAMPLE_SCREEN_Y - if 0 <= mx < SAMPLE_SCREEN_WIDTH and 0 <= my < SAMPLE_SCREEN_HEIGHT: - self.dest_x = int(mx) - self.dest_y = int(my) + match event: + case tcod.event.KeyDown(sym=KeySym.I) if self.dest_y > 0: # destination move north + self.dest_y -= 1 + case tcod.event.KeyDown(sym=KeySym.K) if self.dest_y < SAMPLE_SCREEN_HEIGHT - 1: # destination move south + self.dest_y += 1 + case tcod.event.KeyDown(sym=KeySym.J) if self.dest_x > 0: # destination move west + self.dest_x -= 1 + case tcod.event.KeyDown(sym=KeySym.L) if self.dest_x < SAMPLE_SCREEN_WIDTH - 1: # destination move east + self.dest_x += 1 + case tcod.event.KeyDown(sym=KeySym.TAB): + self.using_astar = not self.using_astar + case tcod.event.MouseMotion(): # Move destination via mouseover + mx = event.tile.x - SAMPLE_SCREEN_X + my = event.tile.y - SAMPLE_SCREEN_Y + if 0 <= mx < SAMPLE_SCREEN_WIDTH and 0 <= my < SAMPLE_SCREEN_HEIGHT: + self.dest_x = int(mx) + self.dest_y = int(my) + case _: + super().on_event(event) ############################################# # bsp sample ############################################# -bsp_depth = 8 -bsp_min_room_size = 4 -# a room fills a random part of the node or the maximum available space ? -bsp_random_room = False -# if true, there is always a wall on north & west side of a room -bsp_room_walls = True # draw a vertical line @@ -817,7 +813,14 @@ def hline_right(map_: NDArray[np.bool_], x: int, y: int) -> None: # the class building the dungeon from the bsp nodes -def traverse_node(bsp_map: NDArray[np.bool_], node: tcod.bsp.BSP) -> None: +def traverse_node( + bsp_map: NDArray[np.bool_], + node: tcod.bsp.BSP, + *, + bsp_min_room_size: int, + bsp_random_room: bool, + bsp_room_walls: bool, +) -> None: if not node.children: # calculate the room size if bsp_room_walls: @@ -879,46 +882,58 @@ class BSPSample(Sample): def __init__(self) -> None: self.bsp = tcod.bsp.BSP(1, 1, SAMPLE_SCREEN_WIDTH - 1, SAMPLE_SCREEN_HEIGHT - 1) self.bsp_map: NDArray[np.bool_] = np.zeros((SAMPLE_SCREEN_HEIGHT, SAMPLE_SCREEN_WIDTH), dtype=bool) + + self.bsp_depth = 8 + self.bsp_min_room_size = 4 + self.bsp_random_room = False # a room fills a random part of the node or the maximum available space ? + self.bsp_room_walls = True # if true, there is always a wall on north & west side of a room + self.bsp_generate() def bsp_generate(self) -> None: self.bsp.children = () - if bsp_room_walls: + if self.bsp_room_walls: self.bsp.split_recursive( - bsp_depth, - bsp_min_room_size + 1, - bsp_min_room_size + 1, + self.bsp_depth, + self.bsp_min_room_size + 1, + self.bsp_min_room_size + 1, 1.5, 1.5, ) else: - self.bsp.split_recursive(bsp_depth, bsp_min_room_size, bsp_min_room_size, 1.5, 1.5) + self.bsp.split_recursive(self.bsp_depth, self.bsp_min_room_size, self.bsp_min_room_size, 1.5, 1.5) self.bsp_refresh() def bsp_refresh(self) -> None: self.bsp_map[...] = False for node in copy.deepcopy(self.bsp).inverted_level_order(): - traverse_node(self.bsp_map, node) + traverse_node( + self.bsp_map, + node, + bsp_min_room_size=self.bsp_min_room_size, + bsp_random_room=self.bsp_random_room, + bsp_room_walls=self.bsp_room_walls, + ) def on_draw(self) -> None: sample_console.clear() rooms = "OFF" - if bsp_random_room: + if self.bsp_random_room: rooms = "ON" sample_console.print( 1, 1, "ENTER : rebuild bsp\n" "SPACE : rebuild dungeon\n" - f"+-: bsp depth {bsp_depth}\n" - f"*/: room size {bsp_min_room_size}\n" + f"+-: bsp depth {self.bsp_depth}\n" + f"*/: room size {self.bsp_min_room_size}\n" f"1 : random room size {rooms}", fg=WHITE, bg=None, ) - if bsp_random_room: + if self.bsp_random_room: walls = "OFF" - if bsp_room_walls: + if self.bsp_room_walls: walls = "ON" sample_console.print(1, 6, f"2 : room walls {walls}", fg=WHITE, bg=None) # render the level @@ -927,34 +942,34 @@ def on_draw(self) -> None: color = DARK_GROUND if self.bsp_map[y, x] else DARK_WALL libtcodpy.console_set_char_background(sample_console, x, y, color, libtcodpy.BKGND_SET) - def ev_keydown(self, event: tcod.event.KeyDown) -> None: - global bsp_random_room, bsp_room_walls, bsp_depth, bsp_min_room_size - if event.sym in (tcod.event.KeySym.RETURN, tcod.event.KeySym.KP_ENTER): - self.bsp_generate() - elif event.sym == tcod.event.KeySym.SPACE: - self.bsp_refresh() - elif event.sym in (tcod.event.KeySym.EQUALS, tcod.event.KeySym.KP_PLUS): - bsp_depth += 1 - self.bsp_generate() - elif event.sym in (tcod.event.KeySym.MINUS, tcod.event.KeySym.KP_MINUS): - bsp_depth = max(1, bsp_depth - 1) - self.bsp_generate() - elif event.sym in (tcod.event.KeySym.N8, tcod.event.KeySym.KP_MULTIPLY): - bsp_min_room_size += 1 - self.bsp_generate() - elif event.sym in (tcod.event.KeySym.SLASH, tcod.event.KeySym.KP_DIVIDE): - bsp_min_room_size = max(2, bsp_min_room_size - 1) - self.bsp_generate() - elif event.sym in (tcod.event.KeySym.N1, tcod.event.KeySym.KP_1): - bsp_random_room = not bsp_random_room - if not bsp_random_room: - bsp_room_walls = True - self.bsp_refresh() - elif event.sym in (tcod.event.KeySym.N2, tcod.event.KeySym.KP_2): - bsp_room_walls = not bsp_room_walls - self.bsp_refresh() - else: - super().ev_keydown(event) + def on_event(self, event: tcod.event.Event) -> None: + match event: + case tcod.event.KeyDown(sym=KeySym.RETURN | KeySym.KP_ENTER): + self.bsp_generate() + case tcod.event.KeyDown(sym=KeySym.SPACE): + self.bsp_refresh() + case tcod.event.KeyDown(sym=KeySym.EQUALS | KeySym.KP_PLUS): + self.bsp_depth += 1 + self.bsp_generate() + case tcod.event.KeyDown(sym=KeySym.MINUS | KeySym.KP_MINUS): + self.bsp_depth = max(1, self.bsp_depth - 1) + self.bsp_generate() + case tcod.event.KeyDown(sym=KeySym.N8 | KeySym.KP_MULTIPLY): + self.bsp_min_room_size += 1 + self.bsp_generate() + case tcod.event.KeyDown(sym=KeySym.SLASH | KeySym.KP_DIVIDE): + self.bsp_min_room_size = max(2, self.bsp_min_room_size - 1) + self.bsp_generate() + case tcod.event.KeyDown(sym=KeySym.N1 | KeySym.KP_1): + self.bsp_random_room = not self.bsp_random_room + if not self.bsp_random_room: + self.bsp_room_walls = True + self.bsp_refresh() + case tcod.event.KeyDown(sym=KeySym.N2 | KeySym.KP_2): + self.bsp_room_walls = not self.bsp_room_walls + self.bsp_refresh() + case _: + super().on_event(event) class ImageSample(Sample): @@ -1005,25 +1020,6 @@ def on_enter(self) -> None: tcod.sdl.mouse.warp_in_window(sdl_window, 320, 200) tcod.sdl.mouse.show(True) - def ev_mousemotion(self, event: tcod.event.MouseMotion) -> None: - self.motion = event - - def ev_mousebuttondown(self, event: tcod.event.MouseButtonDown) -> None: - if event.button == tcod.event.BUTTON_LEFT: - self.mouse_left = True - elif event.button == tcod.event.BUTTON_MIDDLE: - self.mouse_middle = True - elif event.button == tcod.event.BUTTON_RIGHT: - self.mouse_right = True - - def ev_mousebuttonup(self, event: tcod.event.MouseButtonUp) -> None: - if event.button == tcod.event.BUTTON_LEFT: - self.mouse_left = False - elif event.button == tcod.event.BUTTON_MIDDLE: - self.mouse_middle = False - elif event.button == tcod.event.BUTTON_RIGHT: - self.mouse_right = False - def on_draw(self) -> None: sample_console.clear(bg=GREY) sample_console.print( @@ -1046,13 +1042,28 @@ def on_draw(self) -> None: bg=None, ) - def ev_keydown(self, event: tcod.event.KeyDown) -> None: - if event.sym == tcod.event.KeySym.N1: - tcod.sdl.mouse.show(False) - elif event.sym == tcod.event.KeySym.N2: - tcod.sdl.mouse.show(True) - else: - super().ev_keydown(event) + def on_event(self, event: tcod.event.Event) -> None: + match event: + case tcod.event.MouseMotion(): + self.motion = event + case tcod.event.MouseButtonDown(button=tcod.event.MouseButton.LEFT): + self.mouse_left = True + case tcod.event.MouseButtonDown(button=tcod.event.MouseButton.MIDDLE): + self.mouse_middle = True + case tcod.event.MouseButtonDown(button=tcod.event.MouseButton.RIGHT): + self.mouse_right = True + case tcod.event.MouseButtonUp(button=tcod.event.MouseButton.LEFT): + self.mouse_left = False + case tcod.event.MouseButtonUp(button=tcod.event.MouseButton.MIDDLE): + self.mouse_middle = False + case tcod.event.MouseButtonUp(button=tcod.event.MouseButton.RIGHT): + self.mouse_right = False + case tcod.event.KeyDown(sym=KeySym.N1): + tcod.sdl.mouse.show(visible=False) + case tcod.event.KeyDown(sym=KeySym.N2): + tcod.sdl.mouse.show(visible=True) + case _: + super().on_event(event) class NameGeneratorSample(Sample): @@ -1097,15 +1108,16 @@ def on_draw(self) -> None: self.delay -= 0.5 self.names.append(libtcodpy.namegen_generate(self.sets[self.current_set])) - def ev_keydown(self, event: tcod.event.KeyDown) -> None: - if event.sym == tcod.event.KeySym.EQUALS: - self.current_set += 1 - self.names.append("======") - elif event.sym == tcod.event.KeySym.MINUS: - self.current_set -= 1 - self.names.append("======") - else: - super().ev_keydown(event) + def on_event(self, event: tcod.event.Event) -> None: + match event: + case tcod.event.KeyDown(sym=KeySym.EQUALS): + self.current_set += 1 + self.names.append("======") + case tcod.event.KeyDown(sym=KeySym.MINUS): + self.current_set -= 1 + self.names.append("======") + case _: + super().on_event(event) self.current_set %= len(self.sets) @@ -1416,11 +1428,7 @@ def handle_events() -> None: else: context.convert_event(event) - SAMPLES[cur_sample].dispatch(event) - if isinstance(event, tcod.event.Quit): - raise SystemExit - if isinstance(event, tcod.event.KeyDown) and event.sym == tcod.event.KeySym.ESCAPE: - raise SystemExit + SAMPLES[cur_sample].on_event(event) def draw_samples_menu() -> None: @@ -1481,6 +1489,8 @@ def draw_renderer_menu() -> None: if __name__ == "__main__": + if not sys.warnoptions: + warnings.simplefilter("default") # Show all warnings. @tcod.event.add_watch def _handle_events(event: tcod.event.Event) -> None: From 4f8125e8315ac5777752f454c82360bf31b9de55 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Mon, 22 Dec 2025 00:14:47 -0800 Subject: [PATCH 20/78] Mark Numpy type regression --- examples/samples_tcod.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/samples_tcod.py b/examples/samples_tcod.py index d8d79bea..0afad3ba 100755 --- a/examples/samples_tcod.py +++ b/examples/samples_tcod.py @@ -644,7 +644,7 @@ def on_draw(self) -> None: sample_console.bg[...] = np.select( condlist=[fov[:, :, np.newaxis]], choicelist=[self.light_map_bg], - default=self.dark_map_bg, + default=self.dark_map_bg, # type: ignore[call-overload] # Numpy regression https://github.com/numpy/numpy/issues/30497 ) def on_event(self, event: tcod.event.Event) -> None: From 730cde733f4e32f6b6f09e678e62e6aea02cc571 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Mon, 22 Dec 2025 19:36:16 -0800 Subject: [PATCH 21/78] Fix ambiguous cross reference of `type` Sphinx thinks this might be one of the Event attributes when it is actually a builtin type. Being more explicit seems to work for this simple case. Also switched `__exit__` parameters to be positional. --- tcod/sdl/audio.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tcod/sdl/audio.py b/tcod/sdl/audio.py index a2aa62d7..0cb7a9e9 100644 --- a/tcod/sdl/audio.py +++ b/tcod/sdl/audio.py @@ -54,6 +54,7 @@ from tcod.sdl._internal import _check, _check_float, _check_int, _check_p if TYPE_CHECKING: + import builtins from collections.abc import Callable, Hashable, Iterable, Iterator from types import TracebackType @@ -426,9 +427,10 @@ def __enter__(self) -> Self: def __exit__( self, - type: type[BaseException] | None, # noqa: A002 - value: BaseException | None, - traceback: TracebackType | None, + _type: builtins.type[BaseException] | None, # Explicit builtins prefix to disambiguate Sphinx cross-reference + _value: BaseException | None, + _traceback: TracebackType | None, + /, ) -> None: """Close the device when exiting the context.""" self.close() From e3b03e3b5cc8f56d5452e6adf6dae9d3e3f90a1c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Dec 2025 04:02:32 +0000 Subject: [PATCH 22/78] Bump the github-actions group with 2 updates Bumps the github-actions group with 2 updates: [actions/upload-artifact](https://github.com/actions/upload-artifact) and [actions/download-artifact](https://github.com/actions/download-artifact). Updates `actions/upload-artifact` from 5 to 6 - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v5...v6) Updates `actions/download-artifact` from 6 to 7 - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: actions/download-artifact dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] --- .github/workflows/python-package.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index c97b2c06..8b1c36dd 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -66,7 +66,7 @@ jobs: run: pip install build - name: Build source distribution run: python -m build --sdist - - uses: actions/upload-artifact@v5 + - uses: actions/upload-artifact@v6 with: name: sdist path: dist/tcod-*.tar.gz @@ -160,7 +160,7 @@ jobs: if: runner.os != 'Windows' run: cat /tmp/xvfb.log - uses: codecov/codecov-action@v5 - - uses: actions/upload-artifact@v5 + - uses: actions/upload-artifact@v6 if: runner.os == 'Windows' with: name: wheels-windows-${{ matrix.architecture }}-${{ matrix.python-version }} @@ -275,7 +275,7 @@ jobs: BUILD_DESC=${BUILD_DESC//\*} echo BUILD_DESC=${BUILD_DESC} >> $GITHUB_ENV - name: Archive wheel - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: wheels-linux-${{ matrix.arch }}-${{ env.BUILD_DESC }} path: wheelhouse/*.whl @@ -322,7 +322,7 @@ jobs: PYTHON_DESC=${PYTHON_DESC//\*/X} echo PYTHON_DESC=${PYTHON_DESC} >> $GITHUB_ENV - name: Archive wheel - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: wheels-macos-${{ env.PYTHON_DESC }} path: wheelhouse/*.whl @@ -349,7 +349,7 @@ jobs: CIBW_BUILD: cp313-pyodide_wasm32 CIBW_PLATFORM: pyodide - name: Archive wheel - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: pyodide path: wheelhouse/*.whl @@ -367,11 +367,11 @@ jobs: permissions: id-token: write steps: - - uses: actions/download-artifact@v6 + - uses: actions/download-artifact@v7 with: name: sdist path: dist/ - - uses: actions/download-artifact@v6 + - uses: actions/download-artifact@v7 with: pattern: wheels-* path: dist/ From 740c432bc24d0458e0736bf876163a7a5bade59a Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Mon, 5 Jan 2026 11:34:56 -0800 Subject: [PATCH 23/78] Update copyright year [skip ci] --- LICENSE.txt | 2 +- docs/conf.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/LICENSE.txt b/LICENSE.txt index ed980457..8af4c736 100755 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,6 +1,6 @@ BSD 2-Clause License -Copyright (c) 2009-2025, Kyle Benesch and the python-tcod contributors. +Copyright (c) 2009-2026, Kyle Benesch and the python-tcod contributors. All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/docs/conf.py b/docs/conf.py index c107d387..ebca4ee1 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -65,7 +65,7 @@ # General information about the project. project = "python-tcod" -copyright = "2009-2025, Kyle Benesch" # noqa: A001 +copyright = "2009-2026, Kyle Benesch" # noqa: A001 author = "Kyle Benesch" # The version info for the project you're documenting, acts as replacement for From a778c2a0746b1da2d6402cad355b5d388bf47704 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 5 Jan 2026 20:18:15 +0000 Subject: [PATCH 24/78] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.13.3 → v0.14.10](https://github.com/astral-sh/ruff-pre-commit/compare/v0.13.3...v0.14.10) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e7a67182..f04621d0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,7 +17,7 @@ repos: - id: fix-byte-order-marker - id: detect-private-key - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.13.3 + rev: v0.14.10 hooks: - id: ruff-check args: [--fix-only, --exit-non-zero-on-fix] From 4d58eb4a9889f37529a45d9a88098142ca933965 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Tue, 6 Jan 2026 04:11:32 -0800 Subject: [PATCH 25/78] Update to libtcod 2.2.2 --- CHANGELOG.md | 8 ++++++++ libtcod | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9836d283..da9a3c46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version ## [Unreleased] +### Changed + +- Update to libtcod 2.2.2 + +### Fixed + +- Mouse coordinate to tile conversions now support SDL renderer logical size and scaling. + ## [19.6.1] - 2025-12-15 ### Fixed diff --git a/libtcod b/libtcod index ca8efa70..27c2dbc9 160000 --- a/libtcod +++ b/libtcod @@ -1 +1 @@ -Subproject commit ca8efa70c50175c6d24336f9bc84cf995f4dbef4 +Subproject commit 27c2dbc9d97bacb18b9fd43a5c7f070dc34339ed From 440b7b520cc8a58fd709164b55dd31f671877a60 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Sun, 11 Jan 2026 17:13:39 -0800 Subject: [PATCH 26/78] Prepare 19.6.2 release. --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index da9a3c46..7bab34c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version ## [Unreleased] +## [19.6.2] - 2026-01-12 + ### Changed - Update to libtcod 2.2.2 From 63605bb11a60095d8d3fcef19ce8dc624a70e10e Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Sun, 11 Jan 2026 17:21:03 -0800 Subject: [PATCH 27/78] Numpy type issue was resolved upstream --- examples/samples_tcod.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/samples_tcod.py b/examples/samples_tcod.py index 0afad3ba..d8d79bea 100755 --- a/examples/samples_tcod.py +++ b/examples/samples_tcod.py @@ -644,7 +644,7 @@ def on_draw(self) -> None: sample_console.bg[...] = np.select( condlist=[fov[:, :, np.newaxis]], choicelist=[self.light_map_bg], - default=self.dark_map_bg, # type: ignore[call-overload] # Numpy regression https://github.com/numpy/numpy/issues/30497 + default=self.dark_map_bg, ) def on_event(self, event: tcod.event.Event) -> None: From cfefe20f04952d2e6a091173aef16095fae3a7bb Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Sun, 11 Jan 2026 17:26:37 -0800 Subject: [PATCH 28/78] Prepare 19.6.3 release. --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bab34c5..e124797c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version ## [Unreleased] +## [19.6.3] - 2026-01-12 + +Fix missing deployment + ## [19.6.2] - 2026-01-12 ### Changed From 407b3e2ee3b3586cb9df1da8f52f2e6503dbb882 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Mon, 12 Jan 2026 03:15:57 -0800 Subject: [PATCH 29/78] Update classifiers Python 3 is generally supported but specific versions are not tested Add pyproject.toml spelling --- .vscode/settings.json | 12 ++++++++++++ pyproject.toml | 9 ++++----- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 71b8b1cf..27be3c92 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -20,6 +20,7 @@ "aarch", "ADDA", "ADDALPHA", + "addopts", "addressof", "addsub", "addx", @@ -128,8 +129,10 @@ "devel", "DHLINE", "DISPLAYSWITCH", + "distutils", "dlopen", "docstrings", + "doctest", "documentclass", "Doryen", "DPAD", @@ -151,10 +154,12 @@ "errorvf", "EXCLAM", "EXSEL", + "faulthandler", "favicon", "ffade", "fgcolor", "fheight", + "filterwarnings", "Flecs", "flto", "fmean", @@ -166,6 +171,7 @@ "furo", "fwidth", "GAMECONTROLLER", + "gamedev", "gamepad", "gaxis", "gbutton", @@ -296,6 +302,7 @@ "mgrid", "milli", "minmax", + "minversion", "mipmap", "mipmaps", "MMASK", @@ -363,6 +370,7 @@ "pushdown", "pycall", "pycparser", + "pydocstyle", "pyinstaller", "pyodide", "pypa", @@ -399,6 +407,7 @@ "RMASK", "rmeta", "roguelike", + "roguelikedev", "rpath", "RRGGBB", "rtype", @@ -441,6 +450,7 @@ "stdeb", "struct", "structs", + "subclassing", "SUBP", "SYSREQ", "tablefmt", @@ -451,6 +461,8 @@ "TCODLIB", "TEEE", "TEEW", + "termbox", + "testpaths", "TEXTUREACCESS", "thirdparty", "THOUSANDSSEPARATOR", diff --git a/pyproject.toml b/pyproject.toml index ea098b4e..11ed76e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,9 +34,11 @@ dependencies = [ ] keywords = [ "roguelike", - "cffi", + "roguelikedev", + "gamedev", "Unicode", "libtcod", + "libtcodpy", "field-of-view", "pathfinding", ] @@ -46,15 +48,12 @@ classifiers = [ "Environment :: MacOS X", "Environment :: X11 Applications", "Intended Audience :: Developers", + "License :: OSI Approved :: BSD License", "Natural Language :: English", "Operating System :: POSIX", "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Games/Entertainment", From 043637d8d32548272674c8496839f53305e1d341 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Mon, 12 Jan 2026 03:20:59 -0800 Subject: [PATCH 30/78] Remove outdated setting --- .vscode/settings.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 27be3c92..8f592ed3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -542,7 +542,4 @@ "[python]": { "editor.defaultFormatter": "charliermarsh.ruff" }, - "cSpell.enableFiletypes": [ - "github-actions-workflow" - ] } From a0bcee845e46bd9d50ec14e76420be3535063087 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Mon, 12 Jan 2026 03:30:10 -0800 Subject: [PATCH 31/78] Fix classifiers The license classifier exists but is not meant to be used anymore, I'll probably make this mistake again. --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 11ed76e6..704dc0c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,6 @@ classifiers = [ "Environment :: MacOS X", "Environment :: X11 Applications", "Intended Audience :: Developers", - "License :: OSI Approved :: BSD License", "Natural Language :: English", "Operating System :: POSIX", "Operating System :: MacOS :: MacOS X", From 10831856044543dd769c584f4d1e737a12806906 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Tue, 13 Jan 2026 10:29:12 -0800 Subject: [PATCH 32/78] Clean up more warnings Mostly suppressing old unfixable API issues Minor type improvements with map copying and tile accessing --- .vscode/settings.json | 14 ++++ build_libtcod.py | 27 ++++--- build_sdl.py | 10 +-- examples/cavegen.py | 2 +- tcod/color.py | 2 + tcod/console.py | 2 +- tcod/context.py | 6 +- tcod/image.py | 2 +- tcod/libtcodpy.py | 156 ++++++++++++++++++++-------------------- tcod/map.py | 29 ++++---- tcod/noise.py | 7 +- tcod/path.py | 11 +-- tcod/sdl/_internal.py | 4 +- tcod/sdl/joystick.py | 10 +-- tcod/sdl/mouse.py | 11 +-- tcod/sdl/sys.py | 2 +- tcod/sdl/video.py | 4 +- tcod/tileset.py | 2 +- tests/conftest.py | 23 +++--- tests/test_console.py | 10 +-- tests/test_libtcodpy.py | 74 +++++++++---------- tests/test_parser.py | 26 +++---- tests/test_sdl.py | 10 +-- tests/test_sdl_audio.py | 4 +- tests/test_tcod.py | 2 +- 25 files changed, 242 insertions(+), 208 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 8f592ed3..8226a81d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -20,6 +20,7 @@ "aarch", "ADDA", "ADDALPHA", + "addoption", "addopts", "addressof", "addsub", @@ -146,6 +147,7 @@ "DVLINE", "elif", "Emscripten", + "EMSDK", "ENDCALL", "endianness", "epel", @@ -207,6 +209,7 @@ "IJKL", "imageio", "imread", + "includepath", "INCOL", "INPUTTYPE", "INROW", @@ -308,6 +311,7 @@ "MMASK", "modindex", "moduleauthor", + "modversion", "MOUSEBUTTONDOWN", "MOUSEBUTTONUP", "MOUSEMOTION", @@ -328,6 +332,7 @@ "neww", "noarchive", "NODISCARD", + "NOLONGLONG", "NOMESSAGE", "Nonrepresentable", "NONUSBACKSLASH", @@ -350,6 +355,7 @@ "pagerefs", "PAGEUP", "papersize", + "passthru", "PATCHLEVEL", "pathfinding", "pathlib", @@ -361,6 +367,7 @@ "PIXELFORMAT", "PLUSMINUS", "pointsize", + "popleft", "PRESENTVSYNC", "PRINTF", "printn", @@ -378,6 +385,7 @@ "pypiwin", "pypy", "pytest", + "pytestmark", "PYTHONHASHSEED", "PYTHONOPTIMIZE", "Pyup", @@ -393,6 +401,7 @@ "Redistributable", "redistributables", "repr", + "rexpaint", "rgba", "RGUI", "RHYPER", @@ -447,6 +456,8 @@ "sphinxstrong", "sphinxtitleref", "staticmethod", + "stdarg", + "stddef", "stdeb", "struct", "structs", @@ -498,6 +509,7 @@ "VERTICALBAR", "vflip", "viewcode", + "VITAFILE", "vline", "VOLUMEDOWN", "VOLUMEUP", @@ -508,6 +520,7 @@ "WAITARROW", "WASD", "waterlevel", + "WINAPI", "windowclose", "windowenter", "WINDOWEVENT", @@ -526,6 +539,7 @@ "windowshown", "windowsizechanged", "windowtakefocus", + "xcframework", "Xcursor", "xdst", "Xext", diff --git a/build_libtcod.py b/build_libtcod.py index 73d86ea3..2b9d03d6 100755 --- a/build_libtcod.py +++ b/build_libtcod.py @@ -11,9 +11,8 @@ import re import subprocess import sys -from collections.abc import Iterable, Iterator from pathlib import Path -from typing import Any, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar, Final import attrs import pycparser # type: ignore[import-untyped] @@ -27,6 +26,9 @@ import build_sdl +if TYPE_CHECKING: + from collections.abc import Iterable, Iterator + Py_LIMITED_API = 0x03100000 HEADER_PARSE_PATHS = ("tcod/", "libtcod/src/libtcod/") @@ -269,7 +271,7 @@ def find_sdl_attrs(prefix: str) -> Iterator[tuple[str, int | str | Any]]: `prefix` is used to filter out which names to copy. """ - from tcod._libtcod import lib + from tcod._libtcod import lib # noqa: PLC0415 if prefix.startswith("SDL_"): name_starts_at = 4 @@ -320,12 +322,14 @@ def parse_sdl_attrs(prefix: str, all_names: list[str] | None) -> tuple[str, str] ] +RE_CONSTANTS_ALL: Final = re.compile( + r"(.*# --- From constants.py ---).*(# --- End constants.py ---.*)", + re.DOTALL, +) + + def update_module_all(filename: Path, new_all: str) -> None: """Update the __all__ of a file with the constants from new_all.""" - RE_CONSTANTS_ALL = re.compile( - r"(.*# --- From constants.py ---).*(# --- End constants.py ---.*)", - re.DOTALL, - ) match = RE_CONSTANTS_ALL.match(filename.read_text(encoding="utf-8")) assert match, f"Can't determine __all__ subsection in {filename}!" header, footer = match.groups() @@ -346,8 +350,8 @@ def generate_enums(prefix: str) -> Iterator[str]: def write_library_constants() -> None: """Write libtcod constants into the tcod.constants module.""" - import tcod.color - from tcod._libtcod import ffi, lib + import tcod.color # noqa: PLC0415 + from tcod._libtcod import ffi, lib # noqa: PLC0415 with Path("tcod/constants.py").open("w", encoding="utf-8") as f: all_names = [] @@ -441,6 +445,8 @@ def _fix_reserved_name(name: str) -> str: @attrs.define(frozen=True) class ConvertedParam: + """Converted type parameter from C types into Python type-hints.""" + name: str = attrs.field(converter=_fix_reserved_name) hint: str original: str @@ -460,7 +466,8 @@ def _type_from_names(names: list[str]) -> str: return "Any" -def _param_as_hint(node: pycparser.c_ast.Node, default_name: str) -> ConvertedParam: +def _param_as_hint(node: pycparser.c_ast.Node, default_name: str) -> ConvertedParam: # noqa: PLR0911 + """Return a Python type-hint from a C AST node.""" original = pycparser.c_generator.CGenerator().visit(node) name: str names: list[str] diff --git a/build_sdl.py b/build_sdl.py index 2fb39579..dbd49bd5 100755 --- a/build_sdl.py +++ b/build_sdl.py @@ -139,8 +139,8 @@ def check_sdl_version() -> None: sdl_version_str = subprocess.check_output(["sdl3-config", "--version"], universal_newlines=True).strip() except FileNotFoundError as exc: msg = ( - f"libsdl3-dev or equivalent must be installed on your system and must be at least version {needed_version}." - "\nsdl3-config must be on PATH." + f"libsdl3-dev or equivalent must be installed on your system and must be at least version {needed_version}.\n" + "sdl3-config must be on PATH." ) raise RuntimeError(msg) from exc except subprocess.CalledProcessError as exc: @@ -223,8 +223,8 @@ def on_include_not_found(self, is_malformed: bool, is_system_include: bool, curd assert "SDL3/SDL" not in includepath, (includepath, curdir) raise pcpp.OutputDirective(pcpp.Action.IgnoreAndRemove) - def _should_track_define(self, tokens: list[Any]) -> bool: - if len(tokens) < 3: + def _should_track_define(self, tokens: list[Any]) -> bool: # noqa: PLR0911 + if len(tokens) < 3: # noqa: PLR2004 return False if tokens[0].value in IGNORE_DEFINES: return False @@ -236,7 +236,7 @@ def _should_track_define(self, tokens: list[Any]) -> bool: return False # Likely calls a private function. if tokens[1].type == "CPP_LPAREN": return False # Function-like macro. - if len(tokens) >= 4 and tokens[2].type == "CPP_INTEGER" and tokens[3].type == "CPP_DOT": + if len(tokens) >= 4 and tokens[2].type == "CPP_INTEGER" and tokens[3].type == "CPP_DOT": # noqa: PLR2004 return False # Value is a floating point number. if tokens[0].value.startswith("SDL_PR") and (tokens[0].value.endswith("32") or tokens[0].value.endswith("64")): return False # Data type for printing, which is not needed. diff --git a/examples/cavegen.py b/examples/cavegen.py index 8b95d3fc..ace6bb98 100755 --- a/examples/cavegen.py +++ b/examples/cavegen.py @@ -10,7 +10,7 @@ from typing import Any import numpy as np -import scipy.signal # type: ignore +import scipy.signal # type: ignore[import-untyped] from numpy.typing import NDArray diff --git a/tcod/color.py b/tcod/color.py index 9fc6f260..bf9dabb3 100644 --- a/tcod/color.py +++ b/tcod/color.py @@ -89,6 +89,8 @@ def __setitem__(self, index: Any, value: Any) -> None: # noqa: ANN401, D105 else: super().__setitem__(index, value) + __hash__ = None + def __eq__(self, other: object) -> bool: """Compare equality between colors. diff --git a/tcod/console.py b/tcod/console.py index b42c27a1..bfcbce00 100644 --- a/tcod/console.py +++ b/tcod/console.py @@ -8,7 +8,6 @@ from __future__ import annotations import warnings -from collections.abc import Iterable from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, overload @@ -22,6 +21,7 @@ from tcod.cffi import ffi, lib if TYPE_CHECKING: + from collections.abc import Iterable from os import PathLike from numpy.typing import ArrayLike, NDArray diff --git a/tcod/context.py b/tcod/context.py index 2668a120..f152b9d0 100644 --- a/tcod/context.py +++ b/tcod/context.py @@ -29,9 +29,8 @@ import pickle import sys import warnings -from collections.abc import Iterable from pathlib import Path -from typing import Any, Literal, NoReturn, TypeVar +from typing import TYPE_CHECKING, Any, Literal, NoReturn, TypeVar from typing_extensions import Self, deprecated @@ -44,6 +43,9 @@ from tcod._internal import _check, _check_warn from tcod.cffi import ffi, lib +if TYPE_CHECKING: + from collections.abc import Iterable + __all__ = ( "RENDERER_OPENGL", "RENDERER_OPENGL2", diff --git a/tcod/image.py b/tcod/image.py index 6b4a1083..0f154450 100644 --- a/tcod/image.py +++ b/tcod/image.py @@ -71,7 +71,7 @@ def from_array(cls, array: ArrayLike) -> Image: .. versionadded:: 11.4 """ array = np.asarray(array, dtype=np.uint8) - height, width, depth = array.shape + height, width, _depth = array.shape image = cls(width, height) image_array: NDArray[np.uint8] = np.asarray(image) image_array[...] = array diff --git a/tcod/libtcodpy.py b/tcod/libtcodpy.py index c6b591cc..1948b550 100644 --- a/tcod/libtcodpy.py +++ b/tcod/libtcodpy.py @@ -6,7 +6,6 @@ import sys import threading import warnings -from collections.abc import Callable, Hashable, Iterable, Iterator, Sequence from pathlib import Path from typing import TYPE_CHECKING, Any, Literal @@ -54,6 +53,7 @@ ) if TYPE_CHECKING: + from collections.abc import Callable, Hashable, Iterable, Iterator, Sequence from os import PathLike from numpy.typing import NDArray @@ -69,15 +69,15 @@ NOISE_DEFAULT_LACUNARITY = 2.0 -def FOV_PERMISSIVE(p: int) -> int: +def FOV_PERMISSIVE(p: int) -> int: # noqa: N802 return FOV_PERMISSIVE_0 + p -def BKGND_ALPHA(a: int) -> int: +def BKGND_ALPHA(a: int) -> int: # noqa: N802 return BKGND_ALPH | (int(a * 255) << 8) -def BKGND_ADDALPHA(a: int) -> int: +def BKGND_ADDALPHA(a: int) -> int: # noqa: N802 return BKGND_ADDA | (int(a * 255) << 8) @@ -364,14 +364,14 @@ def __init__( vk: int = 0, c: int = 0, text: str = "", - pressed: bool = False, - lalt: bool = False, - lctrl: bool = False, - lmeta: bool = False, - ralt: bool = False, - rctrl: bool = False, - rmeta: bool = False, - shift: bool = False, + pressed: bool = False, # noqa: FBT001, FBT002 + lalt: bool = False, # noqa: FBT001, FBT002 + lctrl: bool = False, # noqa: FBT001, FBT002 + lmeta: bool = False, # noqa: FBT001, FBT002 + ralt: bool = False, # noqa: FBT001, FBT002 + rctrl: bool = False, # noqa: FBT001, FBT002 + rmeta: bool = False, # noqa: FBT001, FBT002 + shift: bool = False, # noqa: FBT001, FBT002 ) -> None: if isinstance(vk, ffi.CData): self.cdata = vk @@ -424,7 +424,7 @@ def __repr__(self) -> str: "rmeta", ]: if getattr(self, attr): - params.append(f"{attr}={getattr(self, attr)!r}") + params.append(f"{attr}={getattr(self, attr)!r}") # noqa: PERF401 return "libtcodpy.Key({})".format(", ".join(params)) @property @@ -502,7 +502,7 @@ def __repr__(self) -> str: "wheel_down", ]: if getattr(self, attr): - params.append(f"{attr}={getattr(self, attr)!r}") + params.append(f"{attr}={getattr(self, attr)!r}") # noqa: PERF401 return "libtcodpy.Mouse({})".format(", ".join(params)) @property @@ -530,7 +530,7 @@ def bsp_new_with_size(x: int, y: int, w: int, h: int) -> tcod.bsp.BSP: @deprecate("Call node.split_once instead.", category=FutureWarning) -def bsp_split_once(node: tcod.bsp.BSP, horizontal: bool, position: int) -> None: +def bsp_split_once(node: tcod.bsp.BSP, horizontal: bool, position: int) -> None: # noqa: FBT001 """Deprecated function. .. deprecated:: 2.0 @@ -544,10 +544,10 @@ def bsp_split_recursive( node: tcod.bsp.BSP, randomizer: tcod.random.Random | None, nb: int, - minHSize: int, - minVSize: int, - maxHRatio: float, - maxVRatio: float, + minHSize: int, # noqa: N803 + minVSize: int, # noqa: N803 + maxHRatio: float, # noqa: N803 + maxVRatio: float, # noqa: N803 ) -> None: """Deprecated function. @@ -633,7 +633,7 @@ def bsp_find_node(node: tcod.bsp.BSP, cx: int, cy: int) -> tcod.bsp.BSP | None: def _bsp_traverse( node_iter: Iterable[tcod.bsp.BSP], callback: Callable[[tcod.bsp.BSP, Any], None], - userData: Any, + userData: Any, # noqa: N803 ) -> None: """Pack callback into a handle for use with the callback _pycall_bsp_callback.""" for node in node_iter: @@ -644,7 +644,7 @@ def _bsp_traverse( def bsp_traverse_pre_order( node: tcod.bsp.BSP, callback: Callable[[tcod.bsp.BSP, Any], None], - userData: Any = 0, + userData: Any = 0, # noqa: N803 ) -> None: """Traverse this nodes hierarchy with a callback. @@ -658,7 +658,7 @@ def bsp_traverse_pre_order( def bsp_traverse_in_order( node: tcod.bsp.BSP, callback: Callable[[tcod.bsp.BSP, Any], None], - userData: Any = 0, + userData: Any = 0, # noqa: N803 ) -> None: """Traverse this nodes hierarchy with a callback. @@ -672,7 +672,7 @@ def bsp_traverse_in_order( def bsp_traverse_post_order( node: tcod.bsp.BSP, callback: Callable[[tcod.bsp.BSP, Any], None], - userData: Any = 0, + userData: Any = 0, # noqa: N803 ) -> None: """Traverse this nodes hierarchy with a callback. @@ -686,7 +686,7 @@ def bsp_traverse_post_order( def bsp_traverse_level_order( node: tcod.bsp.BSP, callback: Callable[[tcod.bsp.BSP, Any], None], - userData: Any = 0, + userData: Any = 0, # noqa: N803 ) -> None: """Traverse this nodes hierarchy with a callback. @@ -700,7 +700,7 @@ def bsp_traverse_level_order( def bsp_traverse_inverted_level_order( node: tcod.bsp.BSP, callback: Callable[[tcod.bsp.BSP, Any], None], - userData: Any = 0, + userData: Any = 0, # noqa: N803 ) -> None: """Traverse this nodes hierarchy with a callback. @@ -792,7 +792,7 @@ def color_get_hsv(c: tuple[int, int, int]) -> tuple[float, float, float]: @pending_deprecate() -def color_scale_HSV(c: Color, scoef: float, vcoef: float) -> None: +def color_scale_HSV(c: Color, scoef: float, vcoef: float) -> None: # noqa: N802 """Scale a color's saturation and value. Does not return a new Color. ``c`` is modified in-place. @@ -848,10 +848,10 @@ def console_init_root( w: int, h: int, title: str | None = None, - fullscreen: bool = False, + fullscreen: bool = False, # noqa: FBT001, FBT002 renderer: int | None = None, order: Literal["C", "F"] = "C", - vsync: bool | None = None, + vsync: bool | None = None, # noqa: FBT001 ) -> tcod.console.Console: """Set up the primary display and return the root console. @@ -947,7 +947,7 @@ def console_init_root( https://python-tcod.readthedocs.io/en/latest/tcod/getting-started.html""" ) def console_set_custom_font( - fontFile: str | PathLike[str], + fontFile: str | PathLike[str], # noqa: N803 flags: int = FONT_LAYOUT_ASCII_INCOL, nb_char_horiz: int = 0, nb_char_vertic: int = 0, @@ -980,7 +980,7 @@ def console_set_custom_font( .. versionchanged:: 16.0 Added PathLike support. `fontFile` no longer takes bytes. """ - fontFile = Path(fontFile).resolve(strict=True) + fontFile = Path(fontFile).resolve(strict=True) # noqa: N806 _check(lib.TCOD_console_set_custom_font(_path_encode(fontFile), flags, nb_char_horiz, nb_char_vertic)) @@ -1017,7 +1017,7 @@ def console_get_height(con: tcod.console.Console) -> int: @deprecate("Setup fonts using the tcod.tileset module.") -def console_map_ascii_code_to_font(asciiCode: int, fontCharX: int, fontCharY: int) -> None: +def console_map_ascii_code_to_font(asciiCode: int, fontCharX: int, fontCharY: int) -> None: # noqa: N803 """Set a character code to new coordinates on the tile-set. `asciiCode` should be any Unicode codepoint. @@ -1037,7 +1037,7 @@ def console_map_ascii_code_to_font(asciiCode: int, fontCharX: int, fontCharY: in @deprecate("Setup fonts using the tcod.tileset module.") -def console_map_ascii_codes_to_font(firstAsciiCode: int, nbCodes: int, fontCharX: int, fontCharY: int) -> None: +def console_map_ascii_codes_to_font(firstAsciiCode: int, nbCodes: int, fontCharX: int, fontCharY: int) -> None: # noqa: N803 """Remap a contiguous set of codes to a contiguous set of tiles. Both the tile-set and character codes must be contiguous to use this @@ -1061,7 +1061,7 @@ def console_map_ascii_codes_to_font(firstAsciiCode: int, nbCodes: int, fontCharX @deprecate("Setup fonts using the tcod.tileset module.") -def console_map_string_to_font(s: str, fontCharX: int, fontCharY: int) -> None: +def console_map_string_to_font(s: str, fontCharX: int, fontCharY: int) -> None: # noqa: N803 r"""Remap a string of codes to a contiguous set of tiles. Args: @@ -1093,7 +1093,7 @@ def console_is_fullscreen() -> bool: @deprecate("This function is not supported if contexts are being used.") -def console_set_fullscreen(fullscreen: bool) -> None: +def console_set_fullscreen(fullscreen: bool) -> None: # noqa: FBT001 """Change the display to be fullscreen or windowed. Args: @@ -1158,7 +1158,7 @@ def console_credits_reset() -> None: lib.TCOD_console_credits_reset() -def console_credits_render(x: int, y: int, alpha: bool) -> bool: +def console_credits_render(x: int, y: int, alpha: bool) -> bool: # noqa: FBT001 return bool(lib.TCOD_console_credits_render(x, y, alpha)) @@ -1537,7 +1537,7 @@ def console_rect( y: int, w: int, h: int, - clr: bool, + clr: bool, # noqa: FBT001 flag: int = BKGND_DEFAULT, ) -> None: """Draw a the background color on a rect optionally clearing the text. @@ -1593,7 +1593,7 @@ def console_print_frame( y: int, w: int, h: int, - clear: bool = True, + clear: bool = True, # noqa: FBT001, FBT002 flag: int = BKGND_DEFAULT, fmt: str = "", ) -> None: @@ -1683,7 +1683,7 @@ def console_get_char(con: tcod.console.Console, x: int, y: int) -> int: @deprecate("This function is not supported if contexts are being used.", category=FutureWarning) -def console_set_fade(fade: int, fadingColor: tuple[int, int, int]) -> None: +def console_set_fade(fade: int, fadingColor: tuple[int, int, int]) -> None: # noqa: N803 """Deprecated function. .. deprecated:: 11.13 @@ -1714,7 +1714,7 @@ def console_get_fading_color() -> Color: # handling keyboard input @deprecate("Use the tcod.event.wait function to wait for events.") -def console_wait_for_keypress(flush: bool) -> Key: +def console_wait_for_keypress(flush: bool) -> Key: # noqa: FBT001 """Block until the user presses a key, then returns a new Key. Args: @@ -2119,7 +2119,7 @@ def path_new_using_function( w: int, h: int, func: Callable[[int, int, int, int, Any], float], - userData: Any = 0, + userData: Any = 0, # noqa: N803 dcost: float = 1.41, ) -> tcod.path.AStar: """Return a new AStar using the given callable function. @@ -2242,7 +2242,7 @@ def path_is_empty(p: tcod.path.AStar) -> bool: @pending_deprecate() -def path_walk(p: tcod.path.AStar, recompute: bool) -> tuple[int, int] | tuple[None, None]: +def path_walk(p: tcod.path.AStar, recompute: bool) -> tuple[int, int] | tuple[None, None]: # noqa: FBT001 """Return the next (x, y) point in a path, or (None, None) if it's empty. When ``recompute`` is True and a previously valid path reaches a point @@ -2281,7 +2281,7 @@ def dijkstra_new_using_function( w: int, h: int, func: Callable[[int, int, int, int, Any], float], - userData: Any = 0, + userData: Any = 0, # noqa: N803 dcost: float = 1.41, ) -> tcod.path.Dijkstra: return tcod.path.Dijkstra(tcod.path._EdgeCostFunc((func, userData), (w, h)), dcost) @@ -2344,7 +2344,7 @@ def dijkstra_delete(p: tcod.path.Dijkstra) -> None: """ -def _heightmap_cdata(array: NDArray[np.float32]) -> ffi.CData: +def _heightmap_cdata(array: NDArray[np.float32]) -> Any: """Return a new TCOD_heightmap_t instance using an array. Formatting is verified during this function. @@ -2599,9 +2599,9 @@ def heightmap_dig_hill(hm: NDArray[np.float32], x: float, y: float, radius: floa @pending_deprecate() def heightmap_rain_erosion( hm: NDArray[np.float32], - nbDrops: int, - erosionCoef: float, - sedimentationCoef: float, + nbDrops: int, # noqa: N803 + erosionCoef: float, # noqa: N803 + sedimentationCoef: float, # noqa: N803 rnd: tcod.random.Random | None = None, ) -> None: """Simulate the effect of rain drops on the terrain, resulting in erosion. @@ -2632,8 +2632,8 @@ def heightmap_kernel_transform( dx: Sequence[int], dy: Sequence[int], weight: Sequence[float], - minLevel: float, - maxLevel: float, + minLevel: float, # noqa: N803 + maxLevel: float, # noqa: N803 ) -> None: """Apply a generic transformation on the map, so that each resulting cell value is the weighted sum of several neighbor cells. @@ -2684,8 +2684,8 @@ def heightmap_kernel_transform( @pending_deprecate() def heightmap_add_voronoi( hm: NDArray[np.float32], - nbPoints: Any, - nbCoef: int, + nbPoints: Any, # noqa: N803 + nbCoef: int, # noqa: N803 coef: Sequence[float], rnd: tcod.random.Random | None = None, ) -> None: @@ -2702,7 +2702,7 @@ def heightmap_add_voronoi( second closest site : coef[1], ... rnd (Optional[Random]): A Random instance, or None. """ - nbPoints = len(coef) + nbPoints = len(coef) # noqa: N806 ccoef = ffi.new("float[]", coef) lib.TCOD_heightmap_add_voronoi( _heightmap_cdata(hm), @@ -2809,10 +2809,10 @@ def heightmap_dig_bezier( hm: NDArray[np.float32], px: tuple[int, int, int, int], py: tuple[int, int, int, int], - startRadius: float, - startDepth: float, - endRadius: float, - endDepth: float, + startRadius: float, # noqa: N803 + startDepth: float, # noqa: N803 + endRadius: float, # noqa: N803 + endDepth: float, # noqa: N803 ) -> None: """Carve a path along a cubic Bezier curve. @@ -2851,14 +2851,14 @@ def heightmap_get_value(hm: NDArray[np.float32], x: int, y: int) -> float: DeprecationWarning, stacklevel=2, ) - return hm[y, x] # type: ignore + return hm.item(y, x) if hm.flags["F_CONTIGUOUS"]: warnings.warn( "Get a value from this heightmap with hm[x,y]", DeprecationWarning, stacklevel=2, ) - return hm[x, y] # type: ignore + return hm.item(x, y) msg = "This array is not contiguous." raise ValueError(msg) @@ -2894,7 +2894,7 @@ def heightmap_get_slope(hm: NDArray[np.float32], x: int, y: int) -> float: @pending_deprecate() -def heightmap_get_normal(hm: NDArray[np.float32], x: float, y: float, waterLevel: float) -> tuple[float, float, float]: +def heightmap_get_normal(hm: NDArray[np.float32], x: float, y: float, waterLevel: float) -> tuple[float, float, float]: # noqa: N803 """Return the map normal at given coordinates. Args: @@ -3250,7 +3250,7 @@ def line_iter(xo: int, yo: int, xd: int, yd: int) -> Iterator[tuple[int, int]]: @deprecate("This function has been replaced by tcod.los.bresenham.", category=FutureWarning) -def line_where(x1: int, y1: int, x2: int, y2: int, inclusive: bool = True) -> tuple[NDArray[np.intc], NDArray[np.intc]]: +def line_where(x1: int, y1: int, x2: int, y2: int, inclusive: bool = True) -> tuple[NDArray[np.intc], NDArray[np.intc]]: # noqa: FBT001, FBT002 """Return a NumPy index array following a Bresenham line. If `inclusive` is true then the start point is included in the result. @@ -3287,12 +3287,12 @@ def map_copy(source: tcod.map.Map, dest: tcod.map.Map) -> None: array attributes manually. """ if source.width != dest.width or source.height != dest.height: - dest.__init__(source.width, source.height, source._order) # type: ignore - dest._Map__buffer[:] = source._Map__buffer[:] # type: ignore + tcod.map.Map.__init__(dest, source.width, source.height, source._order) + dest._buffer[:] = source._buffer[:] @deprecate("Set properties using the m.transparent and m.walkable arrays.", category=FutureWarning) -def map_set_properties(m: tcod.map.Map, x: int, y: int, isTrans: bool, isWalk: bool) -> None: +def map_set_properties(m: tcod.map.Map, x: int, y: int, isTrans: bool, isWalk: bool) -> None: # noqa: FBT001, N803 """Set the properties of a single cell. .. note:: @@ -3305,7 +3305,7 @@ def map_set_properties(m: tcod.map.Map, x: int, y: int, isTrans: bool, isWalk: b @deprecate("Clear maps using NumPy broadcast rules instead.", category=FutureWarning) -def map_clear(m: tcod.map.Map, transparent: bool = False, walkable: bool = False) -> None: +def map_clear(m: tcod.map.Map, transparent: bool = False, walkable: bool = False) -> None: # noqa: FBT001, FBT002 """Change all map cells to a specific value. .. deprecated:: 4.5 @@ -3322,7 +3322,7 @@ def map_compute_fov( x: int, y: int, radius: int = 0, - light_walls: bool = True, + light_walls: bool = True, # noqa: FBT001, FBT002 algo: int = FOV_RESTRICTIVE, ) -> None: """Compute the field-of-view for a map instance. @@ -3378,7 +3378,7 @@ def map_delete(m: tcod.map.Map) -> None: @deprecate("Check the map.width attribute instead.", category=FutureWarning) -def map_get_width(map: tcod.map.Map) -> int: +def map_get_width(map: tcod.map.Map) -> int: # noqa: A002 """Return the width of a map. .. deprecated:: 4.5 @@ -3388,7 +3388,7 @@ def map_get_width(map: tcod.map.Map) -> int: @deprecate("Check the map.height attribute instead.", category=FutureWarning) -def map_get_height(map: tcod.map.Map) -> int: +def map_get_height(map: tcod.map.Map) -> int: # noqa: A002 """Return the height of a map. .. deprecated:: 4.5 @@ -3398,7 +3398,7 @@ def map_get_height(map: tcod.map.Map) -> int: @deprecate("Use `tcod.sdl.mouse.show(visible)` instead.", category=FutureWarning) -def mouse_show_cursor(visible: bool) -> None: +def mouse_show_cursor(visible: bool) -> None: # noqa: FBT001 """Change the visibility of the mouse cursor. .. deprecated:: 16.0 @@ -3434,12 +3434,12 @@ def namegen_parse(filename: str | PathLike[str], random: tcod.random.Random | No @pending_deprecate() def namegen_generate(name: str) -> str: - return _unpack_char_p(lib.TCOD_namegen_generate(_bytes(name), False)) + return _unpack_char_p(lib.TCOD_namegen_generate(_bytes(name), False)) # noqa: FBT003 @pending_deprecate() def namegen_generate_custom(name: str, rule: str) -> str: - return _unpack_char_p(lib.TCOD_namegen_generate_custom(_bytes(name), _bytes(rule), False)) + return _unpack_char_p(lib.TCOD_namegen_generate_custom(_bytes(name), _bytes(rule), False)) # noqa: FBT003 @pending_deprecate() @@ -3618,7 +3618,7 @@ def _pycall_parser_new_flag(name: str) -> Any: @ffi.def_extern() # type: ignore[untyped-decorator] -def _pycall_parser_new_property(propname: Any, type: Any, value: Any) -> Any: +def _pycall_parser_new_property(propname: Any, type: Any, value: Any) -> Any: # noqa: A002 return _parser_listener.new_property(_unpack_char_p(propname), type, _unpack_union(type, value)) @@ -3706,7 +3706,7 @@ def parser_get_dice_property(parser: Any, name: str) -> Dice: @deprecate("Parser functions have been deprecated.") -def parser_get_list_property(parser: Any, name: str, type: Any) -> Any: +def parser_get_list_property(parser: Any, name: str, type: Any) -> Any: # noqa: A002 c_list = lib.TCOD_parser_get_list_property(parser, _bytes(name), type) return _convert_TCODList(c_list, type) @@ -3909,19 +3909,19 @@ def struct_add_flag(struct: Any, name: str) -> None: @deprecate("This function is deprecated.") -def struct_add_property(struct: Any, name: str, typ: int, mandatory: bool) -> None: +def struct_add_property(struct: Any, name: str, typ: int, mandatory: bool) -> None: # noqa: FBT001 lib.TCOD_struct_add_property(struct, _bytes(name), typ, mandatory) @deprecate("This function is deprecated.") -def struct_add_value_list(struct: Any, name: str, value_list: Iterable[str], mandatory: bool) -> None: +def struct_add_value_list(struct: Any, name: str, value_list: Iterable[str], mandatory: bool) -> None: # noqa: FBT001 c_strings = [ffi.new("char[]", value.encode("utf-8")) for value in value_list] c_value_list = ffi.new("char*[]", c_strings) lib.TCOD_struct_add_value_list(struct, name, c_value_list, mandatory) @deprecate("This function is deprecated.") -def struct_add_list_property(struct: Any, name: str, typ: int, mandatory: bool) -> None: +def struct_add_list_property(struct: Any, name: str, typ: int, mandatory: bool) -> None: # noqa: FBT001 lib.TCOD_struct_add_list_property(struct, _bytes(name), typ, mandatory) @@ -4134,7 +4134,7 @@ def sys_get_char_size() -> tuple[int, int]: # update font bitmap @deprecate("This function is not supported if contexts are being used.") def sys_update_char( - asciiCode: int, + asciiCode: int, # noqa: N803 fontx: int, fonty: int, img: tcod.image.Image, @@ -4164,7 +4164,7 @@ def sys_update_char( @deprecate("This function is not supported if contexts are being used.") -def sys_register_SDL_renderer(callback: Callable[[Any], None]) -> None: +def sys_register_SDL_renderer(callback: Callable[[Any], None]) -> None: # noqa: N802 """Register a custom rendering function with libtcod. Note: @@ -4183,7 +4183,7 @@ def sys_register_SDL_renderer(callback: Callable[[Any], None]) -> None: """ with _PropagateException() as propagate: - @ffi.def_extern(onerror=propagate) # type: ignore + @ffi.def_extern(onerror=propagate) # type: ignore[untyped-decorator] def _pycall_sdl_hook(sdl_surface: Any) -> None: callback(sdl_surface) @@ -4208,7 +4208,7 @@ def sys_check_for_event(mask: int, k: Key | None, m: Mouse | None) -> int: @deprecate("Use tcod.event.wait to wait for events.") -def sys_wait_for_event(mask: int, k: Key | None, m: Mouse | None, flush: bool) -> int: +def sys_wait_for_event(mask: int, k: Key | None, m: Mouse | None, flush: bool) -> int: # noqa: FBT001 """Wait for an event then return. If flush is True then the buffer will be cleared before waiting. Otherwise diff --git a/tcod/map.py b/tcod/map.py index d3ec5015..5054226b 100644 --- a/tcod/map.py +++ b/tcod/map.py @@ -82,9 +82,9 @@ def __init__( """Initialize the map.""" self.width = width self.height = height - self._order = tcod._internal.verify_order(order) + self._order: Literal["C", "F"] = tcod._internal.verify_order(order) - self.__buffer: NDArray[np.bool_] = np.zeros((height, width, 3), dtype=np.bool_) + self._buffer: NDArray[np.bool_] = np.zeros((height, width, 3), dtype=np.bool_) self.map_c = self.__as_cdata() def __as_cdata(self) -> Any: # noqa: ANN401 @@ -94,23 +94,23 @@ def __as_cdata(self) -> Any: # noqa: ANN401 self.width, self.height, self.width * self.height, - ffi.from_buffer("struct TCOD_MapCell*", self.__buffer), + ffi.from_buffer("struct TCOD_MapCell*", self._buffer), ), ) @property def transparent(self) -> NDArray[np.bool_]: - buffer: np.ndarray[Any, np.dtype[np.bool_]] = self.__buffer[:, :, 0] + buffer: np.ndarray[Any, np.dtype[np.bool_]] = self._buffer[:, :, 0] return buffer.T if self._order == "F" else buffer @property def walkable(self) -> NDArray[np.bool_]: - buffer: np.ndarray[Any, np.dtype[np.bool_]] = self.__buffer[:, :, 1] + buffer: np.ndarray[Any, np.dtype[np.bool_]] = self._buffer[:, :, 1] return buffer.T if self._order == "F" else buffer @property def fov(self) -> NDArray[np.bool_]: - buffer: np.ndarray[Any, np.dtype[np.bool_]] = self.__buffer[:, :, 2] + buffer: np.ndarray[Any, np.dtype[np.bool_]] = self._buffer[:, :, 2] return buffer.T if self._order == "F" else buffer def compute_fov( @@ -118,7 +118,7 @@ def compute_fov( x: int, y: int, radius: int = 0, - light_walls: bool = True, + light_walls: bool = True, # noqa: FBT001, FBT002 algorithm: int = tcod.constants.FOV_RESTRICTIVE, ) -> None: """Compute a field-of-view on the current instance. @@ -146,12 +146,13 @@ def compute_fov( lib.TCOD_map_compute_fov(self.map_c, x, y, radius, light_walls, algorithm) def __setstate__(self, state: dict[str, Any]) -> None: - if "_Map__buffer" not in state: # deprecated - # remove this check on major version update - self.__buffer = np.zeros((state["height"], state["width"], 3), dtype=np.bool_) - self.__buffer[:, :, 0] = state["buffer"] & 0x01 - self.__buffer[:, :, 1] = state["buffer"] & 0x02 - self.__buffer[:, :, 2] = state["buffer"] & 0x04 + if "_Map__buffer" in state: # Deprecated since 19.6 + state["_buffer"] = state.pop("_Map__buffer") + if "buffer" in state: # Deprecated + self._buffer = np.zeros((state["height"], state["width"], 3), dtype=np.bool_) + self._buffer[:, :, 0] = state["buffer"] & 0x01 + self._buffer[:, :, 1] = state["buffer"] & 0x02 + self._buffer[:, :, 2] = state["buffer"] & 0x04 del state["buffer"] state["_order"] = "F" self.__dict__.update(state) @@ -167,7 +168,7 @@ def compute_fov( transparency: ArrayLike, pov: tuple[int, int], radius: int = 0, - light_walls: bool = True, + light_walls: bool = True, # noqa: FBT001, FBT002 algorithm: int = tcod.constants.FOV_RESTRICTIVE, ) -> NDArray[np.bool_]: """Return a boolean mask of the area covered by a field-of-view. diff --git a/tcod/noise.py b/tcod/noise.py index 533df338..e1295037 100644 --- a/tcod/noise.py +++ b/tcod/noise.py @@ -36,7 +36,6 @@ import enum import warnings -from collections.abc import Sequence from typing import TYPE_CHECKING, Any, Literal import numpy as np @@ -46,6 +45,8 @@ from tcod.cffi import ffi, lib if TYPE_CHECKING: + from collections.abc import Sequence + from numpy.typing import ArrayLike, NDArray @@ -350,9 +351,9 @@ def __getstate__(self) -> dict[str, Any]: self.get_point() self.algorithm = saved_algo - waveletTileData = None + waveletTileData = None # noqa: N806 if self.noise_c.waveletTileData != ffi.NULL: - waveletTileData = list(self.noise_c.waveletTileData[0 : 32 * 32 * 32]) + waveletTileData = list(self.noise_c.waveletTileData[0 : 32 * 32 * 32]) # noqa: N806 state["_waveletTileData"] = waveletTileData state["noise_c"] = { diff --git a/tcod/path.py b/tcod/path.py index 9d759371..d68fa8e1 100644 --- a/tcod/path.py +++ b/tcod/path.py @@ -21,7 +21,6 @@ import functools import itertools import warnings -from collections.abc import Callable, Sequence from typing import TYPE_CHECKING, Any, Final, Literal import numpy as np @@ -32,6 +31,8 @@ from tcod.cffi import ffi, lib if TYPE_CHECKING: + from collections.abc import Callable, Sequence + from numpy.typing import ArrayLike, DTypeLike, NDArray @@ -148,7 +149,7 @@ def get_tcod_path_ffi(self) -> tuple[Any, Any, tuple[int, int]]: msg = f"dtype must be one of {self._C_ARRAY_CALLBACKS.keys()!r}, dtype is {self.dtype.type!r}" raise ValueError(msg) - array_type, callback = self._C_ARRAY_CALLBACKS[self.dtype.type] + _array_type, callback = self._C_ARRAY_CALLBACKS[self.dtype.type] userdata = ffi.new( "struct PathCostArray*", (ffi.cast("char*", self.ctypes.data), self.strides), @@ -525,8 +526,8 @@ def _compile_bool_edges(edge_map: ArrayLike) -> tuple[Any, int]: def hillclimb2d( distance: ArrayLike, start: tuple[int, int], - cardinal: bool | None = None, - diagonal: bool | None = None, + cardinal: bool | None = None, # noqa: FBT001 + diagonal: bool | None = None, # noqa: FBT001 *, edge_map: ArrayLike | None = None, ) -> NDArray[np.intc]: @@ -998,7 +999,7 @@ def _compile_rules(self) -> Any: # noqa: ANN401 def _resolve(self, pathfinder: Pathfinder) -> None: """Run the pathfinding algorithm for this graph.""" - rules, keep_alive = self._compile_rules() + rules, _keep_alive = self._compile_rules() _check( lib.path_compute( pathfinder._frontier_p, diff --git a/tcod/sdl/_internal.py b/tcod/sdl/_internal.py index 4783d551..db67fa2e 100644 --- a/tcod/sdl/_internal.py +++ b/tcod/sdl/_internal.py @@ -4,7 +4,6 @@ import logging import sys -from collections.abc import Callable from dataclasses import dataclass from typing import TYPE_CHECKING, Any, NoReturn, Protocol, TypeVar, overload, runtime_checkable @@ -13,6 +12,7 @@ from tcod.cffi import ffi, lib if TYPE_CHECKING: + from collections.abc import Callable from types import TracebackType T = TypeVar("T") @@ -161,7 +161,7 @@ def _get_error() -> str: return str(ffi.string(lib.SDL_GetError()), encoding="utf-8") -def _check(result: bool, /) -> bool: +def _check(result: bool, /) -> bool: # noqa: FBT001 """Check if an SDL function returned without errors, and raise an exception if it did.""" if not result: raise RuntimeError(_get_error()) diff --git a/tcod/sdl/joystick.py b/tcod/sdl/joystick.py index b658c510..e7e1bec0 100644 --- a/tcod/sdl/joystick.py +++ b/tcod/sdl/joystick.py @@ -335,10 +335,10 @@ def _touchpad(self) -> bool: def init() -> None: """Initialize SDL's joystick and game controller subsystems.""" - CONTROLLER_SYSTEMS = tcod.sdl.sys.Subsystem.JOYSTICK | tcod.sdl.sys.Subsystem.GAMECONTROLLER - if tcod.sdl.sys.Subsystem(lib.SDL_WasInit(CONTROLLER_SYSTEMS)) == CONTROLLER_SYSTEMS: + controller_systems = tcod.sdl.sys.Subsystem.JOYSTICK | tcod.sdl.sys.Subsystem.GAMECONTROLLER + if tcod.sdl.sys.Subsystem(lib.SDL_WasInit(controller_systems)) == controller_systems: return # Already initialized - tcod.sdl.sys.init(CONTROLLER_SYSTEMS) + tcod.sdl.sys.init(controller_systems) def _get_number() -> int: @@ -371,7 +371,7 @@ def _get_all() -> list[Joystick | GameController]: return [GameController._open(i) if lib.SDL_IsGamepad(i) else Joystick._open(i) for i in range(_get_number())] -def joystick_event_state(new_state: bool | None = None) -> bool: +def joystick_event_state(new_state: bool | None = None) -> bool: # noqa: FBT001 """Check or set joystick event polling. .. seealso:: @@ -384,7 +384,7 @@ def joystick_event_state(new_state: bool | None = None) -> bool: return lib.SDL_JoystickEventsEnabled() -def controller_event_state(new_state: bool | None = None) -> bool: +def controller_event_state(new_state: bool | None = None) -> bool: # noqa: FBT001 """Check or set game controller event polling. .. seealso:: diff --git a/tcod/sdl/mouse.py b/tcod/sdl/mouse.py index 9d9227f7..1e0c1438 100644 --- a/tcod/sdl/mouse.py +++ b/tcod/sdl/mouse.py @@ -39,8 +39,11 @@ def __init__(self, sdl_cursor_p: Any) -> None: # noqa: ANN401 def __eq__(self, other: object) -> bool: return bool(self.p == getattr(other, "p", None)) + def __hash__(self) -> int: + return hash(self.p) + @classmethod - def _claim(cls, sdl_cursor_p: Any) -> Cursor: + def _claim(cls, sdl_cursor_p: Any) -> Cursor: # noqa: ANN401 """Verify and wrap this pointer in a garbage collector before returning a Cursor.""" return cls(ffi.gc(_check_p(sdl_cursor_p), lib.SDL_DestroyCursor)) @@ -149,7 +152,7 @@ def get_cursor() -> Cursor | None: return Cursor(cursor_p) if cursor_p else None -def capture(enable: bool) -> None: +def capture(enable: bool) -> None: # noqa: FBT001 """Enable or disable mouse capture to track the mouse outside of a window. It is highly recommended to read the related remarks section in the SDL docs before using this. @@ -176,7 +179,7 @@ def capture(enable: bool) -> None: @deprecated("Set 'Window.relative_mouse_mode = value' instead.") -def set_relative_mode(enable: bool) -> None: +def set_relative_mode(enable: bool) -> None: # noqa: FBT001 """Enable or disable relative mouse mode which will lock and hide the mouse and only report mouse motion. .. seealso:: @@ -248,7 +251,7 @@ def warp_in_window(window: tcod.sdl.video.Window, x: int, y: int) -> None: lib.SDL_WarpMouseInWindow(window.p, x, y) -def show(visible: bool | None = None) -> bool: +def show(visible: bool | None = None) -> bool: # noqa: FBT001 """Optionally show or hide the mouse cursor then return the state of the cursor. Args: diff --git a/tcod/sdl/sys.py b/tcod/sdl/sys.py index 869d6448..c4056c69 100644 --- a/tcod/sdl/sys.py +++ b/tcod/sdl/sys.py @@ -24,7 +24,7 @@ def init(flags: int) -> None: _check(lib.SDL_InitSubSystem(flags)) -def quit(flags: int | None = None) -> None: +def quit(flags: int | None = None) -> None: # noqa: A001 if flags is None: lib.SDL_Quit() return diff --git a/tcod/sdl/video.py b/tcod/sdl/video.py index 4895e0f7..a7d6c330 100644 --- a/tcod/sdl/video.py +++ b/tcod/sdl/video.py @@ -157,7 +157,7 @@ def __init__(self, pixels: ArrayLike) -> None: lib.SDL_CreateSurfaceFrom( self._array.shape[1], self._array.shape[0], - lib.SDL_PIXELFORMAT_RGBA32 if self._array.shape[2] == 4 else lib.SDL_PIXELFORMAT_RGB24, + lib.SDL_PIXELFORMAT_RGBA32 if self._array.shape[2] == 4 else lib.SDL_PIXELFORMAT_RGB24, # noqa: PLR2004 ffi.from_buffer("void*", self._array), self._array.strides[0], ) @@ -555,7 +555,7 @@ def get_grabbed_window() -> Window | None: return Window(sdl_window_p) if sdl_window_p else None -def screen_saver_allowed(allow: bool | None = None) -> bool: +def screen_saver_allowed(allow: bool | None = None) -> bool: # noqa: FBT001 """Allow or prevent a screen saver from being displayed and return the current allowed status. If `allow` is `None` then only the current state is returned. diff --git a/tcod/tileset.py b/tcod/tileset.py index 6362c93d..4b3935ac 100644 --- a/tcod/tileset.py +++ b/tcod/tileset.py @@ -16,7 +16,6 @@ from __future__ import annotations import itertools -from collections.abc import Iterable from pathlib import Path from typing import TYPE_CHECKING, Any @@ -27,6 +26,7 @@ from tcod.cffi import ffi, lib if TYPE_CHECKING: + from collections.abc import Iterable from os import PathLike from numpy.typing import ArrayLike, NDArray diff --git a/tests/conftest.py b/tests/conftest.py index 79891a38..580f16bc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,13 +4,16 @@ import random import warnings -from collections.abc import Callable, Iterator +from typing import TYPE_CHECKING import pytest import tcod from tcod import libtcodpy +if TYPE_CHECKING: + from collections.abc import Callable, Iterator + def pytest_addoption(parser: pytest.Parser) -> None: parser.addoption("--no-window", action="store_true", help="Skip tests which need a rendering context.") @@ -29,15 +32,15 @@ def uses_window(request: pytest.FixtureRequest) -> Iterator[None]: def session_console(request: pytest.FixtureRequest) -> Iterator[tcod.console.Console]: if request.config.getoption("--no-window"): pytest.skip("This test needs a rendering context.") - FONT_FILE = "libtcod/terminal.png" - WIDTH = 12 - HEIGHT = 10 - TITLE = "libtcod-cffi tests" - FULLSCREEN = False - RENDERER = getattr(libtcodpy, "RENDERER_" + request.param) - - libtcodpy.console_set_custom_font(FONT_FILE) - with libtcodpy.console_init_root(WIDTH, HEIGHT, TITLE, FULLSCREEN, RENDERER, vsync=False) as con: + font_file = "libtcod/terminal.png" + width = 12 + height = 10 + title = "libtcod-cffi tests" + fullscreen = False + renderer = getattr(libtcodpy, "RENDERER_" + request.param) + + libtcodpy.console_set_custom_font(font_file) + with libtcodpy.console_init_root(width, height, title, fullscreen, renderer, vsync=False) as con: yield con diff --git a/tests/test_console.py b/tests/test_console.py index 18668ba6..873a7691 100644 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -12,9 +12,9 @@ def test_array_read_write() -> None: console = tcod.console.Console(width=12, height=10) - FG = (255, 254, 253) - BG = (1, 2, 3) - CH = ord("&") + FG = (255, 254, 253) # noqa: N806 + BG = (1, 2, 3) # noqa: N806 + CH = ord("&") # noqa: N806 with pytest.warns(): tcod.console_put_char_ex(console, 0, 0, CH, FG, BG) assert console.ch[0, 0] == CH @@ -74,7 +74,7 @@ def test_console_methods() -> None: console.print_rect(0, 0, 2, 8, "a b c d e f") console.get_height_rect(0, 0, 2, 8, "a b c d e f") with pytest.deprecated_call(): - console.rect(0, 0, 2, 2, True) + console.rect(0, 0, 2, 2, True) # noqa: FBT003 with pytest.deprecated_call(): console.hline(0, 1, 10) with pytest.deprecated_call(): @@ -107,7 +107,7 @@ def test_console_pickle_fortran() -> None: def test_console_repr() -> None: - from numpy import array # noqa: F401 # Used for eval + from numpy import array # Used for eval # noqa: F401, PLC0415 eval(repr(tcod.console.Console(10, 2))) # noqa: S307 diff --git a/tests/test_libtcodpy.py b/tests/test_libtcodpy.py index 61895c60..ca1ab1a2 100644 --- a/tests/test_libtcodpy.py +++ b/tests/test_libtcodpy.py @@ -23,12 +23,12 @@ def test_console_behavior(console: tcod.console.Console) -> None: @pytest.mark.skip("takes too long") @pytest.mark.filterwarnings("ignore") -def test_credits_long(console: tcod.console.Console) -> None: +def test_credits_long(console: tcod.console.Console) -> None: # noqa: ARG001 libtcodpy.console_credits() -def test_credits(console: tcod.console.Console) -> None: - libtcodpy.console_credits_render(0, 0, True) +def test_credits(console: tcod.console.Console) -> None: # noqa: ARG001 + libtcodpy.console_credits_render(0, 0, True) # noqa: FBT003 libtcodpy.console_credits_reset() @@ -112,7 +112,7 @@ def test_console_printing(console: tcod.console.Console, fg: tuple[int, int, int @pytest.mark.filterwarnings("ignore") def test_console_rect(console: tcod.console.Console) -> None: - libtcodpy.console_rect(console, 0, 0, 4, 4, False, libtcodpy.BKGND_SET) + libtcodpy.console_rect(console, 0, 0, 4, 4, False, libtcodpy.BKGND_SET) # noqa: FBT003 @pytest.mark.filterwarnings("ignore") @@ -127,13 +127,13 @@ def test_console_print_frame(console: tcod.console.Console) -> None: @pytest.mark.filterwarnings("ignore") -def test_console_fade(console: tcod.console.Console) -> None: +def test_console_fade(console: tcod.console.Console) -> None: # noqa: ARG001 libtcodpy.console_set_fade(0, (0, 0, 0)) libtcodpy.console_get_fade() libtcodpy.console_get_fading_color() -def assertConsolesEqual(a: tcod.console.Console, b: tcod.console.Console) -> bool: +def assert_consoles_equal(a: tcod.console.Console, b: tcod.console.Console, /) -> bool: return bool((a.fg[:] == b.fg[:]).all() and (a.bg[:] == b.bg[:]).all() and (a.ch[:] == b.ch[:]).all()) @@ -141,7 +141,7 @@ def assertConsolesEqual(a: tcod.console.Console, b: tcod.console.Console) -> boo def test_console_blit(console: tcod.console.Console, offscreen: tcod.console.Console) -> None: libtcodpy.console_print(offscreen, 0, 0, "test") libtcodpy.console_blit(offscreen, 0, 0, 0, 0, console, 0, 0, 1, 1) - assertConsolesEqual(console, offscreen) + assert_consoles_equal(console, offscreen) libtcodpy.console_set_key_color(offscreen, (0, 0, 0)) @@ -152,7 +152,7 @@ def test_console_asc_read_write(console: tcod.console.Console, offscreen: tcod.c asc_file = tmp_path / "test.asc" assert libtcodpy.console_save_asc(console, asc_file) assert libtcodpy.console_load_asc(offscreen, asc_file) - assertConsolesEqual(console, offscreen) + assert_consoles_equal(console, offscreen) @pytest.mark.filterwarnings("ignore") @@ -162,11 +162,11 @@ def test_console_apf_read_write(console: tcod.console.Console, offscreen: tcod.c apf_file = tmp_path / "test.apf" assert libtcodpy.console_save_apf(console, apf_file) assert libtcodpy.console_load_apf(offscreen, apf_file) - assertConsolesEqual(console, offscreen) + assert_consoles_equal(console, offscreen) @pytest.mark.filterwarnings("ignore") -def test_console_rexpaint_load_test_file(console: tcod.console.Console) -> None: +def test_console_rexpaint_load_test_file(console: tcod.console.Console) -> None: # noqa: ARG001 xp_console = libtcodpy.console_from_xp("libtcod/data/rexpaint/test.xp") assert xp_console assert libtcodpy.console_get_char(xp_console, 0, 0) == ord("T") @@ -190,13 +190,13 @@ def test_console_rexpaint_save_load( assert libtcodpy.console_save_xp(console, xp_file, 1) xp_console = libtcodpy.console_from_xp(xp_file) assert xp_console - assertConsolesEqual(console, xp_console) - assert libtcodpy.console_load_xp(None, xp_file) # type: ignore - assertConsolesEqual(console, xp_console) + assert_consoles_equal(console, xp_console) + assert libtcodpy.console_load_xp(None, xp_file) # type: ignore[arg-type] + assert_consoles_equal(console, xp_console) @pytest.mark.filterwarnings("ignore") -def test_console_rexpaint_list_save_load(console: tcod.console.Console, tmp_path: Path) -> None: +def test_console_rexpaint_list_save_load(console: tcod.console.Console, tmp_path: Path) -> None: # noqa: ARG001 con1 = libtcodpy.console_new(8, 2) con2 = libtcodpy.console_new(8, 2) libtcodpy.console_print(con1, 0, 0, "hello") @@ -206,18 +206,18 @@ def test_console_rexpaint_list_save_load(console: tcod.console.Console, tmp_path loaded_consoles = libtcodpy.console_list_load_xp(xp_file) assert loaded_consoles for a, b in zip([con1, con2], loaded_consoles, strict=True): - assertConsolesEqual(a, b) + assert_consoles_equal(a, b) libtcodpy.console_delete(a) libtcodpy.console_delete(b) @pytest.mark.filterwarnings("ignore") -def test_console_fullscreen(console: tcod.console.Console) -> None: - libtcodpy.console_set_fullscreen(False) +def test_console_fullscreen(console: tcod.console.Console) -> None: # noqa: ARG001 + libtcodpy.console_set_fullscreen(False) # noqa: FBT003 @pytest.mark.filterwarnings("ignore") -def test_console_key_input(console: tcod.console.Console) -> None: +def test_console_key_input(console: tcod.console.Console) -> None: # noqa: ARG001 libtcodpy.console_check_for_keypress() libtcodpy.console_is_key_pressed(libtcodpy.KEY_ENTER) @@ -299,15 +299,15 @@ def test_console_buffer_error(console: tcod.console.Console) -> None: @pytest.mark.filterwarnings("ignore") -def test_console_font_mapping(console: tcod.console.Console) -> None: +def test_console_font_mapping(console: tcod.console.Console) -> None: # noqa: ARG001 libtcodpy.console_map_ascii_code_to_font(ord("@"), 1, 1) libtcodpy.console_map_ascii_codes_to_font(ord("@"), 1, 0, 0) libtcodpy.console_map_string_to_font("@", 0, 0) @pytest.mark.filterwarnings("ignore") -def test_mouse(console: tcod.console.Console) -> None: - libtcodpy.mouse_show_cursor(True) +def test_mouse(console: tcod.console.Console) -> None: # noqa: ARG001 + libtcodpy.mouse_show_cursor(True) # noqa: FBT003 libtcodpy.mouse_is_cursor_visible() mouse = libtcodpy.mouse_get_status() repr(mouse) @@ -315,7 +315,7 @@ def test_mouse(console: tcod.console.Console) -> None: @pytest.mark.filterwarnings("ignore") -def test_sys_time(console: tcod.console.Console) -> None: +def test_sys_time(console: tcod.console.Console) -> None: # noqa: ARG001 libtcodpy.sys_set_fps(0) libtcodpy.sys_get_fps() libtcodpy.sys_get_last_frame_length() @@ -325,18 +325,18 @@ def test_sys_time(console: tcod.console.Console) -> None: @pytest.mark.filterwarnings("ignore") -def test_sys_screenshot(console: tcod.console.Console, tmp_path: Path) -> None: +def test_sys_screenshot(console: tcod.console.Console, tmp_path: Path) -> None: # noqa: ARG001 libtcodpy.sys_save_screenshot(tmp_path / "test.png") @pytest.mark.filterwarnings("ignore") -def test_sys_custom_render(console: tcod.console.Console) -> None: +def test_sys_custom_render(console: tcod.console.Console) -> None: # noqa: ARG001 if libtcodpy.sys_get_renderer() != libtcodpy.RENDERER_SDL: pytest.xfail(reason="Only supports SDL") escape = [] - def sdl_callback(sdl_surface: object) -> None: + def sdl_callback(_sdl_surface: object) -> None: escape.append(True) libtcodpy.sys_register_SDL_renderer(sdl_callback) @@ -376,7 +376,7 @@ def test_image(console: tcod.console.Console, tmp_path: Path) -> None: @pytest.mark.parametrize("sample", ["@", "\u2603"]) # Unicode snowman @pytest.mark.xfail(reason="Unreliable") @pytest.mark.filterwarnings("ignore") -def test_clipboard(console: tcod.console.Console, sample: str) -> None: +def test_clipboard(console: tcod.console.Console, sample: str) -> None: # noqa: ARG001 saved = libtcodpy.sys_clipboard_get() try: libtcodpy.sys_clipboard_set(sample) @@ -457,9 +457,9 @@ def test_bsp() -> None: assert libtcodpy.bsp_find_node(bsp, 1, 1) == bsp assert not libtcodpy.bsp_find_node(bsp, -1, -1) - libtcodpy.bsp_split_once(bsp, False, 4) + libtcodpy.bsp_split_once(bsp, False, 4) # noqa: FBT003 repr(bsp) # test __repr__ with parent - libtcodpy.bsp_split_once(bsp, True, 4) + libtcodpy.bsp_split_once(bsp, True, 4) # noqa: FBT003 repr(bsp) # cover functions on parent @@ -473,7 +473,7 @@ def test_bsp() -> None: libtcodpy.bsp_split_recursive(bsp, None, 4, 2, 2, 1.0, 1.0) # cover bsp_traverse - def traverse(node: tcod.bsp.BSP, user_data: object) -> None: + def traverse(_node: tcod.bsp.BSP, _user_data: object) -> None: return None libtcodpy.bsp_traverse_pre_order(bsp, traverse) @@ -492,13 +492,13 @@ def traverse(node: tcod.bsp.BSP, user_data: object) -> None: @pytest.mark.filterwarnings("ignore") def test_map() -> None: - WIDTH, HEIGHT = 13, 17 - map = libtcodpy.map_new(WIDTH, HEIGHT) + WIDTH, HEIGHT = 13, 17 # noqa: N806 + map = libtcodpy.map_new(WIDTH, HEIGHT) # noqa: A001 assert libtcodpy.map_get_width(map) == WIDTH assert libtcodpy.map_get_height(map) == HEIGHT libtcodpy.map_copy(map, map) libtcodpy.map_clear(map) - libtcodpy.map_set_properties(map, 0, 0, True, True) + libtcodpy.map_set_properties(map, 0, 0, True, True) # noqa: FBT003 assert libtcodpy.map_is_transparent(map, 0, 0) assert libtcodpy.map_is_walkable(map, 0, 0) libtcodpy.map_is_in_fov(map, 0, 0) @@ -522,14 +522,14 @@ def test_color() -> None: color_b = libtcodpy.Color(255, 255, 255) assert color_a != color_b - color = libtcodpy.color_lerp(color_a, color_b, 0.5) # type: ignore + color = libtcodpy.color_lerp(color_a, color_b, 0.5) # type: ignore[arg-type] libtcodpy.color_set_hsv(color, 0, 0, 0) - libtcodpy.color_get_hsv(color) # type: ignore + libtcodpy.color_get_hsv(color) # type: ignore[arg-type] libtcodpy.color_scale_HSV(color, 0, 0) def test_color_repr() -> None: - Color = libtcodpy.Color + Color = libtcodpy.Color # noqa: N806 col = Color(0, 1, 2) assert eval(repr(col)) == col # noqa: S307 @@ -668,7 +668,7 @@ def map_() -> Iterator[tcod.map.Map]: @pytest.fixture def path_callback(map_: tcod.map.Map) -> Callable[[int, int, int, int, None], bool]: - def callback(ox: int, oy: int, dx: int, dy: int, user_data: None) -> bool: + def callback(_ox: int, _oy: int, dx: int, dy: int, _user_data: None) -> bool: return bool(map_.walkable[dy, dx]) return callback @@ -703,7 +703,7 @@ def test_astar(map_: tcod.map.Map) -> None: x, y = libtcodpy.path_get(astar, i) while (x, y) != (None, None): - x, y = libtcodpy.path_walk(astar, False) + x, y = libtcodpy.path_walk(astar, False) # noqa: FBT003 libtcodpy.path_delete(astar) diff --git a/tests/test_parser.py b/tests/test_parser.py index 73c54b63..67cd10e3 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -13,19 +13,19 @@ def test_parser() -> None: print("***** File Parser test *****") parser = libtcod.parser_new() struct = libtcod.parser_new_struct(parser, "myStruct") - libtcod.struct_add_property(struct, "bool_field", libtcod.TYPE_BOOL, True) - libtcod.struct_add_property(struct, "char_field", libtcod.TYPE_CHAR, True) - libtcod.struct_add_property(struct, "int_field", libtcod.TYPE_INT, True) - libtcod.struct_add_property(struct, "float_field", libtcod.TYPE_FLOAT, True) - libtcod.struct_add_property(struct, "color_field", libtcod.TYPE_COLOR, True) - libtcod.struct_add_property(struct, "dice_field", libtcod.TYPE_DICE, True) - libtcod.struct_add_property(struct, "string_field", libtcod.TYPE_STRING, True) - libtcod.struct_add_list_property(struct, "bool_list", libtcod.TYPE_BOOL, True) - libtcod.struct_add_list_property(struct, "char_list", libtcod.TYPE_CHAR, True) - libtcod.struct_add_list_property(struct, "integer_list", libtcod.TYPE_INT, True) - libtcod.struct_add_list_property(struct, "float_list", libtcod.TYPE_FLOAT, True) - libtcod.struct_add_list_property(struct, "string_list", libtcod.TYPE_STRING, True) - libtcod.struct_add_list_property(struct, "color_list", libtcod.TYPE_COLOR, True) + libtcod.struct_add_property(struct, "bool_field", libtcod.TYPE_BOOL, True) # noqa: FBT003 + libtcod.struct_add_property(struct, "char_field", libtcod.TYPE_CHAR, True) # noqa: FBT003 + libtcod.struct_add_property(struct, "int_field", libtcod.TYPE_INT, True) # noqa: FBT003 + libtcod.struct_add_property(struct, "float_field", libtcod.TYPE_FLOAT, True) # noqa: FBT003 + libtcod.struct_add_property(struct, "color_field", libtcod.TYPE_COLOR, True) # noqa: FBT003 + libtcod.struct_add_property(struct, "dice_field", libtcod.TYPE_DICE, True) # noqa: FBT003 + libtcod.struct_add_property(struct, "string_field", libtcod.TYPE_STRING, True) # noqa: FBT003 + libtcod.struct_add_list_property(struct, "bool_list", libtcod.TYPE_BOOL, True) # noqa: FBT003 + libtcod.struct_add_list_property(struct, "char_list", libtcod.TYPE_CHAR, True) # noqa: FBT003 + libtcod.struct_add_list_property(struct, "integer_list", libtcod.TYPE_INT, True) # noqa: FBT003 + libtcod.struct_add_list_property(struct, "float_list", libtcod.TYPE_FLOAT, True) # noqa: FBT003 + libtcod.struct_add_list_property(struct, "string_list", libtcod.TYPE_STRING, True) # noqa: FBT003 + libtcod.struct_add_list_property(struct, "color_list", libtcod.TYPE_COLOR, True) # noqa: FBT003 # default listener print("***** Default listener *****") diff --git a/tests/test_sdl.py b/tests/test_sdl.py index 93373a49..42433510 100644 --- a/tests/test_sdl.py +++ b/tests/test_sdl.py @@ -10,7 +10,7 @@ import tcod.sdl.video -def test_sdl_window(uses_window: None) -> None: +def test_sdl_window(uses_window: None) -> None: # noqa: ARG001 assert tcod.sdl.video.get_grabbed_window() is None window = tcod.sdl.video.new_window(1, 1) window.raise_window() @@ -50,14 +50,14 @@ def test_sdl_window_bad_types() -> None: tcod.sdl.video.Window(tcod.ffi.new("SDL_Rect*")) -def test_sdl_screen_saver(uses_window: None) -> None: +def test_sdl_screen_saver(uses_window: None) -> None: # noqa: ARG001 tcod.sdl.sys.init(tcod.sdl.sys.Subsystem.VIDEO) - assert tcod.sdl.video.screen_saver_allowed(False) is False - assert tcod.sdl.video.screen_saver_allowed(True) is True + assert tcod.sdl.video.screen_saver_allowed(False) is False # noqa: FBT003 + assert tcod.sdl.video.screen_saver_allowed(True) is True # noqa: FBT003 assert tcod.sdl.video.screen_saver_allowed() is True -def test_sdl_render(uses_window: None) -> None: +def test_sdl_render(uses_window: None) -> None: # noqa: ARG001 window = tcod.sdl.video.new_window(4, 4) render = tcod.sdl.render.new_renderer(window, driver="software", vsync=False) render.clear() diff --git a/tests/test_sdl_audio.py b/tests/test_sdl_audio.py index 38250758..3cc9204b 100644 --- a/tests/test_sdl_audio.py +++ b/tests/test_sdl_audio.py @@ -124,9 +124,9 @@ def test_audio_callback_unraisable() -> None: class CheckCalled: was_called: bool = False - def __call__(self, device: tcod.sdl.audio.AudioDevice, stream: NDArray[Any]) -> None: + def __call__(self, _device: tcod.sdl.audio.AudioDevice, _stream: NDArray[Any]) -> None: self.was_called = True - raise Exception("Test unraisable error") # noqa + raise Exception("Test unraisable error") # noqa: EM101, TRY002, TRY003 check_called = CheckCalled() with tcod.sdl.audio.open(callback=check_called, paused=False) as device: diff --git a/tests/test_tcod.py b/tests/test_tcod.py index c41da899..3f8c1d9b 100644 --- a/tests/test_tcod.py +++ b/tests/test_tcod.py @@ -102,7 +102,7 @@ def test_tcod_map_pickle() -> None: def test_tcod_map_pickle_fortran() -> None: map_ = tcod.map.Map(2, 3, order="F") map2: tcod.map.Map = pickle.loads(pickle.dumps(copy.copy(map_))) - assert map_._Map__buffer.strides == map2._Map__buffer.strides # type: ignore[attr-defined] + assert map_._buffer.strides == map2._buffer.strides assert map_.transparent.strides == map2.transparent.strides assert map_.walkable.strides == map2.walkable.strides assert map_.fov.strides == map2.fov.strides From 89866837dd1aef5b1f545bb461ccedfcb53518b0 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Tue, 13 Jan 2026 10:57:30 -0800 Subject: [PATCH 33/78] Skip unraisable exception test Not reliable enough to test Strange issues locally --- tests/test_sdl_audio.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_sdl_audio.py b/tests/test_sdl_audio.py index 3cc9204b..94bd151e 100644 --- a/tests/test_sdl_audio.py +++ b/tests/test_sdl_audio.py @@ -114,6 +114,7 @@ def __call__(self, device: tcod.sdl.audio.AudioDevice, stream: NDArray[Any]) -> @pytest.mark.skipif(sys.version_info < (3, 8), reason="Needs sys.unraisablehook support") @pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning") +@pytest.mark.skip(reason="Unsupported, causes too many issues") @needs_audio_device def test_audio_callback_unraisable() -> None: """Test unraisable error in audio callback. From 186cec1884e1f875634e18574c1f9d4271443a6e Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Tue, 13 Jan 2026 11:06:40 -0800 Subject: [PATCH 34/78] Add coverage exclusions for uncoverable lines and files --- pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 704dc0c6..17977d1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -97,6 +97,10 @@ filterwarnings = [ "ignore:'import tcod as libtcodpy' is preferred.", ] +[tool.coverage.report] # https://coverage.readthedocs.io/en/latest/config.html +exclude_lines = ['^\s*\.\.\.', "if TYPE_CHECKING:", "# pragma: no cover"] +omit = ["tcod/__pyinstaller/*"] + [tool.cibuildwheel] # https://cibuildwheel.pypa.io/en/stable/options/ enable = ["pypy", "pyodide-prerelease"] From 57cb18f59c7d13483457f2c1488d9defa5f96e87 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Wed, 14 Jan 2026 10:25:08 -0800 Subject: [PATCH 35/78] Assume Event types with get and wait event iterators Any was meant for more advanced cases which are now being discouraged --- tcod/event.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tcod/event.py b/tcod/event.py index f693291a..d364150f 100644 --- a/tcod/event.py +++ b/tcod/event.py @@ -1198,7 +1198,7 @@ def _parse_event(sdl_event: Any) -> Event: return Undefined.from_sdl_event(sdl_event) -def get() -> Iterator[Any]: +def get() -> Iterator[Event]: """Return an iterator for all pending events. Events are processed as the iterator is consumed. @@ -1218,7 +1218,7 @@ def get() -> Iterator[Any]: yield _parse_event(sdl_event) -def wait(timeout: float | None = None) -> Iterator[Any]: +def wait(timeout: float | None = None) -> Iterator[Event]: """Block until events exist, then return an event iterator. `timeout` is the maximum number of seconds to wait as a floating point From 76c3786a853c8cab27418fd308baa5a01c06bcfd Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Wed, 14 Jan 2026 10:26:27 -0800 Subject: [PATCH 36/78] Add better mouse coordinate conversion functions Update logical_size property to return None instead of (0, 0), None is a more expected return value for this state. Update samples to highlight the tile under the mouse --- CHANGELOG.md | 9 +++++ examples/samples_tcod.py | 31 ++++++++-------- tcod/event.py | 77 +++++++++++++++++++++++++++++++++++++++- tcod/sdl/render.py | 48 +++++++++++++++++++------ 4 files changed, 137 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e124797c..e59d96e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version ## [Unreleased] +### Added + +- Added methods: `Renderer.coordinates_from_window` and `Renderer.coordinates_to_window` +- Added `tcod.event.convert_coordinates_from_window`. + +### Changed + +- `Renderer.logical_size` now returns `None` instead of `(0, 0)` when logical size is unset. + ## [19.6.3] - 2026-01-12 Fix missing deployment diff --git a/examples/samples_tcod.py b/examples/samples_tcod.py index d8d79bea..32107d0d 100755 --- a/examples/samples_tcod.py +++ b/examples/samples_tcod.py @@ -1379,6 +1379,8 @@ def redraw_display() -> None: SAMPLES[cur_sample].on_draw() sample_console.blit(root_console, SAMPLE_SCREEN_X, SAMPLE_SCREEN_Y) draw_stats() + if 0 <= mouse_tile_xy[0] < root_console.width and 0 <= mouse_tile_xy[1] < root_console.height: + root_console.rgb[["fg", "bg"]].T[mouse_tile_xy] = (0, 0, 0), (255, 255, 255) # Highlight mouse tile if context.sdl_renderer: # Clear the screen to ensure no garbage data outside of the logical area is displayed context.sdl_renderer.draw_color = (0, 0, 0, 255) @@ -1410,25 +1412,20 @@ def handle_time() -> None: frame_length.append(frame_times[-1] - frame_times[-2]) +mouse_tile_xy = (-1, -1) +"""Last known mouse tile position.""" + + def handle_events() -> None: + global mouse_tile_xy for event in tcod.event.get(): - if context.sdl_renderer: # Manual handing of tile coordinates since context.present is skipped - assert context.sdl_window - tile_width = context.sdl_window.size[0] / root_console.width - tile_height = context.sdl_window.size[1] / root_console.height - - if isinstance(event, (tcod.event.MouseState, tcod.event.MouseMotion)): - event.tile = tcod.event.Point(event.position.x // tile_width, event.position.y // tile_height) - if isinstance(event, tcod.event.MouseMotion): - prev_tile = ( - (event.position[0] - event.motion[0]) // tile_width, - (event.position[1] - event.motion[1]) // tile_height, - ) - event.tile_motion = tcod.event.Point(event.tile[0] - prev_tile[0], event.tile[1] - prev_tile[1]) - else: - context.convert_event(event) - - SAMPLES[cur_sample].on_event(event) + tile_event = tcod.event.convert_coordinates_from_window(event, context, root_console) + SAMPLES[cur_sample].on_event(tile_event) + match tile_event: + case tcod.event.MouseMotion(position=(x, y)): + mouse_tile_xy = int(x), int(y) + case tcod.event.WindowEvent(type="WindowLeave"): + mouse_tile_xy = -1, -1 def draw_samples_menu() -> None: diff --git a/tcod/event.py b/tcod/event.py index d364150f..63a007c4 100644 --- a/tcod/event.py +++ b/tcod/event.py @@ -87,13 +87,15 @@ import sys import warnings from collections.abc import Callable, Iterator, Mapping -from typing import TYPE_CHECKING, Any, Final, Generic, Literal, NamedTuple, TypeVar +from typing import TYPE_CHECKING, Any, Final, Generic, Literal, NamedTuple, TypeVar, overload import numpy as np from typing_extensions import deprecated +import tcod.context import tcod.event_constants import tcod.sdl.joystick +import tcod.sdl.render import tcod.sdl.sys from tcod.cffi import ffi, lib from tcod.event_constants import * # noqa: F403 @@ -103,6 +105,7 @@ from numpy.typing import NDArray T = TypeVar("T") +_EventType = TypeVar("_EventType", bound="Event") class _ConstantsWithPrefix(Mapping[int, str]): @@ -1564,6 +1567,78 @@ def get_mouse_state() -> MouseState: return MouseState((xy[0], xy[1]), (int(tile[0]), int(tile[1])), buttons) +@overload +def convert_coordinates_from_window( + event: _EventType, + /, + context: tcod.context.Context | tcod.sdl.render.Renderer, + console: tcod.console.Console | tuple[int, int], + dest_rect: tuple[int, int, int, int] | None = None, +) -> _EventType: ... +@overload +def convert_coordinates_from_window( + xy: tuple[float, float], + /, + context: tcod.context.Context | tcod.sdl.render.Renderer, + console: tcod.console.Console | tuple[int, int], + dest_rect: tuple[int, int, int, int] | None = None, +) -> tuple[float, float]: ... +def convert_coordinates_from_window( + event: _EventType | tuple[float, float], + /, + context: tcod.context.Context | tcod.sdl.render.Renderer, + console: tcod.console.Console | tuple[int, int], + dest_rect: tuple[int, int, int, int] | None = None, +) -> _EventType | tuple[float, float]: + """Return an event or position with window mouse coordinates converted into console tile coordinates. + + Args: + event: :any:`Event` to convert, or the `(x, y)` coordinates to convert. + context: Context or Renderer to fetch the SDL renderer from for reference with conversions. + console: A console used as a size reference. + Otherwise the `(columns, rows)` can be given directly as a tuple. + dest_rect: The consoles rendering destination as `(x, y, width, height)`. + If None is given then the whole rendering target is assumed. + + .. versionadded:: Unreleased + """ + if isinstance(context, tcod.context.Context): + maybe_renderer: Final = context.sdl_renderer + if maybe_renderer is None: + return event + context = maybe_renderer + + if isinstance(console, tcod.console.Console): + console = console.width, console.height + + if dest_rect is None: + dest_rect = (0, 0, *(context.logical_size or context.output_size)) + + x_scale: Final = console[0] / dest_rect[2] + y_scale: Final = console[1] / dest_rect[3] + x_offset: Final = dest_rect[0] + y_offset: Final = dest_rect[1] + + if not isinstance(event, Event): + x, y = context.coordinates_from_window(event) + return (x - x_offset) * x_scale, (y - y_offset) * y_scale + + if isinstance(event, MouseMotion): + previous_position = convert_coordinates_from_window( + ((event.position[0] - event.motion[0]), (event.position[1] - event.motion[1])), context, console, dest_rect + ) + position = convert_coordinates_from_window(event.position, context, console, dest_rect) + event.motion = tcod.event.Point(position[0] - previous_position[0], position[1] - previous_position[1]) + event._tile_motion = tcod.event.Point( + int(position[0]) - int(previous_position[0]), int(position[1]) - int(previous_position[1]) + ) + if isinstance(event, (MouseState, MouseMotion)): + event.position = event._tile = tcod.event.Point( + *convert_coordinates_from_window(event.position, context, console, dest_rect) + ) + return event + + @ffi.def_extern() # type: ignore[untyped-decorator] def _sdl_event_watcher(userdata: Any, sdl_event: Any) -> int: callback: Callable[[Event], None] = ffi.from_handle(userdata) diff --git a/tcod/sdl/render.py b/tcod/sdl/render.py index 6833330f..496149e4 100644 --- a/tcod/sdl/render.py +++ b/tcod/sdl/render.py @@ -439,16 +439,16 @@ def draw_blend_mode(self, value: int) -> None: @property def output_size(self) -> tuple[int, int]: - """Get the (width, height) pixel resolution of the rendering context. + """Get the (width, height) pixel resolution of the current rendering context. .. seealso:: - https://wiki.libsdl.org/SDL_GetRendererOutputSize + https://wiki.libsdl.org/SDL3/SDL_GetCurrentRenderOutputSize .. versionadded:: 13.5 """ out = ffi.new("int[2]") _check(lib.SDL_GetCurrentRenderOutputSize(self.p, out, out + 1)) - return out[0], out[1] + return tuple(out) @property def clip_rect(self) -> tuple[int, int, int, int] | None: @@ -481,10 +481,8 @@ def set_logical_presentation(self, resolution: tuple[int, int], mode: LogicalPre _check(lib.SDL_SetRenderLogicalPresentation(self.p, width, height, mode)) @property - def logical_size(self) -> tuple[int, int]: - """Get current independent (width, height) resolution. - - Might be (0, 0) if a resolution was never assigned. + def logical_size(self) -> tuple[int, int] | None: + """Get current independent (width, height) resolution, or None if logical size is unset. .. seealso:: https://wiki.libsdl.org/SDL3/SDL_GetRenderLogicalPresentation @@ -493,10 +491,14 @@ def logical_size(self) -> tuple[int, int]: .. versionchanged:: 19.0 Setter is deprecated, use :any:`set_logical_presentation` instead. + + .. versionchanged:: Unreleased + Return ``None`` instead of ``(0, 0)`` when logical size is disabled. """ out = ffi.new("int[2]") lib.SDL_GetRenderLogicalPresentation(self.p, out, out + 1, ffi.NULL) - return out[0], out[1] + out_tuple = tuple(out) + return None if out_tuple == (0, 0) else out_tuple @logical_size.setter @deprecated("Use set_logical_presentation method to correctly setup logical size.") @@ -509,7 +511,7 @@ def scale(self) -> tuple[float, float]: """Get or set an (x_scale, y_scale) multiplier for drawing. .. seealso:: - https://wiki.libsdl.org/SDL_RenderSetScale + https://wiki.libsdl.org/SDL3/SDL_SetRenderScale .. versionadded:: 13.5 """ @@ -526,7 +528,7 @@ def viewport(self) -> tuple[int, int, int, int] | None: """Get or set the drawing area for the current rendering target. .. seealso:: - https://wiki.libsdl.org/SDL_RenderSetViewport + https://wiki.libsdl.org/SDL3/SDL_SetRenderViewport .. versionadded:: 13.5 """ @@ -753,6 +755,32 @@ def geometry( ) ) + def coordinates_from_window(self, xy: tuple[float, float], /) -> tuple[float, float]: + """Return the renderer coordinates from the given windows coordinates. + + .. seealso:: + https://wiki.libsdl.org/SDL3/SDL_RenderCoordinatesFromWindow + + .. versionadded:: Unreleased + """ + x, y = xy + out_xy = ffi.new("float[2]") + _check(lib.SDL_RenderCoordinatesFromWindow(self.p, x, y, out_xy, out_xy + 1)) + return tuple(out_xy) + + def coordinates_to_window(self, xy: tuple[float, float], /) -> tuple[float, float]: + """Return the window coordinates from the given render coordinates. + + .. seealso:: + https://wiki.libsdl.org/SDL3/SDL_RenderCoordinatesToWindow + + .. versionadded:: Unreleased + """ + x, y = xy + out_xy = ffi.new("float[2]") + _check(lib.SDL_RenderCoordinatesToWindow(self.p, x, y, out_xy, out_xy + 1)) + return tuple(out_xy) + def new_renderer( window: tcod.sdl.video.Window, From 38978dba43dae00472dbd70f57669d887bc91a73 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Wed, 21 Jan 2026 10:07:17 -0800 Subject: [PATCH 37/78] Clean up workflows Add Zizmor and apply easy fixes Clean up one-line jobs Update pre-commit --- .github/workflows/python-package.yml | 35 +++++++++++++++++----------- .github/workflows/release-on-tag.yml | 14 +++++++---- .github/zizmor.yaml | 7 ++++++ .pre-commit-config.yaml | 6 ++++- .vscode/settings.json | 7 +++--- 5 files changed, 47 insertions(+), 22 deletions(-) create mode 100644 .github/zizmor.yaml diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 8b1c36dd..b19f6624 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -26,6 +26,8 @@ jobs: timeout-minutes: 5 steps: - uses: actions/checkout@v6 + with: + persist-credentials: false - name: Install Ruff run: pip install ruff - name: Ruff Check @@ -38,6 +40,8 @@ jobs: timeout-minutes: 5 steps: - uses: actions/checkout@v6 + with: + persist-credentials: false - name: Checkout submodules run: git submodule update --init --recursive --depth 1 - name: Install typing dependencies @@ -59,6 +63,7 @@ jobs: version: ${{ env.sdl-version }} - uses: actions/checkout@v6 with: + persist-credentials: false fetch-depth: ${{ env.git-depth }} - name: Checkout submodules run: git submodule update --init --recursive --depth 1 @@ -86,6 +91,7 @@ jobs: steps: - uses: actions/checkout@v6 with: + persist-credentials: false fetch-depth: ${{ env.git-depth }} - name: Checkout submodules run: git submodule update --init --recursive --depth 1 @@ -117,10 +123,10 @@ jobs: steps: - uses: actions/checkout@v6 with: + persist-credentials: false fetch-depth: ${{ env.git-depth }} - name: Checkout submodules - run: | - git submodule update --init --recursive --depth 1 + run: git submodule update --init --recursive --depth 1 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 with: @@ -143,19 +149,15 @@ jobs: pip install pytest pytest-cov pytest-benchmark pytest-timeout build if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Initialize package - run: | - pip install -e . # Install the package in-place. + run: pip install -e . # Install the package in-place. - name: Build package - run: | - python -m build + run: python -m build - name: Test with pytest if: runner.os == 'Windows' - run: | - pytest --cov-report=xml --timeout=300 + run: pytest --cov-report=xml --timeout=300 - name: Test with pytest (Xvfb) if: always() && runner.os != 'Windows' - run: | - xvfb-run -e /tmp/xvfb.log --server-num=$RANDOM --auto-servernum pytest --cov-report=xml --timeout=300 + run: xvfb-run -e /tmp/xvfb.log --server-num=$RANDOM --auto-servernum pytest --cov-report=xml --timeout=300 - name: Xvfb logs if: runner.os != 'Windows' run: cat /tmp/xvfb.log @@ -181,6 +183,7 @@ jobs: version: ${{ env.sdl-version }} - uses: actions/checkout@v6 with: + persist-credentials: false fetch-depth: ${{ env.git-depth }} - name: Checkout submodules run: git submodule update --init --recursive --depth 1 @@ -206,6 +209,7 @@ jobs: steps: - uses: actions/checkout@v6 with: + persist-credentials: false fetch-depth: ${{ env.git-depth }} - name: Checkout submodules run: git submodule update --init --depth 1 @@ -239,6 +243,7 @@ jobs: steps: - uses: actions/checkout@v6 with: + persist-credentials: false fetch-depth: ${{ env.git-depth }} - name: Checkout submodules run: git submodule update --init --recursive --depth 1 @@ -270,8 +275,9 @@ jobs: # Skip test on emulated architectures CIBW_TEST_SKIP: "*_aarch64" - name: Remove asterisk from label + env: + BUILD_DESC: ${{ matrix.build }} run: | - BUILD_DESC=${{ matrix.build }} BUILD_DESC=${BUILD_DESC//\*} echo BUILD_DESC=${BUILD_DESC} >> $GITHUB_ENV - name: Archive wheel @@ -295,6 +301,7 @@ jobs: steps: - uses: actions/checkout@v6 with: + persist-credentials: false fetch-depth: ${{ env.git-depth }} - name: Checkout submodules run: git submodule update --init --recursive --depth 1 @@ -317,8 +324,9 @@ jobs: CIBW_TEST_SKIP: "pp* *-macosx_arm64 *-macosx_universal2:arm64" MACOSX_DEPLOYMENT_TARGET: "10.13" - name: Remove asterisk from label + env: + PYTHON_DESC: ${{ matrix.python }} run: | - PYTHON_DESC=${{ matrix.python }} PYTHON_DESC=${PYTHON_DESC//\*/X} echo PYTHON_DESC=${PYTHON_DESC} >> $GITHUB_ENV - name: Archive wheel @@ -336,6 +344,7 @@ jobs: steps: - uses: actions/checkout@v6 with: + persist-credentials: false fetch-depth: ${{ env.git-depth }} - name: Checkout submodules run: git submodule update --init --recursive --depth 1 @@ -365,7 +374,7 @@ jobs: name: pypi url: https://pypi.org/project/tcod/${{ github.ref_name }} permissions: - id-token: write + id-token: write # Attestation steps: - uses: actions/download-artifact@v7 with: diff --git a/.github/workflows/release-on-tag.yml b/.github/workflows/release-on-tag.yml index 921ac17d..0b9f07b0 100644 --- a/.github/workflows/release-on-tag.yml +++ b/.github/workflows/release-on-tag.yml @@ -5,19 +5,23 @@ on: name: Create Release +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + jobs: build: name: Create Release runs-on: ubuntu-latest timeout-minutes: 5 permissions: - contents: write + contents: write # Publish GitHub Releases steps: - - name: Checkout code - uses: actions/checkout@v6 + - uses: actions/checkout@v6 + with: + persist-credentials: false - name: Generate body - run: | - scripts/get_release_description.py | tee release_body.md + run: scripts/get_release_description.py | tee release_body.md - name: Create Release id: create_release uses: ncipollo/release-action@v1 diff --git a/.github/zizmor.yaml b/.github/zizmor.yaml new file mode 100644 index 00000000..b247c2d3 --- /dev/null +++ b/.github/zizmor.yaml @@ -0,0 +1,7 @@ +rules: + anonymous-definition: + disable: true + excessive-permissions: + disable: true + unpinned-uses: + disable: true diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f04621d0..aff2ef24 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,8 +17,12 @@ repos: - id: fix-byte-order-marker - id: detect-private-key - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.14.10 + rev: v0.14.13 hooks: - id: ruff-check args: [--fix-only, --exit-non-zero-on-fix] - id: ruff-format + - repo: https://github.com/zizmorcore/zizmor-pre-commit + rev: v1.22.0 + hooks: + - id: zizmor diff --git a/.vscode/settings.json b/.vscode/settings.json index 8226a81d..c1555fa8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -13,7 +13,7 @@ "files.insertFinalNewline": true, "files.trimTrailingWhitespace": true, "files.associations": { - "*.spec": "python", + "*.spec": "python" }, "mypy-type-checker.importStrategy": "fromEnvironment", "cSpell.words": [ @@ -548,12 +548,13 @@ "xrel", "xvfb", "ydst", - "yrel" + "yrel", + "zizmor" ], "python.testing.pytestArgs": [], "python.testing.unittestEnabled": false, "python.testing.pytestEnabled": true, "[python]": { "editor.defaultFormatter": "charliermarsh.ruff" - }, + } } From a40049c46246bf285ed8009d2f3907ac36e879d9 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Wed, 21 Jan 2026 09:36:43 -0800 Subject: [PATCH 38/78] Add ARM64 Windows wheels to be tested and published Update Windows platform detection, unsure if this is better in any way but I need to sort x64 from arm64. This architecture may become more common in the future, I'd rather support it now just in case. --- .github/workflows/python-package.yml | 3 +++ build_sdl.py | 23 ++++++++++++++++++----- setup.py | 12 ++++++------ tcod/cffi.py | 8 +++++--- 4 files changed, 32 insertions(+), 14 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index b19f6624..595ff7a7 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -118,6 +118,9 @@ jobs: - os: "windows-latest" python-version: "3.10" architecture: "x86" + - os: "windows-11-arm" + python-version: "3.11" + architecture: "arm64" fail-fast: false steps: diff --git a/build_sdl.py b/build_sdl.py index dbd49bd5..a1b8f6a2 100755 --- a/build_sdl.py +++ b/build_sdl.py @@ -3,10 +3,10 @@ from __future__ import annotations +import functools import io import logging import os -import platform import re import shutil import subprocess @@ -28,7 +28,20 @@ logger = logging.getLogger(__name__) -BIT_SIZE, LINKAGE = platform.architecture() +RE_MACHINE = re.compile(r".*\((.+)\)\]", re.DOTALL) + + +@functools.cache +def python_machine() -> str: + """Return the Python machine architecture (e.g. 'i386', 'AMD64', 'ARM64').""" + # Only needs to function correctly for Windows platforms. + match = RE_MACHINE.match(sys.version) + assert match, repr(sys.version) + (machine,) = match.groups() + machine = {"Intel": "i386"}.get(machine, machine) + logger.info(f"python_machine: {machine}") + return machine + # Reject versions of SDL older than this, update the requirements in the readme if you change this. SDL_MIN_VERSION = (3, 2, 0) @@ -386,10 +399,10 @@ def get_cdef() -> tuple[str, dict[str, str]]: # Bundle the Windows SDL DLL. if sys.platform == "win32" and SDL_BUNDLE_PATH is not None: include_dirs.append(str(SDL_INCLUDE)) - ARCH_MAPPING = {"32bit": "x86", "64bit": "x64"} - SDL_LIB_DIR = Path(SDL_BUNDLE_PATH, "lib/", ARCH_MAPPING[BIT_SIZE]) + ARCH_MAPPING = {"i386": "x86", "AMD64": "x64", "ARM64": "arm64"} + SDL_LIB_DIR = Path(SDL_BUNDLE_PATH, "lib/", ARCH_MAPPING[python_machine()]) library_dirs.append(str(SDL_LIB_DIR)) - SDL_LIB_DEST = Path("tcod", ARCH_MAPPING[BIT_SIZE]) + SDL_LIB_DEST = Path("tcod", ARCH_MAPPING[python_machine()]) SDL_LIB_DEST.mkdir(exist_ok=True) SDL_LIB_DEST_FILE = SDL_LIB_DEST / "SDL3.dll" SDL_LIB_FILE = SDL_LIB_DIR / "SDL3.dll" diff --git a/setup.py b/setup.py index 3786a1db..35c94af0 100755 --- a/setup.py +++ b/setup.py @@ -3,7 +3,6 @@ from __future__ import annotations -import platform import sys from pathlib import Path @@ -18,18 +17,19 @@ def get_package_data() -> list[str]: """Get data files which will be included in the main tcod/ directory.""" - bit_size, _ = platform.architecture() files = [ "py.typed", "lib/LIBTCOD-CREDITS.txt", "lib/LIBTCOD-LICENSE.txt", "lib/README-SDL.txt", ] - if "win32" in sys.platform: - if bit_size == "32bit": - files += ["x86/SDL3.dll"] - else: + if sys.platform == "win32": + if "ARM64" in sys.version: + files += ["arm64/SDL3.dll"] + elif "AMD64" in sys.version: files += ["x64/SDL3.dll"] + else: + files += ["x86/SDL3.dll"] if sys.platform == "darwin": files += ["SDL3.framework/Versions/A/SDL3"] return files diff --git a/tcod/cffi.py b/tcod/cffi.py index 97a37e44..57f4039b 100644 --- a/tcod/cffi.py +++ b/tcod/cffi.py @@ -7,7 +7,7 @@ import platform import sys from pathlib import Path -from typing import Any +from typing import Any, Literal import cffi @@ -39,8 +39,10 @@ def verify_dependencies() -> None: raise RuntimeError(msg) -def get_architecture() -> str: - """Return the Windows architecture, one of "x86" or "x64".""" +def get_architecture() -> Literal["x86", "x64", "arm64"]: + """Return the Windows architecture.""" + if "(ARM64)" in sys.version: + return "arm64" return "x86" if platform.architecture()[0] == "32bit" else "x64" From 15b5eda8445b2ba1a80e13f05ff0909624c14e1d Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Fri, 23 Jan 2026 15:44:02 -0800 Subject: [PATCH 39/78] Remove internal pending_deprecate decorator Was too complex for linters to pick up on and was preventing warnings from showing correctly --- tcod/_internal.py | 12 +---- tcod/libtcodpy.py | 120 ++++++++++++++++++++++++---------------------- 2 files changed, 63 insertions(+), 69 deletions(-) diff --git a/tcod/_internal.py b/tcod/_internal.py index 3391a602..b67242b8 100644 --- a/tcod/_internal.py +++ b/tcod/_internal.py @@ -8,7 +8,7 @@ from collections.abc import Callable from typing import TYPE_CHECKING, Any, AnyStr, Literal, NoReturn, SupportsInt, TypeVar -from typing_extensions import LiteralString, deprecated +from typing_extensions import deprecated from tcod.cffi import ffi, lib @@ -40,16 +40,6 @@ def decorator(func: F) -> F: deprecate = deprecated if __debug__ or TYPE_CHECKING else _deprecate_passthrough -def pending_deprecate( - message: LiteralString = "This function may be deprecated in the future." - " Consider raising an issue on GitHub if you need this feature.", - category: type[Warning] = PendingDeprecationWarning, - stacklevel: int = 0, -) -> Callable[[F], F]: - """Like deprecate, but the default parameters are filled out for a generic pending deprecation warning.""" - return deprecate(message, category=category, stacklevel=stacklevel) - - def verify_order(order: Literal["C", "F"]) -> Literal["C", "F"]: """Verify and return a Numpy order string.""" order = order.upper() # type: ignore[assignment] diff --git a/tcod/libtcodpy.py b/tcod/libtcodpy.py index 1948b550..7da9ff30 100644 --- a/tcod/libtcodpy.py +++ b/tcod/libtcodpy.py @@ -7,7 +7,7 @@ import threading import warnings from pathlib import Path -from typing import TYPE_CHECKING, Any, Literal +from typing import TYPE_CHECKING, Any, Final, Literal import numpy as np from typing_extensions import deprecated @@ -35,7 +35,6 @@ _unicode, _unpack_char_p, deprecate, - pending_deprecate, ) from tcod.cffi import ffi, lib from tcod.color import Color @@ -81,6 +80,11 @@ def BKGND_ADDALPHA(a: int) -> int: # noqa: N802 return BKGND_ADDA | (int(a * 255) << 8) +_PENDING_DEPRECATE_MSG: Final = ( + "This function may be deprecated in the future. Consider raising an issue on GitHub if you need this feature." +) + + @deprecated("Console array attributes perform better than this class.") class ConsoleBuffer: """Simple console that allows direct (fast) access to cells. Simplifies use of the "fill" functions. @@ -737,7 +741,7 @@ def bsp_delete(node: tcod.bsp.BSP) -> None: """ -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def color_lerp(c1: tuple[int, int, int], c2: tuple[int, int, int], a: float) -> Color: """Return the linear interpolation between two colors. @@ -757,7 +761,7 @@ def color_lerp(c1: tuple[int, int, int], c2: tuple[int, int, int], a: float) -> return Color._new_from_cdata(lib.TCOD_color_lerp(c1, c2, a)) -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def color_set_hsv(c: Color, h: float, s: float, v: float) -> None: """Set a color using: hue, saturation, and value parameters. @@ -774,7 +778,7 @@ def color_set_hsv(c: Color, h: float, s: float, v: float) -> None: c[:] = new_color.r, new_color.g, new_color.b -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def color_get_hsv(c: tuple[int, int, int]) -> tuple[float, float, float]: """Return the (hue, saturation, value) of a color. @@ -791,7 +795,7 @@ def color_get_hsv(c: tuple[int, int, int]) -> tuple[float, float, float]: return hsv[0], hsv[1], hsv[2] -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def color_scale_HSV(c: Color, scoef: float, vcoef: float) -> None: # noqa: N802 """Scale a color's saturation and value. @@ -810,7 +814,7 @@ def color_scale_HSV(c: Color, scoef: float, vcoef: float) -> None: # noqa: N802 c[:] = color_p.r, color_p.g, color_p.b -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def color_gen_map(colors: Iterable[tuple[int, int, int]], indexes: Iterable[int]) -> list[Color]: """Return a smoothly defined scale of colors. @@ -1149,7 +1153,7 @@ def console_set_window_title(title: str) -> None: lib.TCOD_console_set_window_title(_bytes(title)) -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def console_credits() -> None: lib.TCOD_console_credits() @@ -1277,7 +1281,7 @@ def console_clear(con: tcod.console.Console) -> None: lib.TCOD_console_clear(_console(con)) -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def console_put_char( con: tcod.console.Console, x: int, @@ -1297,7 +1301,7 @@ def console_put_char( lib.TCOD_console_put_char(_console(con), x, y, _int(c), flag) -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def console_put_char_ex( con: tcod.console.Console, x: int, @@ -1321,7 +1325,7 @@ def console_put_char_ex( lib.TCOD_console_put_char_ex(_console(con), x, y, _int(c), fore, back) -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def console_set_char_background( con: tcod.console.Console, x: int, @@ -1615,7 +1619,7 @@ def console_print_frame( _check(lib.TCOD_console_printf_frame(_console(con), x, y, w, h, clear, flag, fmt_)) -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def console_set_color_control(con: int, fore: tuple[int, int, int], back: tuple[int, int, int]) -> None: """Configure :term:`color controls`. @@ -2099,7 +2103,7 @@ def console_list_save_xp( lib.TCOD_list_delete(tcod_list) -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def path_new_using_map(m: tcod.map.Map, dcost: float = 1.41) -> tcod.path.AStar: """Return a new AStar using the given Map. @@ -2114,7 +2118,7 @@ def path_new_using_map(m: tcod.map.Map, dcost: float = 1.41) -> tcod.path.AStar: return tcod.path.AStar(m, dcost) -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def path_new_using_function( w: int, h: int, @@ -2138,7 +2142,7 @@ def path_new_using_function( return tcod.path.AStar(tcod.path._EdgeCostFunc((func, userData), (w, h)), dcost) -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def path_compute(p: tcod.path.AStar, ox: int, oy: int, dx: int, dy: int) -> bool: """Find a path from (ox, oy) to (dx, dy). Return True if path is found. @@ -2155,7 +2159,7 @@ def path_compute(p: tcod.path.AStar, ox: int, oy: int, dx: int, dy: int) -> bool return bool(lib.TCOD_path_compute(p._path_c, ox, oy, dx, dy)) -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def path_get_origin(p: tcod.path.AStar) -> tuple[int, int]: """Get the current origin position. @@ -2173,7 +2177,7 @@ def path_get_origin(p: tcod.path.AStar) -> tuple[int, int]: return x[0], y[0] -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def path_get_destination(p: tcod.path.AStar) -> tuple[int, int]: """Get the current destination position. @@ -2189,7 +2193,7 @@ def path_get_destination(p: tcod.path.AStar) -> tuple[int, int]: return x[0], y[0] -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def path_size(p: tcod.path.AStar) -> int: """Return the current length of the computed path. @@ -2202,7 +2206,7 @@ def path_size(p: tcod.path.AStar) -> int: return int(lib.TCOD_path_size(p._path_c)) -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def path_reverse(p: tcod.path.AStar) -> None: """Reverse the direction of a path. @@ -2214,7 +2218,7 @@ def path_reverse(p: tcod.path.AStar) -> None: lib.TCOD_path_reverse(p._path_c) -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def path_get(p: tcod.path.AStar, idx: int) -> tuple[int, int]: """Get a point on a path. @@ -2228,7 +2232,7 @@ def path_get(p: tcod.path.AStar, idx: int) -> tuple[int, int]: return x[0], y[0] -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def path_is_empty(p: tcod.path.AStar) -> bool: """Return True if a path is empty. @@ -2241,7 +2245,7 @@ def path_is_empty(p: tcod.path.AStar) -> bool: return bool(lib.TCOD_path_is_empty(p._path_c)) -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def path_walk(p: tcod.path.AStar, recompute: bool) -> tuple[int, int] | tuple[None, None]: # noqa: FBT001 """Return the next (x, y) point in a path, or (None, None) if it's empty. @@ -2271,12 +2275,12 @@ def path_delete(p: tcod.path.AStar) -> None: """ -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def dijkstra_new(m: tcod.map.Map, dcost: float = 1.41) -> tcod.path.Dijkstra: return tcod.path.Dijkstra(m, dcost) -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def dijkstra_new_using_function( w: int, h: int, @@ -2287,32 +2291,32 @@ def dijkstra_new_using_function( return tcod.path.Dijkstra(tcod.path._EdgeCostFunc((func, userData), (w, h)), dcost) -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def dijkstra_compute(p: tcod.path.Dijkstra, ox: int, oy: int) -> None: lib.TCOD_dijkstra_compute(p._path_c, ox, oy) -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def dijkstra_path_set(p: tcod.path.Dijkstra, x: int, y: int) -> bool: return bool(lib.TCOD_dijkstra_path_set(p._path_c, x, y)) -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def dijkstra_get_distance(p: tcod.path.Dijkstra, x: int, y: int) -> int: return int(lib.TCOD_dijkstra_get_distance(p._path_c, x, y)) -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def dijkstra_size(p: tcod.path.Dijkstra) -> int: return int(lib.TCOD_dijkstra_size(p._path_c)) -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def dijkstra_reverse(p: tcod.path.Dijkstra) -> None: lib.TCOD_dijkstra_reverse(p._path_c) -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def dijkstra_get(p: tcod.path.Dijkstra, idx: int) -> tuple[int, int]: x = ffi.new("int *") y = ffi.new("int *") @@ -2320,12 +2324,12 @@ def dijkstra_get(p: tcod.path.Dijkstra, idx: int) -> tuple[int, int]: return x[0], y[0] -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def dijkstra_is_empty(p: tcod.path.Dijkstra) -> bool: return bool(lib.TCOD_dijkstra_is_empty(p._path_c)) -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def dijkstra_path_walk( p: tcod.path.Dijkstra, ) -> tuple[int, int] | tuple[None, None]: @@ -2362,7 +2366,7 @@ def _heightmap_cdata(array: NDArray[np.float32]) -> Any: return ffi.new("TCOD_heightmap_t *", (width, height, pointer)) -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def heightmap_new(w: int, h: int, order: str = "C") -> NDArray[np.float32]: """Return a new numpy.ndarray formatted for use with heightmap functions. @@ -2486,7 +2490,7 @@ def heightmap_copy(hm1: NDArray[np.float32], hm2: NDArray[np.float32]) -> None: hm2[:] = hm1[:] -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def heightmap_normalize(hm: NDArray[np.float32], mi: float = 0.0, ma: float = 1.0) -> None: """Normalize heightmap values between ``mi`` and ``ma``. @@ -2498,7 +2502,7 @@ def heightmap_normalize(hm: NDArray[np.float32], mi: float = 0.0, ma: float = 1. lib.TCOD_heightmap_normalize(_heightmap_cdata(hm), mi, ma) -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def heightmap_lerp_hm( hm1: NDArray[np.float32], hm2: NDArray[np.float32], @@ -2562,7 +2566,7 @@ def heightmap_multiply_hm( hm3[:] = hm1[:] * hm2[:] -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def heightmap_add_hill(hm: NDArray[np.float32], x: float, y: float, radius: float, height: float) -> None: """Add a hill (a half spheroid) at given position. @@ -2578,7 +2582,7 @@ def heightmap_add_hill(hm: NDArray[np.float32], x: float, y: float, radius: floa lib.TCOD_heightmap_add_hill(_heightmap_cdata(hm), x, y, radius, height) -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def heightmap_dig_hill(hm: NDArray[np.float32], x: float, y: float, radius: float, height: float) -> None: """Dig a hill in a heightmap. @@ -2596,7 +2600,7 @@ def heightmap_dig_hill(hm: NDArray[np.float32], x: float, y: float, radius: floa lib.TCOD_heightmap_dig_hill(_heightmap_cdata(hm), x, y, radius, height) -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def heightmap_rain_erosion( hm: NDArray[np.float32], nbDrops: int, # noqa: N803 @@ -2625,7 +2629,7 @@ def heightmap_rain_erosion( ) -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def heightmap_kernel_transform( hm: NDArray[np.float32], kernelsize: int, @@ -2681,7 +2685,7 @@ def heightmap_kernel_transform( lib.TCOD_heightmap_kernel_transform(_heightmap_cdata(hm), kernelsize, c_dx, c_dy, c_weight, minLevel, maxLevel) -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def heightmap_add_voronoi( hm: NDArray[np.float32], nbPoints: Any, # noqa: N803 @@ -2804,7 +2808,7 @@ def heightmap_scale_fbm( ) -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def heightmap_dig_bezier( hm: NDArray[np.float32], px: tuple[int, int, int, int], @@ -2863,7 +2867,7 @@ def heightmap_get_value(hm: NDArray[np.float32], x: int, y: int) -> float: raise ValueError(msg) -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def heightmap_get_interpolated_value(hm: NDArray[np.float32], x: float, y: float) -> float: """Return the interpolated height at non integer coordinates. @@ -2878,7 +2882,7 @@ def heightmap_get_interpolated_value(hm: NDArray[np.float32], x: float, y: float return float(lib.TCOD_heightmap_get_interpolated_value(_heightmap_cdata(hm), x, y)) -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def heightmap_get_slope(hm: NDArray[np.float32], x: int, y: int) -> float: """Return the slope between 0 and (pi / 2) at given coordinates. @@ -2893,7 +2897,7 @@ def heightmap_get_slope(hm: NDArray[np.float32], x: int, y: int) -> float: return float(lib.TCOD_heightmap_get_slope(_heightmap_cdata(hm), x, y)) -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def heightmap_get_normal(hm: NDArray[np.float32], x: float, y: float, waterLevel: float) -> tuple[float, float, float]: # noqa: N803 """Return the map normal at given coordinates. @@ -2930,7 +2934,7 @@ def heightmap_count_cells(hm: NDArray[np.float32], mi: float, ma: float) -> int: return int(lib.TCOD_heightmap_count_cells(_heightmap_cdata(hm), mi, ma)) -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def heightmap_has_land_on_border(hm: NDArray[np.float32], waterlevel: float) -> bool: """Returns True if the map edges are below ``waterlevel``, otherwise False. @@ -3427,22 +3431,22 @@ def mouse_get_status() -> Mouse: return Mouse(lib.TCOD_mouse_get_status()) -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def namegen_parse(filename: str | PathLike[str], random: tcod.random.Random | None = None) -> None: lib.TCOD_namegen_parse(_path_encode(Path(filename).resolve(strict=True)), random or ffi.NULL) -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def namegen_generate(name: str) -> str: return _unpack_char_p(lib.TCOD_namegen_generate(_bytes(name), False)) # noqa: FBT003 -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def namegen_generate_custom(name: str, rule: str) -> str: return _unpack_char_p(lib.TCOD_namegen_generate_custom(_bytes(name), _bytes(rule), False)) # noqa: FBT003 -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def namegen_get_sets() -> list[str]: sets = lib.TCOD_namegen_get_sets() try: @@ -3454,7 +3458,7 @@ def namegen_get_sets() -> list[str]: return lst -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def namegen_destroy() -> None: lib.TCOD_namegen_destroy() @@ -3721,7 +3725,7 @@ def parser_get_list_property(parser: Any, name: str, type: Any) -> Any: # noqa: DISTRIBUTION_GAUSSIAN_RANGE_INVERSE = 4 -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def random_get_instance() -> tcod.random.Random: """Return the default Random instance. @@ -3731,7 +3735,7 @@ def random_get_instance() -> tcod.random.Random: return tcod.random.Random._new_from_cdata(lib.TCOD_random_get_instance()) -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def random_new(algo: int = RNG_CMWC) -> tcod.random.Random: """Return a new Random instance. Using ``algo``. @@ -3744,7 +3748,7 @@ def random_new(algo: int = RNG_CMWC) -> tcod.random.Random: return tcod.random.Random(algo) -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def random_new_from_seed(seed: Hashable, algo: int = RNG_CMWC) -> tcod.random.Random: """Return a new Random instance. Using the given ``seed`` and ``algo``. @@ -3759,7 +3763,7 @@ def random_new_from_seed(seed: Hashable, algo: int = RNG_CMWC) -> tcod.random.Ra return tcod.random.Random(algo, seed) -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def random_set_distribution(rnd: tcod.random.Random | None, dist: int) -> None: """Change the distribution mode of a random number generator. @@ -3770,7 +3774,7 @@ def random_set_distribution(rnd: tcod.random.Random | None, dist: int) -> None: lib.TCOD_random_set_distribution(rnd.random_c if rnd else ffi.NULL, dist) -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def random_get_int(rnd: tcod.random.Random | None, mi: int, ma: int) -> int: """Return a random integer in the range: ``mi`` <= n <= ``ma``. @@ -3787,7 +3791,7 @@ def random_get_int(rnd: tcod.random.Random | None, mi: int, ma: int) -> int: return int(lib.TCOD_random_get_int(rnd.random_c if rnd else ffi.NULL, mi, ma)) -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def random_get_float(rnd: tcod.random.Random | None, mi: float, ma: float) -> float: """Return a random float in the range: ``mi`` <= n <= ``ma``. @@ -3816,7 +3820,7 @@ def random_get_double(rnd: tcod.random.Random | None, mi: float, ma: float) -> f return float(lib.TCOD_random_get_double(rnd.random_c if rnd else ffi.NULL, mi, ma)) -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def random_get_int_mean(rnd: tcod.random.Random | None, mi: int, ma: int, mean: int) -> int: """Return a random weighted integer in the range: ``mi`` <= n <= ``ma``. @@ -3834,7 +3838,7 @@ def random_get_int_mean(rnd: tcod.random.Random | None, mi: int, ma: int, mean: return int(lib.TCOD_random_get_int_mean(rnd.random_c if rnd else ffi.NULL, mi, ma, mean)) -@pending_deprecate() +@deprecate(_PENDING_DEPRECATE_MSG, category=PendingDeprecationWarning) def random_get_float_mean(rnd: tcod.random.Random | None, mi: float, ma: float, mean: float) -> float: """Return a random weighted float in the range: ``mi`` <= n <= ``ma``. From a93ca11691394e13a93deab53f7269fe1e69b354 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Sat, 24 Jan 2026 17:22:56 -0800 Subject: [PATCH 40/78] Add Giscus widget to documentation Might help with feedback --- .vscode/settings.json | 3 +++ docs/_templates/page.html | 21 +++++++++++++++++++++ docs/conf.py | 12 ++++++------ 3 files changed, 30 insertions(+), 6 deletions(-) create mode 100644 docs/_templates/page.html diff --git a/.vscode/settings.json b/.vscode/settings.json index c1555fa8..dd3ea0db 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -556,5 +556,8 @@ "python.testing.pytestEnabled": true, "[python]": { "editor.defaultFormatter": "charliermarsh.ruff" + }, + "[html]": { + "editor.formatOnSave": false } } diff --git a/docs/_templates/page.html b/docs/_templates/page.html new file mode 100644 index 00000000..213c761f --- /dev/null +++ b/docs/_templates/page.html @@ -0,0 +1,21 @@ +{% extends "!page.html" %} +{% block content %} + {{ super() }} + + +{% endblock %} diff --git a/docs/conf.py b/docs/conf.py index ebca4ee1..70be25bf 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,5 +1,5 @@ """Sphinx config file.""" # noqa: INP001 -# tdl documentation build configuration file, created by +# python-tcod documentation build configuration file, created by # sphinx-quickstart on Fri Nov 25 12:49:46 2016. # # This file is execfile()d with the current directory set to its @@ -73,13 +73,12 @@ # built documents. # # The full version, including alpha/beta/rc tags. -git_describe = subprocess.run( +release = subprocess.run( ["git", "describe", "--abbrev=0"], # noqa: S607 stdout=subprocess.PIPE, text=True, check=True, -) -release = git_describe.stdout.strip() +).stdout.strip() assert release print(f"release version: {release!r}") @@ -87,6 +86,7 @@ match_version = re.match(r"([0-9]+\.[0-9]+).*?", release) assert match_version version = match_version.group() +print(f"short version: {version!r}") # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -167,9 +167,9 @@ # html_theme_path = [] # The name for this set of Sphinx documents. -# " v documentation" by default. +# " documentation" by default. # -# html_title = u'tdl v1' +html_title = f"{project} {release} documentation" # A shorter title for the navigation bar. Default is the same as html_title. # From 9d4d28f31a23b6da6d1ee3935122f070644d508a Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Mon, 2 Feb 2026 14:02:07 -0800 Subject: [PATCH 41/78] Build free-threaded CPython 3.14t wheels Resolves #161 --- .github/workflows/python-package.yml | 13 ++++++++++--- .vscode/settings.json | 2 ++ build_libtcod.py | 9 +++++++-- pyproject.toml | 6 ++---- setup.py | 8 ++++++++ 5 files changed, 29 insertions(+), 9 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 595ff7a7..90c6d21f 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -109,18 +109,25 @@ jobs: needs: [ruff, mypy, sdist] runs-on: ${{ matrix.os }} timeout-minutes: 15 + continue-on-error: ${{ matrix.python-version == '3.14t' && matrix.os == 'windows-11-arm' }} # Until issues are resolved: https://github.com/actions/setup-python/issues/1267 strategy: matrix: os: ["ubuntu-latest", "windows-latest"] - python-version: ["3.10", "pypy-3.10"] + python-version: ["3.10", "3.14t", "pypy-3.10"] architecture: ["x64"] include: - os: "windows-latest" python-version: "3.10" architecture: "x86" + - os: "windows-latest" + python-version: "3.14t" + architecture: "x86" - os: "windows-11-arm" python-version: "3.11" architecture: "arm64" + - os: "windows-11-arm" + python-version: "3.14t" + architecture: "arm64" fail-fast: false steps: @@ -240,7 +247,7 @@ jobs: strategy: matrix: arch: ["x86_64", "aarch64"] - build: ["cp310-manylinux*", "pp310-manylinux*"] + build: ["cp310-manylinux*", "pp310-manylinux*", "cp314t-manylinux*"] env: BUILD_DESC: "" steps: @@ -298,7 +305,7 @@ jobs: strategy: fail-fast: true matrix: - python: ["cp310-*_universal2", "pp310-*"] + python: ["cp310-*_universal2", "cp314t-*_universal2", "pp310-*"] env: PYTHON_DESC: "" steps: diff --git a/.vscode/settings.json b/.vscode/settings.json index dd3ea0db..7428f839 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -107,6 +107,7 @@ "cooldown", "cplusplus", "CPLUSPLUS", + "cpython", "CRSEL", "ctypes", "CURRENCYSUBUNIT", @@ -167,6 +168,7 @@ "fmean", "fontx", "fonty", + "freethreading", "freetype", "frombuffer", "fullscreen", diff --git a/build_libtcod.py b/build_libtcod.py index 2b9d03d6..14a46710 100755 --- a/build_libtcod.py +++ b/build_libtcod.py @@ -29,7 +29,7 @@ if TYPE_CHECKING: from collections.abc import Iterable, Iterator -Py_LIMITED_API = 0x03100000 +Py_LIMITED_API: None | int = 0x03100000 HEADER_PARSE_PATHS = ("tcod/", "libtcod/src/libtcod/") HEADER_PARSE_EXCLUDES = ("gl2_ext_.h", "renderer_gl_internal.h", "event.h") @@ -167,9 +167,14 @@ def walk_sources(directory: str) -> Iterator[str]: library_dirs: list[str] = [*build_sdl.library_dirs] define_macros: list[tuple[str, Any]] = [] -if "PYODIDE" not in os.environ: +if "free-threading build" in sys.version: + Py_LIMITED_API = None +if "PYODIDE" in os.environ: # Unable to apply Py_LIMITED_API to Pyodide in cffi<=1.17.1 # https://github.com/python-cffi/cffi/issues/179 + Py_LIMITED_API = None + +if Py_LIMITED_API: define_macros.append(("Py_LIMITED_API", Py_LIMITED_API)) sources += walk_sources("tcod/") diff --git a/pyproject.toml b/pyproject.toml index 17977d1a..5bed1e4e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,7 @@ keywords = [ "field-of-view", "pathfinding", ] -classifiers = [ +classifiers = [ # https://pypi.org/classifiers/ "Development Status :: 5 - Production/Stable", "Environment :: Win32 (MS Windows)", "Environment :: MacOS X", @@ -53,6 +53,7 @@ classifiers = [ "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows", "Programming Language :: Python :: 3", + "Programming Language :: Python :: Free Threading :: 2 - Beta", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Games/Entertainment", @@ -72,9 +73,6 @@ Source = "https://github.com/libtcod/python-tcod" Tracker = "https://github.com/libtcod/python-tcod/issues" Forum = "https://github.com/libtcod/python-tcod/discussions" -[tool.distutils.bdist_wheel] -py-limited-api = "cp310" - [tool.setuptools_scm] write_to = "tcod/version.py" diff --git a/setup.py b/setup.py index 35c94af0..3ff98114 100755 --- a/setup.py +++ b/setup.py @@ -40,6 +40,13 @@ def get_package_data() -> list[str]: print("Did you forget to run 'git submodule update --init'?") sys.exit(1) +options = { + "bdist_wheel": { + "py_limited_api": "cp310", + } +} +if "free-threading build" in sys.version: + del options["bdist_wheel"]["py_limited_api"] setup( py_modules=["libtcodpy"], @@ -47,4 +54,5 @@ def get_package_data() -> list[str]: package_data={"tcod": get_package_data()}, cffi_modules=["build_libtcod.py:ffi"], platforms=["Windows", "MacOS", "Linux"], + options=options, ) From 32cf62eae466ccbd3c3df31a2ad83e5d1349579d Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Mon, 2 Feb 2026 14:16:01 -0800 Subject: [PATCH 42/78] Pull request limits are already handled by concurrency --- .github/workflows/python-package.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 90c6d21f..69d9c49b 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -6,7 +6,6 @@ name: Package on: push: pull_request: - types: [opened, reopened] concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} From 8f45aabec94e69cc03b917b6b281f425ff4a76d7 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Mon, 2 Feb 2026 14:16:34 -0800 Subject: [PATCH 43/78] Allow manual workflow dispatch --- .github/workflows/python-package.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 69d9c49b..d6df14a6 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -4,6 +4,7 @@ name: Package on: + workflow_dispatch: push: pull_request: From 508792a2cd8951c11b6f67a26bcccd546f9a2d5f Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Thu, 5 Feb 2026 13:27:41 -0800 Subject: [PATCH 44/78] Upstream issues with installing Python 3.14t on Windows ARM were fixed --- .github/workflows/python-package.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index d6df14a6..d52a4140 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -109,7 +109,6 @@ jobs: needs: [ruff, mypy, sdist] runs-on: ${{ matrix.os }} timeout-minutes: 15 - continue-on-error: ${{ matrix.python-version == '3.14t' && matrix.os == 'windows-11-arm' }} # Until issues are resolved: https://github.com/actions/setup-python/issues/1267 strategy: matrix: os: ["ubuntu-latest", "windows-latest"] From bca7e5fef0caf2fa86c1f3e6bfe743528e76273e Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Thu, 5 Feb 2026 18:50:48 -0800 Subject: [PATCH 45/78] Prepare 20.0.0 release. --- CHANGELOG.md | 3 +++ tcod/event.py | 2 +- tcod/sdl/render.py | 6 +++--- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e59d96e9..6a4c8ea5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,11 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version ## [Unreleased] +## [20.0.0] - 2026-02-06 + ### Added +- Now supports free-threaded Python, deploys with `cp314t` wheels. - Added methods: `Renderer.coordinates_from_window` and `Renderer.coordinates_to_window` - Added `tcod.event.convert_coordinates_from_window`. diff --git a/tcod/event.py b/tcod/event.py index 63a007c4..53b06cb5 100644 --- a/tcod/event.py +++ b/tcod/event.py @@ -1600,7 +1600,7 @@ def convert_coordinates_from_window( dest_rect: The consoles rendering destination as `(x, y, width, height)`. If None is given then the whole rendering target is assumed. - .. versionadded:: Unreleased + .. versionadded:: 20.0 """ if isinstance(context, tcod.context.Context): maybe_renderer: Final = context.sdl_renderer diff --git a/tcod/sdl/render.py b/tcod/sdl/render.py index 496149e4..7e4a5df6 100644 --- a/tcod/sdl/render.py +++ b/tcod/sdl/render.py @@ -492,7 +492,7 @@ def logical_size(self) -> tuple[int, int] | None: .. versionchanged:: 19.0 Setter is deprecated, use :any:`set_logical_presentation` instead. - .. versionchanged:: Unreleased + .. versionchanged:: 20.0 Return ``None`` instead of ``(0, 0)`` when logical size is disabled. """ out = ffi.new("int[2]") @@ -761,7 +761,7 @@ def coordinates_from_window(self, xy: tuple[float, float], /) -> tuple[float, fl .. seealso:: https://wiki.libsdl.org/SDL3/SDL_RenderCoordinatesFromWindow - .. versionadded:: Unreleased + .. versionadded:: 20.0 """ x, y = xy out_xy = ffi.new("float[2]") @@ -774,7 +774,7 @@ def coordinates_to_window(self, xy: tuple[float, float], /) -> tuple[float, floa .. seealso:: https://wiki.libsdl.org/SDL3/SDL_RenderCoordinatesToWindow - .. versionadded:: Unreleased + .. versionadded:: 20.0 """ x, y = xy out_xy = ffi.new("float[2]") From bb4013bc387f8de0cc1652ad985202dc3ecd6325 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Wed, 11 Feb 2026 16:05:59 -0800 Subject: [PATCH 46/78] Classify as being partially C --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 5bed1e4e..25cc234f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ classifiers = [ # https://pypi.org/classifiers/ "Operating System :: POSIX", "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows", + "Programming Language :: C", "Programming Language :: Python :: 3", "Programming Language :: Python :: Free Threading :: 2 - Beta", "Programming Language :: Python :: Implementation :: CPython", From 37dccbae52a5a6a8c88328b41d629eeaef2a570d Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Fri, 20 Feb 2026 04:30:03 -0800 Subject: [PATCH 47/78] Document mouse coordinates changed to float in tcod 19.0 --- tcod/event.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tcod/event.py b/tcod/event.py index 53b06cb5..ca3a3eb1 100644 --- a/tcod/event.py +++ b/tcod/event.py @@ -156,6 +156,9 @@ class Point(NamedTuple): .. seealso:: :any:`MouseMotion` :any:`MouseButtonDown` :any:`MouseButtonUp` + + .. versionchanged:: 19.0 + Now uses floating point coordinates due to the port to SDL3. """ x: float @@ -481,6 +484,9 @@ class MouseMotion(MouseState): .. versionchanged:: 15.0 Renamed `pixel` attribute to `position`. Renamed `pixel_motion` attribute to `motion`. + + .. versionchanged:: 19.0 + `position` and `motion` now use floating point coordinates. """ def __init__( @@ -558,7 +564,7 @@ class MouseButtonEvent(MouseState): type (str): Will be "MOUSEBUTTONDOWN" or "MOUSEBUTTONUP", depending on the event. position (Point): The pixel coordinates of the mouse. - tile (Point): The integer tile coordinates of the mouse on the screen. + tile (Point): The tile coordinates of the mouse on the screen. button (int): Which mouse button. This will be one of the following names: @@ -569,6 +575,8 @@ class MouseButtonEvent(MouseState): * tcod.event.BUTTON_X1 * tcod.event.BUTTON_X2 + .. versionchanged:: 19.0 + `position` and `tile` now use floating point coordinates. """ def __init__( From 993e3184c1fd9a2081f22c21fbfed7b77e394d2e Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Mon, 23 Feb 2026 22:21:33 -0800 Subject: [PATCH 48/78] Convert Tileset into a MutableMapping Add related tileset tests --- .vscode/settings.json | 1 + CHANGELOG.md | 13 +++ tcod/context.py | 2 +- tcod/tileset.py | 237 +++++++++++++++++++++++++++++++++++------- tests/test_tileset.py | 99 +++++++++++++++++- 5 files changed, 309 insertions(+), 43 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 7428f839..41cffd1b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -467,6 +467,7 @@ "SUBP", "SYSREQ", "tablefmt", + "Tamzen", "TARGETTEXTURE", "tcod", "tcoddoc", diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a4c8ea5..68d526b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version ## [Unreleased] +### Added + +- `Tileset` now supports `MutableMapping` semantics. + Can get, set, or iterate over tiles as if it were a dictionary containing tile glyph arrays. + Also supports `+=` and `|=` with other tilesets or mappings. +- `tcod.tileset.procedural_block_elements` can take a tile shape and return a tileset. + +### Deprecated + +- `Tileset.set_tile(codepoint, tile)` was replaced with `tileset[codepoint] = tile` syntax. +- `Tileset.get_tile(codepoint)` was soft replaced with `tileset[codepoint]` syntax. +- `tcod.tileset.procedural_block_elements` should be used with dictionary semantics instead of passing in a tileset. + ## [20.0.0] - 2026-02-06 ### Added diff --git a/tcod/context.py b/tcod/context.py index f152b9d0..5b1bb214 100644 --- a/tcod/context.py +++ b/tcod/context.py @@ -125,7 +125,7 @@ def _handle_tileset(tileset: tcod.tileset.Tileset | None) -> Any: # noqa: ANN401 """Get the TCOD_Tileset pointer from a Tileset or return a NULL pointer.""" - return tileset._tileset_p if tileset else ffi.NULL + return tileset._tileset_p if tileset is not None else ffi.NULL def _handle_title(title: str | None) -> Any: # noqa: ANN401 diff --git a/tcod/tileset.py b/tcod/tileset.py index 4b3935ac..9ea4b1d7 100644 --- a/tcod/tileset.py +++ b/tcod/tileset.py @@ -2,25 +2,25 @@ If you want to load a tileset from a common tileset image then you only need :any:`tcod.tileset.load_tilesheet`. -Tilesets can be loaded as a whole from tile-sheets or True-Type fonts, or they -can be put together from multiple tile images by loading them separately -using :any:`Tileset.set_tile`. +Tilesets can be loaded as a whole from tile-sheets or True-Type fonts, +or they can be put together from multiple tile images by loading them separately using :any:`Tileset.__setitem__`. -A major restriction with libtcod is that all tiles must be the same size and -tiles can't overlap when rendered. For sprite-based rendering it can be -useful to use `an alternative library for graphics rendering +A major restriction with libtcod is that all tiles must be the same size and tiles can't overlap when rendered. +For sprite-based rendering it can be useful to use `an alternative library for graphics rendering `_ while continuing to use python-tcod's pathfinding and field-of-view algorithms. """ from __future__ import annotations +import copy import itertools +from collections.abc import Iterator, Mapping, MutableMapping from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, SupportsInt, overload import numpy as np -from typing_extensions import deprecated +from typing_extensions import Self, deprecated from tcod._internal import _check, _check_p, _console, _path_encode, _raise_tcod_error from tcod.cffi import ffi, lib @@ -34,10 +34,11 @@ import tcod.console -class Tileset: +class Tileset(MutableMapping[int, "NDArray[np.uint8]"]): """A collection of graphical tiles. - This class is provisional, the API may change in the future. + .. versionchanged:: Unreleased + Is now a :class:`collections.abc.MutableMapping` type. """ def __init__(self, tile_width: int, tile_height: int) -> None: @@ -80,28 +81,105 @@ def tile_shape(self) -> tuple[int, int]: """Shape (height, width) of the tile in pixels.""" return self.tile_height, self.tile_width - def __contains__(self, codepoint: int) -> bool: + def __copy__(self) -> Self: + """Return a clone of this tileset. + + This is not an exact copy. :any:`remap` will not work on the clone. + + .. versionadded:: Unreleased + """ + clone = self.__class__(self.tile_width, self.tile_height) + for codepoint, tile in self.items(): + clone[codepoint] = tile + return clone + + @staticmethod + def _iter_items( + tiles: Iterable[tuple[int, ArrayLike | NDArray[np.uint8]]] | Mapping[int, ArrayLike | NDArray[np.uint8]], / + ) -> Iterable[tuple[int, ArrayLike | NDArray[np.uint8]]]: + """Convert a potential mapping to an iterator.""" + if isinstance(tiles, Mapping): + return tiles.items() # pyright: ignore[reportReturnType] + return tiles + + def __iadd__( + self, + other: Iterable[tuple[int, ArrayLike | NDArray[np.uint8]]] | Mapping[int, ArrayLike | NDArray[np.uint8]], + /, + ) -> Self: + """Add tiles to this tileset inplace, prefers replacing tiles. + + .. versionadded:: Unreleased + """ + for codepoint, tile in self._iter_items(other): + self[codepoint] = tile + return self + + def __add__( + self, + other: Iterable[tuple[int, ArrayLike | NDArray[np.uint8]]] | Mapping[int, ArrayLike | NDArray[np.uint8]], + /, + ) -> Self: + """Combine tiles with this tileset, prefers replacing tiles. + + .. versionadded:: Unreleased + """ + clone = copy.copy(self) + clone += other + return clone + + def __ior__( + self, + other: Iterable[tuple[int, ArrayLike | NDArray[np.uint8]]] | Mapping[int, ArrayLike | NDArray[np.uint8]], + /, + ) -> Self: + """Add tiles to this tileset inplace, keeps existing tiles instead of replacing them. + + .. versionadded:: Unreleased + """ + for codepoint, tile in self._iter_items(other): + if codepoint not in self: + self[codepoint] = tile + return self + + def __or__( + self, + other: Iterable[tuple[int, ArrayLike | NDArray[np.uint8]]] | Mapping[int, ArrayLike | NDArray[np.uint8]], + /, + ) -> Self: + """Combine tiles with this tileset, prefers keeping existing tiles instead of replacing them. + + .. versionadded:: Unreleased + """ + clone = copy.copy(self) + clone |= other + return clone + + def __contains__(self, codepoint: object, /) -> bool: """Test if a tileset has a codepoint with ``n in tileset``.""" - return bool(lib.TCOD_tileset_get_tile_(self._tileset_p, codepoint, ffi.NULL) == 0) + if not isinstance(codepoint, SupportsInt): + return False + codepoint = int(codepoint) + if not 0 <= codepoint < self._tileset_p.character_map_length: + return False + return bool(self._get_character_map()[int(codepoint)] > 0) - def get_tile(self, codepoint: int) -> NDArray[np.uint8]: - """Return a copy of a tile for the given codepoint. + def __getitem__(self, codepoint: int, /) -> NDArray[np.uint8]: + """Return the RGBA tile data for the given codepoint. - If the tile does not exist yet then a blank array will be returned. + The tile will have a shape of (height, width, rgba) and a dtype of uint8. + Note that most grey-scale tilesets will only use the alpha channel with a solid white color channel. - The tile will have a shape of (height, width, rgba) and a dtype of - uint8. Note that most grey-scale tiles will only use the alpha - channel and will usually have a solid white color channel. + .. versionadded:: Unreleased """ - tile: NDArray[np.uint8] = np.zeros((*self.tile_shape, 4), dtype=np.uint8) - lib.TCOD_tileset_get_tile_( - self._tileset_p, - codepoint, - ffi.from_buffer("struct TCOD_ColorRGBA*", tile), + if codepoint not in self: + raise KeyError(codepoint) + tile_p = lib.TCOD_tileset_get_tile(self._tileset_p, codepoint) + return np.frombuffer(ffi.buffer(tile_p[0 : self.tile_shape[0] * self.tile_shape[1]]), dtype=np.uint8).reshape( + *self.tile_shape, 4, copy=True ) - return tile - def set_tile(self, codepoint: int, tile: ArrayLike | NDArray[np.uint8]) -> None: + def __setitem__(self, codepoint: int, tile: ArrayLike | NDArray[np.uint8], /) -> None: """Upload a tile into this array. Args: @@ -127,14 +205,14 @@ def set_tile(self, codepoint: int, tile: ArrayLike | NDArray[np.uint8]) -> None: # Normal usage when a tile already has its own alpha channel. # The loaded tile must be the correct shape for the tileset you assign it to. # The tile is assigned to a private use area and will not conflict with any exiting codepoint. - tileset.set_tile(0x100000, imageio.imread("rgba_tile.png")) + tileset[0x100000] = imageio.imread("rgba_tile.png") # Load a greyscale tile. - tileset.set_tile(0x100001, imageio.imread("greyscale_tile.png"), mode="L") + tileset[0x100001] = imageio.imread("greyscale_tile.png", mode="L") # If you are stuck with an RGB array then you can use the red channel as the input: `rgb[:, :, 0]` # Loads an RGB sprite without a background. - tileset.set_tile(0x100002, imageio.imread("rgb_no_background.png", mode="RGBA")) + tileset[0x100002] = imageio.imread("rgb_no_background.png", mode="RGBA") # If you're stuck with an RGB array then you can pad the channel axis with an alpha of 255: # rgba = np.pad(rgb, pad_width=((0, 0), (0, 0), (0, 1)), constant_values=255) @@ -147,8 +225,9 @@ def set_tile(self, codepoint: int, tile: ArrayLike | NDArray[np.uint8]) -> None: sprite_alpha = sprite_mask.astype(np.uint8) * 255 # Combine the RGB and alpha arrays into an RGBA array. sprite_rgba = np.append(sprite_rgb, sprite_alpha, axis=2) - tileset.set_tile(0x100003, sprite_rgba) + tileset[0x100003] = sprite_rgba + .. versionadded:: Unreleased """ tile = np.ascontiguousarray(tile, dtype=np.uint8) if tile.shape == self.tile_shape: @@ -173,11 +252,71 @@ def set_tile(self, codepoint: int, tile: ArrayLike | NDArray[np.uint8]) -> None: ) return None + def _get_character_map(self) -> NDArray[np.intc]: + """Return the internal character mapping as an array. + + This reference will break if the tileset is modified. + """ + return np.frombuffer( + ffi.buffer(self._tileset_p.character_map[0 : self._tileset_p.character_map_length]), dtype=np.intc + ) + + def __delitem__(self, codepoint: int, /) -> None: + """Unmap a `codepoint` from this tileset. + + Tilesets are optimized for set-and-forget, deleting a tile may not free up memory. + + .. versionadded:: Unreleased + """ + if codepoint not in self: + raise KeyError(codepoint) + self._get_character_map()[codepoint] = 0 + + def __len__(self) -> int: + """Return the total count of codepoints assigned in this tileset. + + .. versionadded:: Unreleased + """ + return int((self._get_character_map() > 0).sum()) + + def __iter__(self) -> Iterator[int]: + """Iterate over the assigned codepoints of this tileset. + + .. versionadded:: Unreleased + """ + # tolist makes a copy, otherwise the reference to character_map can dangle during iteration + for i, v in enumerate(self._get_character_map().tolist()): + if v: + yield i + + def get_tile(self, codepoint: int) -> NDArray[np.uint8]: + """Return a copy of a tile for the given codepoint. + + If the tile does not exist then a blank zero array will be returned. + + The tile will have a shape of (height, width, rgba) and a dtype of + uint8. Note that most grey-scale tiles will only use the alpha + channel and will usually have a solid white color channel. + """ + try: + return self[codepoint] + except KeyError: + return np.zeros((*self.tile_shape, 4), dtype=np.uint8) + + @deprecated("Assign to a tile using 'tileset[codepoint] = tile' instead.") + def set_tile(self, codepoint: int, tile: ArrayLike | NDArray[np.uint8]) -> None: + """Upload a tile into this array. + + .. deprecated:: Unreleased + Was replaced with :any:`Tileset.__setitem__`. + Use ``tileset[codepoint] = tile`` syntax instead of this method. + """ + self[codepoint] = tile + def render(self, console: tcod.console.Console) -> NDArray[np.uint8]: """Render an RGBA array, using console with this tileset. - `console` is the Console object to render, this can not be the root - console. + `console` is the Console object to render, this can not be the root console. The output array will be a np.uint8 array with the shape of: ``(con_height * tile_height, con_width * tile_width, 4)``. @@ -222,8 +361,8 @@ def remap(self, codepoint: int, x: int, y: int = 0) -> None: Large values of `x` will wrap to the next row, so using `x` by itself is equivalent to `Tile Index` in the :any:`charmap-reference`. - This is normally used on loaded tilesheets. Other methods of Tileset - creation won't have reliable tile indexes. + This is typically used on tilesets loaded with :any:`load_tilesheet`. + Other methods of Tileset creation will not have reliable tile indexes. .. versionadded:: 11.12 """ @@ -379,11 +518,23 @@ def load_tilesheet(path: str | PathLike[str], columns: int, rows: int, charmap: return Tileset._claim(cdata) -def procedural_block_elements(*, tileset: Tileset) -> None: - """Overwrite the block element codepoints in `tileset` with procedurally generated glyphs. +@overload +@deprecated( + "Prefer assigning tiles using dictionary semantics:\n" + "'tileset += tcod.tileset.procedural_block_elements(shape=tileset.tile_shape)'" +) +def procedural_block_elements(*, tileset: Tileset) -> Tileset: ... +@overload +def procedural_block_elements(*, shape: tuple[int, int]) -> Tileset: ... + + +def procedural_block_elements(*, tileset: Tileset | None = None, shape: tuple[int, int] | None = None) -> Tileset: + """Generate and return a :any:`Tileset` with procedurally generated block elements. Args: - tileset (Tileset): A :any:`Tileset` with tiles of any shape. + tileset: A :any:`Tileset` with tiles of any shape. The codepoints of this tileset will be overwritten. + This parameter is deprecated and only shape `should` be used. + shape: The ``(height, width)`` tile size to generate. This will overwrite all of the codepoints `listed here `_ except for the shade glyphs. @@ -393,11 +544,15 @@ def procedural_block_elements(*, tileset: Tileset) -> None: .. versionadded:: 13.1 + .. versionchanged:: Unreleased + Added `shape` parameter, now returns a `Tileset`. + `tileset` parameter is deprecated. + Example:: >>> import tcod.tileset >>> tileset = tcod.tileset.Tileset(8, 8) - >>> tcod.tileset.procedural_block_elements(tileset=tileset) + >>> tileset += tcod.tileset.procedural_block_elements(shape=tileset.tile_shape) >>> tileset.get_tile(0x259E)[:, :, 3] # "▞" Quadrant upper right and lower left. array([[ 0, 0, 0, 0, 255, 255, 255, 255], [ 0, 0, 0, 0, 255, 255, 255, 255], @@ -426,6 +581,9 @@ def procedural_block_elements(*, tileset: Tileset) -> None: [255, 255, 255, 0, 0, 0, 0, 0], [255, 255, 255, 0, 0, 0, 0, 0]], dtype=uint8) """ + if tileset is None: + assert shape is not None + tileset = Tileset(shape[1], shape[0]) quadrants: NDArray[np.uint8] = np.zeros(tileset.tile_shape, dtype=np.uint8) half_height = tileset.tile_height // 2 half_width = tileset.tile_width // 2 @@ -453,7 +611,7 @@ def procedural_block_elements(*, tileset: Tileset) -> None: ): alpha: NDArray[np.uint8] = np.asarray((quadrants & quad_mask) != 0, dtype=np.uint8) alpha *= 255 - tileset.set_tile(codepoint, alpha) + tileset[codepoint] = alpha for codepoint, axis, fraction, negative in ( (0x2581, 0, 7, True), # "▁" Lower one eighth block. @@ -477,7 +635,8 @@ def procedural_block_elements(*, tileset: Tileset) -> None: indexes[axis] = slice(divide, None) if negative else slice(None, divide) alpha = np.zeros(tileset.tile_shape, dtype=np.uint8) alpha[tuple(indexes)] = 255 - tileset.set_tile(codepoint, alpha) + tileset[codepoint] = alpha + return tileset CHARMAP_CP437 = [ diff --git a/tests/test_tileset.py b/tests/test_tileset.py index c7281cef..78b458dd 100644 --- a/tests/test_tileset.py +++ b/tests/test_tileset.py @@ -1,10 +1,103 @@ """Test for tcod.tileset module.""" +from pathlib import Path + +import pytest + +import tcod.console import tcod.tileset +PROJECT_DIR = Path(__file__).parent / ".." + +TERMINAL_FONT = PROJECT_DIR / "fonts/libtcod/terminal8x8_aa_ro.png" +BDF_FONT = PROJECT_DIR / "libtcod/data/fonts/Tamzen5x9r.bdf" + +BAD_FILE = PROJECT_DIR / "CHANGELOG.md" # Any existing non-font file + def test_proc_block_elements() -> None: - tileset = tcod.tileset.Tileset(8, 8) - tcod.tileset.procedural_block_elements(tileset=tileset) tileset = tcod.tileset.Tileset(0, 0) - tcod.tileset.procedural_block_elements(tileset=tileset) + with pytest.deprecated_call(): + tcod.tileset.procedural_block_elements(tileset=tileset) + tileset += tcod.tileset.procedural_block_elements(shape=tileset.tile_shape) + + tileset = tcod.tileset.Tileset(8, 8) + with pytest.deprecated_call(): + tcod.tileset.procedural_block_elements(tileset=tileset) + tileset += tcod.tileset.procedural_block_elements(shape=tileset.tile_shape) + + +def test_tileset_mix() -> None: + tileset1 = tcod.tileset.Tileset(1, 1) + tileset1[ord("a")] = [[0]] + + tileset2 = tcod.tileset.Tileset(1, 1) + tileset2[ord("a")] = [[1]] + tileset2[ord("b")] = [[1]] + + assert (tileset1 + tileset2)[ord("a")].tolist() == [[[255, 255, 255, 1]]] # Replaces tile + assert (tileset1 | tileset2)[ord("a")].tolist() == [[[255, 255, 255, 0]]] # Skips existing tile + + +def test_tileset_contains() -> None: + tileset = tcod.tileset.Tileset(1, 1) + + # Missing keys + assert None not in tileset + assert ord("x") not in tileset + assert -1 not in tileset + with pytest.raises(KeyError, match=rf"{ord('x')}"): + tileset[ord("x")] + with pytest.raises(KeyError, match=rf"{ord('x')}"): + del tileset[ord("x")] + assert len(tileset) == 0 + + # Assigned tile is found + tileset[ord("x")] = [[255]] + assert ord("x") in tileset + assert len(tileset) == 1 + + # Can be deleted and reassigned + del tileset[ord("x")] + assert ord("x") not in tileset + assert len(tileset) == 0 + tileset[ord("x")] = [[255]] + assert ord("x") in tileset + assert len(tileset) == 1 + + +def test_tileset_assignment() -> None: + tileset = tcod.tileset.Tileset(1, 2) + tileset[ord("a")] = [[1], [1]] + tileset[ord("b")] = [[[255, 255, 255, 2]], [[255, 255, 255, 2]]] + + with pytest.raises(ValueError, match=r".*must be \(2, 1, 4\) or \(2, 1\), got \(2, 1, 3\)"): + tileset[ord("c")] = [[[255, 255, 255]], [[255, 255, 255]]] + + assert tileset.get_tile(ord("d")).shape == (2, 1, 4) + + +def test_tileset_render() -> None: + tileset = tcod.tileset.Tileset(1, 2) + tileset[ord("x")] = [[255], [0]] + console = tcod.console.Console(3, 2) + console.rgb[0, 0] = (ord("x"), (255, 0, 0), (0, 255, 0)) + output = tileset.render(console) + assert output.shape == (4, 3, 4) + assert output[0:2, 0].tolist() == [[255, 0, 0, 255], [0, 255, 0, 255]] + + +def test_tileset_tilesheet() -> None: + tileset = tcod.tileset.load_tilesheet(TERMINAL_FONT, 16, 16, tcod.tileset.CHARMAP_CP437) + assert tileset.tile_shape == (8, 8) + + with pytest.raises(RuntimeError): + tcod.tileset.load_tilesheet(BAD_FILE, 16, 16, tcod.tileset.CHARMAP_CP437) + + +def test_tileset_bdf() -> None: + tileset = tcod.tileset.load_bdf(BDF_FONT) + assert tileset.tile_shape == (9, 5) + + with pytest.raises(RuntimeError): + tileset = tcod.tileset.load_bdf(BAD_FILE) From 0e4275786a4dffae417993dae234365ea20f005a Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Tue, 24 Feb 2026 01:03:48 -0800 Subject: [PATCH 49/78] Remove unnecessary Ruff ignores Ignores no longer needed, likely due to the convention being provided --- pyproject.toml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 25cc234f..211448c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -153,20 +153,12 @@ line-length = 120 select = ["ALL"] ignore = [ "COM", # flake8-commas - "D203", # one-blank-line-before-class - "D204", # one-blank-line-after-class - "D213", # multi-line-summary-second-line - "D407", # dashed-underline-after-section - "D408", # section-underline-after-name - "D409", # section-underline-matches-section-length - "D206", # indent-with-spaces "E501", # line-too-long "PYI064", # redundant-final-literal "S101", # assert "S301", # suspicious-pickle-usage "S311", # suspicious-non-cryptographic-random-usage "SLF001", # private-member-access - "W191", # tab-indentation ] [tool.ruff.lint.per-file-ignores] "**/{tests}/*" = [ From 4e50f3347a6da51e2cc5d1760489909249401506 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Tue, 24 Feb 2026 01:15:13 -0800 Subject: [PATCH 50/78] Remove old Python 2 hack --- .gitattributes | 3 --- 1 file changed, 3 deletions(-) diff --git a/.gitattributes b/.gitattributes index 740e182d..2dc9f280 100644 --- a/.gitattributes +++ b/.gitattributes @@ -19,6 +19,3 @@ # Custom for this project *.bat eol=crlf *.txt eol=crlf - -# Fix an issue with Python 2 on Linux -tcod/libtcod_cdef.h eol=lf From 22d80553b585b25c68567f74ac252f4e4cf65ab4 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Tue, 24 Feb 2026 17:32:00 -0800 Subject: [PATCH 51/78] Prepare 20.1.0 release. --- CHANGELOG.md | 4 +++- tcod/tileset.py | 26 +++++++++++++------------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68d526b9..39667759 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,13 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version ## [Unreleased] +## [20.1.0] - 2026-02-25 + ### Added - `Tileset` now supports `MutableMapping` semantics. Can get, set, or iterate over tiles as if it were a dictionary containing tile glyph arrays. - Also supports `+=` and `|=` with other tilesets or mappings. + Also supports `+`, `|`, `+=`, and `|=` with other tilesets or mappings to merge them into a single Tileset. - `tcod.tileset.procedural_block_elements` can take a tile shape and return a tileset. ### Deprecated diff --git a/tcod/tileset.py b/tcod/tileset.py index 9ea4b1d7..58ddf058 100644 --- a/tcod/tileset.py +++ b/tcod/tileset.py @@ -37,7 +37,7 @@ class Tileset(MutableMapping[int, "NDArray[np.uint8]"]): """A collection of graphical tiles. - .. versionchanged:: Unreleased + .. versionchanged:: 20.1 Is now a :class:`collections.abc.MutableMapping` type. """ @@ -86,7 +86,7 @@ def __copy__(self) -> Self: This is not an exact copy. :any:`remap` will not work on the clone. - .. versionadded:: Unreleased + .. versionadded:: 20.1 """ clone = self.__class__(self.tile_width, self.tile_height) for codepoint, tile in self.items(): @@ -109,7 +109,7 @@ def __iadd__( ) -> Self: """Add tiles to this tileset inplace, prefers replacing tiles. - .. versionadded:: Unreleased + .. versionadded:: 20.1 """ for codepoint, tile in self._iter_items(other): self[codepoint] = tile @@ -122,7 +122,7 @@ def __add__( ) -> Self: """Combine tiles with this tileset, prefers replacing tiles. - .. versionadded:: Unreleased + .. versionadded:: 20.1 """ clone = copy.copy(self) clone += other @@ -135,7 +135,7 @@ def __ior__( ) -> Self: """Add tiles to this tileset inplace, keeps existing tiles instead of replacing them. - .. versionadded:: Unreleased + .. versionadded:: 20.1 """ for codepoint, tile in self._iter_items(other): if codepoint not in self: @@ -149,7 +149,7 @@ def __or__( ) -> Self: """Combine tiles with this tileset, prefers keeping existing tiles instead of replacing them. - .. versionadded:: Unreleased + .. versionadded:: 20.1 """ clone = copy.copy(self) clone |= other @@ -170,7 +170,7 @@ def __getitem__(self, codepoint: int, /) -> NDArray[np.uint8]: The tile will have a shape of (height, width, rgba) and a dtype of uint8. Note that most grey-scale tilesets will only use the alpha channel with a solid white color channel. - .. versionadded:: Unreleased + .. versionadded:: 20.1 """ if codepoint not in self: raise KeyError(codepoint) @@ -227,7 +227,7 @@ def __setitem__(self, codepoint: int, tile: ArrayLike | NDArray[np.uint8], /) -> sprite_rgba = np.append(sprite_rgb, sprite_alpha, axis=2) tileset[0x100003] = sprite_rgba - .. versionadded:: Unreleased + .. versionadded:: 20.1 """ tile = np.ascontiguousarray(tile, dtype=np.uint8) if tile.shape == self.tile_shape: @@ -266,7 +266,7 @@ def __delitem__(self, codepoint: int, /) -> None: Tilesets are optimized for set-and-forget, deleting a tile may not free up memory. - .. versionadded:: Unreleased + .. versionadded:: 20.1 """ if codepoint not in self: raise KeyError(codepoint) @@ -275,14 +275,14 @@ def __delitem__(self, codepoint: int, /) -> None: def __len__(self) -> int: """Return the total count of codepoints assigned in this tileset. - .. versionadded:: Unreleased + .. versionadded:: 20.1 """ return int((self._get_character_map() > 0).sum()) def __iter__(self) -> Iterator[int]: """Iterate over the assigned codepoints of this tileset. - .. versionadded:: Unreleased + .. versionadded:: 20.1 """ # tolist makes a copy, otherwise the reference to character_map can dangle during iteration for i, v in enumerate(self._get_character_map().tolist()): @@ -307,7 +307,7 @@ def get_tile(self, codepoint: int) -> NDArray[np.uint8]: def set_tile(self, codepoint: int, tile: ArrayLike | NDArray[np.uint8]) -> None: """Upload a tile into this array. - .. deprecated:: Unreleased + .. deprecated:: 20.1 Was replaced with :any:`Tileset.__setitem__`. Use ``tileset[codepoint] = tile`` syntax instead of this method. """ @@ -544,7 +544,7 @@ def procedural_block_elements(*, tileset: Tileset | None = None, shape: tuple[in .. versionadded:: 13.1 - .. versionchanged:: Unreleased + .. versionchanged:: 20.1 Added `shape` parameter, now returns a `Tileset`. `tileset` parameter is deprecated. From 34a4ab0c7845187365ef1edc29ee3944c1c8548f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 09:01:50 +0000 Subject: [PATCH 52/78] Bump the github-actions group with 2 updates Bumps the github-actions group with 2 updates: [actions/upload-artifact](https://github.com/actions/upload-artifact) and [actions/download-artifact](https://github.com/actions/download-artifact). Updates `actions/upload-artifact` from 6 to 7 - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v6...v7) Updates `actions/download-artifact` from 7 to 8 - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/v7...v8) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: actions/download-artifact dependency-version: '8' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] --- .github/workflows/python-package.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index d52a4140..0ae29156 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -71,7 +71,7 @@ jobs: run: pip install build - name: Build source distribution run: python -m build --sdist - - uses: actions/upload-artifact@v6 + - uses: actions/upload-artifact@v7 with: name: sdist path: dist/tcod-*.tar.gz @@ -171,7 +171,7 @@ jobs: if: runner.os != 'Windows' run: cat /tmp/xvfb.log - uses: codecov/codecov-action@v5 - - uses: actions/upload-artifact@v6 + - uses: actions/upload-artifact@v7 if: runner.os == 'Windows' with: name: wheels-windows-${{ matrix.architecture }}-${{ matrix.python-version }} @@ -290,7 +290,7 @@ jobs: BUILD_DESC=${BUILD_DESC//\*} echo BUILD_DESC=${BUILD_DESC} >> $GITHUB_ENV - name: Archive wheel - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: wheels-linux-${{ matrix.arch }}-${{ env.BUILD_DESC }} path: wheelhouse/*.whl @@ -339,7 +339,7 @@ jobs: PYTHON_DESC=${PYTHON_DESC//\*/X} echo PYTHON_DESC=${PYTHON_DESC} >> $GITHUB_ENV - name: Archive wheel - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: wheels-macos-${{ env.PYTHON_DESC }} path: wheelhouse/*.whl @@ -367,7 +367,7 @@ jobs: CIBW_BUILD: cp313-pyodide_wasm32 CIBW_PLATFORM: pyodide - name: Archive wheel - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: pyodide path: wheelhouse/*.whl @@ -385,11 +385,11 @@ jobs: permissions: id-token: write # Attestation steps: - - uses: actions/download-artifact@v7 + - uses: actions/download-artifact@v8 with: name: sdist path: dist/ - - uses: actions/download-artifact@v7 + - uses: actions/download-artifact@v8 with: pattern: wheels-* path: dist/ From 936b4585277e6ce457006b4abae159e8e8db1cf3 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Mon, 9 Mar 2026 09:24:46 -0700 Subject: [PATCH 53/78] Allow wrapping SDL windows via WindowID --- CHANGELOG.md | 4 ++++ tcod/sdl/video.py | 11 ++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39667759..3a8272d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version ## [Unreleased] +### Added + +- `tcod.sdl.video.Window` now accepts an SDL WindowID. + ## [20.1.0] - 2026-02-25 ### Added diff --git a/tcod/sdl/video.py b/tcod/sdl/video.py index a7d6c330..b15d8d0b 100644 --- a/tcod/sdl/video.py +++ b/tcod/sdl/video.py @@ -174,7 +174,14 @@ class Window: When using the libtcod :any:`Context` you can access its `Window` via :any:`Context.sdl_window`. """ - def __init__(self, sdl_window_p: Any) -> None: # noqa: ANN401 + def __init__(self, sdl_window_p: Any | int) -> None: # noqa: ANN401 + """Wrap a SDL_Window pointer or SDL WindowID. + + .. versionchanged:: Unreleased + Now accepts `int` types as an SDL WindowID. + """ + if isinstance(sdl_window_p, int): + sdl_window_p = _check_p(lib.SDL_GetWindowFromID(sdl_window_p)) if ffi.typeof(sdl_window_p) is not ffi.typeof("struct SDL_Window*"): msg = "sdl_window_p must be {!r} type (was {!r}).".format( ffi.typeof("struct SDL_Window*"), ffi.typeof(sdl_window_p) @@ -186,11 +193,13 @@ def __init__(self, sdl_window_p: Any) -> None: # noqa: ANN401 self.p = sdl_window_p def __eq__(self, other: object) -> bool: + """Return True if `self` and `other` wrap the same window.""" if not isinstance(other, Window): return NotImplemented return bool(self.p == other.p) def __hash__(self) -> int: + """Return the hash of this instances SDL window pointer.""" return hash(self.p) def _as_property_pointer(self) -> Any: # noqa: ANN401 From 1ab50f7ca01495f672ad71d97f30aa6717fc47cd Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Wed, 11 Mar 2026 10:14:13 -0700 Subject: [PATCH 54/78] Refactor event classes into dataclasses Removes tons of boilerplate Improve docs by inheriting members in event subclasses Fix classes being undocumented due to being missing from `__all__` Begin to phase out `Event.type` attribute Hide linter warnings for unmaintained EventDispatch --- CHANGELOG.md | 12 + docs/tcod/event.rst | 1 + pyproject.toml | 1 + tcod/event.py | 799 ++++++++++++++++---------------------------- tcod/sdl/mouse.py | 6 +- 5 files changed, 310 insertions(+), 509 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a8272d7..c612d322 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,18 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version - `tcod.sdl.video.Window` now accepts an SDL WindowID. +### Changed + +- Event classes are now more strict with attribute types +- Event class initializers are keyword-only and no longer take a type parameter, with exceptions. + Generally event class initialization is an internal process. +- `MouseButtonEvent` no longer a subclass of `MouseState`. + +### Deprecated + +- `Event.type` is deprecated except for special cases such as `ControllerDevice`, `WindowEvent`, etc. +- `MouseButtonEvent.state` is deprecated, replaced by the existing `.button` attribute. + ## [20.1.0] - 2026-02-25 ### Added diff --git a/docs/tcod/event.rst b/docs/tcod/event.rst index 7f2f059e..3f2d3eeb 100644 --- a/docs/tcod/event.rst +++ b/docs/tcod/event.rst @@ -3,6 +3,7 @@ SDL Event Handling ``tcod.event`` .. automodule:: tcod.event :members: + :inherited-members: object, int, str, tuple, Event :member-order: bysource :exclude-members: KeySym, Scancode, Modifier, get, wait diff --git a/pyproject.toml b/pyproject.toml index 211448c4..b8c7cc34 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ license-files = [ "libtcod/LIBTCOD-CREDITS.txt", ] dependencies = [ + "attrs>=25.2.0", "cffi>=1.15", 'numpy>=1.21.4; implementation_name != "pypy"', "typing_extensions>=4.12.2", diff --git a/tcod/event.py b/tcod/event.py index ca3a3eb1..d6cf19e2 100644 --- a/tcod/event.py +++ b/tcod/event.py @@ -87,10 +87,11 @@ import sys import warnings from collections.abc import Callable, Iterator, Mapping -from typing import TYPE_CHECKING, Any, Final, Generic, Literal, NamedTuple, TypeVar, overload +from typing import TYPE_CHECKING, Any, Final, Generic, Literal, NamedTuple, TypeAlias, TypeVar, overload +import attrs import numpy as np -from typing_extensions import deprecated +from typing_extensions import Self, deprecated import tcod.context import tcod.event_constants @@ -107,6 +108,12 @@ T = TypeVar("T") _EventType = TypeVar("_EventType", bound="Event") +_C_SDL_Event: TypeAlias = Any +"""A CFFI pointer to an SDL_Event union. + +See SDL docs: https://wiki.libsdl.org/SDL3/SDL_Event +""" + class _ConstantsWithPrefix(Mapping[int, str]): def __init__(self, constants: Mapping[int, str]) -> None: @@ -266,6 +273,7 @@ class MouseButton(enum.IntEnum): """Forward mouse button.""" def __repr__(self) -> str: + """Return the enum name, excluding the value.""" return f"{self.__class__.__name__}.{self.name}" @@ -287,143 +295,116 @@ class MouseButtonMask(enum.IntFlag): """Forward mouse button is held.""" def __repr__(self) -> str: + """Return the bitwise OR flag combination of this value.""" if self.value == 0: return f"{self.__class__.__name__}(0)" return "|".join(f"{self.__class__.__name__}.{self.__class__(bit).name}" for bit in self.__class__ if bit & self) +@attrs.define(slots=True, kw_only=True) class Event: - """The base event class. + """The base event class.""" - Attributes: - type (str): This events type. - sdl_event: When available, this holds a python-cffi 'SDL_Event*' - pointer. All sub-classes have this attribute. - """ + sdl_event: _C_SDL_Event = attrs.field(default=None, eq=False, kw_only=True, repr=False) + """When available, this holds a python-cffi 'SDL_Event*' pointer. All sub-classes have this attribute.""" - def __init__(self, type: str | None = None) -> None: - if type is None: - type = self.__class__.__name__.upper() - self.type: Final = type - self.sdl_event = None + @property + @deprecated("The Event.type attribute is deprecated, use isinstance instead.") + def type(self) -> str: + """This events type. + + .. deprecated:: Unreleased + Using this attribute is now actively discouraged. Use :func:`isinstance` or :ref:`match`. + """ + type_override: str | None = getattr(self, "_type", None) + if type_override is not None: + return type_override + return self.__class__.__name__.upper() @classmethod - def from_sdl_event(cls, sdl_event: Any) -> Event: - """Return a class instance from a python-cffi 'SDL_Event*' pointer.""" - raise NotImplementedError + def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> Event: + """Return a class instance from a python-cffi 'SDL_Event*' pointer. - def __str__(self) -> str: - return f"" + .. versionchanged:: Unreleased + This method was unsuitable for the public API and is now private. + """ + raise NotImplementedError +@attrs.define(slots=True, kw_only=True) class Quit(Event): """An application quit request event. For more info on when this event is triggered see: https://wiki.libsdl.org/SDL_EventType#SDL_QUIT - - Attributes: - type (str): Always "QUIT". """ @classmethod - def from_sdl_event(cls, sdl_event: Any) -> Quit: - self = cls() - self.sdl_event = sdl_event - return self - - def __repr__(self) -> str: - return f"tcod.event.{self.__class__.__name__}()" + def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> Self: + return cls(sdl_event=sdl_event) +@attrs.define(slots=True, kw_only=True) class KeyboardEvent(Event): """Base keyboard event. - Attributes: - type (str): Will be "KEYDOWN" or "KEYUP", depending on the event. - scancode (Scancode): The keyboard scan-code, this is the physical location - of the key on the keyboard rather than the keys symbol. - sym (KeySym): The keyboard symbol. - mod (Modifier): A bitmask of the currently held modifier keys. - - For example, if shift is held then - ``event.mod & tcod.event.Modifier.SHIFT`` will evaluate to a true - value. - - repeat (bool): True if this event exists because of key repeat. - .. versionchanged:: 12.5 `scancode`, `sym`, and `mod` now use their respective enums. """ - def __init__(self, scancode: int, sym: int, mod: int, repeat: bool = False) -> None: - super().__init__() - self.scancode = Scancode(scancode) - self.sym = KeySym(sym) - self.mod = Modifier(mod) - self.repeat = repeat + scancode: Scancode + """The keyboard scan-code, this is the physical location + of the key on the keyboard rather than the keys symbol.""" + sym: KeySym + """The keyboard symbol.""" + mod: Modifier + """A bitmask of the currently held modifier keys. + + For example, if shift is held then + ``event.mod & tcod.event.Modifier.SHIFT`` will evaluate to a true + value. + """ + repeat: bool = False + """True if this event exists because of key repeat.""" @classmethod - def from_sdl_event(cls, sdl_event: Any) -> Any: + def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> Self: keysym = sdl_event.key - self = cls(keysym.scancode, keysym.key, keysym.mod, bool(sdl_event.key.repeat)) - self.sdl_event = sdl_event - return self - - def __repr__(self) -> str: - return "tcod.event.{}(scancode={!r}, sym={!r}, mod={!r}{})".format( - self.__class__.__name__, - self.scancode, - self.sym, - self.mod, - ", repeat=True" if self.repeat else "", + return cls( + scancode=Scancode(keysym.scancode), + sym=KeySym(keysym.key), + mod=Modifier(keysym.mod), + repeat=bool(sdl_event.key.repeat), + sdl_event=sdl_event, ) - def __str__(self) -> str: - return self.__repr__().replace("tcod.event.", "") - +@attrs.define(slots=True, kw_only=True) class KeyDown(KeyboardEvent): pass +@attrs.define(slots=True, kw_only=True) class KeyUp(KeyboardEvent): pass +@attrs.define(slots=True, kw_only=True) class MouseState(Event): """Mouse state. - Attributes: - type (str): Always "MOUSESTATE". - position (Point): The position coordinates of the mouse. - tile (Point): The integer tile coordinates of the mouse on the screen. - state (int): A bitmask of which mouse buttons are currently held. - - Will be a combination of the following names: - - * tcod.event.BUTTON_LMASK - * tcod.event.BUTTON_MMASK - * tcod.event.BUTTON_RMASK - * tcod.event.BUTTON_X1MASK - * tcod.event.BUTTON_X2MASK - .. versionadded:: 9.3 .. versionchanged:: 15.0 Renamed `pixel` attribute to `position`. """ - def __init__( - self, - position: tuple[float, float] = (0, 0), - tile: tuple[float, float] | None = (0, 0), - state: int = 0, - ) -> None: - super().__init__() - self.position = Point(*position) - self._tile = Point(*tile) if tile is not None else None - self.state = state + position: Point = attrs.field(default=Point(0, 0)) + """The position coordinates of the mouse.""" + _tile: Point | None = attrs.field(default=Point(0, 0), alias="tile") + """The integer tile coordinates of the mouse on the screen.""" + state: MouseButtonMask = attrs.field(default=MouseButtonMask(0)) + """A bitmask of which mouse buttons are currently held.""" @property @deprecated("The mouse.pixel attribute is deprecated. Use mouse.position instead.") @@ -450,37 +431,11 @@ def tile(self) -> Point: def tile(self, xy: tuple[float, float]) -> None: self._tile = Point(*xy) - def __repr__(self) -> str: - return f"tcod.event.{self.__class__.__name__}(position={tuple(self.position)!r}, tile={tuple(self._tile or (0, 0))!r}, state={MouseButtonMask(self.state)})" - - def __str__(self) -> str: - return ("<%s, position=(x=%i, y=%i), tile=(x=%i, y=%i), state=%s>") % ( - super().__str__().strip("<>"), - *self.position, - *(self._tile or (0, 0)), - MouseButtonMask(self.state), - ) - +@attrs.define(slots=True, kw_only=True) class MouseMotion(MouseState): """Mouse motion event. - Attributes: - type (str): Always "MOUSEMOTION". - position (Point): The pixel coordinates of the mouse. - motion (Point): The pixel delta. - tile (Point): The integer tile coordinates of the mouse on the screen. - tile_motion (Point): The integer tile delta. - state (int): A bitmask of which mouse buttons are currently held. - - Will be a combination of the following names: - - * tcod.event.BUTTON_LMASK - * tcod.event.BUTTON_MMASK - * tcod.event.BUTTON_RMASK - * tcod.event.BUTTON_X1MASK - * tcod.event.BUTTON_X2MASK - .. versionchanged:: 15.0 Renamed `pixel` attribute to `position`. Renamed `pixel_motion` attribute to `motion`. @@ -489,17 +444,10 @@ class MouseMotion(MouseState): `position` and `motion` now use floating point coordinates. """ - def __init__( - self, - position: tuple[float, float] = (0, 0), - motion: tuple[float, float] = (0, 0), - tile: tuple[float, float] | None = (0, 0), - tile_motion: tuple[float, float] | None = (0, 0), - state: int = 0, - ) -> None: - super().__init__(position, tile, state) - self.motion = Point(*motion) - self._tile_motion = Point(*tile_motion) if tile_motion is not None else None + motion: Point = attrs.field(default=Point(0, 0)) + """The pixel delta.""" + _tile_motion: Point | None = attrs.field(default=Point(0, 0), alias="tile_motion") + """The tile delta.""" @property @deprecated("The mouse.pixel_motion attribute is deprecated. Use mouse.motion instead.") @@ -528,149 +476,99 @@ def tile_motion(self, xy: tuple[float, float]) -> None: self._tile_motion = Point(*xy) @classmethod - def from_sdl_event(cls, sdl_event: Any) -> MouseMotion: + def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> Self: motion = sdl_event.motion + state = MouseButtonMask(motion.state) - pixel = motion.x, motion.y - pixel_motion = motion.xrel, motion.yrel + pixel = Point(motion.x, motion.y) + pixel_motion = Point(motion.xrel, motion.yrel) subtile = _pixel_to_tile(*pixel) if subtile is None: - self = cls(pixel, pixel_motion, None, None, motion.state) + self = cls(position=pixel, motion=pixel_motion, tile=None, tile_motion=None, state=state) else: - tile = int(subtile[0]), int(subtile[1]) + tile = Point(int(subtile[0]), int(subtile[1])) prev_pixel = pixel[0] - pixel_motion[0], pixel[1] - pixel_motion[1] prev_subtile = _pixel_to_tile(*prev_pixel) or (0, 0) prev_tile = int(prev_subtile[0]), int(prev_subtile[1]) - tile_motion = tile[0] - prev_tile[0], tile[1] - prev_tile[1] - self = cls(pixel, pixel_motion, tile, tile_motion, motion.state) + tile_motion = Point(tile[0] - prev_tile[0], tile[1] - prev_tile[1]) + self = cls(position=pixel, motion=pixel_motion, tile=tile, tile_motion=tile_motion, state=state) self.sdl_event = sdl_event return self - def __repr__(self) -> str: - return f"tcod.event.{self.__class__.__name__}(position={tuple(self.position)!r}, motion={tuple(self.motion)!r}, tile={tuple(self.tile)!r}, tile_motion={tuple(self.tile_motion)!r}, state={MouseButtonMask(self.state)!r})" - def __str__(self) -> str: - return ("<%s, motion=(x=%i, y=%i), tile_motion=(x=%i, y=%i)>") % ( - super().__str__().strip("<>"), - *self.motion, - *self.tile_motion, - ) - - -class MouseButtonEvent(MouseState): +@attrs.define(slots=True, kw_only=True) +class MouseButtonEvent(Event): """Mouse button event. - Attributes: - type (str): Will be "MOUSEBUTTONDOWN" or "MOUSEBUTTONUP", - depending on the event. - position (Point): The pixel coordinates of the mouse. - tile (Point): The tile coordinates of the mouse on the screen. - button (int): Which mouse button. - - This will be one of the following names: - - * tcod.event.BUTTON_LEFT - * tcod.event.BUTTON_MIDDLE - * tcod.event.BUTTON_RIGHT - * tcod.event.BUTTON_X1 - * tcod.event.BUTTON_X2 - .. versionchanged:: 19.0 `position` and `tile` now use floating point coordinates. - """ - def __init__( - self, - pixel: tuple[float, float] = (0, 0), - tile: tuple[float, float] | None = (0, 0), - button: int = 0, - ) -> None: - super().__init__(pixel, tile, button) + .. versionchanged:: Unreleased + No longer a subclass of :any:`MouseState`. + """ - @property - def button(self) -> int: - return self.state + position: Point = attrs.field(default=Point(0, 0)) + """The pixel coordinates of the mouse.""" + _tile: Point | None = attrs.field(default=Point(0, 0), alias="tile") + """The tile coordinates of the mouse on the screen.""" + button: MouseButton + """Which mouse button index was pressed or released in this event. - @button.setter - def button(self, value: int) -> None: - self.state = value + .. versionchanged:: Unreleased + Is now strictly a :any:`MouseButton` type. + """ @classmethod - def from_sdl_event(cls, sdl_event: Any) -> Any: + def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> Self: button = sdl_event.button - pixel = button.x, button.y + pixel = Point(button.x, button.y) subtile = _pixel_to_tile(*pixel) if subtile is None: - tile: tuple[float, float] | None = None + tile: Point | None = None else: - tile = float(subtile[0]), float(subtile[1]) - self = cls(pixel, tile, button.button) + tile = Point(float(subtile[0]), float(subtile[1])) + self = cls(position=pixel, tile=tile, button=MouseButton(button.button)) self.sdl_event = sdl_event return self - def __repr__(self) -> str: - return f"tcod.event.{self.__class__.__name__}(position={tuple(self.position)!r}, tile={tuple(self.tile)!r}, button={MouseButton(self.button)!r})" - - def __str__(self) -> str: - return "" % ( - self.type, - *self.position, - *self.tile, - MouseButton(self.button), - ) + @property + @deprecated( + "This attribute is for mouse state and mouse motion only. Use `event.button` instead.", category=FutureWarning + ) + def state(self) -> int: # noqa: D102 # Skip docstring for deprecated property + return int(self.button) +@attrs.define(slots=True, kw_only=True) class MouseButtonDown(MouseButtonEvent): """Same as MouseButtonEvent but with ``type="MouseButtonDown"``.""" +@attrs.define(slots=True, kw_only=True) class MouseButtonUp(MouseButtonEvent): """Same as MouseButtonEvent but with ``type="MouseButtonUp"``.""" +@attrs.define(slots=True, kw_only=True) class MouseWheel(Event): - """Mouse wheel event. - - Attributes: - type (str): Always "MOUSEWHEEL". - x (int): Horizontal scrolling. A positive value means scrolling right. - y (int): Vertical scrolling. A positive value means scrolling away from - the user. - flipped (bool): If True then the values of `x` and `y` are the opposite - of their usual values. This depends on the settings of - the Operating System. + """Mouse wheel event.""" + + x: int + """Horizontal scrolling. A positive value means scrolling right.""" + y: int + """Vertical scrolling. A positive value means scrolling away from the user.""" + flipped: bool + """If True then the values of `x` and `y` are the opposite of their usual values. + This depends on the settings of the Operating System. """ - def __init__(self, x: int, y: int, flipped: bool = False) -> None: - super().__init__() - self.x = x - self.y = y - self.flipped = flipped - @classmethod - def from_sdl_event(cls, sdl_event: Any) -> MouseWheel: + def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> Self: wheel = sdl_event.wheel - self = cls(wheel.x, wheel.y, bool(wheel.direction)) - self.sdl_event = sdl_event - return self - - def __repr__(self) -> str: - return "tcod.event.%s(x=%i, y=%i%s)" % ( - self.__class__.__name__, - self.x, - self.y, - ", flipped=True" if self.flipped else "", - ) - - def __str__(self) -> str: - return "<%s, x=%i, y=%i, flipped=%r>" % ( - super().__str__().strip("<>"), - self.x, - self.y, - self.flipped, - ) + return cls(x=int(wheel.x), y=int(wheel.y), flipped=bool(wheel.direction), sdl_event=sdl_event) +@attrs.define(slots=True, kw_only=True) class TextInput(Event): """SDL text input event. @@ -678,33 +576,21 @@ class TextInput(Event): These events are not enabled by default since `19.0`. Use :any:`Window.start_text_input` to enable this event. - - Attributes: - type (str): Always "TEXTINPUT". - text (str): A Unicode string with the input. """ - def __init__(self, text: str) -> None: - super().__init__() - self.text = text + text: str + """A Unicode string with the input.""" @classmethod - def from_sdl_event(cls, sdl_event: Any) -> TextInput: - self = cls(ffi.string(sdl_event.text.text, 32).decode("utf8")) - self.sdl_event = sdl_event - return self - - def __repr__(self) -> str: - return f"tcod.event.{self.__class__.__name__}(text={self.text!r})" - - def __str__(self) -> str: - return "<{}, text={!r})".format(super().__str__().strip("<>"), self.text) + def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> Self: + return cls(text=str(ffi.string(sdl_event.text.text, 32), encoding="utf8"), sdl_event=sdl_event) +@attrs.define(slots=True, kw_only=True) class WindowEvent(Event): """A window event.""" - type: Final[ # type: ignore[misc] # Narrowing final type. + type: Final[ # Narrowing final type. Literal[ "WindowShown", "WindowHidden", @@ -726,44 +612,50 @@ class WindowEvent(Event): """The current window event. This can be one of various options.""" @classmethod - def from_sdl_event(cls, sdl_event: Any) -> WindowEvent | Undefined: - if sdl_event.type not in cls._WINDOW_TYPES: - return Undefined.from_sdl_event(sdl_event) - event_type: Final = cls._WINDOW_TYPES[sdl_event.type] + def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> WindowEvent | Undefined: + if sdl_event.type not in _WINDOW_TYPES_FROM_ENUM: + return Undefined._from_sdl_event(sdl_event) + event_type: Final = _WINDOW_TYPES_FROM_ENUM[sdl_event.type] self: WindowEvent if sdl_event.type == lib.SDL_EVENT_WINDOW_MOVED: - self = WindowMoved(sdl_event.window.data1, sdl_event.window.data2) + self = WindowMoved(x=int(sdl_event.window.data1), y=int(sdl_event.window.data2), sdl_event=sdl_event) elif sdl_event.type in ( lib.SDL_EVENT_WINDOW_RESIZED, lib.SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED, ): - self = WindowResized(event_type, sdl_event.window.data1, sdl_event.window.data2) + self = WindowResized( + type=event_type, # type: ignore[arg-type] # Currently NOT validated + width=int(sdl_event.window.data1), + height=int(sdl_event.window.data2), + sdl_event=sdl_event, + ) else: - self = cls(event_type) - self.sdl_event = sdl_event + self = cls( + type=event_type, # type: ignore[arg-type] # Currently NOT validated + sdl_event=sdl_event, + ) return self - def __repr__(self) -> str: - return f"tcod.event.{self.__class__.__name__}(type={self.type!r})" - - _WINDOW_TYPES: Final = { - lib.SDL_EVENT_WINDOW_SHOWN: "WindowShown", - lib.SDL_EVENT_WINDOW_HIDDEN: "WindowHidden", - lib.SDL_EVENT_WINDOW_EXPOSED: "WindowExposed", - lib.SDL_EVENT_WINDOW_MOVED: "WindowMoved", - lib.SDL_EVENT_WINDOW_RESIZED: "WindowResized", - lib.SDL_EVENT_WINDOW_MINIMIZED: "WindowMinimized", - lib.SDL_EVENT_WINDOW_MAXIMIZED: "WindowMaximized", - lib.SDL_EVENT_WINDOW_RESTORED: "WindowRestored", - lib.SDL_EVENT_WINDOW_MOUSE_ENTER: "WindowEnter", - lib.SDL_EVENT_WINDOW_MOUSE_LEAVE: "WindowLeave", - lib.SDL_EVENT_WINDOW_FOCUS_GAINED: "WindowFocusGained", - lib.SDL_EVENT_WINDOW_FOCUS_LOST: "WindowFocusLost", - lib.SDL_EVENT_WINDOW_CLOSE_REQUESTED: "WindowClose", - lib.SDL_EVENT_WINDOW_HIT_TEST: "WindowHitTest", - } + +_WINDOW_TYPES_FROM_ENUM: Final = { + lib.SDL_EVENT_WINDOW_SHOWN: "WindowShown", + lib.SDL_EVENT_WINDOW_HIDDEN: "WindowHidden", + lib.SDL_EVENT_WINDOW_EXPOSED: "WindowExposed", + lib.SDL_EVENT_WINDOW_MOVED: "WindowMoved", + lib.SDL_EVENT_WINDOW_RESIZED: "WindowResized", + lib.SDL_EVENT_WINDOW_MINIMIZED: "WindowMinimized", + lib.SDL_EVENT_WINDOW_MAXIMIZED: "WindowMaximized", + lib.SDL_EVENT_WINDOW_RESTORED: "WindowRestored", + lib.SDL_EVENT_WINDOW_MOUSE_ENTER: "WindowEnter", + lib.SDL_EVENT_WINDOW_MOUSE_LEAVE: "WindowLeave", + lib.SDL_EVENT_WINDOW_FOCUS_GAINED: "WindowFocusGained", + lib.SDL_EVENT_WINDOW_FOCUS_LOST: "WindowFocusLost", + lib.SDL_EVENT_WINDOW_CLOSE_REQUESTED: "WindowClose", + lib.SDL_EVENT_WINDOW_HIT_TEST: "WindowHitTest", +} +@attrs.define(slots=True, kw_only=True) class WindowMoved(WindowEvent): """Window moved event. @@ -772,25 +664,14 @@ class WindowMoved(WindowEvent): y (int): Movement on the y-axis. """ - type: Final[Literal["WINDOWMOVED"]] # type: ignore[assignment,misc] + type: Final[Literal["WINDOWMOVED"]] = "WINDOWMOVED" # type: ignore[assignment,misc] """Always "WINDOWMOVED".""" - def __init__(self, x: int, y: int) -> None: - super().__init__(None) - self.x = x - self.y = y - - def __repr__(self) -> str: - return f"tcod.event.{self.__class__.__name__}(type={self.type!r}, x={self.x!r}, y={self.y!r})" - - def __str__(self) -> str: - return "<{}, x={!r}, y={!r})".format( - super().__str__().strip("<>"), - self.x, - self.y, - ) + x: int + y: int +@attrs.define(slots=True, kw_only=True) class WindowResized(WindowEvent): """Window resized event. @@ -802,50 +683,31 @@ class WindowResized(WindowEvent): Removed "WindowSizeChanged" type. """ - type: Final[Literal["WindowResized"]] # type: ignore[misc] + type: Final[Literal["WindowResized"]] = "WindowResized" # type: ignore[misc] """Always "WindowResized".""" - def __init__(self, type: str, width: int, height: int) -> None: - super().__init__(type) - self.width = width - self.height = height - - def __repr__(self) -> str: - return f"tcod.event.{self.__class__.__name__}(type={self.type!r}, width={self.width!r}, height={self.height!r})" - - def __str__(self) -> str: - return "<{}, width={!r}, height={!r})".format( - super().__str__().strip("<>"), - self.width, - self.height, - ) + width: int + height: int +@attrs.define(slots=True, kw_only=True) class JoystickEvent(Event): """A base class for joystick events. .. versionadded:: 13.8 """ - def __init__(self, type: str, which: int) -> None: - super().__init__(type) - self.which = which - """The ID of the joystick this event is for.""" + which: int + """The ID of the joystick this event is for.""" @property def joystick(self) -> tcod.sdl.joystick.Joystick: - if self.type == "JOYDEVICEADDED": + if isinstance(self, JoystickDevice) and self.type == "JOYDEVICEADDED": return tcod.sdl.joystick.Joystick._open(self.which) return tcod.sdl.joystick.Joystick._from_instance_id(self.which) - def __repr__(self) -> str: - return f"tcod.event.{self.__class__.__name__}(type={self.type!r}, which={self.which})" - - def __str__(self) -> str: - prefix = super().__str__().strip("<>") - return f"<{prefix}, which={self.which}>" - +@attrs.define(slots=True, kw_only=True) class JoystickAxis(JoystickEvent): """When a joystick axis changes in value. @@ -855,31 +717,19 @@ class JoystickAxis(JoystickEvent): :any:`tcod.sdl.joystick` """ - which: int - """The ID of the joystick this event is for.""" + _type: Final[Literal["JOYAXISMOTION"]] = "JOYAXISMOTION" - def __init__(self, type: str, which: int, axis: int, value: int) -> None: - super().__init__(type, which) - self.axis = axis - """The index of the changed axis.""" - self.value = value - """The raw value of the axis in the range -32768 to 32767.""" + axis: int + """The index of the changed axis.""" + value: int + """The raw value of the axis in the range -32768 to 32767.""" @classmethod - def from_sdl_event(cls, sdl_event: Any) -> JoystickAxis: - return cls("JOYAXISMOTION", sdl_event.jaxis.which, sdl_event.jaxis.axis, sdl_event.jaxis.value) - - def __repr__(self) -> str: - return ( - f"tcod.event.{self.__class__.__name__}" - f"(type={self.type!r}, which={self.which}, axis={self.axis}, value={self.value})" - ) - - def __str__(self) -> str: - prefix = super().__str__().strip("<>") - return f"<{prefix}, axis={self.axis}, value={self.value}>" + def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> Self: + return cls(which=int(sdl_event.jaxis.which), axis=int(sdl_event.jaxis.axis), value=int(sdl_event.jaxis.value)) +@attrs.define(slots=True, kw_only=True) class JoystickBall(JoystickEvent): """When a joystick ball is moved. @@ -889,35 +739,26 @@ class JoystickBall(JoystickEvent): :any:`tcod.sdl.joystick` """ - which: int - """The ID of the joystick this event is for.""" + _type: Final[Literal["JOYBALLMOTION"]] = "JOYBALLMOTION" - def __init__(self, type: str, which: int, ball: int, dx: int, dy: int) -> None: - super().__init__(type, which) - self.ball = ball - """The index of the moved ball.""" - self.dx = dx - """The X motion of the ball.""" - self.dy = dy - """The Y motion of the ball.""" + ball: int + """The index of the moved ball.""" + dx: int + """The X motion of the ball.""" + dy: int + """The Y motion of the ball.""" @classmethod - def from_sdl_event(cls, sdl_event: Any) -> JoystickBall: + def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> Self: return cls( - "JOYBALLMOTION", sdl_event.jball.which, sdl_event.jball.ball, sdl_event.jball.xrel, sdl_event.jball.yrel - ) - - def __repr__(self) -> str: - return ( - f"tcod.event.{self.__class__.__name__}" - f"(type={self.type!r}, which={self.which}, ball={self.ball}, dx={self.dx}, dy={self.dy})" + which=int(sdl_event.jball.which), + ball=int(sdl_event.jball.ball), + dx=int(sdl_event.jball.xrel), + dy=int(sdl_event.jball.yrel), ) - def __str__(self) -> str: - prefix = super().__str__().strip("<>") - return f"<{prefix}, ball={self.ball}, dx={self.dx}, dy={self.dy}>" - +@attrs.define(slots=True, kw_only=True) class JoystickHat(JoystickEvent): """When a joystick hat changes direction. @@ -927,28 +768,20 @@ class JoystickHat(JoystickEvent): :any:`tcod.sdl.joystick` """ - which: int - """The ID of the joystick this event is for.""" + _type: Final[Literal["JOYHATMOTION"]] = "JOYHATMOTION" - def __init__(self, type: str, which: int, x: Literal[-1, 0, 1], y: Literal[-1, 0, 1]) -> None: - super().__init__(type, which) - self.x = x - """The new X direction of the hat.""" - self.y = y - """The new Y direction of the hat.""" + x: Literal[-1, 0, 1] + """The new X direction of the hat.""" + y: Literal[-1, 0, 1] + """The new Y direction of the hat.""" @classmethod - def from_sdl_event(cls, sdl_event: Any) -> JoystickHat: - return cls("JOYHATMOTION", sdl_event.jhat.which, *_HAT_DIRECTIONS[sdl_event.jhat.hat]) - - def __repr__(self) -> str: - return f"tcod.event.{self.__class__.__name__}(type={self.type!r}, which={self.which}, x={self.x}, y={self.y})" - - def __str__(self) -> str: - prefix = super().__str__().strip("<>") - return f"<{prefix}, x={self.x}, y={self.y}>" + def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> Self: + x, y = _HAT_DIRECTIONS[sdl_event.jhat.hat] + return cls(which=int(sdl_event.jhat.which), x=x, y=y) +@attrs.define(slots=True, kw_only=True) class JoystickButton(JoystickEvent): """When a joystick button is pressed or released. @@ -964,35 +797,31 @@ class JoystickButton(JoystickEvent): print(f"Released {button=} on controller {which}.") """ - which: int - """The ID of the joystick this event is for.""" - - def __init__(self, type: str, which: int, button: int) -> None: - super().__init__(type, which) - self.button = button - """The index of the button this event is for.""" + button: int + """The index of the button this event is for.""" + pressed: bool + """True if the button was pressed, False if the button was released.""" @property - def pressed(self) -> bool: - """True if the joystick button has been pressed, False when the button was released.""" - return self.type == "JOYBUTTONDOWN" + @deprecated("Check 'JoystickButton.pressed' instead of '.type'.") + def type(self) -> Literal["JOYBUTTONUP", "JOYBUTTONDOWN"]: + """Button state as a string. - @classmethod - def from_sdl_event(cls, sdl_event: Any) -> JoystickButton: - type = { - lib.SDL_EVENT_JOYSTICK_BUTTON_DOWN: "JOYBUTTONDOWN", - lib.SDL_EVENT_JOYSTICK_BUTTON_UP: "JOYBUTTONUP", - }[sdl_event.type] - return cls(type, sdl_event.jbutton.which, sdl_event.jbutton.button) - - def __repr__(self) -> str: - return f"tcod.event.{self.__class__.__name__}(type={self.type!r}, which={self.which}, button={self.button})" + .. deprecated:: Unreleased + Use :any:`pressed` instead. + """ + return ("JOYBUTTONUP", "JOYBUTTONDOWN")[self.pressed] - def __str__(self) -> str: - prefix = super().__str__().strip("<>") - return f"<{prefix}, button={self.button}>" + @classmethod + def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> Self: + return cls( + which=int(sdl_event.jbutton.which), + button=int(sdl_event.jbutton.button), + pressed=bool(sdl_event.jbutton.down), + ) +@attrs.define(slots=True, kw_only=True) class JoystickDevice(JoystickEvent): """An event for when a joystick is added or removed. @@ -1009,7 +838,7 @@ class JoystickDevice(JoystickEvent): joysticks.remove(joystick) """ - type: Final[Literal["JOYDEVICEADDED", "JOYDEVICEREMOVED"]] # type: ignore[misc] + type: Final[Literal["JOYDEVICEADDED", "JOYDEVICEREMOVED"]] which: int """When type="JOYDEVICEADDED" this is the device ID. @@ -1017,132 +846,105 @@ class JoystickDevice(JoystickEvent): """ @classmethod - def from_sdl_event(cls, sdl_event: Any) -> JoystickDevice: - type = { + def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> Self: + types: Final[dict[int, Literal["JOYDEVICEADDED", "JOYDEVICEREMOVED"]]] = { lib.SDL_EVENT_JOYSTICK_ADDED: "JOYDEVICEADDED", lib.SDL_EVENT_JOYSTICK_REMOVED: "JOYDEVICEREMOVED", - }[sdl_event.type] - return cls(type, sdl_event.jdevice.which) + } + return cls(type=types[sdl_event.type], which=int(sdl_event.jdevice.which)) +@attrs.define(slots=True, kw_only=True) class ControllerEvent(Event): """Base class for controller events. .. versionadded:: 13.8 """ - def __init__(self, type: str, which: int) -> None: - super().__init__(type) - self.which = which - """The ID of the joystick this event is for.""" + which: int + """The ID of the controller this event is for.""" @property def controller(self) -> tcod.sdl.joystick.GameController: """The :any:`GameController` for this event.""" - if self.type == "CONTROLLERDEVICEADDED": + if isinstance(self, ControllerDevice) and self.type == "CONTROLLERDEVICEADDED": return tcod.sdl.joystick.GameController._open(self.which) return tcod.sdl.joystick.GameController._from_instance_id(self.which) - def __repr__(self) -> str: - return f"tcod.event.{self.__class__.__name__}(type={self.type!r}, which={self.which})" - - def __str__(self) -> str: - prefix = super().__str__().strip("<>") - return f"<{prefix}, which={self.which}>" - +@attrs.define(slots=True, kw_only=True) class ControllerAxis(ControllerEvent): """When a controller axis is moved. .. versionadded:: 13.8 """ - type: Final[Literal["CONTROLLERAXISMOTION"]] # type: ignore[misc] + _type: Final[Literal["CONTROLLERAXISMOTION"]] = "CONTROLLERAXISMOTION" - def __init__(self, type: str, which: int, axis: tcod.sdl.joystick.ControllerAxis, value: int) -> None: - super().__init__(type, which) - self.axis = axis - """Which axis is being moved. One of :any:`ControllerAxis`.""" - self.value = value - """The new value of this events axis. + axis: int + """Which axis is being moved. One of :any:`ControllerAxis`.""" + value: int + """The new value of this events axis. - This will be -32768 to 32767 for all axes except for triggers which are 0 to 32767 instead.""" + This will be -32768 to 32767 for all axes except for triggers which are 0 to 32767 instead.""" @classmethod - def from_sdl_event(cls, sdl_event: Any) -> ControllerAxis: + def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> Self: return cls( - "CONTROLLERAXISMOTION", - sdl_event.gaxis.which, - tcod.sdl.joystick.ControllerAxis(sdl_event.gaxis.axis), - sdl_event.gaxis.value, - ) - - def __repr__(self) -> str: - return ( - f"tcod.event.{self.__class__.__name__}" - f"(type={self.type!r}, which={self.which}, axis={self.axis}, value={self.value})" + which=int(sdl_event.gaxis.which), + axis=tcod.sdl.joystick.ControllerAxis(sdl_event.gaxis.axis), + value=int(sdl_event.gaxis.value), ) - def __str__(self) -> str: - prefix = super().__str__().strip("<>") - return f"<{prefix}, axis={self.axis}, value={self.value}>" - +@attrs.define(slots=True, kw_only=True) class ControllerButton(ControllerEvent): """When a controller button is pressed or released. .. versionadded:: 13.8 """ - type: Final[Literal["CONTROLLERBUTTONDOWN", "CONTROLLERBUTTONUP"]] # type: ignore[misc] + button: tcod.sdl.joystick.ControllerButton + """The button for this event. One of :any:`ControllerButton`.""" + pressed: bool + """True if the button was pressed, False if it was released.""" - def __init__(self, type: str, which: int, button: tcod.sdl.joystick.ControllerButton, pressed: bool) -> None: - super().__init__(type, which) - self.button = button - """The button for this event. One of :any:`ControllerButton`.""" - self.pressed = pressed - """True if the button was pressed, False if it was released.""" + @property + @deprecated("Check 'ControllerButton.pressed' instead of '.type'.") + def type(self) -> Literal["CONTROLLERBUTTONUP", "CONTROLLERBUTTONDOWN"]: + """Button state as a string. + + .. deprecated:: Unreleased + Use :any:`pressed` instead. + """ + return ("CONTROLLERBUTTONUP", "CONTROLLERBUTTONDOWN")[self.pressed] @classmethod - def from_sdl_event(cls, sdl_event: Any) -> ControllerButton: - type = { - lib.SDL_EVENT_GAMEPAD_BUTTON_DOWN: "CONTROLLERBUTTONDOWN", - lib.SDL_EVENT_GAMEPAD_BUTTON_UP: "CONTROLLERBUTTONUP", - }[sdl_event.type] + def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> Self: return cls( - type, - sdl_event.gbutton.which, - tcod.sdl.joystick.ControllerButton(sdl_event.gbutton.button), - bool(sdl_event.gbutton.down), - ) - - def __repr__(self) -> str: - return ( - f"tcod.event.{self.__class__.__name__}" - f"(type={self.type!r}, which={self.which}, button={self.button}, pressed={self.pressed})" + which=int(sdl_event.gbutton.which), + button=tcod.sdl.joystick.ControllerButton(sdl_event.gbutton.button), + pressed=bool(sdl_event.gbutton.down), ) - def __str__(self) -> str: - prefix = super().__str__().strip("<>") - return f"<{prefix}, button={self.button}, pressed={self.pressed}>" - +@attrs.define(slots=True, kw_only=True) class ControllerDevice(ControllerEvent): """When a controller is added, removed, or remapped. .. versionadded:: 13.8 """ - type: Final[Literal["CONTROLLERDEVICEADDED", "CONTROLLERDEVICEREMOVED", "CONTROLLERDEVICEREMAPPED"]] # type: ignore[misc] + type: Final[Literal["CONTROLLERDEVICEADDED", "CONTROLLERDEVICEREMOVED", "CONTROLLERDEVICEREMAPPED"]] @classmethod - def from_sdl_event(cls, sdl_event: Any) -> ControllerDevice: - type = { + def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> Self: + types: dict[int, Literal["CONTROLLERDEVICEADDED", "CONTROLLERDEVICEREMOVED", "CONTROLLERDEVICEREMAPPED"]] = { lib.SDL_EVENT_GAMEPAD_ADDED: "CONTROLLERDEVICEADDED", lib.SDL_EVENT_GAMEPAD_REMOVED: "CONTROLLERDEVICEREMOVED", lib.SDL_EVENT_GAMEPAD_REMAPPED: "CONTROLLERDEVICEREMAPPED", - }[sdl_event.type] - return cls(type, sdl_event.gdevice.which) + } + return cls(type=types[sdl_event.type], which=int(sdl_event.gdevice.which)) @functools.cache @@ -1154,25 +956,17 @@ def _find_event_name(index: int, /) -> str: return "???" +@attrs.define(slots=True, kw_only=True) class Undefined(Event): """This class is a place holder for SDL events without their own tcod.event class.""" - def __init__(self) -> None: - super().__init__("") - @classmethod - def from_sdl_event(cls, sdl_event: Any) -> Undefined: - self = cls() - self.sdl_event = sdl_event - return self - - def __str__(self) -> str: - if self.sdl_event: - return f"" - return "" + def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> Self: + return cls(sdl_event=sdl_event) def __repr__(self) -> str: - return self.__str__() + """Return debug info for this undefined event, including the SDL event name.""" + return f"" _SDL_TO_CLASS_TABLE: dict[int, type[Event]] = { @@ -1200,13 +994,13 @@ def __repr__(self) -> str: } -def _parse_event(sdl_event: Any) -> Event: +def _parse_event(sdl_event: _C_SDL_Event) -> Event: """Convert a C SDL_Event* type into a tcod Event sub-class.""" if sdl_event.type in _SDL_TO_CLASS_TABLE: - return _SDL_TO_CLASS_TABLE[sdl_event.type].from_sdl_event(sdl_event) - if sdl_event.type in WindowEvent._WINDOW_TYPES: - return WindowEvent.from_sdl_event(sdl_event) - return Undefined.from_sdl_event(sdl_event) + return _SDL_TO_CLASS_TABLE[sdl_event.type]._from_sdl_event(sdl_event) + if sdl_event.type in _WINDOW_TYPES_FROM_ENUM: + return WindowEvent._from_sdl_event(sdl_event) + return Undefined._from_sdl_event(sdl_event) def get() -> Iterator[Event]: @@ -1261,7 +1055,8 @@ def wait(timeout: float | None = None) -> Iterator[Event]: @deprecated( - "Event dispatch should be handled via a single custom method in a Protocol instead of this class.", + """EventDispatch is no longer maintained. +Event dispatching should be handled via a single custom method in a Protocol instead of this class.""", category=DeprecationWarning, ) class EventDispatch(Generic[T]): @@ -1275,8 +1070,8 @@ class EventDispatch(Generic[T]): The type hints at the return value of :any:`dispatch` and the `ev_*` methods. .. deprecated:: 18.0 - Event dispatch should be handled via a single custom method in a Protocol instead of this class. - Note that events can and should be handled using Python's `match` statement. + Event dispatch should be handled via a single custom method in a :class:`~typing.Protocol` instead of this class. + Note that events can and should be handled using :ref:`match`. Example:: @@ -1374,7 +1169,7 @@ def cmd_quit(self) -> None: __slots__ = () - def dispatch(self, event: Any) -> T | None: + def dispatch(self, event: Any) -> T | None: # noqa: ANN401 """Send an event to an `ev_*` method. `*` will be the `event.type` attribute converted to lower-case. @@ -1400,11 +1195,11 @@ def dispatch(self, event: Any) -> T | None: return None return func(event) - def event_get(self) -> None: + def event_get(self) -> None: # noqa: D102 for event in get(): self.dispatch(event) - def event_wait(self, timeout: float | None) -> None: + def event_wait(self, timeout: float | None) -> None: # noqa: D102 wait(timeout) self.event_get() @@ -1474,10 +1269,10 @@ def ev_windowfocuslost(self, event: tcod.event.WindowEvent, /) -> T | None: def ev_windowclose(self, event: tcod.event.WindowEvent, /) -> T | None: """Called when the window manager requests the window to be closed.""" - def ev_windowtakefocus(self, event: tcod.event.WindowEvent, /) -> T | None: + def ev_windowtakefocus(self, event: tcod.event.WindowEvent, /) -> T | None: # noqa: D102 pass - def ev_windowhittest(self, event: tcod.event.WindowEvent, /) -> T | None: + def ev_windowhittest(self, event: tcod.event.WindowEvent, /) -> T | None: # noqa: D102 pass def ev_joyaxismotion(self, event: tcod.event.JoystickAxis, /) -> T | None: @@ -1558,7 +1353,7 @@ def ev_controllerdeviceremapped(self, event: tcod.event.ControllerDevice, /) -> .. versionadded:: 13.8 """ - def ev_(self, event: Any, /) -> T | None: + def ev_(self, event: Any, /) -> T | None: # noqa: ANN401, D102 pass @@ -1571,8 +1366,8 @@ def get_mouse_state() -> MouseState: buttons = lib.SDL_GetMouseState(xy, xy + 1) tile = _pixel_to_tile(*xy) if tile is None: - return MouseState((xy[0], xy[1]), None, buttons) - return MouseState((xy[0], xy[1]), (int(tile[0]), int(tile[1])), buttons) + return MouseState(position=Point(xy[0], xy[1]), tile=None, state=buttons) + return MouseState(position=Point(xy[0], xy[1]), tile=Point(int(tile[0]), int(tile[1])), state=buttons) @overload @@ -1648,7 +1443,7 @@ def convert_coordinates_from_window( @ffi.def_extern() # type: ignore[untyped-decorator] -def _sdl_event_watcher(userdata: Any, sdl_event: Any) -> int: +def _sdl_event_watcher(userdata: Any, sdl_event: _C_SDL_Event) -> int: # noqa: ANN401 callback: Callable[[Event], None] = ffi.from_handle(userdata) callback(_parse_event(sdl_event)) return 0 @@ -2994,24 +2789,17 @@ def __getattr__(name: str) -> int: return value -__all__ = [ # noqa: F405 RUF022 - "Modifier", +__all__ = ( # noqa: F405 RUF022 "Point", - "BUTTON_LEFT", - "BUTTON_MIDDLE", - "BUTTON_RIGHT", - "BUTTON_X1", - "BUTTON_X2", - "BUTTON_LMASK", - "BUTTON_MMASK", - "BUTTON_RMASK", - "BUTTON_X1MASK", - "BUTTON_X2MASK", + "Modifier", + "MouseButton", + "MouseButtonMask", "Event", "Quit", "KeyboardEvent", "KeyDown", "KeyUp", + "MouseState", "MouseMotion", "MouseButtonEvent", "MouseButtonDown", @@ -3045,5 +2833,4 @@ def __getattr__(name: str) -> int: # --- From event_constants.py --- "MOUSEWHEEL_NORMAL", "MOUSEWHEEL_FLIPPED", - "MOUSEWHEEL", -] +) diff --git a/tcod/sdl/mouse.py b/tcod/sdl/mouse.py index 1e0c1438..4b1f10ef 100644 --- a/tcod/sdl/mouse.py +++ b/tcod/sdl/mouse.py @@ -210,7 +210,7 @@ def get_global_state() -> tcod.event.MouseState: """ xy = ffi.new("int[2]") state = lib.SDL_GetGlobalMouseState(xy, xy + 1) - return tcod.event.MouseState((xy[0], xy[1]), state=state) + return tcod.event.MouseState(position=tcod.event.Point(xy[0], xy[1]), state=state) def get_relative_state() -> tcod.event.MouseState: @@ -221,7 +221,7 @@ def get_relative_state() -> tcod.event.MouseState: """ xy = ffi.new("int[2]") state = lib.SDL_GetRelativeMouseState(xy, xy + 1) - return tcod.event.MouseState((xy[0], xy[1]), state=state) + return tcod.event.MouseState(position=tcod.event.Point(xy[0], xy[1]), state=state) def get_state() -> tcod.event.MouseState: @@ -232,7 +232,7 @@ def get_state() -> tcod.event.MouseState: """ xy = ffi.new("int[2]") state = lib.SDL_GetMouseState(xy, xy + 1) - return tcod.event.MouseState((xy[0], xy[1]), state=state) + return tcod.event.MouseState(position=tcod.event.Point(xy[0], xy[1]), state=state) def get_focus() -> tcod.sdl.video.Window | None: From 6df9334ca245370c1b5e73570ea4409648d05b1f Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Wed, 11 Mar 2026 19:14:14 -0700 Subject: [PATCH 55/78] Fix mouse tile regressions and improve integer coordinate handling Refactor Point to be generic to better track what uses int and float types Fix wrong C type in get_mouse_state, added function to samples Added separate integer attributes to mouse events --- CHANGELOG.md | 11 +++ examples/samples_tcod.py | 32 +++------ tcod/context.py | 13 +++- tcod/event.py | 150 ++++++++++++++++++++++++--------------- 4 files changed, 125 insertions(+), 81 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c612d322..237c4017 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version ### Added - `tcod.sdl.video.Window` now accepts an SDL WindowID. +- `MouseState.integer_position` and `MouseMotion.integer_motion` to handle cases where integer values are preferred. ### Changed @@ -16,12 +17,22 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version - Event class initializers are keyword-only and no longer take a type parameter, with exceptions. Generally event class initialization is an internal process. - `MouseButtonEvent` no longer a subclass of `MouseState`. +- `tcod.event.Point` is now a generic type containing `int` or `float` values depending on the context. +- When converting mouse events to tiles: + `MouseState.position` and `MouseMotion.motion` refers to sub-tile coordinates. + `MouseState.integer_position` and `MouseMotion.integer_motion` refers to integer tile coordinates. ### Deprecated - `Event.type` is deprecated except for special cases such as `ControllerDevice`, `WindowEvent`, etc. - `MouseButtonEvent.state` is deprecated, replaced by the existing `.button` attribute. +### Fixed + +- Fixed incorrect C FFI types inside `tcod.event.get_mouse_state`. +- Fixed regression in mouse event tile coordinates being `float` instead of `int`. + `convert_coordinates_from_window` can be used if sub-tile coordinates were desired. + ## [20.1.0] - 2026-02-25 ### Added diff --git a/examples/samples_tcod.py b/examples/samples_tcod.py index 32107d0d..19d4fd92 100755 --- a/examples/samples_tcod.py +++ b/examples/samples_tcod.py @@ -1011,7 +1011,6 @@ class MouseSample(Sample): def __init__(self) -> None: self.motion = tcod.event.MouseMotion() - self.mouse_left = self.mouse_middle = self.mouse_right = 0 self.log: list[str] = [] def on_enter(self) -> None: @@ -1022,15 +1021,18 @@ def on_enter(self) -> None: def on_draw(self) -> None: sample_console.clear(bg=GREY) + mouse_state = tcod.event.get_mouse_state() sample_console.print( 1, 1, - f"Pixel position : {self.motion.position.x:4.0f}x{self.motion.position.y:4.0f}\n" - f"Tile position : {self.motion.tile.x:4.0f}x{self.motion.tile.y:4.0f}\n" - f"Tile movement : {self.motion.tile_motion.x:4.0f}x{self.motion.tile_motion.y:4.0f}\n" - f"Left button : {'ON' if self.mouse_left else 'OFF'}\n" - f"Right button : {'ON' if self.mouse_right else 'OFF'}\n" - f"Middle button : {'ON' if self.mouse_middle else 'OFF'}\n", + f"Pixel position : {mouse_state.position.x:4.0f}x{mouse_state.position.y:4.0f}\n" + f"Tile position : {self.motion.tile.x:4d}x{self.motion.tile.y:4d}\n" + f"Tile movement : {self.motion.tile_motion.x:4d}x{self.motion.tile_motion.y:4d}\n" + f"Left button : {'ON' if mouse_state.state & tcod.event.MouseButtonMask.LEFT else 'OFF'}\n" + f"Middle button : {'ON' if mouse_state.state & tcod.event.MouseButtonMask.MIDDLE else 'OFF'}\n" + f"Right button : {'ON' if mouse_state.state & tcod.event.MouseButtonMask.RIGHT else 'OFF'}\n" + f"X1 button : {'ON' if mouse_state.state & tcod.event.MouseButtonMask.X1 else 'OFF'}\n" + f"X2 button : {'ON' if mouse_state.state & tcod.event.MouseButtonMask.X2 else 'OFF'}\n", fg=LIGHT_YELLOW, bg=None, ) @@ -1046,18 +1048,6 @@ def on_event(self, event: tcod.event.Event) -> None: match event: case tcod.event.MouseMotion(): self.motion = event - case tcod.event.MouseButtonDown(button=tcod.event.MouseButton.LEFT): - self.mouse_left = True - case tcod.event.MouseButtonDown(button=tcod.event.MouseButton.MIDDLE): - self.mouse_middle = True - case tcod.event.MouseButtonDown(button=tcod.event.MouseButton.RIGHT): - self.mouse_right = True - case tcod.event.MouseButtonUp(button=tcod.event.MouseButton.LEFT): - self.mouse_left = False - case tcod.event.MouseButtonUp(button=tcod.event.MouseButton.MIDDLE): - self.mouse_middle = False - case tcod.event.MouseButtonUp(button=tcod.event.MouseButton.RIGHT): - self.mouse_right = False case tcod.event.KeyDown(sym=KeySym.N1): tcod.sdl.mouse.show(visible=False) case tcod.event.KeyDown(sym=KeySym.N2): @@ -1422,8 +1412,8 @@ def handle_events() -> None: tile_event = tcod.event.convert_coordinates_from_window(event, context, root_console) SAMPLES[cur_sample].on_event(tile_event) match tile_event: - case tcod.event.MouseMotion(position=(x, y)): - mouse_tile_xy = int(x), int(y) + case tcod.event.MouseMotion(integer_position=(x, y)): + mouse_tile_xy = x, y case tcod.event.WindowEvent(type="WindowLeave"): mouse_tile_xy = -1, -1 diff --git a/tcod/context.py b/tcod/context.py index 5b1bb214..994290c5 100644 --- a/tcod/context.py +++ b/tcod/context.py @@ -29,6 +29,7 @@ import pickle import sys import warnings +from math import floor from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, NoReturn, TypeVar @@ -256,7 +257,8 @@ def convert_event(self, event: _Event) -> _Event: event_copy = copy.copy(event) if isinstance(event, (tcod.event.MouseState, tcod.event.MouseMotion)): assert isinstance(event_copy, (tcod.event.MouseState, tcod.event.MouseMotion)) - event_copy.position = event._tile = tcod.event.Point(*self.pixel_to_tile(*event.position)) + event_copy.position = tcod.event.Point(*self.pixel_to_tile(event.position[0], event.position[1])) + event._tile = tcod.event.Point(floor(event_copy.position[0]), floor(event_copy.position[1])) if isinstance(event, tcod.event.MouseMotion): assert isinstance(event_copy, tcod.event.MouseMotion) assert event._tile is not None @@ -264,8 +266,13 @@ def convert_event(self, event: _Event) -> _Event: event.position[0] - event.motion[0], event.position[1] - event.motion[1], ) - event_copy.motion = event._tile_motion = tcod.event.Point( - int(event._tile[0]) - int(prev_tile[0]), int(event._tile[1]) - int(prev_tile[1]) + event_copy.motion = tcod.event.Point( + event_copy.position[0] - prev_tile[0], + event_copy.position[1] - prev_tile[1], + ) + event._tile_motion = tcod.event.Point( + event._tile[0] - floor(prev_tile[0]), + event._tile[1] - floor(prev_tile[1]), ) return event_copy diff --git a/tcod/event.py b/tcod/event.py index d6cf19e2..b4be0a29 100644 --- a/tcod/event.py +++ b/tcod/event.py @@ -87,6 +87,7 @@ import sys import warnings from collections.abc import Callable, Iterator, Mapping +from math import floor from typing import TYPE_CHECKING, Any, Final, Generic, Literal, NamedTuple, TypeAlias, TypeVar, overload import attrs @@ -149,32 +150,39 @@ def _describe_bitmask(bits: int, table: Mapping[int, str], default: str = "0") - return "|".join(result) -def _pixel_to_tile(x: float, y: float) -> tuple[float, float] | None: +def _pixel_to_tile(xy: tuple[float, float], /) -> Point[float] | None: """Convert pixel coordinates to tile coordinates.""" if not lib.TCOD_ctx.engine: return None - xy = ffi.new("double[2]", (x, y)) - lib.TCOD_sys_pixel_to_tile(xy, xy + 1) - return xy[0], xy[1] + xy_out = ffi.new("double[2]", xy) + lib.TCOD_sys_pixel_to_tile(xy_out, xy_out + 1) + return Point(float(xy_out[0]), float(xy_out[1])) -class Point(NamedTuple): - """A 2D position used for events with mouse coordinates. +if sys.version_info >= (3, 11) or TYPE_CHECKING: - .. seealso:: - :any:`MouseMotion` :any:`MouseButtonDown` :any:`MouseButtonUp` + class Point(NamedTuple, Generic[T]): + """A 2D position used for events with mouse coordinates. - .. versionchanged:: 19.0 - Now uses floating point coordinates due to the port to SDL3. - """ + .. seealso:: + :any:`MouseMotion` :any:`MouseButtonDown` :any:`MouseButtonUp` + + .. versionchanged:: 19.0 + Now uses floating point coordinates due to the port to SDL3. + """ - x: float - """A pixel or tile coordinate starting with zero as the left-most position.""" - y: float - """A pixel or tile coordinate starting with zero as the top-most position.""" + x: T + """A pixel or tile coordinate starting with zero as the left-most position.""" + y: T + """A pixel or tile coordinate starting with zero as the top-most position.""" +else: + class Point(NamedTuple): # noqa: D101 + x: Any + y: Any -def _verify_tile_coordinates(xy: Point | None) -> Point: + +def _verify_tile_coordinates(xy: Point[int] | None) -> Point[int]: """Check if an events tile coordinate is initialized and warn if not. Always returns a valid Point object for backwards compatibility. @@ -399,36 +407,50 @@ class MouseState(Event): Renamed `pixel` attribute to `position`. """ - position: Point = attrs.field(default=Point(0, 0)) + position: Point[float] = attrs.field(default=Point(0.0, 0.0)) """The position coordinates of the mouse.""" - _tile: Point | None = attrs.field(default=Point(0, 0), alias="tile") - """The integer tile coordinates of the mouse on the screen.""" + _tile: Point[int] | None = attrs.field(default=Point(0, 0), alias="tile") + state: MouseButtonMask = attrs.field(default=MouseButtonMask(0)) """A bitmask of which mouse buttons are currently held.""" + @property + def integer_position(self) -> Point[int]: + """Integer coordinates of this event. + + .. versionadded:: Unreleased + """ + x, y = self.position + return Point(floor(x), floor(y)) + @property @deprecated("The mouse.pixel attribute is deprecated. Use mouse.position instead.") - def pixel(self) -> Point: + def pixel(self) -> Point[float]: return self.position @pixel.setter - def pixel(self, value: Point) -> None: + def pixel(self, value: Point[float]) -> None: self.position = value @property @deprecated( "The mouse.tile attribute is deprecated." - " Use mouse.position of the event returned by context.convert_event instead." + " Use mouse.integer_position of the event returned by context.convert_event instead." ) - def tile(self) -> Point: + def tile(self) -> Point[int]: + """The integer tile coordinates of the mouse on the screen. + + .. deprecated:: Unreleased + Use :any:`integer_position` of the event returned by :any:`Context.convert_event` instead. + """ return _verify_tile_coordinates(self._tile) @tile.setter @deprecated( "The mouse.tile attribute is deprecated." - " Use mouse.position of the event returned by context.convert_event instead." + " Use mouse.integer_position of the event returned by context.convert_event instead." ) - def tile(self, xy: tuple[float, float]) -> None: + def tile(self, xy: tuple[int, int]) -> None: self._tile = Point(*xy) @@ -444,35 +466,50 @@ class MouseMotion(MouseState): `position` and `motion` now use floating point coordinates. """ - motion: Point = attrs.field(default=Point(0, 0)) + motion: Point[float] = attrs.field(default=Point(0.0, 0.0)) """The pixel delta.""" - _tile_motion: Point | None = attrs.field(default=Point(0, 0), alias="tile_motion") - """The tile delta.""" + _tile_motion: Point[int] | None = attrs.field(default=Point(0, 0), alias="tile_motion") + + @property + def integer_motion(self) -> Point[int]: + """Integer motion of this event. + + .. versionadded:: Unreleased + """ + x, y = self.position + dx, dy = self.motion + prev_x, prev_y = x - dx, y - dy + return Point(floor(x) - floor(prev_x), floor(y) - floor(prev_y)) @property @deprecated("The mouse.pixel_motion attribute is deprecated. Use mouse.motion instead.") - def pixel_motion(self) -> Point: + def pixel_motion(self) -> Point[float]: return self.motion @pixel_motion.setter @deprecated("The mouse.pixel_motion attribute is deprecated. Use mouse.motion instead.") - def pixel_motion(self, value: Point) -> None: + def pixel_motion(self, value: Point[float]) -> None: self.motion = value @property @deprecated( "The mouse.tile_motion attribute is deprecated." - " Use mouse.motion of the event returned by context.convert_event instead." + " Use mouse.integer_motion of the event returned by context.convert_event instead." ) - def tile_motion(self) -> Point: + def tile_motion(self) -> Point[int]: + """The tile delta. + + .. deprecated:: Unreleased + Use :any:`integer_motion` of the event returned by :any:`Context.convert_event` instead. + """ return _verify_tile_coordinates(self._tile_motion) @tile_motion.setter @deprecated( "The mouse.tile_motion attribute is deprecated." - " Use mouse.motion of the event returned by context.convert_event instead." + " Use mouse.integer_motion of the event returned by context.convert_event instead." ) - def tile_motion(self, xy: tuple[float, float]) -> None: + def tile_motion(self, xy: tuple[int, int]) -> None: self._tile_motion = Point(*xy) @classmethod @@ -480,16 +517,16 @@ def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> Self: motion = sdl_event.motion state = MouseButtonMask(motion.state) - pixel = Point(motion.x, motion.y) - pixel_motion = Point(motion.xrel, motion.yrel) - subtile = _pixel_to_tile(*pixel) + pixel = Point(float(motion.x), float(motion.y)) + pixel_motion = Point(float(motion.xrel), float(motion.yrel)) + subtile = _pixel_to_tile(pixel) if subtile is None: self = cls(position=pixel, motion=pixel_motion, tile=None, tile_motion=None, state=state) else: - tile = Point(int(subtile[0]), int(subtile[1])) - prev_pixel = pixel[0] - pixel_motion[0], pixel[1] - pixel_motion[1] - prev_subtile = _pixel_to_tile(*prev_pixel) or (0, 0) - prev_tile = int(prev_subtile[0]), int(prev_subtile[1]) + tile = Point(floor(subtile[0]), floor(subtile[1])) + prev_pixel = (pixel[0] - pixel_motion[0], pixel[1] - pixel_motion[1]) + prev_subtile = _pixel_to_tile(prev_pixel) or (0, 0) + prev_tile = floor(prev_subtile[0]), floor(prev_subtile[1]) tile_motion = Point(tile[0] - prev_tile[0], tile[1] - prev_tile[1]) self = cls(position=pixel, motion=pixel_motion, tile=tile, tile_motion=tile_motion, state=state) self.sdl_event = sdl_event @@ -507,10 +544,10 @@ class MouseButtonEvent(Event): No longer a subclass of :any:`MouseState`. """ - position: Point = attrs.field(default=Point(0, 0)) + position: Point[float] = attrs.field(default=Point(0.0, 0.0)) """The pixel coordinates of the mouse.""" - _tile: Point | None = attrs.field(default=Point(0, 0), alias="tile") - """The tile coordinates of the mouse on the screen.""" + _tile: Point[int] | None = attrs.field(default=Point(0, 0), alias="tile") + """The tile integer coordinates of the mouse on the screen. Deprecated.""" button: MouseButton """Which mouse button index was pressed or released in this event. @@ -521,12 +558,12 @@ class MouseButtonEvent(Event): @classmethod def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> Self: button = sdl_event.button - pixel = Point(button.x, button.y) - subtile = _pixel_to_tile(*pixel) + pixel = Point(float(button.x), float(button.y)) + subtile = _pixel_to_tile(pixel) if subtile is None: - tile: Point | None = None + tile: Point[int] | None = None else: - tile = Point(float(subtile[0]), float(subtile[1])) + tile = Point(floor(subtile[0]), floor(subtile[1])) self = cls(position=pixel, tile=tile, button=MouseButton(button.button)) self.sdl_event = sdl_event return self @@ -1362,12 +1399,12 @@ def get_mouse_state() -> MouseState: .. versionadded:: 9.3 """ - xy = ffi.new("int[2]") + xy = ffi.new("float[2]") buttons = lib.SDL_GetMouseState(xy, xy + 1) - tile = _pixel_to_tile(*xy) + tile = _pixel_to_tile(tuple(xy)) if tile is None: return MouseState(position=Point(xy[0], xy[1]), tile=None, state=buttons) - return MouseState(position=Point(xy[0], xy[1]), tile=Point(int(tile[0]), int(tile[1])), state=buttons) + return MouseState(position=Point(xy[0], xy[1]), tile=Point(floor(tile[0]), floor(tile[1])), state=buttons) @overload @@ -1431,14 +1468,13 @@ def convert_coordinates_from_window( ((event.position[0] - event.motion[0]), (event.position[1] - event.motion[1])), context, console, dest_rect ) position = convert_coordinates_from_window(event.position, context, console, dest_rect) - event.motion = tcod.event.Point(position[0] - previous_position[0], position[1] - previous_position[1]) - event._tile_motion = tcod.event.Point( - int(position[0]) - int(previous_position[0]), int(position[1]) - int(previous_position[1]) + event.motion = Point(position[0] - previous_position[0], position[1] - previous_position[1]) + event._tile_motion = Point( + floor(position[0]) - floor(previous_position[0]), floor(position[1]) - floor(previous_position[1]) ) if isinstance(event, (MouseState, MouseMotion)): - event.position = event._tile = tcod.event.Point( - *convert_coordinates_from_window(event.position, context, console, dest_rect) - ) + event.position = Point(*convert_coordinates_from_window(event.position, context, console, dest_rect)) + event._tile = Point(floor(event.position[0]), floor(event.position[1])) return event From ec32d8c2ecac0fcd6ca0a316022ec7c64b704bc4 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Thu, 12 Mar 2026 06:40:31 -0700 Subject: [PATCH 56/78] Update missing docs in tcod.event --- tcod/event.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/tcod/event.py b/tcod/event.py index b4be0a29..a7b4bddf 100644 --- a/tcod/event.py +++ b/tcod/event.py @@ -425,7 +425,7 @@ def integer_position(self) -> Point[int]: @property @deprecated("The mouse.pixel attribute is deprecated. Use mouse.position instead.") - def pixel(self) -> Point[float]: + def pixel(self) -> Point[float]: # noqa: D102 # Skip docstring for deprecated attribute return self.position @pixel.setter @@ -483,7 +483,7 @@ def integer_motion(self) -> Point[int]: @property @deprecated("The mouse.pixel_motion attribute is deprecated. Use mouse.motion instead.") - def pixel_motion(self) -> Point[float]: + def pixel_motion(self) -> Point[float]: # noqa: D102 # Skip docstring for deprecated attribute return self.motion @pixel_motion.setter @@ -739,6 +739,7 @@ class JoystickEvent(Event): @property def joystick(self) -> tcod.sdl.joystick.Joystick: + """The :any:`Joystick` for this event.""" if isinstance(self, JoystickDevice) and self.type == "JOYDEVICEADDED": return tcod.sdl.joystick.Joystick._open(self.which) return tcod.sdl.joystick.Joystick._from_instance_id(self.which) @@ -2128,14 +2129,18 @@ def _missing_(cls, value: object) -> Scancode | None: return result def __eq__(self, other: object) -> bool: + """Compare with another Scancode value. + + Comparison between :any:`KeySym` and :any:`Scancode` is not allowed and will raise :any:`TypeError`. + """ if isinstance(other, KeySym): msg = "Scancode and KeySym enums can not be compared directly. Convert one or the other to the same type." raise TypeError(msg) return super().__eq__(other) def __hash__(self) -> int: - # __eq__ was defined, so __hash__ must be defined. - return super().__hash__() + """Return the hash for this value.""" + return super().__hash__() # __eq__ was defined, so __hash__ must be defined def __repr__(self) -> str: """Return the fully qualified name of this enum.""" @@ -2708,14 +2713,18 @@ def _missing_(cls, value: object) -> KeySym | None: return result def __eq__(self, other: object) -> bool: + """Compare with another KeySym value. + + Comparison between :any:`KeySym` and :any:`Scancode` is not allowed and will raise :any:`TypeError`. + """ if isinstance(other, Scancode): msg = "Scancode and KeySym enums can not be compared directly. Convert one or the other to the same type." raise TypeError(msg) return super().__eq__(other) def __hash__(self) -> int: - # __eq__ was defined, so __hash__ must be defined. - return super().__hash__() + """Return the hash for this value.""" + return super().__hash__() # __eq__ was defined, so __hash__ must be defined def __repr__(self) -> str: """Return the fully qualified name of this enum.""" From f873f4432b2d74966a332eea277b90bbe81c4cae Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Thu, 12 Mar 2026 07:13:26 -0700 Subject: [PATCH 57/78] Centralize unpacking of SDL_Event union into Event attribute Add sdl_event to several events which were missing it Haven't decided on timestamp handling yet, this is the most I can do before committing to anything --- tcod/event.py | 72 +++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 55 insertions(+), 17 deletions(-) diff --git a/tcod/event.py b/tcod/event.py index a7b4bddf..004ff59b 100644 --- a/tcod/event.py +++ b/tcod/event.py @@ -88,7 +88,7 @@ import warnings from collections.abc import Callable, Iterator, Mapping from math import floor -from typing import TYPE_CHECKING, Any, Final, Generic, Literal, NamedTuple, TypeAlias, TypeVar, overload +from typing import TYPE_CHECKING, Any, Final, Generic, Literal, NamedTuple, TypeAlias, TypedDict, TypeVar, overload import attrs import numpy as np @@ -309,11 +309,24 @@ def __repr__(self) -> str: return "|".join(f"{self.__class__.__name__}.{self.__class__(bit).name}" for bit in self.__class__ if bit & self) +class _CommonSDLEventAttributes(TypedDict): + """Common keywords for Event subclasses.""" + + sdl_event: _C_SDL_Event + + +def _unpack_sdl_event(sdl_event: _C_SDL_Event) -> _CommonSDLEventAttributes: + """Unpack an SDL_Event union into common attributes, such as timestamp.""" + return { + "sdl_event": sdl_event, + } + + @attrs.define(slots=True, kw_only=True) class Event: """The base event class.""" - sdl_event: _C_SDL_Event = attrs.field(default=None, eq=False, kw_only=True, repr=False) + sdl_event: _C_SDL_Event = attrs.field(default=None, eq=False, repr=False) """When available, this holds a python-cffi 'SDL_Event*' pointer. All sub-classes have this attribute.""" @property @@ -349,7 +362,7 @@ class Quit(Event): @classmethod def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> Self: - return cls(sdl_event=sdl_event) + return cls(**_unpack_sdl_event(sdl_event)) @attrs.define(slots=True, kw_only=True) @@ -383,7 +396,7 @@ def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> Self: sym=KeySym(keysym.key), mod=Modifier(keysym.mod), repeat=bool(sdl_event.key.repeat), - sdl_event=sdl_event, + **_unpack_sdl_event(sdl_event), ) @@ -521,14 +534,28 @@ def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> Self: pixel_motion = Point(float(motion.xrel), float(motion.yrel)) subtile = _pixel_to_tile(pixel) if subtile is None: - self = cls(position=pixel, motion=pixel_motion, tile=None, tile_motion=None, state=state) + self = cls( + position=pixel, + motion=pixel_motion, + tile=None, + tile_motion=None, + state=state, + **_unpack_sdl_event(sdl_event), + ) else: tile = Point(floor(subtile[0]), floor(subtile[1])) prev_pixel = (pixel[0] - pixel_motion[0], pixel[1] - pixel_motion[1]) prev_subtile = _pixel_to_tile(prev_pixel) or (0, 0) prev_tile = floor(prev_subtile[0]), floor(prev_subtile[1]) tile_motion = Point(tile[0] - prev_tile[0], tile[1] - prev_tile[1]) - self = cls(position=pixel, motion=pixel_motion, tile=tile, tile_motion=tile_motion, state=state) + self = cls( + position=pixel, + motion=pixel_motion, + tile=tile, + tile_motion=tile_motion, + state=state, + **_unpack_sdl_event(sdl_event), + ) self.sdl_event = sdl_event return self @@ -564,7 +591,7 @@ def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> Self: tile: Point[int] | None = None else: tile = Point(floor(subtile[0]), floor(subtile[1])) - self = cls(position=pixel, tile=tile, button=MouseButton(button.button)) + self = cls(position=pixel, tile=tile, button=MouseButton(button.button), **_unpack_sdl_event(sdl_event)) self.sdl_event = sdl_event return self @@ -602,7 +629,7 @@ class MouseWheel(Event): @classmethod def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> Self: wheel = sdl_event.wheel - return cls(x=int(wheel.x), y=int(wheel.y), flipped=bool(wheel.direction), sdl_event=sdl_event) + return cls(x=int(wheel.x), y=int(wheel.y), flipped=bool(wheel.direction), **_unpack_sdl_event(sdl_event)) @attrs.define(slots=True, kw_only=True) @@ -620,7 +647,7 @@ class TextInput(Event): @classmethod def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> Self: - return cls(text=str(ffi.string(sdl_event.text.text, 32), encoding="utf8"), sdl_event=sdl_event) + return cls(text=str(ffi.string(sdl_event.text.text, 32), encoding="utf8"), **_unpack_sdl_event(sdl_event)) @attrs.define(slots=True, kw_only=True) @@ -655,7 +682,9 @@ def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> WindowEvent | Undefined: event_type: Final = _WINDOW_TYPES_FROM_ENUM[sdl_event.type] self: WindowEvent if sdl_event.type == lib.SDL_EVENT_WINDOW_MOVED: - self = WindowMoved(x=int(sdl_event.window.data1), y=int(sdl_event.window.data2), sdl_event=sdl_event) + self = WindowMoved( + x=int(sdl_event.window.data1), y=int(sdl_event.window.data2), **_unpack_sdl_event(sdl_event) + ) elif sdl_event.type in ( lib.SDL_EVENT_WINDOW_RESIZED, lib.SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED, @@ -664,12 +693,12 @@ def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> WindowEvent | Undefined: type=event_type, # type: ignore[arg-type] # Currently NOT validated width=int(sdl_event.window.data1), height=int(sdl_event.window.data2), - sdl_event=sdl_event, + **_unpack_sdl_event(sdl_event), ) else: self = cls( type=event_type, # type: ignore[arg-type] # Currently NOT validated - sdl_event=sdl_event, + **_unpack_sdl_event(sdl_event), ) return self @@ -764,7 +793,12 @@ class JoystickAxis(JoystickEvent): @classmethod def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> Self: - return cls(which=int(sdl_event.jaxis.which), axis=int(sdl_event.jaxis.axis), value=int(sdl_event.jaxis.value)) + return cls( + which=int(sdl_event.jaxis.which), + axis=int(sdl_event.jaxis.axis), + value=int(sdl_event.jaxis.value), + **_unpack_sdl_event(sdl_event), + ) @attrs.define(slots=True, kw_only=True) @@ -793,6 +827,7 @@ def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> Self: ball=int(sdl_event.jball.ball), dx=int(sdl_event.jball.xrel), dy=int(sdl_event.jball.yrel), + **_unpack_sdl_event(sdl_event), ) @@ -816,7 +851,7 @@ class JoystickHat(JoystickEvent): @classmethod def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> Self: x, y = _HAT_DIRECTIONS[sdl_event.jhat.hat] - return cls(which=int(sdl_event.jhat.which), x=x, y=y) + return cls(which=int(sdl_event.jhat.which), x=x, y=y, **_unpack_sdl_event(sdl_event)) @attrs.define(slots=True, kw_only=True) @@ -856,6 +891,7 @@ def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> Self: which=int(sdl_event.jbutton.which), button=int(sdl_event.jbutton.button), pressed=bool(sdl_event.jbutton.down), + **_unpack_sdl_event(sdl_event), ) @@ -889,7 +925,7 @@ def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> Self: lib.SDL_EVENT_JOYSTICK_ADDED: "JOYDEVICEADDED", lib.SDL_EVENT_JOYSTICK_REMOVED: "JOYDEVICEREMOVED", } - return cls(type=types[sdl_event.type], which=int(sdl_event.jdevice.which)) + return cls(type=types[sdl_event.type], which=int(sdl_event.jdevice.which), **_unpack_sdl_event(sdl_event)) @attrs.define(slots=True, kw_only=True) @@ -932,6 +968,7 @@ def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> Self: which=int(sdl_event.gaxis.which), axis=tcod.sdl.joystick.ControllerAxis(sdl_event.gaxis.axis), value=int(sdl_event.gaxis.value), + **_unpack_sdl_event(sdl_event), ) @@ -963,6 +1000,7 @@ def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> Self: which=int(sdl_event.gbutton.which), button=tcod.sdl.joystick.ControllerButton(sdl_event.gbutton.button), pressed=bool(sdl_event.gbutton.down), + **_unpack_sdl_event(sdl_event), ) @@ -982,7 +1020,7 @@ def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> Self: lib.SDL_EVENT_GAMEPAD_REMOVED: "CONTROLLERDEVICEREMOVED", lib.SDL_EVENT_GAMEPAD_REMAPPED: "CONTROLLERDEVICEREMAPPED", } - return cls(type=types[sdl_event.type], which=int(sdl_event.gdevice.which)) + return cls(type=types[sdl_event.type], which=int(sdl_event.gdevice.which), **_unpack_sdl_event(sdl_event)) @functools.cache @@ -1000,7 +1038,7 @@ class Undefined(Event): @classmethod def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> Self: - return cls(sdl_event=sdl_event) + return cls(**_unpack_sdl_event(sdl_event)) def __repr__(self) -> str: """Return debug info for this undefined event, including the SDL event name.""" From 4bce98475d5a5fadf9ce8cd05384bf85356f7c96 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Thu, 12 Mar 2026 12:03:00 -0700 Subject: [PATCH 58/78] Add KeyboardEvent attributes from SDL3 --- CHANGELOG.md | 1 + tcod/event.py | 24 +++++++++++++++++++++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 237c4017..9ce0fe0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version - `tcod.sdl.video.Window` now accepts an SDL WindowID. - `MouseState.integer_position` and `MouseMotion.integer_motion` to handle cases where integer values are preferred. +- `KeyboardEvent.pressed`, `KeyboardEvent.which`, `KeyboardEvent.window_id` ### Changed diff --git a/tcod/event.py b/tcod/event.py index 004ff59b..21963e18 100644 --- a/tcod/event.py +++ b/tcod/event.py @@ -387,6 +387,21 @@ class KeyboardEvent(Event): """ repeat: bool = False """True if this event exists because of key repeat.""" + which: int = 0 + """The SDL keyboard instance ID. Zero if unknown or virtual. + + .. versionadded:: Unreleased + """ + window_id: int = 0 + """The SDL window ID with keyboard focus. + + .. versionadded:: Unreleased + """ + pressed: bool = False + """True if the key was pressed, False if the key was released. + + .. versionadded:: Unreleased + """ @classmethod def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> Self: @@ -395,19 +410,22 @@ def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> Self: scancode=Scancode(keysym.scancode), sym=KeySym(keysym.key), mod=Modifier(keysym.mod), - repeat=bool(sdl_event.key.repeat), + repeat=bool(keysym.repeat), + pressed=bool(keysym.down), + which=int(keysym.which), + window_id=int(keysym.windowID), **_unpack_sdl_event(sdl_event), ) @attrs.define(slots=True, kw_only=True) class KeyDown(KeyboardEvent): - pass + """A :any:`KeyboardEvent` where the key was pressed.""" @attrs.define(slots=True, kw_only=True) class KeyUp(KeyboardEvent): - pass + """A :any:`KeyboardEvent` where the key was released.""" @attrs.define(slots=True, kw_only=True) From 6f1dc93e68e1baf20c7785adb634ede1b2be2104 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Thu, 12 Mar 2026 12:34:17 -0700 Subject: [PATCH 59/78] Add ClipboardUpdate event Still missing other related functions to handle the clipboard --- CHANGELOG.md | 6 ++++-- tcod/event.py | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ce0fe0c..06c4ed47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,10 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version ### Added - `tcod.sdl.video.Window` now accepts an SDL WindowID. -- `MouseState.integer_position` and `MouseMotion.integer_motion` to handle cases where integer values are preferred. -- `KeyboardEvent.pressed`, `KeyboardEvent.which`, `KeyboardEvent.window_id` +- `tcod.event`: + - `MouseState.integer_position` and `MouseMotion.integer_motion` to handle cases where integer values are preferred. + - `ClipboardUpdate` event. + - `KeyboardEvent.pressed`, `KeyboardEvent.which`, `KeyboardEvent.window_id` attributes. ### Changed diff --git a/tcod/event.py b/tcod/event.py index 21963e18..e4aa3774 100644 --- a/tcod/event.py +++ b/tcod/event.py @@ -1041,6 +1041,27 @@ def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> Self: return cls(type=types[sdl_event.type], which=int(sdl_event.gdevice.which), **_unpack_sdl_event(sdl_event)) +@attrs.define(slots=True, kw_only=True) +class ClipboardUpdate(Event): + """Announces changed contents of the clipboard. + + .. versionadded:: Unreleased + """ + + mime_types: tuple[str, ...] + """The MIME types of the clipboard.""" + + @classmethod + def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> Self: + return cls( + mime_types=tuple( + str(ffi.string(sdl_event.clipboard.mime_types[i]), encoding="utf8") + for i in range(sdl_event.clipboard.num_mime_types) + ), + **_unpack_sdl_event(sdl_event), + ) + + @functools.cache def _find_event_name(index: int, /) -> str: """Return the SDL event name for this index.""" @@ -1085,6 +1106,7 @@ def __repr__(self) -> str: lib.SDL_EVENT_GAMEPAD_ADDED: ControllerDevice, lib.SDL_EVENT_GAMEPAD_REMOVED: ControllerDevice, lib.SDL_EVENT_GAMEPAD_REMAPPED: ControllerDevice, + lib.SDL_EVENT_CLIPBOARD_UPDATE: ClipboardUpdate, } From 6dee18f54031d3a9805cb67c81fd678efcf12e50 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Thu, 12 Mar 2026 13:43:54 -0700 Subject: [PATCH 60/78] Add Drop events --- CHANGELOG.md | 1 + tcod/event.py | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06c4ed47..e85a0ca6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version - `tcod.event`: - `MouseState.integer_position` and `MouseMotion.integer_motion` to handle cases where integer values are preferred. - `ClipboardUpdate` event. + - `Drop` event. - `KeyboardEvent.pressed`, `KeyboardEvent.which`, `KeyboardEvent.window_id` attributes. ### Changed diff --git a/tcod/event.py b/tcod/event.py index e4aa3774..6c474c86 100644 --- a/tcod/event.py +++ b/tcod/event.py @@ -88,6 +88,7 @@ import warnings from collections.abc import Callable, Iterator, Mapping from math import floor +from pathlib import Path from typing import TYPE_CHECKING, Any, Final, Generic, Literal, NamedTuple, TypeAlias, TypedDict, TypeVar, overload import attrs @@ -1062,6 +1063,68 @@ def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> Self: ) +@attrs.define(slots=True, kw_only=True) +class Drop(Event): + """Handle dropping text or files on the window. + + Example:: + + match event: + case tcod.event.Drop(type="BEGIN"): + print("Object dragged over the window") + case tcod.event.Drop(type="POSITION", position=position): + pass + case tcod.event.Drop(type="TEXT", position=position, text=text): + print(f"Dropped {text=} at {position=}") + case tcod.event.Drop(type="FILE", position=position, path=path): + print(f"Dropped {path=} at {position=}") + case tcod.event.Drop(type="COMPLETE"): + print("Drop handling finished") + + .. versionadded:: Unreleased + """ + + type: Literal["BEGIN", "FILE", "TEXT", "COMPLETE", "POSITION"] + """The subtype of this event.""" + window_id: int + """The active window ID for this event.""" + position: Point[float] + """Mouse position relative to the window. Available in all subtypes except for ``type="BEGIN"``.""" + source: str + """The source app for this event, or an empty string if unavailable.""" + text: str + """The dropped data of a ``Drop(type="TEXT")`` or ``Drop(type="FILE")`` event. + + - If ``Drop(type="TEXT")`` then `text` is the dropped string. + - If ``Drop(type="FILE")`` then `text` is the str path of the dropped file. + Alternatively :any:`path` can be used. + - Otherwise `text` is an empty string. + """ + + @property + def path(self) -> Path: + """Return the current `text` as a :any:`Path`.""" + return Path(self.text) + + @classmethod + def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> Self: + types: dict[int, Literal["BEGIN", "FILE", "TEXT", "COMPLETE", "POSITION"]] = { + lib.SDL_EVENT_DROP_BEGIN: "BEGIN", + lib.SDL_EVENT_DROP_FILE: "FILE", + lib.SDL_EVENT_DROP_TEXT: "TEXT", + lib.SDL_EVENT_DROP_COMPLETE: "COMPLETE", + lib.SDL_EVENT_DROP_POSITION: "POSITION", + } + return cls( + type=types[sdl_event.drop.type], + window_id=int(sdl_event.drop.windowID), + position=Point(float(sdl_event.drop.x), float(sdl_event.drop.y)), + source=str(ffi.string(sdl_event.drop.source), encoding="utf8") if sdl_event.drop.source else "", + text=str(ffi.string(sdl_event.drop.data), encoding="utf8") if sdl_event.drop.data else "", + **_unpack_sdl_event(sdl_event), + ) + + @functools.cache def _find_event_name(index: int, /) -> str: """Return the SDL event name for this index.""" @@ -1107,6 +1170,11 @@ def __repr__(self) -> str: lib.SDL_EVENT_GAMEPAD_REMOVED: ControllerDevice, lib.SDL_EVENT_GAMEPAD_REMAPPED: ControllerDevice, lib.SDL_EVENT_CLIPBOARD_UPDATE: ClipboardUpdate, + lib.SDL_EVENT_DROP_BEGIN: Drop, + lib.SDL_EVENT_DROP_FILE: Drop, + lib.SDL_EVENT_DROP_TEXT: Drop, + lib.SDL_EVENT_DROP_COMPLETE: Drop, + lib.SDL_EVENT_DROP_POSITION: Drop, } From 31d5d85a9058d46608eceff741d9c5ee58306549 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Fri, 13 Mar 2026 09:09:16 -0700 Subject: [PATCH 61/78] Add basic event queue tests Break tcod.event, tcod.context cycle --- .vscode/settings.json | 1 + tcod/context.py | 2 +- tests/test_event.py | 73 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 tests/test_event.py diff --git a/.vscode/settings.json b/.vscode/settings.json index 41cffd1b..3f3eaa0f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -20,6 +20,7 @@ "aarch", "ADDA", "ADDALPHA", + "ADDEVENT", "addoption", "addopts", "addressof", diff --git a/tcod/context.py b/tcod/context.py index 994290c5..f7b92314 100644 --- a/tcod/context.py +++ b/tcod/context.py @@ -68,7 +68,7 @@ "new_window", ) -_Event = TypeVar("_Event", bound=tcod.event.Event) +_Event = TypeVar("_Event", bound="tcod.event.Event") SDL_WINDOW_FULLSCREEN = lib.SDL_WINDOW_FULLSCREEN """Fullscreen mode.""" diff --git a/tests/test_event.py b/tests/test_event.py new file mode 100644 index 00000000..9cc28243 --- /dev/null +++ b/tests/test_event.py @@ -0,0 +1,73 @@ +"""Tests for event parsing and handling.""" + +from typing import Any, Final + +import pytest + +import tcod.event +import tcod.sdl.sys +from tcod._internal import _check +from tcod.cffi import ffi, lib +from tcod.event import KeySym, Modifier, Scancode + +EXPECTED_EVENTS: Final = ( + tcod.event.Quit(), + tcod.event.KeyDown(scancode=Scancode.A, sym=KeySym.A, mod=Modifier(0), pressed=True), + tcod.event.KeyUp(scancode=Scancode.A, sym=KeySym.A, mod=Modifier(0), pressed=False), +) +"""Events to compare with after passing though the SDL event queue.""" + + +def as_sdl_event(event: tcod.event.Event) -> dict[str, dict[str, Any]]: + """Convert events into SDL_Event unions using cffi's union format.""" + match event: + case tcod.event.Quit(): + return {"quit": {"type": lib.SDL_EVENT_QUIT}} + case tcod.event.KeyboardEvent(): + return { + "key": { + "type": (lib.SDL_EVENT_KEY_UP, lib.SDL_EVENT_KEY_DOWN)[event.pressed], + "scancode": event.scancode, + "key": event.sym, + "mod": event.mod, + "down": event.pressed, + "repeat": event.repeat, + } + } + raise AssertionError + + +EVENT_PACK: Final = ffi.new("SDL_Event[]", [as_sdl_event(_e) for _e in EXPECTED_EVENTS]) +"""A custom C array of SDL_Event unions based on EXPECTED_EVENTS.""" + + +def push_events() -> None: + """Reset the SDL event queue to an expected list of events.""" + tcod.sdl.sys.init(tcod.sdl.sys.Subsystem.EVENTS) # Ensure SDL event queue is enabled + + lib.SDL_PumpEvents() # Clear everything from the queue + lib.SDL_FlushEvents(lib.SDL_EVENT_FIRST, lib.SDL_EVENT_LAST) + + assert _check( # Fill the queue with EVENT_PACK + lib.SDL_PeepEvents(EVENT_PACK, len(EVENT_PACK), lib.SDL_ADDEVENT, lib.SDL_EVENT_FIRST, lib.SDL_EVENT_LAST) + ) == len(EVENT_PACK) + + +def test_get_events() -> None: + push_events() + assert tuple(tcod.event.get()) == EXPECTED_EVENTS + + assert tuple(tcod.event.get()) == () + assert tuple(tcod.event.wait(timeout=0)) == () + + push_events() + assert tuple(tcod.event.wait()) == EXPECTED_EVENTS + + +def test_event_dispatch() -> None: + push_events() + with pytest.deprecated_call(): + tcod.event.EventDispatch().event_wait(timeout=0) + push_events() + with pytest.deprecated_call(): + tcod.event.EventDispatch().event_get() From 3e07cbf02a523c126d57d98ce7758f6f7197449b Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Fri, 13 Mar 2026 11:52:12 -0700 Subject: [PATCH 62/78] Fix missing log items in eventget example --- examples/eventget.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/eventget.py b/examples/eventget.py index dd3e4058..9dc82ca3 100755 --- a/examples/eventget.py +++ b/examples/eventget.py @@ -54,8 +54,8 @@ def main() -> None: # noqa: C901, PLR0912 joysticks.remove(joystick) case tcod.event.MouseMotion(): motion_desc = str(event) - case _: # Log all events other than MouseMotion. - event_log.append(repr(event)) + if not isinstance(event, tcod.event.MouseMotion): # Log all events other than MouseMotion + event_log.append(repr(event)) if __name__ == "__main__": From 3b3330205537477da209bb3777f130d72a717edb Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Fri, 13 Mar 2026 11:53:06 -0700 Subject: [PATCH 63/78] Update WindowEvent to handle all window event types Try to reduce code duplication in general at the cost of several minor API breaks --- .vscode/settings.json | 1 + CHANGELOG.md | 1 + tcod/event.py | 176 ++++++++++++++++++++++++++++-------------- 3 files changed, 118 insertions(+), 60 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 3f3eaa0f..10b92720 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -206,6 +206,7 @@ "htmlzip", "IBEAM", "ibus", + "ICCPROF", "ifdef", "ifndef", "iinfo", diff --git a/CHANGELOG.md b/CHANGELOG.md index e85a0ca6..a970f240 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version - `ClipboardUpdate` event. - `Drop` event. - `KeyboardEvent.pressed`, `KeyboardEvent.which`, `KeyboardEvent.window_id` attributes. + - `WindowEvent.data` and `WindowEvent.window_id` attributes and added missing SDL3 window events. ### Changed diff --git a/tcod/event.py b/tcod/event.py index 6c474c86..9b916d22 100644 --- a/tcod/event.py +++ b/tcod/event.py @@ -669,65 +669,113 @@ def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> Self: return cls(text=str(ffi.string(sdl_event.text.text, 32), encoding="utf8"), **_unpack_sdl_event(sdl_event)) +_WindowTypes = Literal[ + "WindowShown", + "WindowHidden", + "WindowExposed", + "WindowMoved", + "WindowResized", + "PixelSizeChanged", + "MetalViewResized", + "WindowMinimized", + "WindowMaximized", + "WindowRestored", + "WindowEnter", + "WindowLeave", + "WindowFocusGained", + "WindowFocusLost", + "WindowClose", + "WindowTakeFocus", + "WindowHitTest", + "ICCProfileChanged", + "DisplayChanged", + "DisplayScaleChanged", + "SafeAreaChanged", + "Occluded", + "EnterFullscreen", + "LeaveFullscreen", + "Destroyed", + "HDRStateChanged", +] + + @attrs.define(slots=True, kw_only=True) class WindowEvent(Event): - """A window event.""" - - type: Final[ # Narrowing final type. - Literal[ - "WindowShown", - "WindowHidden", - "WindowExposed", - "WindowMoved", - "WindowResized", - "WindowMinimized", - "WindowMaximized", - "WindowRestored", - "WindowEnter", - "WindowLeave", - "WindowFocusGained", - "WindowFocusLost", - "WindowClose", - "WindowTakeFocus", - "WindowHitTest", - ] - ] + """A window event. + + Example:: + + match event: + case tcod.event.WindowEvent(type="WindowShown", window_id=window_id): + print(f"Window {window_id} was shown") + case tcod.event.WindowEvent(type="WindowHidden", window_id=window_id): + print(f"Window {window_id} was hidden") + case tcod.event.WindowEvent(type="WindowExposed", window_id=window_id): + print(f"Window {window_id} was exposed and needs to be redrawn") + case tcod.event.WindowEvent(type="WindowMoved", data=(x, y), window_id=window_id): + print(f"Window {window_id} was moved to {x=},{y=}") + case tcod.event.WindowEvent(type="WindowResized", data=(width, height), window_id=window_id): + print(f"Window {window_id} was resized to {width=},{height=}") + case tcod.event.WindowEvent(type="WindowMinimized", window_id=window_id): + print(f"Window {window_id} was minimized") + case tcod.event.WindowEvent(type="WindowMaximized", window_id=window_id): + print(f"Window {window_id} was maximized") + case tcod.event.WindowEvent(type="WindowRestored", window_id=window_id): + print(f"Window {window_id} was restored") + case tcod.event.WindowEvent(type="WindowEnter", window_id=window_id): + print(f"Mouse cursor has entered window {window_id}") + case tcod.event.WindowEvent(type="WindowLeave", window_id=window_id): + print(f"Mouse cursor has left window {window_id}") + case tcod.event.WindowEvent(type="WindowFocusGained", window_id=window_id): + print(f"Window {window_id} has gained keyboard focus") + case tcod.event.WindowEvent(type="WindowFocusLost", window_id=window_id): + print(f"Window {window_id} has lost keyboard focus") + case tcod.event.WindowEvent(type="WindowClose", window_id=window_id): + print(f"Window {window_id} has been closed") + case tcod.event.WindowEvent(type="DisplayChanged", data=(display_id, _), window_id=window_id): + print(f"Window {window_id} has been moved to display {display_id}") + case tcod.event.WindowEvent(type=subtype, data=data, window_id=window_id): + print(f"Other window event {subtype} on window {window_id} with {data=}") + + .. versionchanged:: Unreleased + Added `data` and `window_id` attributes and added missing SDL3 window events. + """ + + type: Final[_WindowTypes] """The current window event. This can be one of various options.""" + window_id: int + """The SDL window ID associated with this event.""" + + data: tuple[int, int] + """The SDL data associated with this event. What these values are for depends on the event sub-type.""" + @classmethod def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> WindowEvent | Undefined: if sdl_event.type not in _WINDOW_TYPES_FROM_ENUM: return Undefined._from_sdl_event(sdl_event) event_type: Final = _WINDOW_TYPES_FROM_ENUM[sdl_event.type] - self: WindowEvent + new_cls = cls if sdl_event.type == lib.SDL_EVENT_WINDOW_MOVED: - self = WindowMoved( - x=int(sdl_event.window.data1), y=int(sdl_event.window.data2), **_unpack_sdl_event(sdl_event) - ) - elif sdl_event.type in ( - lib.SDL_EVENT_WINDOW_RESIZED, - lib.SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED, - ): - self = WindowResized( - type=event_type, # type: ignore[arg-type] # Currently NOT validated - width=int(sdl_event.window.data1), - height=int(sdl_event.window.data2), - **_unpack_sdl_event(sdl_event), - ) - else: - self = cls( - type=event_type, # type: ignore[arg-type] # Currently NOT validated - **_unpack_sdl_event(sdl_event), - ) - return self + new_cls = WindowMoved + elif sdl_event.type == lib.SDL_EVENT_WINDOW_RESIZED: + new_cls = WindowResized + return new_cls( + type=event_type, + window_id=int(sdl_event.window.windowID), + data=(int(sdl_event.window.data1), int(sdl_event.window.data2)), + **_unpack_sdl_event(sdl_event), + ) -_WINDOW_TYPES_FROM_ENUM: Final = { +_WINDOW_TYPES_FROM_ENUM: Final[dict[int, _WindowTypes]] = { lib.SDL_EVENT_WINDOW_SHOWN: "WindowShown", lib.SDL_EVENT_WINDOW_HIDDEN: "WindowHidden", lib.SDL_EVENT_WINDOW_EXPOSED: "WindowExposed", lib.SDL_EVENT_WINDOW_MOVED: "WindowMoved", lib.SDL_EVENT_WINDOW_RESIZED: "WindowResized", + lib.SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED: "PixelSizeChanged", + lib.SDL_EVENT_WINDOW_METAL_VIEW_RESIZED: "MetalViewResized", lib.SDL_EVENT_WINDOW_MINIMIZED: "WindowMinimized", lib.SDL_EVENT_WINDOW_MAXIMIZED: "WindowMaximized", lib.SDL_EVENT_WINDOW_RESTORED: "WindowRestored", @@ -737,42 +785,50 @@ def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> WindowEvent | Undefined: lib.SDL_EVENT_WINDOW_FOCUS_LOST: "WindowFocusLost", lib.SDL_EVENT_WINDOW_CLOSE_REQUESTED: "WindowClose", lib.SDL_EVENT_WINDOW_HIT_TEST: "WindowHitTest", + lib.SDL_EVENT_WINDOW_ICCPROF_CHANGED: "ICCProfileChanged", + lib.SDL_EVENT_WINDOW_DISPLAY_CHANGED: "DisplayChanged", + lib.SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED: "DisplayScaleChanged", + lib.SDL_EVENT_WINDOW_SAFE_AREA_CHANGED: "SafeAreaChanged", + lib.SDL_EVENT_WINDOW_OCCLUDED: "Occluded", + lib.SDL_EVENT_WINDOW_ENTER_FULLSCREEN: "EnterFullscreen", + lib.SDL_EVENT_WINDOW_LEAVE_FULLSCREEN: "LeaveFullscreen", + lib.SDL_EVENT_WINDOW_DESTROYED: "Destroyed", + lib.SDL_EVENT_WINDOW_HDR_STATE_CHANGED: "HDRStateChanged", } @attrs.define(slots=True, kw_only=True) class WindowMoved(WindowEvent): - """Window moved event. + """Window moved event.""" - Attributes: - x (int): Movement on the x-axis. - y (int): Movement on the y-axis. - """ - - type: Final[Literal["WINDOWMOVED"]] = "WINDOWMOVED" # type: ignore[assignment,misc] - """Always "WINDOWMOVED".""" + @property + def x(self) -> int: + """Movement on the x-axis.""" + return self.data[0] - x: int - y: int + @property + def y(self) -> int: + """Movement on the y-axis.""" + return self.data[1] @attrs.define(slots=True, kw_only=True) class WindowResized(WindowEvent): """Window resized event. - Attributes: - width (int): The current width of the window. - height (int): The current height of the window. - .. versionchanged:: 19.4 Removed "WindowSizeChanged" type. """ - type: Final[Literal["WindowResized"]] = "WindowResized" # type: ignore[misc] - """Always "WindowResized".""" + @property + def width(self) -> int: + """The current width of the window.""" + return self.data[0] - width: int - height: int + @property + def height(self) -> int: + """The current height of the window.""" + return self.data[1] @attrs.define(slots=True, kw_only=True) From 25aa1e0bc8c6fd575fe73969fac0fc8287787066 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Fri, 13 Mar 2026 12:10:24 -0700 Subject: [PATCH 64/78] Add `which` and `window_id` attribute for mouse events --- CHANGELOG.md | 1 + tcod/event.py | 36 +++++++++++++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a970f240..c9f385fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version - `Drop` event. - `KeyboardEvent.pressed`, `KeyboardEvent.which`, `KeyboardEvent.window_id` attributes. - `WindowEvent.data` and `WindowEvent.window_id` attributes and added missing SDL3 window events. + - `which` and `window_id` attributes for mouse events. ### Changed diff --git a/tcod/event.py b/tcod/event.py index 9b916d22..3fae7952 100644 --- a/tcod/event.py +++ b/tcod/event.py @@ -446,6 +446,18 @@ class MouseState(Event): state: MouseButtonMask = attrs.field(default=MouseButtonMask(0)) """A bitmask of which mouse buttons are currently held.""" + which: int = 0 + """The mouse device ID for this event. + + .. versionadded:: Unreleased + """ + + window_id: int = 0 + """The window ID with mouse focus. + + .. versionadded:: Unreleased + """ + @property def integer_position(self) -> Point[int]: """Integer coordinates of this event. @@ -547,6 +559,7 @@ def tile_motion(self, xy: tuple[int, int]) -> None: @classmethod def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> Self: motion = sdl_event.motion + common = {"which": int(motion.which), "window_id": int(motion.windowID)} state = MouseButtonMask(motion.state) pixel = Point(float(motion.x), float(motion.y)) @@ -559,6 +572,7 @@ def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> Self: tile=None, tile_motion=None, state=state, + **common, **_unpack_sdl_event(sdl_event), ) else: @@ -573,6 +587,7 @@ def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> Self: tile=tile, tile_motion=tile_motion, state=state, + **common, **_unpack_sdl_event(sdl_event), ) self.sdl_event = sdl_event @@ -601,6 +616,18 @@ class MouseButtonEvent(Event): Is now strictly a :any:`MouseButton` type. """ + which: int = 0 + """The mouse device ID for this event. + + .. versionadded:: Unreleased + """ + + window_id: int = 0 + """The window ID with mouse focus. + + .. versionadded:: Unreleased + """ + @classmethod def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> Self: button = sdl_event.button @@ -610,7 +637,14 @@ def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> Self: tile: Point[int] | None = None else: tile = Point(floor(subtile[0]), floor(subtile[1])) - self = cls(position=pixel, tile=tile, button=MouseButton(button.button), **_unpack_sdl_event(sdl_event)) + self = cls( + position=pixel, + tile=tile, + button=MouseButton(button.button), + which=int(button.which), + window_id=int(button.windowID), + **_unpack_sdl_event(sdl_event), + ) self.sdl_event = sdl_event return self From bc3a6b8b201bf87494cc93b01b4696f9365e45ce Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Fri, 13 Mar 2026 12:47:04 -0700 Subject: [PATCH 65/78] Resolve missing docstrings and minor lint issues Fix documented word accidentally interpreted as a return type --- tcod/map.py | 20 ++++++++++---------- tcod/noise.py | 17 ++++++++++++++--- tcod/path.py | 4 ++++ tcod/sdl/joystick.py | 6 ++++++ tcod/sdl/mouse.py | 7 ++++++- tcod/sdl/render.py | 2 +- 6 files changed, 41 insertions(+), 15 deletions(-) diff --git a/tcod/map.py b/tcod/map.py index 5054226b..e1f4403b 100644 --- a/tcod/map.py +++ b/tcod/map.py @@ -3,7 +3,7 @@ from __future__ import annotations import warnings -from typing import TYPE_CHECKING, Any, Literal +from typing import TYPE_CHECKING, Any, Final, Literal import numpy as np from typing_extensions import deprecated @@ -31,13 +31,6 @@ class Map: height (int): Height of the new Map. order (str): Which numpy memory order to use. - Attributes: - width (int): Read only width of this Map. - height (int): Read only height of this Map. - transparent: A boolean array of transparent cells. - walkable: A boolean array of walkable cells. - fov: A boolean array of the cells lit by :any:'compute_fov'. - Example:: >>> import tcod @@ -80,8 +73,10 @@ def __init__( order: Literal["C", "F"] = "C", ) -> None: """Initialize the map.""" - self.width = width - self.height = height + self.width: Final = width + """Read only width of this Map.""" + self.height: Final = height + """Read only height of this Map.""" self._order: Literal["C", "F"] = tcod._internal.verify_order(order) self._buffer: NDArray[np.bool_] = np.zeros((height, width, 3), dtype=np.bool_) @@ -100,16 +95,19 @@ def __as_cdata(self) -> Any: # noqa: ANN401 @property def transparent(self) -> NDArray[np.bool_]: + """A boolean array of transparent cells.""" buffer: np.ndarray[Any, np.dtype[np.bool_]] = self._buffer[:, :, 0] return buffer.T if self._order == "F" else buffer @property def walkable(self) -> NDArray[np.bool_]: + """A boolean array of walkable cells.""" buffer: np.ndarray[Any, np.dtype[np.bool_]] = self._buffer[:, :, 1] return buffer.T if self._order == "F" else buffer @property def fov(self) -> NDArray[np.bool_]: + """A boolean array of the cells lit by :any:'compute_fov'.""" buffer: np.ndarray[Any, np.dtype[np.bool_]] = self._buffer[:, :, 2] return buffer.T if self._order == "F" else buffer @@ -146,6 +144,7 @@ def compute_fov( lib.TCOD_map_compute_fov(self.map_c, x, y, radius, light_walls, algorithm) def __setstate__(self, state: dict[str, Any]) -> None: + """Unpickle this instance.""" if "_Map__buffer" in state: # Deprecated since 19.6 state["_buffer"] = state.pop("_Map__buffer") if "buffer" in state: # Deprecated @@ -159,6 +158,7 @@ def __setstate__(self, state: dict[str, Any]) -> None: self.map_c = self.__as_cdata() def __getstate__(self) -> dict[str, Any]: + """Pickle this instance.""" state = self.__dict__.copy() del state["map_c"] return state diff --git a/tcod/noise.py b/tcod/noise.py index e1295037..161dfc26 100644 --- a/tcod/noise.py +++ b/tcod/noise.py @@ -66,6 +66,7 @@ class Algorithm(enum.IntEnum): """Wavelet noise.""" def __repr__(self) -> str: + """Return the string representation for this algorithm.""" return f"tcod.noise.Algorithm.{self.name}" @@ -88,6 +89,7 @@ class Implementation(enum.IntEnum): """Turbulence noise implementation.""" def __repr__(self) -> str: + """Return the string representation for this implementation.""" return f"tcod.noise.Implementation.{self.name}" @@ -161,16 +163,17 @@ def __rng_from_seed(seed: None | int | tcod.random.Random) -> tcod.random.Random return seed def __repr__(self) -> str: + """Return the string representation of this noise instance.""" parameters = [ f"dimensions={self.dimensions}", f"algorithm={self.algorithm!r}", f"implementation={Implementation(self.implementation)!r}", ] - if self.hurst != 0.5: + if self.hurst != 0.5: # noqa: PLR2004 # Default value parameters.append(f"hurst={self.hurst}") - if self.lacunarity != 2: + if self.lacunarity != 2: # noqa: PLR2004 # Default value parameters.append(f"lacunarity={self.lacunarity}") - if self.octaves != 4: + if self.octaves != 4: # noqa: PLR2004 # Default value parameters.append(f"octaves={self.octaves}") if self._seed is not None: parameters.append(f"seed={self._seed}") @@ -178,10 +181,12 @@ def __repr__(self) -> str: @property def dimensions(self) -> int: + """Number of dimensions supported by this noise generator.""" return int(self._tdl_noise_c.dimensions) @property def algorithm(self) -> int: + """Current selected algorithm. Can be changed.""" noise_type = self.noise_c.noise_type return Algorithm(noise_type) if noise_type else Algorithm.SIMPLEX @@ -191,6 +196,7 @@ def algorithm(self, value: int) -> None: @property def implementation(self) -> int: + """Current selected implementation. Can be changed.""" return Implementation(self._tdl_noise_c.implementation) @implementation.setter @@ -202,14 +208,17 @@ def implementation(self, value: int) -> None: @property def hurst(self) -> float: + """Noise hurst exponent. Can be changed.""" return float(self.noise_c.H) @property def lacunarity(self) -> float: + """Noise lacunarity. Can be changed.""" return float(self.noise_c.lacunarity) @property def octaves(self) -> float: + """Level of detail on fBm and turbulence implementations. Can be changed.""" return float(self._tdl_noise_c.octaves) @octaves.setter @@ -343,6 +352,7 @@ def sample_ogrid(self, ogrid: Sequence[ArrayLike]) -> NDArray[np.float32]: return out def __getstate__(self) -> dict[str, Any]: + """Support picking this instance.""" state = self.__dict__.copy() if self.dimensions < 4 and self.noise_c.waveletTileData == ffi.NULL: # noqa: PLR2004 # Trigger a side effect of wavelet, so that copies will be synced. @@ -374,6 +384,7 @@ def __getstate__(self) -> dict[str, Any]: return state def __setstate__(self, state: dict[str, Any]) -> None: + """Unpickle this instance.""" if isinstance(state, tuple): # deprecated format return self._setstate_old(state) # unpack wavelet tile data if it exists diff --git a/tcod/path.py b/tcod/path.py index d68fa8e1..ecdb6b46 100644 --- a/tcod/path.py +++ b/tcod/path.py @@ -112,6 +112,7 @@ def __init__( callback: Callable[[int, int, int, int], float], shape: tuple[int, int], ) -> None: + """Initialize this callback.""" self.callback = callback super().__init__(callback, shape) @@ -139,6 +140,7 @@ def __new__(cls, array: ArrayLike) -> Self: return np.asarray(array).view(cls) def __repr__(self) -> str: + """Return the string representation of this object.""" return f"{self.__class__.__name__}({repr(self.view(np.ndarray))!r})" def get_tcod_path_ffi(self) -> tuple[Any, Any, tuple[int, int]]: @@ -1068,10 +1070,12 @@ def __init__(self, *, cost: ArrayLike, cardinal: int, diagonal: int, greed: int @property def ndim(self) -> int: + """Number of dimensions.""" return 2 @property def shape(self) -> tuple[int, int]: + """Shape of this graph.""" return self._shape @property diff --git a/tcod/sdl/joystick.py b/tcod/sdl/joystick.py index e7e1bec0..e6f2de84 100644 --- a/tcod/sdl/joystick.py +++ b/tcod/sdl/joystick.py @@ -103,6 +103,7 @@ class Joystick: """Currently opened joysticks.""" def __init__(self, sdl_joystick_p: Any) -> None: # noqa: ANN401 + """Wrap an SDL joystick C pointer.""" self.sdl_joystick_p: Final = sdl_joystick_p """The CFFI pointer to an SDL_Joystick struct.""" self.axes: Final[int] = _check_int(lib.SDL_GetNumJoystickAxes(self.sdl_joystick_p), failure=-1) @@ -135,11 +136,13 @@ def _from_instance_id(cls, instance_id: int) -> Joystick: return cls._by_instance_id[instance_id] def __eq__(self, other: object) -> bool: + """Return True if `self` and `other` refer to the same joystick.""" if isinstance(other, Joystick): return self.id == other.id return NotImplemented def __hash__(self) -> int: + """Return the joystick id as a hash.""" return hash(self.id) def _get_guid(self) -> str: @@ -173,6 +176,7 @@ class GameController: """Currently opened controllers.""" def __init__(self, sdl_controller_p: Any) -> None: # noqa: ANN401 + """Wrap an SDL controller C pointer.""" self.sdl_controller_p: Final = sdl_controller_p self.joystick: Final = Joystick(lib.SDL_GetGamepadJoystick(self.sdl_controller_p)) """The :any:`Joystick` associated with this controller.""" @@ -200,11 +204,13 @@ def get_axis(self, axis: ControllerAxis) -> int: return int(lib.SDL_GetGamepadAxis(self.sdl_controller_p, axis)) def __eq__(self, other: object) -> bool: + """Return True if `self` and `other` are both controllers referring to the same joystick.""" if isinstance(other, GameController): return self.joystick.id == other.joystick.id return NotImplemented def __hash__(self) -> int: + """Return the joystick id as a hash.""" return hash(self.joystick.id) # These could exist as convenience functions, but the get_X functions are probably better. diff --git a/tcod/sdl/mouse.py b/tcod/sdl/mouse.py index 4b1f10ef..c1dbff7f 100644 --- a/tcod/sdl/mouse.py +++ b/tcod/sdl/mouse.py @@ -28,6 +28,7 @@ class Cursor: """A cursor icon for use with :any:`set_cursor`.""" def __init__(self, sdl_cursor_p: Any) -> None: # noqa: ANN401 + """Wrap an SDL cursor C pointer.""" if ffi.typeof(sdl_cursor_p) is not ffi.typeof("struct SDL_Cursor*"): msg = f"Expected a {ffi.typeof('struct SDL_Cursor*')} type (was {ffi.typeof(sdl_cursor_p)})." raise TypeError(msg) @@ -37,9 +38,13 @@ def __init__(self, sdl_cursor_p: Any) -> None: # noqa: ANN401 self.p = sdl_cursor_p def __eq__(self, other: object) -> bool: - return bool(self.p == getattr(other, "p", None)) + """Return True if `self` is the same cursor as `other`.""" + if isinstance(other, Cursor): + return bool(self.p == getattr(other, "p", None)) + return NotImplemented def __hash__(self) -> int: + """Returns the hash of this objects C pointer.""" return hash(self.p) @classmethod diff --git a/tcod/sdl/render.py b/tcod/sdl/render.py index 7e4a5df6..05147825 100644 --- a/tcod/sdl/render.py +++ b/tcod/sdl/render.py @@ -571,7 +571,7 @@ def read_pixels( See https://wiki.libsdl.org/SDL3/SDL_RenderReadPixels Returns: - The output uint8 array of shape: ``(height, width, channels)`` with the fetched pixels. + The output uint8 array of shape ``(height, width, channels)`` with the fetched pixels. .. versionadded:: 15.0 From a63d3a16d7cb974229a8f4cbc757f3fdf9d48f98 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Fri, 13 Mar 2026 13:34:43 -0700 Subject: [PATCH 66/78] Add timestamp to events, SDL time functions Adjust inherited members from Event, hide the deprecated `type` attribute. --- CHANGELOG.md | 1 + docs/tcod/event.rst | 2 +- tcod/event.py | 44 +++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9f385fa..ea5dc596 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version - `KeyboardEvent.pressed`, `KeyboardEvent.which`, `KeyboardEvent.window_id` attributes. - `WindowEvent.data` and `WindowEvent.window_id` attributes and added missing SDL3 window events. - `which` and `window_id` attributes for mouse events. + - Events now have `Event.timestamp` and `Event.timestamp_ns` which use SDL's timer at `tcod.event.time` and `tcod.event.time_ns`. ### Changed diff --git a/docs/tcod/event.rst b/docs/tcod/event.rst index 3f2d3eeb..1fd5c7bb 100644 --- a/docs/tcod/event.rst +++ b/docs/tcod/event.rst @@ -3,7 +3,7 @@ SDL Event Handling ``tcod.event`` .. automodule:: tcod.event :members: - :inherited-members: object, int, str, tuple, Event + :inherited-members: object, int, str, tuple :member-order: bysource :exclude-members: KeySym, Scancode, Modifier, get, wait diff --git a/tcod/event.py b/tcod/event.py index 3fae7952..28ed6c15 100644 --- a/tcod/event.py +++ b/tcod/event.py @@ -314,12 +314,14 @@ class _CommonSDLEventAttributes(TypedDict): """Common keywords for Event subclasses.""" sdl_event: _C_SDL_Event + timestamp_ns: int def _unpack_sdl_event(sdl_event: _C_SDL_Event) -> _CommonSDLEventAttributes: """Unpack an SDL_Event union into common attributes, such as timestamp.""" return { "sdl_event": sdl_event, + "timestamp_ns": sdl_event.common.timestamp, } @@ -328,7 +330,27 @@ class Event: """The base event class.""" sdl_event: _C_SDL_Event = attrs.field(default=None, eq=False, repr=False) - """When available, this holds a python-cffi 'SDL_Event*' pointer. All sub-classes have this attribute.""" + """Holds a python-cffi ``SDL_Event*`` pointer for this event when available.""" + + timestamp_ns: int = attrs.field(default=0, eq=False) + """The time of this event in nanoseconds since SDL has been initialized. + + .. seealso:: + :any:`tcod.event.time_ns` + + .. versionadded:: Unreleased + """ + + @property + def timestamp(self) -> float: + """The time of this event in seconds since SDL has been initialized. + + .. seealso:: + :any:`tcod.event.time` + + .. versionadded:: Unreleased + """ + return self.timestamp_ns / 1_000_000_000 @property @deprecated("The Event.type attribute is deprecated, use isinstance instead.") @@ -337,6 +359,8 @@ def type(self) -> str: .. deprecated:: Unreleased Using this attribute is now actively discouraged. Use :func:`isinstance` or :ref:`match`. + + :meta private: """ type_override: str | None = getattr(self, "_type", None) if type_override is not None: @@ -3070,6 +3094,22 @@ def __getattr__(name: str) -> int: return value +def time_ns() -> int: + """Return the nanoseconds elapsed since SDL was initialized. + + .. versionadded:: Unreleased + """ + return int(lib.SDL_GetTicksNS()) + + +def time() -> float: + """Return the seconds elapsed since SDL was initialized. + + .. versionadded:: Unreleased + """ + return time_ns() / 1_000_000_000 + + __all__ = ( # noqa: F405 RUF022 "Point", "Modifier", @@ -3111,6 +3151,8 @@ def __getattr__(name: str) -> int: "get_modifier_state", "Scancode", "KeySym", + "time_ns", + "time", # --- From event_constants.py --- "MOUSEWHEEL_NORMAL", "MOUSEWHEEL_FLIPPED", From 8e0324e1e7252e21163e8a714251c707d4647c9f Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Fri, 13 Mar 2026 13:38:44 -0700 Subject: [PATCH 67/78] Remove reference to deprecated type attribute --- tcod/event.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tcod/event.py b/tcod/event.py index 28ed6c15..ab3d36f2 100644 --- a/tcod/event.py +++ b/tcod/event.py @@ -682,12 +682,12 @@ def state(self) -> int: # noqa: D102 # Skip docstring for deprecated property @attrs.define(slots=True, kw_only=True) class MouseButtonDown(MouseButtonEvent): - """Same as MouseButtonEvent but with ``type="MouseButtonDown"``.""" + """Mouse button has been pressed.""" @attrs.define(slots=True, kw_only=True) class MouseButtonUp(MouseButtonEvent): - """Same as MouseButtonEvent but with ``type="MouseButtonUp"``.""" + """Mouse button has been released.""" @attrs.define(slots=True, kw_only=True) From d37feeae0731dac28ce462acf0925bc2ca6dc067 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Fri, 13 Mar 2026 15:01:12 -0700 Subject: [PATCH 68/78] Remove unmaintained termbox Upstream repo has gone stale This might have worked better as an external project --- examples/termbox/README.md | 57 ------ examples/termbox/termbox.py | 296 -------------------------------- examples/termbox/termboxtest.py | 137 --------------- 3 files changed, 490 deletions(-) delete mode 100644 examples/termbox/README.md delete mode 100755 examples/termbox/termbox.py delete mode 100755 examples/termbox/termboxtest.py diff --git a/examples/termbox/README.md b/examples/termbox/README.md deleted file mode 100644 index 36f13c20..00000000 --- a/examples/termbox/README.md +++ /dev/null @@ -1,57 +0,0 @@ -API of `termbox` Python module implemented in `tld`. - -The code here are modified files from -[termbox repository](https://github.com/nsf/termbox/), so please consult -it for the license and other info. - -The code consists of two part - `termbox.py` module with API, translation -of official binding form the description below into `tld`: - -https://github.com/nsf/termbox/blob/b20c0a11/src/python/termboxmodule.pyx - -And the example `termboxtest.py` which is copied verbatim from: - -https://github.com/nsf/termbox/blob/b20c0a11/test_termboxmodule.py - -### API Mapping Notes - -Notes taken while mapping the Termbox class: - - tb_init() // initialization console = tdl.init(132, 60) - tb_shutdown() // shutdown - - tb_width() // width of the terminal screen console.width - tb_height() // height of the terminal screen console.height - - tb_clear() // clear buffer console.clear() - tb_present() // sync internal buffer with terminal tdl.flush() - - tb_put_cell() - tb_change_cell() console.draw_char(x, y, ch, fg, bg) - tb_blit() // drawing functions - - tb_select_input_mode() // change input mode - tb_peek_event() // peek a keyboard event - tb_poll_event() // wait for a keyboard event * tdl.event.get() - - - * - means the translation is not direct - - - - init... - tdl doesn't allow to resize window (or rather libtcod) - tb works in existing terminal window and queries it rather than making own - - colors... - tdl uses RGB values - tb uses it own constants - - event... - tb returns event one by one - tdl return an event iterator - - - tb Event tdl Event - .type .type - EVENT_KEY KEYDOWN diff --git a/examples/termbox/termbox.py b/examples/termbox/termbox.py deleted file mode 100755 index 4634c990..00000000 --- a/examples/termbox/termbox.py +++ /dev/null @@ -1,296 +0,0 @@ -"""Implementation of Termbox Python API in tdl. - -See README.md for details. -""" - -import tdl - -""" -Implementation status: - [ ] tdl.init() needs a window, made 132x60 - [ ] Termbox.close() is not implemented, does nothing - [ ] poll_event needs review, because it does not - completely follows the original logic - [ ] peek is stubbed, but not implemented - [ ] not all keys/events are mapped -""" - -# ruff: noqa - - -class TermboxException(Exception): - def __init__(self, msg) -> None: - self.msg = msg - - def __str__(self) -> str: - return self.msg - - -_instance = None - -# keys ---------------------------------- -KEY_F1 = 0xFFFF - 0 -KEY_F2 = 0xFFFF - 1 -KEY_F3 = 0xFFFF - 2 -KEY_F4 = 0xFFFF - 3 -KEY_F5 = 0xFFFF - 4 -KEY_F6 = 0xFFFF - 5 -KEY_F7 = 0xFFFF - 6 -KEY_F8 = 0xFFFF - 7 -KEY_F9 = 0xFFFF - 8 -KEY_F10 = 0xFFFF - 9 -KEY_F11 = 0xFFFF - 10 -KEY_F12 = 0xFFFF - 11 -KEY_INSERT = 0xFFFF - 12 -KEY_DELETE = 0xFFFF - 13 - -KEY_PGUP = 0xFFFF - 16 -KEY_PGDN = 0xFFFF - 17 - -KEY_MOUSE_LEFT = 0xFFFF - 22 -KEY_MOUSE_RIGHT = 0xFFFF - 23 -KEY_MOUSE_MIDDLE = 0xFFFF - 24 -KEY_MOUSE_RELEASE = 0xFFFF - 25 -KEY_MOUSE_WHEEL_UP = 0xFFFF - 26 -KEY_MOUSE_WHEEL_DOWN = 0xFFFF - 27 - -KEY_CTRL_TILDE = 0x00 -KEY_CTRL_2 = 0x00 -KEY_CTRL_A = 0x01 -KEY_CTRL_B = 0x02 -KEY_CTRL_C = 0x03 -KEY_CTRL_D = 0x04 -KEY_CTRL_E = 0x05 -KEY_CTRL_F = 0x06 -KEY_CTRL_G = 0x07 -KEY_BACKSPACE = 0x08 -KEY_CTRL_H = 0x08 -KEY_TAB = 0x09 -KEY_CTRL_I = 0x09 -KEY_CTRL_J = 0x0A -KEY_CTRL_K = 0x0B -KEY_CTRL_L = 0x0C -KEY_ENTER = 0x0D -KEY_CTRL_M = 0x0D -KEY_CTRL_N = 0x0E -KEY_CTRL_O = 0x0F -KEY_CTRL_P = 0x10 -KEY_CTRL_Q = 0x11 -KEY_CTRL_R = 0x12 -KEY_CTRL_S = 0x13 -KEY_CTRL_T = 0x14 -KEY_CTRL_U = 0x15 -KEY_CTRL_V = 0x16 -KEY_CTRL_W = 0x17 -KEY_CTRL_X = 0x18 -KEY_CTRL_Y = 0x19 -KEY_CTRL_Z = 0x1A - - -# -- mapped to tdl -KEY_HOME = "HOME" -KEY_END = "END" -KEY_ARROW_UP = "UP" -KEY_ARROW_DOWN = "DOWN" -KEY_ARROW_LEFT = "LEFT" -KEY_ARROW_RIGHT = "RIGHT" -KEY_ESC = "ESCAPE" -# /-- - - -KEY_CTRL_LSQ_BRACKET = 0x1B -KEY_CTRL_3 = 0x1B -KEY_CTRL_4 = 0x1C -KEY_CTRL_BACKSLASH = 0x1C -KEY_CTRL_5 = 0x1D -KEY_CTRL_RSQ_BRACKET = 0x1D -KEY_CTRL_6 = 0x1E -KEY_CTRL_7 = 0x1F -KEY_CTRL_SLASH = 0x1F -KEY_CTRL_UNDERSCORE = 0x1F -KEY_SPACE = 0x20 -KEY_BACKSPACE2 = 0x7F -KEY_CTRL_8 = 0x7F - -MOD_ALT = 0x01 - -# attributes ---------------------- - -# -- mapped to tdl -DEFAULT = Ellipsis - -BLACK = 0x000000 -RED = 0xFF0000 -GREEN = 0x00FF00 -YELLOW = 0xFFFF00 -BLUE = 0x0000FF -MAGENTA = 0xFF00FF -CYAN = 0x00FFFF -WHITE = 0xFFFFFF -# /-- - -BOLD = 0x10 -UNDERLINE = 0x20 -REVERSE = 0x40 - -# misc ---------------------------- - -HIDE_CURSOR = -1 -INPUT_CURRENT = 0 -INPUT_ESC = 1 -INPUT_ALT = 2 -OUTPUT_CURRENT = 0 -OUTPUT_NORMAL = 1 -OUTPUT_256 = 2 -OUTPUT_216 = 3 -OUTPUT_GRAYSCALE = 4 - - -# -- mapped to tdl -EVENT_KEY = "KEYDOWN" -# /-- -EVENT_RESIZE = 2 -EVENT_MOUSE = 3 - - -class Event: - """Aggregate for Termbox Event structure.""" - - type = None - ch = None - key = None - mod = None - width = None - height = None - mousex = None - mousey = None - - def gettuple(self): - return (self.type, self.ch, self.key, self.mod, self.width, self.height, self.mousex, self.mousey) - - -class Termbox: - def __init__(self, width=132, height=60) -> None: - global _instance - if _instance: - msg = "It is possible to create only one instance of Termbox" - raise TermboxException(msg) - - try: - self.console = tdl.init(width, height) - except tdl.TDLException as e: - raise TermboxException(e) - - self.e = Event() # cache for event data - - _instance = self - - def __del__(self) -> None: - self.close() - - def __exit__(self, *args): # t, value, traceback): - self.close() - - def __enter__(self): - return self - - def close(self): - global _instance - # tb_shutdown() - _instance = None - # TBD, does nothing - - def present(self): - """Sync state of the internal cell buffer with the terminal.""" - tdl.flush() - - def change_cell(self, x, y, ch, fg, bg): - """Change cell in position (x;y).""" - self.console.draw_char(x, y, ch, fg, bg) - - def width(self): - """Returns width of the terminal screen.""" - return self.console.width - - def height(self): - """Return height of the terminal screen.""" - return self.console.height - - def clear(self): - """Clear the internal cell buffer.""" - self.console.clear() - - def set_cursor(self, x, y): - """Set cursor position to (x;y). - - Set both arguments to HIDE_CURSOR or use 'hide_cursor' function to - hide it. - """ - tb_set_cursor(x, y) - - def hide_cursor(self): - """Hide cursor.""" - tb_set_cursor(-1, -1) - - def select_input_mode(self, mode): - """Select preferred input mode: INPUT_ESC or INPUT_ALT. - - INPUT_CURRENT returns the selected mode without changing anything. - """ - return int(tb_select_input_mode(mode)) - - def select_output_mode(self, mode): - """Select preferred output mode: one of OUTPUT_* constants. - - OUTPUT_CURRENT returns the selected mode without changing anything. - """ - return int(tb_select_output_mode(mode)) - - def peek_event(self, timeout=0): - """Wait for an event up to 'timeout' milliseconds and return it. - - Returns None if there was no event and timeout is expired. - Returns a tuple otherwise: (type, unicode character, key, mod, - width, height, mousex, mousey). - """ - """ - cdef tb_event e - with self._poll_lock: - with nogil: - result = tb_peek_event(&e, timeout) - assert(result >= 0) - if result == 0: - return None - if e.ch: - uch = unichr(e.ch) - else: - uch = None - """ - # return (e.type, uch, e.key, e.mod, e.w, e.h, e.x, e.y) - - def poll_event(self): - """Wait for an event and return it. - - Returns a tuple: (type, unicode character, key, mod, width, height, - mousex, mousey). - """ - """ - cdef tb_event e - with self._poll_lock: - with nogil: - result = tb_poll_event(&e) - assert(result >= 0) - if e.ch: - uch = unichr(e.ch) - else: - uch = None - """ - for e in tdl.event.get(): - # [ ] not all events are passed thru - self.e.type = e.type - if e.type == "KEYDOWN": - self.e.key = e.key - return self.e.gettuple() - return None - - # return (e.type, uch, e.key, e.mod, e.w, e.h, e.x, e.y) diff --git a/examples/termbox/termboxtest.py b/examples/termbox/termboxtest.py deleted file mode 100755 index 696be1ce..00000000 --- a/examples/termbox/termboxtest.py +++ /dev/null @@ -1,137 +0,0 @@ -#!/usr/bin/env python - -import termbox - -# ruff: noqa - -spaceord = ord(" ") - - -def print_line(t, msg, y, fg, bg): - w = t.width() - l = len(msg) - x = 0 - for i in range(w): - c = spaceord - if i < l: - c = ord(msg[i]) - t.change_cell(x + i, y, c, fg, bg) - - -class SelectBox: - def __init__(self, tb, choices, active=-1) -> None: - self.tb = tb - self.active = active - self.choices = choices - self.color_active = (termbox.BLACK, termbox.CYAN) - self.color_normal = (termbox.WHITE, termbox.BLACK) - - def draw(self): - for i, c in enumerate(self.choices): - color = self.color_normal - if i == self.active: - color = self.color_active - print_line(self.tb, c, i, *color) - - def validate_active(self): - if self.active < 0: - self.active = 0 - if self.active >= len(self.choices): - self.active = len(self.choices) - 1 - - def set_active(self, i): - self.active = i - self.validate_active() - - def move_up(self): - self.active -= 1 - self.validate_active() - - def move_down(self): - self.active += 1 - self.validate_active() - - -choices = [ - "This instructs Psyco", - "to compile and run as", - "much of your application", - "code as possible. This is the", - "simplest interface to Psyco.", - "In good cases you can just add", - "these two lines and enjoy the speed-up.", - "If your application does a lot", - "of initialization stuff before", - "the real work begins, you can put", - "the above two lines after this", - "initialization - e.g. after importing", - "modules, creating constant global objects, etc.", - "This instructs Psyco", - "to compile and run as", - "much of your application", - "code as possible. This is the", - "simplest interface to Psyco.", - "In good cases you can just add", - "these two lines and enjoy the speed-up.", - "If your application does a lot", - "of initialization stuff before", - "the real work begins, you can put", - "the above two lines after this", - "initialization - e.g. after importing", - "modules, creating constant global objects, etc.", -] - - -def draw_bottom_line(t, i): - i = i % 8 - w = t.width() - h = t.height() - c = i - palette = [ - termbox.DEFAULT, - termbox.BLACK, - termbox.RED, - termbox.GREEN, - termbox.YELLOW, - termbox.BLUE, - termbox.MAGENTA, - termbox.CYAN, - termbox.WHITE, - ] - for x in range(w): - t.change_cell(x, h - 1, ord(" "), termbox.BLACK, palette[c]) - t.change_cell(x, h - 2, ord(" "), termbox.BLACK, palette[c]) - c += 1 - if c > 7: - c = 0 - - -with termbox.Termbox() as t: - sb = SelectBox(t, choices, 0) - t.clear() - sb.draw() - t.present() - i = 0 - run_app = True - while run_app: - event_here = t.poll_event() - while event_here: - (type, ch, key, mod, w, h, x, y) = event_here - if type == termbox.EVENT_KEY and key == termbox.KEY_ESC: - run_app = False - if type == termbox.EVENT_KEY: - if key == termbox.KEY_ARROW_DOWN: - sb.move_down() - elif key == termbox.KEY_ARROW_UP: - sb.move_up() - elif key == termbox.KEY_HOME: - sb.set_active(-1) - elif key == termbox.KEY_END: - sb.set_active(999) - event_here = t.peek_event() - - t.clear() - sb.draw() - draw_bottom_line(t, i) - t.present() - i += 1 From 72a93eecc7d4642366f2b6423b8cc360911d5f59 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Fri, 13 Mar 2026 15:17:36 -0700 Subject: [PATCH 69/78] Test samples_libtcodpy and fix regressions --- examples/samples_libtcodpy.py | 9 +-------- tcod/libtcodpy.py | 4 +++- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/examples/samples_libtcodpy.py b/examples/samples_libtcodpy.py index 9e0cb41d..71780d97 100755 --- a/examples/samples_libtcodpy.py +++ b/examples/samples_libtcodpy.py @@ -12,14 +12,7 @@ import sys import warnings -import tcod as libtcod - -try: # Import Psyco if available - import psyco - - psyco.full() -except ImportError: - pass +from tcod import tcod as libtcod if not sys.warnoptions: warnings.simplefilter("ignore") # Prevent flood of deprecation warnings. diff --git a/tcod/libtcodpy.py b/tcod/libtcodpy.py index 7da9ff30..bae62a1f 100644 --- a/tcod/libtcodpy.py +++ b/tcod/libtcodpy.py @@ -546,7 +546,7 @@ def bsp_split_once(node: tcod.bsp.BSP, horizontal: bool, position: int) -> None: @deprecate("Call node.split_recursive instead.", category=FutureWarning) def bsp_split_recursive( node: tcod.bsp.BSP, - randomizer: tcod.random.Random | None, + randomizer: Literal[0] | tcod.random.Random | None, nb: int, minHSize: int, # noqa: N803 minVSize: int, # noqa: N803 @@ -558,6 +558,8 @@ def bsp_split_recursive( .. deprecated:: 2.0 Use :any:`BSP.split_recursive` instead. """ + if randomizer == 0: + randomizer = None node.split_recursive(nb, minHSize, minVSize, maxHRatio, maxVRatio, randomizer) From bc4ebdd07572234b09345a5b7073634b9aa3097d Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Fri, 13 Mar 2026 15:47:34 -0700 Subject: [PATCH 70/78] Clean up sample lint issues --- examples/samples_tcod.py | 84 ++++++++++++++++++++-------------------- 1 file changed, 42 insertions(+), 42 deletions(-) diff --git a/examples/samples_tcod.py b/examples/samples_tcod.py index 19d4fd92..bae9acd6 100755 --- a/examples/samples_tcod.py +++ b/examples/samples_tcod.py @@ -22,6 +22,7 @@ import tcod.bsp import tcod.cffi import tcod.console +import tcod.constants import tcod.context import tcod.event import tcod.image @@ -76,15 +77,18 @@ def _get_elapsed_time() -> float: class Sample: + """Samples base class.""" + name: str = "???" def on_enter(self) -> None: - pass + """Called when entering a sample.""" def on_draw(self) -> None: - pass + """Called every frame.""" def on_event(self, event: tcod.event.Event) -> None: + """Called for each event.""" global cur_sample match event: case tcod.event.Quit() | tcod.event.KeyDown(sym=KeySym.ESCAPE): @@ -114,9 +118,12 @@ def on_event(self, event: tcod.event.Event) -> None: class TrueColorSample(Sample): + """Simple performance benchmark.""" + name = "True colors" def __init__(self) -> None: + """Initialize random generators.""" self.noise = tcod.noise.Noise(2, tcod.noise.Algorithm.SIMPLEX) """Noise for generating color.""" @@ -124,6 +131,7 @@ def __init__(self) -> None: """Numpy generator for random text.""" def on_draw(self) -> None: + """Draw this sample.""" self.interpolate_corner_colors() self.darken_background_characters() self.randomize_sample_console() @@ -178,9 +186,15 @@ def randomize_sample_console(self) -> None: class OffscreenConsoleSample(Sample): + """Console blit example.""" + name = "Offscreen console" + CONSOLE_MOVE_RATE = 1 / 2 + CONSOLE_MOVE_MARGIN = 5 + def __init__(self) -> None: + """Initialize the offscreen console.""" self.secondary = tcod.console.Console(sample_console.width // 2, sample_console.height // 2) self.screenshot = tcod.console.Console(sample_console.width, sample_console.height) self.counter = 0.0 @@ -189,57 +203,51 @@ def __init__(self) -> None: self.x_dir = 1 self.y_dir = 1 - self.secondary.draw_frame( + self.secondary.draw_frame(0, 0, self.secondary.width, self.secondary.height, clear=False, fg=WHITE, bg=BLACK) + self.secondary.print( 0, 0, - sample_console.width // 2, - sample_console.height // 2, - "Offscreen console", - clear=False, - fg=WHITE, - bg=BLACK, + width=self.secondary.width, + height=self.secondary.height, + text=" Offscreen console ", + fg=BLACK, + bg=WHITE, + alignment=tcod.constants.CENTER, ) - self.secondary.print_box( - 1, - 2, - sample_console.width // 2 - 2, - sample_console.height // 2, - "You can render to an offscreen console and blit in on another one, simulating alpha transparency.", + self.secondary.print( + x=1, + y=2, + width=sample_console.width // 2 - 2, + height=sample_console.height // 2, + text="You can render to an offscreen console and blit in on another one, simulating alpha transparency.", fg=WHITE, bg=None, alignment=libtcodpy.CENTER, ) def on_enter(self) -> None: + """Capture the previous sample screen as this samples background.""" self.counter = _get_elapsed_time() - # get a "screenshot" of the current sample screen - sample_console.blit(dest=self.screenshot) + sample_console.blit(dest=self.screenshot) # get a "screenshot" of the current sample screen def on_draw(self) -> None: - if _get_elapsed_time() - self.counter >= 1: + """Draw and animate the offscreen console.""" + if _get_elapsed_time() - self.counter >= self.CONSOLE_MOVE_RATE: self.counter = _get_elapsed_time() self.x += self.x_dir self.y += self.y_dir - if self.x == sample_console.width / 2 + 5: + if self.x == sample_console.width / 2 + self.CONSOLE_MOVE_MARGIN: self.x_dir = -1 - elif self.x == -5: + elif self.x == -self.CONSOLE_MOVE_MARGIN: self.x_dir = 1 - if self.y == sample_console.height / 2 + 5: + if self.y == sample_console.height / 2 + self.CONSOLE_MOVE_MARGIN: self.y_dir = -1 - elif self.y == -5: + elif self.y == -self.CONSOLE_MOVE_MARGIN: self.y_dir = 1 self.screenshot.blit(sample_console) self.secondary.blit( - sample_console, - self.x, - self.y, - 0, - 0, - sample_console.width // 2, - sample_console.height // 2, - 1.0, - 0.75, + sample_console, self.x, self.y, 0, 0, sample_console.width // 2, sample_console.height // 2, 1.0, 0.75 ) @@ -298,9 +306,7 @@ def on_draw(self) -> None: for x in range(sample_console.width): value = x * 255 // sample_console.width col = (value, value, value) - libtcodpy.console_set_char_background(sample_console, x, rect_y, col, self.bk_flag) - libtcodpy.console_set_char_background(sample_console, x, rect_y + 1, col, self.bk_flag) - libtcodpy.console_set_char_background(sample_console, x, rect_y + 2, col, self.bk_flag) + sample_console.draw_rect(x=x, y=rect_y, width=1, height=3, ch=0, fg=None, bg=col, bg_blend=self.bk_flag) angle = time.time() * 2.0 cos_angle = math.cos(angle) sin_angle = math.sin(angle) @@ -312,14 +318,8 @@ def on_draw(self) -> None: # in python the easiest way is to use the line iterator for x, y in tcod.los.bresenham((xo, yo), (xd, yd)).tolist(): if 0 <= x < sample_console.width and 0 <= y < sample_console.height: - libtcodpy.console_set_char_background(sample_console, x, y, LIGHT_BLUE, self.bk_flag) - sample_console.print( - 2, - 2, - f"{self.FLAG_NAMES[self.bk_flag & 0xFF]} (ENTER to change)", - fg=WHITE, - bg=None, - ) + sample_console.draw_rect(x, y, width=1, height=1, ch=0, fg=None, bg=LIGHT_BLUE, bg_blend=self.bk_flag) + sample_console.print(2, 2, f"{self.FLAG_NAMES[self.bk_flag & 0xFF]} (ENTER to change)", fg=WHITE, bg=None) class NoiseSample(Sample): From a954fa3a7a4812282961baba271324c590aa4601 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Fri, 13 Mar 2026 15:56:33 -0700 Subject: [PATCH 71/78] Prepare 21.0.0 release. --- CHANGELOG.md | 3 +++ tcod/event.py | 48 +++++++++++++++++++++++------------------------ tcod/sdl/video.py | 2 +- 3 files changed, 28 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea5dc596..c2b81f68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version ## [Unreleased] +## [21.0.0] - 2026-03-13 + ### Added - `tcod.sdl.video.Window` now accepts an SDL WindowID. @@ -39,6 +41,7 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version - Fixed incorrect C FFI types inside `tcod.event.get_mouse_state`. - Fixed regression in mouse event tile coordinates being `float` instead of `int`. `convert_coordinates_from_window` can be used if sub-tile coordinates were desired. +- Fixed regression in `libtcodpy.bsp_split_recursive` not accepting `0`. ## [20.1.0] - 2026-02-25 diff --git a/tcod/event.py b/tcod/event.py index ab3d36f2..591f5e88 100644 --- a/tcod/event.py +++ b/tcod/event.py @@ -338,7 +338,7 @@ class Event: .. seealso:: :any:`tcod.event.time_ns` - .. versionadded:: Unreleased + .. versionadded:: 21.0 """ @property @@ -348,7 +348,7 @@ def timestamp(self) -> float: .. seealso:: :any:`tcod.event.time` - .. versionadded:: Unreleased + .. versionadded:: 21.0 """ return self.timestamp_ns / 1_000_000_000 @@ -357,7 +357,7 @@ def timestamp(self) -> float: def type(self) -> str: """This events type. - .. deprecated:: Unreleased + .. deprecated:: 21.0 Using this attribute is now actively discouraged. Use :func:`isinstance` or :ref:`match`. :meta private: @@ -371,7 +371,7 @@ def type(self) -> str: def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> Event: """Return a class instance from a python-cffi 'SDL_Event*' pointer. - .. versionchanged:: Unreleased + .. versionchanged:: 21.0 This method was unsuitable for the public API and is now private. """ raise NotImplementedError @@ -415,17 +415,17 @@ class KeyboardEvent(Event): which: int = 0 """The SDL keyboard instance ID. Zero if unknown or virtual. - .. versionadded:: Unreleased + .. versionadded:: 21.0 """ window_id: int = 0 """The SDL window ID with keyboard focus. - .. versionadded:: Unreleased + .. versionadded:: 21.0 """ pressed: bool = False """True if the key was pressed, False if the key was released. - .. versionadded:: Unreleased + .. versionadded:: 21.0 """ @classmethod @@ -473,20 +473,20 @@ class MouseState(Event): which: int = 0 """The mouse device ID for this event. - .. versionadded:: Unreleased + .. versionadded:: 21.0 """ window_id: int = 0 """The window ID with mouse focus. - .. versionadded:: Unreleased + .. versionadded:: 21.0 """ @property def integer_position(self) -> Point[int]: """Integer coordinates of this event. - .. versionadded:: Unreleased + .. versionadded:: 21.0 """ x, y = self.position return Point(floor(x), floor(y)) @@ -508,7 +508,7 @@ def pixel(self, value: Point[float]) -> None: def tile(self) -> Point[int]: """The integer tile coordinates of the mouse on the screen. - .. deprecated:: Unreleased + .. deprecated:: 21.0 Use :any:`integer_position` of the event returned by :any:`Context.convert_event` instead. """ return _verify_tile_coordinates(self._tile) @@ -542,7 +542,7 @@ class MouseMotion(MouseState): def integer_motion(self) -> Point[int]: """Integer motion of this event. - .. versionadded:: Unreleased + .. versionadded:: 21.0 """ x, y = self.position dx, dy = self.motion @@ -567,7 +567,7 @@ def pixel_motion(self, value: Point[float]) -> None: def tile_motion(self) -> Point[int]: """The tile delta. - .. deprecated:: Unreleased + .. deprecated:: 21.0 Use :any:`integer_motion` of the event returned by :any:`Context.convert_event` instead. """ return _verify_tile_coordinates(self._tile_motion) @@ -625,7 +625,7 @@ class MouseButtonEvent(Event): .. versionchanged:: 19.0 `position` and `tile` now use floating point coordinates. - .. versionchanged:: Unreleased + .. versionchanged:: 21.0 No longer a subclass of :any:`MouseState`. """ @@ -636,20 +636,20 @@ class MouseButtonEvent(Event): button: MouseButton """Which mouse button index was pressed or released in this event. - .. versionchanged:: Unreleased + .. versionchanged:: 21.0 Is now strictly a :any:`MouseButton` type. """ which: int = 0 """The mouse device ID for this event. - .. versionadded:: Unreleased + .. versionadded:: 21.0 """ window_id: int = 0 """The window ID with mouse focus. - .. versionadded:: Unreleased + .. versionadded:: 21.0 """ @classmethod @@ -795,7 +795,7 @@ class WindowEvent(Event): case tcod.event.WindowEvent(type=subtype, data=data, window_id=window_id): print(f"Other window event {subtype} on window {window_id} with {data=}") - .. versionchanged:: Unreleased + .. versionchanged:: 21.0 Added `data` and `window_id` attributes and added missing SDL3 window events. """ @@ -1013,7 +1013,7 @@ class JoystickButton(JoystickEvent): def type(self) -> Literal["JOYBUTTONUP", "JOYBUTTONDOWN"]: """Button state as a string. - .. deprecated:: Unreleased + .. deprecated:: 21.0 Use :any:`pressed` instead. """ return ("JOYBUTTONUP", "JOYBUTTONDOWN")[self.pressed] @@ -1122,7 +1122,7 @@ class ControllerButton(ControllerEvent): def type(self) -> Literal["CONTROLLERBUTTONUP", "CONTROLLERBUTTONDOWN"]: """Button state as a string. - .. deprecated:: Unreleased + .. deprecated:: 21.0 Use :any:`pressed` instead. """ return ("CONTROLLERBUTTONUP", "CONTROLLERBUTTONDOWN")[self.pressed] @@ -1160,7 +1160,7 @@ def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> Self: class ClipboardUpdate(Event): """Announces changed contents of the clipboard. - .. versionadded:: Unreleased + .. versionadded:: 21.0 """ mime_types: tuple[str, ...] @@ -1195,7 +1195,7 @@ class Drop(Event): case tcod.event.Drop(type="COMPLETE"): print("Drop handling finished") - .. versionadded:: Unreleased + .. versionadded:: 21.0 """ type: Literal["BEGIN", "FILE", "TEXT", "COMPLETE", "POSITION"] @@ -3097,7 +3097,7 @@ def __getattr__(name: str) -> int: def time_ns() -> int: """Return the nanoseconds elapsed since SDL was initialized. - .. versionadded:: Unreleased + .. versionadded:: 21.0 """ return int(lib.SDL_GetTicksNS()) @@ -3105,7 +3105,7 @@ def time_ns() -> int: def time() -> float: """Return the seconds elapsed since SDL was initialized. - .. versionadded:: Unreleased + .. versionadded:: 21.0 """ return time_ns() / 1_000_000_000 diff --git a/tcod/sdl/video.py b/tcod/sdl/video.py index b15d8d0b..e9565f88 100644 --- a/tcod/sdl/video.py +++ b/tcod/sdl/video.py @@ -177,7 +177,7 @@ class Window: def __init__(self, sdl_window_p: Any | int) -> None: # noqa: ANN401 """Wrap a SDL_Window pointer or SDL WindowID. - .. versionchanged:: Unreleased + .. versionchanged:: 21.0 Now accepts `int` types as an SDL WindowID. """ if isinstance(sdl_window_p, int): From 84d37e224d0cf3ed05c29277b79f6773597bbada Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Sat, 4 Apr 2026 10:58:57 -0700 Subject: [PATCH 72/78] Add missing MouseButtonEvent.integer_position property --- CHANGELOG.md | 8 ++++++++ tcod/context.py | 4 ++-- tcod/event.py | 9 +++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2b81f68..8276ae28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version ## [Unreleased] +### Added + +- `MouseButtonEvent.integer_position` property. + +## Fixed + +- `integer_position` was missing from mouse button events. + ## [21.0.0] - 2026-03-13 ### Added diff --git a/tcod/context.py b/tcod/context.py index f7b92314..afffe96f 100644 --- a/tcod/context.py +++ b/tcod/context.py @@ -255,8 +255,8 @@ def convert_event(self, event: _Event) -> _Event: Now returns a new event with the coordinates converted into tiles. """ event_copy = copy.copy(event) - if isinstance(event, (tcod.event.MouseState, tcod.event.MouseMotion)): - assert isinstance(event_copy, (tcod.event.MouseState, tcod.event.MouseMotion)) + if isinstance(event, (tcod.event.MouseState, tcod.event.MouseMotion, tcod.event.MouseButtonEvent)): + assert isinstance(event_copy, (tcod.event.MouseState, tcod.event.MouseMotion, tcod.event.MouseButtonEvent)) event_copy.position = tcod.event.Point(*self.pixel_to_tile(event.position[0], event.position[1])) event._tile = tcod.event.Point(floor(event_copy.position[0]), floor(event_copy.position[1])) if isinstance(event, tcod.event.MouseMotion): diff --git a/tcod/event.py b/tcod/event.py index 591f5e88..ccdbc2af 100644 --- a/tcod/event.py +++ b/tcod/event.py @@ -652,6 +652,15 @@ class MouseButtonEvent(Event): .. versionadded:: 21.0 """ + @property + def integer_position(self) -> Point[int]: + """Integer coordinates of this event. + + .. versionadded:: Unreleased + """ + x, y = self.position + return Point(floor(x), floor(y)) + @classmethod def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> Self: button = sdl_event.button From 01883503319ec1031f1f3b452b522f273a18bb74 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Sat, 4 Apr 2026 11:06:59 -0700 Subject: [PATCH 73/78] Update primary event handing example code --- tcod/event.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/tcod/event.py b/tcod/event.py index ccdbc2af..97eeb2f7 100644 --- a/tcod/event.py +++ b/tcod/event.py @@ -27,21 +27,19 @@ while True: console = context.new_console() context.present(console, integer_scaling=True) - for event in tcod.event.wait(): - context.convert_event(event) # Adds tile coordinates to mouse events. + for pixel_event in tcod.event.wait(): + event = context.convert_event(pixel_event) # Convert mouse pixel coordinates to tile coordinates + print(event) # Print all events, for learning and debugging if isinstance(event, tcod.event.Quit): - print(event) raise SystemExit() elif isinstance(event, tcod.event.KeyDown): - print(event) # Prints the Scancode and KeySym enums for this event. + print(f"{event.sym=}, {event.scancode=}") # Show Scancode and KeySym enum names if event.sym in KEY_COMMANDS: print(f"Command: {KEY_COMMANDS[event.sym]}") elif isinstance(event, tcod.event.MouseButtonDown): - print(event) # Prints the mouse button constant names for this event. + print(f"{event.button=}, {event.integer_position=}") # Show mouse button and tile elif isinstance(event, tcod.event.MouseMotion): - print(event) # Prints the mouse button mask bits in a readable format. - else: - print(event) # Print any unhandled events. + print(f"{event.integer_position=}, {event.integer_motion=}") # Current mouse tile and tile motion Python 3.10 introduced `match statements `_ which can be used to dispatch events more gracefully: @@ -61,8 +59,8 @@ while True: console = context.new_console() context.present(console, integer_scaling=True) - for event in tcod.event.wait(): - context.convert_event(event) # Adds tile coordinates to mouse events. + for pixel_event in tcod.event.wait(): + event = context.convert_event(pixel_event) # Converts mouse pixel coordinates to tile coordinates. match event: case tcod.event.Quit(): raise SystemExit() @@ -70,12 +68,14 @@ print(f"Command: {KEY_COMMANDS[sym]}") case tcod.event.KeyDown(sym=sym, scancode=scancode, mod=mod, repeat=repeat): print(f"KeyDown: {sym=}, {scancode=}, {mod=}, {repeat=}") - case tcod.event.MouseButtonDown(button=button, pixel=pixel, tile=tile): - print(f"MouseButtonDown: {button=}, {pixel=}, {tile=}") - case tcod.event.MouseMotion(pixel=pixel, pixel_motion=pixel_motion, tile=tile, tile_motion=tile_motion): - print(f"MouseMotion: {pixel=}, {pixel_motion=}, {tile=}, {tile_motion=}") + case tcod.event.MouseButtonDown(button=button, integer_position=tile): + print(f"MouseButtonDown: {button=}, {tile=}") + case tcod.event.MouseMotion(integer_position=tile, integer_motion=tile_motion): + assert isinstance(pixel_event, tcod.event.MouseMotion) + pixel_motion = pixel_event.motion + print(f"MouseMotion: {pixel_motion=}, {tile=}, {tile_motion=}") case tcod.event.Event() as event: - print(event) # Show any unhandled events. + print(event) # Print unhandled events .. versionadded:: 8.4 """ From 34e7c3a1f649a9f9a1953e56617c7fb8aa1ea0bc Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Sat, 4 Apr 2026 11:17:26 -0700 Subject: [PATCH 74/78] Prepare 21.1.0 release. --- CHANGELOG.md | 2 ++ tcod/event.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8276ae28..52ea993b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version ## [Unreleased] +## [21.1.0] - 2026-04-04 + ### Added - `MouseButtonEvent.integer_position` property. diff --git a/tcod/event.py b/tcod/event.py index 97eeb2f7..0ae16781 100644 --- a/tcod/event.py +++ b/tcod/event.py @@ -656,7 +656,7 @@ class MouseButtonEvent(Event): def integer_position(self) -> Point[int]: """Integer coordinates of this event. - .. versionadded:: Unreleased + .. versionadded:: 21.1 """ x, y = self.position return Point(floor(x), floor(y)) From fde6ae4566d2c3d37b6d07c0bbee9884562461ec Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Sat, 4 Apr 2026 12:47:25 -0700 Subject: [PATCH 75/78] Modernize MouseWheel and make conversion instance checks generic Add protocols for mouse-like events so that mouse conversions automatically handle new code --- CHANGELOG.md | 8 ++++++ tcod/context.py | 7 +++-- tcod/event.py | 76 +++++++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 82 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52ea993b..8137520f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version ## [Unreleased] +### Added + +- `tcod.event.MouseWheel` now has `which`, `window_id`, `position` and `integer_position` attributes. + +## Fixed + +- `tcod.event.convert_coordinates_from_window` was not converting all types of mouse events. + ## [21.1.0] - 2026-04-04 ### Added diff --git a/tcod/context.py b/tcod/context.py index afffe96f..01ed61f7 100644 --- a/tcod/context.py +++ b/tcod/context.py @@ -255,10 +255,11 @@ def convert_event(self, event: _Event) -> _Event: Now returns a new event with the coordinates converted into tiles. """ event_copy = copy.copy(event) - if isinstance(event, (tcod.event.MouseState, tcod.event.MouseMotion, tcod.event.MouseButtonEvent)): - assert isinstance(event_copy, (tcod.event.MouseState, tcod.event.MouseMotion, tcod.event.MouseButtonEvent)) + if isinstance(event, tcod.event._MouseEventWithPosition): + assert isinstance(event_copy, tcod.event._MouseEventWithPosition) event_copy.position = tcod.event.Point(*self.pixel_to_tile(event.position[0], event.position[1])) - event._tile = tcod.event.Point(floor(event_copy.position[0]), floor(event_copy.position[1])) + if isinstance(event, tcod.event._MouseEventWithTile): + event._tile = tcod.event.Point(floor(event_copy.position[0]), floor(event_copy.position[1])) if isinstance(event, tcod.event.MouseMotion): assert isinstance(event_copy, tcod.event.MouseMotion) assert event._tile is not None diff --git a/tcod/event.py b/tcod/event.py index 0ae16781..1fff269d 100644 --- a/tcod/event.py +++ b/tcod/event.py @@ -89,7 +89,20 @@ from collections.abc import Callable, Iterator, Mapping from math import floor from pathlib import Path -from typing import TYPE_CHECKING, Any, Final, Generic, Literal, NamedTuple, TypeAlias, TypedDict, TypeVar, overload +from typing import ( + TYPE_CHECKING, + Any, + Final, + Generic, + Literal, + NamedTuple, + Protocol, + TypeAlias, + TypedDict, + TypeVar, + overload, + runtime_checkable, +) import attrs import numpy as np @@ -630,7 +643,7 @@ class MouseButtonEvent(Event): """ position: Point[float] = attrs.field(default=Point(0.0, 0.0)) - """The pixel coordinates of the mouse.""" + """The coordinates of the mouse.""" _tile: Point[int] | None = attrs.field(default=Point(0, 0), alias="tile") """The tile integer coordinates of the mouse on the screen. Deprecated.""" button: MouseButton @@ -709,13 +722,63 @@ class MouseWheel(Event): """Vertical scrolling. A positive value means scrolling away from the user.""" flipped: bool """If True then the values of `x` and `y` are the opposite of their usual values. - This depends on the settings of the Operating System. + This depends on the operating system settings. + """ + + position: Point[float] = attrs.field(default=Point(0.0, 0.0)) + """Coordinates of the mouse for this event. + + .. versionadded:: Unreleased + """ + + which: int = 0 + """Mouse device ID for this event. + + .. versionadded:: Unreleased + """ + + window_id: int = 0 + """Window ID with mouse focus. + + .. versionadded:: Unreleased """ + @property + def integer_position(self) -> Point[int]: + """Integer coordinates of this event. + + .. versionadded:: Unreleased + """ + x, y = self.position + return Point(floor(x), floor(y)) + @classmethod def _from_sdl_event(cls, sdl_event: _C_SDL_Event) -> Self: wheel = sdl_event.wheel - return cls(x=int(wheel.x), y=int(wheel.y), flipped=bool(wheel.direction), **_unpack_sdl_event(sdl_event)) + return cls( + x=int(wheel.integer_x), + y=int(wheel.integer_y), + flipped=bool(wheel.direction), + position=Point(float(wheel.mouse_x), float(wheel.mouse_y)), + which=int(wheel.which), + window_id=int(wheel.windowID), + **_unpack_sdl_event(sdl_event), + ) + + +@runtime_checkable +class _MouseEventWithPosition(Protocol): + """Mouse event with position. Used internally to handle conversions.""" + + position: Point[float] + + +@runtime_checkable +class _MouseEventWithTile(Protocol): + """Mouse event with position and deprecated tile attribute. Used internally to handle conversions.""" + + position: Point[float] + _tile: Point[int] | None @attrs.define(slots=True, kw_only=True) @@ -1742,9 +1805,10 @@ def convert_coordinates_from_window( event._tile_motion = Point( floor(position[0]) - floor(previous_position[0]), floor(position[1]) - floor(previous_position[1]) ) - if isinstance(event, (MouseState, MouseMotion)): + elif isinstance(event, _MouseEventWithPosition): event.position = Point(*convert_coordinates_from_window(event.position, context, console, dest_rect)) - event._tile = Point(floor(event.position[0]), floor(event.position[1])) + if isinstance(event, _MouseEventWithTile): + event._tile = Point(floor(event.position[0]), floor(event.position[1])) return event From c3fde2460b910e70bbb34afd32a9d73fb7e211ec Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Sat, 4 Apr 2026 13:11:57 -0700 Subject: [PATCH 76/78] Prepare 21.2.0 release. --- CHANGELOG.md | 2 ++ tcod/event.py | 8 ++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8137520f..bc5f79f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version ## [Unreleased] +## [21.2.0] - 2026-04-04 + ### Added - `tcod.event.MouseWheel` now has `which`, `window_id`, `position` and `integer_position` attributes. diff --git a/tcod/event.py b/tcod/event.py index 1fff269d..6b9453cf 100644 --- a/tcod/event.py +++ b/tcod/event.py @@ -728,26 +728,26 @@ class MouseWheel(Event): position: Point[float] = attrs.field(default=Point(0.0, 0.0)) """Coordinates of the mouse for this event. - .. versionadded:: Unreleased + .. versionadded:: 21.2 """ which: int = 0 """Mouse device ID for this event. - .. versionadded:: Unreleased + .. versionadded:: 21.2 """ window_id: int = 0 """Window ID with mouse focus. - .. versionadded:: Unreleased + .. versionadded:: 21.2 """ @property def integer_position(self) -> Point[int]: """Integer coordinates of this event. - .. versionadded:: Unreleased + .. versionadded:: 21.2 """ x, y = self.position return Point(floor(x), floor(y)) From aea3d3903eb22a27bfdbe1b15ae6cd0ca96d56a3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Apr 2026 08:56:50 +0000 Subject: [PATCH 77/78] Bump codecov/codecov-action from 5 to 6 in the github-actions group Bumps the github-actions group with 1 update: [codecov/codecov-action](https://github.com/codecov/codecov-action). Updates `codecov/codecov-action` from 5 to 6 - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v5...v6) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] --- .github/workflows/python-package.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 0ae29156..7896754a 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -170,7 +170,7 @@ jobs: - name: Xvfb logs if: runner.os != 'Windows' run: cat /tmp/xvfb.log - - uses: codecov/codecov-action@v5 + - uses: codecov/codecov-action@v6 - uses: actions/upload-artifact@v7 if: runner.os == 'Windows' with: From aaafc20a4773709c721fa1b3f02970863477ff1d Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Mon, 6 Apr 2026 14:11:01 -0700 Subject: [PATCH 78/78] Update pre-commit Replace superfluous release action --- .github/workflows/release-on-tag.yml | 6 ++---- .pre-commit-config.yaml | 4 ++-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release-on-tag.yml b/.github/workflows/release-on-tag.yml index 0b9f07b0..d2e52c2f 100644 --- a/.github/workflows/release-on-tag.yml +++ b/.github/workflows/release-on-tag.yml @@ -24,7 +24,5 @@ jobs: run: scripts/get_release_description.py | tee release_body.md - name: Create Release id: create_release - uses: ncipollo/release-action@v1 - with: - name: "" - bodyFile: release_body.md + # https://cli.github.com/manual/gh_release_create + run: gh release create "${GITHUB_REF_NAME}" --verify-tag --notes-file release_body.md diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index aff2ef24..6baa9d9b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,12 +17,12 @@ repos: - id: fix-byte-order-marker - id: detect-private-key - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.14.13 + rev: v0.15.9 hooks: - id: ruff-check args: [--fix-only, --exit-non-zero-on-fix] - id: ruff-format - repo: https://github.com/zizmorcore/zizmor-pre-commit - rev: v1.22.0 + rev: v1.23.1 hooks: - id: zizmor