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..7e4a040bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,14 @@ jobs: clang-format-version: 15 check-path: bindings/c + - 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 }} strategy: @@ -43,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 diff --git a/.github/workflows/python-bindings.yml b/.github/workflows/python-bindings.yml new file mode 100644 index 000000000..ade143d7b --- /dev/null +++ b/.github/workflows/python-bindings.yml @@ -0,0 +1,144 @@ +on: + push: + tags: + - 'accesskit_python-v*' + +name: Publish Python bindings + +env: + MIN_PYTHON_VERSION: 3.9 + +jobs: + macos-wheels: + runs-on: macos-latest + strategy: + fail-fast: false + matrix: + 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@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' + manylinux: auto + - 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 + + 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 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 + + 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 + steps: + - uses: actions/download-artifact@v3 + with: + name: wheels + - uses: PyO3/maturin-action@v1 + with: + command: upload + args: --non-interactive --skip-existing * + + github-release: + name: Add to GitHub release + 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 + path: dist + + - uses: AButler/upload-release-assets@v2.0 + with: + files: "dist/*" + repo-token: ${{ secrets.GITHUB_TOKEN }} + release-tag: ${{ github.ref_name }} 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..a6a3706d6 --- /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.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.1", path = "../../platforms/windows" } + +[target.'cfg(target_os = "macos")'.dependencies] +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.2", path = "../../platforms/unix" } diff --git a/bindings/python/README.md b/bindings/python/README.md new file mode 100644 index 000000000..ae8967fde --- /dev/null +++ b/bindings/python/README.md @@ -0,0 +1,15 @@ +# 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/). + +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. 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..1f2609382 --- /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.9 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..1398edb4b --- /dev/null +++ b/bindings/python/examples/pygame/hello_world.py @@ -0,0 +1,188 @@ +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(source, action_handler) + elif PLATFORM_SYSTEM == "Windows": + hwnd = pygame.display.get_wm_info()["window"] + self.adapter = accesskit.windows.SubclassingAdapter( + hwnd, source, action_handler + ) + + def update_if_active(self, 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 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..f05411d86 --- /dev/null +++ b/bindings/python/src/common.rs @@ -0,0 +1,740 @@ +// 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::{Point, Rect}; +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! converting_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! converting_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, converting_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, converting_setter, accesskit::TextDecoration, $clearer) + })* + } +} + +macro_rules! length_slice_property_methods { + ($(($getter:ident, $setter:ident, $clearer:ident)),+) => { + $(property_methods! { + ($getter, converting_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, converting_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, + pub 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(), + }) + } +} + +#[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<(ActionDataKind, Py)>, +} + +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| 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), + ), + }), + }) + } +} + +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..3a15aebff --- /dev/null +++ b/bindings/python/src/lib.rs @@ -0,0 +1,92 @@ +// 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::()?; + 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..3a574470c --- /dev/null +++ b/bindings/python/src/macos.rs @@ -0,0 +1,197 @@ +// 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, py: Python<'_>) -> PyResult> { + let ptr: isize = self.0.view_children() as _; + Ok(PyCapsule::new(py, ptr, None)?.into()) + } + + 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, 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()) + } +} + +/// 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..33879ade1 --- /dev/null +++ b/bindings/python/src/unix.rs @@ -0,0 +1,45 @@ +// 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 { + #[new] + pub fn new(source: Py, action_handler: Py) -> Self { + Self(accesskit_unix::Adapter::new( + move || { + Python::with_gil(|py| { + source + .call0(py) + .unwrap() + .extract::(py) + .unwrap() + .into() + }) + }, + Box::new(PythonActionHandler(action_handler)), + )) + } + + 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_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) { + 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..5621bd65f --- /dev/null +++ b/bindings/python/src/windows.rs @@ -0,0 +1,140 @@ +// 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) + } +} + +#[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..29e2b0870 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,35 @@ +[build-system] +requires = ["maturin>=1.0,<2.0"] +build-backend = "maturin" + +[tool.maturin] +manifest-path = "bindings/python/Cargo.toml" +features = ["pyo3/extension-module"] +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.ruff] +include = ['bindings/python/*.py'] + +[project] +name = "accesskit" +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.9", + "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": {} } }