From 50721e8e12a9b2395a5df28aa935b40cfe35335f Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Fri, 13 Jun 2025 12:30:05 -0700 Subject: [PATCH 001/131] Prepare 19.0.0 release. --- CHANGELOG.md | 5 +++++ tcod/sdl/audio.py | 38 +++++++++++++++++++------------------- tcod/sdl/mouse.py | 4 ++-- tcod/sdl/render.py | 12 ++++++------ tcod/sdl/video.py | 6 +++--- 5 files changed, 35 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cbd924b..2e1eb24b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version ## [Unreleased] +## [19.0.0] - 2025-06-13 + +Finished port to SDL3, this has caused several breaking changes from SDL such as lowercase key constants now being uppercase and mouse events returning `float` instead of `int`. +Be sure to run [Mypy](https://mypy.readthedocs.io/en/stable/getting_started.html) on your projects to catch any issues from this update. + ### Changed - Updated libtcod to 2.1.1 diff --git a/tcod/sdl/audio.py b/tcod/sdl/audio.py index d15abb32..390bc551 100644 --- a/tcod/sdl/audio.py +++ b/tcod/sdl/audio.py @@ -217,7 +217,7 @@ class AudioDevice: .. versionchanged:: 16.0 Can now be used as a context which will close the device on exit. - .. versionchanged:: Unreleased + .. versionchanged:: 19.0 Removed `spec` and `callback` attribute. `queued_samples`, `queue_audio`, and `dequeue_audio` moved to :any:`AudioStream` class. @@ -269,7 +269,7 @@ def __init__( self.is_physical: Final[bool] = bool(lib.SDL_IsAudioDevicePhysical(device_id)) """True of this is a physical device, or False if this is a logical device. - .. versionadded:: Unreleased + .. versionadded:: 19.0 """ def __repr__(self) -> str: @@ -294,7 +294,7 @@ def __repr__(self) -> str: def name(self) -> str: """Name of the device. - .. versionadded:: Unreleased + .. versionadded:: 19.0 """ return str(ffi.string(_check_p(lib.SDL_GetAudioDeviceName(self.device_id))), encoding="utf-8") @@ -304,7 +304,7 @@ def gain(self) -> float: Default is 1.0 but can be set higher or zero. - .. versionadded:: Unreleased + .. versionadded:: 19.0 """ return _check_float(lib.SDL_GetAudioDeviceGain(self.device_id), failure=-1.0) @@ -320,7 +320,7 @@ def open( ) -> Self: """Open a new logical audio device for this device. - .. versionadded:: Unreleased + .. versionadded:: 19.0 .. seealso:: https://wiki.libsdl.org/SDL3/SDL_OpenAudioDevice @@ -349,7 +349,7 @@ def _sample_size(self) -> int: def stopped(self) -> bool: """Is True if the device has failed or was closed. - .. deprecated:: Unreleased + .. deprecated:: 19.0 No longer used by the SDL3 API. """ return bool(not hasattr(self, "device_id")) @@ -417,7 +417,7 @@ def close(self) -> None: def __enter__(self) -> Self: """Return self and enter a managed context. - .. deprecated:: Unreleased + .. deprecated:: 19.0 Use :func:`contextlib.closing` if you want to close this device after a context. """ return self @@ -443,7 +443,7 @@ def new_stream( ) -> AudioStream: """Create, bind, and return a new :any:`AudioStream` for this device. - .. versionadded:: Unreleased + .. versionadded:: 19.0 """ new_stream = AudioStream.new(format=format, channels=channels, frequency=frequency) self.bind((new_stream,)) @@ -463,7 +463,7 @@ def bind(self, streams: Iterable[AudioStream], /) -> None: class AudioStreamCallbackData: """Data provided to AudioStream callbacks. - .. versionadded:: Unreleased + .. versionadded:: 19.0 """ additional_bytes: int @@ -487,7 +487,7 @@ class AudioStream: This class is commonly created with :any:`AudioDevice.new_stream` which creates a new stream bound to the device. - ..versionadded:: Unreleased + ..versionadded:: 19.0 """ __slots__ = ("__weakref__", "_stream_p") @@ -819,10 +819,10 @@ class BasicMixer: .. versionadded:: 13.6 - .. versionchanged:: Unreleased + .. versionchanged:: 19.0 Added `frequency` and `channels` parameters. - .. deprecated:: Unreleased + .. deprecated:: 19.0 Changes in the SDL3 API have made this classes usefulness questionable. This class should be replaced with custom streams. """ @@ -927,7 +927,7 @@ def _sdl_audio_stream_callback(userdata: Any, stream_p: Any, additional_amount: def get_devices() -> dict[str, AudioDevice]: """Iterate over the available audio output devices. - .. versionchanged:: Unreleased + .. versionchanged:: 19.0 Now returns a dictionary of :any:`AudioDevice`. """ tcod.sdl.sys.init(tcod.sdl.sys.Subsystem.AUDIO) @@ -942,7 +942,7 @@ def get_devices() -> dict[str, AudioDevice]: def get_capture_devices() -> dict[str, AudioDevice]: """Iterate over the available audio capture devices. - .. versionchanged:: Unreleased + .. versionchanged:: 19.0 Now returns a dictionary of :any:`AudioDevice`. """ tcod.sdl.sys.init(tcod.sdl.sys.Subsystem.AUDIO) @@ -960,7 +960,7 @@ def get_default_playback() -> AudioDevice: Example: playback_device = tcod.sdl.audio.get_default_playback().open() - .. versionadded:: Unreleased + .. versionadded:: 19.0 """ tcod.sdl.sys.init(tcod.sdl.sys.Subsystem.AUDIO) return AudioDevice(ffi.cast("SDL_AudioDeviceID", lib.SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK)) @@ -972,7 +972,7 @@ def get_default_recording() -> AudioDevice: Example: recording_device = tcod.sdl.audio.get_default_recording().open() - .. versionadded:: Unreleased + .. versionadded:: 19.0 """ tcod.sdl.sys.init(tcod.sdl.sys.Subsystem.AUDIO) return AudioDevice(ffi.cast("SDL_AudioDeviceID", lib.SDL_AUDIO_DEVICE_DEFAULT_RECORDING)) @@ -982,7 +982,7 @@ def get_default_recording() -> AudioDevice: class AllowedChanges(enum.IntFlag): """Which parameters are allowed to be changed when the values given are not supported. - .. deprecated:: Unreleased + .. deprecated:: 19.0 This is no longer used. """ @@ -1033,12 +1033,12 @@ def open( # noqa: A001, PLR0913 If a callback is given then it will be called with the `AudioDevice` and a Numpy buffer of the data stream. This callback will be run on a separate thread. - .. versionchanged:: Unreleased + .. versionchanged:: 19.0 SDL3 returns audio devices differently, exact formatting is set with :any:`AudioDevice.new_stream` instead. `samples` and `allowed_changes` are ignored. - .. deprecated:: Unreleased + .. deprecated:: 19.0 This is an outdated method. Use :any:`AudioDevice.open` instead, for example: ``tcod.sdl.audio.get_default_playback().open()`` diff --git a/tcod/sdl/mouse.py b/tcod/sdl/mouse.py index f4c16ed5..9d9227f7 100644 --- a/tcod/sdl/mouse.py +++ b/tcod/sdl/mouse.py @@ -183,7 +183,7 @@ def set_relative_mode(enable: bool) -> None: :any:`tcod.sdl.mouse.capture` https://wiki.libsdl.org/SDL_SetWindowRelativeMouseMode - .. deprecated:: Unreleased + .. deprecated:: 19.0 Replaced with :any:`tcod.sdl.video.Window.relative_mouse_mode` """ _check(lib.SDL_SetWindowRelativeMouseMode(lib.SDL_GetMouseFocus(), enable)) @@ -193,7 +193,7 @@ def set_relative_mode(enable: bool) -> None: def get_relative_mode() -> bool: """Return True if relative mouse mode is enabled. - .. deprecated:: Unreleased + .. deprecated:: 19.0 Replaced with :any:`tcod.sdl.video.Window.relative_mouse_mode` """ return bool(lib.SDL_GetWindowRelativeMouseMode(lib.SDL_GetMouseFocus())) diff --git a/tcod/sdl/render.py b/tcod/sdl/render.py index 0a1052e1..511b48fc 100644 --- a/tcod/sdl/render.py +++ b/tcod/sdl/render.py @@ -48,7 +48,7 @@ class LogicalPresentation(enum.IntEnum): See https://wiki.libsdl.org/SDL3/SDL_RendererLogicalPresentation - .. versionadded:: Unreleased + .. versionadded:: 19.0 """ DISABLED = 0 @@ -439,7 +439,7 @@ def set_logical_presentation(self, resolution: tuple[int, int], mode: LogicalPre .. seealso:: https://wiki.libsdl.org/SDL3/SDL_SetRenderLogicalPresentation - .. versionadded:: Unreleased + .. versionadded:: 19.0 """ width, height = resolution _check(lib.SDL_SetRenderLogicalPresentation(self.p, width, height, mode)) @@ -455,7 +455,7 @@ def logical_size(self) -> tuple[int, int]: .. versionadded:: 13.5 - .. versionchanged:: Unreleased + .. versionchanged:: 19.0 Setter is deprecated, use :any:`set_logical_presentation` instead. """ out = ffi.new("int[2]") @@ -537,7 +537,7 @@ def read_pixels( .. versionadded:: 15.0 - .. versionchanged:: Unreleased + .. versionchanged:: 19.0 `format` no longer accepts `int` values. """ surface = _check_p( @@ -681,7 +681,7 @@ def geometry( .. versionadded:: 13.5 - .. versionchanged:: Unreleased + .. versionchanged:: 19.0 `color` now takes float values instead of 8-bit integers. """ xy = np.ascontiguousarray(xy, np.float32) @@ -741,7 +741,7 @@ def new_renderer( .. seealso:: :func:`tcod.sdl.video.new_window` - .. versionchanged:: Unreleased + .. versionchanged:: 19.0 Removed `software` and `target_textures` parameters. `vsync` now takes an integer. `driver` now take a string. diff --git a/tcod/sdl/video.py b/tcod/sdl/video.py index cee02bf5..4cc0c371 100644 --- a/tcod/sdl/video.py +++ b/tcod/sdl/video.py @@ -276,7 +276,7 @@ def opacity(self, value: float) -> None: def grab(self) -> bool: """Get or set this windows input grab mode. - .. deprecated:: Unreleased + .. deprecated:: 19.0 This attribute as been split into :any:`mouse_grab` and :any:`keyboard_grab`. """ return self.mouse_grab @@ -289,7 +289,7 @@ def grab(self, value: bool) -> None: def mouse_grab(self) -> bool: """Get or set this windows mouse input grab mode. - .. versionadded:: Unreleased + .. versionadded:: 19.0 """ return bool(lib.SDL_GetWindowMouseGrab(self.p)) @@ -303,7 +303,7 @@ def keyboard_grab(self) -> bool: https://wiki.libsdl.org/SDL3/SDL_SetWindowKeyboardGrab - .. versionadded:: Unreleased + .. versionadded:: 19.0 """ return bool(lib.SDL_GetWindowKeyboardGrab(self.p)) From 7054780028d598fb63f0a666df9ab38b9437b3f0 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Fri, 13 Jun 2025 12:51:40 -0700 Subject: [PATCH 002/131] Build SDL3 for ReadTheDocs workflow Note PKG_CONFIG_PATH on pkg-config errors to debug issues with the environment sent to Python setup scripts. --- .readthedocs.yaml | 12 +++++++++++- setup.py | 5 +++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 8d733f5e..098d76db 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -9,7 +9,17 @@ build: tools: python: "3.11" apt_packages: - - libsdl3-dev + - build-essential + - make + - pkg-config + - cmake + - ninja-build + jobs: + pre_install: + - git clone --depth 1 --branch release-3.2.16 https://github.com/libsdl-org/SDL.git sdl_repo + - cmake -S sdl_repo -B sdl_build -D CMAKE_INSTALL_PREFIX=~/.local + - cmake --build sdl_build --config Debug + - cmake --install sdl_build submodules: include: all diff --git a/setup.py b/setup.py index 6a835e5b..534cca8b 100755 --- a/setup.py +++ b/setup.py @@ -3,6 +3,7 @@ from __future__ import annotations +import os import platform import subprocess import sys @@ -55,6 +56,10 @@ def check_sdl_version() -> None: "\nsdl3-config must be on PATH." ) raise RuntimeError(msg) from exc + except subprocess.CalledProcessError as exc: + if sys.version_info >= (3, 11): + exc.add_note(f"Note: {os.environ.get('PKG_CONFIG_PATH')=}") + raise print(f"Found SDL {sdl_version_str}.") sdl_version = tuple(int(s) for s in sdl_version_str.split(".")) if sdl_version < SDL_VERSION_NEEDED: From 03fbcaabe901fcc1376631eb743de432976a16a5 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Fri, 13 Jun 2025 16:06:34 -0700 Subject: [PATCH 003/131] Remove leftover item from changelog This meant to mention changes to logical size, but that is already in the changelog --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e1eb24b..3327d110 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,6 @@ Be sure to run [Mypy](https://mypy.readthedocs.io/en/stable/getting_started.html - `tcod.event.KeySym` single letter symbols are now all uppercase. - Relative mouse mode is set via `tcod.sdl.video.Window.relative_mouse_mode` instead of `tcod.sdl.mouse.set_relative_mode`. - `tcod.sdl.render.new_renderer`: Removed `software` and `target_textures` parameters, `vsync` takes `int`, `driver` takes `str` instead of `int`. -- SDL renderer logical - `tcod.sdl.render.Renderer`: `integer_scaling` and `logical_size` are now set with `set_logical_presentation` method. - `tcod.sdl.render.Renderer.geometry` now takes float values for `color` instead of 8-bit integers. - `tcod.event.Point` and other mouse/tile coordinate types now use `float` instead of `int`. From f2e03d0d61637742ac48463422bf7723b867bacc Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Thu, 19 Jun 2025 13:01:18 -0700 Subject: [PATCH 004/131] Update pre-commit Apply new Ruff fixes of unused ignores --- .pre-commit-config.yaml | 2 +- build_sdl.py | 2 +- examples/samples_tcod.py | 1 - setup.py | 2 +- tests/conftest.py | 2 -- tests/test_console.py | 2 -- tests/test_deprecated.py | 2 -- tests/test_libtcodpy.py | 2 -- tests/test_noise.py | 2 -- tests/test_parser.py | 2 -- tests/test_random.py | 2 -- tests/test_sdl.py | 2 -- tests/test_sdl_audio.py | 2 -- tests/test_tcod.py | 2 -- tests/test_tileset.py | 2 -- 15 files changed, 3 insertions(+), 26 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 43080c04..a294765c 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.11.13 + rev: v0.12.0 hooks: - id: ruff-check args: [--fix-only, --exit-non-zero-on-fix] diff --git a/build_sdl.py b/build_sdl.py index 1f7d05ec..b073ab77 100755 --- a/build_sdl.py +++ b/build_sdl.py @@ -20,7 +20,7 @@ import requests # This script calls a lot of programs. -# ruff: noqa: S603, S607, T201 +# ruff: noqa: S603, S607 # Ignore f-strings in logging, these will eventually be replaced with t-strings. # ruff: noqa: G004 diff --git a/examples/samples_tcod.py b/examples/samples_tcod.py index 7c08906a..0955f2a0 100755 --- a/examples/samples_tcod.py +++ b/examples/samples_tcod.py @@ -37,7 +37,6 @@ if TYPE_CHECKING: from numpy.typing import NDArray -# ruff: noqa: S311 if not sys.warnoptions: warnings.simplefilter("default") # Show all warnings. diff --git a/setup.py b/setup.py index 534cca8b..eff99ff1 100755 --- a/setup.py +++ b/setup.py @@ -49,7 +49,7 @@ def check_sdl_version() -> None: ).strip() except FileNotFoundError: try: - sdl_version_str = subprocess.check_output(["sdl3-config", "--version"], universal_newlines=True).strip() # noqa: S603, S607 + sdl_version_str = subprocess.check_output(["sdl3-config", "--version"], universal_newlines=True).strip() # noqa: S607 except FileNotFoundError as exc: msg = ( f"libsdl3-dev or equivalent must be installed on your system and must be at least version {needed_version}." diff --git a/tests/conftest.py b/tests/conftest.py index 182cb6d6..79891a38 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,8 +11,6 @@ import tcod from tcod import libtcodpy -# ruff: noqa: D103 - def pytest_addoption(parser: pytest.Parser) -> None: parser.addoption("--no-window", action="store_true", help="Skip tests which need a rendering context.") diff --git a/tests/test_console.py b/tests/test_console.py index 4b8f8435..18668ba6 100644 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -9,8 +9,6 @@ import tcod import tcod.console -# ruff: noqa: D103 - def test_array_read_write() -> None: console = tcod.console.Console(width=12, height=10) diff --git a/tests/test_deprecated.py b/tests/test_deprecated.py index 82001d47..4a960bba 100644 --- a/tests/test_deprecated.py +++ b/tests/test_deprecated.py @@ -14,8 +14,6 @@ with pytest.warns(): import libtcodpy -# ruff: noqa: D103 - def test_deprecate_color() -> None: with pytest.warns(FutureWarning, match=r"\(0, 0, 0\)"): diff --git a/tests/test_libtcodpy.py b/tests/test_libtcodpy.py index 7ace644a..61895c60 100644 --- a/tests/test_libtcodpy.py +++ b/tests/test_libtcodpy.py @@ -11,8 +11,6 @@ import tcod from tcod import libtcodpy -# ruff: noqa: D103 - pytestmark = [ pytest.mark.filterwarnings("ignore::DeprecationWarning"), pytest.mark.filterwarnings("ignore::PendingDeprecationWarning"), diff --git a/tests/test_noise.py b/tests/test_noise.py index 80023f5f..28825328 100644 --- a/tests/test_noise.py +++ b/tests/test_noise.py @@ -9,8 +9,6 @@ import tcod.noise import tcod.random -# ruff: noqa: D103 - @pytest.mark.parametrize("implementation", tcod.noise.Implementation) @pytest.mark.parametrize("algorithm", tcod.noise.Algorithm) diff --git a/tests/test_parser.py b/tests/test_parser.py index 5494b786..73c54b63 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -7,8 +7,6 @@ import tcod as libtcod -# ruff: noqa: D103 - @pytest.mark.filterwarnings("ignore") def test_parser() -> None: diff --git a/tests/test_random.py b/tests/test_random.py index d20045cc..764ae988 100644 --- a/tests/test_random.py +++ b/tests/test_random.py @@ -8,8 +8,6 @@ import tcod.random -# ruff: noqa: D103 - SCRIPT_DIR = Path(__file__).parent diff --git a/tests/test_sdl.py b/tests/test_sdl.py index f33f4738..9d23d0c8 100644 --- a/tests/test_sdl.py +++ b/tests/test_sdl.py @@ -9,8 +9,6 @@ import tcod.sdl.sys import tcod.sdl.video -# ruff: noqa: D103 - def test_sdl_window(uses_window: None) -> None: assert tcod.sdl.video.get_grabbed_window() is None diff --git a/tests/test_sdl_audio.py b/tests/test_sdl_audio.py index f8da6155..38250758 100644 --- a/tests/test_sdl_audio.py +++ b/tests/test_sdl_audio.py @@ -12,8 +12,6 @@ import tcod.sdl.audio -# ruff: noqa: D103 - def device_works(device: Callable[[], tcod.sdl.audio.AudioDevice]) -> bool: try: diff --git a/tests/test_tcod.py b/tests/test_tcod.py index 25f60d0e..02eb6dbb 100644 --- a/tests/test_tcod.py +++ b/tests/test_tcod.py @@ -12,8 +12,6 @@ import tcod.console from tcod import libtcodpy -# ruff: noqa: D103 - def raise_Exception(*_args: object) -> NoReturn: raise RuntimeError("testing exception") # noqa: TRY003, EM101 diff --git a/tests/test_tileset.py b/tests/test_tileset.py index dd272fe7..c7281cef 100644 --- a/tests/test_tileset.py +++ b/tests/test_tileset.py @@ -2,8 +2,6 @@ import tcod.tileset -# ruff: noqa: D103 - def test_proc_block_elements() -> None: tileset = tcod.tileset.Tileset(8, 8) From 740357d8afed162e22d5699486435d3211828e8a Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Thu, 19 Jun 2025 12:43:57 -0700 Subject: [PATCH 005/131] Upgrade cibuildwheel to 3.0.0 Switch to GitHub actions and remove outdated actions Enable PyPy wheels explicitly, required by latest cibuildwheel Configure compile warnings to show but not fail on zlib implicit functions --- .github/workflows/python-package.yml | 19 +++---------------- build_libtcod.py | 1 + pyproject.toml | 3 +++ 3 files changed, 7 insertions(+), 16 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 28ef5efc..4dcbd93a 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -233,23 +233,10 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: ${{ env.git-depth }} - - name: Set up QEMU - if: ${{ matrix.arch == 'aarch64' }} - uses: docker/setup-qemu-action@v3 - name: Checkout submodules - run: | - git submodule update --init --recursive --depth 1 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.x" - - name: Install Python dependencies - run: | - python -m pip install --upgrade pip - pip install cibuildwheel==2.23.3 + run: git submodule update --init --recursive --depth 1 - name: Build wheels - run: | - python -m cibuildwheel --output-dir wheelhouse + uses: pypa/cibuildwheel@v3.0.0 env: CIBW_BUILD: ${{ matrix.build }} CIBW_ARCHS_LINUX: ${{ matrix.arch }} @@ -312,7 +299,7 @@ jobs: # Downloads SDL for the later step. run: python build_sdl.py - name: Build wheels - uses: pypa/cibuildwheel@v2.23.3 + uses: pypa/cibuildwheel@v3.0.0 env: CIBW_BUILD: ${{ matrix.python }} CIBW_ARCHS_MACOS: x86_64 arm64 universal2 diff --git a/build_libtcod.py b/build_libtcod.py index f9a5e3dd..df4527e0 100755 --- a/build_libtcod.py +++ b/build_libtcod.py @@ -199,6 +199,7 @@ def walk_sources(directory: str) -> Iterator[str]: "-fPIC", "-Wno-deprecated-declarations", "-Wno-discarded-qualifiers", # Ignore discarded restrict qualifiers. + "-Wno-error=implicit-function-declaration", # From zlib sources ], } diff --git a/pyproject.toml b/pyproject.toml index 27a3dc16..6f741af8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -99,6 +99,9 @@ filterwarnings = [ "ignore:'import tcod as libtcodpy' is preferred.", ] +[tool.cibuildwheel] # https://cibuildwheel.pypa.io/en/stable/options/ +enable = ["pypy"] + [tool.mypy] files = ["."] python_version = "3.10" From 9392a486aa0e5a3bfbd126c5177f86db4ff83c44 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Fri, 20 Jun 2025 03:40:16 -0700 Subject: [PATCH 006/131] Fix zlib implicit declarations --- build_libtcod.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/build_libtcod.py b/build_libtcod.py index df4527e0..6de9c2ff 100755 --- a/build_libtcod.py +++ b/build_libtcod.py @@ -180,8 +180,8 @@ def walk_sources(directory: str) -> Iterator[str]: include_dirs.append("libtcod/src/zlib/") -if sys.platform == "darwin": - # Fix "implicit declaration of function 'close'" in zlib. +if sys.platform != "win32": + # Fix implicit declaration of multiple functions in zlib. define_macros.append(("HAVE_UNISTD_H", 1)) @@ -199,7 +199,6 @@ def walk_sources(directory: str) -> Iterator[str]: "-fPIC", "-Wno-deprecated-declarations", "-Wno-discarded-qualifiers", # Ignore discarded restrict qualifiers. - "-Wno-error=implicit-function-declaration", # From zlib sources ], } From fc0f5b34c73281779e13fd449da9f9d69a78fa50 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Thu, 19 Jun 2025 12:25:52 -0700 Subject: [PATCH 007/131] Build Pyodide wheel Disable link flags to let Emscripten take over for SDL3 Disable Py_LIMITED_API definition to workaround issue with cffi Related to #123 --- .github/workflows/python-package.yml | 27 ++++++++++++++++++++++++++- .vscode/settings.json | 1 + build_libtcod.py | 7 ++++++- build_sdl.py | 12 +++++++----- 4 files changed, 40 insertions(+), 7 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 4dcbd93a..b6f15916 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -321,8 +321,33 @@ jobs: retention-days: 7 compression-level: 0 + pyodide: + needs: [ruff, mypy, sdist] + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: ${{ env.git-depth }} + - name: Checkout submodules + run: git submodule update --init --recursive --depth 1 + - uses: libsdl-org/setup-sdl@6574e20ac65ce362cd12f9c26b3a5e4d3cd31dee + with: + install-linux-dependencies: true + build-type: "Debug" + version: ${{ env.sdl-version }} + - uses: pypa/cibuildwheel@v3.0.0 + env: + CIBW_PLATFORM: pyodide + - name: Archive wheel + uses: actions/upload-artifact@v4 + with: + name: wheels-pyodide + path: wheelhouse/*.whl + retention-days: 30 + compression-level: 0 + publish: - needs: [sdist, build, build-macos, linux-wheels] + needs: [sdist, build, build-macos, linux-wheels, pyodide] runs-on: ubuntu-latest if: github.ref_type == 'tag' environment: diff --git a/.vscode/settings.json b/.vscode/settings.json index 446a6363..2ef44da6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -350,6 +350,7 @@ "pycall", "pycparser", "pyinstaller", + "pyodide", "pypa", "PYPI", "pypiwin", diff --git a/build_libtcod.py b/build_libtcod.py index 6de9c2ff..c7b0d577 100755 --- a/build_libtcod.py +++ b/build_libtcod.py @@ -162,7 +162,12 @@ def walk_sources(directory: str) -> Iterator[str]: libraries: list[str] = [*build_sdl.libraries] library_dirs: list[str] = [*build_sdl.library_dirs] -define_macros: list[tuple[str, Any]] = [("Py_LIMITED_API", Py_LIMITED_API)] +define_macros: list[tuple[str, Any]] = [] + +if "PYODIDE" not in os.environ: + # Unable to apply Py_LIMITED_API to Pyodide in cffi<=1.17.1 + # https://github.com/python-cffi/cffi/issues/179 + define_macros.append(("Py_LIMITED_API", Py_LIMITED_API)) sources += walk_sources("tcod/") sources += walk_sources("libtcod/src/libtcod/") diff --git a/build_sdl.py b/build_sdl.py index b073ab77..459b37b4 100755 --- a/build_sdl.py +++ b/build_sdl.py @@ -350,8 +350,9 @@ def get_cdef() -> tuple[str, dict[str, str]]: libraries: list[str] = [] library_dirs: list[str] = [] - -if sys.platform == "darwin": +if "PYODIDE" in os.environ: + pass +elif sys.platform == "darwin": extra_link_args += ["-framework", "SDL3"] else: libraries += ["SDL3"] @@ -382,6 +383,7 @@ def get_cdef() -> tuple[str, dict[str, str]]: extra_compile_args += ( subprocess.check_output(["pkg-config", "sdl3", "--cflags"], universal_newlines=True).strip().split() ) - extra_link_args += ( - subprocess.check_output(["pkg-config", "sdl3", "--libs"], universal_newlines=True).strip().split() - ) + if "PYODIDE" not in os.environ: + extra_link_args += ( + subprocess.check_output(["pkg-config", "sdl3", "--libs"], universal_newlines=True).strip().split() + ) From 6d2b2c7928627d4c6e2010c63e86b00690ca82a6 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Tue, 24 Jun 2025 21:18:22 -0700 Subject: [PATCH 008/131] Use alpha builds of Pyodide supporting SDL3 Stable version is too old and only supports SDL2 Try not to bundle win/mac binaries during Pyodide build --- .github/workflows/python-package.yml | 3 ++- .vscode/settings.json | 1 + build_sdl.py | 35 +++++++++++++++++++--------- pyproject.toml | 6 ++++- 4 files changed, 32 insertions(+), 13 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index b6f15916..1545558e 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -334,9 +334,10 @@ jobs: with: install-linux-dependencies: true build-type: "Debug" - version: ${{ env.sdl-version }} + version: "3.2.4" # Should be equal or less than the version used by Emscripten - uses: pypa/cibuildwheel@v3.0.0 env: + CIBW_BUILD: cp313-pyodide_wasm32 CIBW_PLATFORM: pyodide - name: Archive wheel uses: actions/upload-artifact@v4 diff --git a/.vscode/settings.json b/.vscode/settings.json index 2ef44da6..6dbcc428 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -141,6 +141,7 @@ "dunder", "DVLINE", "elif", + "Emscripten", "ENDCALL", "endianness", "epel", diff --git a/build_sdl.py b/build_sdl.py index 459b37b4..cc1fb787 100755 --- a/build_sdl.py +++ b/build_sdl.py @@ -249,16 +249,27 @@ def on_directive_handle( return super().on_directive_handle(directive, tokens, if_passthru, preceding_tokens) +def get_emscripten_include_dir() -> Path: + """Find and return the Emscripten include dir.""" + # None of the EMSDK environment variables exist! Search PATH for Emscripten as a workaround + for path in os.environ["PATH"].split(os.pathsep)[::-1]: + if Path(path).match("upstream/emscripten"): + return Path(path, "system/include").resolve(strict=True) + raise AssertionError(os.environ["PATH"]) + + check_sdl_version() -if sys.platform == "win32" or sys.platform == "darwin": +SDL_PARSE_PATH: Path | None = None +SDL_BUNDLE_PATH: Path | None = None +if (sys.platform == "win32" or sys.platform == "darwin") and "PYODIDE" not in os.environ: SDL_PARSE_PATH = unpack_sdl(SDL_PARSE_VERSION) SDL_BUNDLE_PATH = unpack_sdl(SDL_BUNDLE_VERSION) SDL_INCLUDE: Path -if sys.platform == "win32": +if sys.platform == "win32" and SDL_PARSE_PATH is not None: SDL_INCLUDE = SDL_PARSE_PATH / "include" -elif sys.platform == "darwin": +elif sys.platform == "darwin" and SDL_PARSE_PATH is not None: SDL_INCLUDE = SDL_PARSE_PATH / "Versions/A/Headers" else: # Unix matches = re.findall( @@ -275,6 +286,7 @@ def on_directive_handle( raise AssertionError(matches) assert SDL_INCLUDE +logger.info(f"{SDL_INCLUDE=}") EXTRA_CDEF = """ #define SDLK_SCANCODE_MASK ... @@ -358,7 +370,7 @@ def get_cdef() -> tuple[str, dict[str, str]]: libraries += ["SDL3"] # Bundle the Windows SDL DLL. -if sys.platform == "win32": +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]) @@ -372,18 +384,19 @@ def get_cdef() -> tuple[str, dict[str, str]]: # Link to the SDL framework on MacOS. # Delocate will bundle the binaries in a later step. -if sys.platform == "darwin": +if sys.platform == "darwin" and SDL_BUNDLE_PATH is not None: include_dirs.append(SDL_INCLUDE) extra_link_args += [f"-F{SDL_BUNDLE_PATH}/.."] extra_link_args += ["-rpath", f"{SDL_BUNDLE_PATH}/.."] extra_link_args += ["-rpath", "/usr/local/opt/llvm/lib/"] -# Use sdl-config to link to SDL on Linux. -if sys.platform not in ["win32", "darwin"]: +if "PYODIDE" in os.environ: + extra_compile_args += ["--use-port=sdl3"] +elif sys.platform not in ["win32", "darwin"]: + # Use sdl-config to link to SDL on Linux. extra_compile_args += ( subprocess.check_output(["pkg-config", "sdl3", "--cflags"], universal_newlines=True).strip().split() ) - if "PYODIDE" not in os.environ: - extra_link_args += ( - subprocess.check_output(["pkg-config", "sdl3", "--libs"], universal_newlines=True).strip().split() - ) + extra_link_args += ( + subprocess.check_output(["pkg-config", "sdl3", "--libs"], universal_newlines=True).strip().split() + ) diff --git a/pyproject.toml b/pyproject.toml index 6f741af8..46a9285b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,7 +100,11 @@ filterwarnings = [ ] [tool.cibuildwheel] # https://cibuildwheel.pypa.io/en/stable/options/ -enable = ["pypy"] +enable = ["pypy", "pyodide-prerelease"] + +[tool.cibuildwheel.pyodide] +dependency-versions = "latest" # Until pyodide-version is stable on cibuildwheel +pyodide-version = "0.28.0a3" [tool.mypy] files = ["."] From 9c352c541019bd580408caed2ef2eafd98afa853 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Wed, 25 Jun 2025 15:58:29 -0700 Subject: [PATCH 009/131] Remove redundant SDL version check from setup.py This duplicates build_sdl.py and maybe isn't as useful as it used to be. I could import from that module if I really need the check in setup.py. Ensured updated code was moved to build_sdl.py --- build_sdl.py | 19 +++++++++++++------ setup.py | 33 --------------------------------- 2 files changed, 13 insertions(+), 39 deletions(-) diff --git a/build_sdl.py b/build_sdl.py index cc1fb787..28069f2d 100755 --- a/build_sdl.py +++ b/build_sdl.py @@ -134,12 +134,19 @@ def check_sdl_version() -> None: sdl_version_str = subprocess.check_output( ["pkg-config", "sdl3", "--modversion"], universal_newlines=True ).strip() - except FileNotFoundError as exc: - msg = ( - "libsdl3-dev or equivalent must be installed on your system and must be at least version" - f" {needed_version}.\nsdl3-config must be on PATH." - ) - raise RuntimeError(msg) from exc + except FileNotFoundError: + try: + 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." + ) + raise RuntimeError(msg) from exc + except subprocess.CalledProcessError as exc: + if sys.version_info >= (3, 11): + exc.add_note(f"Note: {os.environ.get('PKG_CONFIG_PATH')=}") + raise logger.info(f"Found SDL {sdl_version_str}.") sdl_version = tuple(int(s) for s in sdl_version_str.split(".")) if sdl_version < SDL_MIN_VERSION: diff --git a/setup.py b/setup.py index eff99ff1..3786a1db 100755 --- a/setup.py +++ b/setup.py @@ -3,9 +3,7 @@ from __future__ import annotations -import os import platform -import subprocess import sys from pathlib import Path @@ -37,42 +35,11 @@ def get_package_data() -> list[str]: return files -def check_sdl_version() -> None: - """Check the local SDL version on Linux distributions.""" - if not sys.platform.startswith("linux"): - return - needed_version = "{}.{}.{}".format(*SDL_VERSION_NEEDED) - try: - sdl_version_str = subprocess.check_output( - ["pkg-config", "sdl3", "--modversion"], # noqa: S607 - universal_newlines=True, - ).strip() - except FileNotFoundError: - try: - sdl_version_str = subprocess.check_output(["sdl3-config", "--version"], universal_newlines=True).strip() # noqa: S607 - 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." - ) - raise RuntimeError(msg) from exc - except subprocess.CalledProcessError as exc: - if sys.version_info >= (3, 11): - exc.add_note(f"Note: {os.environ.get('PKG_CONFIG_PATH')=}") - raise - print(f"Found SDL {sdl_version_str}.") - sdl_version = tuple(int(s) for s in sdl_version_str.split(".")) - if sdl_version < SDL_VERSION_NEEDED: - msg = f"SDL version must be at least {needed_version}, (found {sdl_version_str})" - raise RuntimeError(msg) - - if not (SETUP_DIR / "libtcod/src").exists(): print("Libtcod submodule is uninitialized.") print("Did you forget to run 'git submodule update --init'?") sys.exit(1) -check_sdl_version() setup( py_modules=["libtcodpy"], From 32553fc6b5dba4c937e7006572fe4971e639266d Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Fri, 27 Jun 2025 16:53:04 -0700 Subject: [PATCH 010/131] Note that TextInput is no longer enabled by default Caused by SDL3 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3327d110..4bdb3102 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Be sure to run [Mypy](https://mypy.readthedocs.io/en/stable/getting_started.html - Sound queueing methods were moved from `AudioDevice` to a new `AudioStream` class. - `BasicMixer` may require manually specifying `frequency` and `channels` to replicate old behavior. - `get_devices` and `get_capture_devices` now return `dict[str, AudioDevice]`. +- `TextInput` events are no longer enabled by default. ### Deprecated From b701e7de7b087fc53b6d2dd69e83feba02d5a2e2 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 7 Jul 2025 19:55:49 +0000 Subject: [PATCH 011/131] [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.12.0 → v0.12.2](https://github.com/astral-sh/ruff-pre-commit/compare/v0.12.0...v0.12.2) --- .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 a294765c..8234ccf4 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.12.0 + rev: v0.12.2 hooks: - id: ruff-check args: [--fix-only, --exit-non-zero-on-fix] From acc775e68b5106be46ac12b2d88280cf4dff8291 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Tue, 8 Jul 2025 02:04:30 -0700 Subject: [PATCH 012/131] Update PyInstaller version in example --- examples/distribution/PyInstaller/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/distribution/PyInstaller/requirements.txt b/examples/distribution/PyInstaller/requirements.txt index 477fbbdc..24379896 100644 --- a/examples/distribution/PyInstaller/requirements.txt +++ b/examples/distribution/PyInstaller/requirements.txt @@ -1,3 +1,3 @@ tcod==16.2.3 -pyinstaller==6.9.0 +pyinstaller==6.14.2 pypiwin32; sys_platform=="win32" From 029ee45683b869b75881b6017e149fef46feaa87 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Tue, 8 Jul 2025 16:32:47 -0700 Subject: [PATCH 013/131] Add missing migration overload for Console.print --- CHANGELOG.md | 4 ++++ tcod/console.py | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bdb3102..b8119181 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version ## [Unreleased] +### Fixed + +- `Console.print` methods using `string` keyword were marked as invalid instead of deprecated. + ## [19.0.0] - 2025-06-13 Finished port to SDL3, this has caused several breaking changes from SDL such as lowercase key constants now being uppercase and mouse events returning `float` instead of `int`. diff --git a/tcod/console.py b/tcod/console.py index c4b24281..44ac3c61 100644 --- a/tcod/console.py +++ b/tcod/console.py @@ -1038,6 +1038,24 @@ def print( string: str = "", ) -> int: ... + @overload + @deprecated( + "Replace text, fg, bg, bg_blend, and alignment with keyword arguments." + "\n'string' keyword should be renamed to `text`" + ) + def print( + self, + x: int, + y: int, + text: str = "", + fg: tuple[int, int, int] | None = None, + bg: tuple[int, int, int] | None = None, + bg_blend: int = tcod.constants.BKGND_SET, + alignment: int = tcod.constants.LEFT, + *, + string: str, + ) -> int: ... + def print( # noqa: PLR0913 self, x: int, From 622a8a92d6b9a26c7a878082765de69dd7966125 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Fri, 11 Jul 2025 02:31:46 -0700 Subject: [PATCH 014/131] Fix SDL setup in CI Switch to custom composite action which fixes the issue Update build_sdl.py to handle SDL installed to the system path --- .github/workflows/python-package.yml | 10 +++++----- build_sdl.py | 9 ++++++++- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 1545558e..0b5462cb 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -45,7 +45,7 @@ jobs: sdist: runs-on: ubuntu-latest steps: - - uses: libsdl-org/setup-sdl@6574e20ac65ce362cd12f9c26b3a5e4d3cd31dee + - uses: HexDecimal/my-setup-sdl-action@v1.0.0 with: install-linux-dependencies: true build-type: "Debug" @@ -125,7 +125,7 @@ jobs: run: | sudo apt-get update sudo apt-get install xvfb - - uses: libsdl-org/setup-sdl@6574e20ac65ce362cd12f9c26b3a5e4d3cd31dee + - uses: HexDecimal/my-setup-sdl-action@v1.0.0 if: runner.os == 'Linux' with: install-linux-dependencies: true @@ -168,7 +168,7 @@ jobs: needs: [ruff, mypy, sdist] runs-on: ubuntu-latest steps: - - uses: libsdl-org/setup-sdl@6574e20ac65ce362cd12f9c26b3a5e4d3cd31dee + - uses: HexDecimal/my-setup-sdl-action@v1.0.0 if: runner.os == 'Linux' with: install-linux-dependencies: true @@ -210,7 +210,7 @@ jobs: - name: Install Python dependencies run: | python -m pip install --upgrade pip tox - - uses: libsdl-org/setup-sdl@6574e20ac65ce362cd12f9c26b3a5e4d3cd31dee + - uses: HexDecimal/my-setup-sdl-action@v1.0.0 if: runner.os == 'Linux' with: install-linux-dependencies: true @@ -330,7 +330,7 @@ jobs: fetch-depth: ${{ env.git-depth }} - name: Checkout submodules run: git submodule update --init --recursive --depth 1 - - uses: libsdl-org/setup-sdl@6574e20ac65ce362cd12f9c26b3a5e4d3cd31dee + - uses: HexDecimal/my-setup-sdl-action@v1.0.0 with: install-linux-dependencies: true build-type: "Debug" diff --git a/build_sdl.py b/build_sdl.py index 28069f2d..aca9fc25 100755 --- a/build_sdl.py +++ b/build_sdl.py @@ -212,6 +212,12 @@ def get_output(self) -> str: buffer.write(f"#define {name} ...\n") return buffer.getvalue() + def on_file_open(self, is_system_include: bool, includepath: str) -> Any: # noqa: ANN401, FBT001 + """Ignore includes other than SDL headers.""" + if not Path(includepath).parent.name == "SDL3": + raise FileNotFoundError + return super().on_file_open(is_system_include, includepath) + def on_include_not_found(self, is_malformed: bool, is_system_include: bool, curdir: str, includepath: str) -> None: # noqa: ARG002, FBT001 """Remove bad includes such as stddef.h and stdarg.h.""" assert "SDL3/SDL" not in includepath, (includepath, curdir) @@ -283,7 +289,8 @@ def get_emscripten_include_dir() -> Path: r"-I(\S+)", subprocess.check_output(["pkg-config", "sdl3", "--cflags"], universal_newlines=True), ) - assert matches + if not matches: + matches = ["/usr/include"] for match in matches: if Path(match, "SDL3/SDL.h").is_file(): From 523262275dacd3b770f04b4c5ce57af2c1dfd411 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Fri, 11 Jul 2025 05:19:01 -0700 Subject: [PATCH 015/131] Prepare 19.0.1 release. --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8119181..4c7af1d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version ## [Unreleased] +## [19.0.1] - 2025-07-11 + ### Fixed - `Console.print` methods using `string` keyword were marked as invalid instead of deprecated. From 7e0c595ec5feabf825ba71bea689eb84e7462fdf Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Fri, 11 Jul 2025 05:40:42 -0700 Subject: [PATCH 016/131] Avoid trying to upload Pyodide wheels --- .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 0b5462cb..0afe55d0 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -342,7 +342,7 @@ jobs: - name: Archive wheel uses: actions/upload-artifact@v4 with: - name: wheels-pyodide + name: pyodide path: wheelhouse/*.whl retention-days: 30 compression-level: 0 From fbd9f78925bfaf2e4eeb75c7f8315e0be3c92ac2 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Fri, 11 Jul 2025 05:42:05 -0700 Subject: [PATCH 017/131] Prepare 19.0.2 release. --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c7af1d9..13870a5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version ## [Unreleased] +## [19.0.2] - 2025-07-11 + ## [19.0.1] - 2025-07-11 ### Fixed From 3131edbe1734f9e1c74ba8debe3946acc73c335e Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Fri, 11 Jul 2025 05:44:37 -0700 Subject: [PATCH 018/131] Update changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13870a5d..1ce3bce3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version ## [19.0.2] - 2025-07-11 +Resolve wheel deployment issue. + ## [19.0.1] - 2025-07-11 ### Fixed From bebbb9a1f9078e13e13fbf3213f3ee4924929b77 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Fri, 11 Jul 2025 20:01:16 -0700 Subject: [PATCH 019/131] Resolve Ruff warnings and add spelling --- .vscode/settings.json | 13 +++++++++++++ docs/conf.py | 4 ++-- scripts/generate_charmap_table.py | 2 +- scripts/tag_release.py | 4 ++-- 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 6dbcc428..f516971f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -26,6 +26,7 @@ "addy", "algo", "ALPH", + "alsa", "ALTERASE", "arange", "ARCHS", @@ -115,6 +116,7 @@ "DBLAMPERSAND", "DBLAPOSTROPHE", "DBLVERTICALBAR", + "dbus", "dcost", "DCROSS", "DECIMALSEPARATOR", @@ -190,6 +192,7 @@ "htmlhelp", "htmlzip", "IBEAM", + "ibus", "ifdef", "ifndef", "iinfo", @@ -255,9 +258,13 @@ "letterpaper", "LGUI", "LHYPER", + "libdrm", + "libgbm", "libsdl", "libtcod", "libtcodpy", + "libusb", + "libxkbcommon", "linspace", "liskin", "LMASK", @@ -347,6 +354,7 @@ "printn", "PRINTSCREEN", "propname", + "pulseaudio", "pushdown", "pycall", "pycparser", @@ -397,6 +405,7 @@ "scancodes", "scipy", "scoef", + "Scrn", "SCROLLLOCK", "sdist", "SDL's", @@ -498,7 +507,11 @@ "windowshown", "windowsizechanged", "windowtakefocus", + "Xcursor", "xdst", + "Xext", + "Xfixes", + "Xrandr", "xrel", "xvfb", "ydst", diff --git a/docs/conf.py b/docs/conf.py index 0fda406a..c107d387 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,4 +1,4 @@ -"""Sphinx config file.""" +"""Sphinx config file.""" # noqa: INP001 # tdl documentation build configuration file, created by # sphinx-quickstart on Fri Nov 25 12:49:46 2016. # @@ -65,7 +65,7 @@ # General information about the project. project = "python-tcod" -copyright = "2009-2025, Kyle Benesch" +copyright = "2009-2025, Kyle Benesch" # noqa: A001 author = "Kyle Benesch" # The version info for the project you're documenting, acts as replacement for diff --git a/scripts/generate_charmap_table.py b/scripts/generate_charmap_table.py index 2a7814b7..bc7efb19 100755 --- a/scripts/generate_charmap_table.py +++ b/scripts/generate_charmap_table.py @@ -8,7 +8,7 @@ import argparse import unicodedata -from collections.abc import Iterable, Iterator +from collections.abc import Iterable, Iterator # noqa: TC003 from tabulate import tabulate diff --git a/scripts/tag_release.py b/scripts/tag_release.py index 066eaeda..5f39e4b2 100755 --- a/scripts/tag_release.py +++ b/scripts/tag_release.py @@ -45,7 +45,7 @@ def parse_changelog(args: argparse.Namespace) -> tuple[str, str]: return f"{header}{tagged}{tail}", changes -def replace_unreleased_tags(tag: str, dry_run: bool) -> None: +def replace_unreleased_tags(tag: str, *, dry_run: bool) -> None: """Walk though sources and replace pending tags with the new tag.""" match = re.match(r"\d+\.\d+", tag) assert match @@ -77,7 +77,7 @@ def main() -> None: print("--- New changelog:") print(new_changelog) - replace_unreleased_tags(args.tag, args.dry_run) + replace_unreleased_tags(args.tag, dry_run=args.dry_run) if not args.dry_run: (PROJECT_DIR / "CHANGELOG.md").write_text(new_changelog, encoding="utf-8") From 84df0e7c1fe5b03857a588dabf2ad712a0cb0af7 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Fri, 11 Jul 2025 21:12:17 -0700 Subject: [PATCH 020/131] Add SDL Window text input methods Text input events were missing since version 19.0.0 and had to be added again. --- .vscode/settings.json | 2 + CHANGELOG.md | 6 +++ examples/eventget.py | 2 + tcod/sdl/video.py | 118 ++++++++++++++++++++++++++++++++++++++++++ tests/test_sdl.py | 4 ++ 5 files changed, 132 insertions(+) diff --git a/.vscode/settings.json b/.vscode/settings.json index f516971f..4b9f44a9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -45,6 +45,7 @@ "AUDIOREWIND", "AUDIOSTOP", "autoclass", + "AUTOCORRECT", "autofunction", "autogenerated", "automodule", @@ -200,6 +201,7 @@ "imageio", "imread", "INCOL", + "INPUTTYPE", "INROW", "interactable", "intersphinx", diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ce3bce3..8f77de53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version ## [Unreleased] +### Added + +- Added text input support to `tcod.sdl.video.Window` which was missing since the SDL3 update. + After creating a context use `assert context.sdl_window` or `if context.sdl_window:` to verify that an SDL window exists then use `context.sdl_window.start_text_input` to enable text input events. + Keep in mind that this can open an on-screen keyboard. + ## [19.0.2] - 2025-07-11 Resolve wheel deployment issue. diff --git a/examples/eventget.py b/examples/eventget.py index dad8649d..8610fae1 100755 --- a/examples/eventget.py +++ b/examples/eventget.py @@ -22,6 +22,8 @@ def main() -> None: joysticks: set[tcod.sdl.joystick.Joystick] = set() with tcod.context.new(width=WIDTH, height=HEIGHT) as context: + if context.sdl_window: + context.sdl_window.start_text_input() console = context.new_console() while True: # Display all event items. diff --git a/tcod/sdl/video.py b/tcod/sdl/video.py index 4cc0c371..75b9e560 100644 --- a/tcod/sdl/video.py +++ b/tcod/sdl/video.py @@ -24,7 +24,9 @@ from numpy.typing import ArrayLike, NDArray __all__ = ( + "Capitalization", "FlashOperation", + "TextInputType", "Window", "WindowFlags", "get_grabbed_window", @@ -89,6 +91,56 @@ class FlashOperation(enum.IntEnum): """Flash until focus is gained.""" +class TextInputType(enum.IntEnum): + """SDL input types for text input. + + .. seealso:: + :any:`Window.start_text_input` + https://wiki.libsdl.org/SDL3/SDL_TextInputType + + .. versionadded:: Unreleased + """ + + TEXT = lib.SDL_TEXTINPUT_TYPE_TEXT + """The input is text.""" + TEXT_NAME = lib.SDL_TEXTINPUT_TYPE_TEXT_NAME + """The input is a person's name.""" + TEXT_EMAIL = lib.SDL_TEXTINPUT_TYPE_TEXT_EMAIL + """The input is an e-mail address.""" + TEXT_USERNAME = lib.SDL_TEXTINPUT_TYPE_TEXT_USERNAME + """The input is a username.""" + TEXT_PASSWORD_HIDDEN = lib.SDL_TEXTINPUT_TYPE_TEXT_PASSWORD_HIDDEN + """The input is a secure password that is hidden.""" + TEXT_PASSWORD_VISIBLE = lib.SDL_TEXTINPUT_TYPE_TEXT_PASSWORD_VISIBLE + """The input is a secure password that is visible.""" + NUMBER = lib.SDL_TEXTINPUT_TYPE_NUMBER + """The input is a number.""" + NUMBER_PASSWORD_HIDDEN = lib.SDL_TEXTINPUT_TYPE_NUMBER_PASSWORD_HIDDEN + """The input is a secure PIN that is hidden.""" + NUMBER_PASSWORD_VISIBLE = lib.SDL_TEXTINPUT_TYPE_NUMBER_PASSWORD_VISIBLE + """The input is a secure PIN that is visible.""" + + +class Capitalization(enum.IntEnum): + """Text capitalization for text input. + + .. seealso:: + :any:`Window.start_text_input` + https://wiki.libsdl.org/SDL3/SDL_Capitalization + + .. versionadded:: Unreleased + """ + + NONE = lib.SDL_CAPITALIZE_NONE + """No auto-capitalization will be done.""" + SENTENCES = lib.SDL_CAPITALIZE_SENTENCES + """The first letter of sentences will be capitalized.""" + WORDS = lib.SDL_CAPITALIZE_WORDS + """The first letter of words will be capitalized.""" + LETTERS = lib.SDL_CAPITALIZE_LETTERS + """All letters will be capitalized.""" + + class _TempSurface: """Holds a temporary surface derived from a NumPy array.""" @@ -133,6 +185,9 @@ def __eq__(self, other: object) -> bool: return NotImplemented return bool(self.p == other.p) + def __hash__(self) -> int: + return hash(self.p) + def _as_property_pointer(self) -> Any: # noqa: ANN401 return self.p @@ -369,6 +424,69 @@ def relative_mouse_mode(self) -> bool: def relative_mouse_mode(self, enable: bool, /) -> None: _check(lib.SDL_SetWindowRelativeMouseMode(self.p, enable)) + def start_text_input( + self, + *, + type: TextInputType = TextInputType.TEXT, # noqa: A002 + capitalization: Capitalization | None = None, + autocorrect: bool = True, + multiline: bool | None = None, + android_type: int | None = None, + ) -> None: + """Start receiving text input events supporting Unicode. This may open an on-screen keyboard. + + This method is meant to be paired with :any:`set_text_input_area`. + + Args: + type: Type of text being inputted, see :any:`TextInputType` + capitalization: Capitalization hint, default is based on `type` given, see :any:`Capitalization`. + autocorrect: Enable auto completion and auto correction. + multiline: Allow multiple lines of text. + android_type: Input type for Android, see SDL docs. + + .. seealso:: + :any:`stop_text_input` + :any:`set_text_input_area` + https://wiki.libsdl.org/SDL3/SDL_StartTextInputWithProperties + + .. versionadded:: Unreleased + """ + props = Properties() + props[("SDL_PROP_TEXTINPUT_TYPE_NUMBER", int)] = int(type) + if capitalization is not None: + props[("SDL_PROP_TEXTINPUT_CAPITALIZATION_NUMBER", int)] = int(capitalization) + props[("SDL_PROP_TEXTINPUT_AUTOCORRECT_BOOLEAN", bool)] = autocorrect + if multiline is not None: + props[("SDL_PROP_TEXTINPUT_MULTILINE_BOOLEAN", bool)] = multiline + if android_type is not None: + props[("SDL_PROP_TEXTINPUT_ANDROID_INPUTTYPE_NUMBER", int)] = int(android_type) + _check(lib.SDL_StartTextInputWithProperties(self.p, props.p)) + + def set_text_input_area(self, rect: tuple[int, int, int, int], cursor: int) -> None: + """Assign the area used for entering Unicode text input. + + Args: + rect: `(x, y, width, height)` rectangle used for text input + cursor: Cursor X position, relative to `rect[0]` + + .. seealso:: + :any:`start_text_input` + https://wiki.libsdl.org/SDL3/SDL_SetTextInputArea + + .. versionadded:: Unreleased + """ + _check(lib.SDL_SetTextInputArea(self.p, (rect,), cursor)) + + def stop_text_input(self) -> None: + """Stop receiving text events for this window and close relevant on-screen keyboards. + + .. seealso:: + :any:`start_text_input` + + .. versionadded:: Unreleased + """ + _check(lib.SDL_StopTextInput(self.p)) + def new_window( # noqa: PLR0913 width: int, diff --git a/tests/test_sdl.py b/tests/test_sdl.py index 9d23d0c8..33ccf8dc 100644 --- a/tests/test_sdl.py +++ b/tests/test_sdl.py @@ -38,6 +38,10 @@ def test_sdl_window(uses_window: None) -> None: window.opacity = window.opacity window.grab = window.grab + window.start_text_input(capitalization=tcod.sdl.video.Capitalization.NONE, multiline=False) + window.set_text_input_area((0, 0, 8, 8), 0) + window.stop_text_input() + def test_sdl_window_bad_types() -> None: with pytest.raises(TypeError): From 651f58f17e54f2701b4241b097edf6a6ebe4c68b Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Sat, 12 Jul 2025 02:37:21 -0700 Subject: [PATCH 021/131] Prepare 19.1.0 release. --- CHANGELOG.md | 2 ++ tcod/sdl/video.py | 10 +++++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f77de53..c9ca828b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version ## [Unreleased] +## [19.1.0] - 2025-07-12 + ### Added - Added text input support to `tcod.sdl.video.Window` which was missing since the SDL3 update. diff --git a/tcod/sdl/video.py b/tcod/sdl/video.py index 75b9e560..4fb14e7d 100644 --- a/tcod/sdl/video.py +++ b/tcod/sdl/video.py @@ -98,7 +98,7 @@ class TextInputType(enum.IntEnum): :any:`Window.start_text_input` https://wiki.libsdl.org/SDL3/SDL_TextInputType - .. versionadded:: Unreleased + .. versionadded:: 19.1 """ TEXT = lib.SDL_TEXTINPUT_TYPE_TEXT @@ -128,7 +128,7 @@ class Capitalization(enum.IntEnum): :any:`Window.start_text_input` https://wiki.libsdl.org/SDL3/SDL_Capitalization - .. versionadded:: Unreleased + .. versionadded:: 19.1 """ NONE = lib.SDL_CAPITALIZE_NONE @@ -449,7 +449,7 @@ def start_text_input( :any:`set_text_input_area` https://wiki.libsdl.org/SDL3/SDL_StartTextInputWithProperties - .. versionadded:: Unreleased + .. versionadded:: 19.1 """ props = Properties() props[("SDL_PROP_TEXTINPUT_TYPE_NUMBER", int)] = int(type) @@ -473,7 +473,7 @@ def set_text_input_area(self, rect: tuple[int, int, int, int], cursor: int) -> N :any:`start_text_input` https://wiki.libsdl.org/SDL3/SDL_SetTextInputArea - .. versionadded:: Unreleased + .. versionadded:: 19.1 """ _check(lib.SDL_SetTextInputArea(self.p, (rect,), cursor)) @@ -483,7 +483,7 @@ def stop_text_input(self) -> None: .. seealso:: :any:`start_text_input` - .. versionadded:: Unreleased + .. versionadded:: 19.1 """ _check(lib.SDL_StopTextInput(self.p)) From d641bc1d64387a4bc1bcc80bc89438b5599b9e0d Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Sat, 12 Jul 2025 05:22:50 -0700 Subject: [PATCH 022/131] Improve text input docs Cross reference with TextInput class Add examples --- tcod/event.py | 5 +++++ tcod/sdl/video.py | 19 ++++++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/tcod/event.py b/tcod/event.py index b780ffdf..d7fc0cc7 100644 --- a/tcod/event.py +++ b/tcod/event.py @@ -674,6 +674,11 @@ def __str__(self) -> str: class TextInput(Event): """SDL text input event. + .. warning:: + 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. diff --git a/tcod/sdl/video.py b/tcod/sdl/video.py index 4fb14e7d..75844823 100644 --- a/tcod/sdl/video.py +++ b/tcod/sdl/video.py @@ -167,7 +167,12 @@ def __init__(self, pixels: ArrayLike) -> None: class Window: - """An SDL2 Window object.""" + """An SDL2 Window object. + + Created from :any:`tcod.sdl.video.new_window` when working with SDL directly. + + 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 if ffi.typeof(sdl_window_p) is not ffi.typeof("struct SDL_Window*"): @@ -444,9 +449,21 @@ def start_text_input( multiline: Allow multiple lines of text. android_type: Input type for Android, see SDL docs. + Example:: + + context: tcod.context.Context # Assuming tcod context is used + + if context.sdl_window: + context.sdl_window.start_text_input() + + ... # Handle Unicode input using TextInput events + + context.sdl_window.stop_text_input() # Close on-screen keyboard when done + .. seealso:: :any:`stop_text_input` :any:`set_text_input_area` + :any:`TextInput` https://wiki.libsdl.org/SDL3/SDL_StartTextInputWithProperties .. versionadded:: 19.1 From ae49f329c9c1fb5c5350f1476e0eee596351268d Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Sun, 13 Jul 2025 03:47:44 -0700 Subject: [PATCH 023/131] Add offset parameter to tcod.noise.grid `origin` parameter is unintuitive for sampling noise by chunks or by integer offsets. --- CHANGELOG.md | 4 ++++ tcod/noise.py | 39 +++++++++++++++++++++++++++++++++++---- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9ca828b..1bc36861 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.noise.grid` now has the `offset` parameter for easier sampling of noise chunks. + ## [19.1.0] - 2025-07-12 ### Added diff --git a/tcod/noise.py b/tcod/noise.py index b4631455..3fba22a0 100644 --- a/tcod/noise.py +++ b/tcod/noise.py @@ -12,7 +12,7 @@ ... algorithm=tcod.noise.Algorithm.SIMPLEX, ... seed=42, ... ) - >>> samples = noise[tcod.noise.grid(shape=(5, 4), scale=0.25, origin=(0, 0))] + >>> samples = noise[tcod.noise.grid(shape=(5, 4), scale=0.25, offset=(0, 0))] >>> samples # Samples are a grid of floats between -1.0 and 1.0 array([[ 0. , -0.55046356, -0.76072866, -0.7088647 , -0.68165785], [-0.27523372, -0.7205134 , -0.74057037, -0.43919194, -0.29195625], @@ -412,8 +412,10 @@ def _setstate_old(self, state: tuple[Any, ...]) -> None: def grid( shape: tuple[int, ...], scale: tuple[float, ...] | float, - origin: tuple[int, ...] | None = None, + origin: tuple[float, ...] | None = None, indexing: Literal["ij", "xy"] = "xy", + *, + offset: tuple[float, ...] | None = None, ) -> tuple[NDArray[np.number], ...]: """Generate a mesh-grid of sample points to use with noise sampling. @@ -427,6 +429,11 @@ def grid( If `None` then the `origin` will be zero on each axis. `origin` is not scaled by the `scale` parameter. indexing: Passed to :any:`numpy.meshgrid`. + offset: The offset into the shape to generate. + Similar to `origin` but is scaled by the `scale` parameter. + Can be multiples of `shape` to index noise samples by chunk. + + .. versionadded:: Unreleased Returns: A sparse mesh-grid to be passed into a :class:`Noise` instance. @@ -435,14 +442,14 @@ def grid( >>> noise = tcod.noise.Noise(dimensions=2, seed=42) - # Common case for ij-indexed arrays. + # Common case for ij-indexed arrays >>> noise[tcod.noise.grid(shape=(3, 5), scale=0.25, indexing="ij")] array([[ 0. , -0.27523372, -0.40398532, -0.50773406, -0.64945626], [-0.55046356, -0.7205134 , -0.57662135, -0.2643614 , -0.12529983], [-0.76072866, -0.74057037, -0.33160293, 0.24446318, 0.5346834 ]], dtype=float32) - # Transpose an xy-indexed array to get a standard order="F" result. + # Transpose an xy-indexed array to get a standard order="F" result >>> noise[tcod.noise.grid(shape=(4, 5), scale=(0.5, 0.25), origin=(1.0, 1.0))].T array([[ 0.52655405, 0.25038874, -0.03488023, -0.18455243, -0.16333057], [-0.5037453 , -0.75348294, -0.73630923, -0.35063767, 0.18149695], @@ -450,6 +457,23 @@ def grid( [-0.7057655 , -0.5817767 , -0.22774395, 0.02399864, -0.07006818]], dtype=float32) + # Can sample noise by chunk using the offset keyword + >>> noise[tcod.noise.grid(shape=(3, 5), scale=0.25, indexing="ij", offset=(0, 0))] + array([[ 0. , -0.27523372, -0.40398532, -0.50773406, -0.64945626], + [-0.55046356, -0.7205134 , -0.57662135, -0.2643614 , -0.12529983], + [-0.76072866, -0.74057037, -0.33160293, 0.24446318, 0.5346834 ]], + dtype=float32) + >>> noise[tcod.noise.grid(shape=(3, 5), scale=0.25, indexing="ij", offset=(3, 0))] + array([[-0.7088647 , -0.43919194, 0.12860827, 0.6390255 , 0.80402255], + [-0.68165785, -0.29195625, 0.2864191 , 0.5922846 , 0.52655405], + [-0.7841389 , -0.46131462, 0.0159424 , 0.17141782, -0.04198273]], + dtype=float32) + >>> noise[tcod.noise.grid(shape=(3, 5), scale=0.25, indexing="ij", offset=(6, 0))] + array([[-0.779634 , -0.60696834, -0.27446985, -0.23233278, -0.5037453 ], + [-0.5474089 , -0.54476213, -0.42235228, -0.49519652, -0.7101793 ], + [-0.28291094, -0.4326369 , -0.5227732 , -0.69655263, -0.81221616]], + dtype=float32) + .. versionadded:: 12.2 """ if isinstance(scale, (int, float)): @@ -462,6 +486,13 @@ def grid( if len(shape) != len(origin): msg = "shape must have the same length as origin" raise TypeError(msg) + if offset is not None: + if len(shape) != len(offset): + msg = "shape must have the same length as offset" + raise TypeError(msg) + origin = tuple( + i_origin + i_scale * i_offset for i_scale, i_offset, i_origin in zip(scale, offset, origin, strict=True) + ) indexes = ( np.arange(i_shape) * i_scale + i_origin for i_shape, i_scale, i_origin in zip(shape, scale, origin, strict=True) ) From 2d74caf3cb98d4e87d6df25d03e5fdad37122e9b Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Sat, 19 Jul 2025 07:15:31 -0700 Subject: [PATCH 024/131] Fix documentation typo Event attributes are not positional --- tcod/event.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tcod/event.py b/tcod/event.py index d7fc0cc7..c10f90b8 100644 --- a/tcod/event.py +++ b/tcod/event.py @@ -66,7 +66,7 @@ match event: case tcod.event.Quit(): raise SystemExit() - case tcod.event.KeyDown(sym) if sym in KEY_COMMANDS: + case tcod.event.KeyDown(sym=sym) if sym in KEY_COMMANDS: 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=}") From c19aacd49bca8e0062bb41495840150fe0a55f5d Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Sun, 20 Jul 2025 07:42:14 -0700 Subject: [PATCH 025/131] Prepare 19.2.0 release. --- CHANGELOG.md | 2 ++ tcod/noise.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bc36861..4f873882 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version ## [Unreleased] +## [19.2.0] - 2025-07-20 + ### Added - `tcod.noise.grid` now has the `offset` parameter for easier sampling of noise chunks. diff --git a/tcod/noise.py b/tcod/noise.py index 3fba22a0..533df338 100644 --- a/tcod/noise.py +++ b/tcod/noise.py @@ -433,7 +433,7 @@ def grid( Similar to `origin` but is scaled by the `scale` parameter. Can be multiples of `shape` to index noise samples by chunk. - .. versionadded:: Unreleased + .. versionadded:: 19.2 Returns: A sparse mesh-grid to be passed into a :class:`Noise` instance. From eeb06fbf67dc3c02cf9719c75dce09cce68164a1 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Sat, 26 Jul 2025 03:23:13 -0700 Subject: [PATCH 026/131] Resolve Ruff warnings --- tcod/sdl/render.py | 35 ++++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/tcod/sdl/render.py b/tcod/sdl/render.py index 511b48fc..ccf31caf 100644 --- a/tcod/sdl/render.py +++ b/tcod/sdl/render.py @@ -6,7 +6,6 @@ from __future__ import annotations import enum -from collections.abc import Sequence from typing import TYPE_CHECKING, Any, Final, Literal import numpy as np @@ -18,6 +17,8 @@ from tcod.sdl._internal import Properties, _check, _check_p if TYPE_CHECKING: + from collections.abc import Sequence + from numpy.typing import NDArray @@ -173,7 +174,7 @@ class Texture: Create a new texture using :any:`Renderer.new_texture` or :any:`Renderer.upload_texture`. """ - def __init__(self, sdl_texture_p: Any, sdl_renderer_p: Any = None) -> None: + def __init__(self, sdl_texture_p: Any, sdl_renderer_p: Any = None) -> None: # noqa: ANN401 """Encapsulate an SDL_Texture pointer. This function is private.""" self.p = sdl_texture_p self._sdl_renderer_p = sdl_renderer_p # Keep alive. @@ -200,6 +201,10 @@ def __eq__(self, other: object) -> bool: return bool(self.p == other.p) return NotImplemented + def __hash__(self) -> int: + """Return hash for the owned pointer.""" + return hash(self.p) + def update(self, pixels: NDArray[Any], rect: tuple[int, int, int, int] | None = None) -> None: """Update the pixel data of this texture. @@ -267,7 +272,7 @@ def __exit__(self, *_: object) -> None: class Renderer: """SDL Renderer.""" - def __init__(self, sdl_renderer_p: Any) -> None: + def __init__(self, sdl_renderer_p: Any) -> None: # noqa: ANN401 """Encapsulate an SDL_Renderer pointer. This function is private.""" if ffi.typeof(sdl_renderer_p) is not ffi.typeof("struct SDL_Renderer*"): msg = f"Expected a {ffi.typeof('struct SDL_Window*')} type (was {ffi.typeof(sdl_renderer_p)})." @@ -283,6 +288,10 @@ def __eq__(self, other: object) -> bool: return bool(self.p == other.p) return NotImplemented + def __hash__(self) -> int: + """Return hash for the owned pointer.""" + return hash(self.p) + def copy( # noqa: PLR0913 self, texture: Texture, @@ -328,7 +337,7 @@ def set_render_target(self, texture: Texture) -> _RestoreTargetContext: _check(lib.SDL_SetRenderTarget(self.p, texture.p)) return restore - def new_texture(self, width: int, height: int, *, format: int | None = None, access: int | None = None) -> Texture: + def new_texture(self, width: int, height: int, *, format: int | None = None, access: int | None = None) -> Texture: # noqa: A002 """Allocate and return a new Texture for this renderer. Args: @@ -339,13 +348,13 @@ def new_texture(self, width: int, height: int, *, format: int | None = None, acc See :any:`TextureAccess` for more options. """ if format is None: - format = 0 + format = 0 # noqa: A001 if access is None: access = int(lib.SDL_TEXTUREACCESS_STATIC) texture_p = ffi.gc(lib.SDL_CreateTexture(self.p, format, access, width, height), lib.SDL_DestroyTexture) return Texture(texture_p, self.p) - def upload_texture(self, pixels: NDArray[Any], *, format: int | None = None, access: int | None = None) -> Texture: + def upload_texture(self, pixels: NDArray[Any], *, format: int | None = None, access: int | None = None) -> Texture: # noqa: A002 """Return a new Texture from an array of pixels. Args: @@ -358,9 +367,9 @@ def upload_texture(self, pixels: NDArray[Any], *, format: int | None = None, acc assert len(pixels.shape) == 3 # noqa: PLR2004 assert pixels.dtype == np.uint8 if pixels.shape[2] == 4: # noqa: PLR2004 - format = int(lib.SDL_PIXELFORMAT_RGBA32) + format = int(lib.SDL_PIXELFORMAT_RGBA32) # noqa: A001 elif pixels.shape[2] == 3: # noqa: PLR2004 - format = int(lib.SDL_PIXELFORMAT_RGB24) + format = int(lib.SDL_PIXELFORMAT_RGB24) # noqa: A001 else: msg = f"Can't determine the format required for an array of shape {pixels.shape}." raise TypeError(msg) @@ -502,7 +511,7 @@ def viewport(self) -> tuple[int, int, int, int] | None: def viewport(self, rect: tuple[int, int, int, int] | None) -> None: _check(lib.SDL_SetRenderViewport(self.p, (rect,))) - def set_vsync(self, enable: bool) -> None: + def set_vsync(self, enable: bool) -> None: # noqa: FBT001 """Enable or disable VSync for this renderer. .. versionadded:: 13.5 @@ -625,7 +634,7 @@ def fill_rects(self, rects: NDArray[np.number] | Sequence[tuple[float, float, fl .. versionadded:: 13.5 """ rects = self._convert_array(rects, item_length=4) - _check(lib.SDL_RenderFillRects(self.p, tcod.ffi.from_buffer("SDL_FRect*", rects), rects.shape[0])) + _check(lib.SDL_RenderFillRects(self.p, ffi.from_buffer("SDL_FRect*", rects), rects.shape[0])) def draw_rects(self, rects: NDArray[np.number] | Sequence[tuple[float, float, float, float]]) -> None: """Draw multiple outlined rectangles from an array. @@ -638,7 +647,7 @@ def draw_rects(self, rects: NDArray[np.number] | Sequence[tuple[float, float, fl rects = self._convert_array(rects, item_length=4) assert len(rects.shape) == 2 # noqa: PLR2004 assert rects.shape[1] == 4 # noqa: PLR2004 - _check(lib.SDL_RenderRects(self.p, tcod.ffi.from_buffer("SDL_FRect*", rects), rects.shape[0])) + _check(lib.SDL_RenderRects(self.p, ffi.from_buffer("SDL_FRect*", rects), rects.shape[0])) def draw_points(self, points: NDArray[np.number] | Sequence[tuple[float, float]]) -> None: """Draw an array of points. @@ -649,7 +658,7 @@ def draw_points(self, points: NDArray[np.number] | Sequence[tuple[float, float]] .. versionadded:: 13.5 """ points = self._convert_array(points, item_length=2) - _check(lib.SDL_RenderPoints(self.p, tcod.ffi.from_buffer("SDL_FPoint*", points), points.shape[0])) + _check(lib.SDL_RenderPoints(self.p, ffi.from_buffer("SDL_FPoint*", points), points.shape[0])) def draw_lines(self, points: NDArray[np.number] | Sequence[tuple[float, float]]) -> None: """Draw a connected series of lines from an array. @@ -660,7 +669,7 @@ def draw_lines(self, points: NDArray[np.number] | Sequence[tuple[float, float]]) .. versionadded:: 13.5 """ points = self._convert_array(points, item_length=2) - _check(lib.SDL_RenderLines(self.p, tcod.ffi.from_buffer("SDL_FPoint*", points), points.shape[0])) + _check(lib.SDL_RenderLines(self.p, ffi.from_buffer("SDL_FPoint*", points), points.shape[0])) def geometry( self, From 3b944f91c3b516b5b501f99c59e42053cd510d22 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Sat, 26 Jul 2025 03:23:00 -0700 Subject: [PATCH 027/131] Port SDL3 texture scale mode --- .vscode/settings.json | 2 ++ CHANGELOG.md | 4 ++++ tcod/sdl/render.py | 27 +++++++++++++++++++++++++++ tests/test_sdl.py | 1 + 4 files changed, 34 insertions(+) diff --git a/.vscode/settings.json b/.vscode/settings.json index 4b9f44a9..05971cbf 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -348,6 +348,7 @@ "PERLIN", "PILCROW", "pilmode", + "PIXELART", "PIXELFORMAT", "PLUSMINUS", "pointsize", @@ -401,6 +402,7 @@ "rtype", "RWIN", "RWOPS", + "SCALEMODE", "scalex", "scaley", "Scancode", diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f873882..f00dfd41 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.render`: Added `ScaleMode` enum and `Texture.scale_mode` attribute. + ## [19.2.0] - 2025-07-20 ### Added diff --git a/tcod/sdl/render.py b/tcod/sdl/render.py index ccf31caf..c8e29715 100644 --- a/tcod/sdl/render.py +++ b/tcod/sdl/render.py @@ -141,6 +141,19 @@ class BlendMode(enum.IntEnum): """""" +class ScaleMode(enum.IntEnum): + """Texture scaling modes. + + .. versionadded:: unreleased + """ + + NEAREST = lib.SDL_SCALEMODE_NEAREST + """Nearing neighbor.""" + LINEAR = lib.SDL_SCALEMODE_LINEAR + """Linier filtering.""" + # PIXELART = lib.SDL_SCALEMODE_PIXELART # Needs SDL 3.4 # noqa: ERA001 + + def compose_blend_mode( # noqa: PLR0913 source_color_factor: BlendFactor, dest_color_factor: BlendFactor, @@ -254,6 +267,20 @@ def color_mod(self) -> tuple[int, int, int]: def color_mod(self, rgb: tuple[int, int, int]) -> None: _check(lib.SDL_SetTextureColorMod(self.p, rgb[0], rgb[1], rgb[2])) + @property + def scale_mode(self) -> ScaleMode: + """Get or set this textures :any:`ScaleMode`. + + ..versionadded:: unreleased + """ + mode = ffi.new("SDL_ScaleMode*") + _check(lib.SDL_GetTextureScaleMode(self.p, mode)) + return ScaleMode(mode[0]) + + @scale_mode.setter + def scale_mode(self, value: ScaleMode, /) -> None: + _check(lib.SDL_SetTextureScaleMode(self.p, value)) + class _RestoreTargetContext: """A context manager which tracks the current render target and restores it on exiting.""" diff --git a/tests/test_sdl.py b/tests/test_sdl.py index 33ccf8dc..93373a49 100644 --- a/tests/test_sdl.py +++ b/tests/test_sdl.py @@ -70,6 +70,7 @@ def test_sdl_render(uses_window: None) -> None: rgb.alpha_mod = rgb.alpha_mod rgb.blend_mode = rgb.blend_mode rgb.color_mod = rgb.color_mod + rgb.scale_mode = rgb.scale_mode rgba = render.upload_texture(np.zeros((8, 8, 4), np.uint8), access=tcod.sdl.render.TextureAccess.TARGET) with render.set_render_target(rgba): render.copy(rgb) From a58c4dc1810f41ce9489d222897078c3b0c08f26 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Sat, 26 Jul 2025 04:00:11 -0700 Subject: [PATCH 028/131] Prepare 19.3.0 release. --- CHANGELOG.md | 2 ++ tcod/sdl/render.py | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f00dfd41..30e49b99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version ## [Unreleased] +## [19.3.0] - 2025-07-26 + ### Added - `tcod.sdl.render`: Added `ScaleMode` enum and `Texture.scale_mode` attribute. diff --git a/tcod/sdl/render.py b/tcod/sdl/render.py index c8e29715..f16714a2 100644 --- a/tcod/sdl/render.py +++ b/tcod/sdl/render.py @@ -144,7 +144,7 @@ class BlendMode(enum.IntEnum): class ScaleMode(enum.IntEnum): """Texture scaling modes. - .. versionadded:: unreleased + .. versionadded:: 19.3 """ NEAREST = lib.SDL_SCALEMODE_NEAREST @@ -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:: unreleased + ..versionadded:: 19.3 """ mode = ffi.new("SDL_ScaleMode*") _check(lib.SDL_GetTextureScaleMode(self.p, mode)) From 616d84ccf0c4d1252b5739d4a8e1ccdee6f2a89c Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Sat, 26 Jul 2025 04:21:50 -0700 Subject: [PATCH 029/131] Personalize changelog releases a little I should add small explanations for why a release was made. --- .vscode/settings.json | 1 + CHANGELOG.md | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/.vscode/settings.json b/.vscode/settings.json index 05971cbf..8bb3a0cd 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -466,6 +466,7 @@ "TRIGGERRIGHT", "tris", "truetype", + "tryddle", "typestr", "undoc", "Unifont", diff --git a/CHANGELOG.md b/CHANGELOG.md index 30e49b99..c7c7e89c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,12 +8,17 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version ## [19.3.0] - 2025-07-26 +Thanks to cr0ne for pointing out missing texture scaling options. +These options did not exist in SDL2. + ### Added - `tcod.sdl.render`: Added `ScaleMode` enum and `Texture.scale_mode` attribute. ## [19.2.0] - 2025-07-20 +Thanks to tryddle for demonstrating how bad the current API was with chunked world generation. + ### Added - `tcod.noise.grid` now has the `offset` parameter for easier sampling of noise chunks. From 01a810cb28460a9b7db72317c53d47ccc7a026c0 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Thu, 31 Jul 2025 05:47:05 -0700 Subject: [PATCH 030/131] Remove TDL_ workarounds Also removes unnecessary SDL main stub Public API is unchanged, this is internal cleanup --- tcod/cffi.h | 1 - tcod/libtcodpy.py | 7 +- tcod/tdl.c | 201 ---------------------------------------------- tcod/tdl.h | 54 ------------- 4 files changed, 5 insertions(+), 258 deletions(-) delete mode 100644 tcod/tdl.c delete mode 100644 tcod/tdl.h diff --git a/tcod/cffi.h b/tcod/cffi.h index fc373dac..5f8fa494 100644 --- a/tcod/cffi.h +++ b/tcod/cffi.h @@ -9,4 +9,3 @@ #include "path.h" #include "random.h" #include "tcod.h" -#include "tdl.h" diff --git a/tcod/libtcodpy.py b/tcod/libtcodpy.py index 2bff5397..fdfaeefe 100644 --- a/tcod/libtcodpy.py +++ b/tcod/libtcodpy.py @@ -3584,8 +3584,11 @@ def _unpack_union(type_: int, union: Any) -> Any: # noqa: PLR0911 raise RuntimeError(msg) -def _convert_TCODList(c_list: Any, type_: int) -> Any: - return [_unpack_union(type_, lib.TDL_list_get_union(c_list, i)) for i in range(lib.TCOD_list_size(c_list))] +def _convert_TCODList(c_list: Any, type_: int) -> Any: # noqa: N802 + with ffi.new("TCOD_value_t[]", lib.TCOD_list_size(c_list)) as unions: + for i, union in enumerate(unions): + union.custom = lib.TCOD_list_get(c_list, i) + return [_unpack_union(type_, union) for union in unions] @deprecate("Parser functions have been deprecated.") diff --git a/tcod/tdl.c b/tcod/tdl.c deleted file mode 100644 index c293428f..00000000 --- a/tcod/tdl.c +++ /dev/null @@ -1,201 +0,0 @@ -/* extra functions provided for the python-tdl library */ -#include "tdl.h" - -#include "../libtcod/src/libtcod/wrappers.h" - -void SDL_main(void){}; - -TCOD_value_t TDL_list_get_union(TCOD_list_t l, int idx) { - TCOD_value_t item; - item.custom = TCOD_list_get(l, idx); - return item; -} - -bool TDL_list_get_bool(TCOD_list_t l, int idx) { return TDL_list_get_union(l, idx).b; } - -char TDL_list_get_char(TCOD_list_t l, int idx) { return TDL_list_get_union(l, idx).c; } - -int TDL_list_get_int(TCOD_list_t l, int idx) { return TDL_list_get_union(l, idx).i; } - -float TDL_list_get_float(TCOD_list_t l, int idx) { return TDL_list_get_union(l, idx).f; } - -char* TDL_list_get_string(TCOD_list_t l, int idx) { return TDL_list_get_union(l, idx).s; } - -TCOD_color_t TDL_list_get_color(TCOD_list_t l, int idx) { return TDL_list_get_union(l, idx).col; } - -TCOD_dice_t TDL_list_get_dice(TCOD_list_t l, int idx) { return TDL_list_get_union(l, idx).dice; } - -/* get a TCOD color type from a 0xRRGGBB formatted integer */ -TCOD_color_t TDL_color_from_int(int color) { - TCOD_color_t tcod_color = {(color >> 16) & 0xff, (color >> 8) & 0xff, color & 0xff}; - return tcod_color; -} - -int TDL_color_to_int(TCOD_color_t* color) { return (color->r << 16) | (color->g << 8) | color->b; } - -int* TDL_color_int_to_array(int color) { - static int array[3]; - array[0] = (color >> 16) & 0xff; - array[1] = (color >> 8) & 0xff; - array[2] = color & 0xff; - return array; -} - -int TDL_color_RGB(int r, int g, int b) { return ((r & 0xff) << 16) | ((g & 0xff) << 8) | (b & 0xff); } - -int TDL_color_HSV(float h, float s, float v) { - TCOD_color_t tcod_color = TCOD_color_HSV(h, s, v); - return TDL_color_to_int(&tcod_color); -} - -bool TDL_color_equals(int c1, int c2) { return (c1 == c2); } - -int TDL_color_add(int c1, int c2) { - TCOD_color_t tc1 = TDL_color_from_int(c1); - TCOD_color_t tc2 = TDL_color_from_int(c2); - tc1 = TCOD_color_add(tc1, tc2); - return TDL_color_to_int(&tc1); -} - -int TDL_color_subtract(int c1, int c2) { - TCOD_color_t tc1 = TDL_color_from_int(c1); - TCOD_color_t tc2 = TDL_color_from_int(c2); - tc1 = TCOD_color_subtract(tc1, tc2); - return TDL_color_to_int(&tc1); -} - -int TDL_color_multiply(int c1, int c2) { - TCOD_color_t tc1 = TDL_color_from_int(c1); - TCOD_color_t tc2 = TDL_color_from_int(c2); - tc1 = TCOD_color_multiply(tc1, tc2); - return TDL_color_to_int(&tc1); -} - -int TDL_color_multiply_scalar(int c, float value) { - TCOD_color_t tc = TDL_color_from_int(c); - tc = TCOD_color_multiply_scalar(tc, value); - return TDL_color_to_int(&tc); -} - -int TDL_color_lerp(int c1, int c2, float coef) { - TCOD_color_t tc1 = TDL_color_from_int(c1); - TCOD_color_t tc2 = TDL_color_from_int(c2); - tc1 = TCOD_color_lerp(tc1, tc2, coef); - return TDL_color_to_int(&tc1); -} - -float TDL_color_get_hue(int color) { - TCOD_color_t tcod_color = TDL_color_from_int(color); - return TCOD_color_get_hue(tcod_color); -} -float TDL_color_get_saturation(int color) { - TCOD_color_t tcod_color = TDL_color_from_int(color); - return TCOD_color_get_saturation(tcod_color); -} -float TDL_color_get_value(int color) { - TCOD_color_t tcod_color = TDL_color_from_int(color); - return TCOD_color_get_value(tcod_color); -} -int TDL_color_set_hue(int color, float h) { - TCOD_color_t tcod_color = TDL_color_from_int(color); - TCOD_color_set_hue(&tcod_color, h); - return TDL_color_to_int(&tcod_color); -} -int TDL_color_set_saturation(int color, float h) { - TCOD_color_t tcod_color = TDL_color_from_int(color); - TCOD_color_set_saturation(&tcod_color, h); - return TDL_color_to_int(&tcod_color); -} -int TDL_color_set_value(int color, float h) { - TCOD_color_t tcod_color = TDL_color_from_int(color); - TCOD_color_set_value(&tcod_color, h); - return TDL_color_to_int(&tcod_color); -} -int TDL_color_shift_hue(int color, float hue_shift) { - TCOD_color_t tcod_color = TDL_color_from_int(color); - TCOD_color_shift_hue(&tcod_color, hue_shift); - return TDL_color_to_int(&tcod_color); -} -int TDL_color_scale_HSV(int color, float scoef, float vcoef) { - TCOD_color_t tcod_color = TDL_color_from_int(color); - TCOD_color_scale_HSV(&tcod_color, scoef, vcoef); - return TDL_color_to_int(&tcod_color); -} - -#define TRANSPARENT_BIT 1 -#define WALKABLE_BIT 2 -#define FOV_BIT 4 - -/* set map transparent and walkable flags from a buffer */ -void TDL_map_data_from_buffer(TCOD_map_t map, uint8_t* buffer) { - int width = TCOD_map_get_width(map); - int height = TCOD_map_get_height(map); - int x; - int y; - for (y = 0; y < height; y++) { - for (x = 0; x < width; x++) { - int i = y * width + x; - TCOD_map_set_properties(map, x, y, (buffer[i] & TRANSPARENT_BIT) != 0, (buffer[i] & WALKABLE_BIT) != 0); - } - } -} - -/* get fov from tcod map */ -void TDL_map_fov_to_buffer(TCOD_map_t map, uint8_t* buffer, bool cumulative) { - int width = TCOD_map_get_width(map); - int height = TCOD_map_get_height(map); - int x; - int y; - for (y = 0; y < height; y++) { - for (x = 0; x < width; x++) { - int i = y * width + x; - if (!cumulative) { buffer[i] &= ~FOV_BIT; } - if (TCOD_map_is_in_fov(map, x, y)) { buffer[i] |= FOV_BIT; } - } - } -} - -/* set functions are called conditionally for ch/fg/bg (-1 is ignored) - colors are converted to TCOD_color_t types in C and is much faster than in - Python. - Also Python indexing is used, negative x/y will index to (width-x, etc.) */ -int TDL_console_put_char_ex(TCOD_console_t console, int x, int y, int ch, int fg, int bg, TCOD_bkgnd_flag_t blend) { - int width = TCOD_console_get_width(console); - int height = TCOD_console_get_height(console); - TCOD_color_t color; - - if (x < -width || x >= width || y < -height || y >= height) { return -1; /* outside of console */ } - - /* normalize x, y */ - if (x < 0) { x += width; }; - if (y < 0) { y += height; }; - - if (ch != -1) { TCOD_console_set_char(console, x, y, ch); } - if (fg != -1) { - color = TDL_color_from_int(fg); - TCOD_console_set_char_foreground(console, x, y, color); - } - if (bg != -1) { - color = TDL_color_from_int(bg); - TCOD_console_set_char_background(console, x, y, color, blend); - } - return 0; -} - -int TDL_console_get_bg(TCOD_console_t console, int x, int y) { - TCOD_color_t tcod_color = TCOD_console_get_char_background(console, x, y); - return TDL_color_to_int(&tcod_color); -} - -int TDL_console_get_fg(TCOD_console_t console, int x, int y) { - TCOD_color_t tcod_color = TCOD_console_get_char_foreground(console, x, y); - return TDL_color_to_int(&tcod_color); -} - -void TDL_console_set_bg(TCOD_console_t console, int x, int y, int color, TCOD_bkgnd_flag_t flag) { - TCOD_console_set_char_background(console, x, y, TDL_color_from_int(color), flag); -} - -void TDL_console_set_fg(TCOD_console_t console, int x, int y, int color) { - TCOD_console_set_char_foreground(console, x, y, TDL_color_from_int(color)); -} diff --git a/tcod/tdl.h b/tcod/tdl.h deleted file mode 100644 index 71a7492e..00000000 --- a/tcod/tdl.h +++ /dev/null @@ -1,54 +0,0 @@ -#ifndef PYTHON_TCOD_TDL_H_ -#define PYTHON_TCOD_TDL_H_ - -#include "../libtcod/src/libtcod/libtcod.h" - -/* TDL FUNCTIONS ---------------------------------------------------------- */ - -TCOD_value_t TDL_list_get_union(TCOD_list_t l, int idx); -bool TDL_list_get_bool(TCOD_list_t l, int idx); -char TDL_list_get_char(TCOD_list_t l, int idx); -int TDL_list_get_int(TCOD_list_t l, int idx); -float TDL_list_get_float(TCOD_list_t l, int idx); -char* TDL_list_get_string(TCOD_list_t l, int idx); -TCOD_color_t TDL_list_get_color(TCOD_list_t l, int idx); -TCOD_dice_t TDL_list_get_dice(TCOD_list_t l, int idx); -/*bool (*TDL_parser_new_property_func)(const char *propname, TCOD_value_type_t - * type, TCOD_value_t *value);*/ - -/* color functions modified to use integers instead of structs */ -TCOD_color_t TDL_color_from_int(int color); -int TDL_color_to_int(TCOD_color_t* color); -int* TDL_color_int_to_array(int color); -int TDL_color_RGB(int r, int g, int b); -int TDL_color_HSV(float h, float s, float v); -bool TDL_color_equals(int c1, int c2); -int TDL_color_add(int c1, int c2); -int TDL_color_subtract(int c1, int c2); -int TDL_color_multiply(int c1, int c2); -int TDL_color_multiply_scalar(int c, float value); -int TDL_color_lerp(int c1, int c2, float coef); -float TDL_color_get_hue(int color); -float TDL_color_get_saturation(int color); -float TDL_color_get_value(int color); -int TDL_color_set_hue(int color, float h); -int TDL_color_set_saturation(int color, float h); -int TDL_color_set_value(int color, float h); -int TDL_color_shift_hue(int color, float hue_shift); -int TDL_color_scale_HSV(int color, float scoef, float vcoef); - -/* map data functions using a bitmap of: - * 1 = is_transparant - * 2 = is_walkable - * 4 = in_fov - */ -void TDL_map_data_from_buffer(TCOD_map_t map, uint8_t* buffer); -void TDL_map_fov_to_buffer(TCOD_map_t map, uint8_t* buffer, bool cumulative); - -int TDL_console_put_char_ex(TCOD_console_t console, int x, int y, int ch, int fg, int bg, TCOD_bkgnd_flag_t flag); -int TDL_console_get_bg(TCOD_console_t console, int x, int y); -int TDL_console_get_fg(TCOD_console_t console, int x, int y); -void TDL_console_set_bg(TCOD_console_t console, int x, int y, int color, TCOD_bkgnd_flag_t flag); -void TDL_console_set_fg(TCOD_console_t console, int x, int y, int color); - -#endif /* PYTHON_TCOD_TDL_H_ */ From beffd5b63b5f60c7eea8874296376dc21e76527d Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Fri, 1 Aug 2025 20:01:04 -0700 Subject: [PATCH 031/131] Avoid deprecated code within Context.convert_event Promote mouse attribute deprecations to decorators --- CHANGELOG.md | 4 ++++ tcod/event.py | 55 ++++++++++++++++++++------------------------------- 2 files changed, 25 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7c7e89c..781fff5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version ## [Unreleased] +### Fixed + +- Silenced internal deprecation warning within `Context.convert_event`. + ## [19.3.0] - 2025-07-26 Thanks to cr0ne for pointing out missing texture scaling options. diff --git a/tcod/event.py b/tcod/event.py index c10f90b8..ca8195d7 100644 --- a/tcod/event.py +++ b/tcod/event.py @@ -418,12 +418,8 @@ def __init__( self.state = state @property + @deprecated("The mouse.pixel attribute is deprecated. Use mouse.position instead.") def pixel(self) -> Point: - warnings.warn( - "The mouse.pixel attribute is deprecated. Use mouse.position instead.", - DeprecationWarning, - stacklevel=2, - ) return self.position @pixel.setter @@ -431,26 +427,29 @@ def pixel(self, value: Point) -> None: self.position = value @property + @deprecated( + "The mouse.tile attribute is deprecated." + " Use mouse.position of the event returned by context.convert_event instead." + ) def tile(self) -> Point: - warnings.warn( - "The mouse.tile attribute is deprecated. Use mouse.position of the event returned by context.convert_event instead.", - DeprecationWarning, - stacklevel=2, - ) 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." + ) 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)!r}, state={MouseButtonMask(self.state)})" + 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, + *(self._tile or (0, 0)), MouseButtonMask(self.state), ) @@ -492,41 +491,29 @@ def __init__( self._tile_motion = Point(*tile_motion) if tile_motion is not None else None @property + @deprecated("The mouse.pixel_motion attribute is deprecated. Use mouse.motion instead.") def pixel_motion(self) -> Point: - warnings.warn( - "The mouse.pixel_motion attribute is deprecated. Use mouse.motion instead.", - DeprecationWarning, - stacklevel=2, - ) 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: - warnings.warn( - "The mouse.pixel_motion attribute is deprecated. Use mouse.motion instead.", - DeprecationWarning, - stacklevel=2, - ) self.motion = value @property + @deprecated( + "The mouse.tile_motion attribute is deprecated." + " Use mouse.motion of the event returned by context.convert_event instead." + ) def tile_motion(self) -> Point: - warnings.warn( - "The mouse.tile_motion attribute is deprecated." - " Use mouse.motion of the event returned by context.convert_event instead.", - DeprecationWarning, - stacklevel=2, - ) 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." + ) def tile_motion(self, xy: tuple[float, float]) -> None: - warnings.warn( - "The mouse.tile_motion attribute is deprecated." - " Use mouse.motion of the event returned by context.convert_event instead.", - DeprecationWarning, - stacklevel=2, - ) self._tile_motion = Point(*xy) @classmethod From 6ed474aad443a13cd47d63a230e3563b94e0d5ff Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Fri, 1 Aug 2025 20:18:12 -0700 Subject: [PATCH 032/131] Move Map deprecation warning to decorator --- tcod/map.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/tcod/map.py b/tcod/map.py index 45b4561a..d3ec5015 100644 --- a/tcod/map.py +++ b/tcod/map.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Any, Literal import numpy as np +from typing_extensions import deprecated import tcod._internal import tcod.constants @@ -15,6 +16,7 @@ from numpy.typing import ArrayLike, NDArray +@deprecated("This class may perform poorly and is no longer needed.") class Map: """A map containing libtcod attributes. @@ -78,11 +80,6 @@ def __init__( order: Literal["C", "F"] = "C", ) -> None: """Initialize the map.""" - warnings.warn( - "This class may perform poorly and is no longer needed.", - DeprecationWarning, - stacklevel=2, - ) self.width = width self.height = height self._order = tcod._internal.verify_order(order) From e9559a932d422dde4e05ca937ee20287785e054a Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Fri, 1 Aug 2025 20:29:38 -0700 Subject: [PATCH 033/131] Update cibuildwheel --- .github/workflows/python-package.yml | 6 +++--- pyproject.toml | 4 ---- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 0afe55d0..6d43a222 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -236,7 +236,7 @@ jobs: - name: Checkout submodules run: git submodule update --init --recursive --depth 1 - name: Build wheels - uses: pypa/cibuildwheel@v3.0.0 + uses: pypa/cibuildwheel@v3.1.3 env: CIBW_BUILD: ${{ matrix.build }} CIBW_ARCHS_LINUX: ${{ matrix.arch }} @@ -299,7 +299,7 @@ jobs: # Downloads SDL for the later step. run: python build_sdl.py - name: Build wheels - uses: pypa/cibuildwheel@v3.0.0 + uses: pypa/cibuildwheel@v3.1.3 env: CIBW_BUILD: ${{ matrix.python }} CIBW_ARCHS_MACOS: x86_64 arm64 universal2 @@ -335,7 +335,7 @@ jobs: install-linux-dependencies: true build-type: "Debug" version: "3.2.4" # Should be equal or less than the version used by Emscripten - - uses: pypa/cibuildwheel@v3.0.0 + - uses: pypa/cibuildwheel@v3.1.3 env: CIBW_BUILD: cp313-pyodide_wasm32 CIBW_PLATFORM: pyodide diff --git a/pyproject.toml b/pyproject.toml index 46a9285b..ea098b4e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -102,10 +102,6 @@ filterwarnings = [ [tool.cibuildwheel] # https://cibuildwheel.pypa.io/en/stable/options/ enable = ["pypy", "pyodide-prerelease"] -[tool.cibuildwheel.pyodide] -dependency-versions = "latest" # Until pyodide-version is stable on cibuildwheel -pyodide-version = "0.28.0a3" - [tool.mypy] files = ["."] python_version = "3.10" From 1af4f65aef6c9c12e4f4628c474be7ccf9b6848d Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Fri, 1 Aug 2025 20:46:08 -0700 Subject: [PATCH 034/131] Prepare 19.3.1 release. --- .vscode/settings.json | 1 + CHANGELOG.md | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 8bb3a0cd..c45bb5e5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -218,6 +218,7 @@ "jhat", "jice", "jieba", + "jmccardle", "JOYAXISMOTION", "JOYBALLMOTION", "JOYBUTTONDOWN", diff --git a/CHANGELOG.md b/CHANGELOG.md index 781fff5e..f25ab027 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,14 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version ## [Unreleased] +## [19.3.1] - 2025-08-02 + +Solved a deprecation warning which was internal to tcod and no doubt annoyed many devs. +Thanks to jmccardle for forcing me to resolve this. + ### Fixed -- Silenced internal deprecation warning within `Context.convert_event`. +- Silenced internal deprecation warnings within `Context.convert_event`. ## [19.3.0] - 2025-07-26 From cb573c245916112487c732ee6ae7537f38970b8a Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Sat, 2 Aug 2025 10:57:18 -0700 Subject: [PATCH 035/131] Fix angle brackets in MouseButtonEvent and MouseWheel Fixes #165 --- CHANGELOG.md | 4 ++++ tcod/event.py | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f25ab027..360688f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version ## [Unreleased] +### Fixed + +- Corrected some inconsistent angle brackets in the `__str__` of Event subclasses. #165 + ## [19.3.1] - 2025-08-02 Solved a deprecation warning which was internal to tcod and no doubt annoyed many devs. diff --git a/tcod/event.py b/tcod/event.py index ca8195d7..946c7056 100644 --- a/tcod/event.py +++ b/tcod/event.py @@ -599,7 +599,7 @@ 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, @@ -650,7 +650,7 @@ def __repr__(self) -> str: ) def __str__(self) -> str: - return "<%s, x=%i, y=%i, flipped=%r)" % ( + return "<%s, x=%i, y=%i, flipped=%r>" % ( super().__str__().strip("<>"), self.x, self.y, From 43038b2653204449da8b29d7eb4c6a7e1a384329 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Wed, 6 Aug 2025 15:19:21 -0700 Subject: [PATCH 036/131] Move self import to doctests --- tcod/event.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tcod/event.py b/tcod/event.py index 946c7056..08330dae 100644 --- a/tcod/event.py +++ b/tcod/event.py @@ -90,7 +90,6 @@ import numpy as np from typing_extensions import deprecated -import tcod.event import tcod.event_constants import tcod.sdl.joystick import tcod.sdl.sys @@ -194,6 +193,7 @@ class Modifier(enum.IntFlag): Example:: + >>> import tcod.event >>> mod = tcod.event.Modifier(4098) >>> mod & tcod.event.Modifier.SHIFT # Check if any shift key is held. @@ -2738,6 +2738,7 @@ def label(self) -> str: Example:: + >>> import tcod.event >>> tcod.event.KeySym.F1.label 'F1' >>> tcod.event.KeySym.BACKSPACE.label From 4822c2bbbaebea889d0b6f99a073d151a141be9b Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Wed, 6 Aug 2025 15:17:13 -0700 Subject: [PATCH 037/131] Resolve window event regressions --- CHANGELOG.md | 6 +++++ docs/tcod/getting-started.rst | 2 +- examples/eventget.py | 2 +- examples/ttf.py | 2 +- tcod/event.py | 50 +++++++++++++++++++++-------------- 5 files changed, 39 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 360688f2..f76b4a75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,15 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version ## [Unreleased] +### Changed + +- Checking "WindowSizeChanged" was not valid since SDL 3 and was also not valid in previous examples. + You must no longer check the type of the `WindowResized` event. + ### Fixed - Corrected some inconsistent angle brackets in the `__str__` of Event subclasses. #165 +- Fix regression with window events causing them to be `Unknown` and uncheckable. ## [19.3.1] - 2025-08-02 diff --git a/docs/tcod/getting-started.rst b/docs/tcod/getting-started.rst index add5d9f0..aabb4f7f 100644 --- a/docs/tcod/getting-started.rst +++ b/docs/tcod/getting-started.rst @@ -114,7 +114,7 @@ clearing the console every frame and replacing it only on resizing the window. match event: case tcod.event.Quit(): raise SystemExit - case tcod.event.WindowResized(type="WindowSizeChanged"): + case tcod.event.WindowResized(width=width, height=height): # Size in pixels pass # The next call to context.new_console may return a different size. diff --git a/examples/eventget.py b/examples/eventget.py index 8610fae1..7f4f9cb3 100755 --- a/examples/eventget.py +++ b/examples/eventget.py @@ -42,7 +42,7 @@ def main() -> None: print(repr(event)) if isinstance(event, tcod.event.Quit): raise SystemExit - if isinstance(event, tcod.event.WindowResized) and event.type == "WindowSizeChanged": + if isinstance(event, tcod.event.WindowResized) and event.type == "WindowResized": console = context.new_console() if isinstance(event, tcod.event.ControllerDevice): if event.type == "CONTROLLERDEVICEADDED": diff --git a/examples/ttf.py b/examples/ttf.py index fb1e35f6..4a6041a8 100755 --- a/examples/ttf.py +++ b/examples/ttf.py @@ -84,7 +84,7 @@ def main() -> None: for event in tcod.event.wait(): if isinstance(event, tcod.event.Quit): raise SystemExit - if isinstance(event, tcod.event.WindowResized) and event.type == "WindowSizeChanged": + if isinstance(event, tcod.event.WindowResized): # Resize the Tileset to match the new screen size. context.change_tileset( load_ttf( diff --git a/tcod/event.py b/tcod/event.py index 08330dae..61aef24e 100644 --- a/tcod/event.py +++ b/tcod/event.py @@ -83,6 +83,7 @@ from __future__ import annotations import enum +import functools import warnings from collections.abc import Callable, Iterator, Mapping from typing import TYPE_CHECKING, Any, Final, Generic, Literal, NamedTuple, TypeVar @@ -698,7 +699,6 @@ class WindowEvent(Event): "WindowExposed", "WindowMoved", "WindowResized", - "WindowSizeChanged", "WindowMinimized", "WindowMaximized", "WindowRestored", @@ -715,13 +715,13 @@ class WindowEvent(Event): @classmethod def from_sdl_event(cls, sdl_event: Any) -> WindowEvent | Undefined: - if sdl_event.window.event not in cls.__WINDOW_TYPES: + if sdl_event.type not in cls._WINDOW_TYPES: return Undefined.from_sdl_event(sdl_event) - event_type: Final = cls.__WINDOW_TYPES[sdl_event.window.event] + event_type: Final = cls._WINDOW_TYPES[sdl_event.type] self: WindowEvent - if sdl_event.window.event == lib.SDL_EVENT_WINDOW_MOVED: + if sdl_event.type == lib.SDL_EVENT_WINDOW_MOVED: self = WindowMoved(sdl_event.window.data1, sdl_event.window.data2) - elif sdl_event.window.event in ( + elif sdl_event.type in ( lib.SDL_EVENT_WINDOW_RESIZED, lib.SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED, ): @@ -734,7 +734,7 @@ def from_sdl_event(cls, sdl_event: Any) -> WindowEvent | Undefined: def __repr__(self) -> str: return f"tcod.event.{self.__class__.__name__}(type={self.type!r})" - __WINDOW_TYPES: Final = { + _WINDOW_TYPES: Final = { lib.SDL_EVENT_WINDOW_SHOWN: "WindowShown", lib.SDL_EVENT_WINDOW_HIDDEN: "WindowHidden", lib.SDL_EVENT_WINDOW_EXPOSED: "WindowExposed", @@ -785,10 +785,13 @@ class WindowResized(WindowEvent): Attributes: width (int): The current width of the window. height (int): The current height of the window. + + .. versionchanged:: Unreleased + Removed "WindowSizeChanged" type. """ - type: Final[Literal["WindowResized", "WindowSizeChanged"]] # type: ignore[misc] - """WindowResized" or "WindowSizeChanged""" + type: Final[Literal["WindowResized"]] # type: ignore[misc] + """Always "WindowResized".""" def __init__(self, type: str, width: int, height: int) -> None: super().__init__(type) @@ -1130,6 +1133,15 @@ def from_sdl_event(cls, sdl_event: Any) -> ControllerDevice: return cls(type, sdl_event.cdevice.which) +@functools.cache +def _find_event_name(index: int, /) -> str: + """Return the SDL event name for this index.""" + for attr in dir(lib): + if attr.startswith("SDL_EVENT_") and getattr(lib, attr) == index: + return attr + return "???" + + class Undefined(Event): """This class is a place holder for SDL events without their own tcod.event class.""" @@ -1144,9 +1156,12 @@ def from_sdl_event(cls, sdl_event: Any) -> Undefined: def __str__(self) -> str: if self.sdl_event: - return "" % self.sdl_event.type + return f"" return "" + def __repr__(self) -> str: + return self.__str__() + _SDL_TO_CLASS_TABLE: dict[int, type[Event]] = { lib.SDL_EVENT_QUIT: Quit, @@ -1157,7 +1172,6 @@ def __str__(self) -> str: lib.SDL_EVENT_MOUSE_BUTTON_UP: MouseButtonUp, lib.SDL_EVENT_MOUSE_WHEEL: MouseWheel, lib.SDL_EVENT_TEXT_INPUT: TextInput, - # lib.SDL_EVENT_WINDOW_EVENT: WindowEvent, lib.SDL_EVENT_JOYSTICK_AXIS_MOTION: JoystickAxis, lib.SDL_EVENT_JOYSTICK_BALL_MOTION: JoystickBall, lib.SDL_EVENT_JOYSTICK_HAT_MOTION: JoystickHat, @@ -1176,9 +1190,11 @@ def __str__(self) -> str: def _parse_event(sdl_event: Any) -> Event: """Convert a C SDL_Event* type into a tcod Event sub-class.""" - if sdl_event.type not in _SDL_TO_CLASS_TABLE: - 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 _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) def get() -> Iterator[Any]: @@ -1198,10 +1214,7 @@ def get() -> Iterator[Any]: return sdl_event = ffi.new("SDL_Event*") while lib.SDL_PollEvent(sdl_event): - if sdl_event.type in _SDL_TO_CLASS_TABLE: - yield _SDL_TO_CLASS_TABLE[sdl_event.type].from_sdl_event(sdl_event) - else: - yield Undefined.from_sdl_event(sdl_event) + yield _parse_event(sdl_event) def wait(timeout: float | None = None) -> Iterator[Any]: @@ -1425,9 +1438,6 @@ def ev_windowmoved(self, event: tcod.event.WindowMoved, /) -> T | None: def ev_windowresized(self, event: tcod.event.WindowResized, /) -> T | None: """Called when the window is resized.""" - def ev_windowsizechanged(self, event: tcod.event.WindowResized, /) -> T | None: - """Called when the system or user changes the size of the window.""" - def ev_windowminimized(self, event: tcod.event.WindowEvent, /) -> T | None: """Called when the window is minimized.""" From 33a24b65449eb61c7905d0181e19c0646a233494 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Wed, 6 Aug 2025 15:41:39 -0700 Subject: [PATCH 038/131] Prepare 19.4.0 release. --- CHANGELOG.md | 4 ++++ tcod/event.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f76b4a75..bd9e618d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version ## [Unreleased] +## [19.4.0] - 2025-08-06 + ### Changed - Checking "WindowSizeChanged" was not valid since SDL 3 and was also not valid in previous examples. @@ -101,6 +103,8 @@ Be sure to run [Mypy](https://mypy.readthedocs.io/en/stable/getting_started.html - `WindowFlags.FULLSCREEN_DESKTOP` is now just `WindowFlags.FULLSCREEN` - `tcod.sdl.render.Renderer.integer_scaling` removed. - Removed `callback`, `spec`, `queued_samples`, `queue_audio`, and `dequeue_audio` attributes from `tcod.sdl.audio.AudioDevice`. +- `tcod.event.WindowResized`: `type="WindowSizeChanged"` removed and must no longer be checked for. + `EventDispatch.ev_windowsizechanged` is no longer called. ### Fixed diff --git a/tcod/event.py b/tcod/event.py index 61aef24e..5a2bceb8 100644 --- a/tcod/event.py +++ b/tcod/event.py @@ -786,7 +786,7 @@ class WindowResized(WindowEvent): width (int): The current width of the window. height (int): The current height of the window. - .. versionchanged:: Unreleased + .. versionchanged:: 19.4 Removed "WindowSizeChanged" type. """ From 1864c16014dd374ed51aeddb6554d8925555817f Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Tue, 19 Aug 2025 22:01:52 -0700 Subject: [PATCH 039/131] Update cibuildwheel --- .github/workflows/python-package.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 6d43a222..a9847a08 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -236,7 +236,7 @@ jobs: - name: Checkout submodules run: git submodule update --init --recursive --depth 1 - name: Build wheels - uses: pypa/cibuildwheel@v3.1.3 + uses: pypa/cibuildwheel@v3.1.4 env: CIBW_BUILD: ${{ matrix.build }} CIBW_ARCHS_LINUX: ${{ matrix.arch }} @@ -299,7 +299,7 @@ jobs: # Downloads SDL for the later step. run: python build_sdl.py - name: Build wheels - uses: pypa/cibuildwheel@v3.1.3 + uses: pypa/cibuildwheel@v3.1.4 env: CIBW_BUILD: ${{ matrix.python }} CIBW_ARCHS_MACOS: x86_64 arm64 universal2 @@ -335,7 +335,7 @@ jobs: install-linux-dependencies: true build-type: "Debug" version: "3.2.4" # Should be equal or less than the version used by Emscripten - - uses: pypa/cibuildwheel@v3.1.3 + - uses: pypa/cibuildwheel@v3.1.4 env: CIBW_BUILD: cp313-pyodide_wasm32 CIBW_PLATFORM: pyodide From dd001c87abc473a5908d35db9959a41aa73c7892 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Wed, 27 Aug 2025 04:30:49 -0700 Subject: [PATCH 040/131] Fix dangling pointer with Pathfinder._travel_p Storing these CFFI pointers was a bad idea, it's better to drop them and then recreate them as they are needed. --- CHANGELOG.md | 4 ++++ tcod/path.py | 31 ++++++++++++++++++++++--------- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd9e618d..d7945eec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version ## [Unreleased] +### Fixed + +- Fixed dangling pointer in `Pathfinder.clear` method. + ## [19.4.0] - 2025-08-06 ### Changed diff --git a/tcod/path.py b/tcod/path.py index f16c0210..a1efc249 100644 --- a/tcod/path.py +++ b/tcod/path.py @@ -1002,8 +1002,8 @@ def _resolve(self, pathfinder: Pathfinder) -> None: _check( lib.path_compute( pathfinder._frontier_p, - pathfinder._distance_p, - pathfinder._travel_p, + _export(pathfinder._distance), + _export(pathfinder._travel), len(rules), rules, pathfinder._heuristic_p, @@ -1110,8 +1110,6 @@ def __init__(self, graph: CustomGraph | SimpleGraph) -> None: self._frontier_p = ffi.gc(lib.TCOD_frontier_new(self._graph._ndim), lib.TCOD_frontier_delete) self._distance = maxarray(self._graph._shape_c) self._travel = _world_array(self._graph._shape_c) - self._distance_p = _export(self._distance) - self._travel_p = _export(self._travel) self._heuristic: tuple[int, int, int, int, tuple[int, ...]] | None = None self._heuristic_p: Any = ffi.NULL @@ -1180,8 +1178,22 @@ def traversal(self) -> NDArray[Any]: def clear(self) -> None: """Reset the pathfinder to its initial state. - This sets all values on the :any:`distance` array to their maximum - value. + This sets all values on the :any:`distance` array to their maximum value. + + Example:: + + >>> import tcod.path + >>> graph = tcod.path.SimpleGraph( + ... cost=np.ones((5, 5), np.int8), cardinal=2, diagonal=3, + ... ) + >>> pf = tcod.path.Pathfinder(graph) + >>> pf.add_root((0, 0)) + >>> pf.path_to((2, 2)).tolist() + [[0, 0], [1, 1], [2, 2]] + >>> pf.clear() # Reset Pathfinder to its initial state + >>> pf.add_root((0, 2)) + >>> pf.path_to((2, 2)).tolist() + [[0, 2], [1, 2], [2, 2]] """ self._distance[...] = np.iinfo(self._distance.dtype).max self._travel = _world_array(self._graph._shape_c) @@ -1237,7 +1249,7 @@ def rebuild_frontier(self) -> None: """ lib.TCOD_frontier_clear(self._frontier_p) self._update_heuristic(None) - _check(lib.rebuild_frontier_from_distance(self._frontier_p, self._distance_p)) + _check(lib.rebuild_frontier_from_distance(self._frontier_p, _export(self._distance))) def resolve(self, goal: tuple[int, ...] | None = None) -> None: """Manually run the pathfinder algorithm. @@ -1341,12 +1353,13 @@ def path_from(self, index: tuple[int, ...]) -> NDArray[np.intc]: self.resolve(index) if self._order == "F": # Convert to ij indexing order. index = index[::-1] - length = _check(lib.get_travel_path(self._graph._ndim, self._travel_p, index, ffi.NULL)) + _travel_p = _export(self._travel) + length = _check(lib.get_travel_path(self._graph._ndim, _travel_p, index, ffi.NULL)) path: np.ndarray[Any, np.dtype[np.intc]] = np.ndarray((length, self._graph._ndim), dtype=np.intc) _check( lib.get_travel_path( self._graph._ndim, - self._travel_p, + _travel_p, index, ffi.from_buffer("int*", path), ) From 254ac30c47e0b6eace332cd9400dd49377fc3065 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Wed, 27 Aug 2025 04:51:31 -0700 Subject: [PATCH 041/131] Fix missing for-loop increment in C frontier updater --- CHANGELOG.md | 1 + tcod/path.c | 2 +- tcod/path.py | 14 ++++++++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7945eec..c6c34c61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version ### Fixed - Fixed dangling pointer in `Pathfinder.clear` method. +- Fixed hang in `Pathfinder.rebuild_frontier` method. ## [19.4.0] - 2025-08-06 diff --git a/tcod/path.c b/tcod/path.c index 61a09260..8f4f2fc6 100644 --- a/tcod/path.c +++ b/tcod/path.c @@ -454,7 +454,7 @@ static int update_frontier_from_distance_iterator( int dist = get_array_int(dist_map, dimension, index); return TCOD_frontier_push(frontier, index, dist, dist); } - for (int i = 0; i < dist_map->shape[dimension];) { + for (int i = 0; i < dist_map->shape[dimension]; ++i) { index[dimension] = i; int err = update_frontier_from_distance_iterator(frontier, dist_map, dimension + 1, index); if (err) { return err; } diff --git a/tcod/path.py b/tcod/path.py index a1efc249..02db86ef 100644 --- a/tcod/path.py +++ b/tcod/path.py @@ -1246,6 +1246,20 @@ def rebuild_frontier(self) -> None: After you are finished editing :any:`distance` you must call this function before calling :any:`resolve` or any function which calls :any:`resolve` implicitly such as :any:`path_from` or :any:`path_to`. + + Example:: + + >>> import tcod.path + >>> graph = tcod.path.SimpleGraph( + ... cost=np.ones((5, 5), np.int8), cardinal=2, diagonal=3, + ... ) + >>> pf = tcod.path.Pathfinder(graph) + >>> pf.distance[:, 0] = 0 # Set roots along entire left edge + >>> pf.rebuild_frontier() + >>> pf.path_to((0, 2)).tolist() # Finds best path from [:, 0] + [[0, 0], [0, 1], [0, 2]] + >>> pf.path_to((4, 2)).tolist() + [[4, 0], [4, 1], [4, 2]] """ lib.TCOD_frontier_clear(self._frontier_p) self._update_heuristic(None) From 30f7834414e6190747c019ef9e800a7c5d657f41 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Wed, 27 Aug 2025 05:05:30 -0700 Subject: [PATCH 042/131] Prepare 19.4.1 release. --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6c34c61..ca8f048b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version ## [Unreleased] +## [19.4.1] - 2025-08-27 + ### Fixed - Fixed dangling pointer in `Pathfinder.clear` method. From 9368809746cd00cd0eed2280c0fd122086957e40 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Wed, 27 Aug 2025 06:01:56 -0700 Subject: [PATCH 043/131] Remove PyPy x86 builds This is creating amd64 wheels so I do not trust it There are issues with ZIP uploads on PyPI which might be caused by this but I'm unsure. --- .github/workflows/python-package.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index a9847a08..af046082 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -103,9 +103,6 @@ jobs: - os: "windows-latest" python-version: "3.10" architecture: "x86" - - os: "windows-latest" - python-version: "pypy-3.10" - architecture: "x86" fail-fast: false steps: From e8c615ad4757af1ece1c22efd6fc1571968d9ce3 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Fri, 5 Sep 2025 04:54:31 -0700 Subject: [PATCH 044/131] Update to libtcod 2.2.1 --- CHANGELOG.md | 8 +++ build_libtcod.py | 1 + libtcod | 2 +- tcod/_libtcod.pyi | 138 +--------------------------------------------- 4 files changed, 11 insertions(+), 138 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca8f048b..015ea936 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.1. + +### Fixed + +- `SDL_RENDER_SCALE_QUALITY` is now respected again since the change to SDL3. + ## [19.4.1] - 2025-08-27 ### Fixed diff --git a/build_libtcod.py b/build_libtcod.py index c7b0d577..73d86ea3 100755 --- a/build_libtcod.py +++ b/build_libtcod.py @@ -43,6 +43,7 @@ r"TCODLIB_C?API|TCOD_PUBLIC|TCOD_NODISCARD|TCOD_DEPRECATED_NOMESSAGE|TCOD_DEPRECATED_ENUM" r"|(TCOD_DEPRECATED\(\".*?\"\))" r"|(TCOD_DEPRECATED|TCODLIB_FORMAT)\([^)]*\)|__restrict" + r"|TCODLIB_(BEGIN|END)_IGNORE_DEPRECATIONS" ) RE_VAFUNC = re.compile(r"^[^;]*\([^;]*va_list.*\);", re.MULTILINE) RE_INLINE = re.compile(r"(^.*?inline.*?\(.*?\))\s*\{.*?\}$", re.DOTALL | re.MULTILINE) diff --git a/libtcod b/libtcod index ffa44720..ca8efa70 160000 --- a/libtcod +++ b/libtcod @@ -1 +1 @@ -Subproject commit ffa447202e9b354691386e91f1288fd69dc1eaba +Subproject commit ca8efa70c50175c6d24336f9bc84cf995f4dbef4 diff --git a/tcod/_libtcod.pyi b/tcod/_libtcod.pyi index 4b8ed0fe..04fba29b 100644 --- a/tcod/_libtcod.pyi +++ b/tcod/_libtcod.pyi @@ -7528,142 +7528,6 @@ class _lib: def TCOD_zip_skip_bytes(zip: Any, nbBytes: Any, /) -> None: """void TCOD_zip_skip_bytes(TCOD_zip_t zip, uint32_t nbBytes)""" - @staticmethod - def TDL_color_HSV(h: float, s: float, v: float, /) -> int: - """int TDL_color_HSV(float h, float s, float v)""" - - @staticmethod - def TDL_color_RGB(r: int, g: int, b: int, /) -> int: - """int TDL_color_RGB(int r, int g, int b)""" - - @staticmethod - def TDL_color_add(c1: int, c2: int, /) -> int: - """int TDL_color_add(int c1, int c2)""" - - @staticmethod - def TDL_color_equals(c1: int, c2: int, /) -> bool: - """bool TDL_color_equals(int c1, int c2)""" - - @staticmethod - def TDL_color_from_int(color: int, /) -> Any: - """TCOD_color_t TDL_color_from_int(int color)""" - - @staticmethod - def TDL_color_get_hue(color: int, /) -> float: - """float TDL_color_get_hue(int color)""" - - @staticmethod - def TDL_color_get_saturation(color: int, /) -> float: - """float TDL_color_get_saturation(int color)""" - - @staticmethod - def TDL_color_get_value(color: int, /) -> float: - """float TDL_color_get_value(int color)""" - - @staticmethod - def TDL_color_int_to_array(color: int, /) -> Any: - """int *TDL_color_int_to_array(int color)""" - - @staticmethod - def TDL_color_lerp(c1: int, c2: int, coef: float, /) -> int: - """int TDL_color_lerp(int c1, int c2, float coef)""" - - @staticmethod - def TDL_color_multiply(c1: int, c2: int, /) -> int: - """int TDL_color_multiply(int c1, int c2)""" - - @staticmethod - def TDL_color_multiply_scalar(c: int, value: float, /) -> int: - """int TDL_color_multiply_scalar(int c, float value)""" - - @staticmethod - def TDL_color_scale_HSV(color: int, scoef: float, vcoef: float, /) -> int: - """int TDL_color_scale_HSV(int color, float scoef, float vcoef)""" - - @staticmethod - def TDL_color_set_hue(color: int, h: float, /) -> int: - """int TDL_color_set_hue(int color, float h)""" - - @staticmethod - def TDL_color_set_saturation(color: int, h: float, /) -> int: - """int TDL_color_set_saturation(int color, float h)""" - - @staticmethod - def TDL_color_set_value(color: int, h: float, /) -> int: - """int TDL_color_set_value(int color, float h)""" - - @staticmethod - def TDL_color_shift_hue(color: int, hue_shift: float, /) -> int: - """int TDL_color_shift_hue(int color, float hue_shift)""" - - @staticmethod - def TDL_color_subtract(c1: int, c2: int, /) -> int: - """int TDL_color_subtract(int c1, int c2)""" - - @staticmethod - def TDL_color_to_int(color: Any, /) -> int: - """int TDL_color_to_int(TCOD_color_t *color)""" - - @staticmethod - def TDL_console_get_bg(console: Any, x: int, y: int, /) -> int: - """int TDL_console_get_bg(TCOD_console_t console, int x, int y)""" - - @staticmethod - def TDL_console_get_fg(console: Any, x: int, y: int, /) -> int: - """int TDL_console_get_fg(TCOD_console_t console, int x, int y)""" - - @staticmethod - def TDL_console_put_char_ex(console: Any, x: int, y: int, ch: int, fg: int, bg: int, flag: Any, /) -> int: - """int TDL_console_put_char_ex(TCOD_console_t console, int x, int y, int ch, int fg, int bg, TCOD_bkgnd_flag_t flag)""" - - @staticmethod - def TDL_console_set_bg(console: Any, x: int, y: int, color: int, flag: Any, /) -> None: - """void TDL_console_set_bg(TCOD_console_t console, int x, int y, int color, TCOD_bkgnd_flag_t flag)""" - - @staticmethod - def TDL_console_set_fg(console: Any, x: int, y: int, color: int, /) -> None: - """void TDL_console_set_fg(TCOD_console_t console, int x, int y, int color)""" - - @staticmethod - def TDL_list_get_bool(l: Any, idx: int, /) -> bool: - """bool TDL_list_get_bool(TCOD_list_t l, int idx)""" - - @staticmethod - def TDL_list_get_char(l: Any, idx: int, /) -> Any: - """char TDL_list_get_char(TCOD_list_t l, int idx)""" - - @staticmethod - def TDL_list_get_color(l: Any, idx: int, /) -> Any: - """TCOD_color_t TDL_list_get_color(TCOD_list_t l, int idx)""" - - @staticmethod - def TDL_list_get_dice(l: Any, idx: int, /) -> Any: - """TCOD_dice_t TDL_list_get_dice(TCOD_list_t l, int idx)""" - - @staticmethod - def TDL_list_get_float(l: Any, idx: int, /) -> float: - """float TDL_list_get_float(TCOD_list_t l, int idx)""" - - @staticmethod - def TDL_list_get_int(l: Any, idx: int, /) -> int: - """int TDL_list_get_int(TCOD_list_t l, int idx)""" - - @staticmethod - def TDL_list_get_string(l: Any, idx: int, /) -> Any: - """char *TDL_list_get_string(TCOD_list_t l, int idx)""" - - @staticmethod - def TDL_list_get_union(l: Any, idx: int, /) -> Any: - """TCOD_value_t TDL_list_get_union(TCOD_list_t l, int idx)""" - - @staticmethod - def TDL_map_data_from_buffer(map: Any, buffer: Any, /) -> None: - """void TDL_map_data_from_buffer(TCOD_map_t map, uint8_t *buffer)""" - - @staticmethod - def TDL_map_fov_to_buffer(map: Any, buffer: Any, cumulative: bool, /) -> None: - """void TDL_map_fov_to_buffer(TCOD_map_t map, uint8_t *buffer, bool cumulative)""" - @staticmethod def _libtcod_log_watcher(message: Any, userdata: Any, /) -> None: """void _libtcod_log_watcher(const TCOD_LogMessage *message, void *userdata)""" @@ -9648,7 +9512,7 @@ class _lib: TCOD_LOG_INFO: Final[Literal[20]] = 20 TCOD_LOG_WARNING: Final[Literal[30]] = 30 TCOD_MAJOR_VERSION: Final[Literal[2]] = 2 - TCOD_MINOR_VERSION: Final[Literal[1]] = 1 + TCOD_MINOR_VERSION: Final[Literal[2]] = 2 TCOD_NB_RENDERERS: Final[int] TCOD_NOISE_DEFAULT: Final[Literal[0]] = 0 TCOD_NOISE_MAX_DIMENSIONS: Final[Literal[4]] = 4 From c422bc7909291e63b1d36acf1676ea66e9c43aa8 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Sat, 13 Sep 2025 09:50:33 -0700 Subject: [PATCH 045/131] Use SDL3 struct attributes for controller events Names were changed from SDL2 and were causing crashes --- .vscode/settings.json | 6 +++--- CHANGELOG.md | 1 + tcod/event.py | 14 +++++++------- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index c45bb5e5..c4b4d82a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -71,11 +71,8 @@ "bysource", "caeldera", "CAPSLOCK", - "caxis", - "cbutton", "ccoef", "cdef", - "cdevice", "cffi", "cflags", "CFLAGS", @@ -169,6 +166,9 @@ "fwidth", "GAMECONTROLLER", "gamepad", + "gaxis", + "gbutton", + "gdevice", "genindex", "getbbox", "GFORCE", diff --git a/CHANGELOG.md b/CHANGELOG.md index 015ea936..11ae529d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version ### Fixed - `SDL_RENDER_SCALE_QUALITY` is now respected again since the change to SDL3. +- Fixed crash on controller events. ## [19.4.1] - 2025-08-27 diff --git a/tcod/event.py b/tcod/event.py index 5a2bceb8..3859547d 100644 --- a/tcod/event.py +++ b/tcod/event.py @@ -1060,9 +1060,9 @@ def __init__(self, type: str, which: int, axis: tcod.sdl.joystick.ControllerAxis def from_sdl_event(cls, sdl_event: Any) -> ControllerAxis: return cls( "CONTROLLERAXISMOTION", - sdl_event.caxis.which, - tcod.sdl.joystick.ControllerAxis(sdl_event.caxis.axis), - sdl_event.caxis.value, + sdl_event.gaxis.which, + tcod.sdl.joystick.ControllerAxis(sdl_event.gaxis.axis), + sdl_event.gaxis.value, ) def __repr__(self) -> str: @@ -1099,9 +1099,9 @@ def from_sdl_event(cls, sdl_event: Any) -> ControllerButton: }[sdl_event.type] return cls( type, - sdl_event.cbutton.which, - tcod.sdl.joystick.ControllerButton(sdl_event.cbutton.button), - bool(sdl_event.cbutton.down), + sdl_event.gbutton.which, + tcod.sdl.joystick.ControllerButton(sdl_event.gbutton.button), + bool(sdl_event.gbutton.down), ) def __repr__(self) -> str: @@ -1130,7 +1130,7 @@ def from_sdl_event(cls, sdl_event: Any) -> ControllerDevice: lib.SDL_EVENT_GAMEPAD_REMOVED: "CONTROLLERDEVICEREMOVED", lib.SDL_EVENT_GAMEPAD_REMAPPED: "CONTROLLERDEVICEREMAPPED", }[sdl_event.type] - return cls(type, sdl_event.cdevice.which) + return cls(type, sdl_event.gdevice.which) @functools.cache From ca3475e1fddcde8c99bee9c4a821c9b48cab7747 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Sat, 13 Sep 2025 10:03:02 -0700 Subject: [PATCH 046/131] Suppress Mypy error Old workaround cases an error in latest Mypy A real fix is a breaking change for later --- tcod/path.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tcod/path.py b/tcod/path.py index 02db86ef..468bd95f 100644 --- a/tcod/path.py +++ b/tcod/path.py @@ -473,7 +473,7 @@ def dijkstra2d( # noqa: PLR0913 Added `out` parameter. Now returns the output array. """ dist: NDArray[Any] = np.asarray(distance) - if out is ...: + if out is ...: # type: ignore[comparison-overlap] out = dist warnings.warn( "No `out` parameter was given. " From 59d5923829f5e6f20ac2776fed843db99eda812e Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Sat, 13 Sep 2025 12:29:26 -0700 Subject: [PATCH 047/131] Prepare 19.5.0 release. --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11ae529d..680841c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,12 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version ## [Unreleased] +## [19.5.0] - 2025-09-13 + ### Changed - Update to libtcod 2.2.1. +- Scaling defaults to nearest, set `os.environ["SDL_RENDER_SCALE_QUALITY"] = "linear"` if linear scaling was preferred. ### Fixed From 1bd481450d10b7d2af2ca853221e1a9348fae97f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 6 Oct 2025 20:51:33 +0000 Subject: [PATCH 048/131] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/pre-commit/pre-commit-hooks: v5.0.0 → v6.0.0](https://github.com/pre-commit/pre-commit-hooks/compare/v5.0.0...v6.0.0) - [github.com/astral-sh/ruff-pre-commit: v0.12.2 → v0.13.3](https://github.com/astral-sh/ruff-pre-commit/compare/v0.12.2...v0.13.3) --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8234ccf4..e7a67182 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,7 +4,7 @@ ci: autoupdate_schedule: quarterly repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer @@ -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.12.2 + rev: v0.13.3 hooks: - id: ruff-check args: [--fix-only, --exit-non-zero-on-fix] From f88862bf80da53b7f683c5ed617d884f2a014483 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Mon, 6 Oct 2025 14:39:17 -0700 Subject: [PATCH 049/131] Test libsdl-org/setup-sdl update Checking if latest version on main no longer needs a workaround --- .github/workflows/python-package.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index af046082..229c44eb 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -45,7 +45,7 @@ jobs: sdist: runs-on: ubuntu-latest steps: - - uses: HexDecimal/my-setup-sdl-action@v1.0.0 + - uses: libsdl-org/setup-sdl@1894460666e4fe0c6603ea0cce5733f1cca56ba1 with: install-linux-dependencies: true build-type: "Debug" @@ -122,7 +122,7 @@ jobs: run: | sudo apt-get update sudo apt-get install xvfb - - uses: HexDecimal/my-setup-sdl-action@v1.0.0 + - uses: libsdl-org/setup-sdl@1894460666e4fe0c6603ea0cce5733f1cca56ba1 if: runner.os == 'Linux' with: install-linux-dependencies: true @@ -165,7 +165,7 @@ jobs: needs: [ruff, mypy, sdist] runs-on: ubuntu-latest steps: - - uses: HexDecimal/my-setup-sdl-action@v1.0.0 + - uses: libsdl-org/setup-sdl@1894460666e4fe0c6603ea0cce5733f1cca56ba1 if: runner.os == 'Linux' with: install-linux-dependencies: true @@ -207,7 +207,7 @@ jobs: - name: Install Python dependencies run: | python -m pip install --upgrade pip tox - - uses: HexDecimal/my-setup-sdl-action@v1.0.0 + - uses: libsdl-org/setup-sdl@1894460666e4fe0c6603ea0cce5733f1cca56ba1 if: runner.os == 'Linux' with: install-linux-dependencies: true @@ -327,7 +327,7 @@ jobs: fetch-depth: ${{ env.git-depth }} - name: Checkout submodules run: git submodule update --init --recursive --depth 1 - - uses: HexDecimal/my-setup-sdl-action@v1.0.0 + - uses: libsdl-org/setup-sdl@1894460666e4fe0c6603ea0cce5733f1cca56ba1 with: install-linux-dependencies: true build-type: "Debug" From 87ecd62f9f2f8aa81c319ad1cc1e4feb96f3cb6f Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Thu, 9 Oct 2025 18:03:54 -0700 Subject: [PATCH 050/131] Fix lowercase letter KeySym regressions Add single number aliases to KeySym Partially update KeySym docs --- CHANGELOG.md | 11 +++++ tcod/event.py | 110 +++++++++++++++++++++++++++++++++++++------------- 2 files changed, 93 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 680841c7..5b245ac8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version ## [Unreleased] +### Added + +- Alternative syntax for number symbols with `KeySym`, can now specify `KeySym["3"]`, etc. + Only available on Python 3.13 or later. + +### Fixed + +- Fixed regression with lowercase key symbols with `tcod.event.K_*` and `KeySym.*` constants, these are still deprecated. + Event constants are only fixed for `tcod.event.K_*`, not the undocumented `tcod.event_constants` module. + Lowercase `KeySym.*` constants are only available on Python 3.13 or later. + ## [19.5.0] - 2025-09-13 ### Changed diff --git a/tcod/event.py b/tcod/event.py index 3859547d..6b52a2a1 100644 --- a/tcod/event.py +++ b/tcod/event.py @@ -84,6 +84,7 @@ import enum import functools +import sys import warnings from collections.abc import Callable, Iterator, Mapping from typing import TYPE_CHECKING, Any, Final, Generic, Literal, NamedTuple, TypeVar @@ -2230,11 +2231,19 @@ def __repr__(self) -> str: class KeySym(enum.IntEnum): """Keyboard constants based on their symbol. - These names are derived from SDL except for the numbers which are prefixed - with ``N`` (since raw numbers can not be a Python name.) + These names are derived from SDL except for numbers which are prefixed with ``N`` (since raw numbers can not be a Python name). + Alternatively ``KeySym["9"]`` can be used to represent numbers (since Python 3.13). .. versionadded:: 12.3 + .. versionchanged:: 19.0 + SDL backend was updated to 3.x, which means some enums have been renamed. + Single letters are now uppercase. + + .. versionchanged:: Unreleased + Number symbols can now be fetched with ``KeySym["9"]``, etc. + With Python 3.13 or later. + ================== ========== UNKNOWN 0 BACKSPACE 8 @@ -2280,32 +2289,32 @@ class KeySym(enum.IntEnum): CARET 94 UNDERSCORE 95 BACKQUOTE 96 - a 97 - b 98 - c 99 - d 100 - e 101 - f 102 - g 103 - h 104 - i 105 - j 106 - k 107 - l 108 - m 109 - n 110 - o 111 - p 112 - q 113 - r 114 - s 115 - t 116 - u 117 - v 118 - w 119 - x 120 - y 121 - z 122 + A 97 + B 98 + C 99 + D 100 + E 101 + F 102 + G 103 + H 104 + I 105 + J 106 + K 107 + L 108 + M 109 + N 110 + O 111 + P 112 + Q 113 + R 114 + S 115 + T 116 + U 117 + V 118 + W 119 + X 120 + Y 121 + Z 122 DELETE 127 SCANCODE_MASK 1073741824 CAPSLOCK 1073741881 @@ -2799,6 +2808,48 @@ def __repr__(self) -> str: return f"tcod.event.{self.__class__.__name__}.{self.name}" +if sys.version_info >= (3, 13): + # Alias for lower case letters removed from SDL3 + KeySym.A._add_alias_("a") + KeySym.B._add_alias_("b") + KeySym.C._add_alias_("c") + KeySym.D._add_alias_("d") + KeySym.E._add_alias_("e") + KeySym.F._add_alias_("f") + KeySym.G._add_alias_("g") + KeySym.H._add_alias_("h") + KeySym.I._add_alias_("i") + KeySym.J._add_alias_("j") + KeySym.K._add_alias_("k") + KeySym.L._add_alias_("l") + KeySym.M._add_alias_("m") + KeySym.N._add_alias_("n") + KeySym.O._add_alias_("o") + KeySym.P._add_alias_("p") + KeySym.Q._add_alias_("q") + KeySym.R._add_alias_("r") + KeySym.S._add_alias_("s") + KeySym.T._add_alias_("t") + KeySym.U._add_alias_("u") + KeySym.V._add_alias_("v") + KeySym.W._add_alias_("w") + KeySym.X._add_alias_("x") + KeySym.Y._add_alias_("y") + KeySym.Z._add_alias_("z") + + # Alias for numbers, since Python enum names can not be number literals + KeySym.N0._add_alias_("0") + KeySym.N1._add_alias_("1") + KeySym.N2._add_alias_("2") + KeySym.N3._add_alias_("3") + KeySym.N4._add_alias_("4") + KeySym.N5._add_alias_("5") + KeySym.N6._add_alias_("6") + KeySym.N7._add_alias_("7") + KeySym.N8._add_alias_("8") + KeySym.N9._add_alias_("9") + + def __getattr__(name: str) -> int: """Migrate deprecated access of event constants.""" if name.startswith("BUTTON_"): @@ -2822,6 +2873,9 @@ def __getattr__(name: str) -> int: ) return replacement + if name.startswith("K_") and len(name) == 3: # noqa: PLR2004 + name = name.upper() # Silently fix single letter key symbols removed from SDL3, these are still deprecated + value: int | None = getattr(tcod.event_constants, name, None) if not value: msg = f"module {__name__!r} has no attribute {name!r}" From 05eda1cdee395c40867e896c260efed3e81aeb1a Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Sun, 19 Oct 2025 20:52:41 -0700 Subject: [PATCH 051/131] Fix BSP.split_recursive crash when passing Random Random was passed directly to CFFI instead of fetching its C pointer Fixes #168 --- CHANGELOG.md | 1 + tcod/bsp.py | 17 +++++++---------- tests/test_tcod.py | 9 +++++++-- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b245ac8..022493d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version - Fixed regression with lowercase key symbols with `tcod.event.K_*` and `KeySym.*` constants, these are still deprecated. Event constants are only fixed for `tcod.event.K_*`, not the undocumented `tcod.event_constants` module. Lowercase `KeySym.*` constants are only available on Python 3.13 or later. +- `BSP.split_recursive` did not accept a `Random` class as the seed. #168 ## [19.5.0] - 2025-09-13 diff --git a/tcod/bsp.py b/tcod/bsp.py index f4d7204d..cfb5d15c 100644 --- a/tcod/bsp.py +++ b/tcod/bsp.py @@ -148,20 +148,17 @@ def split_recursive( # noqa: PLR0913 """Divide this partition recursively. Args: - depth (int): The maximum depth to divide this object recursively. - min_width (int): The minimum width of any individual partition. - min_height (int): The minimum height of any individual partition. - max_horizontal_ratio (float): - Prevent creating a horizontal ratio more extreme than this. - max_vertical_ratio (float): - Prevent creating a vertical ratio more extreme than this. - seed (Optional[tcod.random.Random]): - The random number generator to use. + depth: The maximum depth to divide this object recursively. + min_width: The minimum width of any individual partition. + min_height: The minimum height of any individual partition. + max_horizontal_ratio: Prevent creating a horizontal ratio more extreme than this. + max_vertical_ratio: Prevent creating a vertical ratio more extreme than this. + seed: The random number generator to use. """ cdata = self._as_cdata() lib.TCOD_bsp_split_recursive( cdata, - seed or ffi.NULL, + seed.random_c if seed is not None else ffi.NULL, depth, min_width, min_height, diff --git a/tests/test_tcod.py b/tests/test_tcod.py index 02eb6dbb..74e24c91 100644 --- a/tests/test_tcod.py +++ b/tests/test_tcod.py @@ -9,7 +9,12 @@ from numpy.typing import DTypeLike, NDArray import tcod +import tcod.bsp import tcod.console +import tcod.context +import tcod.map +import tcod.path +import tcod.random from tcod import libtcodpy @@ -20,7 +25,7 @@ def raise_Exception(*_args: object) -> NoReturn: def test_line_error() -> None: """Test exception propagation.""" with pytest.raises(RuntimeError), pytest.warns(): - tcod.line(0, 0, 10, 10, py_callback=raise_Exception) + libtcodpy.line(0, 0, 10, 10, py_callback=raise_Exception) @pytest.mark.filterwarnings("ignore:Iterate over nodes using") @@ -44,7 +49,7 @@ def test_tcod_bsp() -> None: # test that operations on deep BSP nodes preserve depth sub_bsp = bsp.children[0] - sub_bsp.split_recursive(3, 2, 2, 1, 1) + sub_bsp.split_recursive(3, 2, 2, 1, 1, seed=tcod.random.Random(seed=42)) assert sub_bsp.children[0].level == 2 # noqa: PLR2004 # cover find_node method From ebdd0e4cf80339e595cf0a51ffb3585e9d43411c Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Sun, 19 Oct 2025 21:03:38 -0700 Subject: [PATCH 052/131] Resolve lint warnings --- tcod/bsp.py | 19 ++++++++++++------- tcod/random.py | 6 ++++-- tests/test_tcod.py | 24 ++++++++++++------------ 3 files changed, 28 insertions(+), 21 deletions(-) diff --git a/tcod/bsp.py b/tcod/bsp.py index cfb5d15c..21aa093d 100644 --- a/tcod/bsp.py +++ b/tcod/bsp.py @@ -27,7 +27,6 @@ from __future__ import annotations -from collections.abc import Iterator from typing import TYPE_CHECKING, Any from typing_extensions import deprecated @@ -35,6 +34,8 @@ from tcod.cffi import ffi, lib if TYPE_CHECKING: + from collections.abc import Iterator + import tcod.random @@ -125,7 +126,11 @@ def _unpack_bsp_tree(self, cdata: Any) -> None: # noqa: ANN401 self.children[1].parent = self self.children[1]._unpack_bsp_tree(lib.TCOD_bsp_right(cdata)) - def split_once(self, horizontal: bool, position: int) -> None: + def split_once( + self, + horizontal: bool, # noqa: FBT001 + position: int, + ) -> None: """Split this partition into 2 sub-partitions. Args: @@ -211,13 +216,13 @@ def level_order(self) -> Iterator[BSP]: .. versionadded:: 8.3 """ - next = [self] - while next: - level = next - next = [] + next_ = [self] + while next_: + level = next_ + next_ = [] yield from level for node in level: - next.extend(node.children) + next_.extend(node.children) def inverted_level_order(self) -> Iterator[BSP]: """Iterate over this BSP's hierarchy in inverse level order. diff --git a/tcod/random.py b/tcod/random.py index 6d332b86..1b7b16c7 100644 --- a/tcod/random.py +++ b/tcod/random.py @@ -12,14 +12,16 @@ import os import random import warnings -from collections.abc import Hashable -from typing import Any +from typing import TYPE_CHECKING, Any from typing_extensions import deprecated import tcod.constants from tcod.cffi import ffi, lib +if TYPE_CHECKING: + from collections.abc import Hashable + MERSENNE_TWISTER = tcod.constants.RNG_MT COMPLEMENTARY_MULTIPLY_WITH_CARRY = tcod.constants.RNG_CMWC MULTIPLY_WITH_CARRY = tcod.constants.RNG_CMWC diff --git a/tests/test_tcod.py b/tests/test_tcod.py index 74e24c91..23bb7d6b 100644 --- a/tests/test_tcod.py +++ b/tests/test_tcod.py @@ -18,14 +18,14 @@ from tcod import libtcodpy -def raise_Exception(*_args: object) -> NoReturn: +def raise_exception(*_args: object) -> NoReturn: raise RuntimeError("testing exception") # noqa: TRY003, EM101 def test_line_error() -> None: """Test exception propagation.""" with pytest.raises(RuntimeError), pytest.warns(): - libtcodpy.line(0, 0, 10, 10, py_callback=raise_Exception) + libtcodpy.line(0, 0, 10, 10, py_callback=raise_exception) @pytest.mark.filterwarnings("ignore:Iterate over nodes using") @@ -39,7 +39,7 @@ def test_tcod_bsp() -> None: assert not bsp.children with pytest.raises(RuntimeError): - libtcodpy.bsp_traverse_pre_order(bsp, raise_Exception) + libtcodpy.bsp_traverse_pre_order(bsp, raise_exception) bsp.split_recursive(3, 4, 4, 1, 1) for node in bsp.walk(): @@ -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 + assert map_._Map__buffer.strides == map2._Map__buffer.strides # type: ignore[attr-defined] assert map_.transparent.strides == map2.transparent.strides assert map_.walkable.strides == map2.walkable.strides assert map_.fov.strides == map2.fov.strides @@ -148,7 +148,7 @@ def test_path_numpy(dtype: DTypeLike) -> None: tcod.path.AStar(np.ones((2, 2), dtype=np.float64)) -def path_cost(this_x: int, this_y: int, dest_x: int, dest_y: int) -> bool: +def path_cost(_this_x: int, _this_y: int, _dest_x: int, _dest_y: int) -> bool: return True @@ -160,7 +160,7 @@ def test_path_callback() -> None: def test_key_repr() -> None: - Key = libtcodpy.Key + Key = libtcodpy.Key # noqa: N806 key = Key(vk=1, c=2, shift=True) assert key.vk == 1 assert key.c == 2 # noqa: PLR2004 @@ -172,7 +172,7 @@ def test_key_repr() -> None: def test_mouse_repr() -> None: - Mouse = libtcodpy.Mouse + Mouse = libtcodpy.Mouse # noqa: N806 mouse = Mouse(x=1, lbutton=True) mouse_copy = eval(repr(mouse)) # noqa: S307 assert mouse.x == mouse_copy.x @@ -185,16 +185,16 @@ def test_cffi_structs() -> None: @pytest.mark.filterwarnings("ignore") -def test_recommended_size(console: tcod.console.Console) -> None: +def test_recommended_size(console: tcod.console.Console) -> None: # noqa: ARG001 tcod.console.recommended_size() @pytest.mark.filterwarnings("ignore") -def test_context(uses_window: None) -> None: +def test_context(uses_window: None) -> None: # noqa: ARG001 with tcod.context.new_window(32, 32, renderer=libtcodpy.RENDERER_SDL2): pass - WIDTH, HEIGHT = 16, 4 - with tcod.context.new_terminal(columns=WIDTH, rows=HEIGHT, renderer=libtcodpy.RENDERER_SDL2) as context: + width, height = 16, 4 + with tcod.context.new_terminal(columns=width, rows=height, renderer=libtcodpy.RENDERER_SDL2) as context: console = tcod.console.Console(*context.recommended_console_size()) context.present(console) assert context.sdl_window_p is not None @@ -202,5 +202,5 @@ def test_context(uses_window: None) -> None: context.change_tileset(tcod.tileset.Tileset(16, 16)) context.pixel_to_tile(0, 0) context.pixel_to_subtile(0, 0) - with pytest.raises(RuntimeError, match=".*context has been closed"): + with pytest.raises(RuntimeError, match=r".*context has been closed"): context.present(console) From 37e1b3401efceab1960d539f5c79a0385fb4bed9 Mon Sep 17 00:00:00 2001 From: Kyle Benesch <4b796c65+github@gmail.com> Date: Sun, 19 Oct 2025 21:19:42 -0700 Subject: [PATCH 053/131] Prepare 19.6.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 022493d6..23f96318 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.0] - 2025-10-20 + ### Added - Alternative syntax for number symbols with `KeySym`, can now specify `KeySym["3"]`, etc. diff --git a/tcod/event.py b/tcod/event.py index 6b52a2a1..b7657eb2 100644 --- a/tcod/event.py +++ b/tcod/event.py @@ -2240,7 +2240,7 @@ class KeySym(enum.IntEnum): SDL backend was updated to 3.x, which means some enums have been renamed. Single letters are now uppercase. - .. versionchanged:: Unreleased + .. versionchanged:: 19.6 Number symbols can now be fetched with ``KeySym["9"]``, etc. With Python 3.13 or later. From 770ec4610489f2d22d335ee22aea06ac17214bf5 Mon Sep 17 00:00:00 2001 From: Christian Clauss Date: Mon, 3 Nov 2025 12:36:44 +0100 Subject: [PATCH 054/131] 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 055/131] 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 056/131] 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 057/131] 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 058/131] 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 059/131] 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 060/131] 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 061/131] 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 062/131] 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 063/131] 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 064/131] 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 065/131] 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 066/131] 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 067/131] 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 068/131] 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 069/131] 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 070/131] 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 071/131] 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 072/131] 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 073/131] 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 074/131] 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 075/131] 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 076/131] 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 077/131] [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 078/131] 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 079/131] 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 080/131] 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 081/131] 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 082/131] 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 083/131] 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 084/131] 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 085/131] 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 086/131] 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 087/131] 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 088/131] 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 089/131] 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 090/131] 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 091/131] 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 092/131] 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 093/131] 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 094/131] 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 095/131] 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 096/131] 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 097/131] 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 098/131] 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 099/131] 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 100/131] 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 101/131] 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 102/131] 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 103/131] 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 104/131] 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 105/131] 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 106/131] 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 107/131] 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 108/131] 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 109/131] 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 110/131] 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 111/131] 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 112/131] 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 113/131] 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 114/131] 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 115/131] 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 116/131] 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 117/131] 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 118/131] 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 119/131] 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 120/131] 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 121/131] 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 122/131] 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 123/131] 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 124/131] 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 125/131] 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 126/131] 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 127/131] 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 128/131] 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 129/131] 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 130/131] 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 131/131] 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