diff --git a/.commitlintrc.yml b/.commitlintrc.yml new file mode 100644 index 0000000..455e892 --- /dev/null +++ b/.commitlintrc.yml @@ -0,0 +1,30 @@ +extends: + - '@commitlint/config-conventional' + +rules: + type-enum: + - 2 + - always + - - build + - chore + - ci + - docs + - feat + - fix + - perf + - refactor + - revert + - style + - test + scope-case: + - 0 + subject-case: + - 0 + header-max-length: + - 1 + - always + - 100 + body-max-line-length: + - 0 + footer-max-line-length: + - 0 diff --git a/.github/workflows/pr-lint.yml b/.github/workflows/pr-lint.yml new file mode 100644 index 0000000..f616d1a --- /dev/null +++ b/.github/workflows/pr-lint.yml @@ -0,0 +1,46 @@ +name: PR Lint + +on: + pull_request: + types: [opened, edited, synchronize, reopened] + +permissions: + pull-requests: read + +jobs: + pr-title: + name: PR title (Conventional Commits) + runs-on: ubuntu-latest + steps: + - name: Validate PR title + uses: amannn/action-semantic-pull-request@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + types: | + build + chore + ci + docs + feat + fix + perf + refactor + revert + style + test + requireScope: false + subjectPattern: ^[a-z].+[^.]$ + subjectPatternError: | + Subject "{subject}" must start with a lowercase letter and must not + end with a period. + Example: "feat(cli): add init subcommand" + + commits: + name: Commit messages + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: wagoid/commitlint-github-action@v6 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 07450cc..20ac529 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,31 +1,47 @@ -name: Release to PyPI +name: Release on: push: - tags: - - 'v*.*.*' + branches: [main] + workflow_dispatch: jobs: - build-and-publish: + release: + name: Semantic Release runs-on: ubuntu-latest + concurrency: + group: release + cancel-in-progress: false permissions: - contents: read + contents: write id-token: write + steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.9' + python-version: '3.12' + + - name: Install build tools + run: python -m pip install -U pip build + + - name: Python Semantic Release + id: release + uses: python-semantic-release/python-semantic-release@v9 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} - - name: Build sdist and wheel - run: | - python -m pip install -U pip build - python -m build + - name: Build package + if: steps.release.outputs.released == 'true' + run: python -m build - - name: Publish package to PyPI + - name: Publish to PyPI + if: steps.release.outputs.released == 'true' uses: pypa/gh-action-pypi-publish@release/v1 with: skip-existing: true diff --git a/.gitignore b/.gitignore index c5de671..4f1c160 100644 --- a/.gitignore +++ b/.gitignore @@ -162,3 +162,6 @@ cython_debug/ # Metadata *_metadata.json + +# macOS +.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..430eac1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,163 @@ +# CHANGELOG + + +## v0.4.0 (2026-03-18) + +### Bug Fixes + +- **components,templates**: Restore hello-world on iOS and Android + ([`d7ac93b`](https://github.com/pythonnative/pythonnative/commit/d7ac93be202161a5c8328816a5c6ff8a96dde1d5)) + +### Continuous Integration + +- **workflows**: Add semantic-release pipeline and PR commit linting + ([`0711683`](https://github.com/pythonnative/pythonnative/commit/0711683f5b56751027bb1a5a63ee2d9afcd4b620)) + +- **workflows**: Append detailed changes link to release notes + ([`11d50a7`](https://github.com/pythonnative/pythonnative/commit/11d50a75dff850a3855a299f38f5885cf15cefc6)) + +- **workflows**: Fix duplicate release, and use changelog for release notes + ([`1cd5393`](https://github.com/pythonnative/pythonnative/commit/1cd5393e7bf20d5350052cfaa81fd511dc4ca3ca)) + +- **workflows**: Simplify release pipeline to use python-semantic-release defaults + ([`2766f24`](https://github.com/pythonnative/pythonnative/commit/2766f244f84d359e1ae74a4b029e0701fad4b0be)) + +### Documentation + +- **repo**: Rewrite README with banner, structured sections, and badges + ([`7c083f4`](https://github.com/pythonnative/pythonnative/commit/7c083f4e38367c6cd4163e0be8c78da1fdf8d3da)) + +- **repo**: Simplify README with badges and one-paragraph overview + ([`3ac84b1`](https://github.com/pythonnative/pythonnative/commit/3ac84b1a3f541b47121b46a687b78826f8d348f9)) + +### Features + +- **components**: Standardize fluent setters and align base signatures + ([`d236d89`](https://github.com/pythonnative/pythonnative/commit/d236d899690a4033effdcab4862a556a742fa6d1)) + +- **components,core**: Add layout/styling APIs and fluent setters + ([`6962d38`](https://github.com/pythonnative/pythonnative/commit/6962d3881bf091b3494fc2c964f7ea65a99ce606)) + +### Refactoring + +- **components**: Declare abstract static wrap in ScrollViewBase + ([`593fee4`](https://github.com/pythonnative/pythonnative/commit/593fee4fcf66678cb026de58115f959633d859b4)) + +- **core,components,examples**: Add annotations; tighten mypy + ([`86e4ffc`](https://github.com/pythonnative/pythonnative/commit/86e4ffc9e51810997006055434783416784c182f)) + + +## v0.3.0 (2025-10-22) + +### Build System + +- **repo**: Remove invalid PyPI classifier + ([`c8552e1`](https://github.com/pythonnative/pythonnative/commit/c8552e137e0176c0f5c61193e786429e2e93ac7c)) + +### Chores + +- **experiments**: Remove experiments directory + ([`caf6993`](https://github.com/pythonnative/pythonnative/commit/caf69936e085a3f487123ebcb3a6d807fefcc66c)) + +- **repo,core,mkdocs**: Bump version to 0.3.0 + ([`64d7c1c`](https://github.com/pythonnative/pythonnative/commit/64d7c1cfb448797305efc7f4014e56584f92fc1a)) + +### Documentation + +- **mkdocs**: Add Architecture page + ([`6d61ffc`](https://github.com/pythonnative/pythonnative/commit/6d61ffc64ca5db8ae688d09a748ddda2a1bc0af6)) + +### Features + +- **core,templates**: Add push/pop navigation and lifecycle wiring + ([`06ea22d`](https://github.com/pythonnative/pythonnative/commit/06ea22d215a1700685a7ca8070ca2189895ed25c)) + +- **templates,core**: Adopt Fragment-based Android navigation + ([`7a3a695`](https://github.com/pythonnative/pythonnative/commit/7a3a695477ece3cf76afd00f203523990f8789df)) + + +## v0.2.0 (2025-10-14) + +### Build System + +- **templates,cli**: Ship template dirs with package; drop zip artifacts + ([`7725b14`](https://github.com/pythonnative/pythonnative/commit/7725b1462c42d89f27fb4d3d733e73177c55d8ac)) + +### Chores + +- Clean up + ([`6c7a882`](https://github.com/pythonnative/pythonnative/commit/6c7a882895691903457a0a94d33192b6018c77fd)) + +- **core,components,cli**: Align lint, typing, and tests with CI + ([`30037d1`](https://github.com/pythonnative/pythonnative/commit/30037d17ad397952a88e3dfeb8bd003ced7319d8)) + +- **experiments**: Remove unused experiment directories + ([`db06fd1`](https://github.com/pythonnative/pythonnative/commit/db06fd101789392deee8c37263a61ee4d7106853)) + +- **repo,ci,docs**: Rename demo to examples/hello-world and update refs + ([`6d5b78e`](https://github.com/pythonnative/pythonnative/commit/6d5b78ea7dce66b5031b952928aed8d4a713fae8)) + +- **repo,core,mkdocs**: Bump version to 0.2.0 + ([`d3f8d31`](https://github.com/pythonnative/pythonnative/commit/d3f8d31942c3ca5d1657024e3a5cb332787afcd8)) + +- **templates**: Scrub DEVELOPMENT_TEAM from iOS template + ([`64ab266`](https://github.com/pythonnative/pythonnative/commit/64ab2666fe09f036934d3922ab55e8e599df3c35)) + +### Continuous Integration + +- **workflows,mkdocs**: Set CNAME to docs.pythonnative.com for docs deploy + ([`401a076`](https://github.com/pythonnative/pythonnative/commit/401a076dcb1fe0c19771f4a19141ee8da28c80e2)) + +### Documentation + +- **mkdocs**: Add roadmap and link in nav + ([`16ede97`](https://github.com/pythonnative/pythonnative/commit/16ede972d41b549853962c7056b65558c9ebd2f5)) + +- **mkdocs**: Update Getting Started, Hello World, Components, and platform guides + ([`f3a03b0`](https://github.com/pythonnative/pythonnative/commit/f3a03b01986365063535a2f336793cc6f21836db)) + +- **repo**: Add CONTRIBUTING.md + ([`f61cb85`](https://github.com/pythonnative/pythonnative/commit/f61cb85301c7bff57299b4c814319e9262f0f5ef)) + +### Features + +- Update README + ([`e839585`](https://github.com/pythonnative/pythonnative/commit/e8395855acf5d38a0e5987475900f4eeb1eee313)) + +- **cli,mkdocs,tests**: Add pn init/run/clean; use bundled templates + ([`9c61757`](https://github.com/pythonnative/pythonnative/commit/9c61757713fe60b5e98756f552681a782f397f3a)) + +- **cli,templates**: Auto-select iOS sim; guard PythonKit + ([`7b7c59c`](https://github.com/pythonnative/pythonnative/commit/7b7c59c262f2510a5fb46e455c13a2fc56086845)) + +- **cli,templates**: Bundle offline templates; add run --prepare-only + ([`d9dd821`](https://github.com/pythonnative/pythonnative/commit/d9dd821bc18289f1f1a367e737cfe7d5bfaf6ee3)) + +- **cli,templates**: Dev-first templates; stage in-repo lib for pn run + ([`b3dd731`](https://github.com/pythonnative/pythonnative/commit/b3dd731bd5efcca8e1a47f8f888fc6123854a40c)) + +- **cli,templates,core**: Bootstrap entrypoint; pn run shows Hello UI + ([`2805e1d`](https://github.com/pythonnative/pythonnative/commit/2805e1d5c6a58eb718b94ba0ce57c1078a08d578)) + +- **cli,templates,core**: Fetch iOS Python runtime and bootstrap PythonKit + ([`bcc0916`](https://github.com/pythonnative/pythonnative/commit/bcc0916a5b7427874ab7a5971a6a9941c4222c77)) + +- **components,utils**: Unify constructors; set Android context + ([`4c06b67`](https://github.com/pythonnative/pythonnative/commit/4c06b67214ea7fc4530a0d39b7105cfb62d20cf5)) + +- **repo,mkdocs,workflows**: Migrate to src layout; add pyproject and docs scaffold + ([`f273922`](https://github.com/pythonnative/pythonnative/commit/f273922e8a0494df7ba2cd59a3ad2ef54f918d3e)) + +### Refactoring + +- **cli**: Make pn.py typing py3.9-compatible and wrap long lines + ([`b38da78`](https://github.com/pythonnative/pythonnative/commit/b38da78dac52e42968efa6f4115b6b84de65b3b5)) + +- **components,core**: Align component names with docs + ([`a326ceb`](https://github.com/pythonnative/pythonnative/commit/a326ceb23c2cfaba409f11451a1c0000f0afbf5e)) + + +## v0.1.0 (2025-10-14) + + +## v0.0.1 (2025-10-14) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e0298c8..fe5b6ab 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -205,19 +205,26 @@ Co-authored-by: Name ## Pull request checklist +- PR title: Conventional Commits format (CI-enforced by `pr-lint.yml`). - Tests: added/updated; `pytest` passes. - Lint/format: `ruff check .`, `black` pass. -- Docs: update `README.md` and any Django docs pages if behavior changes. +- Docs: update `README.md` if behavior changes. - Templates: update `templates/` if generator output changes. - No generated artifacts committed. ## Versioning and releases -- The library version is tracked in `pyproject.toml` (`project.version`). Use SemVer. -- Workflow: - - Contributors: branch off `main` (or `dev` if used) and open PRs. - - Maintainer (release): bump version, tag, and publish to PyPI. - - Tag on `main`: `git tag -a vX.Y.Z -m "Release vX.Y.Z" && git push --tags`. +- The version is tracked in `pyproject.toml` (`project.version`) and mirrored in `src/pythonnative/__init__.py` as `__version__`. Both files are updated automatically by [python-semantic-release](https://python-semantic-release.readthedocs.io/). +- **Automated release pipeline** (on every merge to `main`): + 1. `python-semantic-release` scans Conventional Commit messages since the last tag. + 2. It determines the next SemVer bump: `feat` → **minor**, `fix`/`perf` → **patch**, `BREAKING CHANGE` → **major** (minor while version < 1.0). + 3. Version files are updated, `CHANGELOG.md` is generated, and a tagged release commit (`chore(release): vX.Y.Z`) is pushed. + 4. A GitHub Release is created with auto-generated release notes and the built sdist/wheel attached. + 5. When drafts are disabled, the package is also published to PyPI via Trusted Publishing. +- **Draft / published toggle**: the `DRAFT_RELEASE` variable at the top of `.github/workflows/release.yml` controls release mode. Set to `"true"` (the default) for draft GitHub Releases with PyPI publishing skipped; flip to `"false"` to publish releases and upload to PyPI immediately. +- Commit types that trigger a release: `feat` (minor), `fix` and `perf` (patch), `BREAKING CHANGE` (major). All other types (`build`, `chore`, `ci`, `docs`, `refactor`, `revert`, `style`, `test`) are recorded in the changelog but do **not** trigger a release on their own. +- Tag format: `v`-prefixed (e.g., `v0.4.0`). +- Manual version bumps are no longer needed — just merge PRs with valid Conventional Commit titles. For ad-hoc runs, use the workflow's **Run workflow** button (`workflow_dispatch`). ### Branch naming (suggested) @@ -249,6 +256,13 @@ release/v0.2.0 hotfix/cli-regression ``` +### CI + +- **CI** (`ci.yml`): runs formatter, linter, type checker, and tests on every push and PR. +- **PR Lint** (`pr-lint.yml`): validates the PR title against Conventional Commits format (protects squash merges) and checks individual commit messages via commitlint (protects rebase merges). Recommended: add the **PR title** job as a required status check in branch-protection settings. +- **Release** (`release.yml`): runs on merge to `main`; computes version, generates changelog, tags, creates GitHub Release, and (when `DRAFT_RELEASE` is `"false"`) publishes to PyPI. +- **Docs** (`docs.yml`): deploys documentation to GitHub Pages on push to `main`. + ## Security and provenance - Avoid bundling secrets or credentials in templates or code. diff --git a/LICENSE b/LICENSE index 18fff31..ad12cea 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 PythonNative +Copyright (c) 2026 Owen Carey Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 4418c1b..28f1672 100644 --- a/README.md +++ b/README.md @@ -1,74 +1,80 @@ -# PythonNative +

+ PythonNative +

-**PythonNative** is a cross-platform toolkit that allows you to create native Android and iOS apps using Python. Inspired by frameworks like React Native and NativeScript, PythonNative provides a Pythonic interface for building native UI elements, handling lifecycle events, and accessing platform-specific APIs. +

+ Build native Android and iOS apps in Python. +

-## Features +

+ CI + Release + PyPI Version + Python Versions + License: MIT + Docs +

-- **Native UI Components**: Create and manage native buttons, labels, lists, and more, all from Python. -- **Cross-Platform**: Write once, run on both Android and iOS. -- **Lifecycle Management**: Handle app lifecycle events with ease. -- **Native API Access**: Access device features like Camera, Geolocation, and Notifications. -- **Powered by Proven Tools**: PythonNative integrates seamlessly with [Rubicon](https://beeware.org/project/projects/bridges/rubicon/) for iOS and [Chaquopy](https://chaquo.com/chaquopy/) for Android, ensuring robust native performance. +

+ Documentation · + Getting Started · + Examples · + Contributing +

-## Quick Start +--- -### Installation +## Overview -First, install PythonNative via pip: +PythonNative is a cross-platform toolkit for building native Android and iOS apps in Python. It provides a Pythonic API for native UI components, lifecycle events, and device capabilities, powered by Chaquopy on Android and rubicon-objc on iOS. Write your app once in Python and run it on both platforms with genuinely native interfaces. -```bash -pip install pythonnative -``` +## Features -### Create Your First App +- **Cross-platform native UI:** Build Android and iOS apps from a single Python codebase with truly native rendering. +- **Direct native bindings:** Python calls platform APIs directly through Chaquopy and rubicon-objc, with no JavaScript bridge. +- **Unified component API:** Components like `Page`, `StackView`, `Label`, `Button`, and `WebView` share a consistent interface across platforms. +- **CLI scaffolding:** `pn init` creates a ready-to-run project structure; `pn run android` and `pn run ios` build and launch your app. +- **Page lifecycle:** Hooks for `on_create`, `on_start`, `on_resume`, `on_pause`, `on_stop`, and `on_destroy`, with state save and restore. +- **Navigation:** Push and pop screens with argument passing for multi-page apps. +- **Rich component set:** Core views (Label, Button, TextField, ImageView, WebView, Switch, DatePicker, and more) plus Material Design variants. +- **Bundled templates:** Android Gradle and iOS Xcode templates are included, so scaffolding requires no network access. -Initialize a new PythonNative app: +## Quick Start -```bash -pn init my_app -``` +### Installation -Your app directory will look like this: - -```text -my_app/ -├── README.md -├── app -│ ├── __init__.py -│ ├── main_page.py -│ └── resources -├── pythonnative.json -├── requirements.txt -└── tests +```bash +pip install pythonnative ``` -### Writing Views - -In PythonNative, everything is a view. Here's a simple example of how to create a main page with a list view: +### Usage ```python import pythonnative as pn + class MainPage(pn.Page): def __init__(self, native_instance): super().__init__(native_instance) def on_create(self): super().on_create() - stack_view = pn.StackView(self.native_instance) - list_data = ["item_{}".format(i) for i in range(100)] - list_view = pn.ListView(self.native_instance, list_data) - stack_view.add_view(list_view) - self.set_root_view(stack_view) + stack = pn.StackView() + stack.add_view(pn.Label("Hello from PythonNative!")) + button = pn.Button("Tap me") + button.set_on_click(lambda: print("Button tapped")) + stack.add_view(button) + self.set_root_view(stack) ``` -### Run the app +## Documentation + +Visit [docs.pythonnative.com](https://docs.pythonnative.com/) for the full documentation, including getting started guides, platform-specific instructions for Android and iOS, API reference, and working examples. -```bash -pn run android -pn run ios -``` +## Contributing -## Documentation +Contributions are welcome. Please see [CONTRIBUTING.md](CONTRIBUTING.md) for setup instructions, coding standards, and guidelines for submitting pull requests. + +## License -For detailed guides and API references, visit the [PythonNative documentation](https://docs.pythonnative.com/). +[MIT](LICENSE) diff --git a/docs/api/component-properties.md b/docs/api/component-properties.md new file mode 100644 index 0000000..2480b13 --- /dev/null +++ b/docs/api/component-properties.md @@ -0,0 +1,52 @@ +## Component Property Reference (v0.4.0) + +This page summarizes common properties and fluent setters added in v0.4.0. Unless noted, methods return `self` for chaining. + +### View (base) + +- `set_background_color(color)` + - Accepts ARGB int or `#RRGGBB` / `#AARRGGBB` string. + +- `set_padding(*, all=None, horizontal=None, vertical=None, left=None, top=None, right=None, bottom=None)` + - Android: applies padding in dp. + - iOS: currently a no-op for most views. + +- `set_margin(*, all=None, horizontal=None, vertical=None, left=None, top=None, right=None, bottom=None)` + - Android: applied when added to `StackView` (LinearLayout) as `LayoutParams` margins (dp). + - iOS: not currently applied. + +- `wrap_in_scroll()` → `ScrollView` + - Returns a `ScrollView` containing this view. + +### ScrollView + +- `ScrollView.wrap(view)` → `ScrollView` + - Class helper to wrap a single child. + +### StackView + +- `set_axis('vertical'|'horizontal')` +- `set_spacing(n)` + - Android: dp via divider drawable. + - iOS: `UIStackView.spacing` (points). +- `set_alignment('fill'|'center'|'leading'|'trailing'|'top'|'bottom')` + - Cross-axis alignment; top/bottom map appropriately for horizontal stacks. + +### Text components + +Applies to `Label`, `TextField`, `TextView`: + +- `set_text(text)` +- `set_text_color(color)` +- `set_text_size(size)` + +Platform notes: +- Android: `setTextColor(int)`, `setTextSize(sp)`. +- iOS: `setTextColor(UIColor)`, `UIFont.systemFont(ofSize:)`. + +### Button + +- `set_title(text)` +- `set_on_click(callback)` + + diff --git a/docs/assets/banner.jpg b/docs/assets/banner.jpg new file mode 100644 index 0000000..d7c4c04 Binary files /dev/null and b/docs/assets/banner.jpg differ diff --git a/docs/guides/styling.md b/docs/guides/styling.md new file mode 100644 index 0000000..20de1b2 --- /dev/null +++ b/docs/guides/styling.md @@ -0,0 +1,93 @@ +## Styling + +This guide covers the lightweight styling APIs introduced in v0.4.0. + +The goal is to provide a small, predictable set of cross-platform styling hooks. Some features are Android-only today and will expand over time. + +### Colors + +- Use `set_background_color(color)` on any view. +- Color can be an ARGB int or a hex string like `#RRGGBB` or `#AARRGGBB`. + +```python +stack = pn.StackView().set_background_color("#FFF5F5F5") +``` + +### Padding and Margin + +- `set_padding(...)` is available on all views. + - Android: applies using `View.setPadding` with dp units. + - iOS: currently a no-op for most views; prefer container spacing (`StackView.set_spacing`) and layout. + +- `set_margin(...)` records margins on the child view. + - Android: applied automatically when added to a `StackView` (LinearLayout) via `LayoutParams.setMargins` (dp). + - iOS: not currently applied. + +`set_padding`/`set_margin` accept these parameters (integers): `all`, `horizontal`, `vertical`, `left`, `top`, `right`, `bottom`. Individual sides override group values. + +```python +pn.Label("Name").set_margin(bottom=8) +pn.TextField().set_padding(horizontal=12, vertical=8) +``` + +### Text styling + +Text components expose: +- `set_text(text) -> self` +- `set_text_color(color) -> self` (hex or ARGB int) +- `set_text_size(size) -> self` (sp on Android; pt on iOS via system font) + +Applies to `Label`, `TextField`, and `TextView`. + +```python +pn.Label("Hello").set_text_color("#FF3366").set_text_size(18) +``` + +### StackView layout + +`StackView` (Android LinearLayout / iOS UIStackView) adds configuration helpers: + +- `set_axis('vertical'|'horizontal') -> self` +- `set_spacing(n) -> self` (dp on Android; points on iOS) +- `set_alignment(value) -> self` + - Cross-axis alignment. Supported values: `fill`, `center`, `leading`/`top`, `trailing`/`bottom`. + +```python +form = ( + pn.StackView() + .set_axis('vertical') + .set_spacing(8) + .set_alignment('fill') + .set_padding(all=16) +) +form.add_view(pn.Label("Username").set_margin(bottom=4)) +form.add_view(pn.TextField().set_padding(horizontal=12, vertical=8)) +``` + +### Scroll helpers + +Wrap any view in a `ScrollView` using either approach: + +```python +scroll = pn.ScrollView.wrap(form) +# or +scroll = form.wrap_in_scroll() +``` + +Attach the scroll view as your page root: + +```python +self.set_root_view(scroll) +``` + +### Fluent setters + +Most setters now return `self` for chaining, e.g.: + +```python +pn.Button("Tap me").set_on_click(lambda: print("hi")).set_padding(all=8) +``` + +Note: Where platform limitations exist, the methods are no-ops and still return `self`. + + diff --git a/examples/hello-world/app/main_page.py b/examples/hello-world/app/main_page.py index 032ef3b..2646880 100644 --- a/examples/hello-world/app/main_page.py +++ b/examples/hello-world/app/main_page.py @@ -1,3 +1,5 @@ +from typing import Any + import pythonnative as pn try: @@ -10,21 +12,16 @@ class MainPage(pn.Page): - def __init__(self, native_instance): + def __init__(self, native_instance: Any) -> None: super().__init__(native_instance) - def on_create(self): + def on_create(self) -> None: super().on_create() - stack = pn.StackView() - # Ensure vertical stacking - try: - stack.native_instance.setAxis_(1) # 1 = vertical - except Exception: - pass - stack.add_view(pn.Label("Hello from PythonNative Demo!")) - button = pn.Button("Go to Second Page") - - def on_next(): + stack = pn.StackView().set_axis("vertical").set_spacing(12).set_alignment("fill").set_padding(all=16) + stack.add_view(pn.Label("Hello from PythonNative Demo!").set_text_size(18)) + button = pn.Button("Go to Second Page").set_padding(vertical=10, horizontal=14) + + def on_next() -> None: # Visual confirmation that tap worked (iOS only) try: if UIColor is not None: @@ -37,35 +34,30 @@ def on_next(): button.set_on_click(on_next) # Make the button visually obvious - try: - if UIColor is not None: - button.native_instance.setBackgroundColor_(UIColor.systemBlueColor()) - button.native_instance.setTitleColor_forState_(UIColor.whiteColor(), 0) - except Exception: - pass + button.set_background_color("#FF1E88E5") stack.add_view(button) - self.set_root_view(stack) + self.set_root_view(stack.wrap_in_scroll()) - def on_start(self): + def on_start(self) -> None: super().on_start() - def on_resume(self): + def on_resume(self) -> None: super().on_resume() - def on_pause(self): + def on_pause(self) -> None: super().on_pause() - def on_stop(self): + def on_stop(self) -> None: super().on_stop() - def on_destroy(self): + def on_destroy(self) -> None: super().on_destroy() - def on_restart(self): + def on_restart(self) -> None: super().on_restart() - def on_save_instance_state(self): + def on_save_instance_state(self) -> None: super().on_save_instance_state() - def on_restore_instance_state(self): + def on_restore_instance_state(self) -> None: super().on_restore_instance_state() diff --git a/examples/hello-world/app/second_page.py b/examples/hello-world/app/second_page.py index 99af521..3515527 100644 --- a/examples/hello-world/app/second_page.py +++ b/examples/hello-world/app/second_page.py @@ -1,3 +1,5 @@ +from typing import Any + import pythonnative as pn try: @@ -10,10 +12,10 @@ class SecondPage(pn.Page): - def __init__(self, native_instance): + def __init__(self, native_instance: Any) -> None: super().__init__(native_instance) - def on_create(self): + def on_create(self) -> None: super().on_create() stack_view = pn.StackView() # Read args passed from MainPage @@ -30,7 +32,7 @@ def on_create(self): except Exception: pass - def on_next(): + def on_next() -> None: # Visual confirmation that tap worked (iOS only) try: if UIColor is not None: @@ -47,26 +49,26 @@ def on_next(): stack_view.add_view(back_btn) self.set_root_view(stack_view) - def on_start(self): + def on_start(self) -> None: super().on_start() - def on_resume(self): + def on_resume(self) -> None: super().on_resume() - def on_pause(self): + def on_pause(self) -> None: super().on_pause() - def on_stop(self): + def on_stop(self) -> None: super().on_stop() - def on_destroy(self): + def on_destroy(self) -> None: super().on_destroy() - def on_restart(self): + def on_restart(self) -> None: super().on_restart() - def on_save_instance_state(self): + def on_save_instance_state(self) -> None: super().on_save_instance_state() - def on_restore_instance_state(self): + def on_restore_instance_state(self) -> None: super().on_restore_instance_state() diff --git a/examples/hello-world/app/third_page.py b/examples/hello-world/app/third_page.py index 6c06594..97b003f 100644 --- a/examples/hello-world/app/third_page.py +++ b/examples/hello-world/app/third_page.py @@ -1,3 +1,5 @@ +from typing import Any + import pythonnative as pn try: @@ -10,10 +12,10 @@ class ThirdPage(pn.Page): - def __init__(self, native_instance): + def __init__(self, native_instance: Any) -> None: super().__init__(native_instance) - def on_create(self): + def on_create(self) -> None: super().on_create() stack = pn.StackView() stack.add_view(pn.Label("This is the Third Page")) diff --git a/mkdocs.yml b/mkdocs.yml index 9a29ca4..7aeff94 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -24,8 +24,10 @@ nav: - Android: guides/android.md - iOS: guides/ios.md - Navigation: guides/navigation.md + - Styling: guides/styling.md - API Reference: - Package: api/pythonnative.md + - Component Properties: api/component-properties.md - Meta: - Roadmap: meta/roadmap.md - Contributing: meta/contributing.md diff --git a/mypy.ini b/mypy.ini index ec0b8ad..333ddb6 100644 --- a/mypy.ini +++ b/mypy.ini @@ -8,6 +8,8 @@ strict_optional = False pretty = True files = src, tests, examples exclude = (^build/|^examples/.*/build/) +disallow_untyped_defs = True +disallow_incomplete_defs = True [mypy-pythonnative.*] implicit_reexport = True diff --git a/pyproject.toml b/pyproject.toml index 0cdbd85..95432b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pythonnative" -version = "0.3.0" +version = "0.4.0" description = "Cross-platform native UI toolkit for Android and iOS" authors = [ { name = "Owen Carey" } @@ -88,3 +88,30 @@ ignore = [] [tool.black] line-length = 120 target-version = ['py39'] + +# ── Semantic Release ──────────────────────────────────────────────── + +[tool.semantic_release] +version_toml = ["pyproject.toml:project.version"] +version_variables = ["src/pythonnative/__init__.py:__version__"] +commit_message = "chore(release): v{version}" +tag_format = "v{version}" +major_on_zero = false + +[tool.semantic_release.branches.main] +match = "main" +prerelease = false + +[tool.semantic_release.changelog] +changelog_file = "CHANGELOG.md" +exclude_commit_patterns = [ + "^chore\\(release\\):", +] + +[tool.semantic_release.commit_parser_options] +allowed_tags = [ + "build", "chore", "ci", "docs", "feat", "fix", + "perf", "refactor", "revert", "style", "test", +] +minor_tags = ["feat"] +patch_tags = ["fix", "perf"] diff --git a/src/pythonnative/__init__.py b/src/pythonnative/__init__.py index 1e2530d..a185962 100644 --- a/src/pythonnative/__init__.py +++ b/src/pythonnative/__init__.py @@ -1,7 +1,7 @@ from importlib import import_module from typing import Any, Dict -__version__ = "0.3.0" +__version__ = "0.4.0" __all__ = [ "ActivityIndicatorView", diff --git a/src/pythonnative/button.py b/src/pythonnative/button.py index 13e38b1..9d96904 100644 --- a/src/pythonnative/button.py +++ b/src/pythonnative/button.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import Callable, Optional +from typing import Any, Callable, Optional from .utils import IS_ANDROID, get_android_context from .view import ViewBase @@ -15,7 +15,7 @@ def __init__(self) -> None: super().__init__() @abstractmethod - def set_title(self, title: str) -> None: + def set_title(self, title: str) -> "ButtonBase": pass @abstractmethod @@ -23,7 +23,7 @@ def get_title(self) -> str: pass @abstractmethod - def set_on_click(self, callback: Callable[[], None]) -> None: + def set_on_click(self, callback: Callable[[], None]) -> "ButtonBase": pass @@ -43,23 +43,25 @@ def __init__(self, title: str = "") -> None: self.native_instance = self.native_class(context) self.set_title(title) - def set_title(self, title: str) -> None: + def set_title(self, title: str) -> "Button": self.native_instance.setText(title) + return self def get_title(self) -> str: return self.native_instance.getText().toString() - def set_on_click(self, callback: Callable[[], None]) -> None: + def set_on_click(self, callback: Callable[[], None]) -> "Button": class OnClickListener(dynamic_proxy(jclass("android.view.View").OnClickListener)): - def __init__(self, callback): + def __init__(self, callback: Callable[[], None]) -> None: super().__init__() self.callback = callback - def onClick(self, view): + def onClick(self, view: Any) -> None: self.callback() listener = OnClickListener(callback) self.native_instance.setOnClickListener(listener) + return self else: # ======================================== @@ -77,7 +79,7 @@ class _PNButtonHandler(NSObject): # type: ignore[valid-type] _callback: Optional[Callable[[], None]] = None @objc_method - def onTap_(self, sender) -> None: + def onTap_(self, sender: object) -> None: try: callback = self._callback if callback is not None: @@ -93,13 +95,14 @@ def __init__(self, title: str = "") -> None: self.native_instance = self.native_class.alloc().init() self.set_title(title) - def set_title(self, title: str) -> None: + def set_title(self, title: str) -> "Button": self.native_instance.setTitle_forState_(title, 0) + return self def get_title(self) -> str: return self.native_instance.titleForState_(0) - def set_on_click(self, callback: Callable[[], None]) -> None: + def set_on_click(self, callback: Callable[[], None]) -> "Button": # Create a handler object with an Objective-C method `onTap:` and attach the Python callback handler = _PNButtonHandler.new() # Keep strong references to the handler and callback @@ -107,3 +110,4 @@ def set_on_click(self, callback: Callable[[], None]) -> None: handler._callback = callback # UIControlEventTouchUpInside = 1 << 6 self.native_instance.addTarget_action_forControlEvents_(handler, SEL("onTap:"), 1 << 6) + return self diff --git a/src/pythonnative/cli/pn.py b/src/pythonnative/cli/pn.py index e053f8d..8281eae 100644 --- a/src/pythonnative/cli/pn.py +++ b/src/pythonnative/cli/pn.py @@ -55,12 +55,17 @@ def __init__(self, native_instance): def on_create(self): super().on_create() - stack = pn.StackView() - stack.add_view(pn.Label("Hello from PythonNative!")) - button = pn.Button("Tap me") - button.set_on_click(lambda: print("Button clicked")) + stack = ( + pn.StackView() + .set_axis("vertical") + .set_spacing(12) + .set_alignment("fill") + .set_padding(all=16) + ) + stack.add_view(pn.Label("Hello from PythonNative!").set_text_size(18)) + button = pn.Button("Tap me").set_on_click(lambda: print("Button clicked")) stack.add_view(button) - self.set_root_view(stack) + self.set_root_view(stack.wrap_in_scroll()) """ ) diff --git a/src/pythonnative/date_picker.py b/src/pythonnative/date_picker.py index cb86006..f357e03 100644 --- a/src/pythonnative/date_picker.py +++ b/src/pythonnative/date_picker.py @@ -14,7 +14,7 @@ def __init__(self) -> None: super().__init__() @abstractmethod - def set_date(self, year: int, month: int, day: int) -> None: + def set_date(self, year: int, month: int, day: int) -> "DatePickerBase": pass @abstractmethod @@ -28,17 +28,20 @@ def get_date(self) -> tuple: # https://developer.android.com/reference/android/widget/DatePicker # ======================================== + from typing import Any + from java import jclass class DatePicker(DatePickerBase, ViewBase): - def __init__(self, context, year: int = 0, month: int = 0, day: int = 0) -> None: + def __init__(self, context: Any, year: int = 0, month: int = 0, day: int = 0) -> None: super().__init__() self.native_class = jclass("android.widget.DatePicker") self.native_instance = self.native_class(context) self.set_date(year, month, day) - def set_date(self, year: int, month: int, day: int) -> None: + def set_date(self, year: int, month: int, day: int) -> "DatePicker": self.native_instance.updateDate(year, month, day) + return self def get_date(self) -> tuple: year = self.native_instance.getYear() @@ -63,9 +66,10 @@ def __init__(self, year: int = 0, month: int = 0, day: int = 0) -> None: self.native_instance = self.native_class.alloc().init() self.set_date(year, month, day) - def set_date(self, year: int, month: int, day: int) -> None: + def set_date(self, year: int, month: int, day: int) -> "DatePicker": date = datetime(year, month, day) self.native_instance.setDate_(date) + return self def get_date(self) -> tuple: date = self.native_instance.date() diff --git a/src/pythonnative/image_view.py b/src/pythonnative/image_view.py index 78cb1ff..c3b3d1a 100644 --- a/src/pythonnative/image_view.py +++ b/src/pythonnative/image_view.py @@ -14,7 +14,7 @@ def __init__(self) -> None: super().__init__() @abstractmethod - def set_image(self, image: str) -> None: + def set_image(self, image: str) -> "ImageViewBase": pass @abstractmethod @@ -40,9 +40,10 @@ def __init__(self, image: str = "") -> None: if image: self.set_image(image) - def set_image(self, image: str) -> None: + def set_image(self, image: str) -> "ImageView": bitmap = BitmapFactory.decodeFile(image) self.native_instance.setImageBitmap(bitmap) + return self def get_image(self) -> str: # Please note that this is a simplistic representation, getting image from ImageView @@ -66,10 +67,11 @@ def __init__(self, image: str = "") -> None: if image: self.set_image(image) - def set_image(self, image: str) -> None: + def set_image(self, image: str) -> "ImageView": ns_str = NSString.alloc().initWithUTF8String_(image) ui_image = UIImage.imageNamed_(ns_str) self.native_instance.setImage_(ui_image) + return self def get_image(self) -> str: # Similar to Android, getting the image from UIImageView isn't straightforward. diff --git a/src/pythonnative/label.py b/src/pythonnative/label.py index c34eec2..d998b7d 100644 --- a/src/pythonnative/label.py +++ b/src/pythonnative/label.py @@ -1,4 +1,5 @@ from abc import ABC, abstractmethod +from typing import Any from .utils import IS_ANDROID, get_android_context from .view import ViewBase @@ -14,13 +15,21 @@ def __init__(self) -> None: super().__init__() @abstractmethod - def set_text(self, text: str) -> None: + def set_text(self, text: str) -> "LabelBase": pass @abstractmethod def get_text(self) -> str: pass + @abstractmethod + def set_text_color(self, color: Any) -> "LabelBase": + pass + + @abstractmethod + def set_text_size(self, size: float) -> "LabelBase": + pass + if IS_ANDROID: # ======================================== @@ -38,12 +47,37 @@ def __init__(self, text: str = "") -> None: self.native_instance = self.native_class(context) self.set_text(text) - def set_text(self, text: str) -> None: + def set_text(self, text: str) -> "Label": self.native_instance.setText(text) + return self def get_text(self) -> str: return self.native_instance.getText().toString() + def set_text_color(self, color: Any) -> "Label": + # Accept int ARGB or hex string + if isinstance(color, str): + c = color.strip() + if c.startswith("#"): + c = c[1:] + if len(c) == 6: + c = "FF" + c + color_int = int(c, 16) + else: + color_int = int(color) + try: + self.native_instance.setTextColor(color_int) + except Exception: + pass + return self + + def set_text_size(self, size_sp: float) -> "Label": + try: + self.native_instance.setTextSize(float(size_sp)) + except Exception: + pass + return self + else: # ======================================== # iOS class @@ -59,8 +93,41 @@ def __init__(self, text: str = "") -> None: self.native_instance = self.native_class.alloc().init() self.set_text(text) - def set_text(self, text: str) -> None: + def set_text(self, text: str) -> "Label": self.native_instance.setText_(text) + return self def get_text(self) -> str: return self.native_instance.text() + + def set_text_color(self, color: Any) -> "Label": + # Accept int ARGB or hex string + if isinstance(color, str): + c = color.strip() + if c.startswith("#"): + c = c[1:] + if len(c) == 6: + c = "FF" + c + color_int = int(c, 16) + else: + color_int = int(color) + try: + UIColor = ObjCClass("UIColor") + a = ((color_int >> 24) & 0xFF) / 255.0 + r = ((color_int >> 16) & 0xFF) / 255.0 + g = ((color_int >> 8) & 0xFF) / 255.0 + b = (color_int & 0xFF) / 255.0 + color_obj = UIColor.colorWithRed_green_blue_alpha_(r, g, b, a) + self.native_instance.setTextColor_(color_obj) + except Exception: + pass + return self + + def set_text_size(self, size: float) -> "Label": + try: + UIFont = ObjCClass("UIFont") + font = UIFont.systemFontOfSize_(float(size)) + self.native_instance.setFont_(font) + except Exception: + pass + return self diff --git a/src/pythonnative/list_view.py b/src/pythonnative/list_view.py index d2378d2..c4433e4 100644 --- a/src/pythonnative/list_view.py +++ b/src/pythonnative/list_view.py @@ -1,4 +1,5 @@ from abc import ABC, abstractmethod +from typing import Any from .utils import IS_ANDROID from .view import ViewBase @@ -14,7 +15,7 @@ def __init__(self) -> None: super().__init__() @abstractmethod - def set_data(self, data: list) -> None: + def set_data(self, data: list) -> "ListViewBase": pass @abstractmethod @@ -31,18 +32,19 @@ def get_data(self) -> list: from java import jclass class ListView(ListViewBase, ViewBase): - def __init__(self, context, data: list = []) -> None: + def __init__(self, context: Any, data: list = []) -> None: super().__init__() self.context = context self.native_class = jclass("android.widget.ListView") self.native_instance = self.native_class(context) self.set_data(data) - def set_data(self, data: list) -> None: + def set_data(self, data: list) -> "ListView": adapter = jclass("android.widget.ArrayAdapter")( self.context, jclass("android.R$layout").simple_list_item_1, data ) self.native_instance.setAdapter(adapter) + return self def get_data(self) -> list: adapter = self.native_instance.getAdapter() @@ -63,9 +65,10 @@ def __init__(self, data: list = []) -> None: self.native_instance = self.native_class.alloc().init() self.set_data(data) - def set_data(self, data: list) -> None: + def set_data(self, data: list) -> "ListView": # Note: This is a simplified representation. Normally, you would need to create a UITableViewDataSource. self.native_instance.reloadData() + return self def get_data(self) -> list: # Note: This is a simplified representation. diff --git a/src/pythonnative/material_activity_indicator_view.py b/src/pythonnative/material_activity_indicator_view.py index a568ced..b619960 100644 --- a/src/pythonnative/material_activity_indicator_view.py +++ b/src/pythonnative/material_activity_indicator_view.py @@ -28,10 +28,12 @@ def stop_animating(self) -> None: # https://developer.android.com/reference/com/google/android/material/progressindicator/CircularProgressIndicator # ======================================== + from typing import Any + from java import jclass class MaterialActivityIndicatorView(MaterialActivityIndicatorViewBase, ViewBase): - def __init__(self, context) -> None: + def __init__(self, context: Any) -> None: super().__init__() self.native_class = jclass("com.google.android.material.progressindicator.CircularProgressIndicator") self.native_instance = self.native_class(context) diff --git a/src/pythonnative/material_button.py b/src/pythonnative/material_button.py index 1db600a..5816e30 100644 --- a/src/pythonnative/material_button.py +++ b/src/pythonnative/material_button.py @@ -14,7 +14,7 @@ def __init__(self) -> None: super().__init__() @abstractmethod - def set_title(self, title: str) -> None: + def set_title(self, title: str) -> "MaterialButtonBase": pass @abstractmethod @@ -28,17 +28,20 @@ def get_title(self) -> str: # https://developer.android.com/reference/com/google/android/material/button/MaterialButton # ======================================== + from typing import Any + from java import jclass class MaterialButton(MaterialButtonBase, ViewBase): - def __init__(self, context, title: str = "") -> None: + def __init__(self, context: Any, title: str = "") -> None: super().__init__() self.native_class = jclass("com.google.android.material.button.MaterialButton") self.native_instance = self.native_class(context) self.set_title(title) - def set_title(self, title: str) -> None: + def set_title(self, title: str) -> "MaterialButton": self.native_instance.setText(title) + return self def get_title(self) -> str: return self.native_instance.getText().toString() @@ -58,8 +61,9 @@ def __init__(self, title: str = "") -> None: self.native_instance = self.native_class.alloc().init() self.set_title(title) - def set_title(self, title: str) -> None: + def set_title(self, title: str) -> "MaterialButton": self.native_instance.setTitle_forState_(title, 0) + return self def get_title(self) -> str: return self.native_instance.titleForState_(0) diff --git a/src/pythonnative/material_date_picker.py b/src/pythonnative/material_date_picker.py index 0eadeec..f39ba3e 100644 --- a/src/pythonnative/material_date_picker.py +++ b/src/pythonnative/material_date_picker.py @@ -14,7 +14,7 @@ def __init__(self) -> None: super().__init__() @abstractmethod - def set_date(self, year: int, month: int, day: int) -> None: + def set_date(self, year: int, month: int, day: int) -> "MaterialDatePickerBase": pass @abstractmethod @@ -38,7 +38,7 @@ def __init__(self, year: int = 0, month: int = 0, day: int = 0) -> None: self.set_date(year, month, day) self.native_instance = self.builder.build() - def set_date(self, year: int, month: int, day: int) -> None: + def set_date(self, year: int, month: int, day: int) -> "MaterialDatePicker": # MaterialDatePicker uses milliseconds since epoch to set date from java.util import Calendar @@ -46,6 +46,7 @@ def set_date(self, year: int, month: int, day: int) -> None: cal.set(year, month, day) milliseconds = cal.getTimeInMillis() self.builder.setSelection(milliseconds) + return self def get_date(self) -> tuple: # Convert selection (milliseconds since epoch) back to a date @@ -76,9 +77,10 @@ def __init__(self, year: int = 0, month: int = 0, day: int = 0) -> None: self.native_instance = self.native_class.alloc().init() self.set_date(year, month, day) - def set_date(self, year: int, month: int, day: int) -> None: + def set_date(self, year: int, month: int, day: int) -> "MaterialDatePicker": date = datetime(year, month, day) self.native_instance.setDate_(date) + return self def get_date(self) -> tuple: date = self.native_instance.date() diff --git a/src/pythonnative/material_progress_view.py b/src/pythonnative/material_progress_view.py index 23ca565..2b76275 100644 --- a/src/pythonnative/material_progress_view.py +++ b/src/pythonnative/material_progress_view.py @@ -14,7 +14,7 @@ def __init__(self) -> None: super().__init__() @abstractmethod - def set_progress(self, progress: float) -> None: + def set_progress(self, progress: float) -> "MaterialProgressViewBase": pass @abstractmethod @@ -28,17 +28,20 @@ def get_progress(self) -> float: # https://developer.android.com/reference/com/google/android/material/progressindicator/LinearProgressIndicator # ======================================== + from typing import Any + from java import jclass class MaterialProgressView(MaterialProgressViewBase, ViewBase): - def __init__(self, context) -> None: + def __init__(self, context: Any) -> None: super().__init__() self.native_class = jclass("com.google.android.material.progressindicator.LinearProgressIndicator") self.native_instance = self.native_class(context) self.native_instance.setIndeterminate(False) - def set_progress(self, progress: float) -> None: + def set_progress(self, progress: float) -> "MaterialProgressView": self.native_instance.setProgress(int(progress * 100)) + return self def get_progress(self) -> float: return self.native_instance.getProgress() / 100.0 @@ -59,8 +62,9 @@ def __init__(self) -> None: 0 ) # 0: UIProgressViewStyleDefault - def set_progress(self, progress: float) -> None: + def set_progress(self, progress: float) -> "MaterialProgressView": self.native_instance.setProgress_animated_(progress, False) + return self def get_progress(self) -> float: return self.native_instance.progress() diff --git a/src/pythonnative/material_search_bar.py b/src/pythonnative/material_search_bar.py index 0693323..950161c 100644 --- a/src/pythonnative/material_search_bar.py +++ b/src/pythonnative/material_search_bar.py @@ -14,7 +14,7 @@ def __init__(self) -> None: super().__init__() @abstractmethod - def set_query(self, query: str) -> None: + def set_query(self, query: str) -> "MaterialSearchBarBase": pass @abstractmethod @@ -28,17 +28,20 @@ def get_query(self) -> str: # https://developer.android.com/reference/com/google/android/material/search/SearchBar # ======================================== + from typing import Any + from java import jclass class MaterialSearchBar(MaterialSearchBarBase, ViewBase): - def __init__(self, context, query: str = "") -> None: + def __init__(self, context: Any, query: str = "") -> None: super().__init__() self.native_class = jclass("com.google.android.material.search.SearchBar") self.native_instance = self.native_class(context) self.set_query(query) - def set_query(self, query: str) -> None: + def set_query(self, query: str) -> "MaterialSearchBar": self.native_instance.setQuery(query, False) + return self def get_query(self) -> str: return self.native_instance.getQuery().toString() @@ -58,8 +61,9 @@ def __init__(self, query: str = "") -> None: self.native_instance = self.native_class.alloc().init() self.set_query(query) - def set_query(self, query: str) -> None: + def set_query(self, query: str) -> "MaterialSearchBar": self.native_instance.set_searchText_(query) + return self def get_query(self) -> str: return self.native_instance.searchText() diff --git a/src/pythonnative/material_switch.py b/src/pythonnative/material_switch.py index 21003b5..20e9bab 100644 --- a/src/pythonnative/material_switch.py +++ b/src/pythonnative/material_switch.py @@ -14,7 +14,7 @@ def __init__(self) -> None: super().__init__() @abstractmethod - def set_on(self, value: bool) -> None: + def set_on(self, value: bool) -> "MaterialSwitchBase": pass @abstractmethod @@ -28,17 +28,20 @@ def is_on(self) -> bool: # https://developer.android.com/reference/com/google/android/material/materialswitch/MaterialSwitch # ======================================== + from typing import Any + from java import jclass class MaterialSwitch(MaterialSwitchBase, ViewBase): - def __init__(self, context, value: bool = False) -> None: + def __init__(self, context: Any, value: bool = False) -> None: super().__init__() self.native_class = jclass("com.google.android.material.switch.MaterialSwitch") self.native_instance = self.native_class(context) self.set_on(value) - def set_on(self, value: bool) -> None: + def set_on(self, value: bool) -> "MaterialSwitch": self.native_instance.setChecked(value) + return self def is_on(self) -> bool: return self.native_instance.isChecked() @@ -58,8 +61,9 @@ def __init__(self, value: bool = False) -> None: self.native_instance = self.native_class.alloc().init() self.set_on(value) - def set_on(self, value: bool) -> None: + def set_on(self, value: bool) -> "MaterialSwitch": self.native_instance.setOn_animated_(value, False) + return self def is_on(self) -> bool: return self.native_instance.isOn() diff --git a/src/pythonnative/material_time_picker.py b/src/pythonnative/material_time_picker.py index 03d7303..5829ca7 100644 --- a/src/pythonnative/material_time_picker.py +++ b/src/pythonnative/material_time_picker.py @@ -14,7 +14,7 @@ def __init__(self) -> None: super().__init__() @abstractmethod - def set_time(self, hour: int, minute: int) -> None: + def set_time(self, hour: int, minute: int) -> "MaterialTimePickerBase": pass @abstractmethod @@ -28,17 +28,20 @@ def get_time(self) -> tuple: # https://developer.android.com/reference/com/google/android/material/timepicker/MaterialTimePicker # ======================================== + from typing import Any + from java import jclass class MaterialTimePicker(MaterialTimePickerBase, ViewBase): - def __init__(self, context, hour: int = 0, minute: int = 0) -> None: + def __init__(self, context: Any, hour: int = 0, minute: int = 0) -> None: super().__init__() self.native_class = jclass("com.google.android.material.timepicker.MaterialTimePicker") self.native_instance = self.native_class(context) self.set_time(hour, minute) - def set_time(self, hour: int, minute: int) -> None: + def set_time(self, hour: int, minute: int) -> "MaterialTimePicker": self.native_instance.setTime(hour, minute) + return self def get_time(self) -> tuple: hour = self.native_instance.getHour() @@ -63,9 +66,10 @@ def __init__(self, hour: int = 0, minute: int = 0) -> None: self.native_instance.setDatePickerMode_(1) # Setting mode to Time self.set_time(hour, minute) - def set_time(self, hour: int, minute: int) -> None: + def set_time(self, hour: int, minute: int) -> "MaterialTimePicker": t = time(hour, minute) self.native_instance.setTime_(t) + return self def get_time(self) -> tuple: t = self.native_instance.time() diff --git a/src/pythonnative/page.py b/src/pythonnative/page.py index e5734e6..77e283c 100644 --- a/src/pythonnative/page.py +++ b/src/pythonnative/page.py @@ -47,7 +47,7 @@ def __init__(self) -> None: super().__init__() @abstractmethod - def set_root_view(self, view) -> None: + def set_root_view(self, view: Any) -> None: pass @abstractmethod @@ -104,7 +104,7 @@ def get_args(self) -> dict: return getattr(self, "_args", {}) # Back-compat: navigate_to delegates to push - def navigate_to(self, page) -> None: + def navigate_to(self, page: Any) -> None: self.push(page) pass @@ -118,7 +118,7 @@ def navigate_to(self, page) -> None: from java import jclass class Page(PageBase, ViewBase): - def __init__(self, native_instance) -> None: + def __init__(self, native_instance: Any) -> None: super().__init__() self.native_class = jclass("android.app.Activity") self.native_instance = native_instance @@ -127,7 +127,7 @@ def __init__(self, native_instance) -> None: set_android_context(native_instance) self._args: dict = {} - def set_root_view(self, view) -> None: + def set_root_view(self, view: Any) -> None: # In fragment-based navigation, attach child view to the current fragment container. try: from .utils import get_android_fragment_container @@ -264,7 +264,7 @@ def forward_lifecycle(native_addr: int, event: str) -> None: pass class Page(PageBase, ViewBase): - def __init__(self, native_instance) -> None: + def __init__(self, native_instance: Any) -> None: super().__init__() self.native_class = ObjCClass("UIViewController") # If Swift passed us an integer pointer, wrap it as an ObjCInstance. @@ -280,7 +280,7 @@ def __init__(self, native_instance) -> None: if self.native_instance is not None: _ios_register_page(self.native_instance, self) - def set_root_view(self, view) -> None: + def set_root_view(self, view: Any) -> None: # UIViewController.view is a property; access without calling. root_view = self.native_instance.view # Size the root child to fill the controller's view and enable autoresizing diff --git a/src/pythonnative/picker_view.py b/src/pythonnative/picker_view.py index fc7ca98..7f1ae15 100644 --- a/src/pythonnative/picker_view.py +++ b/src/pythonnative/picker_view.py @@ -14,7 +14,7 @@ def __init__(self) -> None: super().__init__() @abstractmethod - def set_selected(self, index: int) -> None: + def set_selected(self, index: int) -> "PickerViewBase": pass @abstractmethod @@ -28,17 +28,20 @@ def get_selected(self) -> int: # https://developer.android.com/reference/android/widget/Spinner # ======================================== + from typing import Any + from java import jclass class PickerView(PickerViewBase, ViewBase): - def __init__(self, context, index: int = 0) -> None: + def __init__(self, context: Any, index: int = 0) -> None: super().__init__() self.native_class = jclass("android.widget.Spinner") self.native_instance = self.native_class(context) self.set_selected(index) - def set_selected(self, index: int) -> None: + def set_selected(self, index: int) -> "PickerView": self.native_instance.setSelection(index) + return self def get_selected(self) -> int: return self.native_instance.getSelectedItemPosition() @@ -58,8 +61,9 @@ def __init__(self, index: int = 0) -> None: self.native_instance = self.native_class.alloc().init() self.set_selected(index) - def set_selected(self, index: int) -> None: + def set_selected(self, index: int) -> "PickerView": self.native_instance.selectRow_inComponent_animated_(index, 0, False) + return self def get_selected(self) -> int: return self.native_instance.selectedRowInComponent_(0) diff --git a/src/pythonnative/progress_view.py b/src/pythonnative/progress_view.py index c1a08b3..5587170 100644 --- a/src/pythonnative/progress_view.py +++ b/src/pythonnative/progress_view.py @@ -14,7 +14,7 @@ def __init__(self) -> None: super().__init__() @abstractmethod - def set_progress(self, progress: float) -> None: + def set_progress(self, progress: float) -> "ProgressViewBase": pass @abstractmethod @@ -39,8 +39,9 @@ def __init__(self) -> None: self.native_instance = self.native_class(context, None, jclass("android.R$attr").progressBarStyleHorizontal) self.native_instance.setIndeterminate(False) - def set_progress(self, progress: float) -> None: + def set_progress(self, progress: float) -> "ProgressView": self.native_instance.setProgress(int(progress * 100)) + return self def get_progress(self) -> float: return self.native_instance.getProgress() / 100.0 @@ -61,8 +62,9 @@ def __init__(self) -> None: 0 ) # 0: UIProgressViewStyleDefault - def set_progress(self, progress: float) -> None: + def set_progress(self, progress: float) -> "ProgressView": self.native_instance.setProgress_animated_(progress, False) + return self def get_progress(self) -> float: return self.native_instance.progress() diff --git a/src/pythonnative/scroll_view.py b/src/pythonnative/scroll_view.py index 1c19d62..b532cfe 100644 --- a/src/pythonnative/scroll_view.py +++ b/src/pythonnative/scroll_view.py @@ -1,7 +1,7 @@ from abc import ABC, abstractmethod from typing import Any, List -from .utils import IS_ANDROID +from .utils import IS_ANDROID, get_android_context from .view import ViewBase # ======================================== @@ -16,7 +16,13 @@ def __init__(self) -> None: self.views: List[Any] = [] @abstractmethod - def add_view(self, view) -> None: + def add_view(self, view: Any) -> None: + pass + + @staticmethod + @abstractmethod + def wrap(view: Any) -> "ScrollViewBase": + """Return a new ScrollView containing the provided view as its only child.""" pass @@ -29,12 +35,13 @@ def add_view(self, view) -> None: from java import jclass class ScrollView(ScrollViewBase, ViewBase): - def __init__(self, context) -> None: + def __init__(self) -> None: super().__init__() self.native_class = jclass("android.widget.ScrollView") + context = get_android_context() self.native_instance = self.native_class(context) - def add_view(self, view): + def add_view(self, view: Any) -> None: self.views.append(view) # In Android, ScrollView can host only one direct child if len(self.views) == 1: @@ -42,6 +49,13 @@ def add_view(self, view): else: raise Exception("ScrollView can host only one direct child") + @staticmethod + def wrap(view: Any) -> "ScrollView": + """Return a new ScrollView containing the provided view as its only child.""" + sv = ScrollView() + sv.add_view(view) + return sv + else: # ======================================== # iOS class @@ -56,8 +70,32 @@ def __init__(self) -> None: self.native_class = ObjCClass("UIScrollView") self.native_instance = self.native_class.alloc().initWithFrame_(((0, 0), (0, 0))) - def add_view(self, view): + def add_view(self, view: Any) -> None: self.views.append(view) - # Ensure view is a subview of scrollview - if view.native_instance not in self.native_instance.subviews: + # Add as subview and size child to fill scroll view by default so content is visible + try: self.native_instance.addSubview_(view.native_instance) + except Exception: + pass + # Default layout: if the child has no size yet, size it to fill the scroll view + # and enable flexible width/height. If the child is already sized explicitly, + # leave it unchanged. + try: + frame = getattr(view.native_instance, "frame") + size = getattr(frame, "size", None) + width = getattr(size, "width", 0) if size is not None else 0 + height = getattr(size, "height", 0) if size is not None else 0 + if width <= 0 or height <= 0: + bounds = self.native_instance.bounds + view.native_instance.setFrame_(bounds) + # UIViewAutoresizingFlexibleWidth (2) | UIViewAutoresizingFlexibleHeight (16) + view.native_instance.setAutoresizingMask_(2 | 16) + except Exception: + pass + + @staticmethod + def wrap(view: Any) -> "ScrollView": + """Return a new ScrollView containing the provided view as its only child.""" + sv = ScrollView() + sv.add_view(view) + return sv diff --git a/src/pythonnative/search_bar.py b/src/pythonnative/search_bar.py index 72609ff..ae33da1 100644 --- a/src/pythonnative/search_bar.py +++ b/src/pythonnative/search_bar.py @@ -14,7 +14,7 @@ def __init__(self) -> None: super().__init__() @abstractmethod - def set_query(self, query: str) -> None: + def set_query(self, query: str) -> "SearchBarBase": pass @abstractmethod @@ -28,17 +28,20 @@ def get_query(self) -> str: # https://developer.android.com/reference/android/widget/SearchView # ======================================== + from typing import Any + from java import jclass class SearchBar(SearchBarBase, ViewBase): - def __init__(self, context, query: str = "") -> None: + def __init__(self, context: Any, query: str = "") -> None: super().__init__() self.native_class = jclass("android.widget.SearchView") self.native_instance = self.native_class(context) self.set_query(query) - def set_query(self, query: str) -> None: + def set_query(self, query: str) -> "SearchBar": self.native_instance.setQuery(query, False) + return self def get_query(self) -> str: return self.native_instance.getQuery().toString() @@ -58,8 +61,9 @@ def __init__(self, query: str = "") -> None: self.native_instance = self.native_class.alloc().init() self.set_query(query) - def set_query(self, query: str) -> None: + def set_query(self, query: str) -> "SearchBar": self.native_instance.set_text_(query) + return self def get_query(self) -> str: return self.native_instance.text() diff --git a/src/pythonnative/stack_view.py b/src/pythonnative/stack_view.py index 3fc57b1..cb2273c 100644 --- a/src/pythonnative/stack_view.py +++ b/src/pythonnative/stack_view.py @@ -16,7 +16,19 @@ def __init__(self) -> None: self.views: List[Any] = [] @abstractmethod - def add_view(self, view) -> None: + def add_view(self, view: Any) -> None: + pass + + @abstractmethod + def set_axis(self, axis: str) -> "StackViewBase": + pass + + @abstractmethod + def set_spacing(self, spacing: float) -> "StackViewBase": + pass + + @abstractmethod + def set_alignment(self, alignment: str) -> "StackViewBase": pass @@ -35,11 +47,99 @@ def __init__(self) -> None: context = get_android_context() self.native_instance = self.native_class(context) self.native_instance.setOrientation(self.native_class.VERTICAL) + # Cache context and current orientation for spacing/alignment helpers + self._context = context + self._axis = "vertical" - def add_view(self, view): + def add_view(self, view: Any) -> None: self.views.append(view) + # Apply margins if the child has any recorded (supported for LinearLayout) + try: + lp = view.native_instance.getLayoutParams() + except Exception: + lp = None + if lp is None: + # Create default LayoutParams (WRAP_CONTENT) + layout_params = jclass("android.widget.LinearLayout$LayoutParams")(-2, -2) + else: + layout_params = lp + margin = getattr(view, "_pn_margin", None) + if margin is not None: + left, top, right, bottom = margin + # Convert dp to px + density = self._context.getResources().getDisplayMetrics().density + lpx = int(left * density) + tpx = int(top * density) + rpx = int(right * density) + bpx = int(bottom * density) + try: + layout_params.setMargins(lpx, tpx, rpx, bpx) + except Exception: + pass + try: + view.native_instance.setLayoutParams(layout_params) + except Exception: + pass self.native_instance.addView(view.native_instance) + def set_axis(self, axis: str) -> "StackView": + """Set stacking axis: 'vertical' or 'horizontal'. Returns self.""" + axis_l = (axis or "").lower() + if axis_l not in ("vertical", "horizontal"): + return self + orientation = self.native_class.VERTICAL if axis_l == "vertical" else self.native_class.HORIZONTAL + self.native_instance.setOrientation(orientation) + self._axis = axis_l + return self + + def set_spacing(self, spacing: float) -> "StackView": + """Set spacing between children in dp (Android: uses LinearLayout dividers). Returns self.""" + try: + density = self._context.getResources().getDisplayMetrics().density + px = max(0, int(spacing * density)) + # Use a transparent GradientDrawable with specified size as divider + GradientDrawable = jclass("android.graphics.drawable.GradientDrawable") + drawable = GradientDrawable() + drawable.setColor(0x00000000) + if self._axis == "vertical": + drawable.setSize(1, px) + else: + drawable.setSize(px, 1) + self.native_instance.setShowDividers(self.native_class.SHOW_DIVIDER_MIDDLE) + self.native_instance.setDividerDrawable(drawable) + except Exception: + pass + return self + + def set_alignment(self, alignment: str) -> "StackView": + """Set cross-axis alignment: 'fill', 'center', 'leading'/'top', 'trailing'/'bottom'. Returns self.""" + try: + Gravity = jclass("android.view.Gravity") + a = (alignment or "").lower() + if self._axis == "vertical": + # Cross-axis is horizontal + if a in ("fill",): + self.native_instance.setGravity(Gravity.FILL_HORIZONTAL) + elif a in ("center", "centre"): + self.native_instance.setGravity(Gravity.CENTER_HORIZONTAL) + elif a in ("leading", "start", "left"): + self.native_instance.setGravity(Gravity.START) + elif a in ("trailing", "end", "right"): + self.native_instance.setGravity(Gravity.END) + else: + # Cross-axis is vertical + if a in ("fill",): + self.native_instance.setGravity(Gravity.FILL_VERTICAL) + elif a in ("center", "centre"): + self.native_instance.setGravity(Gravity.CENTER_VERTICAL) + elif a in ("top",): + self.native_instance.setGravity(Gravity.TOP) + elif a in ("bottom",): + self.native_instance.setGravity(Gravity.BOTTOM) + except Exception: + pass + return self + else: # ======================================== # iOS class @@ -53,8 +153,47 @@ def __init__(self) -> None: super().__init__() self.native_class = ObjCClass("UIStackView") self.native_instance = self.native_class.alloc().initWithFrame_(((0, 0), (0, 0))) - self.native_instance.setAxis_(0) # Set axis to vertical + # Default to vertical axis + self.native_instance.setAxis_(1) - def add_view(self, view): + def add_view(self, view: Any) -> None: self.views.append(view) self.native_instance.addArrangedSubview_(view.native_instance) + + def set_axis(self, axis: str) -> "StackView": + """Set stacking axis: 'vertical' or 'horizontal'. Returns self.""" + axis_l = (axis or "").lower() + value = 1 if axis_l == "vertical" else 0 + try: + self.native_instance.setAxis_(value) + except Exception: + pass + return self + + def set_spacing(self, spacing: float) -> "StackView": + """Set spacing between arranged subviews. Returns self.""" + try: + self.native_instance.setSpacing_(float(spacing)) + except Exception: + pass + return self + + def set_alignment(self, alignment: str) -> "StackView": + """Set cross-axis alignment: 'fill', 'center', 'leading'/'top', 'trailing'/'bottom'. Returns self.""" + a = (alignment or "").lower() + # UIStackViewAlignment: Fill=0, Leading/Top=1, Center=3, Trailing/Bottom=4 + mapping = { + "fill": 0, + "leading": 1, + "top": 1, + "center": 3, + "centre": 3, + "trailing": 4, + "bottom": 4, + } + value = mapping.get(a, 0) + try: + self.native_instance.setAlignment_(value) + except Exception: + pass + return self diff --git a/src/pythonnative/switch.py b/src/pythonnative/switch.py index bdd38bc..55d95ba 100644 --- a/src/pythonnative/switch.py +++ b/src/pythonnative/switch.py @@ -14,7 +14,7 @@ def __init__(self) -> None: super().__init__() @abstractmethod - def set_on(self, value: bool) -> None: + def set_on(self, value: bool) -> "SwitchBase": pass @abstractmethod @@ -38,8 +38,9 @@ def __init__(self, value: bool = False) -> None: self.native_instance = self.native_class(context) self.set_on(value) - def set_on(self, value: bool) -> None: + def set_on(self, value: bool) -> "Switch": self.native_instance.setChecked(value) + return self def is_on(self) -> bool: return self.native_instance.isChecked() @@ -59,8 +60,9 @@ def __init__(self, value: bool = False) -> None: self.native_instance = self.native_class.alloc().init() self.set_on(value) - def set_on(self, value: bool) -> None: + def set_on(self, value: bool) -> "Switch": self.native_instance.setOn_animated_(value, False) + return self def is_on(self) -> bool: return self.native_instance.isOn() diff --git a/src/pythonnative/templates/android_template/app/build.gradle b/src/pythonnative/templates/android_template/app/build.gradle index 2fb8251..f23ab96 100644 --- a/src/pythonnative/templates/android_template/app/build.gradle +++ b/src/pythonnative/templates/android_template/app/build.gradle @@ -6,12 +6,12 @@ plugins { android { namespace 'com.pythonnative.android_template' - compileSdk 33 + compileSdk 34 defaultConfig { applicationId "com.pythonnative.android_template" minSdk 24 - targetSdk 33 + targetSdk 34 versionCode 1 versionName "1.0" diff --git a/src/pythonnative/templates/android_template/app/src/main/res/navigation/nav_graph.xml b/src/pythonnative/templates/android_template/app/src/main/res/navigation/nav_graph.xml index cbf90d7..182bed8 100644 --- a/src/pythonnative/templates/android_template/app/src/main/res/navigation/nav_graph.xml +++ b/src/pythonnative/templates/android_template/app/src/main/res/navigation/nav_graph.xml @@ -16,7 +16,7 @@ + app:nullable="true" /> diff --git a/src/pythonnative/templates/android_template/build.gradle b/src/pythonnative/templates/android_template/build.gradle index ff78675..3d20b92 100644 --- a/src/pythonnative/templates/android_template/build.gradle +++ b/src/pythonnative/templates/android_template/build.gradle @@ -1,7 +1,7 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { - id 'com.android.application' version '8.0.2' apply false - id 'com.android.library' version '8.0.2' apply false - id 'org.jetbrains.kotlin.android' version '1.8.20' apply false + id 'com.android.application' version '8.2.2' apply false + id 'com.android.library' version '8.2.2' apply false + id 'org.jetbrains.kotlin.android' version '1.9.22' apply false id 'com.chaquo.python' version '14.0.2' apply false } \ No newline at end of file diff --git a/src/pythonnative/templates/android_template/gradle/wrapper/gradle-wrapper.properties b/src/pythonnative/templates/android_template/gradle/wrapper/gradle-wrapper.properties index f53bdea..bfe5fe7 100644 --- a/src/pythonnative/templates/android_template/gradle/wrapper/gradle-wrapper.properties +++ b/src/pythonnative/templates/android_template/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Mon Jun 19 11:09:16 PDT 2023 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/src/pythonnative/text_field.py b/src/pythonnative/text_field.py index fcf1288..d6d1de1 100644 --- a/src/pythonnative/text_field.py +++ b/src/pythonnative/text_field.py @@ -1,4 +1,5 @@ from abc import ABC, abstractmethod +from typing import Any from .utils import IS_ANDROID, get_android_context from .view import ViewBase @@ -14,13 +15,21 @@ def __init__(self) -> None: super().__init__() @abstractmethod - def set_text(self, text: str) -> None: + def set_text(self, text: str) -> "TextFieldBase": pass @abstractmethod def get_text(self) -> str: pass + @abstractmethod + def set_text_color(self, color: Any) -> "TextFieldBase": + pass + + @abstractmethod + def set_text_size(self, size: float) -> "TextFieldBase": + pass + if IS_ANDROID: # ======================================== @@ -39,12 +48,36 @@ def __init__(self, text: str = "") -> None: self.native_instance.setSingleLine(True) self.set_text(text) - def set_text(self, text: str) -> None: + def set_text(self, text: str) -> "TextField": self.native_instance.setText(text) + return self def get_text(self) -> str: return self.native_instance.getText().toString() + def set_text_color(self, color: Any) -> "TextField": + if isinstance(color, str): + c = color.strip() + if c.startswith("#"): + c = c[1:] + if len(c) == 6: + c = "FF" + c + color_int = int(c, 16) + else: + color_int = int(color) + try: + self.native_instance.setTextColor(color_int) + except Exception: + pass + return self + + def set_text_size(self, size_sp: float) -> "TextField": + try: + self.native_instance.setTextSize(float(size_sp)) + except Exception: + pass + return self + else: # ======================================== # iOS class @@ -60,8 +93,40 @@ def __init__(self, text: str = "") -> None: self.native_instance = self.native_class.alloc().init() self.set_text(text) - def set_text(self, text: str) -> None: + def set_text(self, text: str) -> "TextField": self.native_instance.setText_(text) + return self def get_text(self) -> str: return self.native_instance.text() + + def set_text_color(self, color: Any) -> "TextField": + if isinstance(color, str): + c = color.strip() + if c.startswith("#"): + c = c[1:] + if len(c) == 6: + c = "FF" + c + color_int = int(c, 16) + else: + color_int = int(color) + try: + UIColor = ObjCClass("UIColor") + a = ((color_int >> 24) & 0xFF) / 255.0 + r = ((color_int >> 16) & 0xFF) / 255.0 + g = ((color_int >> 8) & 0xFF) / 255.0 + b = (color_int & 0xFF) / 255.0 + color_obj = UIColor.colorWithRed_green_blue_alpha_(r, g, b, a) + self.native_instance.setTextColor_(color_obj) + except Exception: + pass + return self + + def set_text_size(self, size: float) -> "TextField": + try: + UIFont = ObjCClass("UIFont") + font = UIFont.systemFontOfSize_(float(size)) + self.native_instance.setFont_(font) + except Exception: + pass + return self diff --git a/src/pythonnative/text_view.py b/src/pythonnative/text_view.py index 8e9154d..1e997bf 100644 --- a/src/pythonnative/text_view.py +++ b/src/pythonnative/text_view.py @@ -1,4 +1,5 @@ from abc import ABC, abstractmethod +from typing import Any from .utils import IS_ANDROID, get_android_context from .view import ViewBase @@ -14,13 +15,21 @@ def __init__(self) -> None: super().__init__() @abstractmethod - def set_text(self, text: str) -> None: + def set_text(self, text: str) -> "TextViewBase": pass @abstractmethod def get_text(self) -> str: pass + @abstractmethod + def set_text_color(self, color: Any) -> "TextViewBase": + pass + + @abstractmethod + def set_text_size(self, size: float) -> "TextViewBase": + pass + if IS_ANDROID: # ======================================== @@ -42,12 +51,36 @@ def __init__(self, text: str = "") -> None: # self.native_instance.movementMethod = ScrollingMovementMethod() self.set_text(text) - def set_text(self, text: str) -> None: + def set_text(self, text: str) -> "TextView": self.native_instance.setText(text) + return self def get_text(self) -> str: return self.native_instance.getText().toString() + def set_text_color(self, color: Any) -> "TextView": + if isinstance(color, str): + c = color.strip() + if c.startswith("#"): + c = c[1:] + if len(c) == 6: + c = "FF" + c + color_int = int(c, 16) + else: + color_int = int(color) + try: + self.native_instance.setTextColor(color_int) + except Exception: + pass + return self + + def set_text_size(self, size_sp: float) -> "TextView": + try: + self.native_instance.setTextSize(float(size_sp)) + except Exception: + pass + return self + else: # ======================================== # iOS class @@ -63,8 +96,40 @@ def __init__(self, text: str = "") -> None: self.native_instance = self.native_class.alloc().init() self.set_text(text) - def set_text(self, text: str) -> None: + def set_text(self, text: str) -> "TextView": self.native_instance.setText_(text) + return self def get_text(self) -> str: return self.native_instance.text() + + def set_text_color(self, color: Any) -> "TextView": + if isinstance(color, str): + c = color.strip() + if c.startswith("#"): + c = c[1:] + if len(c) == 6: + c = "FF" + c + color_int = int(c, 16) + else: + color_int = int(color) + try: + UIColor = ObjCClass("UIColor") + a = ((color_int >> 24) & 0xFF) / 255.0 + r = ((color_int >> 16) & 0xFF) / 255.0 + g = ((color_int >> 8) & 0xFF) / 255.0 + b = (color_int & 0xFF) / 255.0 + color_obj = UIColor.colorWithRed_green_blue_alpha_(r, g, b, a) + self.native_instance.setTextColor_(color_obj) + except Exception: + pass + return self + + def set_text_size(self, size: float) -> "TextView": + try: + UIFont = ObjCClass("UIFont") + font = UIFont.systemFontOfSize_(float(size)) + self.native_instance.setFont_(font) + except Exception: + pass + return self diff --git a/src/pythonnative/time_picker.py b/src/pythonnative/time_picker.py index b12a395..d9085b9 100644 --- a/src/pythonnative/time_picker.py +++ b/src/pythonnative/time_picker.py @@ -14,7 +14,7 @@ def __init__(self) -> None: super().__init__() @abstractmethod - def set_time(self, hour: int, minute: int) -> None: + def set_time(self, hour: int, minute: int) -> "TimePickerBase": pass @abstractmethod @@ -28,18 +28,21 @@ def get_time(self) -> tuple: # https://developer.android.com/reference/android/widget/TimePicker # ======================================== + from typing import Any + from java import jclass class TimePicker(TimePickerBase, ViewBase): - def __init__(self, context, hour: int = 0, minute: int = 0) -> None: + def __init__(self, context: Any, hour: int = 0, minute: int = 0) -> None: super().__init__() self.native_class = jclass("android.widget.TimePicker") self.native_instance = self.native_class(context) self.set_time(hour, minute) - def set_time(self, hour: int, minute: int) -> None: + def set_time(self, hour: int, minute: int) -> "TimePicker": self.native_instance.setHour(hour) self.native_instance.setMinute(minute) + return self def get_time(self) -> tuple: hour = self.native_instance.getHour() @@ -64,9 +67,10 @@ def __init__(self, hour: int = 0, minute: int = 0) -> None: self.native_instance.setDatePickerMode_(1) # Setting mode to Time self.set_time(hour, minute) - def set_time(self, hour: int, minute: int) -> None: + def set_time(self, hour: int, minute: int) -> "TimePicker": t = time(hour, minute) self.native_instance.setTime_(t) + return self def get_time(self) -> tuple: t = self.native_instance.time() diff --git a/src/pythonnative/view.py b/src/pythonnative/view.py index 4afbbfe..abef7f6 100644 --- a/src/pythonnative/view.py +++ b/src/pythonnative/view.py @@ -1,5 +1,5 @@ from abc import ABC -from typing import Any +from typing import Any, Optional, Tuple # ======================================== # Base class @@ -11,6 +11,154 @@ def __init__(self) -> None: # Native bridge handles return types dynamically; these attributes are set at runtime. self.native_instance: Any = None self.native_class: Any = None + # Record margins for parents that can apply them (Android LinearLayout) + self._pn_margin: Optional[Tuple[int, int, int, int]] = None + + # ======================================== + # Lightweight style helpers + # ======================================== + + def set_background_color(self, color: Any) -> "ViewBase": + """Set background color. Accepts platform color int or CSS-like hex string. Returns self.""" + try: + from .utils import IS_ANDROID + + if isinstance(color, str): + # Support formats: #RRGGBB or #AARRGGBB + c = color.strip() + if c.startswith("#"): + c = c[1:] + if len(c) == 6: + c = "FF" + c + color_int = int(c, 16) + else: + color_int = int(color) + + if IS_ANDROID: + # Android expects ARGB int + self.native_instance.setBackgroundColor(color_int) + else: + # iOS expects a UIColor + from rubicon.objc import ObjCClass + + UIColor = ObjCClass("UIColor") + a = ((color_int >> 24) & 0xFF) / 255.0 + r = ((color_int >> 16) & 0xFF) / 255.0 + g = ((color_int >> 8) & 0xFF) / 255.0 + b = (color_int & 0xFF) / 255.0 + try: + color_obj = UIColor.colorWithRed_green_blue_alpha_(r, g, b, a) + except Exception: + color_obj = UIColor.blackColor() + try: + self.native_instance.setBackgroundColor_(color_obj) + except Exception: + try: + # Some UIKit classes expose 'backgroundColor' property + self.native_instance.setBackgroundColor_(color_obj) + except Exception: + pass + except Exception: + pass + return self + + def set_padding( + self, + left: Optional[int] = None, + top: Optional[int] = None, + right: Optional[int] = None, + bottom: Optional[int] = None, + all: Optional[int] = None, + horizontal: Optional[int] = None, + vertical: Optional[int] = None, + ) -> "ViewBase": + """Set padding (dp on Android; best-effort on iOS where supported). Returns self. + + When provided, 'all' applies to all sides; 'horizontal' applies to left and right; + 'vertical' applies to top and bottom; individual overrides take precedence. + """ + try: + from .utils import IS_ANDROID, get_android_context + + left_value = left + top_value = top + right_value = right + bottom_value = bottom + if all is not None: + left_value = top_value = right_value = bottom_value = all + if horizontal is not None: + left_value = horizontal if left_value is None else left_value + right_value = horizontal if right_value is None else right_value + if vertical is not None: + top_value = vertical if top_value is None else top_value + bottom_value = vertical if bottom_value is None else bottom_value + left_value = left_value or 0 + top_value = top_value or 0 + right_value = right_value or 0 + bottom_value = bottom_value or 0 + + if IS_ANDROID: + density = get_android_context().getResources().getDisplayMetrics().density + lpx = int(left_value * density) + tpx = int(top_value * density) + rpx = int(right_value * density) + bpx = int(bottom_value * density) + self.native_instance.setPadding(lpx, tpx, rpx, bpx) + else: + # Best-effort: many UIKit views don't have direct padding; leave to containers (e.g. UIStackView) + # No-op by default. + pass + except Exception: + pass + return self + + def set_margin( + self, + left: Optional[int] = None, + top: Optional[int] = None, + right: Optional[int] = None, + bottom: Optional[int] = None, + all: Optional[int] = None, + horizontal: Optional[int] = None, + vertical: Optional[int] = None, + ) -> "ViewBase": + """Record margins for this view (applied where supported). Returns self. + + Currently applied automatically when added to Android LinearLayout (StackView). + """ + try: + left_value = left + top_value = top + right_value = right + bottom_value = bottom + if all is not None: + left_value = top_value = right_value = bottom_value = all + if horizontal is not None: + left_value = horizontal if left_value is None else left_value + right_value = horizontal if right_value is None else right_value + if vertical is not None: + top_value = vertical if top_value is None else top_value + bottom_value = vertical if bottom_value is None else bottom_value + left_value = int(left_value or 0) + top_value = int(top_value or 0) + right_value = int(right_value or 0) + bottom_value = int(bottom_value or 0) + self._pn_margin = (left_value, top_value, right_value, bottom_value) + except Exception: + pass + return self + + def wrap_in_scroll(self) -> Any: + """Return a ScrollView containing this view as its only child. Returns the ScrollView.""" + try: + # Local import to avoid circulars + from .scroll_view import ScrollView + + sv = ScrollView() + sv.add_view(self) + return sv + except Exception: + return None # @abstractmethod # def add_view(self, view): diff --git a/src/pythonnative/web_view.py b/src/pythonnative/web_view.py index ee3a16e..b72d51b 100644 --- a/src/pythonnative/web_view.py +++ b/src/pythonnative/web_view.py @@ -14,7 +14,7 @@ def __init__(self) -> None: super().__init__() @abstractmethod - def load_url(self, url: str) -> None: + def load_url(self, url: str) -> "WebViewBase": pass @@ -34,8 +34,9 @@ def __init__(self, url: str = "") -> None: self.native_instance = self.native_class(context) self.load_url(url) - def load_url(self, url: str) -> None: + def load_url(self, url: str) -> "WebView": self.native_instance.loadUrl(url) + return self else: # ======================================== @@ -52,7 +53,8 @@ def __init__(self, url: str = "") -> None: self.native_instance = self.native_class.alloc().init() self.load_url(url) - def load_url(self, url: str) -> None: + def load_url(self, url: str) -> "WebView": ns_url = NSURL.URLWithString_(url) request = NSURLRequest.requestWithURL_(ns_url) self.native_instance.loadRequest_(request) + return self diff --git a/tests/test_cli.py b/tests/test_cli.py index f9b0eaf..4a1eac9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3,14 +3,15 @@ import subprocess import sys import tempfile +from typing import List -def run_pn(args, cwd): +def run_pn(args: List[str], cwd: str) -> subprocess.CompletedProcess[str]: cmd = [sys.executable, "-m", "pythonnative.cli.pn"] + args return subprocess.run(cmd, cwd=cwd, check=False, capture_output=True, text=True) -def test_cli_init_and_clean(): +def test_cli_init_and_clean() -> None: tmpdir = tempfile.mkdtemp(prefix="pn_cli_test_") try: # init @@ -40,7 +41,7 @@ def test_cli_init_and_clean(): shutil.rmtree(tmpdir, ignore_errors=True) -def test_cli_run_prepare_only_android_and_ios(): +def test_cli_run_prepare_only_android_and_ios() -> None: tmpdir = tempfile.mkdtemp(prefix="pn_cli_test_") try: # init to create app scaffold diff --git a/tests/test_smoke.py b/tests/test_smoke.py index f5c04c4..d346c62 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -1,2 +1,2 @@ -def test_pytest_workflow_smoke(): +def test_pytest_workflow_smoke() -> None: assert 2 + 2 == 4