pax_global_header00006660000000000000000000000064151041606510014511gustar00rootroot0000000000000052 comment=ec8e43d0efce579ee6fa64f302c5ded3913403e8 app-model-0.5.1/000077500000000000000000000000001510416065100133725ustar00rootroot00000000000000app-model-0.5.1/.github/000077500000000000000000000000001510416065100147325ustar00rootroot00000000000000app-model-0.5.1/.github/ISSUE_TEMPLATE.md000066400000000000000000000005001510416065100174320ustar00rootroot00000000000000* app-model version: * Python version: * Operating System: ### Description Describe what you were trying to get done. Tell us what happened, what went wrong, and what you expected to happen. ### What I Did ``` Paste the command(s) you ran and the output. If there was a crash, please include the traceback here. ``` app-model-0.5.1/.github/TEST_FAIL_TEMPLATE.md000066400000000000000000000006111510416065100201170ustar00rootroot00000000000000--- title: "{{ env.TITLE }}" labels: [bug] --- The {{ workflow }} workflow failed on {{ date | date("YYYY-MM-DD HH:mm") }} UTC The most recent failing test was on {{ env.PLATFORM }} py{{ env.PYTHON }} with commit: {{ sha }} Full run: https://github.com/pyapp-kit/app-model/actions/runs/{{ env.RUN_ID }} (This post will be updated if another test fails, as long as this issue remains open.) app-model-0.5.1/.github/dependabot.yml000066400000000000000000000004241510416065100175620ustar00rootroot00000000000000# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" commit-message: prefix: "ci(dependabot):" app-model-0.5.1/.github/workflows/000077500000000000000000000000001510416065100167675ustar00rootroot00000000000000app-model-0.5.1/.github/workflows/ci.yml000066400000000000000000000134641510416065100201150ustar00rootroot00000000000000name: CI on: push: branches: [main] tags: [v*] pull_request: workflow_dispatch: schedule: # run every week (for --pre release tests) - cron: "0 0 * * 0" concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: check-manifest: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - run: pipx run check-manifest pyright: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - uses: astral-sh/setup-uv@v7 - run: uv run --with pyqt6 pyright test: name: ${{ matrix.platform }} py${{ matrix.python-version }} runs-on: ${{ matrix.platform }} env: UV_PRERELEASE: ${{ github.event_name == 'schedule' && 'allow' || 'if-necessary-or-explicit' }} UV_NO_SYNC: 1 strategy: fail-fast: false matrix: python-version: ["3.9", "3.11", "3.13"] platform: [ubuntu-latest, macos-latest, windows-latest] include: - python-version: "3.10" platform: "macos-latest" - python-version: "3.12" platform: "macos-latest" - python-version: "3.9" platform: "ubuntu-latest" resolution: "lowest-direct" steps: - uses: actions/checkout@v5 with: fetch-depth: 0 - name: ๐Ÿ Set up Python ${{ matrix.python-version }} uses: astral-sh/setup-uv@v7 with: python-version: ${{ matrix.python-version }} enable-cache: true cache-dependency-glob: "**/pyproject.toml" - name: Install Dependencies run: uv sync --no-dev --group test --resolution ${{ matrix.resolution || 'highest'}} - name: ๐Ÿงช Run Tests run: uv run coverage run -p -m pytest -v env: PYTEST_ADDOPTS: ${{ matrix.resolution == 'lowest-direct' && '-W ignore' || '' }} - name: Upload coverage uses: actions/upload-artifact@v5 with: name: covreport-${{ matrix.platform }}-py${{ matrix.python-version }} path: ./.coverage* include-hidden-files: true test-qt: name: ${{ matrix.platform }} py${{ matrix.python-version }} ${{matrix.extra }} ${{ matrix.resolution }} runs-on: ${{ matrix.platform }} env: UV_PRERELEASE: ${{ github.event_name == 'schedule' && 'allow' || 'if-necessary-or-explicit' }} UV_NO_SYNC: 1 strategy: fail-fast: false matrix: python-version: ["3.10", "3.12"] platform: [macos-latest, windows-latest] extra: [pyqt5, pyside2, pyside6, pyqt6] resolution: [highest, lowest-direct] exclude: - platform: "macos-latest" extra: "pyside2" - python-version: "3.12" extra: "pyside2" steps: - uses: actions/checkout@v5 with: fetch-depth: 0 - name: ๐Ÿ Set up Python ${{ matrix.python-version }} uses: astral-sh/setup-uv@v7 with: python-version: ${{ matrix.python-version }} enable-cache: true cache-dependency-glob: "**/pyproject.toml" - uses: pyvista/setup-headless-display-action@v4 with: qt: true - name: Install Dependencies run: uv sync --no-dev --group test-qt --extra ${{ matrix.extra }} --resolution ${{ matrix.resolution }} - name: ๐Ÿงช Run Tests run: uv run coverage run -p -m pytest -v env: PYTEST_ADDOPTS: ${{ matrix.resolution == 'lowest-direct' && '-W ignore' || '' }} # If something goes wrong with --pre tests, we can open an issue in the repo - name: ๐Ÿ“ Report --pre Failures if: failure() && github.event_name == 'schedule' uses: JasonEtco/create-an-issue@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} PLATFORM: ${{ matrix.platform }} PYTHON: ${{ matrix.python-version }} RUN_ID: ${{ github.run_id }} TITLE: "[test-bot] pip install --pre is failing" with: filename: .github/TEST_FAIL_TEMPLATE.md update_existing: true - name: Upload coverage uses: actions/upload-artifact@v5 with: name: covreport-${{ matrix.platform }}-py${{ matrix.python-version }}-${{ matrix.extra }}-${{ matrix.resolution }} path: ./.coverage* include-hidden-files: true upload_coverage: if: always() needs: [test, test-qt] uses: pyapp-kit/workflows/.github/workflows/upload-coverage.yml@v2 secrets: inherit test_napari: uses: pyapp-kit/workflows/.github/workflows/test-dependents.yml@v2 with: dependency-repo: napari/napari dependency-extras: "testing" qt: ${{ matrix.qt }} pytest-args: 'src/napari/_qt/_qapp_model src/napari/_app_model src/napari/utils/_tests/test_key_bindings.py --import-mode=importlib -k "not async and not qt_dims_2"' python-version: "3.11" strategy: fail-fast: false matrix: qt: ["pyqt5", "pyside6"] build-and-inspect-package: name: Build & inspect package. needs: [check-manifest, test] runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 with: fetch-depth: 0 - uses: hynek/build-and-inspect-python-package@v2 upload-to-pypi: name: Upload package to PyPI needs: build-and-inspect-package if: success() && startsWith(github.ref, 'refs/tags/') && github.event_name != 'schedule' runs-on: ubuntu-latest permissions: id-token: write contents: write steps: - name: Download built artifact to dist/ uses: actions/download-artifact@v6 with: name: Packages path: dist - name: ๐Ÿšข Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 - uses: softprops/action-gh-release@v2 with: generate_release_notes: true files: "./dist/*" app-model-0.5.1/.github_changelog_generator000066400000000000000000000004211510416065100207270ustar00rootroot00000000000000user=pyapp-kit project=app-model issues=false exclude-labels=duplicate,question,invalid,wontfix,hide add-sections={"tests":{"prefix":"**Tests & CI:**","labels":["tests"]}, "documentation":{"prefix":"**Documentation:**", "labels":["documentation"]}} exclude-tags-regex=.*rc app-model-0.5.1/.gitignore000066400000000000000000000023471510416065100153700ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python env/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg # 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/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # dotenv .env # virtualenv .venv venv/ ENV/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ # IDE settings .vscode/ app_model/_version.py src/app_model/_version.py uv.lock app-model-0.5.1/.pre-commit-config.yaml000066400000000000000000000020311510416065100176470ustar00rootroot00000000000000ci: autoupdate_schedule: monthly autofix_commit_msg: "style: [pre-commit.ci] auto fixes [...]" autoupdate_commit_msg: "ci: [pre-commit.ci] autoupdate" repos: - repo: https://github.com/abravalheri/validate-pyproject rev: v0.24.1 hooks: - id: validate-pyproject - repo: https://github.com/adhtruong/mirrors-typos rev: v1.39.0 hooks: - id: typos args: [--force-exclude] - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.14.4 hooks: - id: ruff-check args: ["--fix", "--unsafe-fixes"] - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.18.2 hooks: - id: mypy files: "^src/" additional_dependencies: - pydantic >2.8 - in-n-out - repo: local hooks: - id: pyright stages: [manual] name: pyright language: system exclude: "^tests/.*|^demo/.*|^docs/.*" types_or: [python, pyi] require_serial: true entry: uv run pyright app-model-0.5.1/.readthedocs.yaml000066400000000000000000000005371510416065100166260ustar00rootroot00000000000000# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details version: 2 build: os: ubuntu-24.04 tools: python: "3.12" jobs: post_install: - pip install uv - UV_PROJECT_ENVIRONMENT=$READTHEDOCS_VIRTUALENV_PATH uv sync --group docs --link-mode=copy mkdocs: configuration: mkdocs.yml fail_on_warning: true app-model-0.5.1/CHANGELOG.md000066400000000000000000000625361510416065100152170ustar00rootroot00000000000000# Changelog ## [v0.5.0](https://github.com/pyapp-kit/app-model/tree/v0.5.0) (2025-09-10) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.4.0...v0.5.0) **Implemented enhancements:** - feat: Update of shortcuts on menu rebuild [\#258](https://github.com/pyapp-kit/app-model/pull/258) ([Czaki](https://github.com/Czaki)) **Merged pull requests:** - build!: drop pydantic 1 [\#263](https://github.com/pyapp-kit/app-model/pull/263) ([tlambert03](https://github.com/tlambert03)) - ci: \[pre-commit.ci\] autoupdate [\#262](https://github.com/pyapp-kit/app-model/pull/262) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci)) - ci\(dependabot\): bump actions/checkout from 4 to 5 [\#261](https://github.com/pyapp-kit/app-model/pull/261) ([dependabot[bot]](https://github.com/apps/dependabot)) - ci\(dependabot\): bump actions/download-artifact from 4 to 5 [\#260](https://github.com/pyapp-kit/app-model/pull/260) ([dependabot[bot]](https://github.com/apps/dependabot)) - chore: Pin `pytest-qt` on python 3.10 to fix pyside2 tests [\#259](https://github.com/pyapp-kit/app-model/pull/259) ([Czaki](https://github.com/Czaki)) - chore: improve error message in `register_action` [\#257](https://github.com/pyapp-kit/app-model/pull/257) ([Czaki](https://github.com/Czaki)) - ci: \[pre-commit.ci\] autoupdate [\#255](https://github.com/pyapp-kit/app-model/pull/255) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci)) ## [v0.4.0](https://github.com/pyapp-kit/app-model/tree/v0.4.0) (2025-06-20) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.3.2...v0.4.0) ## [v0.3.2](https://github.com/pyapp-kit/app-model/tree/v0.3.2) (2025-06-20) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.3.1...v0.3.2) **Implemented enhancements:** - feat: add `registered_actions` to Application [\#242](https://github.com/pyapp-kit/app-model/pull/242) ([tlambert03](https://github.com/tlambert03)) - feat: allow explicit variable bound to be passed to Name [\#241](https://github.com/pyapp-kit/app-model/pull/241) ([tlambert03](https://github.com/tlambert03)) - feat: initial exploration for keybinding source addition and inverse map for keybinding registry [\#226](https://github.com/pyapp-kit/app-model/pull/226) ([dalthviz](https://github.com/dalthviz)) **Fixed bugs:** - fix: order of menu registration to come after keybindings registration [\#249](https://github.com/pyapp-kit/app-model/pull/249) ([tlambert03](https://github.com/tlambert03)) - fix: Use QApplication instance as a parent of global actions [\#246](https://github.com/pyapp-kit/app-model/pull/246) ([Czaki](https://github.com/Czaki)) - fix: Fix recursion check in Qt6 [\#232](https://github.com/pyapp-kit/app-model/pull/232) ([hanjinliu](https://github.com/hanjinliu)) **Documentation:** - docs: fix small typo in Getting Started doc [\#236](https://github.com/pyapp-kit/app-model/pull/236) ([kevinyamauchi](https://github.com/kevinyamauchi)) **Merged pull requests:** - chore: bunch of typing fixes, run pyright on ci [\#252](https://github.com/pyapp-kit/app-model/pull/252) ([tlambert03](https://github.com/tlambert03)) - chore: add ANN rule to ruff [\#251](https://github.com/pyapp-kit/app-model/pull/251) ([tlambert03](https://github.com/tlambert03)) - build: update for uv project management [\#250](https://github.com/pyapp-kit/app-model/pull/250) ([tlambert03](https://github.com/tlambert03)) - ci: \[pre-commit.ci\] autoupdate [\#240](https://github.com/pyapp-kit/app-model/pull/240) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci)) - chore: Filter out \_\_get\_validators\_\_ warning [\#239](https://github.com/pyapp-kit/app-model/pull/239) ([Czaki](https://github.com/Czaki)) - ci: \[pre-commit.ci\] autoupdate [\#237](https://github.com/pyapp-kit/app-model/pull/237) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci)) - ci: \[pre-commit.ci\] autoupdate [\#233](https://github.com/pyapp-kit/app-model/pull/233) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci)) - ci: \[pre-commit.ci\] autoupdate [\#227](https://github.com/pyapp-kit/app-model/pull/227) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci)) ## [v0.3.1](https://github.com/pyapp-kit/app-model/tree/v0.3.1) (2024-11-22) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.3.0...v0.3.1) **Merged pull requests:** - build: drop 3.8 and add 3.13 [\#224](https://github.com/pyapp-kit/app-model/pull/224) ([tlambert03](https://github.com/tlambert03)) - ci: \[pre-commit.ci\] autoupdate [\#222](https://github.com/pyapp-kit/app-model/pull/222) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci)) ## [v0.3.0](https://github.com/pyapp-kit/app-model/tree/v0.3.0) (2024-09-17) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.2.8...v0.3.0) **Implemented enhancements:** - feat: Include command/control-0 for OriginalSize in StandardKeyBindings [\#220](https://github.com/pyapp-kit/app-model/pull/220) ([psobolewskiPhD](https://github.com/psobolewskiPhD)) - feat: add support for `.svg` file paths as `Action` icon [\#219](https://github.com/pyapp-kit/app-model/pull/219) ([dalthviz](https://github.com/dalthviz)) - feat: add action keybinding info over tooltip [\#218](https://github.com/pyapp-kit/app-model/pull/218) ([dalthviz](https://github.com/dalthviz)) ## [v0.2.8](https://github.com/pyapp-kit/app-model/tree/v0.2.8) (2024-07-19) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.2.7...v0.2.8) **Implemented enhancements:** - feat: more flexible keybinding parser [\#213](https://github.com/pyapp-kit/app-model/pull/213) ([tlambert03](https://github.com/tlambert03)) - feat: Add `filter_keybinding` to `KeyBindingRegistry` [\#212](https://github.com/pyapp-kit/app-model/pull/212) ([lucyleeow](https://github.com/lucyleeow)) - feat: add a way to get a user-facing string representation of keybindings [\#211](https://github.com/pyapp-kit/app-model/pull/211) ([dalthviz](https://github.com/dalthviz)) - feat: add Sequences to expressions [\#202](https://github.com/pyapp-kit/app-model/pull/202) ([tlambert03](https://github.com/tlambert03)) **Merged pull requests:** - perf: faster `Expr.eval` by precompiling expressions [\#197](https://github.com/pyapp-kit/app-model/pull/197) ([tlambert03](https://github.com/tlambert03)) ## [v0.2.7](https://github.com/pyapp-kit/app-model/tree/v0.2.7) (2024-05-08) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.2.6...v0.2.7) **Implemented enhancements:** - feat: give registries more control over registration of actions [\#194](https://github.com/pyapp-kit/app-model/pull/194) ([tlambert03](https://github.com/tlambert03)) ## [v0.2.6](https://github.com/pyapp-kit/app-model/tree/v0.2.6) (2024-03-25) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.2.5...v0.2.6) **Fixed bugs:** - fix: Do not use lambda in QMenuItemAction.destroyed callback [\#183](https://github.com/pyapp-kit/app-model/pull/183) ([Czaki](https://github.com/Czaki)) ## [v0.2.5](https://github.com/pyapp-kit/app-model/tree/v0.2.5) (2024-03-18) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.2.4...v0.2.5) **Fixed bugs:** - fix: handle qmods like QT5 for pyside6 not 6.4 [\#179](https://github.com/pyapp-kit/app-model/pull/179) ([psobolewskiPhD](https://github.com/psobolewskiPhD)) **Merged pull requests:** - chore: add format to pre-commit [\#182](https://github.com/pyapp-kit/app-model/pull/182) ([tlambert03](https://github.com/tlambert03)) - chore: use ruff format instead of black [\#181](https://github.com/pyapp-kit/app-model/pull/181) ([tlambert03](https://github.com/tlambert03)) - ci: change test suite to cover more qt versions [\#180](https://github.com/pyapp-kit/app-model/pull/180) ([tlambert03](https://github.com/tlambert03)) - ci\(dependabot\): bump softprops/action-gh-release from 1 to 2 [\#178](https://github.com/pyapp-kit/app-model/pull/178) ([dependabot[bot]](https://github.com/apps/dependabot)) - ci: Add testing of napari/utils/\_tests/test\_key\_bindings.py [\#173](https://github.com/pyapp-kit/app-model/pull/173) ([Czaki](https://github.com/Czaki)) - ci: \[pre-commit.ci\] autoupdate [\#172](https://github.com/pyapp-kit/app-model/pull/172) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci)) ## [v0.2.4](https://github.com/pyapp-kit/app-model/tree/v0.2.4) (2023-12-21) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.2.3...v0.2.4) **Implemented enhancements:** - feat: export register\_action function at top level [\#170](https://github.com/pyapp-kit/app-model/pull/170) ([tlambert03](https://github.com/tlambert03)) **Documentation:** - Fix doc typo for `register_action` [\#168](https://github.com/pyapp-kit/app-model/pull/168) ([aganders3](https://github.com/aganders3)) - docs: Add ref to in n out getting started [\#167](https://github.com/pyapp-kit/app-model/pull/167) ([lucyleeow](https://github.com/lucyleeow)) **Merged pull requests:** - ci\(dependabot\): bump aganders3/headless-gui from 1 to 2 [\#165](https://github.com/pyapp-kit/app-model/pull/165) ([dependabot[bot]](https://github.com/apps/dependabot)) - ci\(dependabot\): bump actions/setup-python from 4 to 5 [\#164](https://github.com/pyapp-kit/app-model/pull/164) ([dependabot[bot]](https://github.com/apps/dependabot)) - refactor: remove comparison between Keybinding and string [\#146](https://github.com/pyapp-kit/app-model/pull/146) ([tlambert03](https://github.com/tlambert03)) ## [v0.2.3](https://github.com/pyapp-kit/app-model/tree/v0.2.3) (2023-12-12) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.2.2...v0.2.3) **Implemented enhancements:** - feat: add top level Application.context [\#145](https://github.com/pyapp-kit/app-model/pull/145) ([tlambert03](https://github.com/tlambert03)) - feat: add `CommandRule.icon_visible_in_menu` [\#135](https://github.com/pyapp-kit/app-model/pull/135) ([tlambert03](https://github.com/tlambert03)) - feat: return QModelToolBar from call to QModelMainWindow.addModelToolBar [\#134](https://github.com/pyapp-kit/app-model/pull/134) ([tlambert03](https://github.com/tlambert03)) - feat: accept single string id as menu key in Actions.menus [\#133](https://github.com/pyapp-kit/app-model/pull/133) ([tlambert03](https://github.com/tlambert03)) - feat: support iconify icon keys [\#130](https://github.com/pyapp-kit/app-model/pull/130) ([tlambert03](https://github.com/tlambert03)) - feat: Show shortcut in `KeyBinding.__repr__` [\#126](https://github.com/pyapp-kit/app-model/pull/126) ([Czaki](https://github.com/Czaki)) - feat: support py312 [\#124](https://github.com/pyapp-kit/app-model/pull/124) ([tlambert03](https://github.com/tlambert03)) **Fixed bugs:** - fix: catch runtime error on QModelSubmenu cleanup [\#151](https://github.com/pyapp-kit/app-model/pull/151) ([tlambert03](https://github.com/tlambert03)) **Tests & CI:** - test: add test for mult\_file [\#140](https://github.com/pyapp-kit/app-model/pull/140) ([tlambert03](https://github.com/tlambert03)) - test: enforce 100 percent test coverage on project [\#136](https://github.com/pyapp-kit/app-model/pull/136) ([tlambert03](https://github.com/tlambert03)) **Documentation:** - docs: remove minify plugin [\#154](https://github.com/pyapp-kit/app-model/pull/154) ([tlambert03](https://github.com/tlambert03)) - docs: use griffe-fieldz instead of builtin-extension [\#149](https://github.com/pyapp-kit/app-model/pull/149) ([tlambert03](https://github.com/tlambert03)) - docs: documentation overhaul [\#142](https://github.com/pyapp-kit/app-model/pull/142) ([tlambert03](https://github.com/tlambert03)) - docs: Fix bullet points in `Exp` [\#125](https://github.com/pyapp-kit/app-model/pull/125) ([lucyleeow](https://github.com/lucyleeow)) **Merged pull requests:** - chore: Provide information about callback registered [\#166](https://github.com/pyapp-kit/app-model/pull/166) ([Czaki](https://github.com/Czaki)) - style: type cleanup/modernization [\#156](https://github.com/pyapp-kit/app-model/pull/156) ([tlambert03](https://github.com/tlambert03)) - ci: \[pre-commit.ci\] autoupdate [\#152](https://github.com/pyapp-kit/app-model/pull/152) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci)) - ci: Update CI workflow to include reusable test [\#150](https://github.com/pyapp-kit/app-model/pull/150) ([tlambert03](https://github.com/tlambert03)) - style: better qt typing [\#141](https://github.com/pyapp-kit/app-model/pull/141) ([tlambert03](https://github.com/tlambert03)) - ci: Unpin pyside6 in tests [\#138](https://github.com/pyapp-kit/app-model/pull/138) ([tlambert03](https://github.com/tlambert03)) - chore: remove setup.py, update ruff [\#131](https://github.com/pyapp-kit/app-model/pull/131) ([tlambert03](https://github.com/tlambert03)) - refactor: use pydantic-compat [\#128](https://github.com/pyapp-kit/app-model/pull/128) ([tlambert03](https://github.com/tlambert03)) ## [v0.2.2](https://github.com/pyapp-kit/app-model/tree/v0.2.2) (2023-09-25) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.2.1...v0.2.2) **Fixed bugs:** - fix: propagate \_recurse value in `QModelSubmenu.update_from_context` method [\#122](https://github.com/pyapp-kit/app-model/pull/122) ([Czaki](https://github.com/Czaki)) **Merged pull requests:** - ci\(dependabot\): bump actions/checkout from 3 to 4 [\#121](https://github.com/pyapp-kit/app-model/pull/121) ([dependabot[bot]](https://github.com/apps/dependabot)) ## [v0.2.1](https://github.com/pyapp-kit/app-model/tree/v0.2.1) (2023-08-30) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.2.0...v0.2.1) **Fixed bugs:** - fix: properly connect events for Contexts comprised of other Contexts [\#119](https://github.com/pyapp-kit/app-model/pull/119) ([kne42](https://github.com/kne42)) ## [v0.2.0](https://github.com/pyapp-kit/app-model/tree/v0.2.0) (2023-07-13) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.1.4...v0.2.0) **Implemented enhancements:** - feat: map win and cmd to meta [\#113](https://github.com/pyapp-kit/app-model/pull/113) ([tlambert03](https://github.com/tlambert03)) - feat: support pydantic v2 [\#98](https://github.com/pyapp-kit/app-model/pull/98) ([tlambert03](https://github.com/tlambert03)) **Fixed bugs:** - fix: Amend preferences `StandardKeyBinding` [\#104](https://github.com/pyapp-kit/app-model/pull/104) ([lucyleeow](https://github.com/lucyleeow)) - fix: fix menu titles in QtModelMenuBar [\#102](https://github.com/pyapp-kit/app-model/pull/102) ([tlambert03](https://github.com/tlambert03)) **Tests & CI:** - ci: test pydantic1 [\#115](https://github.com/pyapp-kit/app-model/pull/115) ([tlambert03](https://github.com/tlambert03)) **Documentation:** - docs: Move `_expressions.py` docstring to be included in documentation [\#107](https://github.com/pyapp-kit/app-model/pull/107) ([lucyleeow](https://github.com/lucyleeow)) ## [v0.1.4](https://github.com/pyapp-kit/app-model/tree/v0.1.4) (2023-04-06) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.1.3...v0.1.4) **Merged pull requests:** - build: pin pydantic \< 2 [\#96](https://github.com/pyapp-kit/app-model/pull/96) ([tlambert03](https://github.com/tlambert03)) ## [v0.1.3](https://github.com/pyapp-kit/app-model/tree/v0.1.3) (2023-04-06) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.1.2...v0.1.3) **Fixed bugs:** - fix: don't use mixin for menus [\#95](https://github.com/pyapp-kit/app-model/pull/95) ([tlambert03](https://github.com/tlambert03)) ## [v0.1.2](https://github.com/pyapp-kit/app-model/tree/v0.1.2) (2023-03-07) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.1.1...v0.1.2) **Fixed bugs:** - fix: Fix typo in execute\_command method [\#86](https://github.com/pyapp-kit/app-model/pull/86) ([davidbrochart](https://github.com/davidbrochart)) - fix: Fix ctrl meta key swap \(for real this time \(i think\)\) [\#82](https://github.com/pyapp-kit/app-model/pull/82) ([kne42](https://github.com/kne42)) **Tests & CI:** - Precommit updates [\#88](https://github.com/pyapp-kit/app-model/pull/88) ([tlambert03](https://github.com/tlambert03)) **Documentation:** - docs: fix docs build \(add ToggleRule\) [\#79](https://github.com/pyapp-kit/app-model/pull/79) ([tlambert03](https://github.com/tlambert03)) **Merged pull requests:** - build: use hatch for build and ruff for linting [\#81](https://github.com/pyapp-kit/app-model/pull/81) ([tlambert03](https://github.com/tlambert03)) - chore: rename napari org to pyapp-kit [\#78](https://github.com/pyapp-kit/app-model/pull/78) ([tlambert03](https://github.com/tlambert03)) ## [v0.1.1](https://github.com/pyapp-kit/app-model/tree/v0.1.1) (2022-11-10) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.1.0...v0.1.1) **Implemented enhancements:** - feat: support python 3.11 [\#77](https://github.com/pyapp-kit/app-model/pull/77) ([tlambert03](https://github.com/tlambert03)) **Fixed bugs:** - fix: fix unsupported operand [\#76](https://github.com/pyapp-kit/app-model/pull/76) ([tlambert03](https://github.com/tlambert03)) **Merged pull requests:** - refactor: Use a dict \(as an ordered set\) instead of a list for menus registry [\#74](https://github.com/pyapp-kit/app-model/pull/74) ([aganders3](https://github.com/aganders3)) - ci\(dependabot\): bump styfle/cancel-workflow-action from 0.10.1 to 0.11.0 [\#72](https://github.com/pyapp-kit/app-model/pull/72) ([dependabot[bot]](https://github.com/apps/dependabot)) ## [v0.1.0](https://github.com/pyapp-kit/app-model/tree/v0.1.0) (2022-10-10) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.0.9...v0.1.0) **Fixed bugs:** - fix: properly detect when ctrl and meta swapped on mac [\#64](https://github.com/pyapp-kit/app-model/pull/64) ([kne42](https://github.com/kne42)) - fix various bugs [\#63](https://github.com/pyapp-kit/app-model/pull/63) ([kne42](https://github.com/kne42)) **Merged pull requests:** - chore: changelog v0.1.0 [\#69](https://github.com/pyapp-kit/app-model/pull/69) ([tlambert03](https://github.com/tlambert03)) - feat: convert keybinding to normal class [\#68](https://github.com/pyapp-kit/app-model/pull/68) ([kne42](https://github.com/kne42)) - ci\(dependabot\): bump styfle/cancel-workflow-action from 0.10.0 to 0.10.1 [\#66](https://github.com/pyapp-kit/app-model/pull/66) ([dependabot[bot]](https://github.com/apps/dependabot)) ## [v0.0.9](https://github.com/pyapp-kit/app-model/tree/v0.0.9) (2022-08-26) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.0.8...v0.0.9) **Implemented enhancements:** - feat: eval expr when creating menus [\#61](https://github.com/pyapp-kit/app-model/pull/61) ([tlambert03](https://github.com/tlambert03)) **Documentation:** - docs: fix a few typos in docs [\#60](https://github.com/pyapp-kit/app-model/pull/60) ([alisterburt](https://github.com/alisterburt)) ## [v0.0.8](https://github.com/pyapp-kit/app-model/tree/v0.0.8) (2022-08-21) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.0.7...v0.0.8) **Implemented enhancements:** - feat: add ToggleRule for toggleable Actions [\#59](https://github.com/pyapp-kit/app-model/pull/59) ([tlambert03](https://github.com/tlambert03)) **Tests & CI:** - ci: add napari tests [\#57](https://github.com/pyapp-kit/app-model/pull/57) ([tlambert03](https://github.com/tlambert03)) **Merged pull requests:** - refactor: switch to extra ignore [\#58](https://github.com/pyapp-kit/app-model/pull/58) ([tlambert03](https://github.com/tlambert03)) ## [v0.0.7](https://github.com/pyapp-kit/app-model/tree/v0.0.7) (2022-07-24) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.0.6...v0.0.7) **Merged pull requests:** - build: relax runtime typing extensions dependency [\#49](https://github.com/pyapp-kit/app-model/pull/49) ([tlambert03](https://github.com/tlambert03)) ## [v0.0.6](https://github.com/pyapp-kit/app-model/tree/v0.0.6) (2022-07-24) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.0.5...v0.0.6) **Implemented enhancements:** - feat: add get\_app class method to Application [\#48](https://github.com/pyapp-kit/app-model/pull/48) ([tlambert03](https://github.com/tlambert03)) ## [v0.0.5](https://github.com/pyapp-kit/app-model/tree/v0.0.5) (2022-07-23) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.0.4...v0.0.5) **Implemented enhancements:** - test: more test coverage [\#46](https://github.com/pyapp-kit/app-model/pull/46) ([tlambert03](https://github.com/tlambert03)) - feat: add register\_actions [\#45](https://github.com/pyapp-kit/app-model/pull/45) ([tlambert03](https://github.com/tlambert03)) - fix: small getitem fixes for napari [\#44](https://github.com/pyapp-kit/app-model/pull/44) ([tlambert03](https://github.com/tlambert03)) - feat: qt key conversion helpers [\#43](https://github.com/pyapp-kit/app-model/pull/43) ([tlambert03](https://github.com/tlambert03)) **Fixed bugs:** - fix: fix sorting when group is None [\#42](https://github.com/pyapp-kit/app-model/pull/42) ([tlambert03](https://github.com/tlambert03)) **Tests & CI:** - tests: more qtest coverage [\#47](https://github.com/pyapp-kit/app-model/pull/47) ([tlambert03](https://github.com/tlambert03)) ## [v0.0.4](https://github.com/pyapp-kit/app-model/tree/v0.0.4) (2022-07-16) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.0.3...v0.0.4) **Implemented enhancements:** - feat: add toggled to command [\#41](https://github.com/pyapp-kit/app-model/pull/41) ([tlambert03](https://github.com/tlambert03)) - feat: raise\_synchronous option, and expose app classes [\#40](https://github.com/pyapp-kit/app-model/pull/40) ([tlambert03](https://github.com/tlambert03)) ## [v0.0.3](https://github.com/pyapp-kit/app-model/tree/v0.0.3) (2022-07-14) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.0.2...v0.0.3) **Merged pull requests:** - fix: expression hashing and repr [\#39](https://github.com/pyapp-kit/app-model/pull/39) ([tlambert03](https://github.com/tlambert03)) ## [v0.0.2](https://github.com/pyapp-kit/app-model/tree/v0.0.2) (2022-07-13) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.0.1...v0.0.2) **Merged pull requests:** - chore: move tlambert03/app-model to napari [\#38](https://github.com/pyapp-kit/app-model/pull/38) ([tlambert03](https://github.com/tlambert03)) - fix: allow older qtpy [\#37](https://github.com/pyapp-kit/app-model/pull/37) ([tlambert03](https://github.com/tlambert03)) - docs: Add Documentation [\#36](https://github.com/pyapp-kit/app-model/pull/36) ([tlambert03](https://github.com/tlambert03)) - feat: cache qactions \[wip\] [\#35](https://github.com/pyapp-kit/app-model/pull/35) ([tlambert03](https://github.com/tlambert03)) - feat: updating demo [\#34](https://github.com/pyapp-kit/app-model/pull/34) ([tlambert03](https://github.com/tlambert03)) - build: pin min typing extensions [\#33](https://github.com/pyapp-kit/app-model/pull/33) ([tlambert03](https://github.com/tlambert03)) - feat: add standard keybindings [\#32](https://github.com/pyapp-kit/app-model/pull/32) ([tlambert03](https://github.com/tlambert03)) - feat: frozen models [\#31](https://github.com/pyapp-kit/app-model/pull/31) ([tlambert03](https://github.com/tlambert03)) - refactor: restrict to only one command per id [\#30](https://github.com/pyapp-kit/app-model/pull/30) ([tlambert03](https://github.com/tlambert03)) ## [v0.0.1](https://github.com/pyapp-kit/app-model/tree/v0.0.1) (2022-07-06) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/3a1e61cc7b0b249a9f2e3fce9cfa6cf6b766cb2a...v0.0.1) **Merged pull requests:** - refactor: a number of fixes [\#26](https://github.com/pyapp-kit/app-model/pull/26) ([tlambert03](https://github.com/tlambert03)) - feat: demo app [\#24](https://github.com/pyapp-kit/app-model/pull/24) ([tlambert03](https://github.com/tlambert03)) - test: fix pre-test [\#23](https://github.com/pyapp-kit/app-model/pull/23) ([tlambert03](https://github.com/tlambert03)) - build: add py.typed [\#22](https://github.com/pyapp-kit/app-model/pull/22) ([tlambert03](https://github.com/tlambert03)) - feat: add injection model to app [\#21](https://github.com/pyapp-kit/app-model/pull/21) ([tlambert03](https://github.com/tlambert03)) - feat: allow callbacks as strings [\#18](https://github.com/pyapp-kit/app-model/pull/18) ([tlambert03](https://github.com/tlambert03)) - refactor: create backend folder [\#17](https://github.com/pyapp-kit/app-model/pull/17) ([tlambert03](https://github.com/tlambert03)) - feat: Keybindings! [\#16](https://github.com/pyapp-kit/app-model/pull/16) ([tlambert03](https://github.com/tlambert03)) - feat: more qt support, submenus, etc [\#11](https://github.com/pyapp-kit/app-model/pull/11) ([tlambert03](https://github.com/tlambert03)) - feat: Add qt module [\#10](https://github.com/pyapp-kit/app-model/pull/10) ([tlambert03](https://github.com/tlambert03)) - feat: combine app model [\#9](https://github.com/pyapp-kit/app-model/pull/9) ([tlambert03](https://github.com/tlambert03)) - test: more test coverage, organization, and documentation [\#7](https://github.com/pyapp-kit/app-model/pull/7) ([tlambert03](https://github.com/tlambert03)) - fix: Fix windows keybindings tests [\#5](https://github.com/pyapp-kit/app-model/pull/5) ([tlambert03](https://github.com/tlambert03)) - ci\(dependabot\): bump codecov/codecov-action from 2 to 3 [\#2](https://github.com/pyapp-kit/app-model/pull/2) ([dependabot[bot]](https://github.com/apps/dependabot)) - ci\(dependabot\): bump styfle/cancel-workflow-action from 0.9.1 to 0.10.0 [\#1](https://github.com/pyapp-kit/app-model/pull/1) ([dependabot[bot]](https://github.com/apps/dependabot)) \* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)* app-model-0.5.1/LICENSE000066400000000000000000000027071510416065100144050ustar00rootroot00000000000000Copyright (c) 2022, Talley Lambert Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. app-model-0.5.1/README.md000066400000000000000000000020221510416065100146450ustar00rootroot00000000000000# app-model [![License](https://img.shields.io/pypi/l/app-model.svg?color=green)](https://github.com/pyapp-kit/app-model/raw/main/LICENSE) [![PyPI](https://img.shields.io/pypi/v/app-model.svg?color=green)](https://pypi.org/project/app-model) [![Python Version](https://img.shields.io/pypi/pyversions/app-model.svg?color=green)](https://python.org) [![CI](https://github.com/pyapp-kit/app-model/actions/workflows/ci.yml/badge.svg)](https://github.com/pyapp-kit/app-model/actions/workflows/ci.yml) [![codecov](https://codecov.io/gh/pyapp-kit/app-model/branch/main/graph/badge.svg)](https://codecov.io/gh/pyapp-kit/app-model) [![Documentation Status](https://readthedocs.org/projects/app-model/badge/?version=latest)](https://app-model.readthedocs.io/en/latest/?badge=latest) Generic application schema implemented in python. This is a schema for declarative organization of application data, such as menus, keybindings, actions/commands, etc... Inspired by the VS-Code application model docs at https://app-model.readthedocs.io/en/latest/ app-model-0.5.1/codecov.yml000066400000000000000000000001641510416065100155400ustar00rootroot00000000000000coverage: status: patch: default: target: 100% project: default: target: 100% app-model-0.5.1/demo/000077500000000000000000000000001510416065100143165ustar00rootroot00000000000000app-model-0.5.1/demo/images/000077500000000000000000000000001510416065100155635ustar00rootroot00000000000000app-model-0.5.1/demo/images/about.svg000066400000000000000000000012121510416065100174120ustar00rootroot00000000000000app-model-0.5.1/demo/keybinding_helper.py000066400000000000000000000003741510416065100203560ustar00rootroot00000000000000import sys from qtpy.QtWidgets import QApplication from app_model.backends.qt import QModelKeyBindingEdit app = QApplication(sys.argv) w = QModelKeyBindingEdit() w.editingFinished.connect(lambda: print(w.keyBinding())) w.show() sys.exit(app.exec()) app-model-0.5.1/demo/model_app.py000066400000000000000000000207121510416065100166320ustar00rootroot00000000000000from pathlib import Path from typing import TYPE_CHECKING, cast from qtpy.QtCore import QFile, QFileInfo, QSaveFile, Qt, QTextStream from qtpy.QtWidgets import QApplication, QFileDialog, QMessageBox, QTextEdit from app_model import Application, types from app_model.backends.qt import QModelMainWindow from app_model.expressions import create_context if TYPE_CHECKING: from app_model.backends.qt._qmenu import QModelMenuBar class MainWindow(QModelMainWindow): def __init__(self, app: Application) -> None: super().__init__(app) self._cur_file: str = "" self._text_edit = QTextEdit() self._text_edit.copyAvailable.connect(self._update_context) self.setCentralWidget(self._text_edit) self.setModelMenuBar([MenuId.FILE, MenuId.EDIT, MenuId.HELP]) self.addModelToolBar(MenuId.FILE, exclude={CommandId.SAVE_AS, CommandId.EXIT}) self.addModelToolBar(MenuId.EDIT) self.addModelToolBar(MenuId.HELP) if sb := self.statusBar(): sb.showMessage("Ready") self.set_current_file("") self._ctx = create_context(self) self._ctx.changed.connect(self._on_context_changed) self._ctx["copyAvailable"] = False def _update_context(self, available: bool) -> None: self._ctx["copyAvailable"] = available def _on_context_changed(self) -> None: mb = cast("QModelMenuBar", self.menuBar()) mb.update_from_context(self._ctx) def set_current_file(self, fileName: str) -> None: self._cur_file = fileName if doc := self._text_edit.document(): doc.setModified(False) self.setWindowModified(False) if self._cur_file: shown_name = QFileInfo(self._cur_file).fileName() else: shown_name = "untitled.txt" self.setWindowTitle(f"{shown_name}[*] - Application") def save(self) -> bool: return self.save_file(self._cur_file) if self._cur_file else self.save_as() def save_as(self) -> bool: fileName, _ = QFileDialog.getSaveFileName(self) if fileName: return self.save_file(fileName) return False def save_file(self, fileName: str) -> bool: error = None QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) file = QSaveFile(fileName) if file.open(QFile.OpenModeFlag.WriteOnly | QFile.OpenModeFlag.Text): # type: ignore outf = QTextStream(file) outf << self._text_edit.toPlainText() # pyright: ignore if not file.commit(): reason = file.errorString() error = f"Cannot write file {fileName}:\n{reason}." else: reason = file.errorString() error = f"Cannot open file {fileName}:\n{reason}." QApplication.restoreOverrideCursor() if error: QMessageBox.warning(self, "Application", error) return False return True def maybe_save(self) -> bool: if (doc := self._text_edit.document()) and doc.isModified(): ret = QMessageBox.warning( self, "Application", "The document has been modified.\nDo you want to save your changes?", QMessageBox.StandardButton.Save # type: ignore | QMessageBox.StandardButton.Discard | QMessageBox.StandardButton.Cancel, ) if ret == QMessageBox.StandardButton.Save: return self.save() elif ret == QMessageBox.StandardButton.Cancel: return False return True def new_file(self) -> None: if self.maybe_save(): self._text_edit.clear() self.set_current_file("") def open_file(self) -> None: if self.maybe_save(): fileName, _ = QFileDialog.getOpenFileName(self) if fileName: self.load_file(fileName) def load_file(self, fileName: str) -> None: file = QFile(fileName) if not file.open(QFile.OpenModeFlag.ReadOnly | QFile.OpenModeFlag.Text): # type: ignore reason = file.errorString() QMessageBox.warning( self, "Application", f"Cannot read file {fileName}:\n{reason}." ) return inf = QTextStream(file) QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) self._text_edit.setPlainText(inf.readAll()) QApplication.restoreOverrideCursor() self.set_current_file(fileName) if sb := self.statusBar(): sb.showMessage("File loaded", 2000) def about(self) -> None: QMessageBox.about( self, "About Application", "The Application example demonstrates how to write " "modern GUI applications using Qt, with a menu bar, " "toolbars, and a status bar.", ) def cut(self) -> None: self._text_edit.cut() def copy(self) -> None: self._text_edit.copy() def paste(self) -> None: self._text_edit.paste() def close(self) -> bool: return super().close() # Actions defined declaratively outside of QMainWindow class ... # menus and toolbars will be made and added automatically class MenuId: FILE = "file" EDIT = "edit" HELP = "help" class CommandId: SAVE_AS = "save_file_as" EXIT = "exit" ABOUT_ICON_PATH = Path(__file__).parent / "images" / "about.svg" ACTIONS: list[types.Action] = [ types.Action( id="new_file", icon="fa6-solid:file-circle-plus", title="New", keybindings=[types.StandardKeyBinding.New], status_tip="Create a new file", menus=[{"id": MenuId.FILE, "group": "1_loadsave"}], callback=MainWindow.new_file, ), types.Action( id="open_file", icon="fa6-solid:folder-open", title="Open...", keybindings=[types.StandardKeyBinding.Open], status_tip="Open an existing file", menus=[{"id": MenuId.FILE, "group": "1_loadsave"}], callback=MainWindow.open_file, ), types.Action( id="save_file", icon="fa6-solid:floppy-disk", title="Save", keybindings=[types.StandardKeyBinding.Save], status_tip="Save the document to disk", menus=[{"id": MenuId.FILE, "group": "1_loadsave"}], callback=MainWindow.save, ), types.Action( id=CommandId.SAVE_AS, title="Save As...", keybindings=[types.StandardKeyBinding.SaveAs], status_tip="Save the document under a new name", menus=[{"id": MenuId.FILE, "group": "1_loadsave"}], callback=MainWindow.save_as, ), types.Action( id=CommandId.EXIT, title="Exit", keybindings=[types.StandardKeyBinding.Quit], status_tip="Exit the application", menus=[{"id": MenuId.FILE, "group": "3_launchexit"}], callback=MainWindow.close, ), types.Action( id="cut", icon="fa6-solid:scissors", title="Cut", keybindings=[types.StandardKeyBinding.Cut], enablement="copyAvailable", status_tip="Cut the current selection's contents to the clipboard", menus=[{"id": MenuId.EDIT}], callback=MainWindow.cut, ), types.Action( id="copy", icon="fa6-solid:copy", title="Copy", keybindings=[types.StandardKeyBinding.Copy], enablement="copyAvailable", status_tip="Copy the current selection's contents to the clipboard", menus=[{"id": MenuId.EDIT}], callback=MainWindow.copy, ), types.Action( id="paste", icon="fa6-solid:paste", title="Paste", keybindings=[types.StandardKeyBinding.Paste], status_tip="Paste the clipboard's contents into the current selection", menus=[{"id": MenuId.EDIT}], callback=MainWindow.paste, ), types.Action( id="about", icon=f"file:///{ABOUT_ICON_PATH}", title="About", status_tip="Show the application's About box", menus=[{"id": MenuId.HELP}], callback=MainWindow.about, ), ] # Main setup if __name__ == "__main__": app = Application(name="my_app") for action in ACTIONS: app.register_action(action) qapp = QApplication.instance() or QApplication([]) qapp.setAttribute(Qt.ApplicationAttribute.AA_DontShowIconsInMenus) main_win = MainWindow(app=app) app.injection_store.register_provider(lambda: main_win, MainWindow) main_win.show() qapp.exec() app-model-0.5.1/demo/multi_file/000077500000000000000000000000001510416065100164475ustar00rootroot00000000000000app-model-0.5.1/demo/multi_file/__init__.py000066400000000000000000000000001510416065100205460ustar00rootroot00000000000000app-model-0.5.1/demo/multi_file/__main__.py000066400000000000000000000003721510416065100205430ustar00rootroot00000000000000import pathlib import sys sys.path.append(str(pathlib.Path(__file__).parent.parent)) from qtpy.QtWidgets import QApplication from multi_file.app import MyApp qapp = QApplication.instance() or QApplication([]) app = MyApp() app.show() qapp.exec() app-model-0.5.1/demo/multi_file/actions.py000066400000000000000000000040641510416065100204650ustar00rootroot00000000000000from app_model.types import Action, KeyBindingRule, KeyCode, KeyMod, MenuRule from . import functions from .constants import CommandId, MenuId ACTIONS: list[Action] = [ Action( id=CommandId.OPEN, title="Open", icon="fa6-solid:folder-open", callback=functions.open_file, menus=[MenuRule(id=MenuId.FILE)], keybindings=[KeyBindingRule(primary=KeyMod.CtrlCmd | KeyCode.KeyO)], ), Action( id=CommandId.CLOSE, title="Close", icon="fa-solid:window-close", callback=functions.close, menus=[MenuRule(id=MenuId.FILE)], keybindings=[KeyBindingRule(primary=KeyMod.CtrlCmd | KeyCode.KeyW)], ), Action( id=CommandId.UNDO, title="Undo", icon="fa-solid:undo", callback=functions.undo, menus=[MenuRule(id=MenuId.EDIT, group="1_undo_redo")], keybindings=[KeyBindingRule(primary=KeyMod.CtrlCmd | KeyCode.KeyZ)], ), Action( id=CommandId.REDO, title="Redo", icon="fa6-solid:rotate-right", callback=functions.redo, menus=[MenuRule(id=MenuId.EDIT, group="1_undo_redo")], keybindings=[ KeyBindingRule(primary=KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyZ) ], ), Action( id=CommandId.CUT, title="Cut", icon="fa-solid:cut", callback=functions.cut, menus=[MenuRule(id=MenuId.EDIT, group="3_copypaste")], keybindings=[KeyBindingRule(primary=KeyMod.CtrlCmd | KeyCode.KeyX)], ), Action( id=CommandId.COPY, title="Copy", icon="fa6-solid:copy", callback=functions.copy, menus=[MenuRule(id=MenuId.EDIT, group="3_copypaste")], keybindings=[KeyBindingRule(primary=KeyMod.CtrlCmd | KeyCode.KeyC)], ), Action( id=CommandId.PASTE, title="Paste", icon="fa6-solid:paste", callback=functions.paste, menus=[MenuRule(id=MenuId.EDIT, group="3_copypaste")], keybindings=[KeyBindingRule(primary=KeyMod.CtrlCmd | KeyCode.KeyV)], ), ] app-model-0.5.1/demo/multi_file/app.py000066400000000000000000000012011510416065100175730ustar00rootroot00000000000000from app_model import Application from app_model.backends.qt import QModelMainWindow from .actions import ACTIONS from .constants import MenuId class MyApp(Application): def __init__(self) -> None: super().__init__("my_application") # ACTIONS is a list of Action objects. for action in ACTIONS: self.register_action(action) self._main_window = QModelMainWindow(app=self) # This will build a menu bar based on these menus self._main_window.setModelMenuBar([MenuId.FILE, MenuId.EDIT]) def show(self) -> None: """Show the app""" self._main_window.show() app-model-0.5.1/demo/multi_file/constants.py000066400000000000000000000007141510416065100210370ustar00rootroot00000000000000from enum import Enum class CommandId(str, Enum): OPEN = "myapp.open" CLOSE = "myapp.close" SAVE = "myapp.save" QUIT = "myapp.quit" UNDO = "myapp.undo" REDO = "myapp.redo" COPY = "myapp.copy" PASTE = "myapp.paste" CUT = "myapp.cut" def __str__(self) -> str: return self.value class MenuId(str, Enum): FILE = "myapp/file" EDIT = "myapp/edit" def __str__(self) -> str: return self.value app-model-0.5.1/demo/multi_file/functions.py000066400000000000000000000007131510416065100210320ustar00rootroot00000000000000from qtpy.QtWidgets import QApplication, QFileDialog def open_file() -> None: name, _ = QFileDialog.getOpenFileName() print("Open file:", name) def close() -> None: if win := QApplication.activeWindow(): win.close() print("close") def undo() -> None: print("undo") def redo() -> None: print("redo") def cut() -> None: print("cut") def copy() -> None: print("copy") def paste() -> None: print("paste") app-model-0.5.1/demo/qapplication.py000066400000000000000000000205221510416065100173550ustar00rootroot00000000000000# Copyright (C) 2013 Riverbank Computing Limited. # Copyright (C) 2022 The Qt Company Ltd. # SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause # pyright: reportOptionalMemberAccess=false from fonticon_fa6 import FA6S from qtpy.QtCore import QFile, QFileInfo, QSaveFile, Qt, QTextStream from qtpy.QtGui import QKeySequence from qtpy.QtWidgets import ( QAction, QApplication, QFileDialog, QMainWindow, QMessageBox, QTextEdit, ) from superqt import fonticon class MainWindow(QMainWindow): def __init__(self) -> None: super().__init__() self._cur_file = "" self._text_edit = QTextEdit() self.setCentralWidget(self._text_edit) self.create_actions() self.create_menus() self.create_tool_bars() self.create_status_bar() self.set_current_file("") def new_file(self) -> None: if self.maybe_save(): self._text_edit.clear() self.set_current_file("") def open(self) -> None: if self.maybe_save(): fileName, _filtr = QFileDialog.getOpenFileName(self) if fileName: self.load_file(fileName) def save(self) -> bool: return self.save_file(self._cur_file) if self._cur_file else self.save_as() def save_as(self) -> bool: fileName, _filtr = QFileDialog.getSaveFileName(self) if fileName: return self.save_file(fileName) return False def about(self) -> None: QMessageBox.about( self, "About Application", "The Application example demonstrates how to write " "modern GUI applications using Qt, with a menu bar, " "toolbars, and a status bar.", ) def create_actions(self) -> None: self._new_act = QAction( fonticon.icon(FA6S.file_circle_plus), "&New", self, shortcut=QKeySequence.StandardKey.New, statusTip="Create a new file", triggered=self.new_file, ) self._open_act = QAction( fonticon.icon(FA6S.folder_open), "&Open...", self, shortcut=QKeySequence.StandardKey.Open, statusTip="Open an existing file", triggered=self.open, ) self._save_act = QAction( fonticon.icon(FA6S.floppy_disk), "&Save", self, shortcut=QKeySequence.StandardKey.Save, statusTip="Save the document to disk", triggered=self.save, ) self._save_as_act = QAction( "Save &As...", self, shortcut=QKeySequence.StandardKey.SaveAs, statusTip="Save the document under a new name", triggered=self.save_as, ) self._exit_act = QAction( "E&xit", self, shortcut="Ctrl+Q", statusTip="Exit the application", triggered=self.close, ) self._cut_act = QAction( fonticon.icon(FA6S.scissors), "Cu&t", self, shortcut=QKeySequence.StandardKey.Cut, statusTip="Cut the current selection's contents to the clipboard", triggered=self._text_edit.cut, ) self._copy_act = QAction( fonticon.icon(FA6S.copy), "&Copy", self, shortcut=QKeySequence.StandardKey.Copy, statusTip="Copy the current selection's contents to the clipboard", triggered=self._text_edit.copy, ) self._paste_act = QAction( fonticon.icon(FA6S.paste), "&Paste", self, shortcut=QKeySequence.StandardKey.Paste, statusTip="Paste the clipboard's contents into the current selection", triggered=self._text_edit.paste, ) self._about_act = QAction( "&About", self, statusTip="Show the application's About box", triggered=self.about, ) self._cut_act.setEnabled(False) self._copy_act.setEnabled(False) self._text_edit.copyAvailable.connect(self._cut_act.setEnabled) self._text_edit.copyAvailable.connect(self._copy_act.setEnabled) def create_menus(self) -> None: self._file_menu = self.menuBar().addMenu("&File") self._file_menu.addAction(self._new_act) self._file_menu.addAction(self._open_act) self._file_menu.addAction(self._save_act) self._file_menu.addAction(self._save_as_act) self._file_menu.addSeparator() self._file_menu.addAction(self._exit_act) self._edit_menu = self.menuBar().addMenu("&Edit") self._edit_menu.addAction(self._cut_act) self._edit_menu.addAction(self._copy_act) self._edit_menu.addAction(self._paste_act) self.menuBar().addSeparator() self._help_menu = self.menuBar().addMenu("&Help") self._help_menu.addAction(self._about_act) def create_tool_bars(self) -> None: self._file_tool_bar = self.addToolBar("File") self._file_tool_bar.addAction(self._new_act) self._file_tool_bar.addAction(self._open_act) self._file_tool_bar.addAction(self._save_act) self._edit_tool_bar = self.addToolBar("Edit") self._edit_tool_bar.addAction(self._cut_act) self._edit_tool_bar.addAction(self._copy_act) self._edit_tool_bar.addAction(self._paste_act) def create_status_bar(self) -> None: self.statusBar().showMessage("Ready") def maybe_save(self) -> bool: if self._text_edit.document().isModified(): ret = QMessageBox.warning( self, "Application", "The document has been modified.\nDo you want to save your changes?", QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Discard | QMessageBox.StandardButton.Cancel, ) if ret == QMessageBox.StandardButton.Save: return self.save() elif ret == QMessageBox.StandardButton.Cancel: return False return True def load_file(self, fileName: str) -> None: file = QFile(fileName) if not file.open(QFile.OpenModeFlag.ReadOnly | QFile.OpenModeFlag.Text): reason = file.errorString() QMessageBox.warning( self, "Application", f"Cannot read file {fileName}:\n{reason}." ) return inf = QTextStream(file) QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) self._text_edit.setPlainText(inf.readAll()) QApplication.restoreOverrideCursor() self.set_current_file(fileName) self.statusBar().showMessage("File loaded", 2000) def save_file(self, fileName: str) -> bool: error = None QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) file = QSaveFile(fileName) if file.open(QFile.OpenModeFlag.WriteOnly | QFile.OpenModeFlag.Text): outf = QTextStream(file) outf << self._text_edit.toPlainText() # pyright: ignore if not file.commit(): reason = file.errorString() error = f"Cannot write file {fileName}:\n{reason}." else: reason = file.errorString() error = f"Cannot open file {fileName}:\n{reason}." QApplication.restoreOverrideCursor() if error: QMessageBox.warning(self, "Application", error) return False self.set_current_file(fileName) self.statusBar().showMessage("File saved", 2000) return True def set_current_file(self, fileName: str) -> None: self._cur_file = fileName self._text_edit.document().setModified(False) self.setWindowModified(False) if self._cur_file: shown_name = self.stripped_name(self._cur_file) else: shown_name = "untitled.txt" self.setWindowTitle(f"{shown_name}[*] - Application") def stripped_name(self, fullFileName: str) -> str: return QFileInfo(fullFileName).fileName() if __name__ == "__main__": qapp = QApplication.instance() or QApplication([]) qapp.setAttribute(Qt.ApplicationAttribute.AA_DontShowIconsInMenus) main_win = MainWindow() main_win.show() qapp.exec() app-model-0.5.1/docs/000077500000000000000000000000001510416065100143225ustar00rootroot00000000000000app-model-0.5.1/docs/css/000077500000000000000000000000001510416065100151125ustar00rootroot00000000000000app-model-0.5.1/docs/css/style.css000066400000000000000000000113771510416065100167750ustar00rootroot00000000000000/* Increase logo size */ .md-header__button.md-logo { padding-bottom: 0.2rem; padding-right: 0; } .md-header__button.md-logo img { height: 1.5rem; } /* Mark external links as such (also in nav) */ a.external:hover::after, a.md-nav__link[href^="https:"]:hover::after { /* https://primer.style/octicons/link-external-16 */ background-image: url('data:image/svg+xml,'); height: 0.8em; width: 0.8em; margin-left: 0.2em; content: ' '; display: inline-block; } /* More space at the bottom of the page */ .md-main__inner { margin-bottom: 1.5rem; } /* ------------------------------- */ /* Indentation. */ div.doc-contents:not(.first) { padding-left: 25px; border-left: .05rem solid var(--md-typeset-table-color); } /* Mark external links as such. */ a.external::after, a.autorefs-external::after { /* https://primer.style/octicons/arrow-up-right-24 */ mask-image: url('data:image/svg+xml,'); -webkit-mask-image: url('data:image/svg+xml,'); content: ' '; display: inline-block; vertical-align: middle; position: relative; height: 1em; width: 1em; background-color: var(--md-typeset-a-color); } a.external:hover::after, a.autorefs-external:hover::after { background-color: var(--md-accent-fg-color); } /* Avoid breaking parameters name, etc. in table cells. */ td code { word-break: normal !important; } /* ------------------------------- */ /* Avoid breaking parameter names, etc. in table cells. */ .doc-contents td code { word-break: normal !important; } /* No line break before first paragraph of descriptions. */ .doc-md-description, .doc-md-description>p:first-child { display: inline; } /* Max width for docstring sections tables. */ .doc .md-typeset__table, .doc .md-typeset__table table { display: table !important; width: 100%; } .doc .md-typeset__table tr { display: table-row; } /* Defaults in Spacy table style. */ .doc-param-default { float: right; } /* Symbols in Navigation and ToC. */ :root, [data-md-color-scheme="default"] { --doc-symbol-attribute-fg-color: #953800; --doc-symbol-function-fg-color: #8250df; --doc-symbol-method-fg-color: #8250df; --doc-symbol-class-fg-color: #0550ae; --doc-symbol-module-fg-color: #5cad0f; --doc-symbol-attribute-bg-color: #9538001a; --doc-symbol-function-bg-color: #8250df1a; --doc-symbol-method-bg-color: #8250df1a; --doc-symbol-class-bg-color: #0550ae1a; --doc-symbol-module-bg-color: #5cad0f1a; } [data-md-color-scheme="slate"] { --doc-symbol-attribute-fg-color: #ffa657; --doc-symbol-function-fg-color: #d2a8ff; --doc-symbol-method-fg-color: #d2a8ff; --doc-symbol-class-fg-color: #79c0ff; --doc-symbol-module-fg-color: #baff79; --doc-symbol-attribute-bg-color: #ffa6571a; --doc-symbol-function-bg-color: #d2a8ff1a; --doc-symbol-method-bg-color: #d2a8ff1a; --doc-symbol-class-bg-color: #79c0ff1a; --doc-symbol-module-bg-color: #baff791a; } code.doc-symbol { border-radius: .1rem; font-size: .85em; padding: 0 .3em; font-weight: bold; } code.doc-symbol-attribute { color: var(--doc-symbol-attribute-fg-color); background-color: var(--doc-symbol-attribute-bg-color); } code.doc-symbol-attribute::after { content: "attr"; } code.doc-symbol-function { color: var(--doc-symbol-function-fg-color); background-color: var(--doc-symbol-function-bg-color); } code.doc-symbol-function::after { content: "func"; } code.doc-symbol-method { color: var(--doc-symbol-method-fg-color); background-color: var(--doc-symbol-method-bg-color); } code.doc-symbol-method::after { content: "meth"; } code.doc-symbol-class { color: var(--doc-symbol-class-fg-color); background-color: var(--doc-symbol-class-bg-color); } code.doc-symbol-class::after { content: "class"; } code.doc-symbol-module { color: var(--doc-symbol-module-fg-color); background-color: var(--doc-symbol-module-bg-color); } code.doc-symbol-module::after { content: "mod"; }app-model-0.5.1/docs/gen_ref_nav.py000066400000000000000000000021631510416065100171470ustar00rootroot00000000000000"""Generate the code reference pages and navigation.""" from pathlib import Path import mkdocs_gen_files SRC = Path("src") PKG = SRC / "app_model" nav = mkdocs_gen_files.Nav() mod_symbol = '' for path in sorted(SRC.rglob("*.py")): module_path = path.relative_to("src").with_suffix("") doc_path = path.relative_to(PKG).with_suffix(".md") full_doc_path = Path("reference", doc_path) parts = tuple(module_path.parts) if parts[-1] == "__init__": parts = parts[:-1] doc_path = doc_path.with_name("index.md") full_doc_path = full_doc_path.with_name("index.md") if parts[-1].startswith("_"): continue nav_parts = [f"{mod_symbol} {part}" for part in parts] nav[tuple(nav_parts)] = doc_path.as_posix() with mkdocs_gen_files.open(full_doc_path, "w") as fd: ident = ".".join(parts) fd.write(f"::: {ident}") mkdocs_gen_files.set_edit_path(full_doc_path, ".." / path) with mkdocs_gen_files.open("reference/SUMMARY.txt", "w") as nav_file: nav_file.writelines(nav.build_literate_nav()) app-model-0.5.1/docs/getting_started.md000066400000000000000000000216351510416065100200420ustar00rootroot00000000000000# Getting Started ## Creating an Application Typical usage will begin by creating a [`Application`][app_model.Application] object. ```python from app_model import Application my_app = Application('my-app') ``` ## Registering Actions Most applications will have some number of actions that can be invoked by the user. Actions are typically callable objects that perform some operation, such as "open a file", "save a file", "copy", "paste", etc. These actions will usually be exposed in the application's menus and toolbars, and will usually have associated keybindings. Sometimes actions hold state, such as "toggle word wrap" or "toggle line numbers". `app-model` provides a high level [`Action`][app_model.Action] object that comprises a pointer to a callable object, along with placement in menus, keybindings, and additional metadata like title, icons, tooltips, etc... ```python from app_model.types import Action, KeyBindingRule, KeyCode, KeyMod, MenuRule def open_file(): print('open file!') def close_window(): print('close window!') ACTIONS: list[Action] = [ Action( id='open', title="Open", icon="fa6-solid:folder-open", callback=open_file, menus=['File'], keybindings=[KeyBindingRule(primary=KeyMod.CtrlCmd | KeyCode.KeyO)], ), Action( id='close', title="Close", icon="fa-solid:window-close", callback=close_window, menus=['File'], keybindings=[KeyBindingRule(primary=KeyMod.CtrlCmd | KeyCode.KeyW)], ), # ... ] ``` Actions are registered with the application using the [`Application.register_action()`][app_model.Application.register_action] method. ```python for action in ACTIONS: my_app.register_action(action) ``` ## Registries The application maintains three internal registries. 1. `Application.commands` is an instance of [`CommandsRegistry`][app_model.registries.CommandsRegistry]. It maintains all of the commands (the actual callable object) that have been registered with the application. 2. `Application.menus` is an instance of [`MenusRegistry`][app_model.registries.MenusRegistry]. It maintains all of the menus and submenu items that have been registered with the application. 3. `Application.keybindings` is an instance of [`KeyBindingsRegistry`][app_model.registries.KeyBindingsRegistry]. It maintains an association between a [KeyBinding][app_model.types.KeyBinding] and a command id in the `CommandsRegistry`. !!! Note Calling [`Application.register_action`][app_model.Application.register_action] with a single [`Action`][app_model.Action] object is just a convenience around independently registering objects with each of the registries using: - [CommandsRegistry.register_command][app_model.registries.CommandsRegistry.register_command] - [MenusRegistry.append_menu_items][app_model.registries.MenusRegistry.append_menu_items] - [KeyBindingsRegistry.register_keybinding_rule][app_model.registries.KeyBindingsRegistry.register_keybinding_rule] ### Registry events Each of these registries has a signal that is emitted when a new item is added. - `CommandsRegistry.registered` is emitted with the new command id (`str`) whenever [`CommandsRegistry.register_command`][app_model.registries.CommandsRegistry.register_command] is called - `MenusRegistry.menus_changed` is emitted with the new menu ids (`set[str]`) whenever [`MenusRegistry.append_menu_items`][app_model.registries.MenusRegistry.append_menu_items] or if the menu items have been disposed. - `KeyBindingsRegistry.registered` is emitted (no arguments) whenever [`KeyBindingsRegistry.register_keybinding_rule`][app_model.registries.KeyBindingsRegistry.register_keybinding_rule] is called. You can connect callbacks to these events to handle them as needed. ```python @my_app.commands.registered.connect def on_command_registered(command_id: str): print(f'Command {command_id!r} registered!') my_app.commands.register_command('new-id', lambda: None, title='No-op') # Command 'new-id' registered! ``` ## Executing Commands Registered commands may be executed on-demand using [`execute_command`][app_model.registries.CommandsRegistry.execute_command] method on the command registry: ```python my_app.commands.execute_command('open') # prints "open file!" from the `open_file` function registered above. ``` ### Command Arguments and Dependency Injection The `execute_command` function does accept `*args` and `**kwargs` that will be passed to the command. However, very often in a GUI application you may wish to infer some of the arguments from the current state of the application. For example, if you have menu item linked to a "close window", you likely want to close the current window. For this, `app-model` uses a dependency injection pattern, provided by the [`in-n-out`](https://github.com/pyapp-kit/in-n-out) library. The application has a [`injection_store`][app_model.Application.injection_store] attribute that is an instance of an `in_n_out.Store`. A `Store` is a collection of: - **providers**: Functions that can be called to return an instance of a given type. These may be used to provide arguments to commands, based on the type annotations in the command function definition. - **processors**: Functions that accept an instance of a given type and do something with it. These are used to process the return value of the command function at execution time, based on command definition return type annotations. See [`in-n-out` getting started](https://ino.readthedocs.io/en/latest/getting_started/) for more details on the use of providers/processors in the `Store`. Here's a simple example. Let's say an application has a `User` object with a `name()` method: ```python class User: def name(self): return 'John Doe' ``` Assume the application has some way of retrieving the current user: ```python def get_current_user() -> User: # ... get the current user from somewhere return User() ``` We register this provider function with the application's injection store: ```python my_app.injection_store.register_provider(get_current_user) ``` Now commands may be defined that accept a `User` argument, and used for callbacks in actions registered with the application. ```python def print_user_name(user: User) -> None: print(f"Hi {user.name()}!") action = Action( id='greet', title="Greet Current User", callback=print_user_name, ) my_app.register_action(action) my_app.commands.execute_command('greet') # prints "Hi John Doe!" ``` ## Connecting a GUI framework Of course, most of this is useless without some way to connect the application to a GUI framework. The [`app_model.backends`][app_model.backends] module provides functions that map the `app-model` model onto various GUI framework models. !!! note "erm... someday ๐Ÿ˜‚" Well, really it's just Qt for now, but the abstraction is valuable for the ability to swap backends. And we hope to add more backends if the demand is there. ### Qt Currently, we don't have a generic abstraction for the application window, so users are encouraged to directly use the classes in the `app_model.backends.qt` module. One of the main classes is the [`QModelMainWindow`][app_model.backends.qt.QModelMainWindow] object: a subclass of `QMainWindow` that knows how to map an `Application` object onto the Qt model. ```python from app_model.backends.qt import QModelMainWindow from qtpy.QtWidgets import QApplication app = QApplication([]) # create the main window with our app_model.Application main = QModelMainWindow(my_app) # pick menus for main menu bar, # using menu ids from the application's MenusRegistry main.setModelMenuBar(['File']) # add toolbars using menu ids from the application's MenusRegistry # here we re-use the File menu ... but you can have menus # dedicated for toolbars, or just exclude items from the menu main.addModelToolBar('File') main.show() app.exec() ``` You should now have a QMainWindow with a menu bar and toolbar populated with the actions you registered with the application with icons, keybindings, and callbacks all connected. ![QMainWindow with menu bar and toolbar](images/qmainwindow.jpeg) Once objects have been registered with the application, it becomes very easy to create Qt objects (such as [`QMainWindow`](https://doc.qt.io/qt-6/qmainwindow.html), [`QMenu`](https://doc.qt.io/qt-6/qmenu.html), [`QMenuBar`](https://doc.qt.io/qt-6/qmenubar.html), [`QAction`](https://doc.qt.io/qt-6/qaction.html), [`QToolBar`](https://doc.qt.io/qt-6/qtoolbar.html), etc...) with very minimal boilerplate and repetitive procedural code. See all objects in the [Qt backend API docs][app_model.backends.qt]. !!! Tip Application registries are backed by [psygnal](https://github.com/pyapp-kit/psygnal), and emit events when modified. These events are connected to the Qt objects, so `QModel...` objects such as `QModelMenu` and `QCommandAction` will be updated when the application's registry is updated. app-model-0.5.1/docs/images/000077500000000000000000000000001510416065100155675ustar00rootroot00000000000000app-model-0.5.1/docs/images/qmainwindow.jpeg000066400000000000000000001276031510416065100210040ustar00rootroot00000000000000ุเJFIFHHแLExifMM*‡i&  Xํ8Photoshop 3.08BIM8BIM%ิŒูฒ้€ ˜์๘B~โ ICC_PROFILE applmntrRGB XYZ ็ acspAPPLAPPL๖ึำ-appldescPbdscmด๊cprt #wtptฤrXYZุgXYZ์bXYZrTRC aarg vcgt @0ndin p>mmod ฐ(vcgp ุ8bTRC gTRC aabg aagg descDisplaymluc& hrHRุkoKRุnbNOุidุhuHUุcsCZุdaDKุnlNLุfiFIุitITุesESุroROุfrCAุarุukUAุheILุzhTWุviVNุskSKุzhCNุruRUุenGBุfrFRุmsุhiINุthTHุcaESุenAUุesXLุdeDEุenUSุptBRุplPLุelGRุsvSEุtrTRุptPTุjaJPุLG HDR 4KtextCopyright Apple Inc., 2023XYZ ๓RพXYZ oค8๖‘XYZ b”ท†ฺXYZ $ž„ถยcurv #(-26;@EJOTY^chmrw|†‹•šŸฃจญฒทผมฦหะีเๅ๋๐๖๛ %+28>ELRY`gnu|ƒ‹’šกฉฑนมษัูแ้๒๚ &/8AKT]gqz„Ž˜ขฌถมหีเ๋๕ !-8COZfr~Š–ขฎบวำเ์๙ -;HUcq~Œšจถฤำแ๐ +:IXgw†–ฆตลีๅ๖'7HYj{Œฏภัใ๕+=Oat†™ฌฟาๅ๘ 2FZn‚–ชพา็๛  % : O d y  ค บ ฯ ๅ ๛  ' = T j  ˜ ฎ ล ๓ " 9 Q i € ˜ ฐ ศ แ ๙  * C \ u Ž ง ภ ู ๓ & @ Z t Ž ฉ ร ๘.Id›ถา๎ %A^z–ณฯ์ &Ca~›นื๕1OmŒชษ่&Ed„ฃรใ#Ccƒคลๅ'Ij‹ญฮ๐4Vx›ฝเ&Ilฒึ๚Ae‰ฎา๗@eŠฏี๚ Ek‘ท*Qwžล์;cŠฒฺ*R{ฃฬ๕Gp™ร์@j”พ้>i”ฟ๊  A l ˜ ฤ ๐!!H!u!ก!ฮ!๛"'"U"‚"ฏ"# #8#f#”#ย#๐$$M$|$ซ$ฺ% %8%h%—%ว%๗&'&W&‡&ท&่''I'z'ซ'( (?(q(ข(ิ))8)k))ะ**5*h*›*ฯ++6+i++ั,,9,n,ข,ื- -A-v-ซ-แ..L.‚.ท.๎/$/Z/‘/ว/050l0ค011J1‚1บ1๒2*2c2›2ิ3 3F33ธ3๑4+4e4ž4ุ55M5‡5ย5676r6ฎ6้7$7`7œ7ื88P8Œ8ศ99B99ผ9๙:6:t:ฒ:๏;-;k;ช;่<' >`> >เ?!?a?ข?โ@#@d@ฆ@็A)AjAฌA๎B0BrBตB๗C:C}CภDDGDŠDฮEEUEšEF"FgFซF๐G5G{GภHHKH‘HืIIcIฉI๐J7J}JฤK KSKšKโL*LrLบMMJM“MN%NnNทOOIO“OP'PqPปQQPQ›QๆR1R|RวSS_SชS๖TBTTU(UuUยVV\VฉV๗WDW’WเX/X}XหYYiYธZZVZฆZ๕[E[•[ๅ\5\†\ึ]']x]ษ^^l^ฝ__a_ณ``W`ช`aOaขa๕bIbœb๐cCc—c๋d@d”d้e=e’e็f=f’f่g=g“g้h?h–h์iCiši๑jHjŸj๗kOkงklWlฏmm`mนnnknฤooxoัp+p†pเq:q•q๐rKrฆss]sธttptฬu(u…uแv>v›v๘wVwณxxnxฬy*y‰y็zFzฅ{{c{ย|!||แ}A}ก~~b~ย#„ๅ€G€จ kอ‚0‚’‚๔ƒWƒบ„„€„ใ…G…ซ††r†ื‡;‡Ÿˆˆiˆฮ‰3‰™‰ŠdŠส‹0‹–‹ŒcŒส1˜ŽfŽฮ6žnึ‘?‘จ’’z’ใ“M“ถ” ”Š”๔•_•ษ–4–Ÿ— —u—เ˜L˜ธ™$™™šhšี›B›ฏœœ‰œ๗dาž@žฎŸŸ‹Ÿ๚ i ุกGกถข&ข–ฃฃvฃๆคVควฅ8ฅฉฆฆ‹ฆงnงเจRจฤฉ7ฉฉชชซซuซ้ฌ\ฌะญDญธฎ-ฎกฏฏ‹ฐฐuฐ๊ฑ`ฑึฒKฒยณ8ณฎด%ดœตตŠถถyถ๐ทhทเธYธันJนยบ;บตป.ปงผ!ผ›ฝฝพ พ„พฟzฟ๕ภpภ์มgมใย_ยรXริฤQฤฮลKลศฦFฦรวAวฟศ=ศผษ:ษนส8สทห6หถฬ5ฬตอ5อตฮ6ฮถฯ7ฯธะ9ะบั<ัพา?ามำDำฦิIิหีNีัึUึุื\ืเุdุู่lู๑ฺvฺ๛€Š–ข฿)฿ฏเ6เฝแDแฬโSโใcใ๋ไsไๅ„ๆ ๆ–็็ฉ่2่ผ้F้ะ๊[๊ๅ๋p๋๛์†ํํœ๎(๎ด๏@๏ฬ๐X๐ๅ๑r๑๒Œ๓๓ง๔4๔ย๕P๕๖m๖๛๗Š๘๘จ๙8๙ว๚W๚็๛w˜)บKmparaff๒ง Yะ [vcgtndin6ฃืT{Lอ™š&f\PT;333333mmodmw๕ุ~‚€vcgpffffff334334334ภX"ฤ ฤต}!1AQa"q2‘ก#BฑมRั๐$3br‚ %&'()*456789:CDEFGHIJSTUVWXYZcdefghijstuvwxyzƒ„…†‡ˆ‰Š’“”•–—˜™šขฃคฅฆงจฉชฒณดตถทธนบยรฤลฦวศษสาำิีึืฺุูแโใไๅๆ็่้๊๑๒๓๔๕๖๗๘๙๚ฤ ฤตw!1AQaq"2B‘กฑม #3R๐brั $4แ%๑&'()*56789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz‚ƒ„…†‡ˆ‰Š’“”•–—˜™šขฃคฅฆงจฉชฒณดตถทธนบยรฤลฦวศษสาำิีึืฺุูโใไๅๆ็่้๊๒๓๔๕๖๗๘๙๚C  C  .ฺ ?ขŠ(ขŠ(ขŠ(หต]Rโ๖แvX•ˆUน๕&ฒr}Mฉ๚าV€.Oฉฃ'ิาW-ใ่฿ผ!ซ๘฿ฤ&Aฆ่–’^{๙q อต27tีd๚š2}M|)/เ=ฤI4>)–7ซฆ–ไzCเŠ๔฿ำ๚›๐_O๘ูแŸ k!ำ๕=@XEง๎ะ๙าB๒IษU ๓–*8๔๖Oฉฃ'ิืˆxƒใ†แ๏‹พ๘Asฅ5Ž,๎ฏ-๎IEKqi ฬษ2ปqFpO5ํิน>ฆŒŸSIE.M9e’2”Žเi”PคxP–ี„็2D@'ิ„๛๑]qž๛ท_X๖j์๊QHŠ( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( ะขŠ(ขŠ(ขŠa4ใmิำr)Œpว'œำsืๆด@รธ"ผ๖ซ'รโ7๓.่ฝ์7ง้อ|๛U6gˆ฿๖/]๔pะษ?nOูำภฟผเ๏kทpjš>mewุธIbŒ+จtŒซGPH5๋ต'วoAš,~1|ีอ‘ีolM๋Fๅญฎ nจTฐช๎ฑ^ฏ๛.C3งร‚QI>ฒ' O”ฟŸ๋^ว3dสƒฎX|อฺ€$๘ภูป~œc%z฿‘\ี/ˆ?0V๘฿ฏ๘3โ;ภG‚!‡๛{ฤฑAs4๗7* ทŠlฎdm9V%€ฺญkโ๑?๐'%Z฿Nๅสๆพh๘เoƒ_ ?ikฟดว„งี|ใน"ิดz/ตซmฆ sE๑œ‘D—v›ี)b‰BฌŠฬHภmmล†ึ?p๎ฏฮูvื๖|๑ล‹๏|๘_wฆฺ่=‹Fพ4นธผŠf“ไkkkK†`ไซXเ€ฟ0&๏ั๔.์๖ฃ5๏๒9คำ๚Puแโ๏๊Ÿ๛5v{‡ฟ—]Ÿฏญx๛7_rx9จ‹_็าฎ๊๖W/๒3Fฤ•ecำŽ„wฌ›หฝh๓ ๕ša—žk0wฆ๛w  o0>?ฮi<๕‘็Gฺ1฿้@_๒j9ผฉะล2,ˆ2ธ ิƒY_i๏ž?ยดsŠืGHิ$`*…Ptฅ๓{ึ8ธ'ฝh็  “/๙4†\๕๙ฌด๗ฯแNIF–'Œ(ษ?•zW„ฅปวLงŸึป/4uว๘W  D๖Vฌำ๐๒J็q฿žk{ํcิzŸๅPภ๓Gฏžพ๔yฟณอb}ฌuฮืŽ)>ิ=G็H ฯ8~๑sKๆŽฃ ย๛Rใจ?ฅ/ฺวจ๔?ส€6ั๋็ฏฝoฌ๓XŸks๕ใŠOตQ๙ะ็œ?x9ฅ๓GQŒ…a}ฉqิา—ํcิzŸๅ@~h๕๓ื7ึyฌOตŽนภ๚๑ล'ฺ‡จ่sฮ‡<œา๙ฃจฦ?ยฐพิธ๊้K๖ฑ๊=?ฯ๒  ฟ4๚๙๋๏G›๋<ึ'ฺว\เ}xโ“ํ@$๛ๆ€7DรงAฯ็4ขaืŒ~9โฐพิ=s๚S…ะ๕้ฯ๓า€7<ว฿ž$๕žฯฅa‹ s๙ž8ฉึu=ม'๚ะศ|๑ž?ฮjPvวใฺฒ–`Gฏ้ชาI๏ํ๕=(๐ ๖?Zp8๚tช๊‘ธฉ”t5ี=ฉิQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEาขŠBp(2rOใQฑ—๔งฑํ฿ sŒใ๙ะNsว๓Šก,ปA=‡>ŸZ’V>‡?•rฺ๋Z้ww(~hโf\z€MR–๖๗WšH์$๛5คLciภ‡ ฑƒภ r @<yย ฬ฿Is+ฌg‘IชBฏY[ +8,ืค1„'ิษ8๎O'ญP7๖š{\gพn?๘ๅ7ํ3าใn?๘ๅmั@๐Ži_Ÿn?๘ๅ๐Ž้ธภ›9[”P#บ_ฅว๑ส?แาบbเอวญส(อ+๛ณเMวฃ/๛ท๘qว+rŠร„wK๔ธภ›9G#บWL\๛yธใ•นEaย9ฅv ธใ”ย;ฅvใn?๘ๅnQ@๐Ž้~—๘qว(„wJ้‹o7rท(  ?๘G4ฏ๎ฯ7r๘Gtฟ๎เMวญส(/าใn?๘ๅ๐Ž้]1p?ํๆใŽVๅ‡ๆ•ู๐&ใŽQ๎— ธใ•นEaย;ฅ๚\เMวฃ+ฆ.ผ๑สข€0แาฟป?๑ส?แาปq7rท(  ?๘GtฟK ธใ”ย;ฅtลภท›9[”P#šW๗gภ›9K๎—้qฯ<๑สข€1แำGk ธใ”๏์-?)ธิ\NBไ~bถ(  3ฅค>าi/`@Kม. €;ฃ7า =ŽpYc}ไ s†ŽEXt *ฯฌmพอqbค‚ไ์Pp‘M vvzr~ฉ"ญฉใพ?.k'*ัณƒ@:G้R็5็€x๖5*๚PจขŠ(ขŠ(ขŠ(ขŠ(ขŠ(ขŠ(ขŠ(ขŠ(ขŠ(ขŠ(ขŠ(ขŠ(ขŠ(ขŠ(ขŠ(ขŠ(ขŠ(ขŠ(ขŠ(ขŠ(ขŠ(ขŠ(ขŠ(ขŠำฆทฅ:˜qŸza๏วJง+qjดฯO๒*„ว>ดp]p,—$W๕๎r„ืat๘๖xฎลญw๛;่&€;CŒœRRžด”Wศ_ถํsแฏู'มšVฟช้’kšฎฝt๖บnขโVšFrฏ…Œ2dc’ภdfพฝฏม๘-ŸัŸ๛˜ฟ๗@ต‚างฺ"๛gร#ไog—จaถ๗ฺLDg๐ฏุ„๑ฏภZgฤ?jpiฺ”aŠ—Q$2€7ร*็*่x เื๒s๐๖7กพ%๘^ฯฦ^๐ฅฬบN  ZL bdฎ๔ศ๙”@#Œƒ^ƒg๛ืv๏์\ฺ๏ฦ๏&vLใฆvใ8 ๋V+‹y๘‚TŽHR?#SW๓ง๛~ฯ฿ถGมฟฺย~&ีกิ,ผ8๗ย]I.เ’สU)'™ญท!”‘•eEEอ ภด.ฒpJF ’Š+๓ฟใ‡ล?Œ_=ง์ท๐We’j-๑2ขษ5ฌR*ธސT…’?ธD#… Šฎฤ๔BŠว๘๐G๖š๘แ{ฏŠ฿ >1๘ฦท:f๛SะผLอwีด<ๆ%’V UA%W+Žฌ7ิฉ฿๐๗วฏ_`hฺศKiมŠ{“้v†H6ว#%ภb Ž3Š๚bŠ๙R๖—๔ฯฟ‚oฐ?gOŠ>,O๘+ฦ0^k33-ผ[ สŒฐ…๎"ฑœ(;ˆจ fพ• Š( Š( Š+ƒ๘‹จ๊Zg‡|ํ:WทqW‚^([;qฮAภศ็ž9 ๒Š๐๏ xฯKัu›2๓_{/สImฎnห;‡8 ปฑ’9ฯ#wฯ,,ิo<อrห\ธmY๕*ำJ‹q…แ,ฉLmไw<œzœะำTP2q‘ƒ^Eฌ_๋พ.๑Tะ๏Nฐำ”˜ฒ$g?ภฌ#ฎใฃ ]ขผoW๐vนแk)5ฟ ๋Wำหhฆim๎฿ฬIT ฟสใ'่AฏH๐ึทˆด+Mb!ทํ –_๎บ’ฎฐ`q๊9  ส(ข€ ๆ์$ฦตซŒŽ."๕ŸxซคฎBัฑฏjใ๕๑~9ทŠ€;หy8ฯ๓็๕ญˆŽq๏ภฮMs–ฏะ>œV์>OC@kำนใๆงํำฅVCวŸOๅSŽู๚PดQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEิฆ6}้๔รึ€ |`ฏ๙ลgอ฿?C{V„Ÿฎz๚ซ6n‡฿t‰tx=_๓Š๓๏1\}ป๏ฎณƒิฝ}+ฯ<]ฮ‰~G๐nฦ€=Œ๕คฅ=i(ฏส/๘(งม๋Ÿ>xSY”ฺxrษ”ญ_›?o-|3๛{o๐ฮผR-O_ดณิ4ถ”€าภ‰Ž‘d฿,ŠH Fู๛ญาj๙W๖ƒ–4?šถ‘ใญ\ผ๐_<<้พ!ำ†็ 2ว4{บซ1 ‡V˜T• o๘กโ-ยฯ“wcญุl .ฟ<๗]PlธศbNใ€n}๐}็Š(ษ>๘ณf—7๒ลrš๕คฎณคท0A`๓ฃฐ,ฒ+J„7$0'’ zYŒ0xƒJถ๐ฤO:ึทเKWˆ6•„vืWฤAHฅฝ๓ค‘‚ํ_พ$Œ…{๏gm?ล?๘IีFe๐ฏํ)mฆ-ฑ˜\ล0ั์-์ต}7โ—emuoF้๏”ว•ๅ ฝ'?ฃUเดภฯ๘^บo„๔๏ํฟ์O๘EMiโ-f๛OŸ๖T‘|Œyฐ์ๆg~[n>้ฯ@Q@Q@`x“^ฒ๐๖˜ืืจำq pฦ2าป็ใ}๋าท๋Ÿ๑/‡ญKฆ‚y^Ixf๏G"gk^ค}Py ติฎWฦ—ทยŽ์ึHฆห ”‡๏y้ำ4พ๑=ฟ‡๔๖ธิticตk้ตDEุ ภ /Cƒ๔ซีผ7แGั.๎ตMB]OPปUGžE'EUใ฿žรฅs๓3Šk™a]Rโ=โ็ํr้มFาไ† >r8วใžhิ+ษ<"Y๘ใลšmมลฤ๓ญฬ`๐Z=ฬ{ใฦฝoยธ๘&฿_ปƒVณบ“Lี-ภB2Hแ—+žงœƒƒƒ‘ลtZๅฝ†{yt@Š(›wC๒œฤ๐rkŽ๘So5ฟ‚lฬนkห"†ใ \๘d}k>O‡ฺๆฒ๑ลโฟK}g๖hใวMฬงe'ะƒอzผฺมตบโ‰B"ŽU{@ัEWnโกึFๅโ/&Šปzแ`8๑ณ]โ๏NัPmhzv๏q] วŸJๆฌ๚ gฑใžพ•ะ๖"€5ฃฦ1้‘GZฌ๐>ีZ,๑[ฏV‡  (ขŠ(ขŠ(ขŠ(ขŠ(ขŠ(ขŠ(ขŠ(ขŠ(ขŠ(ขŠ(ขŠ(ขŠ(ขŠ(ขŠ(ขŠ(ขŠ(ขŠ(ขŠ(ขŠ(ขŠ(ขŠ(ขŠ(ขŠ(ขŠีฆฐงำ3๏@?OฯŠฬœ๐z`}{VœœƒYณƒ>ดwัณ=kฯ<_ฦ‰~้ƒ๕็ฑฏCบ<๊โผ๏ฦฤฟ๕๒A4่‡8คฅ=i(ชืvVW๑y๐Gsเ%Euศ่pภŒีš( ะ&วGฤัว†ฟ่c~#โkrŠกgค้Zsณ้๖p[3ฌะฦˆH‰P3W่ข€ (ข€ อีtm]ต๛ทcmจ[ny7Q$ฉน~๋lpW#ฑฦEiQ@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@pŸ๘จตžG๚๘ฟ๔š*๎๋‚„ลGฌ๚๙๑ืŸ๔hจดด่3=kขƒงืืš็mqฯง้]เw,บ๐/ฤใAื,•Z[[คํq”ttfI‡FFe8 ƒ_ธ๐Eฟ๘‡R๐๗ล? _฿ฯqค่๓่ื6ฒ;4pIvทหpcSกฤไ W=k๓๖๖ฆผC๛G|c•5=O iž{'Mำฏa๊<ม็=๓ธJฬ ๙Y+๙W$ณท฿๐Dฯ๙ฌ฿๗.๎N€?x+ฤ~5ะ฿ ~iฺงฤ]Lม5๙+cงฺกž๎ไฎ7yQ)*ไ๎U €}บฟ3>่v_o‰~/๑lKรK;=?Bทœๅ-ฅtJจวซ œa^Mรๆ @ฑx/๖๑๘โฟูxOZ‡]๐Vกฉ?—d,ฐ>;๘Vใใ?…>'x{ภถW๒O7‡ผ'%ตภx"vHา๊ๆU.ญ!R x์?ู_ใ”฿ดย/๊6‘ูj๖ื2้zดbvมKผg$„uep %sท,โ๔mQ@Q@c๋บ๎Ÿแ=๕=Iศ‰HUTwc๗UGr๚็Šุฎgล~$ำอ'6ำม2\ฬเ’ฆv’งจม#๕ ๘wลบw‰xmฃธถนตว›ot›$P฿uฑ’0~ฟ\dV<฿|;oฉ=ƒญษŠ9ฯ%่6ษ/Mฅ๓ž=vใฟNk„ดฑ๑ม๑ีjvƒP1™f1e=ภ*…ว 2jง„ผ-ญ๘—D–ฦ๋PŽ-$๊ืคy’GFฐ็ b==(ตญVJธีฎ#’Xญ”;ฌ@# g'ž‚ฅำumWMถี-‰ฤฒฎ์ แฐHt<๕ฉ/ฌขฟฐธำๅปธ‰แa์๊T†ผSDืฅาพ๊v๓ทZd“iเg๎ุR?.qํz_…cฅxต.›MYS์ŽฤกA!ณต†าธJB฿ไPวฅ'OๅMฮ=9?ึŒŸ็๋ ๓•อ7$๛) {‘@”?•78๔็๕Z2Ÿฎ€ฯ๙Tc4“๏๔ค-ํE?๑zRw๔TใำŸื๓hษบ?็๙QŒำrOฟาทท๙ว๑้I฿ำ๙SsN_ฯ๕ฃ'๙๚่ŸๅF3M Ÿงj {‘@”?•7?ฏ๋๙ดgฎ=tฯ๒ฃคŸท8ค'๒ว๒ ฯฟใา—“฿ฯืฟท?ญ.Gc}hr•;qฆา‡๒ ๎น๙๚๛R๓IE3>Ÿฅ.๊u™ดQEQEQEืฆฐงำ3๏@?OฯŠฬŸก้วตiIะž฿•fฯ“ศ#œŸ๓š็๎ฯใืฏ5็พ.็Dฟฮิ8็ง๓ฏDบว'Œใฐ้ลy‹W%๘ฦู฿ขž”่8คฅ=i(ฏม๘-ŸัŸ๛˜ฟ๗_ผ๑ํ—๛x{๖ฟ๐๗‡์/A?†uo ฯq%…v๋w—vฑ‹ˆไท2BX1Š2H •๎ ~ม=hO„?ณฟฦ;|\ัD๖ฺ…Ÿูl๕๕็›H˜ปคฒฮค#ฒ*€ฌเm|๑O4?๖็๖GZ?๙c์Oท๙;๓ฃๆฯฯ'w•ณไฮื_Lชอ–๏|่‡&ีfหwพt๐ฯ/๖„๘C๛D|bถ๑'ย=`ถำ์หyฏผoฺปฅเpฅV4gQ#C|ชŠฟsม?ๆณปน:?แษŸ๕Yฟ๒๏~ˆ~ฦŸฑง‡ฟdx‚ยรฤ๘›V๑4๖๒_฿ษnถ‘๙v‹ ทŽ;q$ลB™d%Œ„ฑnภPฺ๙{๑N๓ฤ?ฒOํUฉะS่ืzฏรoˆ6Pู๋๓ุฃJ๚uฬJˆ$ec-pX€ยI~ePPฉฎ‹"”pXeaA๊๎ ~t|Aฝ|=โ>/~สV—8๑๎ฎ้’‚xํmFๅ2Iq๖•„ซ๚ฐ~f`ซ†gํ ฟวู>ฯ ฒšŽขบ„๖้ฒ'นXyอsต ๎* $.2k๔:รLำtดhดหH-ษf[tXม'ฉ!๛ี๊ส๑ฟŠว์๛\xŸโ็Žด๋ฦ๘{๑7Lตต{8ฺuฒพณŽ8๖LŠ rมม]YVถใk_๖ง๘โ฿†๖’๘แJ^k—Q=ฒO{vŠ#†u ฤ|n๐‡/ตษkถr้^ธ๑&๕—tะOผ%€"c@Iๅ ๆ [šnสT$8drกฉ(๐gแฃล?<ฆพ฿i_ผ8ำZ๊02j2พ ๊์c’ ˜D<ล ”Œ‘•bคC๔/i ๛ย฿ฎ>Xk7า฿Gฃฯw<ˆQ#K™Z็Fาช็ๅUlี๔๋[ห*O$h๒EŽ@%sืi##๐ฉจขŠ(ขŠ(ฎ โ<šœ~-ฆ™•<๘ลูทZ-๙S็8ฯถsฦkฝข€>y๐ฟ„t๋ทืv"๛๛(Q"ัS‚ุcษg#ทีอมdฺษฆiใP‡ฤ๐๊ท ž"ร ฿ยTฎr{๗ใ5๕Usฦz๗ฏตญ"ๅ"ท†Uะตkศ59จ\ษํ†;ณ๎}Eyoล8๎,๔XŒ\iK 'ฆษ0ฌฑ` ๛ฺ๘sฆ/ม๖เ‰.PศORe;†}๖เ~Q@Q@y๒|Iฌ๔โxฟ/ณEzW ืŸ Š“Y๎L๑€ัPahOืžฝยบrุ้Žร<๓k›ด*0H้้ำz฿„‚OCวฅlฤฤ๑Žนไ~5iX๑ำŽ=1ว๙ํY่F9นฯ>๊ยถq๋ำิ๑@6ฯ_๋FOำ๋yช๛‡ง๙๔ฃ8์G้า€,ฯol4dž;žีGฤœ W=(pอŸ๓ื๚ั“๔๚žjพแ้}(ฮ;๚t  ณ??็Ž฿็ตA‘฿๑'€รีฯJœ3g๕ดd>ฟ็šฏธzŸJ3Žฤ~(มl๖๖ฯใFO๙ใท๙ํPdwIภ 0๕sา€' ู=ญ?Oฏ๙ๆซ๎Ÿ็าŒใฑงJฐ[=ฝณ๘ั“xํ{Tp( ?\๔  ร6ฯ_๋FOำ๋yช๛‡ง๙๔ฃ8์G้า€,ฯol4dž;žีGฤœ W=(pอŸ๓ื๚ั“๔๚žjพแ้}(ฮ;๚t  ณ??็Ž฿็ตA‘฿๑'€รีฯJœ3g๕ดd>ฟ็šฏธzŸJ3Žฤ~(มl๖๖ฯใFO๙ใท๙ํPn๕sลฝ3๘sำ๛ะเŸ๓๋F์qŒ}ฯ5๚7{‘๚t่rูํื฿็งo๓ฺ ๊< Pฝ?ฯฅLใฟ๕ฅใื_๓อCปžƒ้Jศ#๔้@u๕๚4d?ฯ๙ํP‚ทฉ9ฯ็Šv๎ž๔๊(ข€ (ข€?ะจ๑ว้O'Z‰†y8๕  ๏ฮ~ฟไc๓๋Tf็ธ๏ŸOำขœ๚ใฏ๙j”ร{^?_ฦ€0n็w~}{๕ซˆ๑ซ้—V่2Dส002AŸฟ}:žxฮqว\็ฐฯ๋๕ฎz๊ s๋๋฿ฏcNผMBย๙ฤ๑ซ;9ะƒGb*ๅy๔7šOณDnฌ%bํnYฃฮ†๊TใžA9ิ6ะUspื0ีZbGโŠห๙๋hฎ<๘๓ย๋ึโนใU๘ƒแO๙๙Ÿ.ฟ๘ีv”W ย]>ี>}>ษuฦฉงโ7„างใ.ฟ๘อv๔W ย๓๕?]๑šA๑มฎ็ภKฏ5@ลรยว๐ฯŸOฒ]ฃH~#๘Ÿdบ?๛F๘Y>LŸ๚tบใ4ั\7,ฯไ๘ uฦiฤ๙|Ÿื=.ฟ๘ีwTW #ม็^็ฯงู.ณฯฑฅ…เา็ใ.ฟ๘อwW>!๘Hž.ง?๖้sฦฉใวa๒O;Ÿ๎‹[ O็ญvUๆ๖ญๆซช_&9๎ŠซAX‘bฯฆฬ^(ฟ๑=๖ณฒัเ–ฮ'โKฉ€`๕ข“‚G๑‘ูs‚-้ถัZภ–๑.ิBจฯN3๘๛ะOlq‚H๚๑ฯฏ๋ลmยqhใุgŸ\+sำ฿๙ฯa฿๋Z๑0ภ#ฏ<๓๙๓Mkฃำgฟ่็๓ฉรsฯL?Ltฌ๕~ƒžฃŽฟ็๕ฉC๚žฝ1Ž่@ธ๖๏้Iธ}?—๙๔ช†L~?ฏ็๚ัฟ฿ืื@๗า๏_วตS๓=?!4žg๚*บ\{w๔ค>Ÿห๚UC&?ื๓h฿๏๋๋  {้wฏใฺฉ๙žŸํšO3Ž}?•].=ป๚RnOๅ}*ก“๋๙ดo๗๕๕ะฝ๔ปวื๑ํTฯOศvอ'™ว>Ÿส€.—)7ง๒>•Pษว๕Z7๛๚๚่๚]ใ๋๘๖ช~gงไ;f“ฬใŸOๅ@Kn”›‡ำ๙ŸJจdวใ๚ญto}.๑๕{U?3ำ๒ณIๆqฯง๒  ฅวทJMร้ฟฯฅT2c๑?ึฟบทพ—x๚=ชŸ™้๙ูค๓8็ำ๙PrGz7~หŸ๓ลS2c๑?ึฟบนฟำŠ7?๘ี1'งไ9ฦh๓8็ำ๙~s‘ปฐใ๙sT|ฯง?ฏ็๚า๏๗๕๕ะ‡๙้F๎j “=?Oz7๑ฯงjนปŽqž๊?—?็Šฅๆ~$ฟŸ๋N฿่}|ะภŸ็ฺœ'ื?ช๒๔๕ฅ {PฝวA๋ึœ้ำ?—?็Šฌ?๋๙ฮœ<žฟบฒ๕ฟฅ8๛๛Tณำ๒ํ฿๋P๙ioๅQn#๑๕๏ํฯ๋N๋ะ๚๚่^ฯ๒ฃฆƒวฏำต8็ะ†๕คt=ซฒšs‘๎s€};V\ฐg฿ u?็๕ 2[Nงิgฏ๙9ข๖™๊=†y็๚ื_%ฟว_งงN+ŸฝบŠqgostใ+C.@8ษ์ฃ=K=่ญฆzใีฯญBึž€c้็ตke๘šใ RาัHฮ™ผBไ{)ใรบ้๛ฺธ=0-˜พp๏ํ@Fิz^~ดฦต˜ํฯ9฿>ึ่'1ิ๖ํฤดŸ๐Œ๋๔ƒำ‹VฯŸพ4ฯ›Qœcฎi†ิzหถ?ฯj่แึ่)ฉD#งาjO๘E๕ฮฺคทz7๓๓ฝ~ดฯ›_๖}zา}—ถ=†y็๓ๆบ๘E๕ฟ๚ @?็ะ๔๔โjO๘EuพŸฺzqhsวง๏s฿eฯใ‘G4}•}=วN?ฯj่O…5ฟ๚ A๊ั้๔›ต'"š็`ฯO๘๔c๚๙ิฯ” œ^z๕?โ}”z{sฯ?ึบ๘Euฯ๚ ใ๓่zzq7ญ/"บุ8ีƒำ‹FฯŸพ4ฯ}˜๕†v8=ซก„S[ ฌฟzำ้5๐ŠkŸ๔ƒ=?ใั๋็P?๖p3•ผ๕๋I๖auใžyตะย+ฎะV฿ŸCำำ‰ฝiแึมว๖ฌœZ6x๔๑ w์ภ๑Ž9ฟ๚๔}™{žีะย)ญะV฿=้๔š๘E5ฯ๚ มžŸ๑่ว๕๓จŸ๛8ส^z๕ค๛0๎:๑ฯ<Z่แื?่+oŒฯก้้ฤดฟ๐Š๋`ใ๛VN-ฬฝ€?ฯj่แึ่+๏„t๚MG"š็`ฯO๘๔c๚๙ิฯœ ๅฏ=zา}˜tวท<๓kก„W\ ญพ1>‡งงzาย+ญƒํX=8ดl๑้๛ใ@๘ถ1๊;๕๊Eถฮ?ว๙ํ[Ÿ๐Škƒbฐ๛taำ้5=|+ฎƒ![Nm๙๙ด’–฿๋ฯ^ต~(: {sฯฎฎฏ…ตม1X#ฝก่;q7ญZยฺโ‘X=?ใัณวง๏อG€3œŽ1นญˆ้๕>˜=ชฌ~ืx?ฺะ_๔F>“V„~ืฦ?โoožƒฟŸŸ@Pถ?ฮy๚Ÿ๑ัG้ม๔น็๚ึRxs^๓ƒ๑ดnฟืีต๐ๆผ1x=8ดl๑~€4ึOหŸOๅ๙ิ‚oOงำ๓ฺณG‡uศ^ž่l:}'ํRย;ฏใCใพ}h๙ง๚ZO7๊>ธฌ๓แํ‚๖๘฿—๚๚_๘Gตเxึ -tถ๔อty฿ฯ้ื๚U๘Gต๏๚ @ํอฟ๘รฺ?ไ1ทF?ฏŸ@i>ฟึ“อ๚ฎ+<๘{_ ฝฟทๅพ—ํx5ˆ? F?ํฝ_๓]w๓๚u>•Cํ{ƒ๛so?@๐๖ฟ๙ @?ํั๋็ะ‡šฯฏ๕ค๓~ฃ๋Šฯ>ื่/o€m๙ฏฅ„{^bยั‡O๛o@฿วืGฯฅP„{^ ฤะ<=ฏใC๛tc๚๙๔กๆŸ๓๋i<฿จ๚โณฯ‡ต๚ เ~_๋้แืใXƒ๐ดaำะ7๑๕ั็?ง_๓้T?แืฟ่1ท6ใ๔k๘ฤพ}hyง๚ZO7๊>ธฌ๓แํ‚๖๘฿—๚๚_๘Gตเxึ -tถ๔อty฿ฯ้ื๚U๘Gต๏๚ @ํอฟ๘รฺ?ไ1ทF?ฏŸ@i>ฟึ“อ๚ฎ+<๘{_ ฝฟทๅพ—ํx5ˆ? F?ํฝ_๓]/š=q“฿Žฟ็าณแืฟ่1ท6ใ๔k๘ฤพ}h qzš<ะ_ฯ๓Yวรฺํ๐ ฟ/๕๔ฟ๐kภ๑ฌA๘Z0้m่Cอฯฟใš<ฯรŸง๙ซ?ํ{ƒ๛so?@๐๖ฟA‹o็็ะ”็ฟ^7ิ‘๕็ืY฿๐๋?ตํ๐ ฟ๘/๖ผ:j๐~Œ?๖ฝiyน๗sN?ฯๅYุ๖ไ/nํัฟ๘/๖พ…เ๖่฿ฯฯ  U—;?๋N{‘?ึฒฟฐต๑1{sn๙ฏง ^๓ƒ๕่รงท  q&}Iฯ๙ลb^๓ท?๖่฿~šฺg‰ฃU{g6;4R'๊‘ …\Ÿ_๋O ๙žk•“PีtฑปYณ9ธถo5c?0ภeน+๋[v๗QOห F†SมEjฯฉ๕ำรg๓๏ฦ2?ฯjฆž*eo๒9้}ฺ่ฑ>ด๐q๊>ฟ็šฌ>™ผำAํวาฯ@1๚t ฺQ๑ฮx๗v จ๎O—ดษพฏ€รs w6๎ฒล*‡GBenC)‚1ึŒaG›ก$ํl๗ ใ๒iชY•๖พ฿zั™_e็ฺฒŽxใส9เsŒ๑Z๑ชKษ+ฃ€สT‚# ŒpA็š“ษ๙u?}ไdคนข|๔ฃmˆ-ฐzs๔๕๚Q๖lsŒฅkLะB๗,QDฅไw *จf$เษ'ŒWษ>+ญฟ็๐พHหฏ๙่[`๔็้๋๔ฃ์ุ็J๒{zาศ  _ฒŽ€๓ํGูG<qž+kษ๕?€ฅ๒@]ฯ็@‚ง?O_ฅfภ้ึถžภรžด‚๒(์฿็้N[aฯqฺถ|ฏWXRˆzŸ๓๕  •ท œŒ{Tษpแอiป`:rล฿•RH@ํำ๔๋๚UคŒt๚ŸjS?๓5(\t๚Pqษใื๑ฉ@๖8ฉBใ็ฏฅ8 (Qži๔Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@qถฃรืฉuh6iืrๅˆ ,Rท ุ๋+่1๗Ž;zว๑จผั/เฦXนOgU,Œ=รGา€_าฌ+g฿?็๔ปฟตุ[ฯHีธ๗ญd~จE[ิŽ๕8?‡๒ๆจ+๚๗?ึฌ+{๚.ฝจโžฅHzsํ๊บถ๚๕ ฟบ?็๙Qฯฎ(E!8๏Šำ๕็๓ึฃ?ˆ๚žiว๐ฯOzŒœ{~?:c๘ฮx๋P;?ง_๓้Ocฟ6:~œ๔ ๓zี78๋๓ฯ๕ฉบ๔๚Jฉ#c๔้้@;dt๕็๓ชR8๕ใ8ฯถ?ฯjžG๋Ÿฬ๑TวใำŽx‡ืˆuV<ํถตฟV˜๕;Wmทœšโ<,CxƒX>–๖Ÿ๚•ํ๒ํiโ‹อ7รZO„-หMziž๋ํํ‚ƒŒ3ศ™ฎGz๘+๐ฏพฟk_ ]๊>า|]j›ำA–dบฦ~K{คใฒผiž€.Oj๘ฏZณ‹\ถ๙๖ฒฑนเRรaรุ[Ÿš\๏}? Žฝy๋_{ษ^(ปิผ7ซxF้ฤะe…ํrIูorฌ}z+ฦ๘.A_?}๏๛%xN๏N๐ญโ๋ดุš๔ฐฅฎAํํƒ„“ท ๒>เฎz8KŸ๋k“k;^กใฏีฐ็ํํฯอ^๗พถ๙\๚ศ'ทฏ?ฦผ_ใ.ฉ,:}†ƒข๒=ภ็-nNฝ :ไs๏^ถผ[ใ.‘,บ}†ฟสiฏ$sž~Xฆ —๚Eษ<I=+ี๑Cุ๋8ฟฉ฿›—ฆ๖พฟ…ฯไ^๖ฺ}ทรำOฤ๙๑ฒOOึ…ศ==นๆฅ*A๋@\š=๙่Sพ jrอง฿่ษฆบIn?ปภํNง€ศ๘๔ี์๛xเq^;๐gH–>^‘pš“วœดP†ร๖เณถ1ม๕ํy๗ฏ๔'ยฌ`แ>ป~~^ปฺ๚~?›๘ซูhV๖ Mใฯฺ๏ลWzg†4Ÿฺ?–พ šgปม?=ตฒกh๚ŽไŒ็*่k๓๕ฒI๔้_ ต๏„ฎ๕/ ้>1ดC"hM%-๎•ษวexำ$ใ “ฺฟ>ู0}ซโ๘๏ฺ}}๓6V๘ƒฦ7_๛f~ผซ—ตญญพwงใž+๔๖D๑Uงแ[ม๗oๆ'‡ๅ…ํ2I)mrฎR>Iแ^7 E t๙จI๖ฏะOูยWšo†u]'–šฐฅฆA๙ญํC„“ท ๒>า2 เ๗ฃ=งืื'รgำ๑ํ˜{๒ู๓vตดl}qดzz๗ฯZRž๓Rํ็ญ.ั_ธŸืฤ=ฝx้4mใลMถผ๛ะ;Gงฏ|๕ฅ)ํํฯ5.zาํOo^:xเqSmฃo>๔ั้๋฿=iJ{{sอKทžดปE@ืŽŸใF฿AวใSmฃo>๔ม้๚็ญ8/ฏำžiเRํQŒcืŠpวJ~1E7ฟ๘ำจข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ k(u(?CNข€<ำร๎ะl ็6้฿ุWLŸฯ๙ŸJโ|-'H4๑Ž™ฮ๐ืW๔ว๘๔๗  ˆุ็๕ตež>พฟึณณ้๔ซˆ฿‡้า€4็sž๕:Ÿ็ฮ*’^=Iใšฒ„วŽ9้@“?็ึค๙_๓อ@ำ=1Rฉวท้า€&_J?ฯ๙ๆ˜8?ิ๑Rqื๐็ฺ€?ิ๒l๗#ฏPฑ๋?—4๒pฉ?ึกc๚gืฺ€#cŽœUg9ฯ๐๏S1#ฉปqํห ทฉฯ๘U) ๖\ิ๒?S๋๚ฏึณๅ~ผ๚๚่ _+>W๋฿๓SJ๙้๙zหšNzcฟjฟแ-ฏ๋<ไyŸ๚๕่•ๆž mฺท^๖_๚ลz]Cq7PImr‹,RฉGGซ)*Aเ‚8 ๐E|›โฏู'รz•—^ีฅะO๙ux…อบrO๎ะผnฃฐfะWืW&//ฃŠJ5โšGะd}ป๊ศn ‚๊ -ฎQeŠU)$nVR0ภƒมpAเŠ๙ลŸฒ†u;ทป๐vฏ6€ŽI๛#ยทV่I'๗h^7Qุ ๛G`+๋๚+‡•a๑ฑPลA4พผ๑3ฎมf๑T๓ Jim}๙Ÿ xO๖B๐ฮ™v—~1ีๆืั?dHVึศ ๑ศ์;ฟi๎ }soฐGml‹Q(Hใ@ช…€TิQ€สฐ๘(ธa`’{๗๛ร%แผQO/ค ž๖ีฟ˜QE{AEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPGZ(?•xท…ฅ‰‡{ง‚8ฎฒ'ฯฟ๙๋W แi?โCa฿:sŒญuฑIภ8  ศ฿’*m๘t๚sxฌxŸ๕?ึด#l๗๕๕ะคmฅZSฯฏญP‰ฮ*ฺ9้ŠธงฆH็"ง_NŸหŸ๓ลVS๚ฟŸ๋Sฏ๓ฯฏตM๚ี ŽOJyศ\ใ4ฝ8$๑๘ะี๏9ฯ๙๋jป;ฏ๙ๆฅn3ŒT p};zt่ปœ๛็jœ„}9Ÿ็ตY~3ŸฬเU)ง้ฯO๓๏@db3yต3zไ๓อ[•บ๔อfL฿‡้ำ๓  s6s฿ท\ึTาu็ฟำฏ๙๔ซsท\๑๕ใšศ™๚ใ๔็ฅMแ]Bโฯ^ึLโ}๐Znห์ฦ๖NsŸn•n๊?๔_๛?๘š๓O ฟOu“ว{ฺ9+ผ๓๙๕PํG๋วG๖๎ฃ@๕ฟใ‰ช`?ช<มT๛wQ z฿๑ฤัปจะ=๏๘โj‡˜?ฯช0ŸU_ิ่ฟ๗๑4n๊?๔_๛?๘šกๆ๓๊ฃฬ็ี@ทu๚ฏMบื&จyƒ๚จ๓๙๕PํG๋วG๖๎ฃ@๕ฟใ‰ช`?ช<มT๛wQ z฿๑ฤัปจะ=๏๘โj‡˜?ฯช0ŸU_ิ่ฟ๗๑4n๊?๔_๛?๘šกๆ๓๊ฃฬ็ี@ทu๚ฏMบื&จyƒ๚จ๓๙๕PํG๋วG๖๎ฃ@๕ฟใ‰ช`?ช<มT๛wQ z฿๑ฤัปจะ=๏๘โj‡˜?ฯช0ŸU_ิ่ฟ๗๑4n๊?๔_๛?๘šกๆ๓๊ฃฬ็ี@ทu๚ฏMบื&จyƒ๚จ๓๙๕PํG๋วG๖๎ฃ@๕ฟใ‰ช`?ช<มT๛wQ z฿๑ฤัปจะ=๏๘โj‡˜?ฯช0ŸU_ิ่ฟ๗๑4n๊?๔_๛?๘šกๆ๓๊ฃฬ็ี@ทu๚ฏMบื&จyƒ๚จ๓๙๕PํG๋วG๖๎ฃ@๕ฟใ‰ช`?ช<มT๛wQ z฿๑ฤัปจะ=๏๘โj‡˜?ฯช0ŸU_ิ่ฟ๗๑4n๊?๔_๛?๘šกๆ๓๊ฃฬ็ี@ทu๚ฏMบื&จyƒ๚จ๓๙๕PํG๋วG๖๎ฃ@๕ฟใ‰ช`?ช<มT๛wQ z฿๑ฤัปจะ=๏๘โj‡˜?ฯช0ŸU_ิ่ฟ๗๑4n๊?๔_๛?๘šกๆ๓๊ฃฬ็ี@ทu๚ฏMบื&จyƒ๚จ๓๙๕PํG๋วG๖๎ฃ@๕ฟใ‰ช`?ช<มT๛wQ z฿๑ฤัปจะ=๏๘โj‡˜?ฯช0ŸU_ิ่ฟ๗๑4n๊?๔_๛?๘šกๆ๓๊ฃฬ็ี@ทu๚ฏMบื&จyƒ๚จ๓๙๕PํG๋วG๖๎ฃ@๕ฟใ‰ช`?ช<มT๛wQ z฿๑ฤัปจะ=๏๘โj‡˜?ฯช0ŸU_ิ่ฟ๗๑4n๊?๔_๛?๘šกๆ๓๊ฃฬ็ี@ทu๚ฏMบื&จyƒ๚จ๓๙๕PํG๋วG๖๎ฃ@๕ฟใ‰ช`?ช<มT๛wQ z฿๑ฤัปจะ=๏๘โj‡˜?ฯช0ŸU_ิ่ฟ๗๑4n๊?๔_๛?๘šกๆ๓๊ฃฬ็ี@ทu๚ฏMบ—m=33$ี0ŸU8Iศ๋ื๖ 0๐ณ็Aฐ๏๛„}…u๐ษำ๋ฯฅp~๘‘Xg}ำฎ๐Š์ ~Ÿำžž๔ะBวฏำ๓ตฃt๊>ผ๓k๚gง๙๖ญX[๊?NŸkFs๏๏ืฝ^CŸn~ฯฅfD}เU๘ฆ?NzŸzผ„๕๚~&ฌฏง#ฟ็šฆง้้๙ีค?‡้ำ๓  ๙็Z~3q้้Žนภฉr=ฝ=zPึ๋cวQฮjป๗วห๚T์q๘ฟŸ๋UŸ๋๋๋ํ@ค$Ÿาจห฿ฟ๙๗๚Uษ{qžณYาœŸNุ  S7\‘ฒฆ<8\ž+Bf๊ษZษ˜๕็ื๒๕  ู฿ฎ8=+แบ๗๋วึดงnฟำฐ๎ฏะ๕่1@ แw#]ี๙–Ÿ๚๕y•็>qปซื O็=wj๚P5 งVฟ๐†g\วฟŸ๖ผ|7gW;๗ฌ๙v๗ฎฯฬช>j๚Qๆฏฅ^๓+žึ|eแOสxƒZำ๔ษd‘/.b€ฐ๕ูIโผ'๖ฒ๘ตชm๑3@U:ฆ•ev,เ2ฅลๅฤVpศT๐ย7˜>nฟ‘๘^๑fณwโ?๊7:ฎฉ!š๊๒๒F–i\๕g‘ษb~งภโ€?ดฟ๘ZŸ ่nะฟ๐>฿ŽV๖โฟ ๘ŒH|?ซYjb,y†สx็ ž›ผถlgฟ‡š์<ใฟ|4๑=Œซ]hšฮ ’ ซW(รฌ::60ศภซq@—™G™_–฿ณฟ๗เฏ%km๑“R‹ม>-ถŒGx&Šccrภ`อo*+„V๊cฉRHRเnฏฃฟแนdฏ๚)ฺ'๗'@]๙”y•๒ฮ๛g~ห:๖ฃ•ง|M๐๑นธ`‘ ฎ X๐|ม$๐#'อ},ณคŠ2ฌฌRง ƒะƒะ๓(๓*šพ”yซ้@ผส<สฃๆฏฅj๚Pฤฟตฏํ้๐๏๖Vปณ๐อฦ™?Š|W}บMฌษn@IU’ๆแ–O,นSฑV7$HUมo†?แ๔็ˆ๏\?์ฏ…เฅI7ํก๑]ู–5าU9 ‘fุƒ,Oิš๘R€?tแ๔็ˆ๏\?์ฃN่Žๅรส,ข€?tแ๔็ˆ๏\?์ฃN่Žๅรส,ข€?tแ๔็ˆ๏\?์ฎƒร?๐Yฯ ๋Vึ.๘_wค้r8Y๏,ตeฝ–%'–ํ-ƒใฉbŸJ ข€?ธ/ xปร8๐ึ—ใ วจiอฌwถWQrฒE*‡F็@`r]™_ม8.$—๖.๘tำ1vUีPrpบฝโจ๚a_pyซ้@ผส<สฃๆฏฅj๚P๏22จ๙ซ้Gšพ”{ฬฃฬช>j๚Qๆฏฅ^๓(๓*šพ”yซ้@ผส<สฃๆฏฅj๚P๏22จ๙ซ้Gšพ”{ฬฃฬช>j๚Qๆฏฅ^๓(๓*šพ”yซ้@ผส<สฃๆฏฅj๚P๏22จ๙ซ้Gšพ”{ฬฃฬช>j๚Qๆฏฅ^๓(๓*šพ”yซ้@ผส<สฃๆฏฅj๚P๏22จ๙ซ้Gšพ”{ฬฃฬช>j๚Qๆฏฅ^๓(๓*šพ”yซ้@ผส<สฃๆฏฅj๚P๏22จ๙ซ้Gšพ”{ฬฃฬช>j๚Qๆฏฅ^๓(๓*šพ”yซ้@ผส<สฃๆฏฅj๚P๏22จ๙ซ้Gšพ”{ฬฃฬช>j๚Qๆฏฅ^๓)VL3จyซ้Jฒ รท4ๆ๘‘XvGOq]ค Ÿ๋๙ืแfฮ‡a๙เ;dWip3ำ่ก…บr+Z{sxฌ([๕ฯญlBx—Zุˆ๔ญฮ}๋.œz{VŒ}=จ๚“ศซJ1วNŸNฯMฯื๕ฯnZดฟ็๒๋@Wงž•09จWฟ๔็*๔Gๅhื๊lŒžญTsŽ9_๓อZ ฮ1UŽุ๋ํำ๓  ’t9๚V|งฏnŸ๙๔ซ๒{ผVt‡Ž:๛sา€3ฆ'9๋Zวœ๚๑้Ÿ๓อjฬy=>๙•1ไŒc‡JษธnR}๋ๅ?^Ÿสต๎8๖๕ฯ…pAฮ:๓ำž”OรฎW\ีฝเด็฿3ืgๆŸ๒k‚ะๆซำEง๓šบ2€9ฯ๘Eฟโด„ฟ๛Bใ=ฒgไ้ทฯ๚ฟโู๕Ÿ6๎ีุ๙ฟ็šเแ0?๐˜ย%๖ ๕ฺฑ๒tœc๎ฌ๙qบ๏2€>,‚>bˆc=ฒ?๔๓g_สๅS?๐Qวฯ์g๑ุ'Ouั|4๘yโOŠ=ะxJ?Uืnาึsต7r๒ศ@$G๎ppชMptW๎_๕/ุg๖m7แNฏ๐๎฿โwŒคwฝกmkv่า(*าอ๑ยาน!‰8k1ห+7รv~ล๔m 4o1@W์?7g์QFักเณFใรv~ล๔m 4o1@'ม*%๘—ฦ฿ณี‡Iw-่๐Žฒ๚nŸ,ฌYึอเŠhแ,NH‰™ยไ๐…TaT ๘฿ณ๖(ฃhะ๐Yฃ๑Š๔มQ?g฿‡ึs้๘5'†ญ.d๓งƒG[ 8ไ(]๎"+6 œ t ฿7“G›ษฏฯŸูท ๐ฟ๖Œ๑ฯ+›=#Q๐ๆนq“ุGzัหะ… ’ขWˆต9โ(๐:žา$ 6ฺF#3บญ ๘ใ๖:๘ใ?‚p| M 'Gำb?ู7vˆฟkณนK+|ฯ#eO›’ฑI[[YYyฟb‚(<๙Zy|คT฿#ใtด ฬุ'$๗5๙“๛v~vฌ๎|(บŠ๏ว—1ผฝ]ฏŽฝHๅZ้”ๅไFguX๐cใ'ร _เฟฤ|.ืnญฏฏ|?w๖gนด;ข•YVH{ฉde,ง”lฉไ๓ ฝ}}{ช^jzฤทwwRผ๓ฯ;—’I–wwbY™˜’I$’rjT๐M๗ว์_๐๐gง๖ฟžo+๎7“_ ม8฿ฑŸรัao<^Wพe^๓ฯ๙4yฟ็šฃๆQๆP๏7“G›ษช>ee^๓ฯ๙4yฟ็šฃๆQๆP๏7“G›ษช>ee^๓ฯ๙4yฟ็šฃๆQๆP๏7“G›ษช>ee^๓ฯ๙4yฟ็šฃๆQๆP๏7“G›ษช>ee^๓ฯ๙4yฟ็šฃๆQๆP๏7“G›ษช>ee^๓ฯ๙4yฟ็šฃๆQๆP๏7“G›ษช>ee^๓ฯ๙4yฟ็šฃๆQๆP๏7“G›ษช>ee^๓ฯ๙4yฟ็šฃๆQๆP๏7“G›ษช>ee^๓ฯ๙4yฟ็šฃๆQๆP๏7“G›ษช>ee^๓ฯ๙4yฟ็šฃๆQๆP๏7“G›ษช>ee^๓ฯ๙4yฟ็šฃๆQๆP๏7“G›ษช>ee^๓ฯ๙4yฟ็šฃๆQๆP๏7“NY>aฯqYe9d๙ว=ลp~l่vg๗3๘ ํmงฏๅŒ๓ฺธ? 4K๑๛„ฮxํ]ตฑ้ท๔็ฅtVไ/ฤึฤ๋XP๚Zฺ€๔ํำฅlDzu5ฃnŸ๓Šห‡g๑ฯง้9้@ไ~Ÿ˜ui8วQ๕<ี4?OLJธ‡ฆ;zt่ส๓Ž๙ฉ—ฅBฝF๑Nศห๒ ะ็sวQชฟp8\ž*ำžพ็๓Zชใ๋ํ@ฅใงืญYำฝ๚็šั“ง็k6n‡>Ÿ—Jฬ˜๑ิt59๋?—5ญ1๋๎<Zฦธ=q๏๋ํ@ืแXW<็ฟ_๓๚Vมฮ็ฌ ž๙๔TขถoT็ฌ6พๆฎงฬ?็ี\n”uอSถaตs}+ฃ๓ฯ๙4ฬ?็ีG˜ฯชธ?๘Fฟโฏ„ณํ๗๊<Ÿฒ็ไ้ทฯ-˜๛6{W[ๆŸ๒hใO๘(ณ๎พ ๛้โฮพ Œ<แ๏ูsเ‰lฟ‰–ภ๊Z…›Zx^ส_–G…l{ไ5ไภภฐ!pJฑฏึ_ŽŸ t ๕†:ตมทฐี๎๔้/rญํ5 {นฃR9V’8Šูˆ=ซ๐‡ %๛@ุB๑งมฟบ“?dฟ~ำพ/๒ ๓4ฟ้’/๖ฮดW!G ๖{}รpใ ไFงs๑ต[๚v๘u๐๛ม <งxภztZ^ฆEๅร|’O/$Žrฯ#ถKน%™ŽM~.ม9l ๘+Mดž"ŸIณ–๊Y4=`โ8ฬื.]ญฎุเฮวหœrฐš๚W๖ใบ์>้๗? >^Cyใ›ศส^ฤVH๔˜}ใีZๅส!ศA†a๗U€?Nํต;฿7์wOไJะKๅ:พษ‘๖็k.FAไwฏฬ฿ฃ๖ฒ๘ำiu๑SแUดVž<ถ‹}ๅšํH๕tE่O ทJฮ€e•–ฟk฿~ฮž<ŸYธž็\๐ๆปsๆ๘ƒMšR๏;น๙ฎโw<] “ธœH>W=~6&นฉค๊1์ป[W_ต]ใ?fX˜๎Iธ“p^ nภ€*wึ7บ]์๚nฅ–ทvฒ<ม:’9•tt`YXA‚0j•zgฦ‰šทฦO‰ž ๘ฎZู^k๗h{{QˆฃUE{’จ <ณe$ื™ะ๕้}ฟฑทรแao<^W^a?ช๘k ฺ๘ŽพOํ_;Wฺo๙&€/๙‡๚จ๓๙๕U7“G›ษ  a?ช<รUCอ?ไัๆŸ๒h˜ฯช0ŸUP๓ฯ๙4yฟ็šฟๆ๓๊ฃฬ?็ีT<฿๓Mo๙&€/๙‡๚จ๓๙๕U7“G›ษ  a?ช<รUCอ?ไัๆŸ๒h˜ฯช0ŸUP๓ฯ๙4yฟ็šฟๆ๓๊ฃฬ?็ีT<฿๓Mo๙&€/๙‡๚จ๓๙๕U7“G›ษ  a?ช<รUCอ?ไัๆŸ๒h˜ฯช0ŸUP๓ฯ๙4yฟ็šฟๆ๓๊ฃฬ?็ีT<฿๓Mo๙&€/๙‡๚จ๓๙๕U7“G›ษ  a?ช<รUCอ?ไัๆŸ๒h˜ฯช0ŸUP๓ฯ๙4yฟ็šฟๆ๓๊ฃฬ?็ีT<฿๓Mo๙&€/๙‡๚จ๓๙๕U7“G›ษ  a?ช<รUCอ?ไัๆŸ๒h˜ฯช0ŸUP๓ฯ๙4yฟ็šฟๆ๓๊ฃฬ?็ีT<฿๓Mo๙&€/๙‡๚จ๓๙๕U7“G›ษ  a?ช<รUCอ?ไัๆŸ๒h˜ฯช0ŸUP๓ฯ๙4yฟ็šฟๆ๓๊ง,„ธโณผ฿๓M9d๙‡=ลr%‡o'…v๖็8ว>ฟC\/…NtK๚เ?ฎุ๐3้น  ๛sำ‘อmร้ำรŸ๓ลaภG_๒?ึถ ่?ฯใ@Ÿ็๐ญ8ฝ:žzึ\<ใ๙qšำ‹฿ฅh'Aศ๚ีฤณง?็Šฆœฎ?ฯ๕ซiํ}๛Pฅฯ็๐ฉ=๚z๓Qง๕ํฮ)ไใืhั็lŒ_๋UŒŸว๓Vฺช?ำ‡Oฮ€)I฿gKะ๛Q{V„๓ํœ๑Yา๔z สŸ98๕Ÿ๋Xืˆ๚žkb~:V5มม้ำฅa๗uƒr~งฏ็๒ญ๋ž๙sลs๗=ง=(™ฐ$kZ™๕†๙อ[พgฝsVอkQ้Ÿ&๋ึZึ๓(ฯ๘ซแ๛๊<๏ดใไ้œใ๛Ÿรป?ๅวz๊ฯzกๆQๆP‰~ิ~+๑ฟƒ?g๏x›แิx‚หL&ืศคˆ;ฌs\ ;ญโg˜p@)ศ"ฟ“fv‘‹น,ฬrIไ’z’k๛6๓21\s?๘,$พั–fk+rI=I%9&€?j+๛…y๐๏…mm๘Š?แ^|;ก[Eภ"€?š+๛…y๐๏…mm๘Š?แ^|;ก[Eภ"€?šŒ?เ_ณ|ฟ~?ฤ/Yตท‹|gHc หiงƒบ ฐสดง๗ฒ(ฌ%}ท€i:\ฺ๘oH†h˜ฟOึฎ5S~3๔้๙ะ)??]fอŸ๙๔ญ){็๑ฯ›/๙ว=(*lŒŸ๓Ÿ๋Xณ๐Q๕<ึิ๚tฌI๘ฯoำง็@w<็ฟฎฐn{ฯ๙ลnwฯใž+ ็ฟ๔็ฅ[๘ŸํwธY;Š๕:๒ฯศw]้ขห๋ึโฝN€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ Qิ}i)GQ๕ ผ+ K่้ซนท่>ฟฬžีร๘Sว?๓ม3ž;Wqmž?ง=o[g99ตทoะuw๚~ต‰o๔็ตm๖ํ๚t่^H๏Zฑt็จ=ซ*l็Šี‹ท๔็ฅhEœ็็๚ีด้ŽGื๓U#ํVำ๒:~thr=jo๓y ๗ฯใž*Lฟ*ิ็จ๏Tไ๎ห‘qVุ๓๕?Ÿ็๚ีI:ว๙uํ@ฅใ•›0๋฿๛ึœŸ็โณ&9ํŠส›งQฑn1ศ้นฟOึฎ5S~3๔้๙ะ)??]fอŸ๙๔ญ){็๑ฯ›/๙ว=(*lŒŸ๓Ÿ๋Xณ๐Q๕<ึิ๚tฌI๘ฯoำง็@w<็ฟฎฐn{ฯ๙ลnwฯใž+ ็ฟ๔็ฅ[๘ŸํwธY;Š๕:๒ฯศw]้ขห๋ึโฝN€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ Qิ}i)GQ๕ ผ+ K่้ซนท่>ฟฬžีร๘Sว?๓ม3ž;Wqmž?ง=o[g99ตทoะuw๚~ต‰o๔็ตm๖ํ๚t่^H๏Zฑt็จ=ซ*l็Šี‹ท๔็ฅhEœ็็๚ีด้ŽGื๓U#ํVำ๒:~thr=jo๓y ๗ฯใž*Lฟ*ึ็จ๏Tไ๎ห‘qVุ๓๕?Ÿ็๚ีI:ว๙uํ@ฅใ•›0๋฿๛ึœŸ็โณ&9ํŠส›งQฑn1ศ้นฟOึฎ5S~3๔้๙ะ)??]fอŸ๙๔ญ){็๑ฯ›/๙ว=(*lŒŸ๓Ÿ๋Xณ๐Q๕<ึิ๚tฌI๘ฯoำง็@w<็ฟฎฐn{ฯ๙ลnwฯใž+ ็ฟ๔็ฅ[๘ŸํwธY;Š๕:๒ฯศw]้ขห๋ึโฝN€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ Qิ}i)GQ๕ ผ+ K่้ซนท่>ฟฬžีร๘Sว?๓ม3ž;Wqmž?ง=o[g99ตทoะuw๚~ต‰o๔็ตm๖ํ๚t่^H๏Zฑt็จ=ซ*l็Šี‹ท๔็ฅhEœ็็๚ีด้ŽGื๓U#ํVำ๒:~thr=jo๓y ๗ฯใž*Lฟ*ะ็จ๏Tไ๎ห‘qVุ๓๕?Ÿ็๚ีI:ว๙uํ@ฅใ•›0๋฿๛ึœŸ็โณ&9ํŠส›งQฑn1ศ้นฟOึฎ5S~3๔้๙ะ)??]fอŸ๙๔ญ){็๑ฯ›/๙ว=(*lŒŸ๓Ÿ๋Xณ๐Q๕<ึิ๚tฌI๘ฯoำง็@w<็ฟฎฐn{ฯ๙ลnwฯใž+ ็ฟ๔็ฅ[๘ŸํwธY;Š๕:๒ฯศw]้ขห๋ึโฝN€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ (ข€ Qิ}i)GQ๕ ผ+ K่้ซนท่>ฟฬžีร๘Sว?๓ม3ž;Wqmž?ง=o[g99ตทoะuw๚~ต‰o๔็ตm๖ํ๚t่^H๏Zฑt็จ=ซ*l็Šี‹ท๔็ฅhEœ็็๚ีด้ŽGื๓U#ํVำ๒:~thr=jo๓y ๗ฯใž*Lฟ*า็จ๏Tไ๎ห‘qVุ๓๕?Ÿ็๚ีI:ว๙uํ@ฅใ•›0๋฿๛ึœŸ็โณ&9ํŠส›งQฑn1ศ้นฟOึฎ5S~c๔้๙ะ)??]fอง๙ํZRค9โณฅ๖9่(&\๓yต‹p0;?็šฺŸฟN•‹q้Œ~?:รธฟฎฐ.{ฯ๙ลo๑X=ง=(฿€?ไ;ฎื /็q^ง^[เ๙๋ฝ?ิY}zWฉPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPJ:ญ%(๊>ด๓ฟ…ไ a฿ ๎mนวื๙๓ฺธ ๘’Xg&sว๐Š๎-{NzPํพAฟ?๋[–ืง๓อbAิd฿ชƒŒvใ้ำ๓  xy#ฉ๗ญHบ๓ิžี™ฟถsลiย:c้ว=(B,็?็?ึญงLr>ฟ็šฆzOต\Oห๔้๙ะกศ๕ฉฟฯ๙ไT+฿?Žxฉ2๒จิๆจ๏U\`เqœ}*žพ็๓Zจใ๚๚๛P)8ฮ=ฟU7~sMiH2๒ซ2^„{wํŒPL๙ cN8_็าถg๊Oฏ๋๙ตp9?ฏต`๗?…`\‚s฿ฏ๙+กธฯงท=k็พ}Zณเ๙kŸ๕ยฯBธฏRฏ/๐‰๎ป\,ฟลz…QEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQE dŠJQึ€>y๐ถฑl๋‚!ลv๖ปืแ_๙ุื้ฯjํญฦ@ฯซนAาถเ`t_็าฑ`เž?ึท ว๙uํ@ะƒว๙ด๓๏…W:%‡pA๚ ๎-ม๚O๓ฺธฟ ฏH์sว๎ฎa]ฝฐNzPีฐ9ฯำ๓ตตวทึฑํวn>Ÿาถ ใcทงOฮ€5biล๋ฯฅfย:ํœเVœ]ฏท=(๔y_Ÿ๕ซi9:žjขa้๚Uลใถ9๚t่สs๏RŸ๓ศจ—g๑ฯฟ*ึ้~บซ ฦGOๅฯ๙โญท๓?ึชธฟหฏjฃ ?าณๅฯ๓๏ZOฯ[ถk>aมฯLwํŽh&qืกฮk"เrF1ฟฯฅmL:๛ฟ็ฝdฮฝGืืฺ€0.็ร๚Vส๒{๕ฎŠเuใ๒ํšรธ^ฟCืฺ€ ๐c๙~&ิ '™ํ!q>Sธ?๚ฏQฏ†๕tOY๊๒ถ๎ ญห%*U‰=บฉ'ฐษฏhฟ็šถ็ฆ}?ฅWnฝ1๚t  Rฝ๋>Qqํ{V“ŒusวJฃ ฯOำž”•*เœ๚อdฬฟ‡ึถๆ^ฃ๚ึ\หษํ๚t่uฮ{๚๋แz˜๔๋WE2๕ฯใœ ว™:๑z{ะ/ynณF๑J7+ŒFAฯcุาhž+ป๐iงj้-ึž˜Xn#ๅ‰GDu๊๊ฃก0mnข้ื๔ฌiใ๊1ำง็@cโ๊ 5–งk!oเ๓8y†S์TVY่ศ`{ŽE|แ{ฅู\dA๚–นู9ฃPdฐงงแ@Yํ>”`Žี๑ฤพัฟ็ส๛แ*ฯ“รš?๙B?เ :~€>ืฺ}( Žี๐ห๘sG๏eพQETo่๓ๅเ€๔?ฦ€>๑ฺ}(มซเf๐๎ŽๅสOธน๚T แอ#|ก๐:~€?@6ŸJ#ต~}Ÿhกย=IEาฃ„wGŸ8s์€๔(๔#i๔ฃvฏฯcแ#?๑ๅ๐ฟแM>า8GŸ…~…ํ>”Gj๔„wGlแQG๔ xwHํgเ€๔(๔/i๔ฃvฏฯ_๘Gด๙๓ƒ้ฐRย=คg<โ๐:~€?B๖ŸJ#ต~zย;ฃŽถp(ฃ๚P<;คvณ‡๐@z~๚ด๚Q‚;W็ฏ#ฺG๙ม๔ุ)?แา3q๘?@ก{Oฅฺฟ=?แัว[8Q(า;Yร๘ =? ฺ}(มซ๓ืํ#|เ๚lŸ๐i8‡Ÿ ะฝงา‚ํ_žŸ๐Ž่ใญœ?Š(”้ฌแŸ…~…ํ>”`Žี๙๋๖‘>p}6 O๘GดŒวœCOภะ่^ำ้AvฏฯO๘GtqึฮลJ‡tŽึpOย€?B๖ŸJ0Gj๕„{HŸ8>›'#ฺFใฮ!งเh๔/i๔ ‚;W็ง#บ8๋gโŠ?ฅรบGk8งแ@ก{Oฅ#ต~zย=คฯœM‚“ํ#?๑็€ำ๐4๚ด๚PAซ๓ำuณ‡๑Eาแ#ตœ?‚ำ๐ ะฝงาŒฺฟ=แา?็ฮฆมI๖‘Ÿ๘๓ˆภ้๘ ฺ}( Žี๙้๎Ž:ูร๘ข้@๐๎‘ฺฮม้๘P่^ำ้Fํ_žฟ๐i๓็ำ`ค„{Hฯyฤ?เt ~…ํ>”Gj๔„wGlแQG๔ xwHํgเ€๔(๔/i๔ฃvฏฯ_๘Gด๙๓ƒ้ฐRย=คg<โ๐:~€?B๖ŸJ#ต~zย;ฃŽถp(ฃ๚P<;คvณ‡๐@z~๚ด๚Q‚;W็ฏ#ฺG๙ม๔ุ)?แา3q๘?@ก{Oฅฺฟ=?แัว[8Q(า;Yร๘ =? ฺ}(มซ๓ืํ#|เ๚lŸ๐i8‡Ÿ ะฝงา‚ํ_žŸ๐Ž่ใญœ?Š(”้ฌแŸ…~…ํ>”`Žี๙๋๖‘ำ์pgมK๎?ๅส๛ไŸ ะงา‚ํ_ž๐Ž้ๆส๋”Q)รรบG๙ร๘ =? ฺ}(มซ๓๔xwHŸ(>›5*๘sG๒ๅ€ำ๐ ฟvŸJ#ต|žั๛ูA๘ข้Vc๐๎>pOย€>์ฺ}(มซโ9ฃŸ๙rƒ๘_สฏลแอ…€(้๘๛Ci๔ชw๖6ŠZ๊ๆ(๊euA_&รแอ<ู@:uEำญlฺ่:Ldyv‚=วฟฎ€=ฏS๘กZ‡Kํ[ž‹ฉ ™nnP\Ge5รE๖ํB๙๕}]ฤ—R(UU๛‘ ไ"“Žไ๕'“Uญmา T0‡ซr้มงOฮ€4 L๕ษ{ึิ ำ๋฿Œd~ฟ•gภƒฟ^+fc๔็ฅhภง๙~'๚ึด+ำทืื๚ึ|+žรJึ„c๚t  ัƒฏoZะŒtํฯ~:ŸJงใฏ^*Cง๔็ฅ[@ฯฏ๕ซ(้ŸZGฐ๔ลYQoำงงZœsž๙ใ?H ใj5เs๚๑R‚_งๅ@ะํor0Aๆซธํำ?•ZnŸ็Ÿฯ๕จz{ŸฝSq้}ช”€œ๗>Jัqžƒnูชr.G>Ÿ–(.U๗๋6e๊:.kbAืื๙ž๕Ÿ2ut…2ึL๑๕๏tงทๅ5—2uใตssงปึD๑rp1ฟฯฅtำGํฯ๓Zสš.ฟ๒๋@ผัxฌฉกษ๙จZsฦ้อt-o๊?๚Ÿ๋Q~ฟฎ€9ๆท๖วต4“ž3๋์๙=?.ูฆ~ุํื  Lใพ)ฆ1ž+{์_ฯ๕ฃ์฿ืื@>GตA=ณ}๋{์๙ํ๙Q๖|๕จศ๕—์บ.ฯผmฦz_ฯ๕ฃ์฿ืื@>GตA=ณ}๋{์๙ํ๙Q๖|๕จศ๕—์บ.ฯผmฦz_ฯ๕ฃ์฿ืื@>GตA=ณ}๋{์๙ํ๙Q๖|๕จศ๕—์บ.ฯผmฦz_ฯ๕ฃ์฿ืื@>GตA=ณ}๋{์๙ํ๙Q๖|๕จศ๕—์บ.ฯผmฦz_ฯ๕ฃ์฿ืื@>GตA=ณ}๋{์๙ํ๙Q๖|๕จศ๕—์บ.ฯผmฦz_ฯ๕ฃ์฿ืื@>GตA=ณ}๋{์๙ํ๙Q๖|๕จศ๕—์บ.ฯผmฦz_ฯ๕ฃ์฿ืื@>GตA=ณ}๋{์๙ํ๙Q๖|๕จศ๕—์บ.ฯผmฦz_ฯ๕ฃ์฿ืื@>GตA=ณ}๋{์๙ํ๙Q๖|๕จศ๕—์บ.ฯผmฦz_ฯ๕ฃ์฿ืื@>GตA=ณ}๋{์๙ํ๙Q๖|๕จศ๕—์บ.ฯผmฦz_ฯ๕ฃ์฿ืื@>GตA=ณ}๋{์๙ํ๙Q๖|๕จศ๕—์บ.ฯผmฦz_ฯ๕ฃ์฿ืื@>GตA=ณ}๋{์๙ํ๙Q๖|๕จศ๕—์บ.ฯผmฦz_ฯ๕ฃ์฿ืื@>GตA=ณ}๋{์๙ํ๙Q๖|๕จศ=ภง}œzc๙sxญ฿ณฏ๋๙ด}›ำ฿ื@Ÿg็งแNใ๋yญกoฯN=ฝ8[ไtํจ[๔ฮ;๓าฆ€ว๒>k\[๖=_nG็R-ฟ็ฯฎ€2ึ้๘Jฒฐvฦฯฟาด–็งเ;U…ทเdv๏ํ@R\U๘ มฦ1นซINฟฏๆ?:ปืื@ลตjร=3}้ัAœq๙VŒPŒ ๒(ะลวjุ†!ว้๔็๒*b๖๋ฏึตa‹ำ฿ื@!ง็าตแท_๓๏Uก=ธ๔ณZวะ:ต ๚‘Zฑ/แœ}9พ็๙ŸJ‘:็ฎP’?oหž”,Dวฏ๚ึ\ฑ}Gืš่ค‹$๐3T%‹ž˜:~tอKsฦ^๕,=๑Ž}1ื๚WM$=r?< ก$ฐน้@ฬ–็ž?ึฉ5ฟฑ_Z้ ็ U7ทม้Ž=(™{|๖ฯ๘ ฌึOำUาตทแ๕โ 6p:๑ว==๚ิอ›cฯBึ๚ืJึร้Q5ท8ฦ?NŸsฆฏํ๚ิf๖๊ฯ๙ลtfุr1๎xฆ ooหž”€-๙๕ฆตถL}kก๛7ตfg๚t่›๛.{gตf็โบ?ฒใ็ฒร๒็ฅsขุŸZOณ`r๚๚ืG๖Q้I๖Ažใ๔้@๏ูs=จ๛7๓?็ั}ฃ๓ภฅ๛ ์?.zP:-๙๕ค๛6 ฏญte”Ÿd๎?N”ฮ—=ณฺณ?๓q]ู๊?< _ฒร๒็ฅsขุŸZOณ`r๚๚ืG๖Q้I๖Ažใ๔้@๏ูs=จ๛7๓?็ั}ฃ๓ภฅ๛ ์?.zP:-๙๕ค๛6 ฏญte”Ÿd๎?N”ฮ—=ณฺณ?๓q]ู๊?< _ฒร๒็ฅsขุŸZOณ`r๚๚ืG๖Q้I๖Ažใ๔้@๏ูs=จ๛7๓?็ั}ฃ๓ภฅ๛ ์?.zP:-๙๕ค๛6 ฏญte”Ÿd๎?N”ฮ—=ณฺณ?๓q]ู๊?< _ฒร๒็ฅsขุŸZOณ`r๚๚ืG๖Q้I๖Ažใ๔้@๏ูs=จ๛7๓?็ั}ฃ๓ภฅ๛ ์?.zP:-๙๕ค๛6 ฏญte”Ÿd๎?N”ฮ—=ณฺณ?๓q]ู๊?< _ฒร๒็ฅsขุŸZOณ`r๚๚ืG๖Q้I๖Ažใ๔้@๏ูs=จ๛7๓?็ั}ฃ๓ภฅ๛ ์?.zP:-๙๕ค๛6 ฏญte”Ÿd๎?N”ฮ—=ณฺณ?๓q]ู๊?< _ฒร๒็ฅsขุŸZOณ`r๚๚ืG๖Q้I๖Ažใ๔้@๏ูs=จ๛7๓?็ั}ฃ๓ภฃ์ฃฐน้@๐ต9้้๙ำอ๊๚ื@-Fz ั๖ol~?:็หžูํN[|ฯ๙โทพสQ๙เRญท<หž”†-ˆ=?๚ๆค๘#nko์ี'ูปเำง็@หmํŸ]Nถ1*ืุ<ฉฝฦ?ฯๅVR u๕ฯz(qหž”1๚~ึดข‹ฆA_๓อ:(ฝ‡าด"‹ใงOฮ€(๚dgท๋ZQGำ๋฿Žฃ๖จโ‹๑Z1Gำ๚sำ๛ะฑ'?็“jkภ๊>ฟ็šŠ4๖ื๔ํWQqิ๚t  Pgจ'ท๋Vะ?ง๙ช$_รื8iํ฿Ž9้@';ิห๘Žži€{LT cc๔้@วฏฎž4qืโŸ€ า๔=;sQ‘œŒc๙ŸJ”๑๘พ฿ใL#ธ>ฟหฏjฎ฿็ฺ uฮG_๊?ถรžœzูจX.ิEื้ชบuํฟฯฅhฒฟ็ตYืฏใ๋  ทOlž•NH๚๑ŸZืtฯOำถjฃงlqŽิ‰$_CึฉIถ3๙s[ฏ^:ฟŸ๋U.ฟฎ€0$‡šง$ฯ>๕ะผ9ํ๘ูช๒(ž’ฅUk~ุฦ.ฯั4>_nZฎะutฮตฟ|T-mž1Ÿ_๒kขh=ฟ5 ทใ่žk\Tfื“ว๘ŸJ่šAื๕=้†฿ฏฎ€9ๆถวATฯณ{g๛ืDm๒zqํL๛7ทoๅ@fใท~ิ}˜vใ๙sxญณฮ:ฟ็ฝg๔็ฏฏšภ๛7ตf๖ฯ๙๗ญ๏ณ็ นค๒ ้จ ์v๏ฺณ.ฯผ`>ƒŸื๓i>ฮ}=t…๖oj>อํŸ๓๏[F{~TžA๔?•a}›Ž๛Q๖aๅฯ๙โทŒะs๚ญ'ูฯงฏฎ€0พอํGูฝณ}๋{ศฯoส“ศ>ƒง๒  /ณqฟj>ฬ;qนžฟบย๛7ตf๖ฯ๙๗ญ๏#=ฟ*O ๚Ÿส€0พอวnจ๛0ํว๒็๑[ฦ่9?ึ“์็ำืื@_f๖ฃ์ู>๕ฝไgทๅIไAำ๙Pูธํ฿ตfธ\ž+xภ}?ฏ็๚า}œ๚z๚่ ์ิ}›?็ทผŒ๖ฉ<ƒ่:*ย๛7ป๖ฃ์รทหŸ๓ลo ็๕ZOณŸO_]a}›ฺณ{g๛ึ๗‘ž฿•'}Oๅ@_fใท~ิ}˜vใ๙sxญใ๔ฟŸ๋I๖s้๋๋  /ณ{Q๖olŸz๒3๒ค๒ ้จ ์v๏ฺณ.ฯผ`>ƒŸื๓i>ฮ}=t…๖oj>อํŸ๓๏[F{~TžA๔?•a}›Ž๛Q๖aๅฯ๙โทŒะs๚ญ'ูฯงฏฎ€0พอํGูฝณ}๋{ศฯoส“ศ>ƒง๒  /ณqฟj>ฬ;qน๙Tี\=ช`8็ฟฏlP”t้Š•G'.ฯ฿งื๓jL_](*~3฿ฅ ้ว้A8๏Šำ๘็Ÿ_๋QŸฤ}ฯ5&9ใ:SH๖ว้ำ๓ ˆฯlƒ๏ž๕พฝ~ฟ็าง cŸฬ๑Q?nzPrง'ไิ ฟQ๕<ีฦ\žƒ?็๔จŠ๛cงOJขหื ๚~ตYำ>?ฯๅZ  uIโก)้ืžž๔šัœž?ึชผ__ว๓Zฬžร>•]ฃวl~?:ษxฝณ๋ช๏แฯำUฐb็‘\๑P˜วงๅฯO๓๏@อ ็_ฝ@ะใฑZฺhณุg๑Q˜ฝฑ๚t่ ฯ๕Aํธ=ซhรŽผ}xฆy#ฐน้๏@พAฑM3ศ๖?ญm3ุg๕)<œ„}8้@ŸgฯoวฏzCo๛๑๚๕ซkศวิœ A ฯฅc}Ÿญ฿๚าc๘ึื“์>Ÿช<œvว้ำ๓  _#ุŸ]GทLu>•ณไz๑๋‘Š?ไsา€1„=ๅิZ<€;๕ญŸ'>ŸJ<Œvว้า€1ผbty1ื๚Vฯ‘๋วฎF(‘ฯJฦ`๔?—Sh๒์Gึถ|œ๚}(๒1งJฦ๒=‰๕ัไ{w๔ว_๓้[>Gฏน CG=(Aƒะ]O๕ฃศฑZู๒s้๔ฃศวl~(ศ๖'ืG‘ํ฿ำฯฅl๙ผzไb๙๔  aC๙u?ึ ฤ}kgษฯงา#ฑ๚t  o#ุŸ]GทLu>•ณไz๑๋‘Š?ไsา€1„=ๅิZ<€;๕ญŸ'>ŸJ<Œvว้า€1ผbty1ื๚Vฯ‘๋วฎF(‘ฯJฦ`๔?—Sh๒์Gึถ|œ๚}(๒1งJฦ๒=‰๕ัไ{w๔ว_๓้[>Gฏน CG=(Aƒะ]O๕ฃศฑZู๒s้๔ฃศวl~(ศ๖'ืG‘ํ฿ำฯฅl๙ผzไb๙๔  aC๙u?ึ ฤ}kgษฯงา#ฑ๚t  o#ุŸ]GทLu>•ณไz๑๋‘Š?ไsา€1„=ๅิZ<€;๕ญŸ'>ŸJ<Œvว้า€1ผbty1ื๚Vฯ‘๋วฎF(‘ฯJฦ`๔?—Sh๒์Gึถ|œ๚}(๒1งJฦ๒=‰๕ัไ{w๔ว_๓้[>Gฏน CG=(Aƒะ]O๕ฃศฑZู๒s้๔ฃศวl~(ศ๖'ืG‘ํ฿ำฯฅl๙ผzไb๙๔  aoƒะ]ญgวจ๚ึฯ‘๔๚R๙8ํำง็@฿gขฝฯ๔ญ#@นโ์?.zPHท#ท\าˆ8๔๚๚ึฟ“ํฯLRˆ1งOฮ€2„์}3๘ิ‚l~ฟ็าดฤ#ำ๋œT‹้หž”šฐŸOนฉVb>ผ๚๋DC๔ต ‹ฑ๚tลQX}ฝณSฌ_‡?O๓๙Uล‹ิcืฃง๒ๆงnƒ๑ฆทย€*˜ฟฯฅFั๓ำ?แ๘ีฆ๋ฆฟ…S1๑œ๓Q˜ฝฑนฟ ฒ฿p~4?พh™‹ะŸJi‡ถ3Zโฉ_ย€*ฝq“ษํ๐ๆฆnŸI?•S1z {qA‹5;}๊sPcำฟฅ'’:cหŸ๓ลLz~u!้|ะ_'ะbƒิีŠr๕  †/๋G•oๅฯ๙โง?ใNnŸ๗อW๒qำ๛Ry^ฤีŸ*œฝhก‹๚ัๅc๙sxฉฯ๘ำ›ง๓@œt>ิžWฑ5gŠง/ZจbดyX๖\ž*s4ๆ้|ะ'?ฯต'•์MYโฉหึ€*ฟญV=ฟ—?็Šœ9บ฿4_ษวO๓ํIๅ{V๘ชr๕  †/๋G•oๅฯ๙โง?ใNnŸ๗อW๒qำ๛Ry^ฤีŸ*œฝhก‹๚ัๅc๙sxฉฯ๘ำ›ง๓@œt>ิžWฑ5gŠง/ZจbดyX๖\ž*s4ๆ้|ะ'?ฯต'•์MYโฉหึ€*ฟญV=ฟ—?็Šœ9บ฿4_ษวO๓ํIๅ{V๘ชr๕  †/๋G•oๅฯ๙โง?ใNnŸ๗อW๒qำ๛Ry^ฤีŸ*œฝhก‹๚ัๅc๙sxฉฯ๘ำ›ง๓@œt>ิžWฑ5gŠง/ZจbดyX๖\ž*s4ๆ้|ะ'?ฯตN{g๛ี*”u  Wฎ;า๙8้ึๆฅ?ใR๖จฏ“Žุจ๒Gื๛ิ๏Šƒษใ ๏ฮ)V;c๙sxซม๙าทO๛ๆ€ ž)ย/ว?”tฉฏแ@ˆฐNiโ.ธฮ>Ÿำ๐งแฉป฿4‹็๔ฉD~ูฃ๑งšžฟร๔ „๗๙ฉ–<{ำŸ๓ลE5dฯ๛ๆ€W฿็ฅH>อ'Rง๚ม@ ทœwฉ๚๑น str: T = "::: app_model.types" T2 = T + "\n\toptions:\n\t\tdocstring_section_style: table" return md.replace(T, T2) app-model-0.5.1/mkdocs.yml000066400000000000000000000036461510416065100154060ustar00rootroot00000000000000site_name: App Model site_url: https://github.com/pyapp-kit/app-model site_author: Talley Lambert site_description: Generic application schema implemented in python. # strict: true repo_name: pyapp-kit/app-model repo_url: https://github.com/pyapp-kit/app-model copyright: Copyright © 2021 - 2023 Talley Lambert watch: - src nav: - index.md - getting_started.md # defer to gen-files + literate-nav - API reference: reference/ plugins: - search - gen-files: scripts: - docs/gen_ref_nav.py - literate-nav: nav_file: SUMMARY.txt - autorefs - mkdocstrings: handlers: python: import: - https://docs.python.org/3/objects.inv - https://ino.readthedocs.io/en/latest/objects.inv options: extensions: - griffe_fieldz docstring_style: numpy docstring_options: ignore_init_summary: true docstring_section_style: list filters: ["!^_"] heading_level: 1 inherited_members: true merge_init_into_class: true separate_signature: true show_root_heading: true show_root_full_path: false show_signature_annotations: true show_bases: true show_source: true markdown_extensions: - tables - pymdownx.inlinehilite - pymdownx.snippets - pymdownx.superfences - pymdownx.details - admonition - toc: permalink: "#" - pymdownx.emoji: emoji_index: !!python/name:material.extensions.emoji.twemoji emoji_generator: !!python/name:material.extensions.emoji.to_svg theme: name: material icon: repo: material/github logo: material/application-cog-outline features: - navigation.instant - navigation.indexes - search.highlight - search.suggest - navigation.expand extra_css: - css/style.css hooks: - docs/my_hooks.py app-model-0.5.1/pyproject.toml000066400000000000000000000130261510416065100163100ustar00rootroot00000000000000# https://peps.python.org/pep-0517/ [build-system] requires = ["hatchling", "hatch-vcs"] build-backend = "hatchling.build" # https://hatch.pypa.io/latest/config/metadata/ [tool.hatch.version] source = "vcs" # read more about configuring hatch at: # https://hatch.pypa.io/latest/config/build/ [tool.hatch.build.targets.wheel] only-include = ["src"] sources = ["src"] # https://peps.python.org/pep-0621/ [project] name = "app-model" description = "Generic application schema implemented in python" readme = "README.md" requires-python = ">=3.9" license = { text = "BSD 3-Clause License" } authors = [{ email = "talley.lambert@gmail.com" }, { name = "Talley Lambert" }] classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Topic :: Desktop Environment", "Topic :: Software Development", "Topic :: Software Development :: User Interfaces", ] dynamic = ["version"] dependencies = [ "psygnal>=0.10", "pydantic>=2.8", "in-n-out>=0.1.5", "typing_extensions>=4.12", ] [project.urls] homepage = "https://github.com/pyapp-kit/app-model" repository = "https://github.com/pyapp-kit/app-model" [project.optional-dependencies] qt = ["qtpy>=2.4.0", "superqt[iconify]>=0.7.2"] pyqt5 = [ "app-model[qt]", "PyQt5>=5.15.10", "pyqt5-qt5==5.15.2; sys_platform == 'win32'", "pyqt5-qt5>=5.15.4; sys_platform != 'win32'", ] pyqt6 = ["app-model[qt]", "PyQt6>=6.4.0", "pyqt6-qt6>=6.4.0"] pyside2 = ["app-model[qt]", "PySide2>=5.15.2.1"] pyside6 = ["app-model[qt]", "PySide6>=6.6.0"] # https://peps.python.org/pep-0735/ # setup with `uv sync` or `pip install -e . --group dev` [dependency-groups] test = ["pytest>=7.0", "pytest-cov >=6.1"] test-qt = [ { include-group = "test" }, "app-model[qt]", "pytest-qt >=4.3.0; python_version > '3.10'", "pytest-qt ==4.4.0; python_version <= '3.10'", "fonticon-fontawesome6 >=6.4.0", ] dev = [ { include-group = "test-qt" }, "ruff>=0.8.3", "ipython>=8.18.0", "mypy>=1.13.0", "pdbpp>=0.11.6; sys_platform != 'win32'", "pre-commit-uv>=4", # "pyqt6>=6.8.0", "rich>=13.9.4", "pyright>=1.1.402", ] docs = [ "griffe-fieldz>=0.1.0", "griffe==0.36.9", "mkdocs-gen-files>=0.5.0", "mkdocs-literate-nav>=0.6.2", "mkdocs-macros-plugin==1.0.5", "mkdocs-material==9.4.1", "mkdocs==1.5.3", "mkdocstrings-python==1.7.3", "mkdocstrings==0.23.0", "typing_extensions>=4.11", ] [tool.uv.sources] app-model = { workspace = true } # https://docs.astral.sh/ruff [tool.ruff] line-length = 88 target-version = "py39" src = ["src", "tests"] fix = true # unsafe-fixes = true [tool.ruff.lint] pydocstyle = { convention = "numpy" } select = [ "E", # style errors "W", # style warnings "F", # flakes "D", # pydocstyle "D417", # Missing argument descriptions in Docstrings "I", # isort "UP", # pyupgrade "C4", # flake8-comprehensions "B", # flake8-bugbear "A001", # flake8-builtins "ANN", # flake8-annotations "RUF", # ruff-specific rules "TC", # flake8-type-checking "TID", # flake8-tidy-imports ] ignore = [ "D401", # First line should be in imperative mood (remove to opt in) "ANN401", # Disallow typing.Any ] [tool.ruff.lint.pyupgrade] # Preserve types, even if a file imports `from __future__ import annotations`. keep-runtime-typing = true [tool.ruff.lint.per-file-ignores] "tests/*.py" = ["D", "E501", "ANN"] "demo/*" = ["D"] "docs/*" = ["D"] "src/app_model/_registries.py" = ["D10"] "src/app_model/context/_expressions.py" = ["D10"] "src/app_model/types/_keys/*" = ["E501"] # https://docs.astral.sh/ruff/formatter/ [tool.ruff.format] docstring-code-format = true # https://docs.pytest.org/ [tool.pytest.ini_options] minversion = "7.0" addopts = ["--color=yes"] testpaths = ["tests"] filterwarnings = [ "error", "ignore:Enum value:DeprecationWarning:superqt", "ignore:Failed to disconnect::pytestqt", ] # https://mypy.readthedocs.io/en/stable/config_file.html [tool.mypy] files = "src/**/*.py" strict = true disallow_any_generics = false disallow_subclassing_any = false show_error_codes = true pretty = true plugins = ["pydantic.mypy"] [[tool.mypy.overrides]] module = ["tests.*", "docs.*"] disallow_untyped_defs = false [[tool.mypy.overrides]] module = ["qtpy.*"] implicit_reexport = true [tool.pyright] include = ["src", "demo"] reportArgumentType = "none" # hard with pydantic casting venvPath = "." # https://coverage.readthedocs.io/ [tool.coverage.report] show_missing = true skip_covered = true exclude_lines = [ "pragma: no cover", "if TYPE_CHECKING:", "@overload", "except ImportError", "raise AssertionError", "\\.\\.\\.", "raise NotImplementedError()", "pass", ] [tool.coverage.run] source = ["app_model"] [tool.coverage.paths] source = ["src/", "*/app-model/app-model/src", "*/site-packages/"] [tool.check-manifest] ignore = [ ".github_changelog_generator", ".pre-commit-config.yaml", "tests/**/*", "codecov.yml", "demo/**/*", "docs/**/*", ".readthedocs.yaml", "mkdocs.yml", "CHANGELOG.md", ".ruff_cache/**/*", ] # https://github.com/crate-ci/typos/blob/master/docs/reference.md [tool.typos.default] extend-ignore-identifiers-re = ["to_string_ser_schema"] app-model-0.5.1/src/000077500000000000000000000000001510416065100141615ustar00rootroot00000000000000app-model-0.5.1/src/app_model/000077500000000000000000000000001510416065100161215ustar00rootroot00000000000000app-model-0.5.1/src/app_model/__init__.py000066400000000000000000000006461510416065100202400ustar00rootroot00000000000000"""Generic application schema implemented in python.""" from importlib.metadata import PackageNotFoundError, version try: __version__ = version("app-model") except PackageNotFoundError: # pragma: no cover __version__ = "uninstalled" from ._app import Application from .registries._register import register_action from .types import Action __all__ = ["Action", "Application", "__version__", "register_action"] app-model-0.5.1/src/app_model/_app.py000066400000000000000000000266151510416065100174240ustar00rootroot00000000000000from __future__ import annotations import contextlib import os import sys from collections.abc import Iterable, MutableMapping from types import MappingProxyType from typing import ( TYPE_CHECKING, Any, ClassVar, Literal, Optional, overload, ) import in_n_out as ino from psygnal import Signal from .expressions import Context, app_model_context from .registries import ( CommandsRegistry, KeyBindingsRegistry, MenusRegistry, register_action, ) from .types import ( Action, ) if TYPE_CHECKING: from typing import Callable from .expressions import Expr from .registries._register import CommandDecorator from .types import ( DisposeCallable, IconOrDict, KeyBindingRuleOrDict, MenuRuleOrDict, ) class Application: """Full application model. This is the top level object that comprises all of the registries, and other app-namespace specific objects. Parameters ---------- name : str A name for this application. raise_synchronous_exceptions : bool Whether to raise exceptions that occur while executing commands synchronously, by default False. This is settable after instantiation, and can also be controlled per execution by calling `result.result()` on the future object returned from the `execute_command` method. commands_reg_class : Type[CommandsRegistry] (Optionally) override the class to use when creating the CommandsRegistry menus_reg_class : Type[MenusRegistry] (Optionally) override the class to use when creating the MenusRegistry keybindings_reg_class : Type[KeyBindingsRegistry] (Optionally) override the class to use when creating the KeyBindingsRegistry injection_store_class : Type[ino.Store] (Optionally) override the class to use when creating the injection Store context : Context | MutableMapping | None (Optionally) provide a context to use for this application. If a `MutableMapping` is provided, it will be used to create a `Context` instance. If `None` (the default), a new `Context` instance will be created. Attributes ---------- commands : CommandsRegistry The Commands Registry for this application. menus : MenusRegistry The Menus Registry for this application. keybindings : KeyBindingsRegistry The KeyBindings Registry for this application. injection_store : in_n_out.Store The Injection Store for this application. context : Context The Context for this application. """ destroyed = Signal(str) _instances: ClassVar[dict[str, Application]] = {} def __init__( self, name: str, *, raise_synchronous_exceptions: bool = False, commands_reg_class: type[CommandsRegistry] = CommandsRegistry, menus_reg_class: type[MenusRegistry] = MenusRegistry, keybindings_reg_class: type[KeyBindingsRegistry] = KeyBindingsRegistry, injection_store_class: type[ino.Store] = ino.Store, context: Context | MutableMapping | None = None, ) -> None: self._name = name if name in Application._instances: raise ValueError( f"Application {name!r} already exists. Retrieve it with " f"`Application.get_or_create({name!r})`." ) Application._instances[name] = self if context is None: context = Context() elif isinstance(context, MutableMapping): context = Context(context) if not isinstance(context, Context): raise TypeError( f"context must be a Context or MutableMapping, got {type(context)}" ) self._context = context self._context.update(app_model_context()) self._context["is_linux"] = sys.platform.startswith("linux") self._context["is_mac"] = sys.platform == "darwin" self._context["is_windows"] = os.name == "nt" self._injection_store = injection_store_class.create(name) self._commands = commands_reg_class( self.injection_store, raise_synchronous_exceptions=raise_synchronous_exceptions, ) self._menus = menus_reg_class() self._keybindings = keybindings_reg_class() self.injection_store.on_unannotated_required_args = "ignore" self._registered_actions: dict[str, Action] = {} self._disposers: list[tuple[str, DisposeCallable]] = [] @property def raise_synchronous_exceptions(self) -> bool: """Whether to raise synchronous exceptions.""" return self._commands._raise_synchronous_exceptions @raise_synchronous_exceptions.setter def raise_synchronous_exceptions(self, value: bool) -> None: self._commands._raise_synchronous_exceptions = value @property def commands(self) -> CommandsRegistry: """Return the [`CommandsRegistry`][app_model.registries.CommandsRegistry].""" return self._commands @property def menus(self) -> MenusRegistry: """Return the [`MenusRegistry`][app_model.registries.MenusRegistry].""" return self._menus @property def keybindings(self) -> KeyBindingsRegistry: """Return the [`KeyBindingsRegistry`][app_model.registries.KeyBindingsRegistry].""" # noqa E501 return self._keybindings @property def injection_store(self) -> ino.Store: """Return the `in_n_out.Store` instance associated with this `Application`.""" return self._injection_store @property def context(self) -> Context: """Return the [`Context`][app_model.expressions.Context] for this application.""" # noqa E501 return self._context @classmethod def get_or_create(cls, name: str) -> Application: """Get app named `name` or create and return a new one if it doesn't exist.""" return cls._instances[name] if name in cls._instances else cls(name) @classmethod def get_app(cls, name: str) -> Optional[Application]: """Return app named `name` or None if it doesn't exist.""" return cls._instances.get(name) @classmethod def destroy(cls, name: str) -> None: """Destroy the `Application` named `name`. This will call [`dispose()`][app_model.Application.dispose], destroy the injection store, and remove the application from the list of stored application names (allowing the name to be reused). """ if name not in cls._instances: return # pragma: no cover app = cls._instances.pop(name) app.dispose() app.injection_store.destroy(name) app.destroyed.emit(app.name) @property def name(self) -> str: """Return the name of this `Application`.""" return self._name def __repr__(self) -> str: return f"Application({self.name!r})" def dispose(self) -> None: """Dispose this `Application`. This calls all disposers functions (clearing all registries). """ while self._disposers: with contextlib.suppress(Exception): self._disposers.pop()[1]() @overload def register_action(self, action: Action) -> DisposeCallable: ... @overload def register_action( self, action: str, title: str, *, callback: Literal[None] = ..., category: str | None = ..., tooltip: str | None = ..., icon: IconOrDict | None = ..., enablement: Expr | None = ..., menus: list[MenuRuleOrDict] | None = ..., keybindings: list[KeyBindingRuleOrDict] | None = ..., palette: bool = True, ) -> CommandDecorator: ... @overload def register_action( self, action: str, title: str, *, callback: Callable[..., Any], category: str | None = ..., tooltip: str | None = ..., icon: IconOrDict | None = ..., enablement: Expr | None = ..., menus: list[MenuRuleOrDict] | None = ..., keybindings: list[KeyBindingRuleOrDict] | None = ..., palette: bool = True, ) -> DisposeCallable: ... def register_action( self, action: str | Action, title: str | None = None, *, callback: Callable[..., Any] | None = None, category: str | None = None, tooltip: str | None = None, icon: IconOrDict | None = None, enablement: Expr | None = None, menus: list[MenuRuleOrDict] | None = None, keybindings: list[KeyBindingRuleOrDict] | None = None, palette: bool = True, ) -> CommandDecorator | DisposeCallable: """Register [`Action`][app_model.Action] instance with this application. An [`Action`][app_model.Action] is the complete representation of a command, including information about where and whether it appears in menus and optional keybinding rules. See [`register_action`][app_model.register_action] for complete details on this function. """ if isinstance(action, Action): return register_action(self, action) return register_action( self, id_or_action=action, title=title, # type: ignore callback=callback, # type: ignore category=category, tooltip=tooltip, icon=icon, enablement=enablement, menus=menus, keybindings=keybindings, palette=palette, ) def register_actions(self, actions: Iterable[Action]) -> DisposeCallable: """Register multiple [`Action`][app_model.Action] instances with this app. Returns a function that may be called to undo the registration of `actions`. """ d = [self.register_action(action) for action in actions] def _dispose() -> None: while d: d.pop()() return _dispose @property def registered_actions(self) -> MappingProxyType[str, Action]: """Return a Mapping of id->Action object for all registered actions. Note that this only includes actions that were registered using `register_action`. Commands registered directly via `Application.commands.register_action` will not be included in this mapping. """ return MappingProxyType(self._registered_actions) def _register_action_obj(self, action: Action) -> DisposeCallable: """Register an Action object. Return a function that unregisters the action. Helper for `register_action()`. """ # register commands disposers = [self.commands.register_action(action)] # register keybindings if dk := self.keybindings.register_action_keybindings(action): disposers.append(dk) # register menus if dm := self.menus.append_action_menus(action): disposers.append(dm) # remember the action object as a whole. # note that commands.register_action will have raised an exception # if the action.id is already registered, so we can assume that # the keys are unique. self._registered_actions[action.id] = action # create a function that will dispose of all the disposers def _dispose() -> None: self._registered_actions.pop(action.id, None) for d in disposers: d() self._disposers.append((action.id, _dispose)) return _dispose app-model-0.5.1/src/app_model/backends/000077500000000000000000000000001510416065100176735ustar00rootroot00000000000000app-model-0.5.1/src/app_model/backends/__init__.py000066400000000000000000000001711510416065100220030ustar00rootroot00000000000000"""Adapters for using the app_model with various backends.""" # TODO: make a `use_app()` like adapter to easily switch? app-model-0.5.1/src/app_model/backends/qt/000077500000000000000000000000001510416065100203175ustar00rootroot00000000000000app-model-0.5.1/src/app_model/backends/qt/__init__.py000066400000000000000000000014661510416065100224370ustar00rootroot00000000000000"""Qt objects for app_model.""" from ._qaction import QCommandAction, QCommandRuleAction, QMenuItemAction from ._qkeybindingedit import QModelKeyBindingEdit from ._qkeymap import ( QKeyBindingSequence, qkey2modelkey, qkeycombo2modelkey, qkeysequence2modelkeybinding, qmods2modelmods, ) from ._qmainwindow import QModelMainWindow from ._qmenu import QModelMenu, QModelMenuBar, QModelSubmenu, QModelToolBar from ._util import to_qicon __all__ = [ "QCommandAction", "QCommandRuleAction", "QKeyBindingSequence", "QMenuItemAction", "QModelKeyBindingEdit", "QModelMainWindow", "QModelMenu", "QModelMenuBar", "QModelSubmenu", "QModelToolBar", "qkey2modelkey", "qkeycombo2modelkey", "qkeysequence2modelkeybinding", "qmods2modelmods", "to_qicon", ] app-model-0.5.1/src/app_model/backends/qt/_qaction.py000066400000000000000000000162421510416065100224730ustar00rootroot00000000000000from __future__ import annotations import contextlib from typing import TYPE_CHECKING, ClassVar from weakref import WeakValueDictionary from qtpy.QtGui import QKeySequence from app_model import Application from app_model.expressions import Expr from app_model.types import ToggleRule from ._qkeymap import QKeyBindingSequence from ._util import to_qicon if TYPE_CHECKING: from collections.abc import Mapping from PyQt6.QtGui import QAction from qtpy.QtCore import QObject from typing_extensions import Self from app_model.types import CommandRule, MenuItem else: from qtpy.QtWidgets import QAction class QCommandAction(QAction): """Base QAction for a command id. Can execute the command. Parameters ---------- command_id : str Command ID. app : Application | str Application instance or name of application instance. parent : QObject | None Optional parent widget, by default None """ def __init__( self, command_id: str, app: Application | str, parent: QObject | None = None, ) -> None: super().__init__(parent) self._app = Application.get_or_create(app) if isinstance(app, str) else app self._command_id = command_id self.setObjectName(command_id) self._keybinding_tooltip = "" if kb := self._app.keybindings.get_keybinding(command_id): self.setShortcut(QKeyBindingSequence(kb.keybinding)) self._keybinding_tooltip = f"({kb.keybinding.to_text()})" self.triggered.connect(self._on_triggered) def _update_keybinding(self) -> None: shortcut = self.shortcut() if kb := self._app.keybindings.get_keybinding(self._command_id): self.setShortcut(QKeyBindingSequence(kb.keybinding)) self._keybinding_tooltip = f"({kb.keybinding.to_text()})" elif not shortcut.isEmpty(): self.setShortcut(QKeySequence()) self._keybinding_tooltip = "" def _on_triggered(self, checked: bool) -> None: # execute_command returns a Future, for the sake of eventually being # asynchronous without breaking the API. For now, we call result() # to raise any exceptions. self._app.commands.execute_command(self._command_id).result() class QCommandRuleAction(QCommandAction): """QAction for a CommandRule. Parameters ---------- command_rule : CommandRule `CommandRule` instance to create an action for. app : Application | str Application instance or name of application instance. parent : QObject | None Optional parent widget, by default None use_short_title : bool If True, use the `short_title` of the command rule, if it exists. """ def __init__( self, command_rule: CommandRule, app: Application | str, parent: QObject | None = None, *, use_short_title: bool = False, ) -> None: super().__init__(command_rule.id, app, parent) self._cmd_rule = command_rule self._tooltip = command_rule.tooltip or "" if use_short_title and command_rule.short_title: self.setText(command_rule.short_title) # pragma: no cover else: self.setText(command_rule.title) if command_rule.icon: self.setIcon(to_qicon(command_rule.icon)) self.setIconVisibleInMenu(command_rule.icon_visible_in_menu) if command_rule.status_tip: self.setStatusTip(command_rule.status_tip) if command_rule.toggled is not None: self.setCheckable(True) self._refresh() tooltip_with_keybinding = f"{self._tooltip} {self._keybinding_tooltip}".rstrip() self.setToolTip(tooltip_with_keybinding) def setText(self, text: str | None) -> None: super().setText(text) self._tooltip = self._tooltip or text or "" def _update_keybinding(self) -> None: super()._update_keybinding() tooltip_with_keybinding = f"{self._tooltip} {self._keybinding_tooltip}".rstrip() self.setToolTip(tooltip_with_keybinding) def update_from_context(self, ctx: Mapping[str, object]) -> None: """Update the enabled state of this menu item from `ctx`.""" self.setEnabled(expr.eval(ctx) if (expr := self._cmd_rule.enablement) else True) if expr2 := self._cmd_rule.toggled: if isinstance(expr2, Expr) or ( isinstance(expr2, ToggleRule) and (expr2 := expr2.condition) ): self.setChecked(expr2.eval(ctx)) def _refresh(self) -> None: if isinstance(self._cmd_rule.toggled, ToggleRule): if get_current := self._cmd_rule.toggled.get_current: _current = self._app.injection_store.inject( get_current, on_unresolved_required_args="ignore" ) self.setChecked(_current()) class QMenuItemAction(QCommandRuleAction): """QAction for a MenuItem. Mostly the same as a `CommandRuleAction`, but aware of the `menu_item.when` clause to toggle visibility. Parameters ---------- menu_item : MenuItem `MenuItem` instance to create an action for. app : Application | str Application instance or name of application instance. parent : QObject | None Optional parent widget, by default None """ _cache: ClassVar[WeakValueDictionary[tuple[int, int], Self]] = WeakValueDictionary() def __init__( self, menu_item: MenuItem, app: Application | str, parent: QObject | None = None, ) -> None: super().__init__(menu_item.command, app, parent) self._menu_item = menu_item with contextlib.suppress(NameError): self.update_from_context(self._app.context) @staticmethod def _cache_key(app: Application, menu_item: MenuItem) -> tuple[int, int]: return (id(app), hash(menu_item)) @classmethod def create( cls, menu_item: MenuItem, app: Application | str, parent: QObject | None = None, ) -> Self: """Create a new QMenuItemAction for the given menu item. Prefer this method over `__init__` to ensure that the cache is used, so that: ```python a1 = QMenuItemAction.create(action, full_app) a2 = QMenuItemAction.create(action, full_app) a1 is a2 # True ``` """ app = Application.get_or_create(app) if isinstance(app, str) else app cache_key = QMenuItemAction._cache_key(app, menu_item) if cache_key in cls._cache: res = cls._cache[cache_key] res._update_keybinding() res.setParent(parent) return res cls._cache[cache_key] = obj = cls(menu_item, app, parent) return obj def update_from_context(self, ctx: Mapping[str, object]) -> None: """Update the enabled/visible state of this menu item from `ctx`.""" super().update_from_context(ctx) self.setVisible(expr.eval(ctx) if (expr := self._menu_item.when) else True) def __repr__(self) -> str: name = self.__class__.__name__ return f"{name}({self._menu_item!r}, app={self._app.name!r})" app-model-0.5.1/src/app_model/backends/qt/_qkeybindingedit.py000066400000000000000000000012561510416065100242060ustar00rootroot00000000000000from typing import TYPE_CHECKING, Optional from qtpy.QtWidgets import QKeySequenceEdit from ._qkeymap import qkeysequence2modelkeybinding if TYPE_CHECKING: from app_model.types import KeyBinding class QModelKeyBindingEdit(QKeySequenceEdit): """Editor for a KeyBinding instance. This is a QKeySequenceEdit with a method that converts the current keySequence to an app_model KeyBinding instance. """ def keyBinding(self) -> Optional["KeyBinding"]: """Return app_model KeyBinding instance for the current keySequence.""" if self.keySequence().isEmpty(): return None return qkeysequence2modelkeybinding(self.keySequence()) app-model-0.5.1/src/app_model/backends/qt/_qkeymap.py000066400000000000000000000411121510416065100224760ustar00rootroot00000000000000# mypy: disable-error-code="operator" # pyright: reportOperatorIssue=false from __future__ import annotations import operator from functools import reduce from typing import TYPE_CHECKING from qtpy.QtCore import QCoreApplication, Qt from qtpy.QtGui import QKeySequence from app_model.types import ( KeyBinding, KeyCode, KeyCombo, KeyMod, SimpleKeyBinding, ) from app_model.types._constants import OperatingSystem if TYPE_CHECKING: from collections.abc import Mapping, MutableMapping from qtpy.QtCore import QKeyCombination try: from qtpy import QT6 except ImportError: QT6 = False QCTRL = Qt.KeyboardModifier.ControlModifier QSHIFT = Qt.KeyboardModifier.ShiftModifier QALT = Qt.KeyboardModifier.AltModifier QMETA = Qt.KeyboardModifier.MetaModifier MAC = OperatingSystem.current().is_mac _QMOD_LOOKUP = { "ctrl": QCTRL, "shift": QSHIFT, "alt": QALT, "meta": QMETA, } _SWAPPED_QMOD_LOOKUP = { **_QMOD_LOOKUP, "ctrl": QMETA, "meta": QCTRL, } def _mac_ctrl_meta_swapped() -> bool: """Return True if Qt is swapping Ctrl and Meta for keyboard interactions.""" return not QCoreApplication.testAttribute( Qt.ApplicationAttribute.AA_MacDontSwapCtrlAndMeta ) if QT6: from qtpy.QtCore import QKeyCombination def simple_keybinding_to_qint(skb: SimpleKeyBinding) -> int: """Create Qt Key integer from a SimpleKeyBinding.""" lookup = ( _SWAPPED_QMOD_LOOKUP if MAC and _mac_ctrl_meta_swapped() else _QMOD_LOOKUP ) key = modelkey2qkey(skb.key) if skb.key else Qt.Key.Key_unknown mods = (v for k, v in lookup.items() if getattr(skb, k)) combo = QKeyCombination( reduce(operator.or_, mods, Qt.KeyboardModifier.NoModifier), key ) return int(combo.toCombined()) else: def simple_keybinding_to_qint(skb: SimpleKeyBinding) -> int: """Create Qt Key integer from a SimpleKeyBinding.""" lookup = ( _SWAPPED_QMOD_LOOKUP if MAC and _mac_ctrl_meta_swapped() else _QMOD_LOOKUP ) out = modelkey2qkey(skb.key) if skb.key else 0 mods = (v for k, v in lookup.items() if getattr(skb, k)) out = reduce(operator.or_, mods, out) return int(out) if QT6: # note: this doesn't work on pyside6 < 6.5 ... # but we don't support that anymore def _get_qmods(key: QKeyCombination) -> Qt.KeyboardModifier: return key.keyboardModifiers() def _get_qkey(key: QKeyCombination) -> Qt.Key: return key.key() else: def _get_qmods(key: QKeyCombination) -> Qt.KeyboardModifier: return Qt.KeyboardModifier(key & Qt.KeyboardModifier.KeyboardModifierMask) def _get_qkey(key: QKeyCombination) -> Qt.Key: return Qt.Key(key & ~Qt.KeyboardModifier.KeyboardModifierMask) # maybe ~ 1.5x faster than: # QKeySequence.fromString(",".join(str(x) for x in kb.parts)) # but the string version might be more reliable? class QKeyBindingSequence(QKeySequence): """A QKeySequence based on a KeyBinding instance.""" def __init__(self, kb: KeyBinding) -> None: ints = [simple_keybinding_to_qint(skb) for skb in kb.parts] super().__init__(*ints) KEY_TO_QT: dict[KeyCode | None, Qt.Key] = { None: Qt.Key.Key_unknown, KeyCode.UNKNOWN: Qt.Key.Key_unknown, KeyCode.Backquote: Qt.Key.Key_QuoteLeft, KeyCode.Backslash: Qt.Key.Key_Backslash, KeyCode.IntlBackslash: Qt.Key.Key_Backslash, KeyCode.BracketLeft: Qt.Key.Key_BracketLeft, KeyCode.BracketRight: Qt.Key.Key_BracketRight, KeyCode.Comma: Qt.Key.Key_Comma, KeyCode.Digit0: Qt.Key.Key_0, KeyCode.Digit1: Qt.Key.Key_1, KeyCode.Digit2: Qt.Key.Key_2, KeyCode.Digit3: Qt.Key.Key_3, KeyCode.Digit4: Qt.Key.Key_4, KeyCode.Digit5: Qt.Key.Key_5, KeyCode.Digit6: Qt.Key.Key_6, KeyCode.Digit7: Qt.Key.Key_7, KeyCode.Digit8: Qt.Key.Key_8, KeyCode.Digit9: Qt.Key.Key_9, KeyCode.Equal: Qt.Key.Key_Equal, KeyCode.KeyA: Qt.Key.Key_A, KeyCode.KeyB: Qt.Key.Key_B, KeyCode.KeyC: Qt.Key.Key_C, KeyCode.KeyD: Qt.Key.Key_D, KeyCode.KeyE: Qt.Key.Key_E, KeyCode.KeyF: Qt.Key.Key_F, KeyCode.KeyG: Qt.Key.Key_G, KeyCode.KeyH: Qt.Key.Key_H, KeyCode.KeyI: Qt.Key.Key_I, KeyCode.KeyJ: Qt.Key.Key_J, KeyCode.KeyK: Qt.Key.Key_K, KeyCode.KeyL: Qt.Key.Key_L, KeyCode.KeyM: Qt.Key.Key_M, KeyCode.KeyN: Qt.Key.Key_N, KeyCode.KeyO: Qt.Key.Key_O, KeyCode.KeyP: Qt.Key.Key_P, KeyCode.KeyQ: Qt.Key.Key_Q, KeyCode.KeyR: Qt.Key.Key_R, KeyCode.KeyS: Qt.Key.Key_S, KeyCode.KeyT: Qt.Key.Key_T, KeyCode.KeyU: Qt.Key.Key_U, KeyCode.KeyV: Qt.Key.Key_V, KeyCode.KeyW: Qt.Key.Key_W, KeyCode.KeyX: Qt.Key.Key_X, KeyCode.KeyY: Qt.Key.Key_Y, KeyCode.KeyZ: Qt.Key.Key_Z, KeyCode.Minus: Qt.Key.Key_Minus, KeyCode.Period: Qt.Key.Key_Period, KeyCode.Quote: Qt.Key.Key_Apostrophe, KeyCode.Semicolon: Qt.Key.Key_Semicolon, KeyCode.Slash: Qt.Key.Key_Slash, KeyCode.Alt: Qt.Key.Key_Alt, KeyCode.Backspace: Qt.Key.Key_Backspace, KeyCode.CapsLock: Qt.Key.Key_CapsLock, KeyCode.ContextMenu: Qt.Key.Key_Context1, KeyCode.Ctrl: Qt.Key.Key_Control, KeyCode.Enter: Qt.Key.Key_Enter, KeyCode.Meta: Qt.Key.Key_Meta, KeyCode.Shift: Qt.Key.Key_Shift, KeyCode.Space: Qt.Key.Key_Space, KeyCode.Tab: Qt.Key.Key_Tab, KeyCode.Delete: Qt.Key.Key_Delete, KeyCode.End: Qt.Key.Key_End, KeyCode.Home: Qt.Key.Key_Home, KeyCode.Insert: Qt.Key.Key_Insert, KeyCode.PageDown: Qt.Key.Key_PageDown, KeyCode.PageUp: Qt.Key.Key_PageUp, KeyCode.DownArrow: Qt.Key.Key_Down, KeyCode.LeftArrow: Qt.Key.Key_Left, KeyCode.RightArrow: Qt.Key.Key_Right, KeyCode.UpArrow: Qt.Key.Key_Up, KeyCode.NumLock: Qt.Key.Key_NumLock, KeyCode.Numpad0: Qt.KeyboardModifier.KeypadModifier | Qt.Key.Key_0, KeyCode.Numpad1: Qt.KeyboardModifier.KeypadModifier | Qt.Key.Key_1, KeyCode.Numpad2: Qt.KeyboardModifier.KeypadModifier | Qt.Key.Key_2, KeyCode.Numpad3: Qt.KeyboardModifier.KeypadModifier | Qt.Key.Key_3, KeyCode.Numpad4: Qt.KeyboardModifier.KeypadModifier | Qt.Key.Key_4, KeyCode.Numpad5: Qt.KeyboardModifier.KeypadModifier | Qt.Key.Key_5, KeyCode.Numpad6: Qt.KeyboardModifier.KeypadModifier | Qt.Key.Key_6, KeyCode.Numpad7: Qt.KeyboardModifier.KeypadModifier | Qt.Key.Key_7, KeyCode.Numpad8: Qt.KeyboardModifier.KeypadModifier | Qt.Key.Key_8, KeyCode.Numpad9: Qt.KeyboardModifier.KeypadModifier | Qt.Key.Key_9, KeyCode.NumpadAdd: Qt.KeyboardModifier.KeypadModifier | Qt.Key.Key_Plus, KeyCode.NumpadDecimal: Qt.KeyboardModifier.KeypadModifier | Qt.Key.Key_Period, KeyCode.NumpadDivide: Qt.KeyboardModifier.KeypadModifier | Qt.Key.Key_Slash, KeyCode.NumpadMultiply: Qt.KeyboardModifier.KeypadModifier | Qt.Key.Key_Asterisk, KeyCode.NumpadSubtract: Qt.KeyboardModifier.KeypadModifier | Qt.Key.Key_Minus, KeyCode.Escape: Qt.Key.Key_Escape, KeyCode.F1: Qt.Key.Key_F1, KeyCode.F2: Qt.Key.Key_F2, KeyCode.F3: Qt.Key.Key_F3, KeyCode.F4: Qt.Key.Key_F4, KeyCode.F5: Qt.Key.Key_F5, KeyCode.F6: Qt.Key.Key_F6, KeyCode.F7: Qt.Key.Key_F7, KeyCode.F8: Qt.Key.Key_F8, KeyCode.F9: Qt.Key.Key_F9, KeyCode.F10: Qt.Key.Key_F10, KeyCode.F11: Qt.Key.Key_F11, KeyCode.F12: Qt.Key.Key_F12, KeyCode.PrintScreen: Qt.Key.Key_Print, KeyCode.ScrollLock: Qt.Key.Key_ScrollLock, KeyCode.PauseBreak: Qt.Key.Key_Pause, } KEYMOD_FROM_QT = { Qt.KeyboardModifier.NoModifier: KeyMod.NONE, QALT: KeyMod.Alt, QCTRL: KeyMod.CtrlCmd, QSHIFT: KeyMod.Shift, QMETA: KeyMod.WinCtrl, } MAC_KEYMOD_FROM_QT = {**KEYMOD_FROM_QT, QCTRL: KeyMod.WinCtrl, QMETA: KeyMod.CtrlCmd} KEYMOD_TO_QT = { KeyMod.NONE: Qt.KeyboardModifier.NoModifier, KeyMod.CtrlCmd: QCTRL, KeyMod.Alt: QALT, KeyMod.Shift: QSHIFT, KeyMod.WinCtrl: QMETA, } MAC_KEYMOD_TO_QT = {**KEYMOD_TO_QT, KeyMod.WinCtrl: QCTRL, KeyMod.CtrlCmd: QMETA} KEY_FROM_QT: MutableMapping[Qt.Key, KeyCode | KeyCombo] = { v.toCombined() if hasattr(v, "toCombined") else int(v): k # pyright: ignore for k, v in KEY_TO_QT.items() if k } # Qt Keys which have no representation in the W3C spec _QTONLY_KEYS: Mapping[Qt.Key, KeyCode | KeyCombo] = { Qt.Key.Key_Exclam: KeyMod.Shift | KeyCode.Digit1, Qt.Key.Key_At: KeyMod.Shift | KeyCode.Digit2, Qt.Key.Key_NumberSign: KeyMod.Shift | KeyCode.Digit3, Qt.Key.Key_Dollar: KeyMod.Shift | KeyCode.Digit4, Qt.Key.Key_Percent: KeyMod.Shift | KeyCode.Digit5, Qt.Key.Key_AsciiCircum: KeyMod.Shift | KeyCode.Digit6, Qt.Key.Key_Ampersand: KeyMod.Shift | KeyCode.Digit7, Qt.Key.Key_Asterisk: KeyMod.Shift | KeyCode.Digit8, Qt.Key.Key_ParenLeft: KeyMod.Shift | KeyCode.Digit9, Qt.Key.Key_ParenRight: KeyMod.Shift | KeyCode.Digit0, Qt.Key.Key_Underscore: KeyMod.Shift | KeyCode.Minus, Qt.Key.Key_Plus: KeyMod.Shift | KeyCode.Equal, Qt.Key.Key_BraceLeft: KeyMod.Shift | KeyCode.BracketLeft, Qt.Key.Key_BraceRight: KeyMod.Shift | KeyCode.BracketRight, Qt.Key.Key_Bar: KeyMod.Shift | KeyCode.Backslash, Qt.Key.Key_Colon: KeyMod.Shift | KeyCode.Semicolon, Qt.Key.Key_QuoteDbl: KeyMod.Shift | KeyCode.Quote, Qt.Key.Key_Less: KeyMod.Shift | KeyCode.Comma, Qt.Key.Key_Greater: KeyMod.Shift | KeyCode.Period, Qt.Key.Key_Question: KeyMod.Shift | KeyCode.Slash, Qt.Key.Key_AsciiTilde: KeyMod.Shift | KeyCode.Backquote, Qt.Key.Key_Return: KeyCode.Enter, Qt.Key.Key_Backtab: KeyMod.Shift | KeyCode.Tab, } KEY_FROM_QT.update(_QTONLY_KEYS) def qmods2modelmods(modifiers: Qt.KeyboardModifier) -> KeyMod: """Return KeyMod from Qt.KeyboardModifier.""" mod = KeyMod.NONE lookup = ( MAC_KEYMOD_FROM_QT if MAC and not _mac_ctrl_meta_swapped() else KEYMOD_FROM_QT ) for modifier in lookup: if modifiers & modifier: mod |= lookup[modifier] return mod def modelkey2qkey(key: KeyCode) -> Qt.Key: """Return Qt.Key from KeyCode.""" if MAC and _mac_ctrl_meta_swapped(): if key == KeyCode.Meta: return Qt.Key.Key_Control if key == KeyCode.Ctrl: return Qt.Key.Key_Meta return KEY_TO_QT.get(key, Qt.Key.Key_unknown) def qkey2modelkey(key: Qt.Key) -> KeyCode | KeyCombo: """Return KeyCode from Qt.Key.""" if MAC and _mac_ctrl_meta_swapped(): if key == Qt.Key.Key_Control: return KeyCode.Meta if key == Qt.Key.Key_Meta: return KeyCode.Ctrl return KEY_FROM_QT.get(key, KeyCode.UNKNOWN) def qkeycombo2modelkey(key: QKeyCombination) -> KeyCode | KeyCombo: """Return KeyCode or KeyCombo from QKeyCombination.""" if key in KEY_FROM_QT: # type ignore because in qt5, key may actually just be int ... but it's fine. return KEY_FROM_QT[key] qmods = _get_qmods(key) qkey = _get_qkey(key) return qmods2modelmods(qmods) | qkey2modelkey(qkey) # type: ignore [return-value] def qkeysequence2modelkeybinding(key: QKeySequence) -> KeyBinding: """Return KeyBinding from QKeySequence.""" # FIXME: this should return KeyChord instead of KeyBinding... but that only takes 2 parts = [SimpleKeyBinding.from_int(qkeycombo2modelkey(x)) for x in iter(key)] return KeyBinding(parts=parts) # ################# These are the Qkeys we currently aren't mapping ################ # # Key_F14 # Key_F15 # Key_F16 # Key_F17 # Key_F18 # Key_F19 # Key_F20 # Key_F21 # Key_F22 # Key_F23 # Key_F24 # Key_F25 # Key_F26 # Key_F27 # Key_F28 # Key_F29 # Key_F30 # Key_F31 # Key_F32 # Key_F33 # Key_F34 # Key_F35 # Key_Super_L # Key_Super_R # Key_Menu # Key_Hyper_L # Key_Hyper_R # Key_Help # Key_Direction_L # Key_Direction_R # Key_nobreakspace # Key_exclamdown # Key_cent # Key_sterling # Key_currency # Key_yen # Key_brokenbar # Key_section # Key_diaeresis # Key_copyright # Key_ordfeminine # Key_guillemotleft # Key_notsign # Key_hyphen # Key_registered # Key_macron # Key_degree # Key_plusminus # Key_twosuperior # Key_threesuperior # Key_acute # Key_mu # Key_paragraph # Key_periodcentered # Key_cedilla # Key_onesuperior # Key_masculine # Key_guillemotright # Key_onequarter # Key_onehalf # Key_threequarters # Key_questiondown # Key_Agrave # Key_Aacute # Key_Acircumflex # Key_Atilde # Key_Adiaeresis # Key_Aring # Key_AE # Key_Ccedilla # Key_Egrave # Key_Eacute # Key_Ecircumflex # Key_Ediaeresis # Key_Igrave # Key_Iacute # Key_Icircumflex # Key_Idiaeresis # Key_ETH # Key_Ntilde # Key_Ograve # Key_Oacute # Key_Ocircumflex # Key_Otilde # Key_Odiaeresis # Key_multiply # Key_Ooblique # Key_Ugrave # Key_Uacute # Key_Ucircumflex # Key_Udiaeresis # Key_Yacute # Key_THORN # Key_ssharp # Key_division # Key_ydiaeresis # Key_AltGr # Key_Multi_key # Key_Codeinput # Key_SingleCandidate # Key_MultipleCandidate # Key_PreviousCandidate # Key_Mode_switch # Key_Kanji # Key_Muhenkan # Key_Henkan # Key_Romaji # Key_Hiragana # Key_Katakana # Key_Hiragana_Katakana # Key_Zenkaku # Key_Hankaku # Key_Zenkaku_Hankaku # Key_Touroku # Key_Massyo # Key_Kana_Lock # Key_Kana_Shift # Key_Eisu_Shift # Key_Eisu_toggle # Key_Hangul # Key_Hangul_Start # Key_Hangul_End # Key_Hangul_Hanja # Key_Hangul_Jamo # Key_Hangul_Romaja # Key_Hangul_Jeonja # Key_Hangul_Banja # Key_Hangul_PreHanja # Key_Hangul_PostHanja # Key_Hangul_Special # Key_Dead_Grave # Key_Dead_Acute # Key_Dead_Circumflex # Key_Dead_Tilde # Key_Dead_Macron # Key_Dead_Breve # Key_Dead_Abovedot # Key_Dead_Diaeresis # Key_Dead_Abovering # Key_Dead_Doubleacute # Key_Dead_Caron # Key_Dead_Cedilla # Key_Dead_Ogonek # Key_Dead_Iota # Key_Dead_Voiced_Sound # Key_Dead_Semivoiced_Sound # Key_Dead_Belowdot # Key_Dead_Hook # Key_Dead_Horn # Key_Dead_Stroke # Key_Dead_Abovecomma # Key_Dead_Abovereversedcomma # Key_Dead_Doublegrave # Key_Dead_Belowring # Key_Dead_Belowmacron # Key_Dead_Belowcircumflex # Key_Dead_Belowtilde # Key_Dead_Belowbreve # Key_Dead_Belowdiaeresis # Key_Dead_Invertedbreve # Key_Dead_Belowcomma # Key_Dead_Currency # Key_Dead_a # Key_Dead_A # Key_Dead_e # Key_Dead_E # Key_Dead_i # Key_Dead_I # Key_Dead_o # Key_Dead_O # Key_Dead_u # Key_Dead_U # Key_Dead_Small_Schwa # Key_Dead_Capital_Schwa # Key_Dead_Greek # Key_Dead_Lowline # Key_Dead_Aboveverticalline # Key_Dead_Belowverticalline # Key_Dead_Longsolidusoverlay # Key_Back # Key_Forward # Key_Stop # Key_Refresh # Key_VolumeDown # Key_VolumeMute # Key_VolumeUp # Key_BassBoost # Key_BassUp # Key_BassDown # Key_TrebleUp # Key_TrebleDown # Key_MediaPlay # Key_MediaStop # Key_MediaPrevious # Key_MediaNext # Key_MediaRecord # Key_MediaPause # Key_MediaTogglePlayPause # Key_HomePage # Key_Favorites # Key_Search # Key_Standby # Key_OpenUrl # Key_LaunchMail # Key_LaunchMedia # Key_Launch0 # Key_Launch1 # Key_Launch2 # Key_Launch3 # Key_Launch4 # Key_Launch5 # Key_Launch6 # Key_Launch7 # Key_Launch8 # Key_Launch9 # Key_LaunchA # Key_LaunchB # Key_LaunchC # Key_LaunchD # Key_LaunchE # Key_LaunchF # Key_MonBrightnessUp # Key_MonBrightnessDown # Key_KeyboardLightOnOff # Key_KeyboardBrightnessUp # Key_KeyboardBrightnessDown # Key_PowerOff # Key_WakeUp # Key_Eject # Key_ScreenSaver # Key_WWW # Key_Memo # Key_LightBulb # Key_Shop # Key_History # Key_AddFavorite # Key_HotLinks # Key_BrightnessAdjust # Key_Finance # Key_Community # Key_AudioRewind # Key_BackForward # Key_ApplicationLeft # Key_ApplicationRight # Key_Book # Key_CD # Key_Calculator # Key_ToDoList # Key_ClearGrab # Key_Close # Key_Copy # Key_Cut # Key_Display # Key_DOS # Key_Documents # Key_Excel # Key_Explorer # Key_Game # Key_Go # Key_iTouch # Key_LogOff # Key_Market # Key_Meeting # Key_MenuKB # Key_MenuPB # Key_MySites # Key_News # Key_OfficeHome # Key_Option # Key_Paste # Key_Phone # Key_Calendar # Key_Reply # Key_Reload # Key_RotateWindows # Key_RotationPB # Key_RotationKB # Key_Save # Key_Send # Key_Spell # Key_SplitScreen # Key_Support # Key_TaskPane # Key_Terminal # Key_Tools # Key_Travel # Key_Video # Key_Word # Key_Xfer # Key_ZoomIn # Key_ZoomOut # Key_Away # Key_Messenger # Key_WebCam # Key_MailForward # Key_Pictures # Key_Music # Key_Battery # Key_Bluetooth # Key_WLAN # Key_UWB # Key_AudioForward # Key_AudioRepeat # Key_AudioRandomPlay # Key_Subtitle # Key_AudioCycleTrack # Key_Time # Key_Hibernate # Key_View # Key_TopMenu # Key_PowerDown # Key_Suspend # Key_ContrastAdjust # Key_LaunchG # Key_LaunchH # Key_TouchpadToggle # Key_TouchpadOn # Key_TouchpadOff # Key_MicMute # Key_Red # Key_Green # Key_Yellow # Key_Blue # Key_ChannelUp # Key_ChannelDown # Key_Guide # Key_Info # Key_Settings # Key_MicVolumeUp # Key_MicVolumeDown # Key_New # Key_Open # Key_Find # Key_Undo # Key_Redo # Key_MediaLast # Key_Select # Key_Yes # Key_No # Key_Cancel # Key_Printer # Key_Execute # Key_Sleep # Key_Play # Key_Zoom # Key_Exit # Key_Context2 # Key_Context3 # Key_Context4 # Key_Call # Key_Hangup # Key_Flip # Key_ToggleCallHangup # Key_VoiceDial # Key_LastNumberRedial # Key_Camera # Key_CameraFocus app-model-0.5.1/src/app_model/backends/qt/_qmainwindow.py000066400000000000000000000032431510416065100233670ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING from qtpy.QtCore import Qt from qtpy.QtWidgets import QMainWindow, QWidget from app_model import Application from ._qmenu import QModelMenuBar, QModelToolBar if TYPE_CHECKING: from collections.abc import Collection, Mapping, Sequence class QModelMainWindow(QMainWindow): """QMainWindow with app-model support.""" def __init__(self, app: Application | str, parent: QWidget | None = None) -> None: super().__init__(parent) self._app = Application.get_or_create(app) if isinstance(app, str) else app def setModelMenuBar( self, menu_ids: Mapping[str, str] | Sequence[str | tuple[str, str]] ) -> QModelMenuBar: """Set the menu bar to a list of menu ids. Parameters ---------- menu_ids : Mapping[str, str] | Sequence[str | tuple[str, str]] A mapping of menu ids to menu titles or a sequence of menu ids. """ menu_bar = QModelMenuBar(menu_ids, self._app, self) self.setMenuBar(menu_bar) return menu_bar def addModelToolBar( self, menu_id: str, *, exclude: Collection[str] | None = None, area: Qt.ToolBarArea | None = None, toolbutton_style: Qt.ToolButtonStyle = Qt.ToolButtonStyle.ToolButtonIconOnly, ) -> QModelToolBar: """Add a tool bar to the main window.""" toolbar = QModelToolBar(menu_id, self._app, exclude=exclude, parent=self) toolbar.setToolButtonStyle(toolbutton_style) if area is not None: self.addToolBar(area, toolbar) else: self.addToolBar(toolbar) return toolbar app-model-0.5.1/src/app_model/backends/qt/_qmenu.py000066400000000000000000000276371510416065100221740ustar00rootroot00000000000000from __future__ import annotations import contextlib from collections.abc import Collection, Iterable, Mapping, Sequence from typing import TYPE_CHECKING, cast from qtpy.QtWidgets import QApplication, QMenu, QMenuBar, QToolBar from app_model import Application from app_model.types import SubmenuItem from ._qaction import QCommandRuleAction, QMenuItemAction from ._util import to_qicon try: from qtpy import QT6 except ImportError: QT6 = False if TYPE_CHECKING: from qtpy.QtWidgets import QAction, QWidget class QModelMenu(QMenu): """QMenu for a menu_id in an `app_model` MenusRegistry. Parameters ---------- menu_id : str Menu ID to look up in the registry. app : Application | str Application instance or name of application instance. title : str | None Optional title for the menu, by default None parent : QWidget | None Optional parent widget, by default None """ def __init__( self, menu_id: str, app: Application | str, title: str | None = None, parent: QWidget | None = None, ) -> None: QMenu.__init__(self, parent) # NOTE: code duplication with QModelToolBar, but Qt mixins and multiple # inheritance are problematic for some versions of Qt, and for typing assert isinstance(menu_id, str), f"Expected str, got {type(menu_id)!r}" self._menu_id = menu_id self._app = Application.get_or_create(app) if isinstance(app, str) else app self.setObjectName(menu_id) self.rebuild() self._app.menus.menus_changed.connect(self._on_registry_changed) self.destroyed.connect(self._disconnect) # ---------------------- if title is not None: self.setTitle(title) self.aboutToShow.connect(self._on_about_to_show) def findAction(self, object_name: str) -> QAction | QModelMenu | None: """Find an action by its ObjectName. Parameters ---------- object_name : str Action ID to find. Note that `QCommandAction` have `ObjectName` set to their `command.id` """ return _find_action(self.actions(), object_name) def update_from_context(self, ctx: Mapping[str, object]) -> None: """Update the enabled/visible state of each menu item with `ctx`. See `app_model.expressions` for details on expressions. Parameters ---------- ctx : Mapping A namespace that will be used to `eval()` the `'enablement'` and `'when'` expressions provided for each action in the menu. *ALL variables used in these expressions must either be present in the `ctx` dict, or be builtins*. """ _update_from_context(self.actions(), ctx) def rebuild( self, include_submenus: bool = True, exclude: Collection[str] | None = None ) -> None: """Rebuild menu by looking up self._menu_id in menu_registry.""" _rebuild( menu=self, app=self._app, menu_id=self._menu_id, include_submenus=include_submenus, exclude=exclude, ) def _on_about_to_show(self) -> None: for action in self.actions(): if isinstance(action, QCommandRuleAction): action._refresh() def _disconnect(self) -> None: self._app.menus.menus_changed.disconnect(self._on_registry_changed) def _on_registry_changed(self, changed_ids: set[str]) -> None: if self._menu_id in changed_ids: # if this (sub)menu has been removed from the registry, # we may hit a RuntimeError when trying to rebuild it. with contextlib.suppress(RuntimeError): self.rebuild() class QModelSubmenu(QModelMenu): """QMenu for a menu_id in an `app_model` MenusRegistry. Parameters ---------- submenu : SubmenuItem SubmenuItem for which to create a QMenu. app : Application | str Application instance or name of application instance. parent : QWidget | None Optional parent widget, by default None """ def __init__( self, submenu: SubmenuItem, app: Application | str, parent: QWidget | None = None, ) -> None: assert isinstance(submenu, SubmenuItem), f"Expected str, got {type(submenu)!r}" self._submenu = submenu super().__init__( menu_id=submenu.submenu, app=app, title=submenu.title, parent=parent ) if submenu.icon: self.setIcon(to_qicon(submenu.icon)) def update_from_context(self, ctx: Mapping[str, object]) -> None: """Update the enabled state of this menu item from `ctx`.""" super().update_from_context(ctx) self.setEnabled(expr.eval(ctx) if (expr := self._submenu.enablement) else True) # TODO: ... visibility needs to be controlled at the level of placement # in the submenu. consider only using the `when` expression # self.setVisible(expr.eval(ctx) if (expr := self._submenu.when) else True) class QModelToolBar(QToolBar): """QToolBar that is built from a list of model menu ids. Parameters ---------- menu_id : str Menu ID to look up in the registry. app : Application | str Application instance or name of application instance. exclude : Collection[str] | None Optional list of menu ids to exclude from the toolbar, by default None title : str | None Optional title for the menu, by default None parent : QWidget | None Optional parent widget, by default None """ def __init__( self, menu_id: str, app: Application | str, *, exclude: Collection[str] | None = None, title: str | None = None, parent: QWidget | None = None, ) -> None: self._exclude = exclude QToolBar.__init__(self, parent) # NOTE: code duplication with QModelMenu, but Qt mixins and multiple # inheritance are problematic for some versions of Qt, and for typing assert isinstance(menu_id, str), f"Expected str, got {type(menu_id)!r}" self._menu_id = menu_id self._app = Application.get_or_create(app) if isinstance(app, str) else app self.setObjectName(menu_id) self.rebuild() self._app.menus.menus_changed.connect(self._on_registry_changed) self.destroyed.connect(self._disconnect) # ---------------------- if title is not None: self.setWindowTitle(title) def addMenu(self, menu: QMenu) -> None: """No-op for toolbar.""" def findAction(self, object_name: str) -> QAction | QModelMenu | None: """Find an action by its ObjectName. Parameters ---------- object_name : str Action ID to find. Note that `QCommandAction` have `ObjectName` set to their `command.id` """ return _find_action(self.actions(), object_name) def update_from_context(self, ctx: Mapping[str, object]) -> None: """Update the enabled/visible state of each menu item with `ctx`. See `app_model.expressions` for details on expressions. Parameters ---------- ctx : Mapping A namespace that will be used to `eval()` the `'enablement'` and `'when'` expressions provided for each action in the menu. *ALL variables used in these expressions must either be present in the `ctx` dict, or be builtins*. """ _update_from_context(self.actions(), ctx) def rebuild( self, include_submenus: bool = True, exclude: Collection[str] | None = None ) -> None: """Rebuild toolbar by looking up self._menu_id in menu_registry.""" _rebuild( menu=self, app=self._app, menu_id=self._menu_id, include_submenus=include_submenus, exclude=self._exclude if exclude is None else exclude, ) def _disconnect(self) -> None: self._app.menus.menus_changed.disconnect(self._on_registry_changed) def _on_registry_changed(self, changed_ids: set[str]) -> None: if self._menu_id in changed_ids: self.rebuild() class QModelMenuBar(QMenuBar): """QMenuBar that is built from a list of model menu ids. Parameters ---------- menus : Mapping[str, str] | Sequence[str | tuple[str, str]] A mapping of menu ids to menu titles or a sequence of menu ids. app : Application | str Application instance or name of application instance. parent : QWidget | None Optional parent widget, by default None """ def __init__( self, menus: Mapping[str, str] | Sequence[str | tuple[str, str]], app: Application | str, parent: QWidget | None = None, ) -> None: super().__init__(parent) menu_items = menus.items() if isinstance(menus, Mapping) else menus for item in menu_items: id_, title = item if isinstance(item, tuple) else (item, item.title()) self.addMenu(QModelMenu(id_, app, title, self)) def update_from_context(self, ctx: Mapping[str, object]) -> None: """Update the enabled/visible state of each menu item with `ctx`. See `app_model.expressions` for details on expressions. Parameters ---------- ctx : Mapping A namespace that will be used to `eval()` the `'enablement'` and `'when'` expressions provided for each action in the menu. *ALL variables used in these expressions must either be present in the `ctx` dict, or be builtins*. """ _update_from_context(self.actions(), ctx) def _rebuild( menu: QMenu | QToolBar, app: Application, menu_id: str, include_submenus: bool = True, exclude: Collection[str] | None = None, ) -> None: """Rebuild menu by looking up `menu` in `Application`'s menu_registry.""" actions = menu.actions() for action in actions: menu.removeAction(action) _exclude = exclude or set() groups = list(app.menus.iter_menu_groups(menu_id)) n_groups = len(groups) qapp = QApplication.instance() for n, group in enumerate(groups): for item in group: if isinstance(item, SubmenuItem): if include_submenus: submenu = QModelSubmenu(item, app, parent=menu) cast("QMenu", menu).addMenu(submenu) elif item.command.id not in _exclude: # use QApplication instance as parent for actions # because we use action singleton, and actions # are not related to any window. action = QMenuItemAction.create(item, app=app, parent=qapp) menu.addAction(action) if n < n_groups - 1: menu.addSeparator() def _update_from_context(actions: Iterable[QAction], ctx: Mapping[str, object]) -> None: """Update the enabled/visible state of each menu item with `ctx`. See `app_model.expressions` for details on expressions. Parameters ---------- actions : Iterable[QAction] Actions to update. ctx : Mapping A namespace that will be used to `eval()` the `'enablement'` and `'when'` expressions provided for each action in the menu. *ALL variables used in these expressions must either be present in the `ctx` dict, or be builtins*. """ try: for action in actions: if isinstance(action, QMenuItemAction): action.update_from_context(ctx) elif isinstance(menu := action.menu(), QModelMenu): menu.update_from_context(ctx) except AttributeError as e: # pragma: no cover raise AttributeError(f"This version of Qt is not supported: {e}") from e def _find_action(actions: Iterable[QAction], object_name: str) -> QAction | None: return next((a for a in actions if a.objectName() == object_name), None) app-model-0.5.1/src/app_model/backends/qt/_util.py000066400000000000000000000012221510416065100220020ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING from qtpy.QtCore import QUrl from qtpy.QtGui import QIcon if TYPE_CHECKING: from typing import Literal from app_model.types import Icon def to_qicon(icon: Icon, theme: Literal["dark", "light"] = "dark") -> QIcon: """Create QIcon from Icon.""" from superqt import QIconifyIcon, fonticon if icn := getattr(icon, theme, ""): if icn.startswith("file://"): return QIcon(QUrl(icn).toLocalFile()) elif ":" in icn: return QIconifyIcon(icn) else: return fonticon.icon(icn) return QIcon() # pragma: no cover app-model-0.5.1/src/app_model/expressions/000077500000000000000000000000001510416065100205035ustar00rootroot00000000000000app-model-0.5.1/src/app_model/expressions/__init__.py000066400000000000000000000012711510416065100226150ustar00rootroot00000000000000"""Abstraction on expressions, and contexts in which to evaluate them.""" from ._context import Context, app_model_context, create_context, get_context from ._context_keys import ContextKey, ContextKeyInfo, ContextNamespace from ._expressions import ( BinOp, BoolOp, Compare, Constant, Expr, IfExp, Name, UnaryOp, parse_expression, safe_eval, ) __all__ = [ "BinOp", "BoolOp", "Compare", "Constant", "Context", "ContextKey", "ContextKeyInfo", "ContextNamespace", "Expr", "IfExp", "Name", "UnaryOp", "app_model_context", "create_context", "get_context", "parse_expression", "safe_eval", ] app-model-0.5.1/src/app_model/expressions/_context.py000066400000000000000000000126301510416065100227020ustar00rootroot00000000000000from __future__ import annotations import os import sys from collections import ChainMap from contextlib import contextmanager from typing import TYPE_CHECKING, Any, Callable from weakref import finalize from psygnal import Signal if TYPE_CHECKING: from collections.abc import Iterator, MutableMapping from types import FrameType from typing import TypedDict class AppModelContextDict(TypedDict): """Global context keys offered by app-model.""" is_linux: bool is_mac: bool is_windows: bool _null = object() class Context(ChainMap): """Evented Mapping of keys to values.""" changed = Signal(set) # Set[str] def __init__(self, *maps: MutableMapping) -> None: super().__init__(*maps) for m in maps: if isinstance(m, Context): m.changed.connect(self.changed) @contextmanager def buffered_changes(self) -> Iterator[None]: """Context in which to accumulated changes before emitting.""" with self.changed.paused(lambda a, b: (a[0].union(b[0]),)): yield def __setitem__(self, k: str, v: Any) -> None: emit = self.get(k, _null) is not v super().__setitem__(k, v) if emit: self.changed.emit({k}) def __delitem__(self, k: str) -> None: emit = k in self super().__delitem__(k) if emit: self.changed.emit({k}) def new_child(self, m: MutableMapping | None = None) -> Context: """Create a new child context from this one.""" new = super().new_child(m=m) self.changed.connect(new.changed) return new def __hash__(self) -> int: return id(self) # note: it seems like WeakKeyDictionary would be a nice match here, but # it appears that the object somehow isn't initialized "enough" to register # as the same object in the WeakKeyDictionary later when queried with # `obj in _OBJ_TO_CONTEXT` ... so instead we use id(obj) # _OBJ_TO_CONTEXT: WeakKeyDictionary[object, Context] = WeakKeyDictionary() _OBJ_TO_CONTEXT: dict[int, Context] = {} _ROOT_CONTEXT: Context | None = None def _pydantic_abort(frame: FrameType) -> bool: # type is being declared and pydantic is checking defaults # this context will never be used. return frame.f_code.co_name in ("__new__", "_set_default_and_type") def create_context( obj: object, max_depth: int = 20, start: int = 2, root: Context | None = None, root_class: type[Context] = Context, frame_predicate: Callable[[FrameType], bool] = _pydantic_abort, ) -> Context: """Create context for any object. Parameters ---------- obj : object Any object max_depth : int, optional Max frame depth to search for another object (that already has a context) off of which to scope this new context. by default 20 start : int, optional first frame to use in search, by default 2 root : Optional[Context], optional Root context to use, by default None root_class : type[Context], optional Root class to use when creating a global root context, by default Context The global context is used when root is None. frame_predicate : Callable[[FrameType], bool], optional Callback that can be used to abort context creation. Will be called on each frame in the stack, and if it returns True, the context will not be created. by default, uses pydantic-specific function to determine if a new pydantic BaseModel is being *declared*, (which means that the context will never be used) `lambda frame: frame.f_code.co_name in ("__new__", "_set_default_and_type")` Returns ------- Optional[Context] Context for the object, or None if no context was found """ if root is None: global _ROOT_CONTEXT if _ROOT_CONTEXT is None: _ROOT_CONTEXT = root_class() root = _ROOT_CONTEXT else: assert isinstance(root, Context), "root must be an instance of Context" parent = root if hasattr(sys, "_getframe"): # CPython implementation detail frame: FrameType | None = sys._getframe(start) i = -1 # traverse call stack looking for another object that has a context # to scope this new context off of. while frame and (i := i + 1) < max_depth: if frame_predicate(frame): return root # pragma: no cover # FIXME: should this be allowed? # FIXME: this might be a bit napari "magic" # it also assumes someone uses "self" as the first argument if "self" in frame.f_locals: _ctx = _OBJ_TO_CONTEXT.get(id(frame.f_locals["self"])) if _ctx is not None: parent = _ctx break frame = frame.f_back new_context = parent.new_child() obj_id = id(obj) _OBJ_TO_CONTEXT[obj_id] = new_context # remove key from dict when object is deleted finalize(obj, lambda: _OBJ_TO_CONTEXT.pop(obj_id, None)) return new_context def get_context(obj: object) -> Context | None: """Return context for any object, if found.""" return _OBJ_TO_CONTEXT.get(id(obj)) def app_model_context() -> AppModelContextDict: """A set of useful global context keys to use.""" return { "is_linux": sys.platform.startswith("linux"), "is_mac": sys.platform == "darwin", "is_windows": os.name == "nt", } app-model-0.5.1/src/app_model/expressions/_context_keys.py000066400000000000000000000163411510416065100237400ustar00rootroot00000000000000from __future__ import annotations import contextlib from types import MappingProxyType from typing import ( TYPE_CHECKING, Any, Callable, ClassVar, Generic, Literal, NamedTuple, TypeVar, overload, ) from ._expressions import Name if TYPE_CHECKING: import builtins from collections.abc import MutableMapping T = TypeVar("T") A = TypeVar("A") class __missing: """Sentinel... done this way for the purpose of typing.""" def __repr__(self) -> str: return "MISSING" MISSING = __missing() class ContextKeyInfo(NamedTuple): """Just a recordkeeping tuple. Retrieve all declared ContextKeys with ContextKeyInfo.info(). """ key: str type: type | None description: str | None namespace: builtins.type[ContextNamespace] | None class ContextKey(Name, Generic[A, T]): """Context key name, default, description, and getter. This is intended to be used as class attribute in a `ContextNamespace`. This is a subclass of `Name`, and is therefore usable in an `Expression`. (see examples.) Parameters ---------- default_value : Any, optional The default value for this key, by default MISSING description : str, optional Description of this key. Useful for documentation, by default None getter : callable, optional Callable that receives an object and retrieves the current value for this key, by default None. For example, if this ContextKey represented the length of some list, (like the layerlist) it might look like `length = ContextKey(0, 'length of the list', lambda x: len(x))` id : str, optional Explicitly provide the `Name` string used when evaluating a context, by default the key will be taken as the attribute name to which this object is assigned as a class attribute: Examples -------- >>> class MyNames(ContextNamespace): ... some_key = ContextKey(0, "some description", lambda x: sum(x)) >>> expr = MyNames.some_key > 5 # create an expression using this key these expressions can be later evaluated with some concrete context. >>> expr.eval({"some_key": 3}) # False >>> expr.eval({"some_key": 6}) # True """ # This will catalog all ContextKeys that get instantiated, which provides # an easy way to organize documentation. # ContextKey.info() returns a list with info for all ContextKeys _info: ClassVar[list[ContextKeyInfo]] = [] MISSING = MISSING def __init__( self, default_value: T | __missing = MISSING, description: str | None = None, getter: Callable[[A], T] | None = None, *, id: str = "", # optional because of __set_name__ ) -> None: bound = type(default_value) if default_value is not MISSING else None super().__init__(id or "", bound=bound) self._default_value = default_value self._getter = getter self._description = description self._owner: type[ContextNamespace] | None = None self._type = ( type(default_value) if default_value not in (None, MISSING) else None ) if id: self._store() def __str__(self) -> str: return self.id @classmethod def info(cls) -> list[ContextKeyInfo]: """Return list of all stored context keys.""" return list(cls._info) def _store(self) -> None: self._info.append( ContextKeyInfo(self.id, self._type, self._description, self._owner) ) def __set_name__(self, owner: type[ContextNamespace[A]], name: str) -> None: """Set the name for this key. (this happens when you instantiate this class as a class attribute). """ if self.id: raise RuntimeError( f"Cannot change id of ContextKey (already {self.id!r})", ) self._owner = owner self.id = name # recompile the code with the new name self._recompile() self._store() @overload def __get__(self, obj: Literal[None], objtype: type) -> ContextKey[A, T]: # When we __get__ from the class, we return ourself ... @overload def __get__(self, obj: ContextNamespace[A], objtype: type) -> T: # When we got from the object, we return the current value ... def __get__( self, obj: ContextNamespace[A] | None, objtype: type ) -> T | ContextKey[A, T] | None: """Get current value of the key in the associated context.""" return self if obj is None else obj._context.get(self.id, MISSING) def __set__(self, obj: ContextNamespace[A], value: T) -> None: """Set current value of the key in the associated context.""" obj._context[self.id] = value def __delete__(self, obj: ContextNamespace[A]) -> None: """Delete key from the associated context.""" del obj._context[self.id] class ContextNamespaceMeta(type): """Metaclass that finds all ContextNamespace members.""" _members_map_: dict[str, ContextKey] def __new__(cls, clsname: str, bases: tuple, attrs: dict) -> ContextNamespaceMeta: """Create a new ContextNamespace class.""" new_cls = super().__new__(cls, clsname, bases, attrs) new_cls._members_map_ = { k: v for k, v in attrs.items() if isinstance(v, ContextKey) } return new_cls @property def __members__(self) -> MappingProxyType[str, ContextKey]: return MappingProxyType(self._members_map_) def __dir__(self) -> list[str]: # pragma: no cover return [ "__class__", "__doc__", "__members__", "__module__", *list(self._members_map_), ] class ContextNamespace(Generic[A], metaclass=ContextNamespaceMeta): """A collection of related keys in a context. meant to be subclassed, with `ContextKeys` as class attributes. """ def __init__(self, context: MutableMapping) -> None: self._context = context # on instantiation we create an index of defaults and value-getters # to speed up retrieval later self._defaults: dict[str, Any] = {} # default values per key self._getters: dict[str, Callable[[A], Any]] = {} # value getters for name, ctxkey in type(self).__members__.items(): self._defaults[name] = ctxkey._default_value if ctxkey._default_value is not MISSING: context[ctxkey.id] = ctxkey._default_value if callable(ctxkey._getter): self._getters[name] = ctxkey._getter def reset(self, key: str) -> None: """Reset keys to its default.""" val = self._defaults[key] if val is MISSING: with contextlib.suppress(KeyError): delattr(self, key) else: setattr(self, key, self._defaults[key]) def reset_all(self) -> None: """Reset all keys to their defaults.""" for key in self._defaults: self.reset(key) def dict(self) -> dict: """Return all keys in this namespace.""" return {k: getattr(self, k) for k in type(self).__members__} def __repr__(self) -> str: import pprint return pprint.pformat(self.dict()) app-model-0.5.1/src/app_model/expressions/_expressions.py000066400000000000000000000530131510416065100236000ustar00rootroot00000000000000"""Provides `Expr` and its subclasses.""" from __future__ import annotations import ast from typing import ( TYPE_CHECKING, Any, Generic, SupportsIndex, TypeVar, Union, cast, overload, ) ConstType = Union[None, str, bytes, bool, int, float] PassedType = TypeVar( "PassedType", bound=Union[ast.cmpop, ast.operator, ast.boolop, ast.unaryop, ast.expr_context], ) T = TypeVar("T") T2 = TypeVar("T2", bound=Union[ConstType, "Expr"]) V = TypeVar("V", bound=ConstType) if TYPE_CHECKING: from collections.abc import Iterator, Mapping, Sequence from types import CodeType from pydantic.annotated_handlers import GetCoreSchemaHandler from pydantic_core import core_schema from typing_extensions import TypedDict, Unpack from ._context_keys import ContextKey # Used for node end positions in constructor keyword arguments _EndPositionT = TypeVar("_EndPositionT", int, None) # Corresponds to the names in the `_attributes` # class variable which is non-empty in certain AST nodes class _Attributes(TypedDict, Generic[_EndPositionT], total=False): lineno: int col_offset: int end_lineno: _EndPositionT end_col_offset: _EndPositionT def parse_expression(expr: Expr | str) -> Expr: """Parse string expression into an [`Expr`][app_model.expressions.Expr] instance. Parameters ---------- expr : Expr | str Expression to parse. (If already an `Expr`, it is returned) Returns ------- Expr Instance of `Expr`. Raises ------ SyntaxError If the provided string is not an expression (e.g. it's a statement), or if it uses any forbidden syntax components (e.g. Call, Attribute, Containers, Indexing, Slicing, f-strings, named expression, comprehensions.) """ if isinstance(expr, Expr): return expr try: # mode='eval' means the expr must consist of a single expression tree = ast.parse(str(expr), mode="eval") if not isinstance(tree, ast.Expression): raise SyntaxError # pragma: no cover return ExprTransformer().visit(tree.body) except SyntaxError as e: raise SyntaxError(f"{expr!r} is not a valid expression: ({e}).") from None def safe_eval(expr: str | bool | Expr, context: Mapping | None = None) -> Any: """Safely evaluate `expr` string given `context` dict. This lets you evaluate a string expression with broader expression support than `ast.literal_eval`, but much less support than `eval()`. It also supports booleans (which are returned directly), and `Expr` instances, which are evaluated in the given `context`. Parameters ---------- expr : str | bool | Expr Expression to evaluate. If `expr` is a string, it is parsed into an `Expr` instance. If a `bool`, it is returned directly. context : Mapping | None Context (mapping of names to objects) to evaluate the expression in. """ if isinstance(expr, bool): return expr return parse_expression(expr).eval(context) class Expr(ast.AST, Generic[T]): """Base Expression class providing dunder and convenience methods. This is a subclass of `ast.AST` that provides rich dunder methods that facilitate joining and comparing typed expressions. It only implements a subset of ast Expressions (for safety of evaluation), but provides more than `ast.literal_eval`. Expressions that are supported: - Names: 'myvar' (these must be evaluated along with some context) - Constants: '1' - Comparisons: 'myvar > 1' - Boolean Operators: 'myvar & yourvar' (bitwise `&` and `|` are overloaded here to mean boolean `and` and `or`) - Binary Operators: 'myvar + 42' (includes `//`, `@`, `^`) - Unary Operators: 'not myvar' Things that are *NOT* supported: - attribute access: 'my.attr' - calls: 'f(x)' - containers (lists, tuples, sets, dicts) - indexing or slicing - joined strings (f-strings) - named expressions (walrus operator) - comprehensions (list, set, dict, generator) - statements & assignments (e.g. 'a = b') This class is not meant to be instantiated directly. Instead, use [`parse_expression`][app_model.expressions._expressions.parse_expression], or the [`Expr.parse`][app_model.expressions.Expr.parse] classmethod to create an expression instance. Once created, an expression can be joined with other expressions, or constants. Examples -------- >>> expr = parse_expression("myvar > 5") combine expressions with operators >>> new_expr = expr & parse_expression("v2") nice `repr` >>> new_expr BoolOp( op=And(), values=[ Compare( left=Name(id='myvar', ctx=Load()), ops=[ Gt()], comparators=[ Constant(value=5)]), Name(id='v2', ctx=Load())]) evaluate in some context >>> new_expr.eval(dict(v2="hello!", myvar=8)) 'hello!' you can also use keyword arguments. This is *slightly* slower >>> new_expr.eval(v2="hello!", myvar=4) serialize >>> str(new_expr) 'myvar > 5 and v2' One reason you might want to use this object is to capture named expressions that can be evaluated repeatedly as some underlying context changes. ```python light_is_green = Name[bool]("light_is_green") count = Name[int]("count") is_ready = light_is_green & count > 5 assert is_ready.eval({"count": 4, "light_is_green": True}) == False assert is_ready.eval({"count": 7, "light_is_green": False}) == False assert is_ready.eval({"count": 7, "light_is_green": True}) == True ``` this will also preserve type information: >>> reveal_type(is_ready()) # revealed type is `bool` """ _names: set[str] _code: CodeType def __init__(self, *args: Any, **kwargs: Any) -> None: if type(self).__name__ == "Expr": raise RuntimeError("Don't instantiate Expr. Use `Expr.parse`") super().__init__(*args, **kwargs) self._recompile() def _recompile(self) -> None: ast.fix_missing_locations(self) self._code = compile(ast.Expression(body=self), "", "eval") # type: ignore [arg-type] self._names = set(self._iter_names()) def eval( self, context: Mapping[str, object] | None = None, **ctx_kwargs: object ) -> T: """Evaluate this expression with names in `context`.""" if context is None: context = ctx_kwargs elif ctx_kwargs: context = {**context, **ctx_kwargs} try: return eval(self._code, {}, context) # type: ignore except NameError as e: miss = {k for k in self._names if k not in context} raise NameError( f"Names required to eval this expression are missing: {miss}" ) from e @classmethod def parse(cls, expr: str) -> Expr: """Parse string into Expr (classmethod). see docstring of [`parse_expression`][app_model.expressions.parse_expression] for details. """ return parse_expression(expr) def __str__(self) -> str: """Serialize this expression to string form.""" return self._serialize() def _serialize(self) -> str: """Serialize this expression to string form.""" return str(_ExprSerializer(self)) def __repr__(self) -> str: return f"Expr.parse({str(self)!r})" @staticmethod def _cast(obj: Any) -> Expr: """Cast object into an Expression.""" return obj if isinstance(obj, Expr) else Constant(obj) # boolean operators # '&' and '|' are normally binary operators... but we use them here to # combine expression objects meaning "and" and "or". # if you want the binary operators, use Expr.bitand, and Expr.bitor def __and__( self, other: Expr[T2] | Expr[T] | ConstType | Compare ) -> BoolOp[T | T2]: return BoolOp(ast.And(), [self, other]) def __or__(self, other: Expr[T2] | Expr[T] | ConstType | Compare) -> BoolOp[T | T2]: return BoolOp(ast.Or(), [self, other]) # comparisons def __lt__(self, other: Any) -> Compare: return Compare(self, [ast.Lt()], [other]) def __le__(self, other: Any) -> Compare: return Compare(self, [ast.LtE()], [other]) def __eq__(self, other: Any) -> Compare: # type: ignore return Compare(self, [ast.Eq()], [other]) def __ne__(self, other: Any) -> Compare: # type: ignore return Compare(self, [ast.NotEq()], [other]) def __gt__(self, other: Any) -> Compare: return Compare(self, [ast.Gt()], [other]) def __ge__(self, other: Any) -> Compare: return Compare(self, [ast.GtE()], [other]) # using __contains__ always returns a bool... so we provide our own # Expr.in_ and Expr.not_in methods def in_(self, other: Any) -> Compare: """Return a comparison for `self` in `other`.""" # not a dunder, use with Expr.in_(a, other) return Compare(self, [ast.In()], [other]) def not_in(self, other: Any) -> Compare: """Return a comparison for `self` no in `other`.""" return Compare(self, [ast.NotIn()], [other]) # binary operators # (note that __and__ and __or__ are reserved for boolean operators.) def __add__(self, other: T | Expr[T]) -> BinOp[T]: return BinOp(self, ast.Add(), other) def __sub__(self, other: T | Expr[T]) -> BinOp[T]: return BinOp(self, ast.Sub(), other) def __mul__(self, other: T | Expr[T]) -> BinOp[T]: return BinOp(self, ast.Mult(), other) def __truediv__(self, other: T | Expr[T]) -> BinOp[T]: return BinOp(self, ast.Div(), other) def __floordiv__(self, other: T | Expr[T]) -> BinOp[T]: return BinOp(self, ast.FloorDiv(), other) def __mod__(self, other: T | Expr[T]) -> BinOp[T]: return BinOp(self, ast.Mod(), other) def __matmul__(self, other: T | Expr[T]) -> BinOp[T]: return BinOp(self, ast.MatMult(), other) # pragma: no cover def __pow__(self, other: T | Expr[T]) -> BinOp[T]: return BinOp(self, ast.Pow(), other) def __xor__(self, other: T | Expr[T]) -> BinOp[T]: return BinOp(self, ast.BitXor(), other) def bitand(self, other: T | Expr[T]) -> BinOp[T]: """Return bitwise self & other.""" return BinOp(self, ast.BitAnd(), other) def bitor(self, other: T | Expr[T]) -> BinOp[T]: """Return bitwise self | other.""" return BinOp(self, ast.BitOr(), other) # unary operators def __neg__(self) -> UnaryOp[T]: return UnaryOp(ast.USub(), self) def __pos__(self) -> UnaryOp[T]: # usually a no-op return UnaryOp(ast.UAdd(), self) def __invert__(self) -> UnaryOp[T]: # note: we're using the invert operator `~` to mean "not ___" return UnaryOp(ast.Not(), self) def __reduce_ex__(self, protocol: SupportsIndex) -> tuple[Any, ...]: rv: list[Any] = list(super().__reduce_ex__(protocol)) rv[1] = tuple(getattr(self, f) for f in self._fields) return tuple(rv) @classmethod def __get_pydantic_core_schema__( cls, source: type, handler: GetCoreSchemaHandler ) -> core_schema.CoreSchema: # this will only be called by pydantic v2 from pydantic_core import core_schema return core_schema.no_info_plain_validator_function(cls._validate) @classmethod def _validate(cls, v: Any) -> Expr: """Validate v as an `Expr`. For use with Pydantic.""" return v if isinstance(v, Expr) else parse_expression(v) def __hash__(self) -> int: _hash = hash(self.__class__) for f in self._fields: field = getattr(self, f) if isinstance(field, list): field = tuple(field) _hash += hash(field) return _hash def _iter_names(self) -> Iterator[str]: yield from _iter_names(self) LOAD = ast.Load() class Name(Expr[T], ast.Name): """A variable name. Parameters ---------- id : str The name of the variable. bound : Any | None The type of the variable represented by this name (i.e. the type to which this name evaluates to when used in an expression). This is used to provide type hints when evaluating the expression. If `None`, the type is not known. """ def __init__( self, id: str, ctx: ast.expr_context = LOAD, *, bound: type[T] | None = None, **kwargs: Unpack[_Attributes], ) -> None: super().__init__(id, ctx=ctx, **kwargs) self.bound = bound class Constant(Expr[V], ast.Constant): """A constant value. Parameters ---------- value : V the Python object this constant represents. Types supported: NoneType, str, bytes, bool, int, float kind : str | None The kind of constant. This is used to provide type hints when """ value: V # pyright: ignore[reportIncompatibleVariableOverride] def __init__( self, value: V, kind: str | None = None, **kwargs: Unpack[_Attributes] ) -> None: _valid_type = (type(None), str, bytes, bool, int, float) if not isinstance(value, _valid_type): raise TypeError(f"Constants must be type: {_valid_type!r}") super().__init__(value, kind, **kwargs) class Compare(Expr[bool], ast.Compare): """A comparison of two or more values. `left` is the first value in the comparison, `ops` the list of operators, and `comparators` the list of values after the first element in the comparison. """ def __init__( self, left: Expr, ops: Sequence[ast.cmpop], comparators: Sequence[Expr], **kwargs: Unpack[_Attributes], ) -> None: super().__init__( Expr._cast(left), ops, [Expr._cast(c) for c in comparators], **kwargs ) class BinOp(Expr[T], ast.BinOp): """A binary operation (like addition or division). `op` is the operator, and `left` and `right` are any expression nodes. """ def __init__( self, left: T | Expr[T], op: ast.operator, right: T | Expr[T], **kwargs: Unpack[_Attributes], ) -> None: super().__init__(Expr._cast(left), op, Expr._cast(right), **kwargs) class BoolOp(Expr[T], ast.BoolOp): """A boolean operation, 'or' or 'and'. `op` is Or or And. `values` are the values involved. Consecutive operations with the same operator, such as a or b or c, are collapsed into one node with several values. This doesn't include `not`, which is a `UnaryOp`. """ def __init__( self, op: ast.boolop, values: Sequence[ConstType | Expr], **kwargs: Unpack[_Attributes], ) -> None: super().__init__(op, [Expr._cast(v) for v in values], **kwargs) class UnaryOp(Expr[T], ast.UnaryOp): """A unary operation. `op` is the operator, and `operand` any expression node. """ def __init__( self, op: ast.unaryop, operand: Expr, **kwargs: Unpack[_Attributes] ) -> None: super().__init__(op, Expr._cast(operand), **kwargs) class IfExp(Expr, ast.IfExp): """An expression such as `'a if b else c'`. `body` if `test` else `orelse` """ def __init__( self, test: Expr, body: Expr, orelse: Expr, **kwargs: Unpack[_Attributes] ) -> None: super().__init__( Expr._cast(test), Expr._cast(body), Expr._cast(orelse), **kwargs ) class Tuple(Expr, ast.Tuple): """A tuple expression. `elts` is a list of expressions. """ def __init__( self, elts: Sequence[Expr], ctx: ast.expr_context = LOAD, **kwargs: Unpack[_Attributes], ) -> None: super().__init__(elts=[Expr._cast(e) for e in elts], ctx=ctx, **kwargs) class List(Expr, ast.List): """A tuple expression. `elts` is a list of expressions. """ def __init__( self, elts: Sequence[Expr], ctx: ast.expr_context = LOAD, **kwargs: Unpack[_Attributes], ) -> None: super().__init__(elts=[Expr._cast(e) for e in elts], ctx=ctx, **kwargs) class Set(Expr, ast.Set): """A tuple expression. `elts` is a list of expressions. """ def __init__(self, elts: Sequence[Expr], **kwargs: Unpack[_Attributes]) -> None: super().__init__(elts=[Expr._cast(e) for e in elts], **kwargs) class ExprTransformer(ast.NodeTransformer): """Transformer that converts an ast.expr into an `Expr`. Examples -------- >>> tree = ast.parse("my_var > 11", mode="eval") >>> tree = ExprTransformer().visit(tree) # transformed """ _SUPPORTED_NODES = frozenset( k for k, v in globals().items() if isinstance(v, type) and issubclass(v, Expr) ) # fmt: off @overload def visit(self, node: ast.expr) -> Expr: ... @overload def visit(self, node: PassedType) -> PassedType: ... # fmt: on def visit(self, node: ast.AST) -> ast.AST | None: """Visit a node in the tree, transforming into Expr.""" if isinstance( node, ( ast.cmpop, ast.operator, ast.boolop, ast.unaryop, ast.expr_context, ), ): # all operation types just get passed through return node # filter here for supported expression node types type_ = type(node).__name__ if type_ not in ExprTransformer._SUPPORTED_NODES: raise SyntaxError(f"Type {type_!r} not supported") # providing fake lineno and col_offset here rather than using # ast.fill_missing_locations for typing purposes kwargs: dict[str, Any] = {"lineno": 1, "col_offset": 0} for name, field in ast.iter_fields(node): if isinstance(field, ast.expr): kwargs[name] = self.visit(field) elif isinstance(field, list): kwargs[name] = [self.visit(item) for item in field] else: kwargs[name] = field # return instance of Expr from this module corresponding to the node type return cast("Expr", globals()[type_](**kwargs)) class _ExprSerializer(ast.NodeVisitor): """Serializes an `Expr` into a string. Examples -------- >>> expr = Expr.parse("a + b == c") >>> print(expr) 'a + b == c' or ... using this visitor directly: >>> serializer = ExprSerializer() >>> serializer.visit(expr) >>> out = "".join(serializer.result) """ def __init__(self, node: Expr | None = None) -> None: self._result: list[str] = [] def write(*params: ast.AST | str) -> None: for item in params: if isinstance(item, ast.AST): self.visit(item) elif item: self._result.append(item) self.write = write if node is not None: self.visit(node) def __str__(self) -> str: return "".join(self._result) def visit_Name(self, node: ast.Name) -> None: self.write(node.id) def visit_Tuple(self, node: ast.Tuple) -> None: self.write(f"({', '.join(map(str, node.elts))})") def visit_Set(self, node: ast.Set) -> None: self.write("{" + ", ".join(map(str, node.elts)) + "}") def visit_List(self, node: ast.List) -> None: self.write(f"[{', '.join(map(str, node.elts))}]") def visit_ContextKey(self, node: ContextKey) -> None: return self.visit_Name(node) def visit_Constant(self, node: ast.Constant) -> None: self.write(repr(node.value)) def visit_BoolOp(self, node: ast.BoolOp) -> Any: op = f" {_OPS[type(node.op)]} " for idx, value in enumerate(node.values): self.write((idx and op) or "", value) def visit_Compare(self, node: ast.Compare) -> None: self.visit(node.left) for op, right in zip(node.ops, node.comparators): self.write(f" {_OPS[type(op)]} ", right) def visit_BinOp(self, node: ast.BinOp) -> None: self.write(node.left, f" {_OPS[type(node.op)]} ", node.right) def visit_UnaryOp(self, node: ast.UnaryOp) -> None: sym = _OPS[type(node.op)] self.write(sym, " " if sym.isalpha() else "", node.operand) def visit_IfExp(self, node: ast.IfExp) -> Any: self.write(node.body, " if ", node.test, " else ", node.orelse) OpType = Union[type[ast.operator], type[ast.cmpop], type[ast.boolop], type[ast.unaryop]] _OPS: dict[OpType, str] = { # ast.boolop ast.Or: "or", ast.And: "and", # ast.cmpop ast.Eq: "==", ast.Gt: ">", ast.GtE: ">=", ast.In: "in", ast.Is: "is", ast.NotEq: "!=", ast.Lt: "<", ast.LtE: "<=", ast.NotIn: "not in", ast.IsNot: "is not", # ast.operator ast.BitOr: "|", ast.BitXor: "^", ast.BitAnd: "&", ast.LShift: "<<", ast.RShift: ">>", ast.Add: "+", ast.Sub: "-", ast.Mult: "*", ast.Div: "/", ast.Mod: "%", ast.FloorDiv: "//", ast.MatMult: "@", ast.Pow: "**", # ast.unaryop ast.Not: "not", ast.Invert: "~", ast.UAdd: "+", ast.USub: "-", } def _iter_names(expr: Expr) -> Iterator[str]: """Iterate all (nested) names used in the expression. Could be used to provide nicer error messages when eval() fails. """ if isinstance(expr, Name): yield expr.id elif isinstance(expr, Expr): for _, val in ast.iter_fields(expr): val = val if isinstance(val, list) else [val] for v in val: yield from _iter_names(v) app-model-0.5.1/src/app_model/py.typed000066400000000000000000000000001510416065100176060ustar00rootroot00000000000000app-model-0.5.1/src/app_model/registries/000077500000000000000000000000001510416065100203015ustar00rootroot00000000000000app-model-0.5.1/src/app_model/registries/__init__.py000066400000000000000000000006101510416065100224070ustar00rootroot00000000000000"""App-model registries, such as menus, keybindings, commands.""" from ._commands_reg import CommandsRegistry, RegisteredCommand from ._keybindings_reg import KeyBindingsRegistry from ._menus_reg import MenusRegistry from ._register import register_action __all__ = [ "CommandsRegistry", "KeyBindingsRegistry", "MenusRegistry", "RegisteredCommand", "register_action", ] app-model-0.5.1/src/app_model/registries/_commands_reg.py000066400000000000000000000202101510416065100234430ustar00rootroot00000000000000from __future__ import annotations from concurrent.futures import Future, ThreadPoolExecutor from typing import TYPE_CHECKING, Any, Callable, Generic, TypeVar, cast from in_n_out import Store from psygnal import Signal # maintain runtime compatibility with older typing_extensions if TYPE_CHECKING: from collections.abc import Iterator from typing_extensions import ParamSpec from app_model.types import Action, DisposeCallable P = ParamSpec("P") else: try: from typing_extensions import ParamSpec P = ParamSpec("P") except ImportError: P = TypeVar("P") R = TypeVar("R") class RegisteredCommand(Generic[P, R]): """Small object to represent a command in the CommandsRegistry. Used internally by the CommandsRegistry. This helper class allows us to cache the dependency-injected variant of the command, so that type resolution and dependency injection is performed only once. """ __slots__ = ( "_initialized", "_injected_callback", "_injection_store", "_resolved_callback", "callback", "id", "title", ) def __init__( self, id: str, callback: Callable[P, R] | str, title: str, store: Store | None = None, ) -> None: self.id = id self.callback = callback self.title = title self._injection_store: Store = store or Store.get_store() self._resolved_callback = callback if callable(callback) else None self._injected_callback: Callable[P, R] | None = None self._initialized = True def __setattr__(self, name: str, value: Any) -> None: """Object is immutable after initialization.""" if getattr(self, "_initialized", False): raise AttributeError("RegisteredCommand object is immutable.") super().__setattr__(name, value) @property def resolved_callback(self) -> Callable[P, R]: """Return the resolved command callback. This property is cached, so the callback types are only resolved once. """ if self._resolved_callback is None: from app_model.types._utils import import_python_name try: cb = import_python_name(str(self.callback)) except ImportError as e: object.__setattr__(self, "_resolved_callback", lambda *a, **k: None) raise type(e)( f"Command pointer {self.callback!r} registered for Command " f"{self.id!r} was not importable: {e}" ) from e if not callable(cb): # don't try to import again, just create a no-op object.__setattr__(self, "_resolved_callback", lambda *a, **k: None) raise TypeError( f"Command pointer {self.callback!r} registered for Command " f"{self.id!r} did not resolve to a callble object." ) object.__setattr__(self, "_resolved_callback", cb) return cast("Callable[P, R]", self._resolved_callback) @property def run_injected(self) -> Callable[P, R]: """Return the command callback with dependencies injected. This property is cached, so the injected version is only created once. """ if self._injected_callback is None: cb = self._injection_store.inject(self.resolved_callback, processors=True) object.__setattr__(self, "_injected_callback", cb) return cast("Callable[P, R]", self._injected_callback) class CommandsRegistry: """Registry for commands (callable objects).""" registered = Signal(str) def __init__( self, injection_store: Store | None = None, raise_synchronous_exceptions: bool = False, ) -> None: self._commands: dict[str, RegisteredCommand] = {} self._injection_store = injection_store self._raise_synchronous_exceptions = raise_synchronous_exceptions def register_action(self, action: Action) -> DisposeCallable: """Register an Action object. This is a convenience method that registers the action's callback with the action's ID and title using `register_command`. Parameters ---------- action: Action Action to register Returns ------- DisposeCallable A function that can be called to unregister the action. """ return self.register_command(action.id, action.callback, action.title) def register_command( self, id: str, callback: Callable[P, R] | str, title: str ) -> DisposeCallable: """Register a callable as the handler for command `id`. Parameters ---------- id : CommandId Command identifier callback : Callable Callable to be called when the command is executed title : str Title for the command. Returns ------- DisposeCallable A function that can be called to unregister the command. """ if id in self._commands: raise ValueError( f"Command {id!r} already registered with callback " f"{self._commands[id].callback!r} (new callback: {callback!r})" ) cmd = RegisteredCommand(id, callback, title, self._injection_store) self._commands[id] = cmd def _dispose() -> None: self._commands.pop(id, None) self.registered.emit(id) return _dispose def __iter__(self) -> Iterator[tuple[str, RegisteredCommand]]: yield from self._commands.items() def __len__(self) -> int: return len(self._commands) def __contains__(self, id: str) -> bool: return id in self._commands def __repr__(self) -> str: name = self.__class__.__name__ return f"<{name} at {hex(id(self))} ({len(self._commands)} commands)>" def __getitem__(self, id: str) -> RegisteredCommand: """Retrieve commands registered under a given ID.""" if id not in self._commands: raise KeyError(f"Command {id!r} not registered") return self._commands[id] def execute_command( self, id: str, *args: Any, execute_asynchronously: bool = False, **kwargs: Any, ) -> Future: """Execute a registered command. Parameters ---------- id : CommandId ID of the command to execute *args: Any Positional arguments to pass to the command execute_asynchronously : bool Whether to execute the command asynchronously in a thread, by default `False`. Note that *regardless* of this setting, the return value will implement the `Future` API (so it's necessary) to call `result()` on the returned object. Eventually, this will default to True, but we need to solve `ensure_main_thread` Qt threading issues first **kwargs: Any Keyword arguments to pass to the command Returns ------- Future: concurrent.futures.Future Future object containing the result of the command Raises ------ KeyError If the command is not registered or has no callbacks. """ try: cmd = self[id].run_injected except KeyError as e: raise KeyError(f"Command {id!r} not registered") from e # pragma: no cover if execute_asynchronously: with ThreadPoolExecutor() as executor: return executor.submit(cmd, *args, **kwargs) future: Future = Future() try: future.set_result(cmd(*args, **kwargs)) except Exception as e: if self._raise_synchronous_exceptions: # note, the caller of this function can also achieve this by # calling `future.result()` on the returned future object. raise e future.set_exception(e) return future def __str__(self) -> str: lines = [f"{id_!r:<32} -> {cmd.title!r}" for id_, cmd in self] return "\n".join(lines) app-model-0.5.1/src/app_model/registries/_keybindings_reg.py000066400000000000000000000170561510416065100241660ustar00rootroot00000000000000from __future__ import annotations from bisect import insort_left from collections import defaultdict from typing import TYPE_CHECKING, Callable, NamedTuple from psygnal import Signal from app_model.types import KeyBinding if TYPE_CHECKING: from collections.abc import Iterable, Iterator, Mapping from typing import TypeVar from app_model import expressions from app_model.types import ( Action, DisposeCallable, KeyBindingRule, KeyBindingSource, ) CommandDecorator = Callable[[Callable], Callable] CommandCallable = TypeVar("CommandCallable", bound=Callable) class _RegisteredKeyBinding(NamedTuple): """Internal object representing a fully registered keybinding.""" keybinding: KeyBinding # the keycode to bind to command_id: str # the command to run weight: int # the weight of the binding, for prioritization source: KeyBindingSource # who defined the binding, for prioritization when: expressions.Expr | None = None # condition to enable keybinding def __gt__(self, other: object) -> bool: if not isinstance(other, _RegisteredKeyBinding): return NotImplemented return (self.source, self.weight) > (other.source, other.weight) def __lt__(self, other: object) -> bool: if not isinstance(other, _RegisteredKeyBinding): return NotImplemented return (self.source, self.weight) < (other.source, other.weight) def __eq__(self, other: object) -> bool: if not isinstance(other, _RegisteredKeyBinding): return NotImplemented return (self.source, self.weight) == (other.source, other.weight) class KeyBindingsRegistry: """Registry for keybindings. Attributes ---------- filter_keybinding : Callable[[KeyBinding], str] | None Optional function for applying additional `KeyBinding` filtering. Callable should accept a `KeyBinding` object and return an error message (`str`) if `KeyBinding` is rejected, or empty string otherwise. """ registered = Signal() unregistered = Signal() def __init__(self) -> None: self._keymap = defaultdict[int, list[_RegisteredKeyBinding]](list) self._filter_keybinding: Callable[[KeyBinding], str] | None = None @property def _keybindings(self) -> Iterable[_RegisteredKeyBinding]: return (entry for entries in self._keymap.values() for entry in entries) @property def filter_keybinding(self) -> Callable[[KeyBinding], str] | None: """Return the `filter_keybinding`.""" return self._filter_keybinding @filter_keybinding.setter def filter_keybinding(self, value: Callable[[KeyBinding], str] | None) -> None: if callable(value) or value is None: self._filter_keybinding = value else: raise TypeError("'filter_keybinding' must be a callable or None") def register_action_keybindings(self, action: Action) -> DisposeCallable | None: """Register all keybindings declared in `action.keybindings`. Parameters ---------- action : Action The action to register keybindings for. Returns ------- DisposeCallable | None A function that can be called to unregister the keybindings. If no keybindings were registered, returns None. """ if not (keybindings := action.keybindings): return None disposers: list[Callable[[], None]] = [] msg: list[str] = [] for keyb in keybindings: if action.enablement is not None: kwargs = keyb.model_dump() kwargs["when"] = ( action.enablement if keyb.when is None else action.enablement | keyb.when ) _keyb = type(keyb)(**kwargs) else: _keyb = keyb try: if d := self.register_keybinding_rule(action.id, _keyb): disposers.append(d) except ValueError as e: msg.append(str(e)) if msg: raise ValueError( "The following keybindings were not valid:\n" + "\n".join(msg) ) if not disposers: # pragma: no cover return None def _dispose() -> None: for disposer in disposers: disposer() return _dispose def register_keybinding_rule( self, id: str, rule: KeyBindingRule ) -> DisposeCallable | None: """Register a new keybinding rule. Parameters ---------- id : str Command identifier that should be run when the keybinding is triggered rule : KeyBindingRule KeyBinding information Returns ------- Optional[DisposeCallable] A callable that can be used to unregister the keybinding """ if plat_keybinding := rule._bind_to_current_platform(): # list registry keybinding = KeyBinding.validate(plat_keybinding) if self._filter_keybinding: msg = self._filter_keybinding(keybinding) if msg: raise ValueError(f"{keybinding}: {msg}") entry = _RegisteredKeyBinding( keybinding=keybinding, command_id=id, weight=rule.weight, when=rule.when, source=rule.source, ) # inverse map registry entries = self._keymap[keybinding.to_int()] insort_left(entries, entry) self.registered.emit() def _dispose() -> None: # inverse map registry remove entries.remove(entry) self.unregistered.emit() if len(entries) == 0: del self._keymap[keybinding.to_int()] return _dispose return None # pragma: no cover def __iter__(self) -> Iterator[_RegisteredKeyBinding]: yield from self._keybindings def __len__(self) -> int: return sum(len(entries) for entries in self._keymap.values()) def __repr__(self) -> str: name = self.__class__.__name__ return f"<{name} at {hex(id(self))} ({len(self)} bindings)>" def get_keybinding(self, command_id: str) -> _RegisteredKeyBinding | None: """Return the first keybinding that matches the given command ID.""" # TODO: improve me. matches = (kb for kb in self._keybindings if kb.command_id == command_id) sorted_matches = sorted(matches, key=lambda x: x.source, reverse=True) return next(iter(sorted_matches), None) def get_context_prioritized_keybinding( self, key: int, context: Mapping[str, object] ) -> _RegisteredKeyBinding | None: """ Return the first keybinding that matches the given key sequence. The keybinding should be enabled given the context to be returned. Parameters ---------- key : int The key sequence that represent the keybinding. context : Mapping[str, object] Variable context to parse the `when` expression to determine if the keybinding is enabled or not. Returns ------- _RegisteredKeyBinding | None The keybinding found or None otherwise. """ if key in self._keymap: for entry in reversed(self._keymap[key]): if entry.when is None or entry.when.eval(context): return entry return None app-model-0.5.1/src/app_model/registries/_menus_reg.py000066400000000000000000000136561510416065100230110ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING, Any, Callable, Final, cast from psygnal import Signal from app_model.types import MenuItem if TYPE_CHECKING: from collections.abc import Iterable, Iterator from app_model.types import Action, DisposeCallable, MenuOrSubmenu MenuId = str class MenusRegistry: """Registry for menu and submenu items.""" COMMAND_PALETTE_ID: Final = "_command_pallet_" menus_changed = Signal(set) def __init__(self) -> None: self._menu_items: dict[MenuId, dict[MenuOrSubmenu, None]] = {} def append_action_menus(self, action: Action) -> DisposeCallable | None: """Append all MenuRule items declared in `action.menus`. Parameters ---------- action : Action The action containing menus to append. Returns ------- DisposeCallable | None A function that can be called to unregister the menu items. If no menu items were registered, returns `None`. """ disposers: list[Callable[[], None]] = [] disp1 = self.append_menu_items( ( rule.id, MenuItem( command=action, when=rule.when, group=rule.group, order=rule.order ), ) for rule in action.menus or () ) disposers.append(disp1) if action.palette: menu_item = MenuItem(command=action, when=action.enablement) disp = self.append_menu_items([(self.COMMAND_PALETTE_ID, menu_item)]) disposers.append(disp) if not disposers: # pragma: no cover return None def _dispose() -> None: for disposer in disposers: disposer() return _dispose def append_menu_items( self, items: Iterable[tuple[MenuId, MenuOrSubmenu | dict]] ) -> DisposeCallable: """Append menu items to the registry. Parameters ---------- items : Iterable[Tuple[str, MenuOrSubmenu]] Items to append. Returns ------- DisposeCallable A function that can be called to unregister the menu items. """ changed_ids: set[str] = set() disposers: list[Callable[[], None]] = [] for menu_id, item in items: item = cast("MenuOrSubmenu", MenuItem._validate(item)) menu_dict = self._menu_items.setdefault(menu_id, {}) menu_dict[item] = None changed_ids.add(menu_id) def _remove(dct: dict = menu_dict, _item: Any = item) -> None: dct.pop(_item, None) disposers.append(_remove) def _dispose() -> None: for disposer in disposers: disposer() for id_ in changed_ids: if not self._menu_items.get(id_): del self._menu_items[id_] self.menus_changed.emit(changed_ids) if changed_ids: self.menus_changed.emit(changed_ids) return _dispose def __iter__( self, ) -> Iterator[tuple[MenuId, Iterable[MenuOrSubmenu]]]: yield from self._menu_items.items() def __contains__(self, id: object) -> bool: return id in self._menu_items def get_menu(self, menu_id: MenuId) -> list[MenuOrSubmenu]: """Return menu items for `menu_id`.""" # using method rather than __getitem__ so that subclasses can use arguments return list(self._menu_items[menu_id]) def __repr__(self) -> str: name = self.__class__.__name__ return f"<{name} at {hex(id(self))} ({len(self._menu_items)} menus)>" def __str__(self) -> str: return "\n".join(self._render()) def _render(self) -> list[str]: """Return registered menu items as lines of strings.""" # this is mostly here as a debugging tool. Can be removed or improved later. lines: list[str] = [] branch = " โ”œโ”€โ”€" for menu in self._menu_items: lines.append(menu) for group in self.iter_menu_groups(menu): first = next(iter(group)) lines.append(f" โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€{first.group}โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€") for child in group: if isinstance(child, MenuItem): lines.append( f"{branch} {child.command.title} ({child.command.id})" ) else: lines.extend( [ f"{branch} {child.submenu}", " โ”œโ”€โ”€ โ””โ”€โ”€ ...", ] ) lines.append("") return lines def iter_menu_groups(self, menu_id: MenuId) -> Iterator[list[MenuOrSubmenu]]: """Iterate over menu groups for `menu_id`. Groups are broken into sections (lists of menu or submenu items) based on their `group` attribute. And each group is sorted by `order` attribute. Parameters ---------- menu_id : str The menu ID to return groups for. Yields ------ Iterator[List[MenuOrSubmenu]] Iterator of menu/submenu groups. """ if menu_id in self: yield from _sort_groups(self.get_menu(menu_id)) def _sort_groups( items: list[MenuOrSubmenu], group_key: Callable = lambda x: "0000" if x == "navigation" else x or "", order_key: Callable = lambda x: getattr(x, "order", "") or 0, ) -> Iterator[list[MenuOrSubmenu]]: """Sort a list of menu items based on their .group and .order attributes.""" groups: dict[str | None, list[MenuOrSubmenu]] = {} for item in items: groups.setdefault(item.group, []).append(item) for group_id in sorted(groups, key=group_key): yield sorted(groups[group_id], key=order_key) app-model-0.5.1/src/app_model/registries/_register.py000066400000000000000000000226631510416065100226470ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING, overload from app_model.types import Action if TYPE_CHECKING: from typing import Any, Callable, Literal, TypeVar from app_model import Application, expressions from app_model.types import ( DisposeCallable, Icon, # noqa: F401 ... used in type hints for docs IconOrDict, KeyBindingRuleOrDict, MenuRuleOrDict, ) CommandCallable = TypeVar("CommandCallable", bound=Callable[..., Any]) CommandDecorator = Callable[[Callable], Callable] @overload def register_action( app: Application | str, id_or_action: Action ) -> DisposeCallable: ... @overload def register_action( app: Application | str, id_or_action: str, title: str, *, callback: Literal[None] = ..., category: str | None = ..., tooltip: str | None = ..., icon: IconOrDict | None = ..., enablement: expressions.Expr | None = ..., menus: list[MenuRuleOrDict] | None = ..., keybindings: list[KeyBindingRuleOrDict] | None = ..., palette: bool = True, ) -> CommandDecorator: ... @overload def register_action( app: Application | str, id_or_action: str, title: str, *, callback: Callable[..., Any], category: str | None = ..., tooltip: str | None = ..., icon: IconOrDict | None = ..., enablement: expressions.Expr | None = ..., menus: list[MenuRuleOrDict] | None = ..., keybindings: list[KeyBindingRuleOrDict] | None = ..., palette: bool = True, ) -> DisposeCallable: ... def register_action( app: Application | str, id_or_action: str | Action, title: str | None = None, *, callback: Callable[..., Any] | None = None, category: str | None = None, tooltip: str | None = None, icon: IconOrDict | None = None, enablement: expressions.Expr | None = None, menus: list[MenuRuleOrDict] | None = None, keybindings: list[KeyBindingRuleOrDict] | None = None, palette: bool = True, ) -> CommandDecorator | DisposeCallable: """Register an action. This is a functional form of the [`Application.register_action()`][app_model.Application.register_action] method. It accepts various overloads to allow for a more concise syntax. See examples below. An `Action` is the "complete" representation of a command. The command is the function/callback itself, and an action also includes information about where and whether it appears in menus and optional keybinding rules. Since, most of the arguments to this function are simply passed through to the `Action` constructor, see also docstrings for: - [`Action`][app_model.types.Action] - [`CommandRule`][app_model.types.CommandRule] - [`MenuRule`][app_model.types.MenuRule] - [`KeyBindingRule`][app_model.types.KeyBindingRule] Parameters ---------- app: Application | str The app in which to register the action. If a string, the app is retrieved or created as necessary using [`Application.get_or_create(app)`][app_model.Application.get_or_create]. id_or_action : str | Action Either a complete Action object or a string id of the command being registered. If an `Action` object is provided, then all other arguments are ignored. title : str | None Title by which the command is represented in the UI. Required when `id_or_action` is a string. callback : CommandHandler | None Callable object that executes this command, by default None. If not provided, a decorator is returned that can be used to decorate a function that executes this action. category : str | None Category string by which the command may be grouped in the UI, by default None tooltip : str | None Tooltip to show when hovered., by default None icon : Icon | None [`Icon`][app_model.types.Icon] used to represent this command, e.g. on buttons or in menus. by default None enablement : expressions.Expr | None Condition which must be true to enable the command in in the UI, by default None menus : list[MenuRuleOrDict] | None List of [`MenuRule`][app_model.types.MenuRule] or kwarg `dicts` containing menu placements for this action, by default None keybindings : list[KeyBindingRuleOrDict] | None List of [`KeyBindingRule`][app_model.types.KeyBindingRule] or kwargs `dicts` containing default keybindings for this action, by default None palette : bool Whether to adds this command to the Command Palette, by default True Returns ------- CommandDecorator If `callback` is not provided, then a decorator is returned that can be used to decorate a function as the executor of the command. DisposeCallable If `callback` is provided, or `id_or_action` is an `Action` object, then a function is returned that may be used to unregister the action. Raises ------ ValueError If `id_or_action` is a string and `title` is not provided. TypeError If `id_or_action` is not a string or an `Action` object. Examples -------- This function can be used directly or as a decorator, and accepts arguments in various forms. ## Passing an existing Action object When the `id_or_action` argument is an instance of `app_model.Action`, then all other arguments are ignored, the action object is registered directly, and the return value is a function that may be used to unregister the action is returned. ```python from app_model import Application, Action, register_action app = Application.get_or_create("myapp") action = Action("my_action", title="My Action", callback=lambda: print("hi")) register_action(app, action) app.commands.execute_command("my_action") # prints "hi" ``` ## Creating a new Action When the `id_or_action` argument is a string, it is interpreted as the `id` of the command being registered, in which case `title` must then also be provided. All other arguments are optional, but may be used to customize the action being created (with keybindings, menus, icons, etc). ```python register_action( app, "my_action2", title="My Action2", callback=lambda: print("hello again!"), ) app.commands.execute_command("my_action2") # prints "hello again!" ``` ## Usage as a decorator If `callback` is not provided, then a decorator is returned that can be used decorate a function as the executor of the command: ```python @register_action(app, "my_action3", title="My Action3") def my_action3(): print("hello again, again!") app.commands.execute_command("my_action3") # prints "hello again, again!" ``` ## Passing app as a string Note that in all of the above examples, the first `app` argument may be either an instance of an [`Application`][app_model.Application] object, or a string name of an application. If a string is provided, then the application is retrieved or created as necessary using [`Application.get_or_create()`][app_model.Application.get_or_create]. ```python register_action( "myapp", # app name instead of Application instance "my_action4", title="My Action4", callback=lambda: print("hello again, again, again!"), ) ``` """ if isinstance(id_or_action, Action): return _register_action_obj(app, id_or_action) if isinstance(id_or_action, str): if not title: raise ValueError("'title' is required when 'id' is a string") return _register_action_str( app=app, id=id_or_action, title=title, category=category, tooltip=tooltip, icon=icon, enablement=enablement, callback=callback, palette=palette, menus=menus, keybindings=keybindings, ) raise TypeError( f"'id_or_action' must be a string or an Action not {type(id_or_action)}" ) def _register_action_str( app: Application | str, **kwargs: Any ) -> CommandDecorator | DisposeCallable: """Create and register an Action with a string id and title. Helper for `register_action()`. If `kwargs['callback']` is a callable, a complete `Action` is created (thereby performing type validation and casting) and registered with the corresponding registries. Otherwise a decorator returned that can be used to decorate the callable that executes the action. """ if kwargs.get("callback") is not None: return _register_action_obj(app, Action(**kwargs)) def decorator(command: CommandCallable, **k: Any) -> CommandCallable: if not callable(command): raise TypeError( "@register_action decorator must be passed a callable object" ) _register_action_obj(app, Action(**{**kwargs, **k, "callback": command})) return command decorator.__doc__ = f"Decorate function as callback for command {kwargs['id']!r}" return decorator def _register_action_obj(app: Application | str, action: Action) -> DisposeCallable: """Register an Action object. Return a function that unregisters the action. Helper for `register_action()`. """ from app_model._app import Application app = app if isinstance(app, Application) else Application.get_or_create(app) return app._register_action_obj(action) app-model-0.5.1/src/app_model/types/000077500000000000000000000000001510416065100172655ustar00rootroot00000000000000app-model-0.5.1/src/app_model/types/__init__.py000066400000000000000000000026601510416065100214020ustar00rootroot00000000000000"""App-model types.""" from typing import TYPE_CHECKING from ._action import Action from ._command_rule import CommandRule, ToggleRule from ._constants import KeyBindingSource, OperatingSystem from ._icon import Icon from ._keybinding_rule import KeyBindingRule from ._keys import ( KeyBinding, KeyChord, KeyCode, KeyCombo, KeyMod, ScanCode, SimpleKeyBinding, StandardKeyBinding, ) from ._menu_rule import MenuItem, MenuItemBase, MenuRule, SubmenuItem if TYPE_CHECKING: from typing import Callable from typing_extensions import TypeAlias from ._icon import IconOrDict as IconOrDict from ._keybinding_rule import KeyBindingRuleDict as KeyBindingRuleDict from ._keybinding_rule import KeyBindingRuleOrDict as KeyBindingRuleOrDict from ._menu_rule import MenuOrSubmenu as MenuOrSubmenu from ._menu_rule import MenuRuleDict as MenuRuleDict from ._menu_rule import MenuRuleOrDict as MenuRuleOrDict # function that can be called without arguments to dispose of a resource DisposeCallable: TypeAlias = Callable[[], None] __all__ = [ "Action", "CommandRule", "Icon", "KeyBinding", "KeyBindingRule", "KeyBindingSource", "KeyChord", "KeyCode", "KeyCombo", "KeyMod", "MenuItem", "MenuItemBase", "MenuRule", "OperatingSystem", "ScanCode", "SimpleKeyBinding", "StandardKeyBinding", "SubmenuItem", "ToggleRule", ] app-model-0.5.1/src/app_model/types/_action.py000066400000000000000000000046741510416065100212660ustar00rootroot00000000000000from typing import TYPE_CHECKING, Callable, Generic, Optional, TypeVar, Union from pydantic import Field, field_validator from ._command_rule import CommandRule from ._keybinding_rule import KeyBindingRule from ._menu_rule import MenuRule from ._utils import _validate_python_name # maintain runtime compatibility with older typing_extensions if TYPE_CHECKING: from typing_extensions import ParamSpec P = ParamSpec("P") else: try: from typing_extensions import ParamSpec P = ParamSpec("P") except ImportError: P = TypeVar("P") R = TypeVar("R") class Action(CommandRule, Generic[P, R]): """An Action is a callable object with menu placement, keybindings, and metadata. This is the "complete" representation of a command. Including a pointer to the actual callable object, as well as any additional menu and keybinding rules. Most commands and menu items will be represented by Actions, and registered using `register_action`. """ callback: Union[Callable[P, R], str] = Field( ..., description="A function to call when the associated command id is executed. " "If a string is provided, it must be a fully qualified name to a callable " "python object. This usually takes the form of " "`{obj.__module__}:{obj.__qualname__}` " "(e.g. `my_package.a_module:some_function`)", ) menus: Optional[list[MenuRule]] = Field( default=None, description="(Optional) Menus to which this action should be added. Note that " "menu items in the sequence may be supplied as a plain string, which will " "be converted to a `MenuRule` with the string as the `id` field.", ) keybindings: Optional[list[KeyBindingRule]] = Field( default=None, description="(Optional) Default keybinding(s) that will trigger this command.", ) palette: bool = Field( default=True, description="Whether to add this command to the global Command Palette " "during registration.", ) @field_validator("callback") def _validate_callback(callback: object) -> Union[Callable, str]: """Assert that `callback` is a callable or valid fully qualified name.""" if callable(callback): return callback elif isinstance(callback, str): return _validate_python_name(str(callback)) raise TypeError("callback must be a callable or a string") # pragma: no cover app-model-0.5.1/src/app_model/types/_base.py000066400000000000000000000006231510416065100207110ustar00rootroot00000000000000from typing import TYPE_CHECKING, ClassVar from pydantic import BaseModel if TYPE_CHECKING: from pydantic import ConfigDict class _BaseModel(BaseModel): """Base model for all types.""" # don't switch to exclude ... it makes it hard to add fields to the # schema without breaking backwards compatibility model_config: ClassVar["ConfigDict"] = {"frozen": True, "extra": "ignore"} app-model-0.5.1/src/app_model/types/_command_rule.py000066400000000000000000000072431510416065100224510ustar00rootroot00000000000000from typing import Callable, Optional, Union from pydantic import Field from app_model import expressions from ._base import _BaseModel from ._icon import Icon class ToggleRule(_BaseModel): """More detailed description of a toggle rule.""" condition: Optional[expressions.Expr] = Field( default=None, description="(Optional) Condition under which the command should appear " "checked/toggled in any GUI representation (like a menu or button).", ) get_current: Optional[Callable[[], bool]] = Field( default=None, description="Function that returns the current state of the toggle.", ) class CommandRule(_BaseModel): """Data representing a command and its presentation. Presentation of contributed commands depends on the containing menu. The Command Palette, for instance, prefixes commands with their category, allowing for easy grouping. However, the Command Palette doesn't show icons nor disabled commands. Menus, on the other hand, shows disabled items as grayed out, but don't show the category label. """ id: str = Field(..., description="A global identifier for the command.") title: str = Field( ..., description="Title by which the command is represented in the UI.", ) category: Optional[str] = Field( default=None, description="(Optional) Category string by which the command may be grouped " "in the UI", ) tooltip: Optional[str] = Field( default=None, description="(Optional) Tooltip to show when hovered." ) status_tip: Optional[str] = Field( default=None, description="(Optional) Help message to show in the status bar when a " "button representing this command is hovered (for backends that support it).", ) icon: Optional[Icon] = Field( default=None, description="(Optional) Icon used to represent this command, e.g. on buttons " "or in menus. These may be [iconify keys](https://icon-sets.iconify.design), " "such as `fa6-solid:arrow-down`, or " "[superqt.fonticon](https://pyapp-kit.github.io/superqt/utilities/fonticon/)" " keys, such as `fa6s.arrow_down`, or a path to a local `.svg` file using the " "[file URI scheme](https://en.wikipedia.org/wiki/File_URI_scheme). " "Note that on Windows the file URI scheme should always start with " "`file:///` (three slashes)", ) icon_visible_in_menu: bool = Field( default=True, description="Whether to show the icon in menus (for backends that support it). " "If `False`, only the title will be shown. By default, `True`.", ) enablement: Optional[expressions.Expr] = Field( default=None, description="(Optional) Condition which must be true to enable the command in " "the UI (menu and keybindings). Does not prevent executing the command by " "other means, like the `execute_command` API.", ) short_title: Optional[str] = Field( default=None, description="(Optional) Short title by which the command is represented in " "the UI. Menus pick either `title` or `short_title` depending on the context " "in which they show commands.", ) toggled: Union[ToggleRule, expressions.Expr, None] = Field( default=None, description="(Optional) Condition under which the command should appear " "checked/toggled in any GUI representation (like a menu or button).", ) def _as_command_rule(self) -> "CommandRule": """Simplify (subclasses) to a plain CommandRule.""" return CommandRule(**{f: getattr(self, f) for f in CommandRule.__annotations__}) app-model-0.5.1/src/app_model/types/_constants.py000066400000000000000000000022161510416065100220130ustar00rootroot00000000000000import os import sys from enum import Enum, IntEnum class KeyBindingSource(IntEnum): """Keybinding source enum.""" APP = 0 PLUGIN = 1 USER = 2 class OperatingSystem(Enum): """Operating system enum.""" UNKNOWN = 0 WINDOWS = 1 MACOS = 2 LINUX = 3 @staticmethod def current() -> "OperatingSystem": """Return the current operating system as enum.""" return _CURRENT @property def is_windows(self) -> bool: """Returns True if the current operating system is Windows.""" return _CURRENT == OperatingSystem.WINDOWS @property def is_linux(self) -> bool: """Returns True if the current operating system is Linux.""" return _CURRENT == OperatingSystem.LINUX @property def is_mac(self) -> bool: """Returns True if the current operating system is MacOS.""" return _CURRENT == OperatingSystem.MACOS _CURRENT = OperatingSystem.UNKNOWN if os.name == "nt": _CURRENT = OperatingSystem.WINDOWS if sys.platform.startswith("linux"): _CURRENT = OperatingSystem.LINUX elif sys.platform == "darwin": _CURRENT = OperatingSystem.MACOS app-model-0.5.1/src/app_model/types/_icon.py000066400000000000000000000032661510416065100207350ustar00rootroot00000000000000from typing import Any, Optional, TypedDict, Union from pydantic import Field, model_validator from ._base import _BaseModel class Icon(_BaseModel): """Icons used to represent commands, or submenus. May provide both a light and dark variant. If only one is provided, it is used in all theme types. """ dark: Optional[str] = Field( default=None, description="Icon path when a dark theme is used. These may be " "[iconify keys](https://icon-sets.iconify.design), such as " "`fa6-solid:arrow-down`, or " "[superqt.fonticon](https://pyapp-kit.github.io/superqt/utilities/fonticon/)" " keys, such as `fa6s.arrow_down`", ) light: Optional[str] = Field( default=None, description="Icon path when a light theme is used. These may be " "[iconify keys](https://icon-sets.iconify.design), such as " "`fa6-solid:arrow-down`, or " "[superqt.fonticon](https://pyapp-kit.github.io/superqt/utilities/fonticon/)" " keys, such as `fa6s.arrow_down`", ) @classmethod def _validate(cls, v: Any) -> "Icon": """Validate icon.""" # if a single string is passed, use it for both light and dark. if isinstance(v, Icon): return v if isinstance(v, str): v = {"dark": v, "light": v} return cls(**v) # for v2 @model_validator(mode="before") @classmethod def _model_val(cls, v: dict) -> dict: if isinstance(v, str): v = {"dark": v, "light": v} return v class IconDict(TypedDict): """Icon dictionary.""" dark: Optional[str] light: Optional[str] IconOrDict = Union[Icon, IconDict] app-model-0.5.1/src/app_model/types/_keybinding_rule.py000066400000000000000000000052271510416065100231560ustar00rootroot00000000000000from typing import Any, Callable, Optional, TypedDict, TypeVar, Union from pydantic import Field, model_validator from app_model import expressions from ._base import _BaseModel from ._constants import KeyBindingSource, OperatingSystem from ._keys import StandardKeyBinding KeyEncoding = Union[int, str] M = TypeVar("M") _OS = OperatingSystem.current() _WIN = _OS.is_windows _MAC = _OS.is_mac _LINUX = _OS.is_linux class KeyBindingRule(_BaseModel): """Data representing a keybinding and when it should be active. This model lacks a corresponding command. That gets linked up elsewhere, such as below in `Action`. Values can be expressed as either a string (e.g. `"Ctrl+O"`) or an integer, using combinations of [`KeyMod`][app_model.types.KeyMod] and [`KeyCode`][app_model.types.KeyCode], (e.g. `KeyMod.CtrlCmd | KeyCode.KeyO`). """ primary: Optional[KeyEncoding] = Field( default=None, description="(Optional) Key combo, (e.g. Ctrl+O)." ) win: Optional[KeyEncoding] = Field( default=None, description="(Optional) Windows specific key combo." ) mac: Optional[KeyEncoding] = Field( default=None, description="(Optional) MacOS specific key combo." ) linux: Optional[KeyEncoding] = Field( default=None, description="(Optional) Linux specific key combo." ) when: Optional[expressions.Expr] = Field( default=None, description="(Optional) Condition when the keybingding is active.", ) weight: int = Field( default=0, description="Internal weight used to sort keybindings. " "This is not part of the plugin schema", ) source: KeyBindingSource = Field( default=KeyBindingSource.APP, description="Who registered the keybinding. Used to sort keybindings.", ) def _bind_to_current_platform(self) -> Optional[KeyEncoding]: if _WIN and self.win: return self.win if _MAC and self.mac: return self.mac if _LINUX and self.linux: return self.linux return self.primary @model_validator(mode="wrap") @classmethod def _model_val( cls: type[M], v: Any, handler: Callable[[Any], M] ) -> "KeyBindingRule": if isinstance(v, StandardKeyBinding): return v.to_keybinding_rule() return handler(v) # type: ignore class KeyBindingRuleDict(TypedDict, total=False): """Typed dict for KeyBindingRule kwargs.""" primary: Optional[str] win: Optional[str] linux: Optional[str] mac: Optional[str] weight: int when: Optional[expressions.Expr] KeyBindingRuleOrDict = Union[KeyBindingRule, KeyBindingRuleDict] app-model-0.5.1/src/app_model/types/_keys/000077500000000000000000000000001510416065100203775ustar00rootroot00000000000000app-model-0.5.1/src/app_model/types/_keys/__init__.py000066400000000000000000000005201510416065100225050ustar00rootroot00000000000000from ._key_codes import KeyChord, KeyCode, KeyCombo, KeyMod, ScanCode from ._keybindings import KeyBinding, SimpleKeyBinding from ._standard_bindings import StandardKeyBinding __all__ = [ "KeyBinding", "KeyChord", "KeyCode", "KeyCombo", "KeyMod", "ScanCode", "SimpleKeyBinding", "StandardKeyBinding", ] app-model-0.5.1/src/app_model/types/_keys/_key_codes.py000066400000000000000000001124421510416065100230610ustar00rootroot00000000000000from enum import IntEnum, IntFlag, auto from typing import ( TYPE_CHECKING, Any, Callable, Dict, Generator, NamedTuple, Optional, Set, Tuple, Type, Union, overload, ) from app_model.types._constants import OperatingSystem if TYPE_CHECKING: from pydantic.annotated_handlers import GetCoreSchemaHandler from pydantic_core import core_schema __all__ = ["KeyCode", "KeyMod", "ScanCode", "KeyChord"] # TODO: # https://stackoverflow.com/questions/3202629/where-can-i-find-a-list-of-mac-virtual-key-codes/16125341#16125341 # flake8: noqa # fmt: off class KeyCode(IntEnum): """Virtual Key Codes, the integer value does not hold any inherent meaning. This is the primary internal representation of a key. """ UNKNOWN = 0 # ----------------------- Writing System Keys ----------------------- Backquote = auto() # `~ on a US keyboard. Backslash = auto() # \| on a US keyboard. BracketLeft = auto() # [{ on a US keyboard. BracketRight = auto() # ]} on a US keyboard. Comma = auto() # ,< on a US keyboard. Digit0 = auto() # 0) on a US keyboard. Digit1 = auto() # 1! on a US keyboard. Digit2 = auto() # 2@ on a US keyboard. Digit3 = auto() # 3# on a US keyboard. Digit4 = auto() # 4$ on a US keyboard. Digit5 = auto() # 5% on a US keyboard. Digit6 = auto() # 6^ on a US keyboard. Digit7 = auto() # 7& on a US keyboard. Digit8 = auto() # 8* on a US keyboard. Digit9 = auto() # 9( on a US keyboard. Equal = auto() # =+ on a US keyboard. IntlBackslash = auto() # Located between the left Shift and Z keys. Labelled \| on a UK keyboard. KeyA = auto() KeyB = auto() KeyC = auto() KeyD = auto() KeyE = auto() KeyF = auto() KeyG = auto() KeyH = auto() KeyI = auto() KeyJ = auto() KeyK = auto() KeyL = auto() KeyM = auto() KeyN = auto() KeyO = auto() KeyP = auto() KeyQ = auto() KeyR = auto() KeyS = auto() KeyT = auto() KeyU = auto() KeyV = auto() KeyW = auto() KeyX = auto() KeyY = auto() KeyZ = auto() Minus = auto() # -_ on a US keyboard. Period = auto() # .> on a US keyboard. Quote = auto() # '" on a US keyboard. Semicolon = auto() # ;: on a US keyboard. Slash = auto() # /? on a US keyboard. # ------------------- Functional Keys -------------------------------- Alt = auto() # Alt, Option or โŒฅ. Backspace = auto() # Backspace or โŒซ. Labelled Delete on Apple keyboards. CapsLock = auto() # CapsLock or โ‡ช ContextMenu = auto() # The application context menu key, which is typically found between the right Meta key and the right Control key. Ctrl = auto() # Control or โŒƒ Enter = auto() # Enter or โ†ต. Labelled Return on Apple keyboards. Meta = auto() # The Windows, โŒ˜, Command or other OS symbol key. Shift = auto() # Shift or โ‡ง Space = auto() # (space) Tab = auto() # Tab or โ‡ฅ # ---------------------- Control Pad -------------------------------- Delete = auto() # โŒฆ. The forward delete key. NOT the Delete key on a mac End = auto() # Page Down, End or โ†˜ Home = auto() # Home or โ†– Insert = auto() # Insert or Ins. Not present on Apple keyboards. PageDown = auto() # Page Down, PgDn or โ‡Ÿ PageUp = auto() # Page Up, PgUp or โ‡ž # ----------------------- Arrow Pad ---------------------------------- DownArrow = auto() # โ†“ LeftArrow = auto() # โ† RightArrow = auto() # โ†’ UpArrow = auto() # โ†‘ # ----------------------- Numpad Section ----------------------------- NumLock = auto() # Numpad0 = auto() # 0 Numpad1 = auto() # 1 Numpad2 = auto() # 2 Numpad3 = auto() # 3 Numpad4 = auto() # 4 Numpad5 = auto() # 5 Numpad6 = auto() # 6 Numpad7 = auto() # 7 Numpad8 = auto() # 8 Numpad9 = auto() # 9 NumpadAdd = auto() # + NumpadDecimal = auto() # . NumpadDivide = auto() # / NumpadMultiply = auto() # * NumpadSubtract = auto() # - # --------------------- Function Section ----------------------------- Escape = auto() # Esc or โŽ‹ F1 = auto() F2 = auto() F3 = auto() F4 = auto() F5 = auto() F6 = auto() F7 = auto() F8 = auto() F9 = auto() F10 = auto() F11 = auto() F12 = auto() PrintScreen = auto() ScrollLock = auto() PauseBreak = auto() def __str__(self) -> str: """Get a normalized string representation (constant to all OSes) of this `KeyCode`.""" return keycode_to_string(self) def os_symbol(self, os: Optional[OperatingSystem] = None) -> str: """Get a string representation of this `KeyCode` using a symbol/OS specific symbol. Some examples: * `KeyCode.Enter` is represented by `โ†ต` * `KeyCode.Meta` is represented by `โŠž` on Windows, `Super` on Linux and `โŒ˜` on MacOS If no OS is given, the current detected one is used. """ os = OperatingSystem.current() if os is None else os return keycode_to_os_symbol(self, os) def os_name(self, os: Optional[OperatingSystem] = None) -> str: """Get a string representation of this `KeyCode` using the OS specific naming for the key. This differs from `__str__` since with it a normalized representation (constant to all OSes) is given. Sometimes these representations coincide but not always! Some examples: * `KeyCode.Enter` is represented by `Enter` (`__str__` represents it as `Enter`) * `KeyCode.Meta` is represented by `Win` on Windows, `Super` on Linux and `Cmd` on MacOS (`__str__` represents it as `Meta`) If no OS is given, the current detected one is used. """ os = OperatingSystem.current() if os is None else os return keycode_to_os_name(self, os) @classmethod def from_string(cls, string: str) -> 'KeyCode': """Return the `KeyCode` associated with the given string. Returns `KeyCode.UNKNOWN` if no `KeyCode` is associated with the string. """ return keycode_from_string(string) @classmethod def from_event_code(cls, event_code: int) -> 'KeyCode': """Return the `KeyCode` associated with the given event code. Returns `KeyCode.UNKNOWN` if no `KeyCode` is associated with the event code. """ return _EVENTCODE_TO_KEYCODE.get(event_code, KeyCode.UNKNOWN) @classmethod def __get_pydantic_core_schema__( cls, source: type, handler: 'GetCoreSchemaHandler' ) -> 'core_schema.CoreSchema': from pydantic_core import core_schema return core_schema.no_info_plain_validator_function(cls.validate) @classmethod def validate(cls, value: Any) -> 'KeyCode': if isinstance(value, KeyCode): return value if isinstance(value, int): return cls(value) if isinstance(value, str): return cls.from_string(value) raise TypeError(f'cannot convert type {type(value)!r} to KeyCode') class ScanCode(IntEnum): """Scan codes for the keyboard. https://en.wikipedia.org/wiki/Scancode These are the scan codes required to conform to the W3C specification for KeyboardEvent.code A scan code is a hardware-specific code that is generated by the keyboard when a key is pressed or released. It represents the physical location of a key on the keyboard and is unique to each key. A key code, on the other hand, is a higher-level representation of a keypress or key release event. They are associated with characters, functions, or actions rather than hardware locations. As an example, the left and right control keys have the same key code (KeyCode.Ctrl) but different scan codes (LeftControl and RightControl). https://w3c.github.io/uievents-code/ commented out lines represent keys that are optional and may be used by implementations to support special keyboards (such as multimedia or legacy keyboards). """ UNIDENTIFIED = 0 # This value code should be used when no other value given in this specification is appropriate. # ----------------------- Writing System Keys ----------------------- # https://w3c.github.io/uievents-code/#key-alphanumeric-writing-system # The writing system keys are those that change meaning (i.e., they produce # different key values) based on the current locale and keyboard layout. # ---------------------------------------------------------------------- Backquote = auto() # `~ on a US keyboard. This is the ๅŠ่ง’/ๅ…จ่ง’/ๆผขๅญ— (hankaku/zenkaku/kanji) key on Japanese keyboards Backslash = auto() # Used for both the US \| (on the 101-key layout) and also for the key located between the " and Enter keys on row C of the 102-, 104- and 106-key layouts. Labelled #~ on a UK (102) keyboard. BracketLeft = auto() # [{ on a US keyboard. BracketRight = auto() # ]} on a US keyboard. Comma = auto() # ,< on a US keyboard. Digit0 = auto() # 0) on a US keyboard. Digit1 = auto() # 1! on a US keyboard. Digit2 = auto() # 2@ on a US keyboard. Digit3 = auto() # 3# on a US keyboard. Digit4 = auto() # 4$ on a US keyboard. Digit5 = auto() # 5% on a US keyboard. Digit6 = auto() # 6^ on a US keyboard. Digit7 = auto() # 7& on a US keyboard. Digit8 = auto() # 8* on a US keyboard. Digit9 = auto() # 9( on a US keyboard. Equal = auto() # =+ on a US keyboard. IntlBackslash = auto() # Located between the left Shift and Z keys. Labelled \| on a UK keyboard. IntlRo = auto() # Located between the / and right Shift keys. Labelled \ใ‚ (ro) on a Japanese keyboard. IntlYen = auto() # Located between the = and Backspace keys. Labelled ยฅ (yen) on a Japanese keyboard. \/ on a Russian keyboard. KeyA = auto() # a on a US keyboard. Labelled q on an AZERTY (e.g., French) keyboard. KeyB = auto() # b on a US keyboard. KeyC = auto() # c on a US keyboard. KeyD = auto() # d on a US keyboard. KeyE = auto() # e on a US keyboard. KeyF = auto() # f on a US keyboard. KeyG = auto() # g on a US keyboard. KeyH = auto() # h on a US keyboard. KeyI = auto() # i on a US keyboard. KeyJ = auto() # j on a US keyboard. KeyK = auto() # k on a US keyboard. KeyL = auto() # l on a US keyboard. KeyM = auto() # m on a US keyboard. KeyN = auto() # n on a US keyboard. KeyO = auto() # o on a US keyboard. KeyP = auto() # p on a US keyboard. KeyQ = auto() # q on a US keyboard. Labelled a on an AZERTY (e.g., French) keyboard. KeyR = auto() # r on a US keyboard. KeyS = auto() # s on a US keyboard. KeyT = auto() # t on a US keyboard. KeyU = auto() # u on a US keyboard. KeyV = auto() # v on a US keyboard. KeyW = auto() # w on a US keyboard. Labelled z on an AZERTY (e.g., French) keyboard. KeyX = auto() # x on a US keyboard. KeyY = auto() # y on a US keyboard. Labelled z on a QWERTZ (e.g., German) keyboard. KeyZ = auto() # z on a US keyboard. Labelled w on an AZERTY (e.g., French) keyboard, and y on a QWERTZ (e.g., German) keyboard. Minus = auto() # -_ on a US keyboard. Period = auto() # .> on a US keyboard. Quote = auto() # '" on a US keyboard. Semicolon = auto() # ;: on a US keyboard. Slash = auto() # /? on a US keyboard. # ------------------- Functional Keys -------------------------------- # https://w3c.github.io/uievents-code/#key-alphanumeric-functional # The functional keys (not to be confused with the function keys described later) # are those keys in the alphanumeric section that provide general editing functions # that are common to all locales (like Shift, Tab, Enter and Backspace). # With a few exceptions, these keys do not change meaning based on the current # keyboard layout. # ------------------------------------------------------------------------ AltLeft = auto() # Alt, Option or โŒฅ. AltRight = auto() # Alt, Option or โŒฅ. This is labelled AltGr key on many keyboard layouts. Backspace = auto() # Backspace or โŒซ. Labelled Delete on Apple keyboards. CapsLock = auto() # CapsLock or โ‡ช ContextMenu = auto() # The application context menu key, which is typically found between the right Meta key and the right Control key. ControlLeft = auto() # Control or โŒƒ ControlRight = auto() # Control or โŒƒ Enter = auto() # Enter or โ†ต. Labelled Return on Apple keyboards. MetaLeft = auto() # The Windows, โŒ˜, Command or other OS symbol key. MetaRight = auto() # The Windows, โŒ˜, Command or other OS symbol key. ShiftLeft = auto() # Shift or โ‡ง ShiftRight = auto() # Shift or โ‡ง Space = auto() # (space) Tab = auto() # Tab or โ‡ฅ # Japanese and Korean keyboards. Convert = auto() # Japanese: ๅค‰ๆ› (henkan) KanaMode = auto() # Japanese: ใ‚ซใ‚ฟใ‚ซใƒŠ/ใฒใ‚‰ใŒใช/ใƒญใƒผใƒžๅญ— (katakana/hiragana/romaji) NonConvert = auto() # Japanese: ็„กๅค‰ๆ› (muhenkan) # Lang1 = auto() # Korean: HangulMode ํ•œ/์˜ (han/yeong) Japanese (Mac keyboard): ใ‹ใช (kana) # Lang2 = auto() # Korean: Hanja ํ•œ์ž (hanja) Japanese (Mac keyboard): ่‹ฑๆ•ฐ (eisu) # Lang3 = auto() # Japanese (word-processing keyboard): Katakana # Lang4 = auto() # Japanese (word-processing keyboard): Hiragana # Lang5 = auto() # Japanese (word-processing keyboard): Zenkaku/Hankaku # ---------------------- Control Pad -------------------------------- # https://w3c.github.io/uievents-code/#key-controlpad-section # The control pad section of the keyboard is the set of (usually 6) keys that # perform navigating and editing operations, for example, Home, PageUp and Insert. # ------------------------------------------------------------------------ Delete = auto() # โŒฆ. The forward delete key. Note that on Apple keyboards, the key labelled Delete on the main part of the keyboard should be encoded as "Backspace". End = auto() # Page Down, End or โ†˜ Help = auto() # Help. Not present on standard PC keyboards. Home = auto() # Home or โ†– Insert = auto() # Insert or Ins. Not present on Apple keyboards. PageDown = auto() # Page Down, PgDn or โ‡Ÿ PageUp = auto() # Page Up, PgUp or โ‡ž # ----------------------- Arrow Pad ---------------------------------- # https://w3c.github.io/uievents-code/#key-arrowpad-section # The arrow pad contains the 4 arrow keys. The keys are commonly arranged in an # "upside-down T" configuration. # ------------------------------------------------------------------------ ArrowDown = auto() # โ†“ ArrowLeft = auto() # โ† ArrowRight = auto() # โ†’ ArrowUp = auto() # โ†‘ # ----------------------- Numpad Section ----------------------------- # https://w3c.github.io/uievents-code/#key-numpad-section # The numpad section is the set of keys on the keyboard arranged in a grid like a # calculator or mobile phone. This section contains numeric and mathematical # operator keys. Laptop computers and compact keyboards will commonly omit # these keys to save space. # ------------------------------------------------------------------------ NumLock = auto() # On the Mac, the "NumLock" code should be used for the numpad Clear key. Numpad0 = auto() # 0 Ins on a keyboard 0 on a phone or remote control Numpad1 = auto() # 1 End on a keyboard 1 or 1 QZ on a phone or remote control Numpad2 = auto() # 2 โ†“ on a keyboard 2 ABC on a phone or remote control Numpad3 = auto() # 3 PgDn on a keyboard 3 DEF on a phone or remote control Numpad4 = auto() # 4 โ† on a keyboard 4 GHI on a phone or remote control Numpad5 = auto() # 5 on a keyboard 5 JKL on a phone or remote control Numpad6 = auto() # 6 โ†’ on a keyboard 6 MNO on a phone or remote control Numpad7 = auto() # 7 Home on a keyboard 7 PQRS or 7 PRS on a phone or remote control Numpad8 = auto() # 8 โ†‘ on a keyboard 8 TUV on a phone or remote control Numpad9 = auto() # 9 PgUp on a keyboard 9 WXYZ or 9 WXY on a phone or remote control NumpadAdd = auto() # + NumpadDecimal = auto() # . Del. For locales where the decimal separator is "," (e.g., Brazil), this key may generate a ,. NumpadDivide = auto() # / NumpadEnter = auto() # NumpadMultiply = auto() # * on a keyboard. For use with numpads that provide mathematical operations (+, -, * and /). Use "NumpadStar" for the * key on phones and remote controls. NumpadSubtract = auto() # - NumpadEqual = auto() # = NOTE: not required to conform to spec. # NumpadBackspace = auto() # Found on the Microsoft Natural Keyboard. # NumpadClear = auto() # C or AC (All Clear). Also for use with numpads that have a Clear key that is separate from the NumLock key. On the Mac, the numpad Clear key should always be encoded as "NumLock". # NumpadClearEntry = auto() # CE (Clear Entry) # NumpadComma = auto() # , (thousands separator). For locales where the thousands separator is a "." (e.g., Brazil), this key may generate a .. # NumpadHash = auto() # # on a phone or remote control device. This key is typically found below the 9 key and to the right of the 0 key. # NumpadMemoryAdd = auto() # M+ Add current entry to the value stored in memory. # NumpadMemoryClear = auto() # MC Clear the value stored in memory. # NumpadMemoryRecall = auto() # MR Replace the current entry with the value stored in memory. # NumpadMemoryStore = auto() # MS Replace the value stored in memory with the current entry. # NumpadMemorySubtract = auto() # M- Subtract current entry from the value stored in memory. # NumpadParenLeft = auto() # ( Found on the Microsoft Natural Keyboard. # NumpadParenRight = auto() # ) Found on the Microsoft Natural Keyboard. # NumpadStar = auto() # * on a phone or remote control device. This key is typically found below the 7 key and to the left of the 0 key. Use "NumpadMultiply" for the * key on numeric keypads. # --------------------- Function Section ----------------------------- # https://w3c.github.io/uievents-code/#key-function-section # The function section runs along the top of the keyboard (above the alphanumeric # section) and contains the function keys and a few additional special keys # (for example, Esc and Print Screen). A function key is any of the keys labelled # F1 ... F12 that an application or operating system can associate with a # custom function or action. # ------------------------------------------------------------------------ Escape = auto() # Esc or โŽ‹ F1 = auto() # F1 F2 = auto() # F2 F3 = auto() # F3 F4 = auto() # F4 F5 = auto() # F5 F6 = auto() # F6 F7 = auto() # F7 F8 = auto() # F8 F9 = auto() # F9 F10 = auto() # F10 F11 = auto() # F11 F12 = auto() # F12 PrintScreen = auto() # PrtScr SysRq or Print Screen ScrollLock = auto() # Scroll Lock Pause = auto() # Pause Break # Fn = auto() # Fn This is typically a hardware key that does not generate a separate code. Most keyboards do not place this key in the function section, but it is included here to keep it with related keys. # FnLock = auto() # FLock or FnLock. Function Lock key. Found on the Microsoft Natural Keyboard. # --------------------- Media Keys ---------------------------- # https://w3c.github.io/uievents-code/#key-media # none of these are required to conform to the spec, and are omitted for now # ------------ Legacy, Non-Standard and Special Keys -------------- # https://w3c.github.io/uievents-code/#key-legacy # none of these are required to conform to the spec, and are omitted for now def __str__(self) -> str: return scancode_to_string(self) @classmethod def from_string(cls, string: str) -> 'ScanCode': """Return the KeyCode associated with the given string. Returns ScanCode.UNIDENTIFIED if no match is found. """ return scancode_from_string(string) _EVENTCODE_TO_KEYCODE: Dict[int, KeyCode] = {} _NATIVE_WINDOWS_VK_TO_KEYCODE: Dict[str, KeyCode] = {} # build in a closure to prevent modification and declutter namespace def _build_maps() -> Tuple[ Callable[[KeyCode], str], Callable[[str], KeyCode], Callable[[KeyCode, OperatingSystem], str], Callable[[KeyCode, OperatingSystem], str], Callable[[ScanCode], str], Callable[[str], ScanCode], ]: class _KM(NamedTuple): scancode: ScanCode scanstr: str keycode: KeyCode keystr: str eventcode: int virtual_key: str _ = '' _MAPPINGS = [ _KM(ScanCode.UNIDENTIFIED, 'None', KeyCode.UNKNOWN, 'unknown', 0, 'VK_UNKNOWN'), _KM(ScanCode.KeyA, 'KeyA', KeyCode.KeyA, 'A', 65, 'VK_A'), _KM(ScanCode.KeyB, 'KeyB', KeyCode.KeyB, 'B', 66, 'VK_B'), _KM(ScanCode.KeyC, 'KeyC', KeyCode.KeyC, 'C', 67, 'VK_C'), _KM(ScanCode.KeyD, 'KeyD', KeyCode.KeyD, 'D', 68, 'VK_D'), _KM(ScanCode.KeyE, 'KeyE', KeyCode.KeyE, 'E', 69, 'VK_E'), _KM(ScanCode.KeyF, 'KeyF', KeyCode.KeyF, 'F', 70, 'VK_F'), _KM(ScanCode.KeyG, 'KeyG', KeyCode.KeyG, 'G', 71, 'VK_G'), _KM(ScanCode.KeyH, 'KeyH', KeyCode.KeyH, 'H', 72, 'VK_H'), _KM(ScanCode.KeyI, 'KeyI', KeyCode.KeyI, 'I', 73, 'VK_I'), _KM(ScanCode.KeyJ, 'KeyJ', KeyCode.KeyJ, 'J', 74, 'VK_J'), _KM(ScanCode.KeyK, 'KeyK', KeyCode.KeyK, 'K', 75, 'VK_K'), _KM(ScanCode.KeyL, 'KeyL', KeyCode.KeyL, 'L', 76, 'VK_L'), _KM(ScanCode.KeyM, 'KeyM', KeyCode.KeyM, 'M', 77, 'VK_M'), _KM(ScanCode.KeyN, 'KeyN', KeyCode.KeyN, 'N', 78, 'VK_N'), _KM(ScanCode.KeyO, 'KeyO', KeyCode.KeyO, 'O', 79, 'VK_O'), _KM(ScanCode.KeyP, 'KeyP', KeyCode.KeyP, 'P', 80, 'VK_P'), _KM(ScanCode.KeyQ, 'KeyQ', KeyCode.KeyQ, 'Q', 81, 'VK_Q'), _KM(ScanCode.KeyR, 'KeyR', KeyCode.KeyR, 'R', 82, 'VK_R'), _KM(ScanCode.KeyS, 'KeyS', KeyCode.KeyS, 'S', 83, 'VK_S'), _KM(ScanCode.KeyT, 'KeyT', KeyCode.KeyT, 'T', 84, 'VK_T'), _KM(ScanCode.KeyU, 'KeyU', KeyCode.KeyU, 'U', 85, 'VK_U'), _KM(ScanCode.KeyV, 'KeyV', KeyCode.KeyV, 'V', 86, 'VK_V'), _KM(ScanCode.KeyW, 'KeyW', KeyCode.KeyW, 'W', 87, 'VK_W'), _KM(ScanCode.KeyX, 'KeyX', KeyCode.KeyX, 'X', 88, 'VK_X'), _KM(ScanCode.KeyY, 'KeyY', KeyCode.KeyY, 'Y', 89, 'VK_Y'), _KM(ScanCode.KeyZ, 'KeyZ', KeyCode.KeyZ, 'Z', 90, 'VK_Z'), _KM(ScanCode.Digit1, 'Digit1', KeyCode.Digit1, '1', 49, 'VK_1'), _KM(ScanCode.Digit2, 'Digit2', KeyCode.Digit2, '2', 50, 'VK_2'), _KM(ScanCode.Digit3, 'Digit3', KeyCode.Digit3, '3', 51, 'VK_3'), _KM(ScanCode.Digit4, 'Digit4', KeyCode.Digit4, '4', 52, 'VK_4'), _KM(ScanCode.Digit5, 'Digit5', KeyCode.Digit5, '5', 53, 'VK_5'), _KM(ScanCode.Digit6, 'Digit6', KeyCode.Digit6, '6', 54, 'VK_6'), _KM(ScanCode.Digit7, 'Digit7', KeyCode.Digit7, '7', 55, 'VK_7'), _KM(ScanCode.Digit8, 'Digit8', KeyCode.Digit8, '8', 56, 'VK_8'), _KM(ScanCode.Digit9, 'Digit9', KeyCode.Digit9, '9', 57, 'VK_9'), _KM(ScanCode.Digit0, 'Digit0', KeyCode.Digit0, '0', 48, 'VK_0'), _KM(ScanCode.Enter, 'Enter', KeyCode.Enter, 'Enter', 13, 'VK_RETURN'), _KM(ScanCode.Escape, 'Escape', KeyCode.Escape, 'Escape', 27, 'VK_ESCAPE'), _KM(ScanCode.Backspace, 'Backspace', KeyCode.Backspace, 'Backspace', 8, 'VK_BACK'), _KM(ScanCode.Tab, 'Tab', KeyCode.Tab, 'Tab', 9, 'VK_TAB'), _KM(ScanCode.Space, 'Space', KeyCode.Space, 'Space', 32, 'VK_SPACE'), _KM(ScanCode.Minus, 'Minus', KeyCode.Minus, '-', 189, 'VK_OEM_MINUS'), _KM(ScanCode.Equal, 'Equal', KeyCode.Equal, '=', 187, 'VK_OEM_PLUS'), _KM(ScanCode.BracketLeft, 'BracketLeft', KeyCode.BracketLeft, '[', 219, 'VK_OEM_4'), _KM(ScanCode.BracketRight, 'BracketRight', KeyCode.BracketRight, ']', 221, 'VK_OEM_6'), _KM(ScanCode.Backslash, 'Backslash', KeyCode.Backslash, '\\', 220, 'VK_OEM_5'), _KM(ScanCode.Semicolon, 'Semicolon', KeyCode.Semicolon, ';', 186, 'VK_OEM_1'), _KM(ScanCode.Quote, 'Quote', KeyCode.Quote, "'", 222, 'VK_OEM_7'), _KM(ScanCode.Backquote, 'Backquote', KeyCode.Backquote, '`', 192, 'VK_OEM_3'), _KM(ScanCode.Comma, 'Comma', KeyCode.Comma, ',', 188, 'VK_OEM_COMMA'), _KM(ScanCode.Period, 'Period', KeyCode.Period, '.', 190, 'VK_OEM_PERIOD'), _KM(ScanCode.Slash, 'Slash', KeyCode.Slash, '/', 191, 'VK_OEM_2'), _KM(ScanCode.CapsLock, 'CapsLock', KeyCode.CapsLock, 'CapsLock', 20, 'VK_CAPITAL'), _KM(ScanCode.F1, 'F1', KeyCode.F1, 'F1', 112, 'VK_F1'), _KM(ScanCode.F2, 'F2', KeyCode.F2, 'F2', 113, 'VK_F2'), _KM(ScanCode.F3, 'F3', KeyCode.F3, 'F3', 114, 'VK_F3'), _KM(ScanCode.F4, 'F4', KeyCode.F4, 'F4', 115, 'VK_F4'), _KM(ScanCode.F5, 'F5', KeyCode.F5, 'F5', 116, 'VK_F5'), _KM(ScanCode.F6, 'F6', KeyCode.F6, 'F6', 117, 'VK_F6'), _KM(ScanCode.F7, 'F7', KeyCode.F7, 'F7', 118, 'VK_F7'), _KM(ScanCode.F8, 'F8', KeyCode.F8, 'F8', 119, 'VK_F8'), _KM(ScanCode.F9, 'F9', KeyCode.F9, 'F9', 120, 'VK_F9'), _KM(ScanCode.F10, 'F10', KeyCode.F10, 'F10', 121, 'VK_F10'), _KM(ScanCode.F11, 'F11', KeyCode.F11, 'F11', 122, 'VK_F11'), _KM(ScanCode.F12, 'F12', KeyCode.F12, 'F12', 123, 'VK_F12'), _KM(ScanCode.PrintScreen, 'PrintScreen', KeyCode.PrintScreen, "PrintScreen", 42, "VK_PRINT"), _KM(ScanCode.ScrollLock, 'ScrollLock', KeyCode.ScrollLock, 'ScrollLock', 145, 'VK_SCROLL'), _KM(ScanCode.Pause, 'Pause', KeyCode.PauseBreak, 'PauseBreak', 19, 'VK_PAUSE'), _KM(ScanCode.Insert, 'Insert', KeyCode.Insert, 'Insert', 45, 'VK_INSERT'), _KM(ScanCode.Home, 'Home', KeyCode.Home, 'Home', 36, 'VK_HOME'), _KM(ScanCode.PageUp, 'PageUp', KeyCode.PageUp, 'PageUp', 33, 'VK_PRIOR'), _KM(ScanCode.Delete, 'Delete', KeyCode.Delete, 'Delete', 46, 'VK_DELETE'), _KM(ScanCode.End, 'End', KeyCode.End, 'End', 35, 'VK_END'), _KM(ScanCode.PageDown, 'PageDown', KeyCode.PageDown, 'PageDown', 34, 'VK_NEXT'), _KM(ScanCode.ArrowRight, 'ArrowRight', KeyCode.RightArrow, 'Right', 39, 'VK_RIGHT'), _KM(ScanCode.ArrowLeft, 'ArrowLeft', KeyCode.LeftArrow, 'Left', 37, 'VK_LEFT'), _KM(ScanCode.ArrowDown, 'ArrowDown', KeyCode.DownArrow, 'Down', 40, 'VK_DOWN'), _KM(ScanCode.ArrowUp, 'ArrowUp', KeyCode.UpArrow, 'Up', 38, 'VK_UP'), _KM(ScanCode.NumLock, 'NumLock', KeyCode.NumLock, 'NumLock', 144, 'VK_NUMLOCK'), _KM(ScanCode.NumpadDivide, 'NumpadDivide', KeyCode.NumpadDivide, 'NumPad_Divide', 111, 'VK_DIVIDE'), _KM(ScanCode.NumpadMultiply, 'NumpadMultiply', KeyCode.NumpadMultiply, 'NumPad_Multiply', 106, 'VK_MULTIPLY'), _KM(ScanCode.NumpadSubtract, 'NumpadSubtract', KeyCode.NumpadSubtract, 'NumPad_Subtract', 109, 'VK_SUBTRACT'), _KM(ScanCode.NumpadAdd, 'NumpadAdd', KeyCode.NumpadAdd, 'NumPad_Add', 107, 'VK_ADD'), _KM(ScanCode.NumpadEnter, 'NumpadEnter', KeyCode.Enter, _, 0, _), _KM(ScanCode.Numpad1, 'Numpad1', KeyCode.Numpad1, 'NumPad1', 97, 'VK_NUMPAD1'), _KM(ScanCode.Numpad2, 'Numpad2', KeyCode.Numpad2, 'NumPad2', 98, 'VK_NUMPAD2'), _KM(ScanCode.Numpad3, 'Numpad3', KeyCode.Numpad3, 'NumPad3', 99, 'VK_NUMPAD3'), _KM(ScanCode.Numpad4, 'Numpad4', KeyCode.Numpad4, 'NumPad4', 100, 'VK_NUMPAD4'), _KM(ScanCode.Numpad5, 'Numpad5', KeyCode.Numpad5, 'NumPad5', 101, 'VK_NUMPAD5'), _KM(ScanCode.Numpad6, 'Numpad6', KeyCode.Numpad6, 'NumPad6', 102, 'VK_NUMPAD6'), _KM(ScanCode.Numpad7, 'Numpad7', KeyCode.Numpad7, 'NumPad7', 103, 'VK_NUMPAD7'), _KM(ScanCode.Numpad8, 'Numpad8', KeyCode.Numpad8, 'NumPad8', 104, 'VK_NUMPAD8'), _KM(ScanCode.Numpad9, 'Numpad9', KeyCode.Numpad9, 'NumPad9', 105, 'VK_NUMPAD9'), _KM(ScanCode.Numpad0, 'Numpad0', KeyCode.Numpad0, 'NumPad0', 96, 'VK_NUMPAD0'), _KM(ScanCode.NumpadDecimal, 'NumpadDecimal', KeyCode.NumpadDecimal, 'NumPad_Decimal', 110, 'VK_DECIMAL'), _KM(ScanCode.IntlBackslash, 'IntlBackslash', KeyCode.IntlBackslash, 'OEM_102', 226, 'VK_OEM_102'), _KM(ScanCode.ContextMenu, 'ContextMenu', KeyCode.ContextMenu, 'ContextMenu', 93, _), _KM(ScanCode.NumpadEqual, 'NumpadEqual', KeyCode.UNKNOWN, _, 0, _), _KM(ScanCode.Help, 'Help', KeyCode.UNKNOWN, _, 0, _), _KM(ScanCode.IntlRo, 'IntlRo', KeyCode.UNKNOWN, _, 193, 'VK_ABNT_C1'), _KM(ScanCode.KanaMode, 'KanaMode', KeyCode.UNKNOWN, _, 0, _), _KM(ScanCode.IntlYen, 'IntlYen', KeyCode.UNKNOWN, _, 0, _), _KM(ScanCode.Convert, 'Convert', KeyCode.UNKNOWN, _, 0, _), _KM(ScanCode.NonConvert, 'NonConvert', KeyCode.UNKNOWN, _, 0, _), _KM(ScanCode.UNIDENTIFIED, _, KeyCode.Ctrl, 'Ctrl', 17, 'VK_CONTROL'), _KM(ScanCode.UNIDENTIFIED, _, KeyCode.Shift, 'Shift', 16, 'VK_SHIFT'), _KM(ScanCode.UNIDENTIFIED, _, KeyCode.Alt, 'Alt', 18, 'VK_MENU'), _KM(ScanCode.UNIDENTIFIED, _, KeyCode.Meta, 'Meta', 0, 'VK_COMMAND'), _KM(ScanCode.ControlLeft, 'ControlLeft', KeyCode.Ctrl, _, 0, 'VK_LCONTROL'), _KM(ScanCode.ShiftLeft, 'ShiftLeft', KeyCode.Shift, _, 0, 'VK_LSHIFT'), _KM(ScanCode.AltLeft, 'AltLeft', KeyCode.Alt, _, 0, 'VK_LMENU'), _KM(ScanCode.MetaLeft, 'MetaLeft', KeyCode.Meta, _, 0, 'VK_LWIN'), _KM(ScanCode.ControlRight, 'ControlRight', KeyCode.Ctrl, _, 0, 'VK_RCONTROL'), _KM(ScanCode.ShiftRight, 'ShiftRight', KeyCode.Shift, _, 0, 'VK_RSHIFT'), _KM(ScanCode.AltRight, 'AltRight', KeyCode.Alt, _, 0, 'VK_RMENU'), _KM(ScanCode.MetaRight, 'MetaRight', KeyCode.Meta, _, 0, 'VK_RWIN'), ] SCANCODE_TO_STRING: Dict[ScanCode, str] = {} SCANCODE_FROM_LOWERCASE_STRING: Dict[str, ScanCode] = {} KEYCODE_TO_STRING: Dict[KeyCode, str] = {} KEYCODE_FROM_LOWERCASE_STRING: Dict[str, KeyCode] = { # two special cases for assigning os-specific strings to the meta key 'win': KeyCode.Meta, 'cmd': KeyCode.Meta, } # key symbols on all platforms KEY_SYMBOLS: dict[KeyCode, str] = { KeyCode.Shift: "โ‡ง", KeyCode.LeftArrow: "โ†", KeyCode.RightArrow: "โ†’", KeyCode.UpArrow: "โ†‘", KeyCode.DownArrow: "โ†“", KeyCode.Backspace: "โŒซ", KeyCode.Delete: "โŒฆ", KeyCode.Tab: "โ‡ฅ", KeyCode.Escape: "โŽ‹", KeyCode.Enter: "โ†ต", KeyCode.Space: "โฃ", KeyCode.CapsLock: "โ‡ช", } # key symbols mappings per platform OS_KEY_SYMBOLS: dict[OperatingSystem, dict[KeyCode, str]] = { OperatingSystem.WINDOWS: {**KEY_SYMBOLS, KeyCode.Meta: "โŠž"}, OperatingSystem.LINUX: {**KEY_SYMBOLS, KeyCode.Meta: "Super"}, OperatingSystem.MACOS: { **KEY_SYMBOLS, KeyCode.Ctrl: "โŒƒ", KeyCode.Alt: "โŒฅ", KeyCode.Meta: "โŒ˜", }, } # key names mappings per platform OS_KEY_NAMES: dict[OperatingSystem, dict[KeyCode, str]] = { OperatingSystem.WINDOWS: {KeyCode.Meta: "Win"}, OperatingSystem.LINUX: {KeyCode.Meta: "Super"}, OperatingSystem.MACOS: { KeyCode.Ctrl: "Control", KeyCode.Alt: "Option", KeyCode.Meta: "Cmd", }, } seen_scancodes: Set[ScanCode] = set() seen_keycodes: Set[KeyCode] = set() for i, km in enumerate(_MAPPINGS): if km.scancode not in seen_scancodes: seen_scancodes.add(km.scancode) SCANCODE_TO_STRING[km.scancode] = km.scanstr SCANCODE_FROM_LOWERCASE_STRING[km.scanstr.lower()] = km.scancode if km.keycode not in seen_keycodes: seen_keycodes.add(km.keycode) if not km.keystr: # pragma: no cover raise ValueError( f"String representation missing for key code {km.keycode!r} " f"around scan code {km.scancode!r} at line {i + 1}" ) KEYCODE_TO_STRING[km.keycode] = km.keystr KEYCODE_FROM_LOWERCASE_STRING[km.keystr.lower()] = km.keycode if km.eventcode: _EVENTCODE_TO_KEYCODE[km.eventcode] = km.keycode if km.virtual_key: _NATIVE_WINDOWS_VK_TO_KEYCODE[km.virtual_key] = km.keycode def _keycode_to_string(keycode: KeyCode) -> str: """Return the string representation of a KeyCode.""" # sourcery skip return KEYCODE_TO_STRING.get(keycode, "") def _keycode_from_string(keystr: str) -> KeyCode: """Return KeyCode for a given string.""" # sourcery skip return KEYCODE_FROM_LOWERCASE_STRING.get(str(keystr).lower(), KeyCode.UNKNOWN) def _keycode_to_os_symbol(keycode: KeyCode, os: OperatingSystem) -> str: """Return key symbol for an OS for a given KeyCode.""" if keycode in (symbols := OS_KEY_SYMBOLS.get(os, {})): return symbols[keycode] return str(keycode) def _keycode_to_os_name(keycode: KeyCode, os: OperatingSystem) -> str: """Return key name for an OS for a given KeyCode.""" if keycode in (names := OS_KEY_NAMES.get(os, {})): return names[keycode] return str(keycode) def _scancode_to_string(scancode: ScanCode) -> str: """Return the string representation of a ScanCode.""" # sourcery skip return SCANCODE_TO_STRING.get(scancode, "") def _scancode_from_string(scanstr: str) -> ScanCode: """Return ScanCode for a given string.""" # sourcery skip return SCANCODE_FROM_LOWERCASE_STRING.get( str(scanstr).lower(), ScanCode.UNIDENTIFIED ) return ( _keycode_to_string, _keycode_from_string, _keycode_to_os_symbol, _keycode_to_os_name, _scancode_to_string, _scancode_from_string, ) ( keycode_to_string, keycode_from_string, keycode_to_os_symbol, keycode_to_os_name, scancode_to_string, scancode_from_string, ) = _build_maps() # fmt: on # Keys with modifiers are expressed # with a 16-bit binary encoding # # 1111 11 # 5432 1098 7654 3210 # ---- CSAW KKKK KKKK # C = bit 11 -> ctrlCmd flag # S = bit 10 -> shift flag # A = bit 9 -> alt flag # W = bit 8 -> winCtrl flag # K = bits 0-7 -> key code class KeyMod(IntFlag): """A Flag indicating keyboard modifiers.""" NONE = 0 CtrlCmd = 1 << 11 # command on a mac, control on windows Shift = 1 << 10 # shift key Alt = 1 << 9 # alt option WinCtrl = 1 << 8 # meta key on windows, ctrl key on mac @overload # type: ignore def __or__(self, other: "KeyMod") -> "KeyMod": ... @overload def __or__(self, other: KeyCode) -> "KeyCombo": ... @overload def __or__(self, other: int) -> int: ... def __or__( # pyright: ignore[reportIncompatibleMethodOverride] self, other: Union["KeyMod", KeyCode, int] ) -> Union["KeyMod", "KeyCombo", int]: if isinstance(other, self.__class__): return self.__class__(self._value_ | other._value_) if isinstance(other, KeyCode): return KeyCombo(self, other) return NotImplemented # pragma: no cover class KeyCombo(int): """KeyCombo is an integer combination of one or more. [`KeyMod`][app_model.types.KeyMod] and [`KeyCode`][app_model.types.KeyCode]. """ def __new__( cls: Type["KeyCombo"], modifiers: KeyMod, key: KeyCode = KeyCode.UNKNOWN ) -> "KeyCombo": return super().__new__(cls, int(modifiers) | int(key)) def __init__(self, modifiers: KeyMod, key: KeyCode = KeyCode.UNKNOWN): self._modifiers = modifiers self._key = key def __repr__(self) -> str: name = self.__class__.__name__ mods_repr = repr(self._modifiers).split(":", 1)[0].split(".", 1)[1] return f"<{name}.{mods_repr}|{self._key.name}: {int(self)}>" class KeyChord(int): """KeyChord is an integer combination of two key combos. It could be two [`KeyCombo`][app_model.types.KeyCombo] [`KeyCode`][app_model.types.KeyCode], or [int][]. Parameters ---------- first_part : KeyCombo | int The first part of the chord. second_part : KeyCombo | int The second part of the chord. """ def __new__(cls: Type["KeyChord"], first_part: int, second_part: int) -> "KeyChord": # shift the second part 16 bits to the left chord_part = (second_part & 0x0000FFFF) << 16 # then combine then to make the full chord return super().__new__(cls, first_part | chord_part) def __init__(self, first_part: int, second_part: int): self._first_part = first_part self._second_part = second_part def __repr__(self) -> str: return f"KeyChord({self._first_part!r}, {self._second_part!r})" app-model-0.5.1/src/app_model/types/_keys/_keybindings.py000066400000000000000000000267101510416065100234240ustar00rootroot00000000000000import re from typing import TYPE_CHECKING, Any, Optional from pydantic import BaseModel, Field, model_validator from app_model.types._constants import OperatingSystem from ._key_codes import KeyChord, KeyCode, KeyMod if TYPE_CHECKING: from pydantic.annotated_handlers import GetCoreSchemaHandler from pydantic_core import core_schema class SimpleKeyBinding(BaseModel): """Represent a simple combination modifier(s) and a key, e.g. Ctrl+A.""" ctrl: bool = Field(False, description='Whether the "Ctrl" modifier is active.') shift: bool = Field(False, description='Whether the "Shift" modifier is active.') alt: bool = Field(False, description='Whether the "Alt" modifier is active.') meta: bool = Field(False, description='Whether the "Meta" modifier is active.') key: Optional[KeyCode] = Field( None, description="The key that is pressed (e.g. `KeyCode.A`)" ) # def hash_code(self) -> str: # used by vscode for caching during keybinding resolution def is_modifier_key(self) -> bool: """Return true if this is a modifier key.""" return self.key in ( KeyCode.Alt, KeyCode.Shift, KeyCode.Ctrl, KeyCode.Meta, KeyCode.UNKNOWN, ) def __str__(self) -> str: """Get a normalized string representation (constant to all OSes) of this SimpleKeyBinding.""" out = "" if self.ctrl: out += "Ctrl+" if self.shift: out += "Shift+" if self.alt: out += "Alt+" if self.meta: out += "Meta+" if self.key: out += str(self.key) return out def __eq__(self, other: Any) -> bool: # sourcery skip: remove-unnecessary-cast if not isinstance(other, SimpleKeyBinding): try: if (other := SimpleKeyBinding._parse_input(other)) is None: return NotImplemented except TypeError: # pragma: no cover # can happen with pydantic v2 return NotImplemented return bool( self.ctrl == other.ctrl and self.shift == other.shift and self.alt == other.alt and self.meta == other.meta and self.key == other.key ) @classmethod def from_str(cls, key_str: str) -> "SimpleKeyBinding": """Parse a string into a SimpleKeyBinding.""" mods, remainder = _parse_modifiers(key_str.strip()) key = KeyCode.from_string(remainder) return cls(**mods, key=key) @classmethod def from_int( cls, key_int: int, os: Optional[OperatingSystem] = None ) -> "SimpleKeyBinding": """Create a SimpleKeyBinding from an integer.""" ctrl_cmd = bool(key_int & KeyMod.CtrlCmd) win_ctrl = bool(key_int & KeyMod.WinCtrl) shift = bool(key_int & KeyMod.Shift) alt = bool(key_int & KeyMod.Alt) os = OperatingSystem.current() if os is None else os ctrl = win_ctrl if os.is_mac else ctrl_cmd meta = ctrl_cmd if os.is_mac else win_ctrl key = key_int & 0x000000FF # keycode mask return cls(ctrl=ctrl, shift=shift, alt=alt, meta=meta, key=key) def __int__(self) -> int: return int(self.to_int()) def __hash__(self) -> int: return hash((self.ctrl, self.shift, self.alt, self.meta, self.key)) def to_int(self, os: Optional[OperatingSystem] = None) -> int: """Convert this SimpleKeyBinding to an integer representation.""" os = OperatingSystem.current() if os is None else os mods: KeyMod = KeyMod.NONE if self.ctrl: mods |= KeyMod.WinCtrl if os.is_mac else KeyMod.CtrlCmd if self.shift: mods |= KeyMod.Shift if self.alt: mods |= KeyMod.Alt if self.meta: mods |= KeyMod.CtrlCmd if os.is_mac else KeyMod.WinCtrl return mods | (self.key or 0) def _mods2keycodes(self) -> list[KeyCode]: """Create KeyCode instances list of modifiers from this SimpleKeyBinding.""" mods = [] if self.ctrl: mods.append(KeyCode.Ctrl) if self.shift: mods.append(KeyCode.Shift) if self.alt: mods.append(KeyCode.Alt) if self.meta: mods.append(KeyCode.Meta) return mods def to_text( self, os: Optional[OperatingSystem] = None, use_symbols: bool = False, joinchar: str = "+", ) -> str: """Get a user-facing string representation of this SimpleKeyBinding. Optionally, the string representation can be constructed with symbols like โ†ต for Enter or OS specific ones like โŒ˜ for Meta on MacOS. If no symbols should be used, the string representation will use the OS specific names for the keys like `Cmd` for Meta or `Option` for Ctrl on MacOS. Also, a join character can be defined. By default `+` is used. """ os = OperatingSystem.current() if os is None else os keybinding_elements = [*self._mods2keycodes()] if self.key: keybinding_elements.append(self.key) return joinchar.join( kbe.os_symbol(os=os) if use_symbols else kbe.os_name(os=os) for kbe in keybinding_elements ) @classmethod def _parse_input(cls, v: Any) -> "SimpleKeyBinding": if isinstance(v, SimpleKeyBinding): return v if isinstance(v, str): return cls.from_str(v) if isinstance(v, int): return cls.from_int(v) raise TypeError(f"invalid type: {type(v)}") @model_validator(mode="before") @classmethod def _model_val(cls, val: "SimpleKeyBinding") -> "SimpleKeyBinding": if not isinstance(val, (SimpleKeyBinding, dict)): return cls._parse_input(val) return val MIN1 = {"min_length": 1} class KeyBinding: """KeyBinding. May be a multi-part "Chord" (e.g. 'Ctrl+K Ctrl+C'). This is the primary representation of a fully resolved keybinding. For consistency in the downstream API, it should be preferred to [`SimpleKeyBinding`][app_model.types.SimpleKeyBinding], even when there is only a single part in the keybinding (i.e. when it is not a chord.) Chords (two separate keypress actions) are expressed as a string by separating the two keypress codes with a space. For example, 'Ctrl+K Ctrl+C'. Parameters ---------- parts : List[SimpleKeyBinding] The parts of the keybinding. There must be at least one part. """ parts: list[SimpleKeyBinding] = Field(..., **MIN1) # type: ignore def __init__(self, *, parts: list[SimpleKeyBinding]) -> None: self.parts = parts def __str__(self) -> str: """Get a normalized string representation (constant to all OSes) of this KeyBinding.""" return " ".join(str(part) for part in self.parts) def __repr__(self) -> str: return f"<{self.__class__.__name__} at {hex(id(self))}: {self}>" def __eq__(self, other: Any) -> bool: if isinstance(other, KeyBinding): return self.parts == other.parts return NotImplemented def __len__(self) -> int: return len(self.parts) @property def part0(self) -> SimpleKeyBinding: """Return the first part of this keybinding. All keybindings have at least one part. """ return self.parts[0] @classmethod def from_str(cls, key_str: str) -> "KeyBinding": """Parse a string into a SimpleKeyBinding.""" parts = [SimpleKeyBinding.from_str(part) for part in key_str.split()] return cls(parts=parts) @classmethod def from_int( cls, key_int: int, os: Optional[OperatingSystem] = None ) -> "KeyBinding": """Create a KeyBinding from an integer.""" # a multi keybinding is represented as an integer # with the first_part in the lowest 16 bits, # the second_part in the next 16 bits, etc. first_part = key_int & 0x0000FFFF chord_part = (key_int & 0xFFFF0000) >> 16 if chord_part != 0: return cls( parts=[ SimpleKeyBinding.from_int(first_part, os), SimpleKeyBinding.from_int(chord_part, os), ] ) return cls(parts=[SimpleKeyBinding.from_int(first_part, os)]) def to_int(self, os: Optional[OperatingSystem] = None) -> int: """Convert this KeyBinding to an integer representation.""" if len(self.parts) > 2: # pragma: no cover raise NotImplementedError( "Cannot represent chords with more than 2 parts as int" ) os = OperatingSystem.current() if os is None else os parts = [part.to_int(os) for part in self.parts] if len(parts) == 2: return KeyChord(*parts) return parts[0] def to_text( self, os: Optional[OperatingSystem] = None, use_symbols: bool = False, joinchar: str = "+", ) -> str: """Get a text representation of this KeyBinding. Optionally, the string representation can be constructed with symbols like โ†ต for Enter or OS specific ones like โŒ˜ for Meta on MacOS. If no symbols should be used, the string representation will use the OS specific names for the keys like `Cmd` for Meta or `Option` for Ctrl on MacOS. Also, a join character can be defined. By default `+` is used. """ return " ".join( part.to_text(os=os, use_symbols=use_symbols, joinchar=joinchar) for part in self.parts ) def __int__(self) -> int: return int(self.to_int()) def __hash__(self) -> int: return hash(tuple(self.parts)) @classmethod def __get_pydantic_core_schema__( cls, source: type, handler: "GetCoreSchemaHandler" ) -> "core_schema.CoreSchema": from pydantic_core import core_schema return core_schema.no_info_plain_validator_function( cls.validate, serialization=core_schema.to_string_ser_schema() ) @classmethod def validate(cls, v: Any) -> "KeyBinding": """Validate a SimpleKeyBinding.""" if isinstance(v, KeyBinding): return v if isinstance(v, SimpleKeyBinding): return cls(parts=[v]) if isinstance(v, int): return cls.from_int(v) if isinstance(v, str): return cls.from_str(v) raise TypeError("invalid keybinding") # pragma: no cover _re_ctrl = re.compile(r"(ctrl|control|ctl|โŒƒ|\^)[\+|\-]") _re_shift = re.compile(r"(shift|โ‡ง)[\+|\-]") _re_alt = re.compile(r"(alt|opt|option|โŒฅ)[\+|\-]") _re_meta = re.compile(r"(meta|super|win|windows|โŠž|cmd|command|โŒ˜)[\+|\-]") def _parse_modifiers(input: str) -> tuple[dict[str, bool], str]: """Parse modifiers from a string (case insensitive). modifiers must start at the beginning of the string, and be separated by "+" or "-". e.g. "ctrl+shift+alt+K" or "Ctrl-Cmd-K" """ remainder = input.lower() patterns = {"ctrl": _re_ctrl, "shift": _re_shift, "alt": _re_alt, "meta": _re_meta} mods = dict.fromkeys(patterns, False) while True: saw_modifier = False for key, ptrn in patterns.items(): if m := ptrn.match(remainder): remainder = remainder[m.span()[1] :] mods[key] = True saw_modifier = True break if not saw_modifier: break return mods, remainder app-model-0.5.1/src/app_model/types/_keys/_standard_bindings.py000066400000000000000000000174141510416065100245740ustar00rootroot00000000000000from collections import namedtuple from enum import Enum, auto from typing import TYPE_CHECKING, Dict from typing_extensions import Final from ._key_codes import KeyCode, KeyMod if TYPE_CHECKING: from .._keybinding_rule import KeyBindingRule class StandardKeyBinding(Enum): AddTab = auto() Back = auto() Bold = auto() Cancel = auto() Close = auto() Copy = auto() Cut = auto() Delete = auto() DeleteCompleteLine = auto() DeleteEndOfLine = auto() DeleteEndOfWord = auto() DeleteStartOfWord = auto() Deselect = auto() Find = auto() FindNext = auto() FindPrevious = auto() Forward = auto() FullScreen = auto() HelpContents = auto() Italic = auto() MoveToEndOfDocument = auto() MoveToEndOfLine = auto() MoveToNextChar = auto() MoveToNextLine = auto() MoveToNextPage = auto() MoveToNextWord = auto() MoveToPreviousChar = auto() MoveToPreviousLine = auto() MoveToPreviousPage = auto() MoveToPreviousWord = auto() MoveToStartOfDocument = auto() MoveToStartOfLine = auto() New = auto() NextChild = auto() Open = auto() Paste = auto() Preferences = auto() PreviousChild = auto() Print = auto() Quit = auto() Redo = auto() Refresh = auto() Replace = auto() Save = auto() SaveAs = auto() SelectAll = auto() SelectEndOfDocument = auto() SelectEndOfLine = auto() SelectNextChar = auto() SelectNextLine = auto() SelectNextPage = auto() SelectNextWord = auto() SelectPreviousChar = auto() SelectPreviousLine = auto() SelectPreviousPage = auto() SelectPreviousWord = auto() SelectStartOfDocument = auto() SelectStartOfLine = auto() Underline = auto() Undo = auto() WhatsThis = auto() OriginalSize = auto() ZoomIn = auto() ZoomOut = auto() def to_keybinding_rule(self) -> "KeyBindingRule": """Return KeyBindingRule for this StandardKeyBinding.""" from .._keybinding_rule import KeyBindingRule return KeyBindingRule(**_STANDARD_KEY_MAP[self]) _: Final[None] = None SK = namedtuple("SK", "sk, primary, win, mac, gnome", defaults=(_, _, _, _, _)) # fmt: off # flake8: noqa _STANDARD_KEYS = [ SK(StandardKeyBinding.AddTab, KeyMod.CtrlCmd | KeyCode.KeyT), SK(StandardKeyBinding.Back, KeyMod.Alt | KeyCode.LeftArrow, _, KeyMod.CtrlCmd | KeyCode.BracketLeft), SK(StandardKeyBinding.Bold, KeyMod.CtrlCmd | KeyCode.KeyB), SK(StandardKeyBinding.Cancel, KeyCode.Escape), SK(StandardKeyBinding.Close, KeyMod.CtrlCmd | KeyCode.KeyW), SK(StandardKeyBinding.Copy, KeyMod.CtrlCmd | KeyCode.KeyC), SK(StandardKeyBinding.Cut, KeyMod.CtrlCmd | KeyCode.KeyX), SK(StandardKeyBinding.Delete, KeyCode.Delete), SK(StandardKeyBinding.DeleteCompleteLine, _, _, _, KeyMod.CtrlCmd | KeyCode.KeyU), SK(StandardKeyBinding.DeleteEndOfLine, _, _, _, KeyMod.CtrlCmd | KeyCode.KeyK), SK(StandardKeyBinding.DeleteEndOfWord, _, KeyMod.CtrlCmd | KeyCode.Delete, _, KeyMod.CtrlCmd | KeyCode.Delete), SK(StandardKeyBinding.DeleteStartOfWord, _, KeyMod.CtrlCmd | KeyCode.Backspace, KeyMod.Alt | KeyCode.Backspace, KeyMod.CtrlCmd | KeyCode.Backspace), SK(StandardKeyBinding.Deselect, _, _, _, KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyA), SK(StandardKeyBinding.Find, KeyMod.CtrlCmd | KeyCode.KeyF), SK(StandardKeyBinding.FindNext, KeyMod.CtrlCmd | KeyCode.KeyG), SK(StandardKeyBinding.FindPrevious, KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyG), SK(StandardKeyBinding.Forward, _, KeyMod.Alt | KeyCode.RightArrow, KeyMod.CtrlCmd | KeyCode.BracketRight, KeyMod.Alt | KeyCode.RightArrow), SK(StandardKeyBinding.FullScreen, _, KeyMod.Alt | KeyCode.Enter, KeyMod.WinCtrl | KeyMod.CtrlCmd | KeyCode.KeyF, KeyMod.CtrlCmd | KeyCode.F11), SK(StandardKeyBinding.HelpContents, KeyCode.F1, _, KeyMod.CtrlCmd | KeyCode.Slash), SK(StandardKeyBinding.Italic, KeyMod.CtrlCmd | KeyCode.KeyI), SK(StandardKeyBinding.MoveToEndOfDocument, KeyMod.CtrlCmd | KeyCode.End, _, KeyMod.CtrlCmd | KeyCode.DownArrow), SK(StandardKeyBinding.MoveToEndOfLine, KeyCode.End, _, KeyMod.CtrlCmd | KeyCode.RightArrow), SK(StandardKeyBinding.MoveToNextChar, KeyCode.RightArrow), SK(StandardKeyBinding.MoveToNextLine, KeyCode.DownArrow), SK(StandardKeyBinding.MoveToNextPage, KeyCode.PageDown), SK(StandardKeyBinding.MoveToNextWord, KeyMod.CtrlCmd | KeyCode.RightArrow, _, KeyMod.Alt | KeyCode.RightArrow), SK(StandardKeyBinding.MoveToPreviousChar, KeyCode.LeftArrow), SK(StandardKeyBinding.MoveToPreviousLine, KeyCode.UpArrow), SK(StandardKeyBinding.MoveToPreviousPage, KeyCode.PageUp), SK(StandardKeyBinding.MoveToPreviousWord, KeyMod.CtrlCmd | KeyCode.LeftArrow, _, KeyMod.Alt | KeyCode.LeftArrow), SK(StandardKeyBinding.MoveToStartOfDocument, KeyMod.CtrlCmd | KeyCode.Home, _, KeyCode.Home), SK(StandardKeyBinding.MoveToStartOfLine, KeyCode.Home, _, KeyMod.CtrlCmd | KeyCode.LeftArrow), SK(StandardKeyBinding.New, KeyMod.CtrlCmd | KeyCode.KeyN), SK(StandardKeyBinding.NextChild, KeyMod.CtrlCmd | KeyCode.Tab, _, KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.BracketRight), SK(StandardKeyBinding.Open, KeyMod.CtrlCmd | KeyCode.KeyO), SK(StandardKeyBinding.Paste, KeyMod.CtrlCmd | KeyCode.KeyV), SK(StandardKeyBinding.Preferences, KeyMod.CtrlCmd | KeyCode.Comma), SK(StandardKeyBinding.PreviousChild, KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Tab, _, KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.BracketLeft), SK(StandardKeyBinding.Print, KeyMod.CtrlCmd | KeyCode.KeyP), SK(StandardKeyBinding.Quit, KeyMod.CtrlCmd | KeyCode.KeyQ), SK(StandardKeyBinding.Redo, KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyZ, KeyMod.CtrlCmd | KeyCode.KeyY), SK(StandardKeyBinding.Refresh, KeyMod.CtrlCmd | KeyCode.KeyR), SK(StandardKeyBinding.Replace, KeyMod.CtrlCmd | KeyCode.KeyH), SK(StandardKeyBinding.Save, KeyMod.CtrlCmd | KeyCode.KeyS), SK(StandardKeyBinding.SaveAs, KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyS), SK(StandardKeyBinding.SelectAll, KeyMod.CtrlCmd | KeyCode.KeyA), SK(StandardKeyBinding.SelectEndOfDocument, KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.End), SK(StandardKeyBinding.SelectEndOfLine, KeyMod.Shift | KeyCode.End, _, KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.RightArrow), SK(StandardKeyBinding.SelectNextChar, KeyMod.Shift | KeyCode.RightArrow), SK(StandardKeyBinding.SelectNextLine, KeyMod.Shift | KeyCode.DownArrow), SK(StandardKeyBinding.SelectNextPage, KeyMod.Shift | KeyCode.PageDown), SK(StandardKeyBinding.SelectNextWord, KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.RightArrow, _, KeyMod.Alt | KeyMod.Shift | KeyCode.RightArrow), SK(StandardKeyBinding.SelectPreviousChar, KeyMod.Shift | KeyCode.LeftArrow), SK(StandardKeyBinding.SelectPreviousLine, KeyMod.Shift | KeyCode.UpArrow), SK(StandardKeyBinding.SelectPreviousPage, KeyMod.Shift | KeyCode.PageUp), SK(StandardKeyBinding.SelectPreviousWord, KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.LeftArrow, _, KeyMod.Alt | KeyMod.Shift | KeyCode.LeftArrow), SK(StandardKeyBinding.SelectStartOfDocument, KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Home), SK(StandardKeyBinding.SelectStartOfLine, KeyMod.Shift | KeyCode.Home, _, KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.LeftArrow), SK(StandardKeyBinding.Underline, KeyMod.CtrlCmd | KeyCode.KeyU), SK(StandardKeyBinding.Undo, KeyMod.CtrlCmd | KeyCode.KeyZ), SK(StandardKeyBinding.WhatsThis, KeyMod.Shift | KeyCode.F1), SK(StandardKeyBinding.OriginalSize, KeyMod.CtrlCmd | KeyCode.Digit0), SK(StandardKeyBinding.ZoomIn, KeyMod.CtrlCmd | KeyCode.Equal), SK(StandardKeyBinding.ZoomOut, KeyMod.CtrlCmd | KeyCode.Minus), ] # fmt: on _STANDARD_KEY_MAP: Dict[StandardKeyBinding, Dict[str, str]] = { nt.sk: {"primary": nt.primary, "win": nt.win, "mac": nt.mac, "linux": nt.gnome} for nt in _STANDARD_KEYS } app-model-0.5.1/src/app_model/types/_menu_rule.py000066400000000000000000000104001510416065100217640ustar00rootroot00000000000000from typing import ( Any, Optional, TypedDict, Union, ) from pydantic import Field, field_validator, model_validator from app_model import expressions from ._base import _BaseModel from ._command_rule import CommandRule from ._icon import Icon class MenuItemBase(_BaseModel): """Data representing where and when a menu item should be shown.""" when: Optional[expressions.Expr] = Field( default=None, description="(Optional) Condition which must be true to show the item.", ) group: Optional[str] = Field( default=None, description="(Optional) Menu group to which this item should be added. Menu " "groups are sortable strings (like `'1_cutandpaste'`). 'navigation' is a " "special group that always appears at the top of a menu. If not provided, " "the item is added in the last group of the menu.", ) order: Optional[float] = Field( default=None, description="(Optional) Order of the item *within* its group. Note, order is " "not part of the plugin schema, plugins may provide it using the group key " "and the syntax 'group@order'. If not provided, items are sorted by title.", ) @classmethod def _validate(cls: type["MenuItemBase"], v: Any) -> "MenuItemBase": """Validate icon.""" if isinstance(v, MenuItemBase): return v if isinstance(v, dict): if "command" in v: return MenuItem(**v) if "id" in v: return MenuRule(**v) if "submenu" in v: return SubmenuItem(**v) raise ValueError(f"Invalid menu item: {v!r}", cls) # pragma: no cover class MenuRule(MenuItemBase): """A MenuRule defines a menu location and conditions for presentation. It does not define an actual command. That is done in either `MenuItem` or `Action`. """ id: str = Field(..., description="Menu in which to place this item.") @model_validator(mode="before") @classmethod def _validate_model(cls, v: Any) -> Any: """If a single string is provided, convert to a dict with `id` key.""" return {"id": v} if isinstance(v, str) else v class MenuItem(MenuItemBase): """Combination of a Command and conditions for menu presentation. This object is mostly constructed by `register_action` right before menu item registration. """ command: CommandRule = Field( ..., description="CommandRule to execute when this menu item is selected.", ) alt: Optional[CommandRule] = Field( default=None, description="(Optional) Alternate command to execute when this menu item is " "selected, (e.g. when the Alt-key is held when opening the menu)", ) @field_validator("command") def _simplify_command_rule(cls, v: Any) -> CommandRule: if isinstance(v, CommandRule): return v._as_command_rule() raise TypeError("command must be a CommandRule") # pragma: no cover class SubmenuItem(MenuItemBase): """Point to another Menu that will be displayed as a submenu.""" submenu: str = Field(..., description="Menu to insert as a submenu.") title: str = Field(..., description="Title of this submenu, shown in the UI.") icon: Optional[Icon] = Field( default=None, description="(Optional) Icon used to represent this submenu. " "These may be [iconify keys](https://icon-sets.iconify.design), " "such as `fa6-solid:arrow-down`, or " "[superqt.fonticon](https://pyapp-kit.github.io/superqt/utilities/fonticon/)" " keys, such as `fa6s.arrow_down`", ) enablement: Optional[expressions.Expr] = Field( default=None, description="(Optional) Condition which must be true to enable the submenu. " "Disabled submenus appear grayed out in the UI, and cannot be selected. By " "default, submenus are enabled.", ) class MenuRuleDict(TypedDict, total=False): """Typed dict for MenuRule kwargs. This mimics the pydantic `MenuRule` interface, but allows you to pass in a dict """ when: Optional[expressions.Expr] group: str order: Optional[float] id: str MenuRuleOrDict = Union[MenuRule, MenuRuleDict] MenuOrSubmenu = Union[MenuItem, SubmenuItem] app-model-0.5.1/src/app_model/types/_utils.py000066400000000000000000000033321510416065100211370ustar00rootroot00000000000000import re from importlib import import_module from typing import Any _identifier_plus_dash = "(?:[a-zA-Z_][a-zA-Z_0-9-]+)" _dotted_name = f"(?:(?:{_identifier_plus_dash}\\.)*{_identifier_plus_dash})" PYTHON_NAME_PATTERN = re.compile(f"^({_dotted_name}):({_dotted_name})$") def _validate_python_name(name: str) -> str: """Assert that `name` is a valid python name: e.g. `module.submodule:funcname`.""" if name and not PYTHON_NAME_PATTERN.match(name): msg = ( f"{name!r} is not a valid python_name. A python_name must " "be of the form '{obj.__module__}:{obj.__qualname__}' (e.g. " "'my_package.a_module:some_function')." ) if ".." in name: # pragma: no cover *_, a, b = name.split("..") a = a.split(":")[-1] msg += ( " Note: functions defined in local scopes are not yet supported. " f"Please move function {b!r} to the global scope of module {a!r}" ) raise ValueError(msg) return name def import_python_name(python_name: str) -> Any: """Import object from a fully qualified python name. Examples -------- >>> import_python_name("my_package.a_module:some_function") >>> import_python_name("pydantic:BaseModel") """ _validate_python_name(python_name) # shows the best error message if match := PYTHON_NAME_PATTERN.match(python_name): module_name, funcname = match.groups() mod = import_module(module_name) return getattr(mod, funcname) raise ValueError( # pragma: no cover f"Could not parse python_name: {python_name!r}" ) app-model-0.5.1/tests/000077500000000000000000000000001510416065100145345ustar00rootroot00000000000000app-model-0.5.1/tests/conftest.py000066400000000000000000000163701510416065100167420ustar00rootroot00000000000000from __future__ import annotations import sys from pathlib import Path from typing import TYPE_CHECKING from unittest.mock import Mock import pytest from app_model import Action, Application from app_model.types import KeyCode, KeyMod, SubmenuItem if TYPE_CHECKING: from collections.abc import Iterator from typing import NoReturn try: from fonticon_fa6 import FA6S UNDO_ICON = FA6S.rotate_left except ImportError: UNDO_ICON = "fa6-solid:undo" FIXTURES = Path(__file__).parent / "fixtures" class Menus: FILE = "file" EDIT = "edit" HELP = "help" FILE_OPEN_FROM = "file/open_from" class Commands: TOP = "top" OPEN = "open" UNDO = "undo" REDO = "redo" COPY = "copy" PASTE = "paste" TOGGLE_THING = "toggle_thing" OPEN_FROM_A = "open.from_a" OPEN_FROM_B = "open.from_b" UNIMPORTABLE = "unimportable" NOT_CALLABLE = "not.callable" RAISES = "raises.error" def _raise_an_error() -> NoReturn: raise ValueError("This is an error") class Mocks: def __init__(self) -> None: self.open = Mock(name=Commands.OPEN) self.undo = Mock(name=Commands.UNDO) self.copy = Mock(name=Commands.COPY) self.paste = Mock(name=Commands.PASTE) self.open_from_a = Mock(name=Commands.OPEN_FROM_A) self.open_from_b = Mock(name=Commands.OPEN_FROM_B) @property def redo(self) -> Mock: """This tests that we can lazily import a callback. There is a function called `run_me` in fixtures/fake_module.py that calls the global mock in that module. In the redo action below, we declare: `callback="fake_module:run_me"` So, whenever the redo action is triggered, it should import that module, and then call the mock. We can also access it here at `mocks.redo`... but the fixtures directory must be added to sys path during the test (as we do below) """ try: from fake_module import GLOBAL_MOCK return GLOBAL_MOCK except ImportError as e: raise ImportError( "This mock must be run with the fixutres directory added to sys.path." ) from e class FullApp(Application): Menus = Menus Commands = Commands def __init__(self, name: str) -> None: super().__init__(name) self.mocks = Mocks() def build_app(name: str = "complete_test_app") -> FullApp: app = FullApp(name) app.menus.append_menu_items( [ ( Menus.FILE, SubmenuItem( submenu=Menus.FILE_OPEN_FROM, title="Open From...", icon="fa6s.folder-open", when="not something_open", enablement="friday", ), ) ] ) actions: list[Action] = [ Action( id=Commands.OPEN, title="Open...", callback=app.mocks.open, menus=[{"id": Menus.FILE}], keybindings=[{"primary": "Ctrl+O"}], ), # putting these above undo redo to make sure that group sorting works Action( id=Commands.COPY, title="Copy", icon="fa6-solid:copy", # iconify font style works too callback=app.mocks.copy, menus=[{"id": Menus.EDIT, "group": "2_copy_paste"}], keybindings=[{"primary": KeyMod.CtrlCmd | KeyCode.KeyC}], ), Action( id=Commands.PASTE, title="Paste", icon="fa6s.paste", callback=app.mocks.paste, menus=[{"id": Menus.EDIT, "group": "2_copy_paste"}], keybindings=[{"primary": "Ctrl+V", "mac": "Cmd+V"}], ), # putting this above UNDO to make sure that order sorting works Action( id=Commands.REDO, title="Redo", tooltip="Redo it!", icon="fa6-solid:rotate-right", enablement="allow_undo_redo", callback="fake_module:run_me", # this is a function in fixtures keybindings=[{"primary": "Ctrl+Shift+Z"}], menus=[ { "id": Menus.EDIT, "group": "1_undo_redo", "order": 1, "when": "not something_to_undo", } ], ), Action( id=Commands.UNDO, tooltip="Undo it!", title="Undo", icon=UNDO_ICON, # testing alternate way to specify icon enablement="allow_undo_redo", callback=app.mocks.undo, keybindings=[{"primary": "Ctrl+Z"}], menus=[ { "id": Menus.EDIT, "group": "1_undo_redo", "order": 0, "when": "something_to_undo", } ], ), # test the navigation key Action( id=Commands.TOP, title="AtTop", callback=lambda: None, menus=[{"id": Menus.EDIT, "group": "navigation"}], ), # test submenus Action( id=Commands.OPEN_FROM_A, title="Open from A", callback=app.mocks.open_from_a, menus=[{"id": Menus.FILE_OPEN_FROM}], ), Action( id=Commands.OPEN_FROM_B, title="Open from B", callback=app.mocks.open_from_b, menus=[{"id": Menus.FILE_OPEN_FROM}], enablement="sat", ), Action( id=Commands.UNIMPORTABLE, title="Can't be found", callback="unresolvable:function", ), Action( id=Commands.NOT_CALLABLE, title="Will Never Work", callback="fake_module:attr", ), Action( id=Commands.RAISES, title="Will raise an error", callback=_raise_an_error, ), Action( id=Commands.TOGGLE_THING, title="Toggle Thing", callback=lambda: None, menus=[{"id": Menus.HELP}], toggled="thing_toggled", ), ] for action in actions: app.register_action(action) return app @pytest.fixture def full_app(monkeypatch: pytest.MonkeyPatch) -> Iterator[Application]: """Premade application.""" try: app = build_app("complete_test_app") with monkeypatch.context() as m: # mock path to add fake_module m.setattr(sys, "path", [str(FIXTURES), *sys.path]) # make sure it's not already in sys.modules sys.modules.pop("fake_module", None) yield app # clear the global mock if it's been called app.mocks.redo.reset_mock() finally: Application.destroy("complete_test_app") @pytest.fixture def simple_app() -> Iterator[Application]: app = Application("test") app.commands_changed = Mock() app.commands.registered.connect(app.commands_changed) app.keybindings_changed = Mock() app.keybindings.registered.connect(app.keybindings_changed) app.menus_changed = Mock() app.menus.menus_changed.connect(app.menus_changed) yield app Application.destroy("test") assert "test" not in Application._instances app-model-0.5.1/tests/fixtures/000077500000000000000000000000001510416065100164055ustar00rootroot00000000000000app-model-0.5.1/tests/fixtures/fake_module.py000066400000000000000000000002251510416065100212310ustar00rootroot00000000000000from unittest.mock import Mock GLOBAL_MOCK = Mock(name="GLOBAL") def run_me() -> bool: GLOBAL_MOCK() return True attr = "not a callble" app-model-0.5.1/tests/test_actions.py000066400000000000000000000075161510416065100176160ustar00rootroot00000000000000import pytest from app_model import Application from app_model.registries import register_action from app_model.types import Action, KeyBinding PRIMARY_KEY = "ctrl+a" OS_KEY = "ctrl+b" MENUID = "some.menu.id" KWARGS = [ {}, {"enablement": "x == 1"}, {"menus": [MENUID]}, # test that we can pass menus as a single string too {"enablement": "3 >= 1", "menus": [{"id": MENUID}]}, {"keybindings": [{"primary": PRIMARY_KEY}]}, { "keybindings": [ {"primary": PRIMARY_KEY, "mac": OS_KEY, "win": OS_KEY, "linux": OS_KEY} ] }, {"keybindings": [{"primary": "ctrl+a"}], "menus": [{"id": MENUID}]}, {"palette": False}, ] @pytest.mark.parametrize("kwargs", KWARGS) @pytest.mark.parametrize("mode", ["str", "decorator", "action"]) def test_register_action_decorator( kwargs: dict, simple_app: Application, mode: str ) -> None: # make sure mocks are working app = simple_app assert not list(app.commands) assert not list(app.keybindings) assert not list(app.menus) cmd_id = "cmd.id" kwargs["title"] = "Test title" # register the action if mode == "decorator": @register_action(app=app, id_or_action=cmd_id, **kwargs) def f1() -> str: return "hi" assert f1() == "hi" # decorator returns the function else: def f2() -> str: return "hi" if mode == "str": register_action(app=app, id_or_action=cmd_id, callback=f2, **kwargs) elif mode == "action": action = Action(id=cmd_id, callback=f2, **kwargs) app.register_action(action) # make sure the command is registered assert cmd_id in app.commands assert list(app.commands) # make sure an event was emitted signaling the command was registered app.commands_changed.assert_called_once_with(cmd_id) # type: ignore # make sure we can call the command, and that we can inject dependencies. assert app.commands.execute_command(cmd_id).result() == "hi" # make sure menus are registered if specified menus = kwargs.get("menus", []) if menus := kwargs.get("menus"): for entry in menus: id_ = entry if isinstance(entry, str) else entry["id"] assert id_ in app.menus app.menus_changed.assert_any_call({id_}) else: menus = list(app.menus) if kwargs.get("palette") is not False: assert app.menus.COMMAND_PALETTE_ID in app.menus assert len(menus) == 1 else: assert not list(app.menus) # make sure keybindings are registered if specified if keybindings := kwargs.get("keybindings"): for entry in keybindings: key = PRIMARY_KEY if len(entry) == 1 else OS_KEY # see KWARGS[5] key = KeyBinding.from_str(key) assert any(i.keybinding == key for i in app.keybindings) app.keybindings_changed.assert_called() # type: ignore else: assert not list(app.keybindings) # check that calling the dispose function removes everything. app.dispose() assert not list(app.commands) assert not list(app.keybindings) assert not list(app.menus) def test_errors(simple_app: Application) -> None: with pytest.raises(ValueError, match="'title' is required"): simple_app.register_action("cmd_id") # type: ignore with pytest.raises(TypeError, match="must be a string or an Action"): simple_app.register_action(None) # type: ignore def test_register_multiple_actions(simple_app: Application) -> None: actions: list[Action] = [ Action(id="cmd_id1", title="title1", callback=lambda: None), Action(id="cmd_id2", title="title2", callback=lambda: None), ] dispose = simple_app.register_actions(actions) assert len(simple_app.commands) == 2 dispose() assert not list(simple_app.commands) app-model-0.5.1/tests/test_app.py000066400000000000000000000110461510416065100167270ustar00rootroot00000000000000from __future__ import annotations import os import sys from typing import TYPE_CHECKING import pytest from app_model import Application from app_model.expressions import Context from app_model.types import Action from app_model.types._menu_rule import MenuRule if TYPE_CHECKING: from conftest import FullApp def test_app_create() -> None: assert Application.get_app("my_app") is None app = Application("my_app") assert Application.get_app("my_app") is app # NOTE: for some strange reason, this test fails if I move this line # below the error assertion below... I don't know why. assert Application.get_or_create("my_app") is app with pytest.raises(ValueError, match="Application 'my_app' already exists"): Application("my_app") assert repr(app) == "Application('my_app')" Application.destroy("my_app") def test_app(full_app: FullApp) -> None: app = full_app app.commands.execute_command(app.Commands.OPEN) app.mocks.open.assert_called_once() app.commands.execute_command(app.Commands.COPY) app.mocks.copy.assert_called_once() app.commands.execute_command(app.Commands.PASTE) app.mocks.paste.assert_called_once() def test_sorting(full_app: FullApp) -> None: groups = list(full_app.menus.iter_menu_groups(full_app.Menus.EDIT)) assert len(groups) == 3 [_g0, g1, g2] = groups assert all(i.group == "1_undo_redo" for i in g1) assert all(i.group == "2_copy_paste" for i in g2) assert [i.command.title for i in g1] == ["Undo", "Redo"] assert [i.command.title for i in g2] == ["Copy", "Paste"] def test_action_import_by_string(full_app: FullApp) -> None: """the REDO command is declared as a string in the conftest.py file This tests that it can be lazily imported at callback runtime and executed """ assert "fake_module" not in sys.modules assert full_app.commands.execute_command(full_app.Commands.REDO).result() assert "fake_module" in sys.modules full_app.mocks.redo.assert_called_once() # tests what happens when the module cannot be found with pytest.raises( ModuleNotFoundError, match="Command 'unimportable' was not importable" ): full_app.commands.execute_command(full_app.Commands.UNIMPORTABLE) # the second time we try within a session, nothing should happen full_app.commands.execute_command(full_app.Commands.UNIMPORTABLE) # tests what happens when the object is not callable cannot be found with pytest.raises( TypeError, match=r"Command 'not\.callable' did not resolve to a callble object", ): full_app.commands.execute_command(full_app.Commands.NOT_CALLABLE) # the second time we try within a session, nothing should happen full_app.commands.execute_command(full_app.Commands.NOT_CALLABLE) def test_action_raises_exception(full_app: FullApp) -> None: result = full_app.commands.execute_command(full_app.Commands.RAISES) with pytest.raises(ValueError): result.result() # the function that raised the exception is `_raise_an_error` in conftest.py assert str(result.exception()) == "This is an error" assert not full_app.raise_synchronous_exceptions full_app.raise_synchronous_exceptions = True assert full_app.raise_synchronous_exceptions with pytest.raises(ValueError): full_app.commands.execute_command(full_app.Commands.RAISES) def test_app_context() -> None: app = Application("app1") assert isinstance(app.context, Context) Application.destroy("app1") assert app.context["is_windows"] == (os.name == "nt") assert "is_mac" in app.context assert "is_linux" in app.context app = Application("app2", context={"a": 1}) assert isinstance(app.context, Context) assert app.context["a"] == 1 Application.destroy("app2") app = Application("app3", context=Context({"a": 1})) assert isinstance(app.context, Context) assert app.context["a"] == 1 Application.destroy("app3") with pytest.raises(TypeError, match="context must be a Context or MutableMapping"): Application("app4", context=1) # type: ignore[arg-type] def test_register_actions() -> None: app = Application("app5") actions = app.registered_actions assert not actions dispose = app.register_action( "my_action", title="My Action", callback=lambda: None, menus=["Window"] ) assert "my_action" in actions assert isinstance(action := actions["my_action"], Action) assert action.menus == [MenuRule(id="Window")] dispose() assert "my_action" not in actions assert not actions app-model-0.5.1/tests/test_command_registry.py000066400000000000000000000031301510416065100215100ustar00rootroot00000000000000import pytest from app_model.registries import CommandsRegistry, RegisteredCommand def raise_exc() -> None: raise RuntimeError("boom") def test_commands_registry() -> None: reg = CommandsRegistry() id1 = "my.id" reg.register_command(id1, lambda: 42, "My Title") assert "(1 commands)" in repr(reg) assert id1 in str(reg) assert id1 in reg with pytest.raises(KeyError, match=r"my\.id2"): reg["my.id2"] with pytest.raises(ValueError, match=r"Command 'my\.id' already registered"): reg.register_command(id1, lambda: 42, "My Title") assert reg.execute_command(id1, execute_asynchronously=True).result() == 42 assert reg.execute_command(id1, execute_asynchronously=False).result() == 42 reg.register_command("my.id2", raise_exc, "My Title 2") future_async = reg.execute_command("my.id2", execute_asynchronously=True) future_sync = reg.execute_command("my.id2", execute_asynchronously=False) with pytest.raises(RuntimeError, match="boom"): future_async.result() with pytest.raises(RuntimeError, match="boom"): future_sync.result() def test_commands_raises() -> None: reg = CommandsRegistry(raise_synchronous_exceptions=True) id_ = "my.id" title = "My Title" reg.register_command(id_, raise_exc, title) with pytest.raises(RuntimeError, match="boom"): reg.execute_command(id_) cmd = reg[id_] assert isinstance(cmd, RegisteredCommand) assert cmd.title == title with pytest.raises(AttributeError, match="immutable"): cmd.title = "New Title" assert cmd.title == title app-model-0.5.1/tests/test_context/000077500000000000000000000000001510416065100172575ustar00rootroot00000000000000app-model-0.5.1/tests/test_context/test_context.py000066400000000000000000000071741510416065100223650ustar00rootroot00000000000000import gc from unittest.mock import Mock import pytest from app_model.expressions import Context, create_context, get_context from app_model.expressions._context import _OBJ_TO_CONTEXT def test_create_context() -> None: """You can create a context for any object""" class T: ... t = T() tid = id(t) ctx = create_context(t) assert get_context(t) == ctx assert hash(ctx) # hashable assert tid in _OBJ_TO_CONTEXT _OBJ_TO_CONTEXT.pop(tid) del t gc.collect() assert tid not in _OBJ_TO_CONTEXT # you can provide your own root, but it must be a context create_context(T(), root=Context()) with pytest.raises(AssertionError): create_context(T(), root={}) # type: ignore def test_create_and_get_scoped_contexts() -> None: """Test that objects created in the stack of another contexted object. likely the most common way that this API will be used: """ before = len(_OBJ_TO_CONTEXT) class A: def __init__(self) -> None: create_context(self) self.b = B() class B: def __init__(self) -> None: create_context(self) obja = A() assert len(_OBJ_TO_CONTEXT) == before + 2 ctxa = get_context(obja) ctxb = get_context(obja.b) assert ctxa is not None assert ctxb is not None ctxa["hi"] = "hi" assert ctxb["hi"] == "hi" # keys get deleted on object deletion del obja gc.collect() assert len(_OBJ_TO_CONTEXT) == before def test_context_events() -> None: """Changing context keys emits an event""" mock = Mock() root = Context() scoped = root.new_child() scoped.changed.connect(mock) # connect the mock to the child root["a"] = 1 # child re-emits parent events assert mock.call_args[0][0] == {"a"} mock.reset_mock() scoped["b"] = 1 # also emits own events assert mock.call_args[0][0] == {"b"} mock.reset_mock() del scoped["b"] assert mock.call_args[0][0] == {"b"} # but parent does not emit child events mock.reset_mock() mock2 = Mock() root.changed.connect(mock2) scoped["c"] = "c" mock.assert_called_once() mock2.assert_not_called() mock.reset_mock() with scoped.buffered_changes(): scoped["d"] = "d" scoped["e"] = "f" scoped["f"] = "f" mock.assert_called_once_with({"d", "e", "f"}) # check events properly propagated when adding already existing context as child mock3 = Mock() root2a = Context() root2b = Context() scoped2 = root2a.new_child(root2b) scoped2.changed.connect(mock3) # connect the mock to the child root2a["a"] = 1 # child re-emits parent events assert mock3.call_args[0][0] == {"a"} mock3.reset_mock() root2b["b"] = 1 # child re-emits added events assert mock3.call_args[0][0] == {"b"} mock3.reset_mock() scoped2["c"] = 1 # also emits own events assert mock3.call_args[0][0] == {"c"} # check events properly propagated when making a context of contexts mock4 = Mock() root3a = Context() root3b = Context() root3c = Context() root3d = Context() root3e = Context() combined = Context(root3a, root3b, root3c, root3d, root3e) combined.changed.connect(mock4) root3a["a"] = 1 assert mock4.call_args[0][0] == {"a"} mock4.reset_mock() root3b["b"] = 1 assert mock4.call_args[0][0] == {"b"} mock4.reset_mock() root3c["c"] = 1 assert mock4.call_args[0][0] == {"c"} mock4.reset_mock() root3d["d"] = 1 assert mock4.call_args[0][0] == {"d"} mock4.reset_mock() root3e["e"] = 1 assert mock4.call_args[0][0] == {"e"} app-model-0.5.1/tests/test_context/test_context_keys.py000066400000000000000000000037031510416065100234120ustar00rootroot00000000000000import pytest from app_model.expressions._context_keys import ( ContextKey, ContextKeyInfo, ContextNamespace, ) def test_context_key_info() -> None: key = ContextKey("default", "description", None, id="some_key") info = ContextKey.info() assert isinstance(info, list) and len(info) assert all(isinstance(x, ContextKeyInfo) for x in info) assert "some_key" in {x.key for x in info} assert repr(key) == "Expr.parse('some_key')" assert repr(key == 1) == "Expr.parse('some_key == 1')" def _adder(x: list) -> int: return sum(x) def test_context_namespace() -> None: class Ns(ContextNamespace): my_key = ContextKey[list, int](0, "description", _adder) optional_key = ContextKey[None, str](description="might be missing") assert "my_key" in Ns.__members__ assert str(Ns.my_key) == "my_key" assert any(x.description == "description" for x in ContextKey.info()) # make sure the type hints were inferred from adder assert Ns.my_key.__orig_class__.__args__ == (list, int) # type: ignore assert isinstance(Ns.my_key, ContextKey) ctx: dict = {} ns = Ns(ctx) assert ns.my_key == 0 assert ctx["my_key"] == 0 ns.my_key = 2 assert ctx["my_key"] == 2 assert "optional_key" not in ctx assert ns.optional_key is ContextKey.MISSING ns.reset("optional_key") # shouldn't raise error to reset a missing key # maybe the key is there though ctx["optional_key"] = "hi" assert ns.optional_key == "hi" ns.reset_all() assert ctx["my_key"] == 0 assert "optional_key" not in ctx assert repr(ns) == "{'my_key': 0, 'optional_key': MISSING}" assert Ns.my_key.eval(ctx) == 0 def test_good_naming() -> None: with pytest.raises(RuntimeError): # you're not allowed to create a key with an id different from # it's attribute name class Ns(ContextNamespace): my_key = ContextKey(id="not_my_key") # type: ignore app-model-0.5.1/tests/test_context/test_expressions.py000066400000000000000000000163471510416065100232650ustar00rootroot00000000000000import ast from copy import deepcopy import pytest from app_model.expressions import Constant, Expr, Name, parse_expression, safe_eval from app_model.expressions._expressions import _OPS, _iter_names def test_names() -> None: expr = Name("n", bound=int) assert expr.eval({"n": 5}) == 5 # currently, evaludating with a missing name is an error. with pytest.raises(NameError): Name("n").eval() assert repr(Name("n")) == "Expr.parse('n')" def test_constants() -> None: assert Constant(1).eval() == 1 assert Constant(3.14).eval() == 3.14 assert Constant("asdf").eval() == "asdf" assert str(Constant("asdf")) == "'asdf'" assert str(Constant(r"asdf")) == "'asdf'" assert Constant(b"byte").eval() == b"byte" assert str(Constant(b"byte")) == "b'byte'" assert Constant(True).eval() is True assert Constant(False).eval() is False assert Constant(None).eval() is None assert repr(Constant(1)) == "Expr.parse('1')" # only {None, str, bytes, bool, int, float} allowed with pytest.raises(TypeError): Constant((1, 2)) # type: ignore def test_bool_ops() -> None: n1 = Name[bool]("n1") true = Constant(True) false = Constant(False) assert (n1 & true).eval({"n1": True}) is True assert (n1 & false).eval({"n1": True}) is False assert (n1 & false).eval({"n1": False}) is False assert (n1 | true).eval({"n1": True}) is True assert (n1 | false).eval({"n1": True}) is True assert (n1 | false).eval({"n1": False}) is False # real constants assert (n1 & True).eval({"n1": True}) is True assert (n1 & False).eval({"n1": True}) is False assert (n1 & False).eval({"n1": False}) is False assert (n1 | True).eval({"n1": True}) is True assert (n1 | False).eval({"n1": True}) is True assert (n1 | False).eval({"n1": False}) is False # when working with Expr objects: # the binary "op" & refers to the boolean op "and" assert str(Constant(1) & 1) == "1 and 1" # note: using "and" does NOT work to combine expressions # (in this case, it would just return the second value "1") assert not isinstance(Constant(1) and 1, Expr) def test_bin_ops() -> None: one = Constant(1) assert (one + 1).eval() == 2 assert (one - 1).eval() == 0 assert (one * 4).eval() == 4 assert (one / 4).eval() == 0.25 assert (one // 4).eval() == 0 assert (one % 2).eval() == 1 assert (one % 1).eval() == 0 assert (Constant(2) ** 2).eval() == 4 assert (one ^ 2).eval() == 3 assert (Constant(4) & Constant(16)).eval() == 16 assert (Constant(4) | Constant(16)).eval() == 4 assert (Constant(16).bitand(16)).eval() == 16 assert (Constant(16).bitor(4)).eval() == 20 def test_unary_ops() -> None: assert Constant(1).eval() == 1 assert (+Constant(1)).eval() == 1 assert (-Constant(1)).eval() == -1 assert Constant(True).eval() is True assert (~Constant(True)).eval() is False def test_comparison() -> None: n = Name[int]("n") n2 = Name[int]("n2") one = Constant(1) assert (n == n2).eval({"n": 2, "n2": 2}) assert not (n == n2).eval({"n": 2, "n2": 1}) assert (n != n2).eval({"n": 2, "n2": 1}) assert not (n != n2).eval({"n": 2, "n2": 2}) # real constant assert (n != 1).eval({"n": 2}) assert not (n != 2).eval({"n": 2}) assert (n < one).eval({"n": -1}) assert not (n < one).eval({"n": 2}) assert (n <= one).eval({"n": 0}) assert (n <= one).eval({"n": 1}) assert not (n <= one).eval({"n": 2}) # with real constant assert (n < 1).eval({"n": -1}) assert not (n < 1).eval({"n": 2}) assert (n <= 1).eval({"n": 0}) assert (n <= 1).eval({"n": 1}) assert not (n <= 1).eval({"n": 2}) assert (n > one).eval({"n": 2}) assert not (n > one).eval({"n": 1}) assert (n >= one).eval({"n": 2}) assert (n >= one).eval({"n": 1}) assert not (n >= one).eval({"n": 0}) # real constant assert (n > 1).eval({"n": 2}) assert not (n > 1).eval({"n": 1}) assert (n >= 1).eval({"n": 2}) assert (n >= 1).eval({"n": 1}) assert not (n >= 1).eval({"n": 0}) assert Expr.in_(Constant("a"), Constant("abcd")).eval() is True assert Constant("a").in_(Constant("abcd")).eval() is True assert Expr.not_in(Constant("a"), Constant("abcd")).eval() is False assert Constant("a").not_in(Constant("abcd")).eval() is False assert repr(n > n2) == "Expr.parse('n > n2')" def test_iter_names() -> None: expr = "a if b in c else d > e" a = parse_expression(expr) assert a is parse_expression(a) b = Expr.parse(expr) # alias assert sorted(_iter_names(a)) == ["a", "b", "c", "d", "e"] assert sorted(_iter_names(b)) == ["a", "b", "c", "d", "e"] with pytest.raises(RuntimeError): # don't directly instantiate Expr() GOOD_EXPRESSIONS = [ "a and b", "a == 1", "a @ 1", "2 & 4", "a if b == 7 else False", # valid constants: "1", "3.14", "True", "1 in {1, 2, 3}", "1 in [1, 2, 3]", "1 in (1, 2, 3)", "False", "None", "hieee", "b'bytes'", "1 < x < 2", ] for k, v in _OPS.items(): if issubclass(k, ast.unaryop): GOOD_EXPRESSIONS.append(f"{v} 1" if v == "not" else f"{v}1") elif v not in {"is", "is not"}: GOOD_EXPRESSIONS.append(f"1 {v} 2") # these are not supported BAD_EXPRESSIONS = [ "a orr b", # typo "a b", # invalid syntax "a = b", # Assign "my.attribute", # Attribute "__import__(something)", # Call 'print("hi")', '{"key": "val"}', # dicts not yet supported "mylist[0]", # Index "mylist[0:1]", # Slice 'f"a"', # JoinedStr "a := 1", # NamedExpr r'f"{a}"', # FormattedValue "[v for v in val]", # ListComp "{v for v in val}", # SetComp r"{k:v for k, v in val}", # DictComp "(v for v in val)", # GeneratorExp ] @pytest.mark.parametrize("expr", GOOD_EXPRESSIONS) def test_serdes(expr) -> None: assert str(parse_expression(expr)) == expr assert repr(parse_expression(expr)) # smoke test @pytest.mark.parametrize("expr", BAD_EXPRESSIONS) def test_bad_serdes(expr) -> None: with pytest.raises(SyntaxError): parse_expression(expr) def test_deepcopy_expression() -> None: deepcopy(parse_expression("1")) deepcopy(parse_expression("1 > 2")) deepcopy(parse_expression("1 & 2")) deepcopy(parse_expression("1 or 2")) deepcopy(parse_expression("not 1")) deepcopy(parse_expression("~x")) deepcopy(parse_expression("2 if x else 3")) def test_safe_eval() -> None: expr = "7 > x if x > 2 else 3" assert safe_eval(expr, {"x": 3}) is True assert safe_eval(expr, {"x": 10}) is False assert safe_eval(expr, {"x": 1}) == 3 assert safe_eval(True) is True assert safe_eval(False) is False assert safe_eval("[1,2,3]") == [1, 2, 3] assert safe_eval("(1,2,3)") == (1, 2, 3) assert safe_eval("{1,2,3}") == {1, 2, 3} with pytest.raises(SyntaxError, match="Type 'Call' not supported"): safe_eval("func(x)") def test_eval_kwargs() -> None: expr = parse_expression("a + b") assert expr.eval(a=1, b=2) == 3 assert expr.eval({"a": 2}, b=2) == 4 @pytest.mark.parametrize("expr", GOOD_EXPRESSIONS) def test_hash(expr) -> None: assert isinstance(hash(parse_expression(expr)), int) app-model-0.5.1/tests/test_key_codes.py000066400000000000000000000043511510416065100201150ustar00rootroot00000000000000from typing import Callable import pytest from app_model.types._constants import OperatingSystem from app_model.types._keys import KeyChord, KeyCode, KeyMod, ScanCode, SimpleKeyBinding from app_model.types._keys._key_codes import keycode_to_os_name, keycode_to_os_symbol def test_key_codes() -> None: for key in KeyCode: assert key == KeyCode.from_string(str(key)) assert KeyCode.from_event_code(65) == KeyCode.KeyA assert KeyCode.validate(int(KeyCode.KeyA)) == KeyCode.KeyA assert KeyCode.validate(KeyCode.KeyA) == KeyCode.KeyA assert KeyCode.validate("A") == KeyCode.KeyA with pytest.raises(TypeError, match="cannot convert"): KeyCode.validate({"a"}) @pytest.mark.parametrize("symbol_or_name", ["symbol", "name"]) @pytest.mark.parametrize( ("os", "key_symbols_func", "key_names_func"), [ (OperatingSystem.WINDOWS, keycode_to_os_symbol, keycode_to_os_name), (OperatingSystem.MACOS, keycode_to_os_symbol, keycode_to_os_name), (OperatingSystem.LINUX, keycode_to_os_symbol, keycode_to_os_name), ], ) def test_key_codes_to_os( symbol_or_name: str, os: OperatingSystem, key_symbols_func: Callable[[KeyCode, OperatingSystem], str], key_names_func: Callable[[KeyCode, OperatingSystem], str], ) -> None: os_method = f"os_{symbol_or_name}" key_map_func = key_symbols_func if symbol_or_name == "symbol" else key_names_func for key in KeyCode: assert getattr(key, os_method)(os) == key_map_func(key, os) def test_scan_codes() -> None: for scan in ScanCode: assert scan == ScanCode.from_string(str(scan)), scan def test_key_combo() -> None: """KeyCombo is an integer combination of one or more KeyMod and KeyCode.""" combo = KeyMod.Shift | KeyMod.Alt | KeyCode.KeyK assert repr(combo) == "" kb = SimpleKeyBinding.from_int(combo) assert kb == SimpleKeyBinding(shift=True, alt=True, key=KeyCode.KeyK) def test_key_chord() -> None: """KeyChord is an integer combination of two KeyCombos, KeyCodes, or integers.""" chord = KeyChord(KeyMod.CtrlCmd | KeyCode.KeyK, KeyCode.KeyM) assert int(chord) == 1968156 assert repr(chord) == "KeyChord(, )" app-model-0.5.1/tests/test_keybindings.py000066400000000000000000000132271510416065100204600ustar00rootroot00000000000000import itertools import sys import pytest from pydantic import BaseModel from app_model.types import ( KeyBinding, KeyBindingRule, KeyCode, KeyMod, SimpleKeyBinding, ) from app_model.types._constants import OperatingSystem from app_model.types._keys import KeyChord, KeyCombo, StandardKeyBinding MAC = sys.platform == "darwin" @pytest.mark.parametrize("use_symbols", [True, False]) @pytest.mark.parametrize( ("os", "joinchar", "expected_use_symbols", "expected_non_use_symbols"), [ (OperatingSystem.WINDOWS, "+", "โŠž+A", "Win+A"), (OperatingSystem.LINUX, "-", "Super-A", "Super-A"), (OperatingSystem.MACOS, "", "โŒ˜A", "CmdA"), ], ) def test_simple_keybinding_to_text( use_symbols: bool, os: OperatingSystem, joinchar: str, expected_use_symbols: str, expected_non_use_symbols: str, ) -> None: kb = SimpleKeyBinding.from_str("Meta+A") expected = expected_non_use_symbols if use_symbols: expected = expected_use_symbols assert kb.to_text(os=os, use_symbols=use_symbols, joinchar=joinchar) == expected @pytest.mark.parametrize("use_symbols", [True, False]) @pytest.mark.parametrize( ("os", "joinchar", "expected_use_symbols", "expected_non_use_symbols"), [ ( OperatingSystem.WINDOWS, "+", "Ctrl+A โ‡ง+[ Alt+/ โŠž+9", "Ctrl+A Shift+[ Alt+/ Win+9", ), ( OperatingSystem.LINUX, "-", "Ctrl-A โ‡ง-[ Alt-/ Super-9", "Ctrl-A Shift-[ Alt-/ Super-9", ), (OperatingSystem.MACOS, "", "โŒƒA โ‡ง[ โŒฅ/ โŒ˜9", "ControlA Shift[ Option/ Cmd9"), ], ) def test_keybinding_to_text( use_symbols: bool, os: OperatingSystem, joinchar: str, expected_use_symbols: str, expected_non_use_symbols: str, ) -> None: kb = KeyBinding.from_str("Ctrl+A Shift+[ Alt+/ Meta+9") expected = expected_non_use_symbols if use_symbols: expected = expected_use_symbols assert kb.to_text(os=os, use_symbols=use_symbols, joinchar=joinchar) == expected @pytest.mark.parametrize("key", list("ADgf`]/,")) @pytest.mark.parametrize("mod", ["ctrl", "shift", "alt", "meta", None]) def test_simple_keybinding_single_mod(mod: str, key: str) -> None: _mod = f"{mod}+" if mod else "" kb = SimpleKeyBinding.from_str(f"{_mod}{key}") assert str(kb).lower() == f"{_mod}{key}".lower() assert not kb.is_modifier_key() # we can compare it with another SimpleKeyBinding # using validate method just for test coverage... will pass to from_str assert kb == SimpleKeyBinding._parse_input(f"{_mod}{key}") # or with a string assert kb == f"{_mod}{key}" assert kb != ["A", "B"] # check type error during comparison # round trip to int assert isinstance(kb.to_int(), KeyCombo) # using validate method just for test coverage... will pass to from_int assert SimpleKeyBinding._parse_input(int(kb)) == kb assert SimpleKeyBinding._parse_input(kb) == kb # first part of a Keybinding is a simple keybinding as_full_kb = KeyBinding.validate(kb) assert as_full_kb.part0 == kb assert KeyBinding.validate(int(kb)).part0 == kb assert int(as_full_kb) == int(kb) def test_simple_keybinding_multi_mod() -> None: # here we're also testing that cmd and win get cast to 'KeyMod.CtrlCmd' kb = SimpleKeyBinding.from_str("cmd+shift+A") assert not kb.is_modifier_key() assert int(kb) & KeyMod.CtrlCmd | KeyMod.Shift kb = SimpleKeyBinding.from_str("win+shift+A") assert not kb.is_modifier_key() assert int(kb) & KeyMod.CtrlCmd | KeyMod.Shift kb = SimpleKeyBinding.from_str("win") # just a modifier assert kb.is_modifier_key() controls = ["ctrl", "control", "ctl", "โŒƒ", "^"] shifts = ["shift", "โ‡ง"] alts = ["alt", "opt", "option", "โŒฅ"] metas = ["meta", "super", "cmd", "command", "โŒ˜", "win", "windows", "โŠž"] delimiters = ["+", "-"] key = ["A"] combos = [ delim.join(x) for delim, *x in itertools.product(delimiters, controls, shifts, alts, metas, key) ] @pytest.mark.parametrize("key", combos) def test_keybinding_parser(key: str) -> None: # Test all the different ways to write the modifiers assert str(KeyBinding.from_str(key)) == "Ctrl+Shift+Alt+Meta+A" def test_chord_keybinding() -> None: kb = KeyBinding.from_str("Shift+A Cmd+9") assert len(kb) == 2 assert kb != "Shift+A Cmd+9" # comparison with string considered anti-pattern assert kb == KeyBinding.from_str("Shift+A Cmd+9") assert kb.part0 == SimpleKeyBinding(shift=True, key=KeyCode.KeyA) assert kb.part0 == "Shift+A" assert str(kb) in repr(kb) # round trip to int assert isinstance(kb.to_int(), KeyChord) # using validate method just for test coverage... will pass to from_int assert KeyBinding.validate(int(kb)) == kb assert KeyBinding.validate(kb) == kb def test_in_dict() -> None: a = SimpleKeyBinding.from_str("Shift+A") b = KeyBinding.from_str("Shift+B") try: kbs = { a: 0, b: 1, } except TypeError as e: if str(e).startswith("unhashable type"): pytest.fail(f"keybinds not hashable: {e}") else: raise e assert kbs[a] == 0 assert kbs[b] == 1 new_a = KeyBinding.from_str("Shift+A") with pytest.raises(KeyError): kbs[new_a] def test_in_model() -> None: class M(BaseModel): key: KeyBinding m = M(key="Shift+A B") assert m.model_dump_json() == '{"key":"Shift+A B"}' def test_standard_keybindings() -> None: class M(BaseModel): key: KeyBindingRule m = M(key=StandardKeyBinding.Copy) assert m.key.primary == KeyMod.CtrlCmd | KeyCode.KeyC app-model-0.5.1/tests/test_qt/000077500000000000000000000000001510416065100162175ustar00rootroot00000000000000app-model-0.5.1/tests/test_qt/__init__.py000066400000000000000000000001721510416065100203300ustar00rootroot00000000000000import pytest try: import qtpy # noqa except ImportError: pytest.skip("No Qt backend", allow_module_level=True) app-model-0.5.1/tests/test_qt/test_demos.py000066400000000000000000000006311510416065100207370ustar00rootroot00000000000000import runpy from pathlib import Path import pytest from qtpy.QtWidgets import QApplication DEMO = Path(__file__).parent.parent.parent / "demo" @pytest.mark.parametrize("fname", ["qapplication.py", "model_app.py", "multi_file"]) def test_qapp(qapp, fname, monkeypatch) -> None: monkeypatch.setattr(QApplication, "exec", lambda *a, **k: None) runpy.run_path(str(DEMO / fname), run_name="__main__") app-model-0.5.1/tests/test_qt/test_qactions.py000066400000000000000000000110361510416065100214520ustar00rootroot00000000000000from typing import TYPE_CHECKING from unittest.mock import Mock import pytest from app_model.backends.qt import QCommandRuleAction, QMenuItemAction from app_model.types import ( Action, CommandRule, KeyBindingRule, KeyBindingSource, KeyCode, MenuItem, ToggleRule, ) if TYPE_CHECKING: from app_model import Application from conftest import FullApp @pytest.mark.usefixtures("qapp") def test_cache_qaction(full_app: "FullApp") -> None: action = next( i for k, items in full_app.menus for i in items if isinstance(i, MenuItem) ) a1 = QMenuItemAction.create(action, full_app) a2 = QMenuItemAction.create(action, full_app) assert a1 is a2 assert repr(a1).startswith("QMenuItemAction") @pytest.mark.usefixtures("qapp") def test_toggle_qaction(simple_app: "Application") -> None: mock = Mock() x = False def current() -> bool: mock() return x def _toggle() -> None: nonlocal x x = not x action = Action( id="test.toggle", title="Test toggle", toggled=ToggleRule(get_current=current), callback=_toggle, ) simple_app.register_action(action) a1 = QCommandRuleAction(action, simple_app) mock.assert_called_once() mock.reset_mock() assert a1.isCheckable() assert not a1.isChecked() a1.trigger() assert a1.isChecked() assert x a1.trigger() assert not a1.isChecked() assert not x x = True a1._refresh() mock.assert_called_once() assert a1.isChecked() def test_icon_visible_in_menu(qapp, simple_app: "Application") -> None: rule = CommandRule(id="test", title="Test", icon_visible_in_menu=False) q_action = QCommandRuleAction(command_rule=rule, app=simple_app) assert not q_action.isIconVisibleInMenu() @pytest.mark.usefixtures("qapp") @pytest.mark.parametrize( ("tooltip", "expected_tooltip"), [ ("", "Test tooltip"), ("Test action with a tooltip", "Test action with a tooltip"), ], ) def test_tooltip( simple_app: "Application", tooltip: str, expected_tooltip: str ) -> None: action = Action( id="test.tooltip", title="Test tooltip", tooltip=tooltip, callback=lambda: None ) simple_app.register_action(action) q_action = QCommandRuleAction(action, simple_app) assert q_action.toolTip() == expected_tooltip @pytest.mark.usefixtures("qapp") @pytest.mark.parametrize( ("tooltip", "tooltip_with_keybinding", "tooltip_without_keybinding"), [ ("", "Test keybinding tooltip (K)", "Test keybinding tooltip"), ( "Test action with a tooltip and a keybinding", "Test action with a tooltip and a keybinding (K)", "Test action with a tooltip and a keybinding", ), ], ) def test_keybinding_in_tooltip( simple_app: "Application", tooltip: str, tooltip_with_keybinding: str, tooltip_without_keybinding: str, ) -> None: action = Action( id="test.keybinding.tooltip", title="Test keybinding tooltip", callback=lambda: None, tooltip=tooltip, keybindings=[KeyBindingRule(primary=KeyCode.KeyK)], ) simple_app.register_action(action) # check initial action instance shows keybinding info in its tooltip if available q_action = QCommandRuleAction(action, simple_app) assert q_action.toolTip() == tooltip_with_keybinding # check setting tooltip manually removes keybinding info q_action.setToolTip(tooltip) assert q_action.toolTip() == tooltip_without_keybinding @pytest.mark.usefixtures("qapp") def test_update_keybinding_in_tooltip( simple_app: "Application", ) -> None: action = Action( id="test.update.keybinding.tooltip", title="Test update keybinding tooltip", callback=lambda: None, tooltip="Initial tooltip", keybindings=[KeyBindingRule(primary=KeyCode.KeyK)], ) dispose1 = simple_app.register_action(action) q_action = QCommandRuleAction(action, simple_app) assert q_action.toolTip() == "Initial tooltip (K)" # Update the keybinding dispose2 = simple_app.keybindings.register_keybinding_rule( "test.update.keybinding.tooltip", KeyBindingRule(primary=KeyCode.KeyL, source=KeyBindingSource.USER), ) q_action._update_keybinding() assert q_action.toolTip() == "Initial tooltip (L)" dispose2() q_action._update_keybinding() assert q_action.toolTip() == "Initial tooltip (K)" dispose1() q_action._update_keybinding() assert q_action.toolTip() == "Initial tooltip" app-model-0.5.1/tests/test_qt/test_qkeybindingedit.py000066400000000000000000000006421510416065100230040ustar00rootroot00000000000000from qtpy.QtGui import QKeySequence from app_model.backends.qt import QModelKeyBindingEdit from app_model.types import KeyBinding, KeyCode, KeyMod def test_qkeysequenceedit(qtbot) -> None: edit = QModelKeyBindingEdit() qtbot.addWidget(edit) assert edit.keyBinding() is None edit.setKeySequence(QKeySequence("Shift+A")) assert edit.keyBinding() == KeyBinding(parts=[KeyMod.Shift | KeyCode.KeyA]) app-model-0.5.1/tests/test_qt/test_qkeymap.py000066400000000000000000000156501510416065100213060ustar00rootroot00000000000000# mypy: disable-error-code="operator" # pyright: reportOperatorIssue=false from unittest.mock import patch from qtpy.QtCore import Qt from qtpy.QtGui import QKeySequence from app_model.backends.qt import ( _qkeymap, qkey2modelkey, qkeysequence2modelkeybinding, qmods2modelmods, ) from app_model.backends.qt._qkeymap import modelkey2qkey from app_model.types import KeyBinding, KeyCode, KeyCombo, KeyMod # stuff we don't know how to deal with yet def test_modelkey_lookup() -> None: assert modelkey2qkey(KeyCode.KeyM) == Qt.Key.Key_M with patch.object(_qkeymap, "MAC", True): with patch.object(_qkeymap, "_mac_ctrl_meta_swapped", return_value=False): assert modelkey2qkey(KeyCode.Ctrl) == Qt.Key.Key_Control assert modelkey2qkey(KeyCode.Meta) == Qt.Key.Key_Meta with patch.object(_qkeymap, "_mac_ctrl_meta_swapped", return_value=True): assert modelkey2qkey(KeyCode.Meta) == Qt.Key.Key_Control assert modelkey2qkey(KeyCode.Ctrl) == Qt.Key.Key_Meta with patch.object(_qkeymap, "MAC", False): assert modelkey2qkey(KeyCode.Ctrl) == Qt.Key.Key_Control assert modelkey2qkey(KeyCode.Meta) == Qt.Key.Key_Meta def test_qkey_lookup() -> None: for keyname in (k for k in dir(Qt.Key) if k.startswith("Key")): key = getattr(Qt.Key, keyname) assert isinstance(qkey2modelkey(key), (KeyCode, KeyCombo)) assert qkey2modelkey(Qt.Key.Key_M) == KeyCode.KeyM with patch.object(_qkeymap, "MAC", True): with patch.object(_qkeymap, "_mac_ctrl_meta_swapped", return_value=False): assert qkey2modelkey(Qt.Key.Key_Control) == KeyCode.Ctrl assert qkey2modelkey(Qt.Key.Key_Meta) == KeyCode.Meta with patch.object(_qkeymap, "_mac_ctrl_meta_swapped", return_value=True): assert qkey2modelkey(Qt.Key.Key_Control) == KeyCode.Meta assert qkey2modelkey(Qt.Key.Key_Meta) == KeyCode.Ctrl with patch.object(_qkeymap, "MAC", False): assert qkey2modelkey(Qt.Key.Key_Control) == KeyCode.Ctrl assert qkey2modelkey(Qt.Key.Key_Meta) == KeyCode.Meta def test_qmod_lookup() -> None: assert qmods2modelmods(Qt.KeyboardModifier.ShiftModifier) == KeyMod.Shift assert qmods2modelmods(Qt.KeyboardModifier.AltModifier) == KeyMod.Alt with patch.object(_qkeymap, "MAC", True): with patch.object(_qkeymap, "_mac_ctrl_meta_swapped", return_value=False): assert ( qmods2modelmods(Qt.KeyboardModifier.ControlModifier) == KeyMod.WinCtrl ) assert qmods2modelmods(Qt.KeyboardModifier.MetaModifier) == KeyMod.CtrlCmd with patch.object(_qkeymap, "_mac_ctrl_meta_swapped", return_value=True): assert ( qmods2modelmods(Qt.KeyboardModifier.ControlModifier) == KeyMod.CtrlCmd ) assert qmods2modelmods(Qt.KeyboardModifier.MetaModifier) == KeyMod.WinCtrl with patch.object(_qkeymap, "MAC", False): assert qmods2modelmods(Qt.KeyboardModifier.ControlModifier) == KeyMod.CtrlCmd assert qmods2modelmods(Qt.KeyboardModifier.MetaModifier) == KeyMod.WinCtrl def test_qkeysequence2modelkeybinding() -> None: seq = QKeySequence( Qt.Modifier.SHIFT | Qt.Key.Key_M, Qt.KeyboardModifier.NoModifier | Qt.Key.Key_K ) app_key = KeyBinding(parts=[KeyMod.Shift | KeyCode.KeyM, KeyCode.KeyK]) assert qkeysequence2modelkeybinding(seq) == app_key seq = QKeySequence( Qt.Modifier.ALT | Qt.Key.Key_M, Qt.KeyboardModifier.NoModifier | Qt.Key.Key_K ) app_key = KeyBinding(parts=[KeyMod.Alt | KeyCode.KeyM, KeyCode.KeyK]) assert qkeysequence2modelkeybinding(seq) == app_key with patch.object(_qkeymap, "MAC", True): with patch.object(_qkeymap, "_mac_ctrl_meta_swapped", return_value=False): # on Macs, unswapped, Meta -> Cmd seq = QKeySequence( Qt.Modifier.META | Qt.Key.Key_M, Qt.KeyboardModifier.NoModifier | Qt.Key.Key_K, ) app_key = KeyBinding(parts=[KeyMod.CtrlCmd | KeyCode.KeyM, KeyCode.KeyK]) assert qkeysequence2modelkeybinding(seq) == app_key # on Macs, unswapped, Ctrl -> Ctrl seq = QKeySequence( Qt.Modifier.CTRL | Qt.Key.Key_M, Qt.KeyboardModifier.NoModifier | Qt.Key.Key_K, ) app_key = KeyBinding(parts=[KeyMod.WinCtrl | KeyCode.KeyM, KeyCode.KeyK]) assert qkeysequence2modelkeybinding(seq) == app_key seq = QKeySequence( Qt.Modifier.META | Qt.Key.Key_Meta, Qt.Modifier.CTRL | Qt.Key.Key_Control, ) app_key = KeyBinding( parts=[KeyMod.CtrlCmd | KeyCode.Meta, KeyMod.WinCtrl | KeyCode.Ctrl] ) assert qkeysequence2modelkeybinding(seq) == app_key with patch.object(_qkeymap, "_mac_ctrl_meta_swapped", return_value=True): # on Mac swapped, Ctrl -> Meta/Cmd seq = QKeySequence( Qt.Modifier.CTRL | Qt.Key.Key_M, Qt.KeyboardModifier.NoModifier | Qt.Key.Key_K, ) app_key = KeyBinding(parts=[KeyMod.CtrlCmd | KeyCode.KeyM, KeyCode.KeyK]) assert qkeysequence2modelkeybinding(seq) == app_key # on Mac swapped, Meta/Cmd -> Ctrl seq = QKeySequence( Qt.Modifier.META | Qt.Key.Key_M, Qt.KeyboardModifier.NoModifier | Qt.Key.Key_K, ) app_key = KeyBinding(parts=[KeyMod.WinCtrl | KeyCode.KeyM, KeyCode.KeyK]) assert qkeysequence2modelkeybinding(seq) == app_key seq = QKeySequence( Qt.Modifier.META | Qt.Key.Key_Meta, Qt.Modifier.CTRL | Qt.Key.Key_Control, ) app_key = KeyBinding( parts=[KeyMod.WinCtrl | KeyCode.Ctrl, KeyMod.CtrlCmd | KeyCode.Meta] ) assert qkeysequence2modelkeybinding(seq) == app_key with patch.object(_qkeymap, "MAC", False): # on Win/Unix, Ctrl -> Ctrl seq = QKeySequence( Qt.Modifier.CTRL | Qt.Key.Key_M, Qt.KeyboardModifier.NoModifier | Qt.Key.Key_K, ) app_key = KeyBinding(parts=[KeyMod.CtrlCmd | KeyCode.KeyM, KeyCode.KeyK]) assert qkeysequence2modelkeybinding(seq) == app_key # on Win, Meta -> Win, on Unix, Meta -> Super seq = QKeySequence( Qt.Modifier.META | Qt.Key.Key_M, Qt.KeyboardModifier.NoModifier | Qt.Key.Key_K, ) app_key = KeyBinding(parts=[KeyMod.WinCtrl | KeyCode.KeyM, KeyCode.KeyK]) assert qkeysequence2modelkeybinding(seq) == app_key seq = QKeySequence( Qt.Modifier.META | Qt.Key.Key_Meta, Qt.Modifier.CTRL | Qt.Key.Key_Control, ) app_key = KeyBinding( parts=[KeyMod.WinCtrl | KeyCode.Meta, KeyMod.CtrlCmd | KeyCode.Ctrl] ) assert qkeysequence2modelkeybinding(seq) == app_key app-model-0.5.1/tests/test_qt/test_qmainwindow.py000066400000000000000000000026001510416065100221630ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING from qtpy.QtCore import Qt from qtpy.QtWidgets import QAction, QApplication from app_model.backends.qt import QModelMainWindow, QModelToolBar from app_model.types._action import Action if TYPE_CHECKING: from pytestqt.qtbot import QtBot from ..conftest import FullApp # noqa: TID252 def test_qmodel_main_window( qtbot: QtBot, qapp: QApplication, full_app: FullApp ) -> None: win = QModelMainWindow(full_app) qtbot.addWidget(win) win.setModelMenuBar( { full_app.Menus.FILE: "File", full_app.Menus.EDIT: "Edit", full_app.Menus.HELP: "Help", } ) assert [a.text() for a in win.menuBar().actions()] == ["File", "Edit", "Help"] tb = win.addModelToolBar( full_app.Menus.FILE, toolbutton_style=Qt.ToolButtonStyle.ToolButtonTextBesideIcon, ) assert isinstance(tb, QModelToolBar) win.addModelToolBar(full_app.Menus.EDIT, area=Qt.ToolBarArea.RightToolBarArea) full_app.register_action( Action( id="late-action", title="Late Action", keybindings=[{"primary": "Shift+L"}], menus=[{"id": full_app.Menus.FILE}], callback=lambda: None, ) ) action = qapp.findChild(QAction, "late-action") assert action.shortcut().toString() == "Shift+L" app-model-0.5.1/tests/test_qt/test_qmenu.py000066400000000000000000000136341510416065100207640ustar00rootroot00000000000000from __future__ import annotations import sys from typing import TYPE_CHECKING, cast import pytest from qtpy.QtCore import Qt from qtpy.QtWidgets import QAction, QMainWindow from app_model.backends.qt import QModelMenu, QModelToolBar if TYPE_CHECKING: from pytestqt.plugin import QtBot from conftest import FullApp SEP = "" LINUX = sys.platform.startswith("linux") @pytest.mark.parametrize("MenuCls", [QModelMenu, QModelToolBar]) def test_menu( MenuCls: type[QModelMenu] | type[QModelToolBar], qtbot: QtBot, full_app: FullApp ) -> None: app = full_app menu = MenuCls(app.Menus.EDIT, app, title="just-for-coverage") qtbot.addWidget(menu) # The "" are separators, according to our group settings in full_app menu_texts = [a.text() for a in menu.actions()] assert menu_texts == ["AtTop", SEP, "Undo", "Redo", SEP, "Copy", "Paste"] # check that triggering the actions calls the associated commands for cmd in (app.Commands.UNDO, app.Commands.REDO): action = cast("QAction", menu.findAction(cmd)) with qtbot.waitSignal(action.triggered): action.trigger() getattr(app.mocks, cmd).assert_called_once() redo_action = cast("QAction", menu.findAction(app.Commands.REDO)) assert redo_action.isVisible() assert redo_action.isEnabled() # change that visibility and enablement follows the context menu.update_from_context({"allow_undo_redo": True, "something_to_undo": False}) assert redo_action.isVisible() assert redo_action.isEnabled() menu.update_from_context({"allow_undo_redo": False, "something_to_undo": False}) assert redo_action.isVisible() assert not redo_action.isEnabled() menu.update_from_context({"allow_undo_redo": False, "something_to_undo": True}) assert not redo_action.isVisible() assert not redo_action.isEnabled() menu.update_from_context({"allow_undo_redo": True, "something_to_undo": False}) assert redo_action.isVisible() assert redo_action.isEnabled() # useful error when we forget a required name with pytest.raises(NameError, match="Names required to eval this expression"): menu.update_from_context({}) menu._disconnect() def test_submenu(qtbot: QtBot, full_app: FullApp) -> None: app = full_app menu = QModelMenu(app.Menus.FILE, app) qtbot.addWidget(menu) menu_texts = [a.text() for a in menu.actions()] assert menu_texts == ["Open From...", "Open..."] submenu = menu.findChild(QModelMenu, app.Menus.FILE_OPEN_FROM) submenu_action = submenu.findAction(app.Commands.OPEN_FROM_B) assert submenu_action assert isinstance(submenu, QModelMenu) submenu.setVisible(True) assert submenu.isVisible() assert submenu.isEnabled() menu.aboutToShow.emit() # for test coverage # "not something_open" is the when clause # "friday" is the enablement clause menu.update_from_context({"something_open": False, "friday": True, "sat": True}) assert submenu.isVisible() assert submenu.isEnabled() assert submenu_action.isVisible() assert submenu_action.isEnabled() menu.update_from_context({"something_open": False, "friday": False, "sat": True}) assert submenu.isVisible() assert not submenu.isEnabled() assert submenu_action.isVisible() assert submenu_action.isEnabled() menu.update_from_context({"something_open": True, "friday": False, "sat": True}) assert not submenu.isEnabled() assert submenu_action.isEnabled() menu.update_from_context({"something_open": True, "friday": True, "sat": True}) assert submenu.isEnabled() assert submenu_action.isEnabled() menu.update_from_context({"something_open": True, "friday": True, "sat": False}) assert submenu.isEnabled() assert not submenu_action.isEnabled() @pytest.mark.filterwarnings("ignore:QPixmapCache.find:") @pytest.mark.skipif(LINUX, reason="Linux keytest not working") def test_shortcuts(qtbot: QtBot, full_app: FullApp) -> None: app = full_app win = QMainWindow() menu = QModelMenu(app.Menus.EDIT, app=app, title="Edit", parent=win) win.menuBar().addMenu(menu) qtbot.addWidget(win) qtbot.addWidget(menu) with qtbot.waitExposed(win): win.show() copy_action = menu.findAction(app.Commands.COPY) with qtbot.waitSignal(copy_action.triggered, timeout=2000): qtbot.keyClicks(win, "C", Qt.KeyboardModifier.ControlModifier) paste_action = menu.findAction(app.Commands.PASTE) with qtbot.waitSignal(paste_action.triggered, timeout=1000): qtbot.keyClicks(win, "V", Qt.KeyboardModifier.ControlModifier) def test_toggled_menu_item(qtbot: QtBot, full_app: FullApp) -> None: app = full_app menu = QModelMenu(app.Menus.HELP, app) qtbot.addWidget(menu) menu.update_from_context({"thing_toggled": True}) action = menu.findAction(app.Commands.TOGGLE_THING) assert action.isChecked() menu.update_from_context({"thing_toggled": False}) assert not action.isChecked() @pytest.mark.parametrize("MenuCls", [QModelMenu, QModelToolBar]) def test_menu_events( MenuCls: type[QModelMenu] | type[QModelToolBar], qtbot: QtBot, full_app: FullApp ) -> None: app = full_app menu = MenuCls(app.Menus.EDIT, app) qtbot.addWidget(menu) # The "" are separators, according to our group settings in full_app menu_texts = [a.text() for a in menu.actions()] assert menu_texts == ["AtTop", SEP, "Undo", "Redo", SEP, "Copy", "Paste"] # simulate something changing the edit menu... normally this would be # triggered by a dispose() call, but that's a bit hard to do currently with the # test app fixture. copy_item = next( x for x in full_app.menus._menu_items["edit"] if x.command.title == "Copy" ) full_app.menus._menu_items["edit"].pop(copy_item) full_app.menus.menus_changed.emit(app.Menus.EDIT) menu_texts = [a.text() for a in menu.actions()] assert menu_texts == ["AtTop", SEP, "Undo", "Redo", SEP, "Paste"] app-model-0.5.1/tests/test_registries.py000066400000000000000000000165741510416065100203420ustar00rootroot00000000000000# mypy: disable-error-code="var-annotated" import pytest from app_model.registries import KeyBindingsRegistry, MenusRegistry from app_model.registries._keybindings_reg import _RegisteredKeyBinding from app_model.types import ( Action, KeyBinding, KeyBindingRule, KeyBindingSource, KeyCode, KeyMod, MenuItem, ) def _noop() -> None: pass def test_menus_registry() -> None: reg = MenusRegistry() reg.append_menu_items([("file", {"command": {"id": "file.new", "title": "File"}})]) reg.append_menu_items([("file.sub", {"submenu": "Sub", "title": "SubTitle"})]) assert isinstance(reg.get_menu("file")[0], MenuItem) assert "(2 menus)" in repr(reg) assert "File" in str(reg) assert "Sub" in str(reg) # ok to change def test_keybindings_registry() -> None: reg = KeyBindingsRegistry() assert "(0 bindings)" in repr(reg) def test_register_keybinding_rule_filter_type() -> None: """Check `_filter_keybinding` type checking when setting.""" reg = KeyBindingsRegistry() with pytest.raises(TypeError, match="'filter_keybinding' must be a callable"): reg.filter_keybinding = "string" # type: ignore def _filter_fun(kb: KeyBinding) -> str: if kb.part0.is_modifier_key(): return "modifier only sequences not allowed" return "" def test_register_keybinding_rule_filter_get() -> None: """Check `_filter_keybinding` getter.""" reg = KeyBindingsRegistry() reg.filter_keybinding = _filter_fun assert callable(reg.filter_keybinding) def test_register_keybinding_rule_filter() -> None: """Check `filter_keybinding` in `register_keybinding_rule`.""" reg = KeyBindingsRegistry() reg.filter_keybinding = _filter_fun # Valid keybinding kb = KeyBindingRule(primary=KeyMod.CtrlCmd | KeyCode.KeyO) reg.register_keybinding_rule("test", kb) # Invalid keybinding kb = KeyBindingRule(primary=KeyMod.Alt) with pytest.raises(ValueError, match=r"Alt\+: modifier only"): reg.register_keybinding_rule("test", kb) @pytest.mark.parametrize( "kb, msg", [ ( [ {"primary": KeyMod.CtrlCmd | KeyCode.KeyA}, {"primary": KeyMod.Shift | KeyCode.KeyC}, ], "", ), ( [{"primary": KeyMod.Alt}, {"primary": KeyMod.Shift}], r"Alt\+: modifier only sequences not allowed\nShift\+: modifier", ), ], ) def test_register_action_keybindings_filter(kb: list[dict], msg: str) -> None: """Check `filter_keybinding` in `register_action_keybindings`.""" reg = KeyBindingsRegistry() reg.filter_keybinding = _filter_fun action = Action( id="cmd_id1", title="title1", callback=_noop, keybindings=kb, ) if msg: with pytest.raises(ValueError, match=msg): reg.register_action_keybindings(action) else: reg.register_action_keybindings(action) @pytest.mark.parametrize( "kb1, kb2, kb3", [ ( [ { "primary": KeyMod.CtrlCmd | KeyCode.KeyA, "when": "active", "weight": 10, }, ], [ { "primary": KeyMod.CtrlCmd | KeyCode.KeyA, }, ], [ {"primary": KeyMod.CtrlCmd | KeyCode.KeyA, "weight": 5}, ], ), ], ) def test_register_action_keybindings_prioritization( kb1: str, kb2: str, kb3: str ) -> None: """Check `get_context_prioritized_keybinding`.""" reg = KeyBindingsRegistry() action1 = Action( id="cmd_id1", title="title1", callback=_noop, keybindings=kb1, ) reg.register_action_keybindings(action1) action2 = Action( id="cmd_id2", title="title2", callback=_noop, keybindings=kb2, ) reg.register_action_keybindings(action2) action3 = Action( id="cmd_id3", title="title3", callback=_noop, keybindings=kb3, ) reg.register_action_keybindings(action3) keybinding = reg.get_context_prioritized_keybinding( kb1[0]["primary"], {"active": False} ) assert keybinding.command_id == "cmd_id3" keybinding = reg.get_context_prioritized_keybinding( kb1[0]["primary"], {"active": True} ) assert keybinding.command_id == "cmd_id1" keybinding = reg.get_context_prioritized_keybinding( KeyMod.Shift | kb1[0]["primary"], {"active": True} ) assert keybinding is None @pytest.mark.parametrize( "kb1, kb2, gt, lt, eq", [ ( { "primary": KeyMod.CtrlCmd | KeyCode.KeyA, "command_id": "command_id1", "weight": 0, "when": None, "source": KeyBindingSource.USER, }, { "primary": KeyMod.CtrlCmd | KeyCode.KeyA, "command_id": "command_id2", "weight": 0, "when": None, "source": KeyBindingSource.APP, }, True, False, False, ), ( { "primary": KeyMod.CtrlCmd | KeyCode.KeyA, "command_id": "command_id1", "weight": 0, "when": None, "source": KeyBindingSource.USER, }, { "primary": KeyMod.CtrlCmd | KeyCode.KeyA, "command_id": "command_id2", "weight": 10, "when": None, "source": KeyBindingSource.APP, }, True, False, False, ), ( { "primary": KeyMod.CtrlCmd | KeyCode.KeyA, "command_id": "command_id1", "weight": 0, "when": None, "source": KeyBindingSource.USER, }, { "primary": KeyMod.CtrlCmd | KeyCode.KeyA, "command_id": "command_id2", "weight": 10, "when": None, "source": KeyBindingSource.USER, }, False, True, False, ), ( { "primary": KeyMod.CtrlCmd | KeyCode.KeyA, "command_id": "command_id1", "weight": 10, "when": None, "source": KeyBindingSource.USER, }, { "primary": KeyMod.CtrlCmd | KeyCode.KeyA, "command_id": "command_id2", "weight": 10, "when": None, "source": KeyBindingSource.USER, }, False, False, True, ), ], ) def test_registered_keybinding_comparison( kb1: dict, kb2: dict, gt: bool, lt: bool, eq: bool ) -> None: rkb1 = _RegisteredKeyBinding( keybinding=kb1["primary"], command_id=kb1["command_id"], weight=kb1["weight"], when=kb1["when"], source=kb1["source"], ) rkb2 = _RegisteredKeyBinding( keybinding=kb2["primary"], command_id=kb2["command_id"], weight=kb2["weight"], when=kb2["when"], source=kb2["source"], ) assert (rkb1 > rkb2) == gt assert (rkb1 < rkb2) == lt assert (rkb1 == rkb2) == eq app-model-0.5.1/tests/test_types.py000066400000000000000000000012561510416065100173150ustar00rootroot00000000000000import pytest from pydantic import ValidationError from app_model.types import Action, Icon def test_icon_validate() -> None: assert Icon._validate('"fa6s.arrow_down"') == Icon( dark='"fa6s.arrow_down"', light='"fa6s.arrow_down"' ) def test_action_validation() -> None: with pytest.raises(ValidationError, match="'s!adf' is not a valid python_name"): Action(id="test", title="test", callback="s!adf") with pytest.raises(ValidationError): Action(id="test", title="test", callback=[]) with pytest.raises(ValidationError, match=r"'x\.\\:asdf' is not a valid"): Action(id="test", title="test", callback="x.:asdf")