From bf3e6f2c3b28ad33659794a2961c8a9287e28697 Mon Sep 17 00:00:00 2001 From: DataTriny Date: Wed, 9 Aug 2023 23:16:16 +0200 Subject: [PATCH 01/12] feat: Add Python bindings --- .../{bindings.yml => c-bindings.yml} | 2 +- .github/workflows/ci.yml | 3 + .github/workflows/python-bindings.yml | 76 ++ Cargo.lock | 18 + Cargo.toml | 2 + README.md | 6 +- bindings/python/.cargo/config.toml | 11 + bindings/python/Cargo.toml | 30 + bindings/python/README.md | 3 + bindings/python/examples/pygame/.gitignore | 160 ++++ bindings/python/examples/pygame/README.md | 15 + .../python/examples/pygame/hello_world.py | 199 +++++ .../python/examples/pygame/requirements.txt | 2 + bindings/python/src/common.rs | 752 ++++++++++++++++++ bindings/python/src/geometry.rs | 251 ++++++ bindings/python/src/lib.rs | 90 +++ bindings/python/src/macos.rs | 194 +++++ bindings/python/src/unix.rs | 48 ++ bindings/python/src/windows.rs | 141 ++++ pyproject.toml | 38 + release-please-config.json | 3 +- 21 files changed, 2041 insertions(+), 3 deletions(-) rename .github/workflows/{bindings.yml => c-bindings.yml} (99%) create mode 100644 .github/workflows/python-bindings.yml create mode 100644 bindings/python/.cargo/config.toml create mode 100644 bindings/python/Cargo.toml create mode 100644 bindings/python/README.md create mode 100644 bindings/python/examples/pygame/.gitignore create mode 100644 bindings/python/examples/pygame/README.md create mode 100644 bindings/python/examples/pygame/hello_world.py create mode 100644 bindings/python/examples/pygame/requirements.txt create mode 100644 bindings/python/src/common.rs create mode 100644 bindings/python/src/geometry.rs create mode 100644 bindings/python/src/lib.rs create mode 100644 bindings/python/src/macos.rs create mode 100644 bindings/python/src/unix.rs create mode 100644 bindings/python/src/windows.rs create mode 100644 pyproject.toml diff --git a/.github/workflows/bindings.yml b/.github/workflows/c-bindings.yml similarity index 99% rename from .github/workflows/bindings.yml rename to .github/workflows/c-bindings.yml index 47217c1e9..3c574fdb2 100644 --- a/.github/workflows/bindings.yml +++ b/.github/workflows/c-bindings.yml @@ -2,7 +2,7 @@ on: release: types: - published -name: Publish bindings +name: Publish C bindings jobs: build-binaries: if: startsWith(github.ref_name, 'accesskit_c-v') diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57352b568..27b5383d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,9 @@ jobs: clang-format-version: 15 check-path: bindings/c + - name: black --check + uses: psf/black@stable + test: runs-on: ${{ matrix.os }} strategy: diff --git a/.github/workflows/python-bindings.yml b/.github/workflows/python-bindings.yml new file mode 100644 index 000000000..23e26b47d --- /dev/null +++ b/.github/workflows/python-bindings.yml @@ -0,0 +1,76 @@ +on: + push: + tags: + - 'accesskit_python-v*' + +name: Publish Python bindings + +jobs: + build-wheels: + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - os: macos-latest + python-arch: x64 + rust-target: x86_64 + - os: macos-latest + python-arch: x64 + rust-target: universal2-apple-darwin + - os: ubuntu-latest + python-arch: x64 + rust-target: x86_64 + - os: ubuntu-latest + python-arch: x64 + rust-target: i686 + skip-wheel-installation: true + - os: windows-latest + python-arch: x64 + rust-target: x64 + - os: windows-latest + python-arch: x86 + rust-target: x86 + + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: 3.7 + architecture: ${{ matrix.python-arch }} + - uses: dtolnay/rust-toolchain@stable + - name: Build wheel + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.rust-target }} + manylinux: auto + args: --release --out dist --sdist + - name: Test wheel installation + if: matrix.skip-wheel-installation != true + run: | + pip install accesskit --no-index --find-links dist --force-reinstall + python -c "import accesskit" + - name: Upload wheel + uses: actions/upload-artifact@v3 + with: + name: wheels + path: dist + + release: + name: Release + runs-on: ubuntu-latest + if: "startsWith(github.ref, 'refs/tags/')" + needs: [build-wheels] + steps: + - uses: actions/download-artifact@v3 + with: + name: wheels + - uses: actions/setup-python@v4 + with: + python-version: 3.9 + - name: Publish to PyPI + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} + run: | + pip install --upgrade twine + twine upload --skip-existing * diff --git a/Cargo.lock b/Cargo.lock index 84dd0c7c7..1c61cd1b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -56,6 +56,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "accesskit_python" +version = "0.1.0" +dependencies = [ + "accesskit", + "accesskit_macos", + "accesskit_unix", + "accesskit_windows", + "pyo3", +] + [[package]] name = "accesskit_unix" version = "0.6.2" @@ -997,6 +1008,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "inventory" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1be380c410bf0595e94992a648ea89db4dd3f3354ba54af206fd2a68cf5ac8e" + [[package]] name = "io-lifetimes" version = "1.0.7" @@ -1458,6 +1475,7 @@ checksum = "e82ad98ce1991c9c70c3464ba4187337b9c45fcbbb060d46dca15f0c075e14e2" dependencies = [ "cfg-if", "indoc", + "inventory", "libc", "memoffset 0.9.0", "parking_lot", diff --git a/Cargo.toml b/Cargo.toml index f54b5c378..cb2bb6051 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,12 +8,14 @@ members = [ "platforms/windows", "platforms/winit", "bindings/c", + "bindings/python", ] default-members = [ "common", "consumer", "platforms/winit", "bindings/c", + "bindings/python", ] [profile.release] diff --git a/README.md b/README.md index c4cf1ec76..7f33862f1 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,11 @@ While we expect GUI toolkit developers to eventually integrate AccessKit into th ### Language bindings -UI toolkit developers who merely want to use AccessKit should not be required to use Rust directly. In addition to a direct Rust API, AccessKit provides a C API covering both the core data structures and all platform adapters. This C API can be used from a variety of languages. The Rust source for the C bindings is in [the `bindings/c directory`](https://github.com/AccessKit/accesskit/tree/main/bindings/c). The AccessKit project also provides a pre-built package, including a header file, both dynamic and static libraries, and sample code, for the C API, so toolkit developers won't need to deal with Rust at all. The latest pre-built package can be found in [AccessKit's GitHub releases](https://github.com/AccessKit/accesskit/releases); search for the name "accesskit_c". +UI toolkit developers who merely want to use AccessKit should not be required to use Rust directly. + +AccessKit provides a C API covering both the core data structures and all platform adapters. This C API can be used from a variety of languages. The Rust source for the C bindings is in [the `bindings/c directory`](https://github.com/AccessKit/accesskit/tree/main/bindings/c). The AccessKit project also provides a pre-built package, including a header file, both dynamic and static libraries, and sample code, for the C API, so toolkit developers won't need to deal with Rust at all. The latest pre-built package can be found in [AccessKit's GitHub releases](https://github.com/AccessKit/accesskit/releases); search for the name "accesskit_c". + +Bindings for the Python programming language are also available. Rust source code is in [the `bindings/python directory`](https://github.com/AccessKit/accesskit/tree/main/bindings/python). Releases can be found on [PyPI](https://pypi.org/project/accesskit/) and can be included in your project using `pip`. While many languages can use a C API, we also plan to provide libraries that make it easier to safely use AccessKit from languages other than Rust and C. In particular, we're planning to provide such a library for Java and other JVM-based languages. diff --git a/bindings/python/.cargo/config.toml b/bindings/python/.cargo/config.toml new file mode 100644 index 000000000..7cb83a102 --- /dev/null +++ b/bindings/python/.cargo/config.toml @@ -0,0 +1,11 @@ +[target.aarch64-apple-darwin] +rustflags = [ + "-C", "link-arg=-undefined", + "-C", "link-arg=dynamic_lookup", +] + +[target.x86_64-apple-darwin] +rustflags = [ + "-C", "link-arg=-undefined", + "-C", "link-arg=dynamic_lookup", +] diff --git a/bindings/python/Cargo.toml b/bindings/python/Cargo.toml new file mode 100644 index 000000000..95d1c37be --- /dev/null +++ b/bindings/python/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "accesskit_python" +version = "0.1.0" +authors = ["Arnold Loubriat "] +license = "MIT OR Apache-2.0" +description = "Python bindings to the AccessKit library" +readme = "README.md" +publish = false +edition = "2021" + +[lib] +name = "accesskit" +crate-type = ["cdylib"] +doc = false + +[features] +extension-module = ["pyo3/extension-module"] + +[dependencies] +accesskit = { version = "0.12.0", path = "../../common", features = ["pyo3"] } +pyo3 = { version = "0.20", features = ["abi3-py37", "multiple-pymethods"] } + +[target.'cfg(target_os = "windows")'.dependencies] +accesskit_windows = { version = "0.15.0", path = "../../platforms/windows" } + +[target.'cfg(target_os = "macos")'.dependencies] +accesskit_macos = { version = "0.10.0", path = "../../platforms/macos" } + +[target.'cfg(any(target_os = "linux", target_os = "dragonfly", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))'.dependencies] +accesskit_unix = { version = "0.6.0", path = "../../platforms/unix" } diff --git a/bindings/python/README.md b/bindings/python/README.md new file mode 100644 index 000000000..6dc3e133b --- /dev/null +++ b/bindings/python/README.md @@ -0,0 +1,3 @@ +# AccessKit + +These are the bindings to use AccessKit from Python. diff --git a/bindings/python/examples/pygame/.gitignore b/bindings/python/examples/pygame/.gitignore new file mode 100644 index 000000000..68bc17f9f --- /dev/null +++ b/bindings/python/examples/pygame/.gitignore @@ -0,0 +1,160 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ diff --git a/bindings/python/examples/pygame/README.md b/bindings/python/examples/pygame/README.md new file mode 100644 index 000000000..cfdb8089d --- /dev/null +++ b/bindings/python/examples/pygame/README.md @@ -0,0 +1,15 @@ +# pygame example + +This directory contains a cross-platform application that demonstrates how to integrate AccessKit with the pygame library. + +## Prerequisites + +- Python 3.7 or higher +- A virtual environment: `python -m venv .venv` (activating it will vary based on your platform) +- `pip install -r requirements.txt` + +## How to run + +```bash +python hello_world.py +``` diff --git a/bindings/python/examples/pygame/hello_world.py b/bindings/python/examples/pygame/hello_world.py new file mode 100644 index 000000000..0f874a0ee --- /dev/null +++ b/bindings/python/examples/pygame/hello_world.py @@ -0,0 +1,199 @@ +import accesskit +import os +import platform +import pygame + +PLATFORM_SYSTEM = platform.system() + +WINDOW_TITLE = "Hello world" +WINDOW_WIDTH = 400 +WINDOW_HEIGHT = 200 + +WINDOW_ID = 0 +BUTTON_1_ID = 1 +BUTTON_2_ID = 2 +ANNOUNCEMENT_ID = 3 +INITIAL_FOCUS = BUTTON_1_ID + +BUTTON_1_RECT = accesskit.Rect(20.0, 20.0, 100.0, 60.0) + +BUTTON_2_RECT = accesskit.Rect(20.0, 60.0, 100.0, 100.0) + +ACCESSKIT_EVENT = pygame.event.custom_type() +SET_FOCUS_MSG = 0 +DO_DEFAULT_ACTION_MSG = 1 + + +def build_button(id, name, classes): + builder = accesskit.NodeBuilder(accesskit.Role.BUTTON) + builder.set_bounds(BUTTON_1_RECT if id == BUTTON_1_ID else BUTTON_2_RECT) + builder.set_name(name) + builder.add_action(accesskit.Action.FOCUS) + builder.set_default_action_verb(accesskit.DefaultActionVerb.CLICK) + return builder.build(classes) + + +def build_announcement(text, classes): + builder = accesskit.NodeBuilder(accesskit.Role.STATIC_TEXT) + builder.set_name(text) + builder.set_live(accesskit.Live.POLITE) + return builder.build(classes) + + +class PygameAdapter: + def __init__(self, source, action_handler): + if PLATFORM_SYSTEM == "Darwin": + accesskit.macos.add_focus_forwarder_to_window_class("SDLWindow") + window = pygame.display.get_wm_info()["window"] + self.adapter = accesskit.macos.SubclassingAdapter.for_window( + window, source, action_handler + ) + elif os.name == "posix": + self.adapter = accesskit.unix.Adapter.create(source, False, action_handler) + elif PLATFORM_SYSTEM == "Windows": + hwnd = pygame.display.get_wm_info()["window"] + self.adapter = accesskit.windows.SubclassingAdapter( + hwnd, source, action_handler + ) + + def update(self, tree_update): + if self.adapter is not None: + events = self.adapter.update(tree_update) + if events is not None: + events.raise_events() + + def update_if_active(self, update_factory): + if self.adapter is not None: + if PLATFORM_SYSTEM in ["Darwin", "Windows"]: + events = self.adapter.update_if_active(update_factory) + if events is not None: + events.raise_events() + else: + self.adapter.update(update_factory()) + + def update_window_focus_state(self, is_focused): + if self.adapter is not None: + if PLATFORM_SYSTEM == "Darwin": + events = self.adapter.update_view_focus_state(is_focused) + if events is not None: + events.raise_events() + elif os.name == "posix": + self.adapter.update_window_focus_state(is_focused) + + +class WindowState: + def __init__(self): + self.focus = INITIAL_FOCUS + self.announcement = None + self.node_classes = accesskit.NodeClassSet() + + def build_root(self): + builder = accesskit.NodeBuilder(accesskit.Role.WINDOW) + builder.set_children([BUTTON_1_ID, BUTTON_2_ID]) + if self.announcement is not None: + builder.push_child(ANNOUNCEMENT_ID) + builder.set_name(WINDOW_TITLE) + return builder.build(self.node_classes) + + def build_initial_tree(self): + root = self.build_root() + button_1 = build_button(BUTTON_1_ID, "Button 1", self.node_classes) + button_2 = build_button(BUTTON_2_ID, "Button 2", self.node_classes) + result = accesskit.TreeUpdate(self.focus) + tree = accesskit.Tree(WINDOW_ID) + tree.app_name = "Hello world" + result.tree = tree + result.nodes.append((WINDOW_ID, root)) + result.nodes.append((BUTTON_1_ID, button_1)) + result.nodes.append((BUTTON_2_ID, button_2)) + if self.announcement is not None: + result.nodes.append( + ( + ANNOUNCEMENT_ID, + build_announcement(self.announcement, self.node_classes), + ) + ) + return result + + def press_button(self, adapter, id): + self.announcement = ( + "You pressed button 1" if id == BUTTON_1_ID else "You pressed button 2" + ) + adapter.update_if_active(self.build_tree_update_for_button_press) + + def build_tree_update_for_button_press(self): + update = accesskit.TreeUpdate(self.focus) + update.nodes.append( + (ANNOUNCEMENT_ID, build_announcement(self.announcement, self.node_classes)) + ) + update.nodes.append((WINDOW_ID, self.build_root())) + return update + + def set_focus(self, adapter, focus): + self.focus = focus + adapter.update_if_active(self.build_tree_update_for_focus_update) + + def build_tree_update_for_focus_update(self): + return accesskit.TreeUpdate(self.focus) + + +def do_action(request): + if request.action in [accesskit.Action.DEFAULT, accesskit.Action.FOCUS]: + args = { + "event": SET_FOCUS_MSG + if request.action == accesskit.Action.FOCUS + else DO_DEFAULT_ACTION_MSG, + "target": request.target, + } + event = pygame.event.Event(ACCESSKIT_EVENT, args) + pygame.event.post(event) + + +def main(): + print("This example has no visible GUI, and a keyboard interface:") + print("- [Tab] switches focus between two logical buttons.") + print( + "- [Space] 'presses' the button, adding static text in a live region announcing that it was pressed." + ) + if PLATFORM_SYSTEM == "Windows": + print( + "Enable Narrator with [Win]+[Ctrl]+[Enter] (or [Win]+[Enter] on older versions of Windows)." + ) + elif os.name == "posix" and PLATFORM_SYSTEM != "Darwin": + print("Enable Orca with [Super]+[Alt]+[S].") + + state = WindowState() + pygame.display.set_mode((WINDOW_WIDTH, WINDOW_HEIGHT), pygame.HIDDEN) + pygame.display.set_caption(WINDOW_TITLE) + adapter = PygameAdapter(state.build_initial_tree, do_action) + pygame.display.set_mode((WINDOW_WIDTH, WINDOW_HEIGHT), pygame.SHOWN) + is_running = True + while is_running: + for event in pygame.event.get(): + if event.type == pygame.QUIT: + is_running = False + elif event.type == pygame.WINDOWFOCUSGAINED: + adapter.update_window_focus_state(True) + elif event.type == pygame.WINDOWFOCUSLOST: + adapter.update_window_focus_state(False) + elif event.type == pygame.KEYDOWN: + if event.key == pygame.K_TAB: + new_focus = ( + BUTTON_2_ID if state.focus == BUTTON_1_ID else BUTTON_1_ID + ) + state.set_focus(adapter, new_focus) + elif event.key == pygame.K_SPACE: + state.press_button(adapter, state.focus) + elif event.type == ACCESSKIT_EVENT and event.__dict__["target"] in [ + BUTTON_1_ID, + BUTTON_2_ID, + ]: + target = event.__dict__["target"] + if event.__dict__["event"] == SET_FOCUS_MSG: + state.set_focus(adapter, target) + elif event.__dict__["event"] == DO_DEFAULT_ACTION_MSG: + state.press_button(adapter, target) + + +if __name__ == "__main__": + main() diff --git a/bindings/python/examples/pygame/requirements.txt b/bindings/python/examples/pygame/requirements.txt new file mode 100644 index 000000000..c2e970537 --- /dev/null +++ b/bindings/python/examples/pygame/requirements.txt @@ -0,0 +1,2 @@ +accesskit +pygame==2.5.0 diff --git a/bindings/python/src/common.rs b/bindings/python/src/common.rs new file mode 100644 index 000000000..047bde0a2 --- /dev/null +++ b/bindings/python/src/common.rs @@ -0,0 +1,752 @@ +// Copyright 2023 The AccessKit Authors. All rights reserved. +// Licensed under the Apache License, Version 2.0 (found in +// the LICENSE-APACHE file) or the MIT license (found in +// the LICENSE-MIT file), at your option. + +use pyo3::{prelude::*, types::PyList}; + +#[pyclass(module = "accesskit")] +pub struct NodeClassSet(accesskit::NodeClassSet); + +#[pymethods] +impl NodeClassSet { + #[new] + pub fn __new__() -> Self { + Self(accesskit::NodeClassSet::new()) + } +} + +#[derive(Clone)] +#[pyclass(module = "accesskit")] +pub struct Node(accesskit::Node); + +impl Node { + #[inline] + fn inner(&self) -> &accesskit::Node { + &self.0 + } +} + +impl From for accesskit::Node { + fn from(node: Node) -> accesskit::Node { + node.0 + } +} + +#[pymethods] +impl Node { + #[getter] + pub fn role(&self) -> accesskit::Role { + self.inner().role() + } + + pub fn supports_action(&self, action: accesskit::Action) -> bool { + self.inner().supports_action(action) + } +} + +#[pyclass(module = "accesskit")] +pub struct NodeBuilder(Option); + +impl NodeBuilder { + #[inline] + fn inner(&self) -> &accesskit::NodeBuilder { + self.0.as_ref().unwrap() + } + + #[inline] + fn inner_mut(&mut self) -> &mut accesskit::NodeBuilder { + self.0.as_mut().unwrap() + } +} + +#[pymethods] +impl NodeBuilder { + #[new] + pub fn new(role: accesskit::Role) -> NodeBuilder { + Self(Some(accesskit::NodeBuilder::new(role))) + } + + pub fn build(&mut self, classes: &mut NodeClassSet) -> Node { + let builder = self.0.take().unwrap(); + Node(builder.build(&mut classes.0)) + } + + #[getter] + pub fn role(&self) -> accesskit::Role { + self.inner().role() + } + + pub fn set_role(&mut self, value: accesskit::Role) { + self.inner_mut().set_role(value); + } + + pub fn supports_action(&self, action: accesskit::Action) -> bool { + self.inner().supports_action(action) + } + + pub fn add_action(&mut self, action: accesskit::Action) { + self.inner_mut().add_action(action) + } + + pub fn remove_action(&mut self, action: accesskit::Action) { + self.inner_mut().remove_action(action) + } + + pub fn clear_actions(&mut self) { + self.inner_mut().clear_actions() + } +} + +pub type NodeId = u64; + +#[derive(Clone)] +#[pyclass(module = "accesskit")] +pub struct CustomAction(accesskit::CustomAction); + +#[pymethods] +impl CustomAction { + #[new] + pub fn new(id: i32, description: &str) -> Self { + Self(accesskit::CustomAction { + id, + description: description.into(), + }) + } + + #[getter] + pub fn id(&self) -> i32 { + self.0.id + } + + #[setter] + pub fn set_id(&mut self, id: i32) { + self.0.id = id; + } + + #[getter] + pub fn description(&self) -> &str { + &self.0.description + } + + #[setter] + pub fn set_description(&mut self, description: &str) { + self.0.description = description.into() + } +} + +impl From for accesskit::CustomAction { + fn from(action: CustomAction) -> Self { + action.0 + } +} + +impl From for CustomAction { + fn from(action: accesskit::CustomAction) -> Self { + Self(action) + } +} + +#[derive(Clone)] +#[pyclass(module = "accesskit")] +pub struct TextPosition(accesskit::TextPosition); + +#[pymethods] +impl TextPosition { + #[new] + pub fn new(node: NodeId, character_index: usize) -> Self { + Self(accesskit::TextPosition { + node: node.into(), + character_index, + }) + } + + #[getter] + pub fn node(&self) -> NodeId { + self.0.node.into() + } + + #[setter] + pub fn set_node(&mut self, node: NodeId) { + self.0.node = node.into(); + } + + #[getter] + pub fn character_index(&self) -> usize { + self.0.character_index + } + + #[setter] + pub fn set_character_index(&mut self, character_index: usize) { + self.0.character_index = character_index; + } +} + +impl From for TextPosition { + fn from(position: accesskit::TextPosition) -> Self { + Self(position) + } +} + +#[derive(Clone)] +#[pyclass(get_all, set_all, module = "accesskit")] +pub struct TextSelection { + pub anchor: Py, + pub focus: Py, +} + +#[pymethods] +impl TextSelection { + #[new] + pub fn new(anchor: Py, focus: Py) -> Self { + Self { anchor, focus } + } +} + +impl From<&accesskit::TextSelection> for TextSelection { + fn from(selection: &accesskit::TextSelection) -> Self { + Python::with_gil(|py| Self { + anchor: Py::new(py, TextPosition::from(selection.anchor)).unwrap(), + focus: Py::new(py, TextPosition::from(selection.focus)).unwrap(), + }) + } +} + +impl From for accesskit::TextSelection { + fn from(selection: TextSelection) -> Self { + Python::with_gil(|py| accesskit::TextSelection { + anchor: selection.anchor.as_ref(py).borrow().0, + focus: selection.focus.as_ref(py).borrow().0, + }) + } +} + +impl From for Box { + fn from(selection: TextSelection) -> Self { + Box::new(selection.into()) + } +} + +macro_rules! clearer { + ($clearer:ident) => { + #[pymethods] + impl NodeBuilder { + pub fn $clearer(&mut self) { + self.inner_mut().$clearer() + } + } + }; +} + +macro_rules! getters { + ($getter:ident, $macro_name:ident, $type:ty) => { + $macro_name! { Node, $getter, $type } + $macro_name! { NodeBuilder, $getter, $type } + }; +} + +macro_rules! simple_getter { + ($struct_name:ident, $getter:ident, $type:ty) => { + #[pymethods] + impl $struct_name { + #[getter] + pub fn $getter(&self) -> $type { + self.inner().$getter() + } + } + }; +} + +macro_rules! convertion_getter { + ($struct_name:ident, $getter:ident, $type:ty) => { + #[pymethods] + impl $struct_name { + #[getter] + pub fn $getter(&self) -> $type { + self.inner().$getter().into() + } + } + }; +} + +macro_rules! option_getter { + ($struct_name:ident, $getter:ident, $type:ty) => { + #[pymethods] + impl $struct_name { + #[getter] + pub fn $getter(&self) -> $type { + self.inner().$getter().map(Into::into) + } + } + }; +} + +macro_rules! simple_setter { + ($setter:ident, $setter_param:ty) => { + #[pymethods] + impl NodeBuilder { + pub fn $setter(&mut self, value: $setter_param) { + self.inner_mut().$setter(value); + } + } + }; +} + +macro_rules! convertion_setter { + ($setter:ident, $setter_param:ty) => { + #[pymethods] + impl NodeBuilder { + pub fn $setter(&mut self, value: $setter_param) { + self.inner_mut().$setter(value.into()); + } + } + }; +} + +macro_rules! flag_methods { + ($(($getter:ident, $setter:ident, $clearer:ident)),+) => { + $(getters! { $getter, simple_getter, bool } + #[pymethods] + impl NodeBuilder { + pub fn $setter(&mut self) { + self.inner_mut().$setter(); + } + } + clearer! { $clearer })* + } +} + +macro_rules! property_methods { + ( + $(($getter:ident, $getter_macro:ident, $getter_result:ty, $setter:ident, $setter_macro:ident, $setter_param:ty, $clearer:ident)),+ + ) => { + $(getters! { $getter, $getter_macro, $getter_result } + $setter_macro! { $setter, $setter_param } + clearer! { $clearer })* + } +} + +macro_rules! vec_property_methods { + ($(($py_item_type:ty, $accesskit_item_type:ty, $getter:ident, $setter:ident, $pusher:ident, $clearer:ident)),+) => { + #[pymethods] + impl Node { + $(#[getter] + pub fn $getter(&self, py: Python) -> Py { + let values = self.inner().$getter().iter().cloned().map(<$py_item_type>::from).map(|i| i.into_py(py)); + PyList::new(py, values).into() + })* + } + $(#[pymethods] + impl NodeBuilder { + #[getter] + pub fn $getter(&self, py: Python) -> Py { + let values = self.inner().$getter().iter().cloned().map(<$py_item_type>::from).map(|i| i.into_py(py)); + PyList::new(py, values).into() + } + pub fn $setter(&mut self, values: &PyList) { + let values = values + .iter() + .map(PyAny::extract::<$py_item_type>) + .filter_map(PyResult::ok) + .map(<$accesskit_item_type>::from) + .collect::>(); + self.inner_mut().$setter(values); + } + pub fn $pusher(&mut self, item: $py_item_type) { + self.inner_mut().$pusher(item.into()); + } + } + clearer! { $clearer })* + } +} + +macro_rules! string_property_methods { + ($(($getter:ident, $setter:ident, $clearer:ident)),+) => { + $(property_methods! { + ($getter, option_getter, Option<&str>, $setter, simple_setter, String, $clearer) + })* + } +} + +macro_rules! node_id_vec_property_methods { + ($(($getter:ident, $setter:ident, $pusher:ident, $clearer:ident)),+) => { + $(vec_property_methods! { + (NodeId, accesskit::NodeId, $getter, $setter, $pusher, $clearer) + })* + } +} + +macro_rules! node_id_property_methods { + ($(($getter:ident, $setter:ident, $clearer:ident)),+) => { + $(property_methods! { + ($getter, option_getter, Option, $setter, convertion_setter, NodeId, $clearer) + })* + } +} + +macro_rules! f64_property_methods { + ($(($getter:ident, $setter:ident, $clearer:ident)),+) => { + $(property_methods! { + ($getter, option_getter, Option, $setter, simple_setter, f64, $clearer) + })* + } +} + +macro_rules! usize_property_methods { + ($(($getter:ident, $setter:ident, $clearer:ident)),+) => { + $(property_methods! { + ($getter, option_getter, Option, $setter, simple_setter, usize, $clearer) + })* + } +} + +macro_rules! color_property_methods { + ($(($getter:ident, $setter:ident, $clearer:ident)),+) => { + $(property_methods! { + ($getter, option_getter, Option, $setter, simple_setter, u32, $clearer) + })* + } +} + +macro_rules! text_decoration_property_methods { + ($(($getter:ident, $setter:ident, $clearer:ident)),+) => { + $(property_methods! { + ($getter, option_getter, Option, $setter, convertion_setter, accesskit::TextDecoration, $clearer) + })* + } +} + +macro_rules! length_slice_property_methods { + ($(($getter:ident, $setter:ident, $clearer:ident)),+) => { + $(property_methods! { + ($getter, convertion_getter, Vec, $setter, simple_setter, Vec, $clearer) + })* + } +} + +macro_rules! coord_slice_property_methods { + ($(($getter:ident, $setter:ident, $clearer:ident)),+) => { + $(property_methods! { + ($getter, option_getter, Option>, $setter, simple_setter, Vec, $clearer) + })* + } +} + +macro_rules! bool_property_methods { + ($(($getter:ident, $setter:ident, $clearer:ident)),+) => { + $(property_methods! { + ($getter, option_getter, Option, $setter, simple_setter, bool, $clearer) + })* + } +} + +macro_rules! unique_enum_property_methods { + ($(($type:ty, $getter:ident, $setter:ident, $clearer:ident)),+) => { + $(property_methods! { + ($getter, option_getter, Option<$type>, $setter, simple_setter, $type, $clearer) + })* + } +} + +flag_methods! { + (is_hovered, set_hovered, clear_hovered), + (is_hidden, set_hidden, clear_hidden), + (is_linked, set_linked, clear_linked), + (is_multiselectable, set_multiselectable, clear_multiselectable), + (is_required, set_required, clear_required), + (is_visited, set_visited, clear_visited), + (is_busy, set_busy, clear_busy), + (is_live_atomic, set_live_atomic, clear_live_atomic), + (is_modal, set_modal, clear_modal), + (is_touch_transparent, set_touch_transparent, clear_touch_transparent), + (is_read_only, set_read_only, clear_read_only), + (is_disabled, set_disabled, clear_disabled), + (is_bold, set_bold, clear_bold), + (is_italic, set_italic, clear_italic), + (clips_children, set_clips_children, clear_clips_children), + (is_line_breaking_object, set_is_line_breaking_object, clear_is_line_breaking_object), + (is_page_breaking_object, set_is_page_breaking_object, clear_is_page_breaking_object), + (is_spelling_error, set_is_spelling_error, clear_is_spelling_error), + (is_grammar_error, set_is_grammar_error, clear_is_grammar_error), + (is_search_match, set_is_search_match, clear_is_search_match), + (is_suggestion, set_is_suggestion, clear_is_suggestion) +} + +node_id_vec_property_methods! { + (children, set_children, push_child, clear_children), + (controls, set_controls, push_controlled, clear_controls), + (details, set_details, push_detail, clear_details), + (described_by, set_described_by, push_described_by, clear_described_by), + (flow_to, set_flow_to, push_flow_to, clear_flow_to), + (labelled_by, set_labelled_by, push_labelled_by, clear_labelled_by), + (radio_group, set_radio_group, push_to_radio_group, clear_radio_group) +} + +node_id_property_methods! { + (active_descendant, set_active_descendant, clear_active_descendant), + (error_message, set_error_message, clear_error_message), + (in_page_link_target, set_in_page_link_target, clear_in_page_link_target), + (member_of, set_member_of, clear_member_of), + (next_on_line, set_next_on_line, clear_next_on_line), + (previous_on_line, set_previous_on_line, clear_previous_on_line), + (popup_for, set_popup_for, clear_popup_for), + (table_header, set_table_header, clear_table_header), + (table_row_header, set_table_row_header, clear_table_row_header), + (table_column_header, set_table_column_header, clear_table_column_header) +} + +string_property_methods! { + (name, set_name, clear_name), + (description, set_description, clear_description), + (value, set_value, clear_value), + (access_key, set_access_key, clear_access_key), + (class_name, set_class_name, clear_class_name), + (font_family, set_font_family, clear_font_family), + (html_tag, set_html_tag, clear_html_tag), + (inner_html, set_inner_html, clear_inner_html), + (keyboard_shortcut, set_keyboard_shortcut, clear_keyboard_shortcut), + (language, set_language, clear_language), + (placeholder, set_placeholder, clear_placeholder), + (role_description, set_role_description, clear_role_description), + (state_description, set_state_description, clear_state_description), + (tooltip, set_tooltip, clear_tooltip), + (url, set_url, clear_url) +} + +f64_property_methods! { + (scroll_x, set_scroll_x, clear_scroll_x), + (scroll_x_min, set_scroll_x_min, clear_scroll_x_min), + (scroll_x_max, set_scroll_x_max, clear_scroll_x_max), + (scroll_y, set_scroll_y, clear_scroll_y), + (scroll_y_min, set_scroll_y_min, clear_scroll_y_min), + (scroll_y_max, set_scroll_y_max, clear_scroll_y_max), + (numeric_value, set_numeric_value, clear_numeric_value), + (min_numeric_value, set_min_numeric_value, clear_min_numeric_value), + (max_numeric_value, set_max_numeric_value, clear_max_numeric_value), + (numeric_value_step, set_numeric_value_step, clear_numeric_value_step), + (numeric_value_jump, set_numeric_value_jump, clear_numeric_value_jump), + (font_size, set_font_size, clear_font_size), + (font_weight, set_font_weight, clear_font_weight) +} + +usize_property_methods! { + (table_row_count, set_table_row_count, clear_table_row_count), + (table_column_count, set_table_column_count, clear_table_column_count), + (table_row_index, set_table_row_index, clear_table_row_index), + (table_column_index, set_table_column_index, clear_table_column_index), + (table_cell_column_index, set_table_cell_column_index, clear_table_cell_column_index), + (table_cell_column_span, set_table_cell_column_span, clear_table_cell_column_span), + (table_cell_row_index, set_table_cell_row_index, clear_table_cell_row_index), + (table_cell_row_span, set_table_cell_row_span, clear_table_cell_row_span), + (hierarchical_level, set_hierarchical_level, clear_hierarchical_level), + (size_of_set, set_size_of_set, clear_size_of_set), + (position_in_set, set_position_in_set, clear_position_in_set) +} + +color_property_methods! { + (color_value, set_color_value, clear_color_value), + (background_color, set_background_color, clear_background_color), + (foreground_color, set_foreground_color, clear_foreground_color) +} + +text_decoration_property_methods! { + (overline, set_overline, clear_overline), + (strikethrough, set_strikethrough, clear_strikethrough), + (underline, set_underline, clear_underline) +} + +length_slice_property_methods! { + (character_lengths, set_character_lengths, clear_character_lengths), + (word_lengths, set_word_lengths, clear_word_lengths) +} + +coord_slice_property_methods! { + (character_positions, set_character_positions, clear_character_positions), + (character_widths, set_character_widths, clear_character_widths) +} + +bool_property_methods! { + (is_expanded, set_expanded, clear_expanded), + (is_selected, set_selected, clear_selected) +} + +unique_enum_property_methods! { + (accesskit::Invalid, invalid, set_invalid, clear_invalid), + (accesskit::Checked, checked, set_checked, clear_checked), + (accesskit::Live, live, set_live, clear_live), + (accesskit::DefaultActionVerb, default_action_verb, set_default_action_verb, clear_default_action_verb), + (accesskit::TextDirection, text_direction, set_text_direction, clear_text_direction), + (accesskit::Orientation, orientation, set_orientation, clear_orientation), + (accesskit::SortDirection, sort_direction, set_sort_direction, clear_sort_direction), + (accesskit::AriaCurrent, aria_current, set_aria_current, clear_aria_current), + (accesskit::AutoComplete, auto_complete, set_auto_complete, clear_auto_complete), + (accesskit::HasPopup, has_popup, set_has_popup, clear_has_popup), + (accesskit::ListStyle, list_style, set_list_style, clear_list_style), + (accesskit::TextAlign, text_align, set_text_align, clear_text_align), + (accesskit::VerticalOffset, vertical_offset, set_vertical_offset, clear_vertical_offset) +} + +property_methods! { + (transform, option_getter, Option, set_transform, simple_setter, crate::Affine, clear_transform), + (bounds, option_getter, Option, set_bounds, convertion_setter, crate::Rect, clear_bounds), + (text_selection, option_getter, Option, set_text_selection, simple_setter, TextSelection, clear_text_selection) +} + +vec_property_methods! { + (CustomAction, accesskit::CustomAction, custom_actions, set_custom_actions, push_custom_action, clear_custom_actions) +} + +#[derive(Clone)] +#[pyclass(module = "accesskit", get_all, set_all)] +pub struct Tree { + pub root: NodeId, + pub app_name: Option, + pub toolkit_name: Option, + toolkit_version: Option, +} + +#[pymethods] +impl Tree { + #[new] + pub fn new(root: NodeId) -> Self { + Self { + root, + app_name: None, + toolkit_name: None, + toolkit_version: None, + } + } +} + +impl From for accesskit::Tree { + fn from(tree: Tree) -> Self { + Self { + root: tree.root.into(), + app_name: tree.app_name, + toolkit_name: tree.toolkit_name, + toolkit_version: tree.toolkit_version, + } + } +} + +#[derive(Clone)] +#[pyclass(module = "accesskit", get_all, set_all)] +pub struct TreeUpdate { + pub nodes: Py, + pub tree: Option>, + pub focus: NodeId, +} + +#[pymethods] +impl TreeUpdate { + #[new] + pub fn new(py: Python<'_>, focus: NodeId) -> Self { + Self { + nodes: PyList::empty(py).into(), + tree: None, + focus, + } + } +} + +impl From for accesskit::TreeUpdate { + fn from(update: TreeUpdate) -> Self { + Python::with_gil(|py| Self { + nodes: update + .nodes + .as_ref(py) + .iter() + .map(PyAny::extract::<(NodeId, Node)>) + .filter_map(Result::ok) + .map(|(id, node)| (id.into(), node.into())) + .collect(), + tree: update.tree.map(|tree| { + let tree = tree.as_ref(py).borrow(); + accesskit::Tree { + root: tree.root.into(), + app_name: tree.app_name.clone(), + toolkit_name: tree.toolkit_name.clone(), + toolkit_version: tree.toolkit_version.clone(), + } + }), + focus: update.focus.into(), + }) + } +} + +#[pyclass(module = "accesskit")] +pub struct ActionData(accesskit::ActionData); + +#[pymethods] +impl ActionData { + #[staticmethod] + pub fn custom_action(action: i32) -> Self { + accesskit::ActionData::CustomAction(action).into() + } + + #[staticmethod] + pub fn value(value: &str) -> Self { + accesskit::ActionData::Value(value.into()).into() + } + + #[staticmethod] + pub fn numeric_value(value: f64) -> Self { + accesskit::ActionData::NumericValue(value).into() + } + + #[staticmethod] + pub fn scroll_target_rect(rect: crate::Rect) -> Self { + accesskit::ActionData::ScrollTargetRect(rect.into()).into() + } + + #[staticmethod] + pub fn scroll_to_point(point: crate::Point) -> Self { + accesskit::ActionData::ScrollToPoint(point.into()).into() + } + + #[staticmethod] + pub fn set_scroll_offset(offset: crate::Point) -> Self { + accesskit::ActionData::SetScrollOffset(offset.into()).into() + } + + #[staticmethod] + pub fn set_text_selection(selection: TextSelection) -> Self { + accesskit::ActionData::SetTextSelection(selection.into()).into() + } +} + +impl From for ActionData { + fn from(data: accesskit::ActionData) -> Self { + Self(data) + } +} + +#[pyclass(get_all, set_all, module = "accesskit")] +pub struct ActionRequest { + pub action: accesskit::Action, + pub target: NodeId, + pub data: Option>, +} + +impl From for ActionRequest { + fn from(request: accesskit::ActionRequest) -> Self { + Python::with_gil(|py| Self { + action: request.action, + target: request.target.into(), + data: request + .data + .map(|data| Py::new(py, ActionData::from(data)).unwrap()), + }) + } +} + +pub struct PythonActionHandler(pub(crate) Py); + +impl accesskit::ActionHandler for PythonActionHandler { + fn do_action(&mut self, request: accesskit::ActionRequest) { + let request = ActionRequest::from(request); + Python::with_gil(|py| { + self.0.call(py, (request,), None).unwrap(); + }); + } +} diff --git a/bindings/python/src/geometry.rs b/bindings/python/src/geometry.rs new file mode 100644 index 000000000..4df784a07 --- /dev/null +++ b/bindings/python/src/geometry.rs @@ -0,0 +1,251 @@ +// Copyright 2023 The AccessKit Authors. All rights reserved. +// Licensed under the Apache License, Version 2.0 (found in +// the LICENSE-APACHE file) or the MIT license (found in +// the LICENSE-MIT file), at your option. + +use pyo3::prelude::*; + +#[derive(Clone)] +#[pyclass(module = "accesskit")] +pub struct Affine(accesskit::Affine); + +#[pymethods] +impl Affine { + #[new] + pub fn new(c: [f64; 6]) -> Affine { + accesskit::Affine::new(c).into() + } + + #[getter] + pub fn coeffs(&self) -> [f64; 6] { + self.0.as_coeffs() + } +} + +impl From for Box { + fn from(value: Affine) -> Self { + Box::new(value.0) + } +} + +impl From for Affine { + fn from(value: accesskit::Affine) -> Self { + Self(value) + } +} + +impl From<&accesskit::Affine> for Affine { + fn from(value: &accesskit::Affine) -> Self { + Self(*value) + } +} + +#[derive(Clone)] +#[pyclass(module = "accesskit")] +pub struct Point(accesskit::Point); + +#[pymethods] +impl Point { + #[new] + pub fn new(x: f64, y: f64) -> Self { + accesskit::Point::new(x, y).into() + } + + #[getter] + pub fn get_x(&self) -> f64 { + self.0.x + } + + #[setter] + pub fn set_x(&mut self, value: f64) { + self.0.x = value + } + + #[getter] + pub fn get_y(&self) -> f64 { + self.0.y + } + + #[setter] + pub fn set_y(&mut self, value: f64) { + self.0.y = value + } +} + +impl From for accesskit::Point { + fn from(value: Point) -> Self { + value.0 + } +} + +impl From for Point { + fn from(value: accesskit::Point) -> Self { + Self(value) + } +} + +#[derive(Clone)] +#[pyclass(module = "accesskit")] +pub struct Rect(accesskit::Rect); + +#[pymethods] +impl Rect { + #[new] + pub fn new(x0: f64, y0: f64, x1: f64, y1: f64) -> Rect { + accesskit::Rect::new(x0, y0, x1, y1).into() + } + + #[staticmethod] + pub fn from_points(p0: Point, p1: Point) -> Self { + let p0 = accesskit::Point::from(p0); + let p1 = accesskit::Point::from(p1); + accesskit::Rect::from_points(p0, p1).into() + } + + #[staticmethod] + pub fn from_origin_size(origin: Point, size: Size) -> Self { + let origin = accesskit::Point::from(origin); + let size = accesskit::Size::from(size); + accesskit::Rect::from_origin_size(origin, size).into() + } + + #[getter] + pub fn get_x0(&self) -> f64 { + self.0.x0 + } + + #[setter] + pub fn set_x0(&mut self, value: f64) { + self.0.x0 = value + } + + #[getter] + pub fn get_y0(&self) -> f64 { + self.0.y0 + } + + #[setter] + pub fn set_y0(&mut self, value: f64) { + self.0.y0 = value + } + + #[getter] + pub fn get_x1(&self) -> f64 { + self.0.x1 + } + + #[setter] + pub fn set_x1(&mut self, value: f64) { + self.0.x1 = value + } + + #[getter] + pub fn get_y1(&self) -> f64 { + self.0.y1 + } + + #[setter] + pub fn set_y1(&mut self, value: f64) { + self.0.y1 = value + } +} + +impl From for accesskit::Rect { + fn from(value: Rect) -> Self { + value.0 + } +} + +impl From for Rect { + fn from(value: accesskit::Rect) -> Self { + Self(value) + } +} + +#[derive(Clone)] +#[pyclass(module = "accesskit")] +pub struct Size(accesskit::Size); + +#[pymethods] +impl Size { + #[new] + pub fn new(width: f64, height: f64) -> Self { + accesskit::Size::new(width, height).into() + } + + #[getter] + pub fn get_width(&self) -> f64 { + self.0.width + } + + #[setter] + pub fn set_width(&mut self, value: f64) { + self.0.width = value + } + + #[getter] + pub fn get_height(&self) -> f64 { + self.0.height + } + + #[setter] + pub fn set_height(&mut self, value: f64) { + self.0.height = value + } +} + +impl From for accesskit::Size { + fn from(value: Size) -> Self { + value.0 + } +} + +impl From for Size { + fn from(value: accesskit::Size) -> Self { + Self(value) + } +} + +#[derive(Clone)] +#[pyclass(module = "accesskit")] +pub struct Vec2(accesskit::Vec2); + +#[pymethods] +impl Vec2 { + #[new] + pub fn new(x: f64, y: f64) -> Vec2 { + accesskit::Vec2::new(x, y).into() + } + + #[getter] + pub fn get_x(&self) -> f64 { + self.0.x + } + + #[setter] + pub fn set_x(&mut self, value: f64) { + self.0.x = value + } + + #[getter] + pub fn get_y(&self) -> f64 { + self.0.y + } + + #[setter] + pub fn set_y(&mut self, value: f64) { + self.0.y = value + } +} + +impl From for accesskit::Vec2 { + fn from(value: Vec2) -> Self { + value.0 + } +} + +impl From for Vec2 { + fn from(value: accesskit::Vec2) -> Self { + Self(value) + } +} diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs new file mode 100644 index 000000000..cad553cab --- /dev/null +++ b/bindings/python/src/lib.rs @@ -0,0 +1,90 @@ +// Copyright 2023 The AccessKit Authors. All rights reserved. +// Licensed under the Apache License, Version 2.0 (found in +// the LICENSE-APACHE file) or the MIT license (found in +// the LICENSE-MIT file), at your option. + +mod common; +mod geometry; + +#[cfg(target_os = "macos")] +mod macos; +#[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd", +))] +mod unix; +#[cfg(target_os = "windows")] +mod windows; + +pub use common::*; +pub use geometry::*; + +use pyo3::prelude::*; + +#[pymodule] +fn accesskit(py: Python<'_>, m: &PyModule) -> PyResult<()> { + m.add_class::<::accesskit::Role>()?; + m.add_class::<::accesskit::Action>()?; + m.add_class::<::accesskit::Orientation>()?; + m.add_class::<::accesskit::TextDirection>()?; + m.add_class::<::accesskit::Invalid>()?; + m.add_class::<::accesskit::Checked>()?; + m.add_class::<::accesskit::DefaultActionVerb>()?; + m.add_class::<::accesskit::SortDirection>()?; + m.add_class::<::accesskit::AriaCurrent>()?; + m.add_class::<::accesskit::Live>()?; + m.add_class::<::accesskit::HasPopup>()?; + m.add_class::<::accesskit::ListStyle>()?; + m.add_class::<::accesskit::TextAlign>()?; + m.add_class::<::accesskit::VerticalOffset>()?; + m.add_class::<::accesskit::TextDecoration>()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + + #[cfg(target_os = "macos")] + { + let macos_module = PyModule::new(py, "macos")?; + macos_module.add_class::()?; + macos_module.add_class::()?; + macos_module.add_class::()?; + macos_module.add_function(wrap_pyfunction!( + macos::add_focus_forwarder_to_window_class, + macos_module + )?)?; + m.add_submodule(macos_module)?; + } + #[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd", + ))] + { + let unix_module = PyModule::new(py, "unix")?; + unix_module.add_class::()?; + m.add_submodule(unix_module)?; + } + #[cfg(target_os = "windows")] + { + let windows_module = PyModule::new(py, "windows")?; + windows_module.add_class::()?; + windows_module.add_class::()?; + windows_module.add_class::()?; + windows_module.add_class::()?; + m.add_submodule(windows_module)?; + } + + Ok(()) +} diff --git a/bindings/python/src/macos.rs b/bindings/python/src/macos.rs new file mode 100644 index 000000000..5b222b0d0 --- /dev/null +++ b/bindings/python/src/macos.rs @@ -0,0 +1,194 @@ +// Copyright 2023 The AccessKit Authors. All rights reserved. +// Licensed under the Apache License, Version 2.0 (found in +// the LICENSE-APACHE file) or the MIT license (found in +// the LICENSE-MIT file), at your option. + +use crate::{PythonActionHandler, TreeUpdate}; +use accesskit_macos::NSPoint; +use pyo3::{prelude::*, types::PyCapsule}; +use std::ffi::c_void; + +/// This class must only be used from the main thread. +#[pyclass(module = "accesskit.macos", unsendable)] +pub struct QueuedEvents(Option); + +#[pymethods] +impl QueuedEvents { + pub fn raise_events(&mut self) { + let events = self.0.take().unwrap(); + events.raise(); + } +} + +impl From for QueuedEvents { + fn from(events: accesskit_macos::QueuedEvents) -> Self { + Self(Some(events)) + } +} + +/// This class must only be used from the main thread. +#[pyclass(module = "accesskit.macos", unsendable)] +pub struct Adapter(accesskit_macos::Adapter); + +#[pymethods] +impl Adapter { + /// Create a new macOS adapter. + /// + /// The action handler will always be called on the main thread. + /// + /// # Safety + /// + /// `view` must be a valid, unreleased pointer to an `NSView`. + #[new] + pub unsafe fn new( + view: &PyAny, + initial_state: TreeUpdate, + is_view_focused: bool, + handler: Py, + ) -> Self { + Self(accesskit_macos::Adapter::new( + to_void_ptr(view), + initial_state.into(), + is_view_focused, + Box::new(PythonActionHandler(handler)), + )) + } + + /// You must call `accesskit.macos.QueuedEvents.raise_events` on the returned value. + pub fn update(&self, update: TreeUpdate) -> QueuedEvents { + self.0.update(update.into()).into() + } + + /// You must call `accesskit.macos.QueuedEvents.raise_events` on the returned value. + pub fn update_view_focus_state(&self, is_focused: bool) -> QueuedEvents { + self.0.update_view_focus_state(is_focused).into() + } + + pub fn view_children(&self) -> isize { + self.0.view_children() as _ + } + + pub fn focus(&self) -> isize { + self.0.focus() as _ + } + + pub fn hit_test(&self, x: f64, y: f64) -> isize { + self.0.hit_test(NSPoint::new(x, y)) as _ + } +} + +/// This class must only be used from the main thread. +#[pyclass(module = "accesskit.macos", unsendable)] +pub struct SubclassingAdapter(accesskit_macos::SubclassingAdapter); + +#[pymethods] +impl SubclassingAdapter { + /// Create an adapter that dynamically subclasses the specified view. + /// This must be done before the view is shown or focused for + /// the first time. + /// + /// The action handler will always be called on the main thread. + /// + /// # Safety + /// + /// `view` must be a valid, unreleased pointer to an `NSView`. + #[new] + pub unsafe fn new(view: &PyAny, source: Py, handler: Py) -> Self { + Self(accesskit_macos::SubclassingAdapter::new( + to_void_ptr(view), + move || { + Python::with_gil(|py| { + source + .call0(py) + .unwrap() + .extract::(py) + .unwrap() + .into() + }) + }, + Box::new(PythonActionHandler(handler)), + )) + } + + /// Create an adapter that dynamically subclasses the content view + /// of the specified window. + /// + /// The action handler will always be called on the main thread. + /// + /// # Safety + /// + /// `window` must be a valid, unreleased pointer to an `NSWindow`. + /// + /// # Panics + /// + /// This function panics if the specified window doesn't currently have + /// a content view. + #[staticmethod] + pub unsafe fn for_window(window: &PyAny, source: Py, handler: Py) -> Self { + Self(accesskit_macos::SubclassingAdapter::for_window( + to_void_ptr(window), + move || { + Python::with_gil(|py| { + source + .call0(py) + .unwrap() + .extract::(py) + .unwrap() + .into() + }) + }, + Box::new(PythonActionHandler(handler)), + )) + } + + /// You must call `accesskit.macos.QueuedEvents.raise_events` on the returned value. + pub fn update(&self, update: TreeUpdate) -> QueuedEvents { + self.0.update(update.into()).into() + } + + /// You must call `accesskit.macos.QueuedEvents.raise_events` on the returned value. It can be `None` if the window is not active. + pub fn update_if_active( + &self, + py: Python<'_>, + update_factory: Py, + ) -> Option { + self.0 + .update_if_active(|| { + let update = update_factory.call0(py).unwrap(); + update.extract::(py).unwrap().into() + }) + .map(Into::into) + } + + /// You must call `accesskit.macos.QueuedEvents.raise_events` on the returned value. It can be `None` if the window is not active. + pub fn update_view_focus_state(&self, is_focused: bool) -> Option { + self.0.update_view_focus_state(is_focused).map(Into::into) + } +} + +/// Modifies the specified class, which must be a subclass of `NSWindow`, +/// to include an `accessibilityFocusedUIElement` method that calls +/// the corresponding method on the window's content view. This is needed +/// for windowing libraries such as SDL that place the keyboard focus +/// directly on the window rather than the content view. +/// +/// # Safety +/// +/// This function is declared unsafe because the caller must ensure that the +/// code for this library is never unloaded from the application process, +/// since it's not possible to reverse this operation. It's safest +/// if this library is statically linked into the application's main executable. +/// Also, this function assumes that the specified class is a subclass +/// of `NSWindow`. +#[pyfunction] +pub unsafe fn add_focus_forwarder_to_window_class(class_name: &str) { + accesskit_macos::add_focus_forwarder_to_window_class(class_name) +} + +fn to_void_ptr(value: &PyAny) -> *mut c_void { + if let Ok(value) = value.extract::<&PyCapsule>() { + return value.pointer(); + } + let value = value.getattr("value").unwrap_or(value); + value.extract::().unwrap() as *mut _ +} diff --git a/bindings/python/src/unix.rs b/bindings/python/src/unix.rs new file mode 100644 index 000000000..554134775 --- /dev/null +++ b/bindings/python/src/unix.rs @@ -0,0 +1,48 @@ +// Copyright 2023 The AccessKit Authors. All rights reserved. +// Licensed under the Apache License, Version 2.0 (found in +// the LICENSE-APACHE file) or the MIT license (found in +// the LICENSE-MIT file), at your option. + +use crate::{PythonActionHandler, Rect, TreeUpdate}; +use pyo3::prelude::*; + +#[pyclass(module = "accesskit.unix")] +pub struct Adapter(accesskit_unix::Adapter); + +#[pymethods] +impl Adapter { + #[staticmethod] + pub fn create( + source: Py, + is_window_focused: bool, + action_handler: Py, + ) -> Option { + accesskit_unix::Adapter::new( + move || { + Python::with_gil(|py| { + source + .call0(py) + .unwrap() + .extract::(py) + .unwrap() + .into() + }) + }, + is_window_focused, + Box::new(PythonActionHandler(action_handler)), + ) + .map(Self) + } + + pub fn set_root_window_bounds(&mut self, outer: Rect, inner: Rect) { + self.0.set_root_window_bounds(outer.into(), inner.into()); + } + + pub fn update(&self, update: TreeUpdate) { + self.0.update(update.into()); + } + + pub fn update_window_focus_state(&self, is_focused: bool) { + self.0.update_window_focus_state(is_focused); + } +} diff --git a/bindings/python/src/windows.rs b/bindings/python/src/windows.rs new file mode 100644 index 000000000..23fa01a14 --- /dev/null +++ b/bindings/python/src/windows.rs @@ -0,0 +1,141 @@ +// Copyright 2023 The AccessKit Authors. All rights reserved. +// Licensed under the Apache License, Version 2.0 (found in +// the LICENSE-APACHE file) or the MIT license (found in +// the LICENSE-MIT file), at your option. + +use crate::{PythonActionHandler, TreeUpdate}; +use accesskit_windows::{HWND, LPARAM, WPARAM}; +use pyo3::prelude::*; + +#[derive(Clone)] +#[pyclass(module = "accesskit.windows")] +pub struct UiaInitMarker(accesskit_windows::UiaInitMarker); + +#[pymethods] +impl UiaInitMarker { + #[new] + pub fn __new__() -> Self { + Self(accesskit_windows::UiaInitMarker::new()) + } +} + +impl From for accesskit_windows::UiaInitMarker { + fn from(marker: UiaInitMarker) -> Self { + marker.0 + } +} + +#[pyclass(module = "accesskit.windows")] +pub struct QueuedEvents(Option); + +#[pymethods] +impl QueuedEvents { + pub fn raise_events(&mut self) { + let events = self.0.take().unwrap(); + events.raise(); + } +} + +impl From for QueuedEvents { + fn from(events: accesskit_windows::QueuedEvents) -> Self { + Self(Some(events)) + } +} + +#[pyclass(module = "accesskit.windows")] +pub struct Adapter(accesskit_windows::Adapter); + +#[pymethods] +impl Adapter { + /// Creates a new Windows platform adapter. + /// + /// The action handler may or may not be called on the thread that owns + /// the window. + #[new] + pub fn new( + hwnd: &PyAny, + initial_state: TreeUpdate, + is_window_focused: bool, + action_handler: Py, + uia_init_marker: UiaInitMarker, + ) -> Self { + Self(accesskit_windows::Adapter::new( + HWND(cast::(hwnd)), + initial_state.into(), + is_window_focused, + Box::new(PythonActionHandler(action_handler)), + uia_init_marker.into(), + )) + } + + /// You must call `accesskit.windows.QueuedEvents.raise_events` on the returned value. + pub fn update(&self, update: TreeUpdate) -> QueuedEvents { + self.0.update(update.into()).into() + } + + /// You must call `accesskit.windows.QueuedEvents.raise_events` on the returned value. + pub fn update_window_focus_state(&self, is_focused: bool) -> QueuedEvents { + self.0.update_window_focus_state(is_focused).into() + } + + pub fn handle_wm_getobject(&self, wparam: &PyAny, lparam: &PyAny) -> Option { + self.0 + .handle_wm_getobject(WPARAM(cast::(wparam)), LPARAM(cast::(lparam))) + .map(|lresult| lresult.into().0) + } +} + +/// This class must only be used from the main thread. +#[pyclass(module = "accesskit.windows", unsendable)] +pub struct SubclassingAdapter(accesskit_windows::SubclassingAdapter); + +#[pymethods] +impl SubclassingAdapter { + /// Creates a new Windows platform adapter using window subclassing. + /// This must be done before the window is shown or focused + /// for the first time. + /// + /// The action handler may or may not be called on the thread that owns + /// the window. + #[new] + pub fn new(hwnd: &PyAny, source: Py, action_handler: Py) -> Self { + Self(accesskit_windows::SubclassingAdapter::new( + HWND(cast::(hwnd)), + move || { + Python::with_gil(|py| { + source + .call0(py) + .unwrap() + .extract::(py) + .unwrap() + .into() + }) + }, + Box::new(PythonActionHandler(action_handler)), + )) + } + + /// You must call `accesskit.windows.QueuedEvents.raise_events` on the returned value. + pub fn update(&self, update: TreeUpdate) -> QueuedEvents { + self.0.update(update.into()).into() + } + + /// You must call `accesskit.windows.QueuedEvents.raise_events` on the returned value. It can be `None` if the window is not active. + pub fn update_if_active( + &self, + py: Python<'_>, + update_factory: Py, + ) -> Option { + self.0 + .update_if_active(|| { + let update = update_factory.call0(py).unwrap(); + update.extract::(py).unwrap().into() + }) + .map(Into::into) + } +} + +fn cast<'a, D: FromPyObject<'a>>(value: &'a PyAny) -> D { + let value = value.getattr("value").unwrap_or(value); + value.extract().unwrap() +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..c2c7dd8c4 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,38 @@ +[build-system] +requires = ["maturin>=1.0,<2.0"] +build-backend = "maturin" + +[tool.maturin] +manifest-path = "bindings/python/Cargo.toml" +features = ["pyo3/extension-module"] +exclude = [ + "bindings/python/examples/**" +] +include = [ + "LICENSE*" +] + +[tool.maturin.target."x86_64-apple-darwin"] +macos-deployment-target = "10.12" + +[tool.maturin.target."aarch64-apple-darwin"] +macos-deployment-target = "11.0" + +[tool.black] +include = 'bindings/python/.*\.py' + +[project] +name = "accesskit" +requires-python = ">= 3.7" +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.7", + "Topic :: Software Development :: User Interfaces" +] + +[project.urls] +Homepage = "https://github.com/AccessKit/accesskit" diff --git a/release-please-config.json b/release-please-config.json index 797027299..67d86f748 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -10,6 +10,7 @@ "platforms/unix": {}, "platforms/windows": {}, "platforms/winit": {}, - "bindings/c": {} + "bindings/c": {}, + "bindings/python": {} } } From 76624e97ea2695cf0835c25578e9e3177c976eec Mon Sep 17 00:00:00 2001 From: DataTriny Date: Sat, 21 Oct 2023 12:46:40 +0200 Subject: [PATCH 02/12] Use ruff to ensure formatting and linting --- .github/workflows/ci.yml | 9 +++++++-- pyproject.toml | 4 ++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 27b5383d9..62b87252a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,8 +28,13 @@ jobs: clang-format-version: 15 check-path: bindings/c - - name: black --check - uses: psf/black@stable + - name: ruff format + uses: chartboost/ruff-action@v1 + with: + args: format --check + + - name: ruff check + uses: chartboost/ruff-action@v1 test: runs-on: ${{ matrix.os }} diff --git a/pyproject.toml b/pyproject.toml index c2c7dd8c4..18f51115c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,8 +18,8 @@ macos-deployment-target = "10.12" [tool.maturin.target."aarch64-apple-darwin"] macos-deployment-target = "11.0" -[tool.black] -include = 'bindings/python/.*\.py' +[tool.ruff] +include = ['bindings/python/*.py'] [project] name = "accesskit" From 419f75a339ed9fa28876a1c9267f41c502800337 Mon Sep 17 00:00:00 2001 From: DataTriny Date: Sat, 30 Dec 2023 18:26:06 +0100 Subject: [PATCH 03/12] Bump dependencies versions and minimum Python version --- bindings/python/Cargo.toml | 10 +++++----- pyproject.toml | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/bindings/python/Cargo.toml b/bindings/python/Cargo.toml index 95d1c37be..a6a3706d6 100644 --- a/bindings/python/Cargo.toml +++ b/bindings/python/Cargo.toml @@ -17,14 +17,14 @@ doc = false extension-module = ["pyo3/extension-module"] [dependencies] -accesskit = { version = "0.12.0", path = "../../common", features = ["pyo3"] } -pyo3 = { version = "0.20", features = ["abi3-py37", "multiple-pymethods"] } +accesskit = { version = "0.12.1", path = "../../common", features = ["pyo3"] } +pyo3 = { version = "0.20", features = ["abi3-py39", "multiple-pymethods"] } [target.'cfg(target_os = "windows")'.dependencies] -accesskit_windows = { version = "0.15.0", path = "../../platforms/windows" } +accesskit_windows = { version = "0.15.1", path = "../../platforms/windows" } [target.'cfg(target_os = "macos")'.dependencies] -accesskit_macos = { version = "0.10.0", path = "../../platforms/macos" } +accesskit_macos = { version = "0.10.1", path = "../../platforms/macos" } [target.'cfg(any(target_os = "linux", target_os = "dragonfly", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))'.dependencies] -accesskit_unix = { version = "0.6.0", path = "../../platforms/unix" } +accesskit_unix = { version = "0.6.2", path = "../../platforms/unix" } diff --git a/pyproject.toml b/pyproject.toml index 18f51115c..37f7ee8ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,14 +23,14 @@ include = ['bindings/python/*.py'] [project] name = "accesskit" -requires-python = ">= 3.7" +requires-python = ">= 3.9" classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.9", "Topic :: Software Development :: User Interfaces" ] From b3f84617b992ca4ff6d69a4da9e2c5e7891d3f57 Mon Sep 17 00:00:00 2001 From: DataTriny Date: Sat, 30 Dec 2023 18:34:59 +0100 Subject: [PATCH 04/12] Remove unused `PygameAdapter.update` method from the pygame example --- bindings/python/examples/pygame/hello_world.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/bindings/python/examples/pygame/hello_world.py b/bindings/python/examples/pygame/hello_world.py index 0f874a0ee..32a045eaa 100644 --- a/bindings/python/examples/pygame/hello_world.py +++ b/bindings/python/examples/pygame/hello_world.py @@ -56,11 +56,6 @@ def __init__(self, source, action_handler): hwnd, source, action_handler ) - def update(self, tree_update): - if self.adapter is not None: - events = self.adapter.update(tree_update) - if events is not None: - events.raise_events() def update_if_active(self, update_factory): if self.adapter is not None: From 024b3cb2a35c76e4be7e70364262ca30b58a2cac Mon Sep 17 00:00:00 2001 From: DataTriny Date: Sat, 30 Dec 2023 21:27:22 +0100 Subject: [PATCH 05/12] Reformat the example --- bindings/python/examples/pygame/hello_world.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bindings/python/examples/pygame/hello_world.py b/bindings/python/examples/pygame/hello_world.py index 32a045eaa..11ee362b7 100644 --- a/bindings/python/examples/pygame/hello_world.py +++ b/bindings/python/examples/pygame/hello_world.py @@ -56,7 +56,6 @@ def __init__(self, source, action_handler): hwnd, source, action_handler ) - def update_if_active(self, update_factory): if self.adapter is not None: if PLATFORM_SYSTEM in ["Darwin", "Windows"]: From 7bc9a2de4428e838eb82ac4cb2a238335c9881e5 Mon Sep 17 00:00:00 2001 From: DataTriny Date: Sat, 30 Dec 2023 21:43:29 +0100 Subject: [PATCH 06/12] Setup proper Python version in the main CI workflow --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62b87252a..7e4a040bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,11 @@ jobs: toolchain: stable components: clippy + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.9' + - name: restore cache uses: Swatinem/rust-cache@v2 From 067334197ddc5cb3f11cdd0d071dcd12ff567c23 Mon Sep 17 00:00:00 2001 From: DataTriny Date: Sun, 31 Dec 2023 12:12:12 +0100 Subject: [PATCH 07/12] Improve CD pipeline --- .github/workflows/python-bindings.yml | 153 ++++++++++++++++++-------- 1 file changed, 110 insertions(+), 43 deletions(-) diff --git a/.github/workflows/python-bindings.yml b/.github/workflows/python-bindings.yml index 23e26b47d..fb22b8651 100644 --- a/.github/workflows/python-bindings.yml +++ b/.github/workflows/python-bindings.yml @@ -5,72 +5,139 @@ on: name: Publish Python bindings +env: + MIN_PYTHON_VERSION: 3.9 + jobs: - build-wheels: - runs-on: ${{ matrix.os }} + macos-wheels: + runs-on: macos-latest strategy: + fail-fast: false matrix: - include: - - os: macos-latest - python-arch: x64 - rust-target: x86_64 - - os: macos-latest - python-arch: x64 - rust-target: universal2-apple-darwin - - os: ubuntu-latest - python-arch: x64 - rust-target: x86_64 - - os: ubuntu-latest - python-arch: x64 - rust-target: i686 - skip-wheel-installation: true - - os: windows-latest - python-arch: x64 - rust-target: x64 - - os: windows-latest - python-arch: x86 - rust-target: x86 + target: [x86_64, aarch64] + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v5 + with: + python-version: ${{ env.MIN_PYTHON_VERSION }} + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.target }} + args: --release --out dist + sccache: 'true' + - name: Test wheel installation + if: matrix.target == 'x86_64' + run: | + pip install accesskit --no-index --find-links dist --force-reinstall + python -c "import accesskit" + - name: Upload wheels + uses: actions/upload-artifact@v3 + with: + name: wheels + path: dist + unix-wheels: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + target: [x86_64, x86, aarch64] steps: - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: - python-version: 3.7 - architecture: ${{ matrix.python-arch }} - - uses: dtolnay/rust-toolchain@stable - - name: Build wheel + python-version: ${{ env.MIN_PYTHON_VERSION }} + - name: Build wheels uses: PyO3/maturin-action@v1 with: - target: ${{ matrix.rust-target }} + target: ${{ matrix.target }} + args: --release --out dist + sccache: 'true' manylinux: auto - args: --release --out dist --sdist - name: Test wheel installation - if: matrix.skip-wheel-installation != true + if: matrix.target == 'x86_64' + run: | + pip install accesskit --no-index --find-links dist --force-reinstall + python -c "import accesskit" + - name: Upload wheels + uses: actions/upload-artifact@v3 + with: + name: wheels + path: dist + + windows-wheels: + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + target: [x64, x86] + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v5 + with: + python-version: ${{ env.MIN_PYTHON_VERSION }} + architecture: ${{ matrix.target }} + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.target }} + args: --release --out dist + sccache: 'true' + - name: Test wheel installation run: | pip install accesskit --no-index --find-links dist --force-reinstall python -c "import accesskit" - - name: Upload wheel + - name: Upload wheels + uses: actions/upload-artifact@v3 + with: + name: wheels + path: dist + + sdist: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Build sdist + uses: PyO3/maturin-action@v1 + with: + command: sdist + args: --out dist + - name: Upload sdist uses: actions/upload-artifact@v3 with: name: wheels path: dist - release: - name: Release + pypi-release: + name: Publish to PyPI + if: "startsWith(github.ref, 'refs/tags/')" + needs: [macos-wheels, unix-wheels, windows-wheels, sdist] runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v3 + with: + name: wheels + - uses: PyO3/maturin-action@v1 + env: + MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} + with: + command: upload + args: --non-interactive --skip-existing * + + github-release: + name: Add to GitHub release if: "startsWith(github.ref, 'refs/tags/')" - needs: [build-wheels] + needs: [macos-wheels, unix-wheels, windows-wheels, sdist] + runs-on: ubuntu-latest steps: - uses: actions/download-artifact@v3 with: name: wheels - - uses: actions/setup-python@v4 + path: dist + + - uses: AButler/upload-release-assets@v2.0 with: - python-version: 3.9 - - name: Publish to PyPI - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} - run: | - pip install --upgrade twine - twine upload --skip-existing * + files: "dist/*" + repo-token: ${{ secrets.GITHUB_TOKEN }} + release-tag: ${{ github.ref_name }} From 5e4ce8cfd83e0ebb2e3da2100084c6822546272b Mon Sep 17 00:00:00 2001 From: DataTriny Date: Mon, 1 Jan 2024 19:52:42 +0100 Subject: [PATCH 08/12] Address review comments --- bindings/python/examples/pygame/README.md | 2 +- bindings/python/src/common.rs | 106 ++++++++++------------ bindings/python/src/lib.rs | 2 + bindings/python/src/macos.rs | 15 +-- bindings/python/src/windows.rs | 1 - pyproject.toml | 3 - 6 files changed, 59 insertions(+), 70 deletions(-) diff --git a/bindings/python/examples/pygame/README.md b/bindings/python/examples/pygame/README.md index cfdb8089d..1f2609382 100644 --- a/bindings/python/examples/pygame/README.md +++ b/bindings/python/examples/pygame/README.md @@ -4,7 +4,7 @@ This directory contains a cross-platform application that demonstrates how to in ## Prerequisites -- Python 3.7 or higher +- Python 3.9 or higher - A virtual environment: `python -m venv .venv` (activating it will vary based on your platform) - `pip install -r requirements.txt` diff --git a/bindings/python/src/common.rs b/bindings/python/src/common.rs index 047bde0a2..f05411d86 100644 --- a/bindings/python/src/common.rs +++ b/bindings/python/src/common.rs @@ -3,6 +3,7 @@ // the LICENSE-APACHE file) or the MIT license (found in // the LICENSE-MIT file), at your option. +use crate::{Point, Rect}; use pyo3::{prelude::*, types::PyList}; #[pyclass(module = "accesskit")] @@ -257,7 +258,7 @@ macro_rules! simple_getter { }; } -macro_rules! convertion_getter { +macro_rules! converting_getter { ($struct_name:ident, $getter:ident, $type:ty) => { #[pymethods] impl $struct_name { @@ -292,7 +293,7 @@ macro_rules! simple_setter { }; } -macro_rules! convertion_setter { +macro_rules! converting_setter { ($setter:ident, $setter_param:ty) => { #[pymethods] impl NodeBuilder { @@ -379,7 +380,7 @@ macro_rules! node_id_vec_property_methods { macro_rules! node_id_property_methods { ($(($getter:ident, $setter:ident, $clearer:ident)),+) => { $(property_methods! { - ($getter, option_getter, Option, $setter, convertion_setter, NodeId, $clearer) + ($getter, option_getter, Option, $setter, converting_setter, NodeId, $clearer) })* } } @@ -411,7 +412,7 @@ macro_rules! color_property_methods { macro_rules! text_decoration_property_methods { ($(($getter:ident, $setter:ident, $clearer:ident)),+) => { $(property_methods! { - ($getter, option_getter, Option, $setter, convertion_setter, accesskit::TextDecoration, $clearer) + ($getter, option_getter, Option, $setter, converting_setter, accesskit::TextDecoration, $clearer) })* } } @@ -419,7 +420,7 @@ macro_rules! text_decoration_property_methods { macro_rules! length_slice_property_methods { ($(($getter:ident, $setter:ident, $clearer:ident)),+) => { $(property_methods! { - ($getter, convertion_getter, Vec, $setter, simple_setter, Vec, $clearer) + ($getter, converting_getter, Vec, $setter, simple_setter, Vec, $clearer) })* } } @@ -588,7 +589,7 @@ unique_enum_property_methods! { property_methods! { (transform, option_getter, Option, set_transform, simple_setter, crate::Affine, clear_transform), - (bounds, option_getter, Option, set_bounds, convertion_setter, crate::Rect, clear_bounds), + (bounds, option_getter, Option, set_bounds, converting_setter, crate::Rect, clear_bounds), (text_selection, option_getter, Option, set_text_selection, simple_setter, TextSelection, clear_text_selection) } @@ -602,7 +603,7 @@ pub struct Tree { pub root: NodeId, pub app_name: Option, pub toolkit_name: Option, - toolkit_version: Option, + pub toolkit_version: Option, } #[pymethods] @@ -674,58 +675,23 @@ impl From for accesskit::TreeUpdate { } } -#[pyclass(module = "accesskit")] -pub struct ActionData(accesskit::ActionData); - -#[pymethods] -impl ActionData { - #[staticmethod] - pub fn custom_action(action: i32) -> Self { - accesskit::ActionData::CustomAction(action).into() - } - - #[staticmethod] - pub fn value(value: &str) -> Self { - accesskit::ActionData::Value(value.into()).into() - } - - #[staticmethod] - pub fn numeric_value(value: f64) -> Self { - accesskit::ActionData::NumericValue(value).into() - } - - #[staticmethod] - pub fn scroll_target_rect(rect: crate::Rect) -> Self { - accesskit::ActionData::ScrollTargetRect(rect.into()).into() - } - - #[staticmethod] - pub fn scroll_to_point(point: crate::Point) -> Self { - accesskit::ActionData::ScrollToPoint(point.into()).into() - } - - #[staticmethod] - pub fn set_scroll_offset(offset: crate::Point) -> Self { - accesskit::ActionData::SetScrollOffset(offset.into()).into() - } - - #[staticmethod] - pub fn set_text_selection(selection: TextSelection) -> Self { - accesskit::ActionData::SetTextSelection(selection.into()).into() - } -} - -impl From for ActionData { - fn from(data: accesskit::ActionData) -> Self { - Self(data) - } -} - -#[pyclass(get_all, set_all, module = "accesskit")] +#[derive(Clone)] +#[pyclass(module = "accesskit", rename_all = "SCREAMING_SNAKE_CASE")] +pub enum ActionDataKind { + CustomAction, + Value, + NumericValue, + ScrollTargetRect, + ScrollToPoint, + SetScrollOffset, + SetTextSelection, +} + +#[pyclass(get_all, module = "accesskit")] pub struct ActionRequest { pub action: accesskit::Action, pub target: NodeId, - pub data: Option>, + pub data: Option<(ActionDataKind, Py)>, } impl From for ActionRequest { @@ -733,9 +699,31 @@ impl From for ActionRequest { Python::with_gil(|py| Self { action: request.action, target: request.target.into(), - data: request - .data - .map(|data| Py::new(py, ActionData::from(data)).unwrap()), + data: request.data.map(|data| match data { + accesskit::ActionData::CustomAction(action) => { + (ActionDataKind::CustomAction, action.into_py(py)) + } + accesskit::ActionData::Value(value) => (ActionDataKind::Value, value.into_py(py)), + accesskit::ActionData::NumericValue(value) => { + (ActionDataKind::NumericValue, value.into_py(py)) + } + accesskit::ActionData::ScrollTargetRect(rect) => ( + ActionDataKind::ScrollTargetRect, + Rect::from(rect).into_py(py), + ), + accesskit::ActionData::ScrollToPoint(point) => ( + ActionDataKind::ScrollToPoint, + Point::from(point).into_py(py), + ), + accesskit::ActionData::SetScrollOffset(point) => ( + ActionDataKind::SetScrollOffset, + Point::from(point).into_py(py), + ), + accesskit::ActionData::SetTextSelection(selection) => ( + ActionDataKind::SetTextSelection, + TextSelection::from(&selection).into_py(py), + ), + }), }) } } diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index cad553cab..3a15aebff 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -46,6 +46,8 @@ fn accesskit(py: Python<'_>, m: &PyModule) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; diff --git a/bindings/python/src/macos.rs b/bindings/python/src/macos.rs index 5b222b0d0..3a574470c 100644 --- a/bindings/python/src/macos.rs +++ b/bindings/python/src/macos.rs @@ -64,16 +64,19 @@ impl Adapter { self.0.update_view_focus_state(is_focused).into() } - pub fn view_children(&self) -> isize { - self.0.view_children() as _ + pub fn view_children(&self, py: Python<'_>) -> PyResult> { + let ptr: isize = self.0.view_children() as _; + Ok(PyCapsule::new(py, ptr, None)?.into()) } - pub fn focus(&self) -> isize { - self.0.focus() as _ + pub fn focus(&self, py: Python<'_>) -> PyResult> { + let ptr: isize = self.0.focus() as _; + Ok(PyCapsule::new(py, ptr, None)?.into()) } - pub fn hit_test(&self, x: f64, y: f64) -> isize { - self.0.hit_test(NSPoint::new(x, y)) as _ + pub fn hit_test(&self, py: Python<'_>, x: f64, y: f64) -> PyResult> { + let ptr: isize = self.0.hit_test(NSPoint::new(x, y)) as _; + Ok(PyCapsule::new(py, ptr, None)?.into()) } } diff --git a/bindings/python/src/windows.rs b/bindings/python/src/windows.rs index 23fa01a14..5621bd65f 100644 --- a/bindings/python/src/windows.rs +++ b/bindings/python/src/windows.rs @@ -85,7 +85,6 @@ impl Adapter { } } -/// This class must only be used from the main thread. #[pyclass(module = "accesskit.windows", unsendable)] pub struct SubclassingAdapter(accesskit_windows::SubclassingAdapter); diff --git a/pyproject.toml b/pyproject.toml index 37f7ee8ab..29e2b0870 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,9 +5,6 @@ build-backend = "maturin" [tool.maturin] manifest-path = "bindings/python/Cargo.toml" features = ["pyo3/extension-module"] -exclude = [ - "bindings/python/examples/**" -] include = [ "LICENSE*" ] From 83913a451996f42bb502b8d7c2138bdff77d63f9 Mon Sep 17 00:00:00 2001 From: DataTriny Date: Tue, 2 Jan 2024 14:34:16 +0100 Subject: [PATCH 09/12] Add more explanations in the README --- bindings/python/README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bindings/python/README.md b/bindings/python/README.md index 6dc3e133b..efd4d6606 100644 --- a/bindings/python/README.md +++ b/bindings/python/README.md @@ -1,3 +1,9 @@ # AccessKit These are the bindings to use AccessKit from Python. + +Documentation for the Rust packages can be found [here](https://docs.rs/accesskit/latest/accesskit/). + +## Building + +If there are no wheels available for your platform, you will have to build one yourself. You will need to have Rust installed on your system, so that the native libraries can be compiled. Please visit [rustup.rs](https://rustup.rs) for instructions on how to proceed. From aa2a3f57487784cb1669b41fa13f88799140d936 Mon Sep 17 00:00:00 2001 From: DataTriny Date: Wed, 3 Jan 2024 17:58:34 +0100 Subject: [PATCH 10/12] Improve the README even more --- bindings/python/README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/bindings/python/README.md b/bindings/python/README.md index efd4d6606..ae8967fde 100644 --- a/bindings/python/README.md +++ b/bindings/python/README.md @@ -4,6 +4,12 @@ These are the bindings to use AccessKit from Python. Documentation for the Rust packages can be found [here](https://docs.rs/accesskit/latest/accesskit/). -## Building +An example program showing how to integrate AccessKit in a pygame application is available [here](https://github.com/AccessKit/accesskit/tree/main/bindings/python/examples/pygame). + +## Building from a Source Distribution If there are no wheels available for your platform, you will have to build one yourself. You will need to have Rust installed on your system, so that the native libraries can be compiled. Please visit [rustup.rs](https://rustup.rs) for instructions on how to proceed. + +## Building from within the repository + +This project uses [maturin](https://github.com/PyO3/maturin) as its build tool. If you need to manually build wheels for development purposes, it is recommended to install it inside a virtual environment. All maturin commands must be issued from this repository's root directory. From ab9ac0c3d5e7b058551df139e57f0bb6d84bcdf5 Mon Sep 17 00:00:00 2001 From: DataTriny Date: Wed, 3 Jan 2024 20:19:50 +0100 Subject: [PATCH 11/12] Update Unix Adapter API --- .../python/examples/pygame/hello_world.py | 25 ++++++++----------- bindings/python/src/unix.rs | 21 +++++++--------- 2 files changed, 19 insertions(+), 27 deletions(-) diff --git a/bindings/python/examples/pygame/hello_world.py b/bindings/python/examples/pygame/hello_world.py index 11ee362b7..1398edb4b 100644 --- a/bindings/python/examples/pygame/hello_world.py +++ b/bindings/python/examples/pygame/hello_world.py @@ -49,7 +49,7 @@ def __init__(self, source, action_handler): window, source, action_handler ) elif os.name == "posix": - self.adapter = accesskit.unix.Adapter.create(source, False, action_handler) + self.adapter = accesskit.unix.Adapter(source, action_handler) elif PLATFORM_SYSTEM == "Windows": hwnd = pygame.display.get_wm_info()["window"] self.adapter = accesskit.windows.SubclassingAdapter( @@ -57,22 +57,17 @@ def __init__(self, source, action_handler): ) def update_if_active(self, update_factory): - if self.adapter is not None: - if PLATFORM_SYSTEM in ["Darwin", "Windows"]: - events = self.adapter.update_if_active(update_factory) - if events is not None: - events.raise_events() - else: - self.adapter.update(update_factory()) + events = self.adapter.update_if_active(update_factory) + if events is not None: + events.raise_events() def update_window_focus_state(self, is_focused): - if self.adapter is not None: - if PLATFORM_SYSTEM == "Darwin": - events = self.adapter.update_view_focus_state(is_focused) - if events is not None: - events.raise_events() - elif os.name == "posix": - self.adapter.update_window_focus_state(is_focused) + if PLATFORM_SYSTEM == "Darwin": + events = self.adapter.update_view_focus_state(is_focused) + if events is not None: + events.raise_events() + elif os.name == "posix": + self.adapter.update_window_focus_state(is_focused) class WindowState: diff --git a/bindings/python/src/unix.rs b/bindings/python/src/unix.rs index 554134775..33879ade1 100644 --- a/bindings/python/src/unix.rs +++ b/bindings/python/src/unix.rs @@ -11,13 +11,9 @@ pub struct Adapter(accesskit_unix::Adapter); #[pymethods] impl Adapter { - #[staticmethod] - pub fn create( - source: Py, - is_window_focused: bool, - action_handler: Py, - ) -> Option { - accesskit_unix::Adapter::new( + #[new] + pub fn new(source: Py, action_handler: Py) -> Self { + Self(accesskit_unix::Adapter::new( move || { Python::with_gil(|py| { source @@ -28,18 +24,19 @@ impl Adapter { .into() }) }, - is_window_focused, Box::new(PythonActionHandler(action_handler)), - ) - .map(Self) + )) } pub fn set_root_window_bounds(&mut self, outer: Rect, inner: Rect) { self.0.set_root_window_bounds(outer.into(), inner.into()); } - pub fn update(&self, update: TreeUpdate) { - self.0.update(update.into()); + pub fn update_if_active(&self, py: Python<'_>, update_factory: Py) { + self.0.update_if_active(|| { + let update = update_factory.call0(py).unwrap(); + update.extract::(py).unwrap().into() + }); } pub fn update_window_focus_state(&self, is_focused: bool) { From fdf268e103a2afe4db70494954221c65297e4f9b Mon Sep 17 00:00:00 2001 From: DataTriny Date: Wed, 3 Jan 2024 22:28:06 +0100 Subject: [PATCH 12/12] Setup Trusted publisher in the workflow --- .github/workflows/python-bindings.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-bindings.yml b/.github/workflows/python-bindings.yml index fb22b8651..ade143d7b 100644 --- a/.github/workflows/python-bindings.yml +++ b/.github/workflows/python-bindings.yml @@ -111,6 +111,9 @@ jobs: pypi-release: name: Publish to PyPI + environment: release + permissions: + id-token: write if: "startsWith(github.ref, 'refs/tags/')" needs: [macos-wheels, unix-wheels, windows-wheels, sdist] runs-on: ubuntu-latest @@ -119,8 +122,6 @@ jobs: with: name: wheels - uses: PyO3/maturin-action@v1 - env: - MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} with: command: upload args: --non-interactive --skip-existing *