diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..d0507c9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,17 @@ +name: Bug report +description: Report a reproducible application problem +body: + - type: textarea + id: environment + attributes: + label: Environment + description: OS/version, CPU architecture, desktop/session, Python, Qt and selected OCR/translation/capture engines. + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Steps and expected/actual behavior + description: Include relevant diagnostics, but remove API keys and private screen content. + validations: + required: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..e79bd11 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,7 @@ +Describe the user-visible problem and resulting behavior. + +Validation performed (commands and results): + +Linux/macOS native acceptance performed or still unavailable: + +Settings/preset migration or packaging impact, if applicable: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6ba54c9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: Checks +on: + push: + pull_request: +jobs: + portable: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-14] + python: ['3.10', '3.14'] + runs-on: ${{ matrix.os }} + env: + QT_QPA_PLATFORM: offscreen + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + - if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libegl1 libopengl0 libxkbcommon0 + - run: python -m pip install --upgrade pip + - run: python -m pip install -e '.[dev]' + - run: ruff check . + - run: ruff format --check . + - run: mypy + - run: python -m pip check + - run: python -m unittest discover -s tests -v diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml new file mode 100644 index 0000000..bd8a41b --- /dev/null +++ b/.github/workflows/package.yml @@ -0,0 +1,27 @@ +name: Package +on: + push: + pull_request: +jobs: + artifacts: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.14' + - run: sudo apt-get update && sudo apt-get install -y libegl1 libopengl0 libxkbcommon0 + - run: python -m pip install '.[dev]' + - run: python -m build + - run: USTA_REQUIRE_ARTIFACTS=1 python -m unittest tests.packaging.test_artifacts -v + - name: Install wheel into a clean environment + run: | + python -m venv /tmp/usta-wheel-test + /tmp/usta-wheel-test/bin/python -m pip install dist/*.whl + cd /tmp + /tmp/usta-wheel-test/bin/python -c "from importlib.metadata import distribution; from usta.resource_paths import resource_path; import usta.main; assert resource_path('resources/translations/tr/LC_MESSAGES/messages.mo').is_file(); assert any(e.name == 'usta' for e in distribution('usta').entry_points)" + /tmp/usta-wheel-test/bin/python -m pip check + - uses: actions/upload-artifact@v4 + with: + name: python-distributions + path: dist/* diff --git a/.gitignore b/.gitignore index c7ff63c..a50bccc 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,16 @@ target/ #Ipython Notebook .ipynb_checkpoints + +# Local environments and tooling +.venv/ +.ruff_cache/ +.mypy_cache/ +.pytest_cache/ +.superpowers/ + +# Local native/runtime scratch +/:memory:.ses + +# Reusable macOS build recipe +!packaging/macos/usta.spec diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9a88b6d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +## Unreleased + +- Package the application as `usta`, including OCR models and translation catalogs. +- Consolidate dependency/version declarations and optional Paddle support. +- Preserve legacy settings/presets while migrating to atomic, versioned JSON. +- Separate tab widgets, window lifecycle, settings presentation and capture controllers. +- Own capture and translation jobs explicitly and invalidate stale results after stop/configuration changes. +- Bound IPC queues and isolate temporary capture files per session. +- Add regression tests, packaging checks, style/type checks and contributor documentation. + +Linux KDE/GNOME and macOS native behavior remain required release acceptance checks; see `docs/compatibility.md` for actual verification status. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9785037 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,31 @@ +# Contributing to USTA + +USTA uses Python and PySide6. Keep user-visible behavior and Linux/macOS native window rules stable when refactoring. + +## Development setup + +Create a virtual environment (Linux Portal users need system PyGObject/GStreamer packages and `--system-site-packages`), then run: + +```sh +python -m pip install -e '.[dev]' +python run.py +``` + +Paddle is opt-in: `python -m pip install -e '.[paddle]'`. Its native runtime must be available for your interpreter/architecture. Existing default OCR engines remain installed by the standard setup. + +## Before opening a pull request + +```sh +ruff check . +ruff format --check . +mypy +QT_QPA_PLATFORM=offscreen python -m unittest discover -s tests -v +python -m build +python -m pip check +``` + +Do not use real translation APIs, private screenshots, or automatic model downloads in unit tests. Regression tests should reproduce a specific user-visible failure. Native desktop tests are opt-in; see [testing](docs/testing.md). Report skipped or unavailable platform checks honestly. + +Keep each change focused. Separate mechanical formatting/moves from behavior fixes. Update documentation and translations when changing user-facing text. Never commit API keys, local settings, or screen content. + +See [architecture](docs/architecture.md), [development](docs/development.md), and [compatibility](docs/compatibility.md) for module boundaries and release checks. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..2035fe0 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,7 @@ +include LICENSE README.md CONTRIBUTING.md CHANGELOG.md requirements*.txt babel.cfg +recursive-include docs *.md +recursive-include tests *.py +recursive-include packaging * +recursive-include scripts *.sh +recursive-include src/usta/resources *.onnx *.po *.mo +recursive-include src/usta/ui/assets *.png *.svg diff --git a/README.md b/README.md index 83e0cf3..fc9a2a6 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # USTA (Universal Screen Translator Application) Real-time OCR translation for games, videos and desktop. -[![USTA demo cover](src/ui/assets/usta_cover.png)](https://www.youtube.com/watch?v=jGHMmDznum8) +[![USTA demo cover](src/usta/ui/assets/usta_cover.png)](https://www.youtube.com/watch?v=jGHMmDznum8) By "sniping" a specific area of your screen, USTA continuously monitors that region, extracts text using OCR (Optical Character Recognition), and overlays the translated text instantly, making it perfect for playing untranslated games or watching foreign media. -> **Note:** Current version is optimized for **KDE Plasma and GNOME (Wayland)** using **xcb** mode. Future versions will expand compatibility. +> **Platforms:** Linux KDE Plasma/GNOME (Wayland with xcb) and macOS. See [compatibility and validation status](docs/compatibility.md). ## 🚀 Key Features @@ -54,7 +54,7 @@ While currently focused on KDE/Linux, support for the following is planned: - [ ] **Native Linux Packaging:** Distribution via `.deb` (Debian/Ubuntu), AUR (Arch Linux), and **Flatpak**. - [ ] **Other Linux DEs:** Full native Wayland support without xcb. - [ ] **Windows Support:** Integration with Windows-native capture and OCR APIs. -- [ ] **macOS Support:** Support for macOS native APIs and capture systems. +- [x] **macOS Support:** Native capture, Apple Vision OCR, fullscreen overlays and global shortcuts. --- @@ -207,6 +207,28 @@ instead. --- +### Apple Vision OCR on macOS + +On macOS, select **Apple Vision** in the **OCR Engine** menu to recognize text +locally using Apple's Vision framework. Select the source language in the +translation settings, or choose **Auto** for automatic language detection. +Language availability depends on your macOS version; unsupported selections use +automatic detection with a warning in the console. + +The macOS dependency is included in `requirements.txt`. To add it to an existing +installation, run `.venv/bin/python -m pip install 'pyobjc-framework-Vision>=10.0'` +and restart USTA. Apple Vision does not appear in the engine list on Linux or Windows. + +## macOS global shortcut permissions + +If startup reports missing Accessibility permission, open **System Settings → Privacy & Security → Accessibility** and allow the app/terminal running USTA (or the Python app shown by macOS). Restart USTA after granting access. The log includes the Python executable to help identify the launcher. USTA cannot grant this permission itself; screen capture/OCR and global keyboard access are separate capabilities. + +USTA reports missing Accessibility permission once per denied period and preserves event-tap startup, since macOS Input Monitoring permission can be separate. Linux shortcut behavior is unchanged. See [Apple's Accessibility permission guide](https://support.apple.com/guide/mac-help/mh43185/mac). + +## Development and contributions + +See [CONTRIBUTING](CONTRIBUTING.md) for development setup, tests and code standards, [architecture](docs/architecture.md) for module boundaries, and [testing](docs/testing.md) for native desktop checks. Install optional Paddle support with `pip install '.[paddle]'` when its native runtime is available. + ## 🛠️ Tech Stack * **GUI:** PySide6 * **OCR:** pytesseract, easyocr @@ -226,3 +248,30 @@ USTA may process visible screen content, interact with local desktop capture mec The developers, maintainers, contributors, and distributors of USTA shall not be held liable for any direct, indirect, incidental, consequential, special, exemplary, or punitive damages resulting from the application, including damages caused by user configuration, third-party services, external integrations, dependency behavior, system incompatibilities, or any other circumstance connected to the use of the software. If you do not accept full responsibility for the risks described above, you should not install, run, or use USTA. + +## System tray and window shortcuts + +At startup, a balloon points to the tray icon for ten seconds to show where the USTA menu is. It does not require notification permission or take keyboard focus. + +The USTA tray menu lets you show/hide the main window and translation overlay +independently, start or stop OCR, select a capture region, select a temporary +region, and quit. Start is disabled while OCR is running; Stop is disabled while +OCR is stopped. +Temporary region selection is available while OCR is running and restores the +original region after ten seconds. Hiding either window keeps OCR and translation +running. Shown windows stay above other applications, retaining the existing +macOS fullscreen overlay behavior and Linux window-manager support. + +The **Show/hide main window** shortcut (default **Shift+Alt+M**) uses the same action +as the tray menu. Existing saved shortcut combinations are retained. Closing the +main window with **X** hides it; use **Quit Application** in the tray menu to exit. + +On Linux, a desktop with system tray support is required to display the icon +(some GNOME configurations need a tray extension). If no tray is available, **X** +exits normally so the application cannot become inaccessible in the background. + +## macOS application package + +See [Building and installing the macOS DMG](docs/macos-dmg.md) for the Apple Silicon build recipe, bundled engines, permissions and signing details. + +Apple-trusted distribution DMGs require a Developer ID Application identity and notarization. Free persistent self-signed builds use `USTA_SIGNING_MODE=self-signed` with the pinned Keychain certificate and produce `-selfsigned.dmg`; they are not Apple-notarized. The build refuses silent ad-hoc fallback; explicit local builds use `USTA_ALLOW_ADHOC=1` and a `-dev.dmg` filename. See [macOS signing setup](docs/macos-dmg.md). diff --git a/docs/appimage.md b/docs/appimage.md index 2cd48f4..49678fc 100644 --- a/docs/appimage.md +++ b/docs/appimage.md @@ -1,21 +1,20 @@ # USTA AppImage Packaging -This document describes the AppImage packaging strategy for USTA without changing the -application source code. +This document describes the AppImage packaging strategy for USTA using the installed application package. ## Strategy USTA is a Python/PySide6 application with the runtime entry point declared in -`pyproject.toml` as `usta = "src.main:main"`. The AppImage build creates an +`pyproject.toml` as `usta = "usta.main:main"`. The AppImage build creates an AppDir containing: - the installed USTA Python package, -- Python wheel dependencies from `requirements.txt`, -- the application icon from `src/ui/assets/usta.png`, +- Python dependencies from the project metadata, +- the application icon from `src/usta/ui/assets/usta.png`, - a desktop entry from `packaging/appimage/usta.desktop`, - an `AppRun` wrapper from `packaging/appimage/AppRun`. -The wrapper starts the app with `python3 -m src.main` and sets `QT_QPA_PLATFORM=xcb` +The wrapper starts the app with `python3 -m usta.main` and sets `QT_QPA_PLATFORM=xcb` unless the user already provided a different value. ## Why some dependencies remain system dependencies @@ -139,5 +138,5 @@ Validate the following: ## Notes -This build path avoids changes to application modules such as `src/main.py` and keeps +This build path avoids changes to application modules such as `src/usta/main.py` and keeps all AppImage-specific behavior in `packaging/appimage` and `scripts`. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..70de900 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,18 @@ +# Architecture + +The installed package is `usta`, in `src/usta`. `usta`, `python -m usta` and the repository's `run.py` enter `usta.main.main`. Main configures logging and runtime directories before creating Qt widgets. + +- `application` contains persistent value models, typed translation jobs/results, controllers, the capture pipeline and its Qt thread adapter. +- `core/ocr` and `core/translation` implement existing providers. The lazy registry gives capability lists and manager factories one naming source. Backend `read_text` remains a string boundary; pipeline input uses `OCRResult` and translation requests/results carry immutable session metadata. +- `core/screenshot` owns native capture, including Portal/PipeWire/GStreamer state. Native implementations load only when selected. `core/platform` resolves OS/desktop-specific overrides. +- `infrastructure` owns atomic storage, primitive-only legacy migration, runtime paths, logging and Unix socket broadcasting. +- `ui` owns widgets, settings presentation, region selector windows and Cocoa window helpers. Tabs own their widgets and emit actions; they do not access worker internals. Core engines must not import UI. +- `resources` contains unchanged ONNX models and gettext catalogs. `resource_paths` retains extracted installed resources for the application lifetime. + +## Thread ownership + +The GUI submits immutable settings to the worker. Its capture loop owns screenshot and OCR instances. One translation executor creates a separate translator for each immutable job. Session identifiers invalidate results after stop or configuration change. GUI stop requests do not wait on the network; closing the window waits asynchronously for capture cleanup. Socket I/O runs on its own thread with bounded queues and per-client pending bytes. + +## Persistence + +Settings/presets use schema-versioned JSON under the existing platform config directory. Writes use a private temporary file and atomic replacement. Legacy local pickle files are read using a globals-rejecting unpickler and remain untouched after migration. Unsupported/corrupt JSON is preserved rather than silently overwritten. Downgrading does not backport later JSON edits to the old pickle files. diff --git a/docs/compatibility.md b/docs/compatibility.md new file mode 100644 index 0000000..e2953b7 --- /dev/null +++ b/docs/compatibility.md @@ -0,0 +1,13 @@ +# Compatibility record + +The cleanup preserves the existing Linux KDE/GNOME capture paths and macOS Cocoa window behavior. No new Windows support is claimed. + +| Environment | Automated contracts | Native acceptance | +| --- | --- | --- | +| macOS / Python 3.14 (local environment) | Passed during each phase; final results in execution ledger | Passed outside sandbox: native fullscreen Space/overlay/focus restoration, shortcut lifecycle and Apple Vision recognition (23 Oct 2026) | +| KDE Wayland with xcb | Portable contracts | Requires real Linux desktop; unverified | +| GNOME Wayland with xcb | Portable contracts | Requires real Linux desktop; unverified | + +Manual acceptance: launch; select OCR and translator; select/cancel region; test multiple screens and DPI lock; start/stop/restart; use both shortcuts; restore temporary region after 10 seconds; save/reload presets; restart settings; listen to IPC. On macOS also test another application's native fullscreen Space, focus restoration and Retina. On Linux test Portal allow/deny/cancel and KDE Spectacle fallback. Record OS, CPU architecture, Python/Qt/native tool versions and results before a release. + +CI definitions include Python 3.10/3.14 on Linux and macOS, but these remote jobs have not been run during the local cleanup. Python 3.10 is the language/API floor; dependency wheel availability must be verified by that matrix. Multi-monitor/Retina hardware combinations beyond this local host still need manual acceptance. diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..d66610a --- /dev/null +++ b/docs/development.md @@ -0,0 +1,11 @@ +# Development + +Use the setup and checks in [CONTRIBUTING](../CONTRIBUTING.md). Python 3.10 is the declared language/API floor, correcting the former inaccurate 3.8 claim. The local cleanup is verified on Python 3.14; other interpreter/native dependency combinations need their own install evidence. ML wheels may have narrower platform/interpreter availability. + +Runtime dependencies are declared in `pyproject.toml`; `requirements.txt` delegates to the same source. The `dev` extra contains checks/build tooling, and `paddle` contains the opt-in Paddle dependencies. Version metadata comes from `usta._version` without importing the GUI. + +Use `python -m build` to produce wheel and sdist. After a build run `python -m unittest tests.packaging.test_artifacts -v` to inspect all resource bytes in both artifacts. Packaging validation must also install a wheel into a fresh environment and import from outside the repository. + +The AppImage script installs the same project metadata once and starts `python -m usta`. Desktop services, native OCR executables and host GStreamer remain system dependencies. See [AppImage](appimage.md). + +Ruff checks imports, unused values and syntax/style correctness; its formatter provides one consistent layout. Mypy currently checks settings/results/storage boundaries; expand coverage as native SDK annotations become practical. Avoid broad suppressions for the entire codebase. diff --git a/docs/development/cleanup-progress.md b/docs/development/cleanup-progress.md new file mode 100644 index 0000000..a71e3cb --- /dev/null +++ b/docs/development/cleanup-progress.md @@ -0,0 +1,76 @@ +# Cleanup execution ledger + +Plan: ../superpowers/plans/2026-10-23-project-cleanup.md + +Ruling: Work in the existing feature/macos_support_1 checkout; preserve the existing environment and keep changes reviewable in place. No main branch changes or external publication. +Ruling: Real KDE/GNOME acceptance needs an actual Linux desktop; implement and run portable contracts here, explicitly leave native acceptance unverified. +Pre-flight: Tasks 4/6/7/8 share settings/results/worker interfaces; migrate their consumers in the same task, retaining Qt signals and public launch commands. Task 3 changes all import paths before subsequent modules are introduced. + +## Baseline +Host: macOS-26.5.2-arm64-arm-64bit-Mach-O; Python 3.14.7 +- PySide6: 6.11.2 +- googletrans: 4.0.2 +- rapidocr-onnxruntime: 1.2.3 +- pynput: 1.8.2 +- deepl: 1.32.0 + +Task 1: portable baseline 33 tests, 21 native skips, 0 failures. Initial unrestricted discovery aborted in Cocoa and Vision recognition failed because desktop services are inaccessible in sandbox (log /tmp/usta-baseline.log). Native tests now explicitly opt in. Linux window contracts run offscreen on either OS. + +Task 2: metadata regression failed before fix (missing DeepL). Dependency declarations now have one source and paddle/dev extras. Python 3.10 is the syntax/API floor, NOT a claim that all untested dependency combinations support 3.10; local 3.14 is recorded separately. Full lower-version install matrix remains CI validation. Existing environment pip check passed. + +Task 3: package layout tests failed before move and pass after; 36 portable tests green (21 skips); editable install succeeded. Resources resolve through importlib.resources with process-owned extraction lifetime. Wheel/sdist inspection follows once build tool installation completes. Model bytes unchanged by directory moves. + +Task 3 artifact follow-up: wheel and sdist built; all resources present in both. +Task 4: six storage regression tests passed; full portable suite 42 tests green (21 skips). Primitive-only legacy migration preserves originals, future/corrupt JSON cannot be overwritten, atomic replace failure preserves last settings. UI load signals blocked and does not save. + +Task 5: import side-effect test reproduced stdout output before removal and passes after. 43 portable tests green (21 skips); config no longer creates directories; bootstrap owns path preparation and logging. Diagnostic print calls replaced with module loggers; OCR content/key diagnostics are debug-only. + +Task 6: three new engine contract tests plus full suite pass (46 tests, 21 skips). Managers lazily construct selected engines; provider failures use EngineError and existing read_text string boundary is retained for backend compatibility, with typed read_result/translate_request boundaries for pipeline. Ruling: installed RapidOCR 1.2.x has no public thread-count option (source inspected); preserve proven patch in dedicated adapter rather than changing runtime threading. + +Task 7: Event-controlled delayed API, reconfiguration and EngineError tests pass; full portable suite 50 tests (21 skips). Capture/engine state owned by the loop; immutable jobs and session invalidation suppress stale results. GUI stop is non-blocking, close waits via finished signal. Portal sessions survive region changes; native command waits bounded. Ruling: DeepL SDK constructor has no timeout/retry keyword parameters; contract regression caught this and implementation retains SDK's finite built-in timeout/backoff policy (source inspected), rather than global monkey-patching. + +Task 8: architecture tests failed on core→UI imports and panel mutation, pass after split. Tabs own widgets and emit actions; application/region/hotkey controllers and settings presenter separate responsibilities. Linux/macOS window algorithms retained in UI platform modules. Two real Qt offscreen integration tests confirm load does not save, user change saves once, preset cancels temporary restore. Full suite validation recorded below. + +Task 9: private capture workspace and real Unix socket tests pass outside sandbox (sandbox forbids bind). Full suite 58 tests green, 21 native skips. Active socket and ordinary files are preserved, pending broadcasts bounded, independent capture workspaces cleaned individually. IPC JSON/address retained. New screenshot factory avoids importing Linux native modules on macOS. + +Task 10: Ruff check/format and mypy (four boundary modules) pass. Contributor, architecture, testing, compatibility and model provenance docs added; README macOS status corrected. UI status/preset messages now use existing TR/UK catalogs; native callback names and window algorithms retained. Model provenance is explicitly unverified rather than assuming source-code license covers models. + +Task 11 implementation: CI matrix and package workflow added; wheel/sdist build and byte-for-byte resource checks passed. Clean no-dependency wheel venv imported package/resources/entry point outside repo; full dependency install is defined in CI, not claimed locally. Native macOS suite passed all 59 tests without skips outside sandbox, including fullscreen host helper and Vision. Final acceptance found queued Qt delivery could outlive stop and repeated start could request restart: both reproduced RED and fixed GREEN with new worker signal tests. RapidOCR compatibility option/idempotence contract added. Real Linux KDE/GNOME, AppImage execution, remote CI and additional Python/architecture combinations remain unverified external checks. + +Final acceptance before review: all 63 tests passed with USTA_NATIVE_TESTS=1 (no skips), Ruff check/format passed, mypy passed, pip check passed, Bash syntax checks passed. Clean wheel imported all eight ONNX models and catalogs outside repository. Synthetic Apple Vision comparison on same host/image: baseline median 31.5 ms, current 32.3 ms, six warm samples each; identical recognized text. This small timing difference is not a full OCR/provider performance certification. Remaining external checks: Linux desktop/AppImage, remote CI and extra hardware/interpreter combinations. + +## Independent final review and fix pass + +A fresh gpt-6-astra reviewer examined the full branch read-only. P1 malformed preset data loss and P2 UI unknown-field loss were confirmed. Two additional findings (overlay state during queued restart and stale installed AppImage version) were regraded Important because they affect supported start/stop behavior and release artifact identification; all four were fixed in this single pass. + +Final: fixed malformed-preset overwrite — store/presenter regressions RED→GREEN; valid entries recover and writes remain blocked until malformed original is repaired. +Final: fixed unknown settings fields lost on UI save — Qt regression RED→GREEN; loaded model metadata retained. +Final: fixed overlay mode after queued restart — false/true status replay RED→GREEN. +Final: fixed AppImage version source — metadata regression RED→GREEN; source version file used without GUI import. +No review findings deferred. Linux desktop/AppImage and remote CI remain external acceptance items, not successful checks. Generated native scratch file accidentally included in the CI commit was removed and ignored. + +## Final verification + +- `USTA_NATIVE_TESTS=1 .venv/bin/python -m unittest discover -s tests -v`: **72 passed, zero skips**, 11.076 seconds, outside sandbox. Includes native fullscreen/overlay/focus, Vision, hotkey lifecycle, real IPC, portable Linux factory/window contracts, storage and review regressions. +- `ruff check .`, `ruff format --check .`: passed. +- `mypy`: passed on four declared boundary modules. +- `python -m pip check`: no broken requirements. +- `python -m build --no-isolation`: wheel and sdist built. All source model/catalog bytes match both artifacts. +- Clean wheel venv outside repository: package/resources/entry point imported. Installed wheel Qt startup/close smoke passed using shared existing runtime dependencies; no user settings touched. This is separate from the full clean dependency install in the unrun CI workflow. +- `bash -n scripts/build-appimage.sh packaging/appimage/AppRun` and `git diff --check`: passed. +- All eleven implementation stages have landed locally. Full Linux desktop/AppImage acceptance, remote CI version matrix, extra monitor/architecture combinations and exhaustive OCR memory/performance benchmarking remain unverified; they are not marked passed. +- Changes retained on existing `feature/macos_support_1`; no merge, remote push, release or publication performed. + +## Follow-up: macOS startup warnings + +User reported repeated pynput Accessibility warnings and Qt monospace alias lookup. Root causes verified in installed pynput's AXIsProcessTrusted check and StatusTab stylesheet. Missing Accessibility trust is now explained once per denied period; only pynput's duplicate message is filtered, event-tap startup and Linux listener behavior are preserved. StatusTab uses Qt's available system FixedFont. Three regressions added (permission warning deduplication, permission recheck for a new listener, system font). Final native suite: 75 passed, zero skips; Ruff check/format and mypy passed. macOS permissions were not modified by the application. + +## Follow-up: tab signal argument mismatch + +Reproduced `changed() only accepts 0 argument(s), 1 given` and `language_changed()` TypeErrors via Qt selection events while capturing sys.excepthook (Qt slot exceptions do not normally fail unittest). No-argument notification/action signals now use Qt signal-to-signal connections so extra widget arguments are dropped; typed Python forwarding remains unchanged (notably QFont to object). Covers OCR/capture, source/target language, translation provider, color/preset/reset buttons. End-to-end tests assert worker values, saved values and one write per selection. Relevant integration group: 14 passed; Ruff check/format and mypy passed. Broader native run: 97 tests, 95 passed, two fullscreen helper transitions timed out. The same two tests also timed out when isolated; these checks are not marked passed. + +## Follow-up: macOS overlay hover cursors + +Qt's Cocoa mouse-move/cursor-update handlers skip inactive applications, so QWidget cursor changes alone did not handle the nonactivating overlay. Added a local, always-active NSTrackingArea and public NSCursor updates: edge/corner hover selects resize before pressing, center hover selects open hand, and moving selects closed hand. Tracking is removed on hide/close, obscured windows do not change the cursor, and older macOS versions get drawn diagonal cursor images. Linux retains the Qt interaction path. Native tests invoke the tracking owner with real NSEvent objects and verify NSCursor state; physical pointer event delivery remains a manual check. Independent read-only review found no actionable material findings. + +Latest complete native-enabled suite: 104 tests passed in 12.493 seconds, including the two fullscreen tests that previously timed out. Ruff check/format, mypy on the four boundary modules, and git diff whitespace checks passed. diff --git a/docs/macos-dmg.md b/docs/macos-dmg.md new file mode 100644 index 0000000..ad03dfd --- /dev/null +++ b/docs/macos-dmg.md @@ -0,0 +1,198 @@ +# macOS DMG + +The current recipe builds USTA for **Apple Silicon (arm64), macOS 26 or newer**. +The Python runtime used for this release has a macOS 26 deployment target. +The application includes Python, PySide6, RapidOCR models, Apple Vision bindings, +and the EasyOCR/PyTorch dependencies. No separate Python installation is needed. + +## Distribution signing: one-time setup + +For distribution to other Macs, enroll in the [Apple Developer Program](https://developer.apple.com/programs/), +then create a [Developer ID Application certificate](https://developer.apple.com/help/account/certificates/create-developer-id-certificates/). +Install the certificate with its matching private key in the build Mac's Keychain. +Use the same Apple Team and `com.sedataym.usta` bundle identifier for subsequent releases. +This is the Apple-trusted distribution path. For the explicitly requested free +self-signed workflow, see below; it is not notarized and has additional first-install steps. + +Check the installed identity (the printed SHA-1 fingerprint is public metadata): + +```sh +security find-identity -v -p codesigning +``` + +Store notarization credentials interactively in Keychain; no password goes into the +repository, build script or chat: + +```sh +xcrun notarytool store-credentials "usta-notary" +``` + +Use a current Xcode/Command Line Tools installation containing `notarytool` and +`stapler`. Notarization uses your Apple account; it is separate from user privacy +consent and never grants Screen Recording, Accessibility or Input Monitoring. + +## Build a distribution DMG + +From an Apple Silicon Mac with the project virtual environment installed: + +```sh +.venv/bin/python -m pip install 'pyinstaller>=6.16,<7' +export USTA_CODESIGN_IDENTITY="Developer ID Application: Your Publisher (ABCDE12345)" +export USTA_SIGNING_TEAM_ID="ABCDE12345" +export USTA_NOTARY_PROFILE="usta-notary" +./scripts/build-dmg.sh +``` + +The script checks that the certificate and private key exist, the certificate is +Developer ID Application, and the configured Team ID matches. It checks notarization +credentials before building. PyInstaller signs the app and embedded libraries with +hardened runtime. The post-build check requires the fixed bundle ID, expected Team +ID and a stable Apple certificate requirement instead of an ad-hoc `cdhash`. + +The signed DMG is submitted to Apple. Only an `Accepted` result, successful ticket +stapling/validation and Gatekeeper assessment allow it to be renamed to +`dist/macos/USTA--macOS-arm64.dmg`. Until then it has a hidden pending filename. +The notarization result is in `build/macos/notarization.json`. The standalone `.app` +and DMG receive their tickets; the DMG's ticket covers the application inside it. +The final SHA-256 checksum is written after stapling. + +The disk image contains the application, an Applications shortcut, installation notes +and `SIGNING.txt` describing the build. No separate Python installation is required. +Offline diagnostics verify Qt/SVG resources, translations, dynamic engine imports, +EasyOCR/PyTorch, actual OCR tab engine options and RapidOCR/Apple Vision recognition. +They do not capture the screen, start keyboard listeners or make network calls. +The report is saved to `build/macos/bundle-check.json`. + +Set `PYTHON_BIN` to use another compatible project Python environment. The target +remains Apple Silicon/macOS 26+. + +## Free persistent self-signed builds + +The build Mac has a dedicated `USTA Local Code Signing` identity in its login +Keychain. Its public certificate is `packaging/macos/certs/usta-self-signed.cer`; +`self-signed-identity.json` pins its SHA-1, SHA-256 and bundle identifier. The private +key stays in Keychain and must never be committed or included in a DMG. Back up the +identity, including its private key, securely through Keychain Access before moving +to another build Mac. Restore that identity; do not generate a new certificate for +an update. A lost key means losing continuity with this identity. + +```sh +USTA_SIGNING_MODE=self-signed ./scripts/build-dmg.sh +``` + +This produces `USTA--macOS-arm64-selfsigned.dmg` and a SHA-256 checksum. +It fails if the pinned certificate/private key is missing or a different identity +is selected. The application and nested code are signed after PyInstaller's initial +ad-hoc assembly. Hardened runtime/library validation is not enabled in this mode: +self-signed certificates have no Apple Team ID. Offline bundle checks still run. + +The designated requirement binds `com.sedataym.usta` to the exact pinned certificate, +not a per-build code hash. Updates must preserve this certificate, key, identifier, +and installation path. The DMG includes the **public certificate only** and signing +notes. This is not Apple-trusted or notarized: another Mac can require a Gatekeeper +exception and local certificate trust setup, plus the normal initial privacy grants. +Do not disable Gatekeeper or system protections globally. Certificate trust, when +needed, should be limited to code signing in the user's Keychain, not SSL trust. + +Self-signing is intended to stabilize the application identity; permission retention +must be checked on the installed app and may still be affected by macOS policy. +The initial migration from an old ad-hoc app can require granting permissions once +again. Never reset permissions between the two signed versions in an upgrade test. + +Read-only privacy diagnostics (no requests, resets or keyboard listener): + +```sh +open -n /Applications/USTA.app --args --check-permissions /tmp/usta-permissions.json +``` + +Wait for the JSON output. It reports the bundle identity, version, executable and +Screen Recording, Accessibility and Input Monitoring preflight results. Launch via +Launch Services as shown; running the executable directly in Terminal can use a +different privacy responsibility chain. Confirm actual capture and shortcuts too. + +## Explicit local development build + +```sh +USTA_ALLOW_ADHOC=1 ./scripts/build-dmg.sh +``` + +This opt-in produces `USTA--macOS-arm64-dev.dmg`, with no notarization. +Ad-hoc code hashes change on rebuild, so privacy permissions may be invalidated. +The regular distribution command refuses to fall back to this mode. Existing +0.1.10 and earlier DMGs remain ad-hoc builds; changing the recipe does not re-sign them. + +## Permission continuity + +The first transition from an ad-hoc build to the Developer ID build can require a +one-time regrant. Later updates preserve signing identity when the same publisher +Team ID and bundle ID are retained. Apple can still require consent due to system +policy changes, revocation or explicit user resets; signing is not a permission bypass. +Notarization provides Gatekeeper verification, while the stable signing requirement +is what addresses the code-hash mismatch seen in this project. + +The application needs Screen & System Audio Recording access for capture, and +Accessibility and potentially Input Monitoring access for global shortcuts. +Quit from the tray and relaunch after changing permissions. + +RapidOCR and Apple Vision work locally. EasyOCR downloads its models on first use. +Google/DeepL translation needs internet access; DeepL also needs an API key. +Tesseract and its language data are external optional dependencies; PaddleOCR +is not included. Use RapidOCR or Apple Vision for an installation without these tools. + +Application logs: `~/Library/Logs/USTA/usta.log`. +User settings: `~/Library/Application Support/usta/`. + + +## Release regression check + +```sh +USTA_REQUIRE_MACOS_APP=1 .venv/bin/python -m unittest tests.packaging.test_macos_bundle -v +``` + +Set `USTA_MACOS_APP` to check another installed or mounted USTA.app. The check +must show `MacOS` as the screen engine and `Apple Vision` in the OCR selector. +The macOS capabilities module is a dynamic import in a namespace-package folder, +so the spec explicitly includes it. Testing the OCR engine directly is insufficient +because the UI list is populated separately by the platform capability resolver. + + +## Screen capture and repeated permission requests + +USTA 0.1.9 captures through ScreenCaptureKit inside the USTA process. It no longer +launches Pillow's external `screencapture` helper for each frame. Screen Recording +authorization is checked before capture; access is requested at most once per +explicit capture attempt. A denial or native capture error stops OCR and delivers +the error to the UI instead of retrying permission requests in the capture loop. +The capture implementation uses the macOS 15.2+ region API; the current DMG still +requires macOS 26+ because of its embedded Python runtime. + +Ad-hoc builds have no stable Developer ID signing identity. After replacing such +a build, macOS can require access to be granted again even when an old USTA entry +is enabled. Quit USTA, turn its Screen Recording access off and on for the current +copy in Applications, then reopen it. USTA does not reset or change privacy grants. + +### Enabled toggle but capture still denied after an update + +An enabled privacy toggle can still reference a previous ad-hoc code hash. +Confirm this in the macOS TCC unified log: `Failed to match existing code +requirement` for `com.sedataym.usta` and `kTCCServiceScreenCapture`, followed by +different old/current `cdhash` values. `codesign --verify` can succeed at the +same time; bundle integrity and the saved privacy requirement are separate checks. + +Quit USTA, then use the supported per-app reset (not a reset of every application's +permissions): + +```sh +tccutil reset ScreenCapture com.sedataym.usta +open /Applications/USTA.app +``` + +Press Start and grant screen recording access again to the installed copy. Use +Quit & Reopen if macOS requests it. The user must grant access; neither the app +nor the build script should edit TCC databases or programmatically grant access. + +For subsequent releases, use the pinned self-signed identity or a stable +Developer ID signing identity as described above. Ad-hoc builds use code-hash requirements and should not be described +as retaining permissions across updates. Test grants by launching the installed +bundle with Launch Services; direct execution from a terminal can test the +terminal's responsibility chain instead of the GUI application's own permission. diff --git a/docs/macos-selfsigned-test.md b/docs/macos-selfsigned-test.md new file mode 100644 index 0000000..87b1775 --- /dev/null +++ b/docs/macos-selfsigned-test.md @@ -0,0 +1,52 @@ +# Self-signed upgrade test — 2026-10-27 + +Tested on macOS 26.5.2, Apple Silicon. Both real USTA bundles target macOS 26+. + +| Check | 0.1.11 (A) | 0.1.12 (B) | +| --- | --- | --- | +| Bundle identifier | com.sedataym.usta | com.sedataym.usta | +| Signature integrity / designated requirement | Passed | Passed | +| Screen Recording preflight | Allowed | Allowed | +| Accessibility preflight | Allowed | Allowed | +| Input Monitoring preflight | Allowed | Allowed | +| Actual screen capture (user verified) | Working | Working | +| Shift+Control+M hide/show (user verified) | Working after relaunch | Working | +| New privacy consent during A → B upgrade | — | None | +| RapidOCR / Apple Vision offline recognition | Passed | Passed | + +Both versions use the pinned `USTA Local Code Signing` certificate with SHA-1 +`4CCEC0AD3C63C8BEA5622471611F6F3E0FD5A24E`. Their designated requirements are identical: + +```text +identifier "com.sedataym.usta" and certificate leaf = H"4ccec0ad3c63c8bea5622471611f6f3e0fd5a24e" +``` + +Their executable code hashes differ: + +- A: `ec4b77671d1ca9837ab73f6bb28f186291d6c65f` +- B: `b7172b2e6307ee9a50506173af45f33fa00f21c5` + +The old ad-hoc 0.1.10 application was backed up. On the initial migration to A, +only USTA's old ScreenCapture, ListenEvent and Accessibility records were reset, +and the user granted access manually. A was relaunched and both functions were +confirmed working. The application was then quit normally and replaced at +`/Applications/USTA.app` by B. No permissions were reset or changed between A and B. +Both versions were launched through Launch Services, avoiding Terminal permission +inheritance. Read-only permission diagnostics returned all three grants as allowed +before and after the upgrade. The user confirmed actual capture and shortcuts in B +without any new permission prompt. + +Both DMGs were mounted read-only, their application signatures and public +certificate verified, and SHA-256 checksums checked. The private key remains in the +build user's Keychain and is absent from the repository and DMGs. The automated +suite passed 135 tests (32 skipped); Ruff and mypy also passed. + +Artifacts: `dist/macos/USTA-0.1.11-macOS-arm64-selfsigned.dmg`, +`dist/macos/USTA-0.1.12-macOS-arm64-selfsigned.dmg`, and +`dist/macos/selfsigned-upgrade-test.json`. The installed app is 0.1.12. + +This demonstrates permission retention for this upgrade on this Mac. These are +self-signed, non-notarized builds. Other Macs still require first-install approval; +macOS policy changes can require renewed consent. Preserve the pinned certificate, +its private key, bundle identifier and installation path for subsequent updates. +See [the build instructions](macos-dmg.md#free-persistent-self-signed-builds). diff --git a/docs/model-provenance.md b/docs/model-provenance.md new file mode 100644 index 0000000..63fb4e4 --- /dev/null +++ b/docs/model-provenance.md @@ -0,0 +1,5 @@ +# OCR model provenance + +The ONNX files under `src/usta/resources/ocr_models/rapidocr` were already present in this repository before the cleanup. They were moved without changing their bytes. Filenames identify PP-OCR language/model variants, but this checkout does not include per-file download URLs, licenses, or original checksums. + +Exact upstream provenance and redistribution terms are **not yet verified**. Do not infer a model license from USTA's MIT source license. Before publishing redistributed artifacts, maintainers should record each original model URL/revision, its license/notice requirements and SHA-256. Wheel/sdist tests preserve the repository's current bytes; they do not establish licensing provenance. diff --git a/docs/superpowers/plans/2026-10-23-project-cleanup.md b/docs/superpowers/plans/2026-10-23-project-cleanup.md new file mode 100644 index 0000000..74baf3b --- /dev/null +++ b/docs/superpowers/plans/2026-10-23-project-cleanup.md @@ -0,0 +1,261 @@ +# USTA Proje Düzeni ve Uyumluluğu Koruma Uygulama Planı + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Bu belge uygulama yetkisi vermez; mevcut talep plan hazırlamaktır. + +**Goal:** Mevcut özellikleri ve Linux/macOS uyumluluğunu koruyarak USTA'yı okunaklı, test edilebilir ve katkı verilmesi kolay bir açık kaynak projesine dönüştürmek. + +**Architecture:** Mevcut Python/PySide6 uygulamasını küçük adımlarla düzenlemek. OCR, çeviri ve ekran yakalama modüllerini korumak; ana pencereden uygulama akışını ve dosya işlemlerini ayırmak. Yeni framework veya genel amaçlı eklenti sistemi eklememek. + +**Tech Stack:** Python, PySide6, unittest, setuptools, mevcut OCR/çeviri sağlayıcıları, gettext, AppImage. Geliştirme araçları: Ruff, build ve kapsamı kademeli genişletilen mypy. + +**Spec:** Kullanıcının 23 Ekim 2026 tarihli isteği: önceki incelemedeki tüm bulgular için plan; mevcut özellikler ve Linux/macOS uyumluluğu korunacak; temiz ve düzenli açık kaynak proje. Aşağıdaki kısıtlar bu isteğin uygulama önerisidir; henüz uygulanmamıştır. + +**Execution status:** Implementation is recorded in [the ledger](../../development/cleanup-progress.md). Checked items are locally verified; unchecked platform/build acceptance items still require external environments. + +## Global Constraints + +- Linux KDE/GNOME ve macOS için mevcut başarılı kullanıcı akışları, arayüz yerleşimi, motor listeleri ve varsayılanlar korunacak. +- Linux'ta mevcut xcb varsayılanı ve kullanıcının QT_QPA_PLATFORM tercihi korunacak. Wayland/Portal/Spectacle yaklaşımı değiştirilmeyecek. +- macOS'ta native fullscreen Space, odak geri verme, overlay pencere seviyeleri, Cocoa bildirimleri ve ana thread üzerinde klavye düzeni okuma davranışı korunacak. +- Çoklu ekran, negatif ekran koordinatları, DPI kilidi/ölçekleme, bölge seçimini iptal etme ve geçici bölgenin 10 saniye sonra geri alınması korunacak. +- OCR motorlarının çalışma biçimi, metin temizleme kuralları, EasyOCR GPU/MPS tercihi ve Apple Vision dil/fallback davranışı korunacak. +- Google/DeepL dil kodları, API anahtarı kullanımı ve ayarların platforma özgü mevcut dizinleri korunacak. +- IPC socket adresi ve JSON alanları korunacak. Soket yolunu yeniden tasarlamak veya yeni bir single-instance mekanizması eklemek kapsam dışı. +- `usta`, `python run.py` ve AppImage başlatma akışları çalışmaya devam edecek. İç Python import yolları desteklenen dış API sayılmayacak. +- Eski ayar ve presetler kaybolmayacak. Geçiş geri alınabilir olacak; kaynak pickle dosyaları silinmeyecek veya üzerine yazılmayacak. +- Gerçekte çalışan Python/platform kombinasyonları önce kayda alınacak. `>=3.8` beyanı düzeltilirken destek daraltımı sessizce yapılmayacak; yeni bağımlılık sürümlerine toplu yükseltme yapılmayacak. +- Windows için yeni destek vaadi verilmeyecek; mevcut platform tanımları gereksiz yere silinmeyecek. +- Her aşama ayrı incelenebilir değişiklik olacak; yapısal taşıma ile çalışma davranışındaki hata düzeltmeleri ayrı commitlerde tutulacak. + +## Review Focus + +1. Çeviri sürerken motor/dil/bölge değişimi ve stop/start: eski sonuç yeni oturuma karışmayacak. Görev 7. +2. Kısmen bozuk/eski ayarlar ve yazma sırasında kesinti: özgün dosya korunacak, geçerli veri kurtarılacak. Görev 4. +3. macOS başka uygulamanın fullscreen Space'indeyken pencere açma/kapatma: Space ve odak korunacak. Görev 1, 8 ve 11. +4. Linux'ta Portal izninin iptali, eksik native bağımlılık ve yakalama sürerken kapatma: mevcut fallback korunacak, kaynaklar temizlenecek. Görev 1, 6 ve 11. +5. Kurulu wheel/AppImage'da kaynak ağacına erişim olmaması: ikonlar, çeviriler ve OCR modelleri bulunacak. Görev 2, 3 ve 11. + +## Yaklaşım ve hedef yerleşim + +Önerilen yaklaşım kademeli refaktör. Yalnız dosya adlarını düzeltmek sorumluluk ve thread sorunlarını çözmez; tek seferde yeniden yazmak platform regresyon riskini büyütür. Mevcut motorları ve native yardımcıları koruyarak modül sınırları adım adım kurulacak. + +```text +src/usta/ + __init__.py # Hafif paket yüzeyi; GUI/native import yok + __main__.py, main.py # Giriş ve uygulama bileşenlerini bağlama + config.py # Saf sabitler/varsayılanlar + i18n.py + application/ + settings.py # Ayar/preset veri modelleri + results.py # OCR/çeviri sonuç ve hata modelleri + controller.py # UI ile worker arasında koordinasyon + region_controller.py # Ana/geçici bölge ve DPI durumu + hotkey_controller.py # Kısayol değiştirme/reset yaşam döngüsü + pipeline.py # UI'dan bağımsız OCR/çeviri iş akışı + worker.py # Qt thread/signal adaptörü + core/ + ocr/ # Mevcut motorlar ve registry + translation/ # Mevcut motorlar ve registry + screenshot/ # Mevcut native yakalama motorları + platform/ # OS/DE tespiti, yetenekler, native hotkey + shortcut.py, exceptions.py + infrastructure/ + paths.py # Açıkça oluşturulan runtime yolları + settings_store.py # Atomik JSON yükleme/kaydetme + legacy_settings.py # Sınırlı ve tek seferlik eski dosya aktarımı + socket_publisher.py # Mevcut IPC sözleşmesi + logging_setup.py + ui/ + main_window.py, overlay_window.py + tabs/ # Kendi widget'larını yöneten sekme sınıfları + region_selection/ # Eski core/sniper'ın görsel bölge seçimi + platform/macos/ # Mevcut Cocoa pencere yardımcıları + assets/ + resources/ + translations/ + ocr_models/rapidocr/ +tests/ + unit/ # OS/GUI/ağ gerektirmeyen testler + integration/ # Qt ve bileşen bağlantıları + native/ # Gerçek desktop/macOS API testleri + packaging/ # Wheel/sdist kaynak ve kurulum testleri + helpers/ +docs/ + architecture.md + development.md + compatibility.md + testing.md +``` + +Bu yapı görevler boyunca oluşacak; başlangıçta boş klasörler üretilmeyecek. `core` altında motorları korumak taşıma yükünü azaltır. Portal'ın 421 satır olması tek başına parçalama gerekçesi değildir; protokol ve kaynak yönetimi birlikte anlaşılabiliyorsa aynı modülde kalabilir. + +## Görev 1 — Mevcut davranışı ve destek matrisini kayda al + +**Dosyalar:** Mevcut `tests/test_*.py`; yeni `tests/unit/`, `tests/integration/`, `tests/native/`, `docs/compatibility.md`, `docs/testing.md`. + +**Arayüz:** Mevcut public motor metotları, Qt sinyalleri ve kullanıcı eylemleri testlerin sınırıdır. Testler dosya uzunluğu veya özel alan adlarını doğrulamayacak. + +- [x] Mevcut 31 test metodunu sınıflandır; macOS sınıfı içinde kaldığı için Linux'ta atlanan Linux davranış testlerini platformdan bağımsız sınıflara taşı. Önce `python -m unittest discover -s tests -v` sonucunu ve skip nedenlerini kaydet. +- [x] Varsayılanlar, motor listeleri, dil kodları, IPC alanları, OCR temizleme, ana/geçici bölge ve DPI davranışları için mevcut davranışı belgeleyen testler ekle. Bunların ilk etapta geçmesi beklenir; mevcut kusurlar ayrı regresyon testleriyle ilgili görevde ele alınır. +- [x] Native testleri yalnız açık çalıştırma tercihiyle başlat; sıradan unit test komutu kullanıcı ekranını/kısayollarını kullanmasın. Atlanan native testleri başarılı platform doğrulaması olarak raporlama. +- [x] Linux KDE Wayland+xcb, GNOME Wayland+xcb ve macOS native fullscreen için manuel senaryo listesi oluştur. OS/Python/Qt/CPU mimarisi ve native araç sürümlerini kaydet; erişilmeyen kombinasyonu doğrulanmadı olarak işaretle. +- [x] `python -m unittest discover -s tests/unit -v` ve ilgili integration komutları geçsin; değişikliği ayrı committe kaydet. + +## Görev 2 — Bağımlılık ve sürüm tanımlarını birleştir + +**Dosyalar:** `pyproject.toml`, `requirements.txt`, `scripts/build-appimage.sh`; yeni `requirements-dev.txt`, `tests/packaging/test_metadata.py`. + +**Arayüz:** `pip install .` mevcut varsayılan motorları ve kısayolları çalıştıracak; `pip install '.[paddle]'` isteğe bağlı Paddle desteğini kuracak. Geliştirme araçları `dev` extras grubunda olacak. + +- [x] Metadata testi yaz: DeepL/pynput runtime bağımlılıklarında bulunmalı; Paddle zorunlu olmamalı; iki kurulum yolunda Google Translate koşulu aynı olmalı. Mevcut çelişkilerde testin kaldığını doğrula. +- [ ] Çalışan ortamın sürümlerini kaydet. Python alt sınırını kod anotasyonları ve gerçekten kurulabilen bağımlılıklarla doğrula; 3.10+ başlangıç adayıdır, doğrulanmış destek matrisi kurulmadan kesin destek sözü verme. Alt sınır değişimi ve gerekçesini belgele. +- [x] Temel bağımlılıkları `pyproject.toml` içinde tekilleştir. RapidOCR/Tesseract/EasyOCR varsayılan erişimini koru; Paddle'ı extras grubuna taşı. macOS PyObjC işaretlerini koru; platforma özel diğer bağımlılıkları import testleriyle ayır. +- [x] `requirements.txt` dosyasını `.` üzerinden proje metadata'sını kullanan uyumluluk girişine dönüştür. AppImage scriptindeki requirements ve proje için tekrarlanan kurulumları tek kurulum akışında birleştir. +- [ ] Sürümü tek yerden üret; `APP_VERSION` erişimini koruyup AppImage scriptinin config dosyasını regex ile okumasını kaldır. Temiz ortamda metadata testleri ve `python -m pip check` geçsin; commit oluştur. + +## Görev 3 — Gerçek paket adı ve dağıtım kaynakları + +**Dosyalar:** `src/` → `src/usta/`; `run.py`, `pyproject.toml`, `babel.cfg`, `packaging/appimage/AppRun`, build scripti ve mevcut test importları. Yeni `src/usta/__main__.py`, `tests/packaging/test_artifacts.py`. + +**Arayüz:** `usta`, `python -m usta`, `python run.py` aynı `usta.main:main()` fonksiyonuna ulaşacak. Kaynaklar `importlib.resources` ile çözülecek. + +- [x] Build artifact testi ekle: wheel/sdist içinde tüm mevcut ONNX dosyaları, ikonlar ve derlenmiş TR/UK katalogları bulunmalı. Repo dışından import edilebilmeli; yalnız editable kurulum yeterli sayılmamalı. +- [x] Dosya taşımalarını ve import güncellemelerini birlikte yap; `sys.path` eklemesini kaldır. Bu committe algoritma/varsayılan değiştirme. Test patch hedeflerini ve helper scriptlerini güncelle. +- [x] Modelleri ve çevirileri hedef resources dizinine taşı; runtime dosya yolu isteyen motorlara `importlib.resources.as_file()` ömrü doğru yönetilen kaynak ver. Model içeriklerinin hash'lerini koru. +- [x] AppRun'u `python -m usta` ile başlat; ikon yollarını ve Babel katalog üretimini güncelle. `run.py` uyumluluk girişini koru. +- [ ] `python -m build`, artifact testleri ve temiz venv içine wheel kurulumu geçsin. Repo dışında başlatma ve native platform smoke testlerini kaydet; commit oluştur. + +## Görev 4 — Doğrulanan modeller ve kayıpsız ayar/preset geçişi + +**Dosyalar:** `ui/main_window.py`, `application/settings.py`, `infrastructure/settings_store.py`, `infrastructure/legacy_settings.py`; yeni `tests/unit/test_settings_store.py`, `tests/unit/test_settings_migration.py`. + +**Arayüz:** `AppSettings` ve `Preset` dataclass'ları; `SettingsStore.load() -> AppSettings`, `save(settings: AppSettings) -> None`, `load_presets() -> dict[str, Preset]`, `save_presets(presets: dict[str, Preset]) -> None`. JSON şeması `schema_version=1` içerir. + +- [x] Mevcut ayar ve preset alanlarından anonim fixture oluştur. Renk/font, motor, dil, API anahtarı, kısayol, capture rectangle ve DPI değerlerinin aktarımda aynı kaldığını test et. +- [x] Eksik alan, bilinmeyen alan/sürüm, bozuk dosya, yazma hatası, negatif ekran koordinatı ve yarım kalmış geçiş testlerini yaz. Başarısız yazmanın son geçerli dosyayı değiştirmediğini doğrula. +- [x] Aynı dizinde geçici dosya + flush/fsync + `os.replace` ile JSON yaz. Alan doğrulaması geçerli eski değerleri keyfi aralıklara sıkıştırmasın. Gelecek şema sürümünü sessizce üzerine yazma. +- [x] Yalnız mevcut yerel `settings.pkl`/`presets.pkl` dosyaları için primitive veri kabul eden, global/sınıf yüklemeyi reddeden sınırlı legacy okuyucu ekle. JSON başarıyla yazılıp tekrar okunmadan geçişi tamamlanmış sayma. Özgün pickle'ları koru; JSON varken eski pickle'ı yeniden çalıştırma. +- [x] Yükleme sırasında `QSignalBlocker` kullan; yükleme sıfır kayıt tetiklesin, kullanıcı değişikliği tek tutarlı kayıt oluştursun. API anahtarları loga girmesin; yeni dosyalarda kullanıcıya özel izinler kullan. İlgili unit/integration testleri geçsin; commit oluştur. + +## Görev 5 — Import yan etkileri, yollar ve loglama + +**Dosyalar:** `config.py`, `main.py`, `core/platform/__init__.py`, `infrastructure/paths.py`, `infrastructure/logging_setup.py`, mevcut print kullanan modüller; yeni `tests/unit/test_bootstrap.py`. + +**Arayüz:** `RuntimePaths` dataclass; `resolve_paths() -> RuntimePaths` yalnız hesaplar, `prepare_paths(paths: RuntimePaths) -> None` açıkça hazırlar. `configure_logging(level: str = "INFO") -> None` yalnız başlangıçta çağrılır. + +- [x] Paket/config importunun dizin oluşturmadığını, stdout'a yazmadığını ve platform dışı native bağımlılık yüklemediğini test et; mevcut yan etkiler nedeniyle ilgili testlerin kaldığını doğrula. +- [x] Config'deki `makedirs` ve temp path printini bootstrap'a taşı; platform paketinin `__init__` yüzeyini hafiflet. Gerekli çalışma yollarını bileşenlere açıkça ver. +- [x] `print()` çağrılarını anlamlı seviyelerle `logging` kullanımına çevir. Beklenmeyen hatalarda traceback, beklenen iptal/eksik bağımlılıkta kontrollü mesaj kullan; bütün genel catch bloklarını mekanik olarak kaldırma. +- [x] API anahtarı, OCR metni ve tam ekran içeriğini varsayılan INFO loglarında yazma. UI metinlerinin ve başarılı sonuçların değişmediğini test et. +- [x] `python -m unittest discover -s tests/unit -v` ve başlangıç integration testi geçsin; commit oluştur. + +## Görev 6 — Motor kayıtları, sonuç sözleşmesi ve uyumluluk adaptörleri + +**Dosyalar:** `core/ocr/ocr_manager.py`, `core/translation/translator_manager.py`, base sınıflar, motorlar/factory'ler; yeni `application/results.py`, `core/ocr/rapidocr_compat.py`, `tests/unit/test_engine_contracts.py`, `tests/unit/test_rapidocr_compat.py`. + +**Arayüz:** `OCRResult(text: str, confidence: float | None = None)`, `TranslationRequest(text: str, source_lang: str, target_lang: str, engine_name: str, session_id: int)`, `TranslationResult(translated_text: str, source_lang: str, target_lang: str, engine_name: str, session_id: int)`, `EngineError(code: str, message: str)`. Sağlayıcı hatası `EngineError` üretir; UI bunu hata kanalında gösterir. + +- [x] Her mevcut motor için başarılı çıktı, boş OCR, dil kodu eşleme ve beklenen hata sözleşmesini fixture/fake ile test et. Hata metninin başarılı çeviri olarak yayınlanmaması için başarısız regresyon testi ekle. +- [x] Kullanılmayan sonuç dataclass'larını tek sonuç modeline birleştir. Sağlayıcı hatalarını metin yerine tipli hataya çevir; kullanıcıya hata görünürlüğünü koru. UI ve worker tüketimini aynı görevde güncelle. +- [x] UI listesi ve motor oluşturmayı ortak registry üzerinden bağla. Görünen adlar, sıralama ve varsayılanlar aynı kalsın; eksik bağımlılık yüzünden motorları sessizce listeden silme. Motorları ihtiyaç anında oluştur. +- [x] RapidOCR thread sınırı uyarlamasını izole et; önce mevcut paketin public konfigürasyonuyla eşdeğer sonuç alınabildiğini test et. Alınamıyorsa sürüm kontrollü mevcut patch'i koru; private constructor ve pynput adaptörünü sözleşme testleriyle kapsa. GPU/MPS veya CPU thread değerlerini değiştirme. +- [x] Ekran yakalama temel arayüzüne idempotent `close() -> None` ekle; `hasattr(close)` kontrollerini kaldır. Portal iptali ve mevcut fallback testleri geçsin; commit oluştur. + +## Görev 7 — İş akışı, thread sahipliği ve kontrollü kapanış + +**Dosyalar:** `core/worker.py` → `application/worker.py`; yeni `application/pipeline.py`, `tests/unit/test_pipeline_lifecycle.py`, `tests/integration/test_worker_signals.py`; ilgili UI bağlantıları. + +**Arayüz:** `Pipeline.configure(settings: AppSettings) -> None`, `start() -> None`, `stop() -> None`; Qt adaptöründe mevcut `new_translation`, `performance_update`, `translation_status`, `running_status` sinyalleri korunur; hata için ayrı `error_occurred(str)` sinyali eklenir. + +- [x] Fake capture/OCR/translator ile bloke API çağrısı, stop sırasında sonuç, ardışık stop/start, dil/motor/bölge değişimi ve capture hatası senaryolarını test et. Gerçek ağ, rastgele sleep veya OCR modeli gerektirmesin; Event/barrier kullan. +- [x] Her işin settings/motor/dil/region snapshot'ını oluştur. Tek sahipli komut kuyruğu üzerinden yapılandır; worker'ın kullandığı motoru GUI thread'inde kapatma/değiştirme. Ağ çağrısı boyunca global lock tutma. +- [x] Tek aktif çeviri yürütücüsünü açıkça sahiplen; takip edilmeyen daemon thread kaldır. Her stop/config değişiminde session kimliğiyle eski sonucu ele. İptal edilemeyen çağrı yeni oturuma sonuç gönderemesin. +- [x] Yakalama/sağlayıcı beklemelerine sonlu timeout koy; stop isteği GUI event loop'unu bloklamasın. Kaynaklar kendi thread'inde temizlensin; Qt worker tamamlanmadan nesnesi yok edilmesin. Sahte sağlayıcılarla zaman sınırını doğrula; gerçek ortam değerlerini uyumluluk kaydına yaz. +- [ ] Başlat/durdur tekrarı, hata/boş metin, yinelenen metin bastırma, 0.2 saniyelik döngü aralığı ve IPC metadata doğruluğu testleri geçsin. Önceki/sonraki performansı aynı fixture ve donanımda karşılaştır; commit oluştur. + +## Görev 8 — Ana pencere, sekmeler ve bölge seçimi sınırları + +**Dosyalar:** `ui/main_window.py`, `ui/tabs/*.py`, `application/controller.py`, `application/region_controller.py`, `application/hotkey_controller.py`; `core/sniper/` → `ui/region_selection/`; `ui/macos_*.py` → `ui/platform/macos/`; yeni `tests/integration/test_control_panel.py`. + +**Arayüz:** Sekmeler `set_settings(settings: AppSettings) -> None` ve alanlara özgü Qt sinyalleri sunar; `ControlPanel` widget'ları oluşturup signal bağlar. `RegionController.apply_main_region(rect: QRect, dpi_scale: float | None = None)`, `apply_temporary_region(rect: QRect, dpi_scale: float | None = None)`, `restore_main_region()` bölge durumunun tek sahibidir. + +- [x] Kullanıcı eylemiyle ayar değişimi, preset uygulama, kısayol değişimini reddetme/eski kısayolu koruma ve 10 saniyelik bölge geri dönüş testlerini ekle. Aktif geçici bölge sırasında preset yüklenince eski bölgeye yanlış geri dönmeme regresyonunu kapsa. +- [x] Dosya işlemlerini store'a, worker koordinasyonunu controller'a, hotkey yaşam döngüsünü hotkey controller'a taşı. Ana pencere ve sekmeler worker alanlarına doğrudan yazmasın. +- [x] Sekmeleri kendi widget'larını yöneten sınıflara dönüştür. Ortak font/renk uygulamasını tek metotta topla; UI sırası, metinleri, kısayollar ve görünümü korunacak. Dosya satır sayısını yapay hedef yapma. +- [ ] Region selector widget'larını UI katmanına taşı. Qt dışı motor modüllerinin `usta.ui` import etmediğini mimari testle doğrula. Native pencere bayraklarını, Cocoa çağrı sırasını ve GUI thread üzerinde deleteLater davranışını değiştirme. +- [ ] GUI thread'indeki `time.sleep(0.2)` yerine aynı seçim öncesi gecikmeyi `QTimer` ile sağla. İptal/başarılı seçim, iki platform pencere davranışı ve tüm mevcut native testler geçsin; commit oluştur. + +## Görev 9 — IPC ve geçici dosya kaynak yönetimi + +**Dosyalar:** `core/socket_publisher.py` → `infrastructure/socket_publisher.py`, `infrastructure/paths.py`, yakalama yollarını kullanan motorlar; yeni `tests/unit/test_socket_publisher.py`, `tests/unit/test_runtime_paths.py`. + +**Arayüz:** `TranslationPublisher` mevcut JSON broadcast parametrelerini ve socket yolunu korur. `RuntimePaths` her uygulama oturumu için özel capture dizini taşır; IPC yolu ayrı kalır. + +- [x] İki istemci, bağlantı kopması, okumayan istemci, stop/start ve aynı adresin başka aktif süreç tarafından kullanılması testlerini yaz. Yavaş bir istemci diğer yayınları ve kapanışı sınırsız süre bloke etmesin. +- [x] Socket gönderimini sınırlı bekleme/kuyrukla yönet; taşan/yavaş bağlantıyı kontrollü kapat. Çalışan başka sürecin socket dosyasını stale sanıp unlink etme; kullanımda adres için anlaşılır hata üret. Yeni IPC protokolü ekleme. +- [x] Capture dosyalarını oturuma özel temp dizinine taşı; Linux'ta mevcut `/dev/shm` tercihini ve macOS'ta temp fallback'ini koru. Başarı/hata/kapanışta yalnız kendi dosyalarını temizle. +- [x] `python -m unittest tests.unit.test_socket_publisher tests.unit.test_runtime_paths -v` geçsin. IPC JSON fixture'ı alan ve tür düzeyinde aynı olsun; commit oluştur. + +## Görev 10 — Okunabilirlik ve katkı standartları + +**Dosyalar:** `pyproject.toml`, `.gitignore`, `README.md`; yeni `CONTRIBUTING.md`, `docs/architecture.md`, `docs/development.md`, `docs/compatibility.md`, `CHANGELOG.md`, `.github/ISSUE_TEMPLATE/bug_report.yml`, `.github/pull_request_template.md`. + +**Arayüz:** `ruff check .`, `ruff format --check .`; mypy başlangıçta saf application veri modelleri ve infrastructure store modüllerini kapsar. Native SDK'lar için geniş kapsamlı `ignore_errors` kullanılmaz. + +- [x] Ruff ile import/unused ve biçim kurallarını tekilleştir; mekanik biçim değişikliklerini işlevsel değişikliklerden ayrı committe tut. Qt override adlarını koru; yeni uygulama metotlarında snake_case kullan. +- [x] Eski yorumları ve gerçekten kullanılmayan importları kaldır; çalışır alternatif backend'leri sırf o an kullanılmıyor diye silme. Türkçe/Ukraynaca kataloglar ve msgid'ler korunacak; mevcut çevrilmemiş UI metinlerini ayrı katalog güncellemesiyle kapsa. +- [x] README'de mevcut macOS desteği ile eskimiş roadmap'i uzlaştır; kurulum, çalıştırma, sistem bağımlılıkları, Paddle extras, AppImage ve test komutlarını açıkla. Belgeleri repo'nun mevcut İngilizce diliyle tutarlı yaz. +- [x] CONTRIBUTING'e geliştirme kurulumu, mimari sınırlar, format/test komutları ve native test gereksinimlerini ekle. Bug şablonunda OS/DE/session/Python/Qt/motor bilgilerini iste; anahtar/ekran metni paylaşılmamasını belirt. +- [x] `.venv/`, araç cache'leri ve yerel çıktılar ignore edilsin. Lisansı koru; model dosyalarının kaynak/lisans bilgilerini mevcut kanıtlardan belgeleyip doğrulanamayanları açıkça işaretle. Araç kontrolleri geçsin; commit oluştur. + +## Görev 11 — CI, dağıtım ve gerçek platform kabulü + +**Dosyalar:** yeni `.github/workflows/ci.yml`, `.github/workflows/package.yml`, `tests/packaging/`; `docs/testing.md`, `docs/compatibility.md`, `docs/appimage.md`. + +**Arayüz:** PR kontrolleri format/lint, saf testler, Qt integration testleri ve artifact doğrulamasıdır. Gerçek masaüstü/native test sonuçları bunlardan ayrı raporlanır. + +- [ ] Linux ve macOS runner'larında Görev 2'de doğrulanmış Python sürümleriyle CI matrisi kur. Ağ/model indiren OCR ve gerçek sağlayıcı çağrılarını unit testlerden ayır. Testlerdeki skip sayısını ve nedenlerini görünür tut. +- [x] Yeni modeller/store/pipeline için mypy, wheel/sdist build ve temiz venv kurulum kontrolünü ekle. Önceki 10-test örneklemini tüm testler geçmiş gibi sunma; her test grubunun sonucu ayrı kaydedilsin. +- [ ] Linux Qt integration testlerini sanal ekranla çalıştır. Sanal ekranın gerçek Wayland/Portal davranışını kanıtlamadığını belgeleyip KDE ve GNOME masaüstünde manuel kabulü ayrı çalıştır. +- [x] macOS'ta izinler, fullscreen Space, çoklu ekran/Retina, kısayol tekrar bağlama, odak iadesi ve Apple Vision native testlerini gerçek oturumda çalıştır. Desteklenen mimarilerden erişilemeyenleri doğrulanmadı olarak işaretle. +- [ ] AppImage'i gerçek Linux masaüstünde çalıştır: ikon/katalog/modeller, Portal iptali, Spectacle, ayar geçişi ve IPC kontrolü. Kaynak kurulum ve kurulu wheel için aynı kullanıcı senaryolarını doğrula. +- [x] Aynı OCR fixture/donanımda süre, bellek ve kapanış gecikmesini başlangıç kaydıyla karşılaştır; ölçüm gürültüsünü aşan kötüleşmeleri araştır. Belgelenmemiş platform regresyonu varsa ilgili aşamayı geri al; yayınlamadan önce düzelt. + +## Bulguların görevlerle eşlemesi + +| İnceleme bulgusu | Görev | +|---|---| +| Bağımlılık kaynaklarının çelişmesi | 2 | +| Python alt sürüm beyanının yanlışlığı | 1, 2, 11 | +| Ana pencerenin fazla sorumluluğu | 4, 7, 8 | +| Worker paylaşılmış durum/kapanış sorunu | 7 | +| Sekmelerin panel alanlarına bağımlılığı | 8 | +| Ayar yüklerken kayıt, pickle/atomik yazım | 4 | +| Core → UI bağımlılığı | 8 | +| Hatanın normal çeviri metni sayılması | 6, 7 | +| Paket adı, sys.path ve model paketleme | 3 | +| Print/genel exception, RapidOCR patch | 5, 6 | +| Test dağılımı, CI/lint/type check eksikliği | 1, 10, 11 | +| IPC bloklama ve ortak geçici dosyalar | 9 | + +## Uygulama sırası ve geri dönüş + +Sıra: **1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → 10 → 11**. Bir sonraki görev başlamadan önce önceki görevin kontrolleri geçmeli; native yapıyı etkileyen görevler için ilgili platform kontrolü de gerekli. Görev 11'de CI'ye taşınacak kontroller görevler sırasında yerelde çalıştırılır. + +Her görev ayrı review/commit sınırıdır; gerekirse içindeki mekanik ve davranış değişiklikleri ayrı commitlere bölünür. Uygulama başında mevcut çalışma ağacındaki kullanıcı değişiklikleri korunur. Ayar geçişinde eski pickle dosyaları durduğu için kod geri alındığında eski sürüm kendi dosyalarını okuyabilir; yeni sürümde yapılan JSON değişikliklerinin eski sürüme otomatik aktarılmadığı belgelenir. + +## Tamamlanma koşulları + +- [ ] Tüm bulguların karşılığında uygulanmış değişiklik veya kanıtlı, açık gerekçeli karar var. +- [ ] Linux KDE/GNOME ve macOS başarılı kullanıcı senaryoları başlangıç kaydıyla aynı; doğrulanmamış platform sonucu başarılı gösterilmiyor. +- [x] Eski ayar/presetler korunarak yükleniyor; durdurulan/eski iş overlay veya IPC'ye sonuç gönderemiyor. +- [x] Ana pencere dosya işlemi yapmıyor; sekmeler worker'a doğrudan erişmiyor; core motorlarından UI'a import yok. +- [ ] Wheel/sdist/AppImage kaynakları eksiksiz; temiz ortamda başlatma yolları çalışıyor. +- [ ] Testler, lint/format, tanımlı tip kontrolleri ve paket testleri geçiyor; native kabul kaydı mevcut. +- [x] README/katkı/mimari/test belgeleri gerçek komutlarla uyumlu; yeni geliştirici temiz clone üzerinden kurulumu tekrarlayabiliyor. + +## Plan öz denetimi + +Bu plan önceki incelemedeki 11 maddeyi ve inceleme sırasında görülen IPC/geçici dosya risklerini görevlerle eşleştirir. Mevcut davranışı kayda alma testleri başta; bilinen kusurların başarısız regresyon testleri ilgili düzeltme görevindedir. Dış sağlayıcı sözleşmesini değiştiren bir API geliştirme işi planlanmaz; mevcut istemciler korunur. Plan hazırlama aşamasında uygulama kodu, bağımlılıklar ve platform ayarları değiştirilmez. + +Son yerel sonuç: 72 test geçti, atlanan test yok. Linux masaüstü/AppImage ve uzak CI kabulü için açık maddeler uygulama günlüğünde ayrıca belirtilmiştir. diff --git a/docs/superpowers/plans/2026-10-27-system-tray.md b/docs/superpowers/plans/2026-10-27-system-tray.md new file mode 100644 index 0000000..e49244a --- /dev/null +++ b/docs/superpowers/plans/2026-10-27-system-tray.md @@ -0,0 +1,61 @@ +# System Tray Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking. + +**Goal:** Add tray actions and a shared main-window visibility shortcut on Linux and macOS. + +**Architecture:** ControlPanel owns visibility and graceful exit. A small SystemTray component builds and refreshes the menu. The overlay emits a stateless toggle request, preserving shortcut configuration compatibility. + +**Tech Stack:** Python, PySide6, gettext, unittest. + +**Spec:** docs/superpowers/specs/2026-10-27-system-tray-design.md + +## Global Constraints + +- Preserve native macOS fullscreen panels and Linux overlay flags. +- Hidden windows must not interrupt OCR/translation. +- Retain saved hotkey configuration keys. +- No new dependencies. + +## Review Focus + +- X and hotkey interaction must use actual visibility. +- Hidden overlay must stay hidden on translation updates. +- Running-worker exit must wait for worker completion. +- Missing tray must not strand the application. +- Temporary selection must be disabled when stopped or already selecting. + +### Task 1: Tray, shared visibility and graceful exit + +**Files:** Create src/usta/ui/system_tray.py; modify src/usta/ui/main_window.py, overlay_window.py, main.py; tests/integration/test_control_panel.py and platform window tests. + +**Interfaces:** ControlPanel.toggle_main_window(), toggle_overlay(), show_main_window(), hide_main_window(), request_quit(); SystemTray.refresh(), available. + +- [x] Write regression tests for menu/shortcut/X sequences, independent overlay visibility, missing-tray exit, and asynchronous worker shutdown. +- [x] Run tests and confirm failure against the current implementation. +- [x] Add tray component and route hotkey through the shared action. Preserve native presentation and always-on-top flags. Separate close-to-tray from explicit shutdown. +- [x] Run integration tests and update existing native window tests for the new contract. + +### Task 2: Copy, translations and verification + +**Files:** Hotkeys tab, Turkish/Ukrainian gettext catalogs, README. + +- [x] Rename the user-facing shortcut and describe tray usage/fallback. +- [x] Compile catalogs using Babel. +- [x] Run full unittest suite, Ruff checks and mypy; review final diff for shutdown/visibility races. + +## Execution notes + +User explicitly requested implementation in the existing project; execute inline in the clean feature/macos_support_1 checkout. No new worktree or commits requested. Baseline and verification outputs are saved under /tmp/usta-tray-*.log. + + +## Verification and review + +- Baseline: 105 tests; three Unix-socket permission errors inside sandbox, 29 platform skips. Socket tests pass outside sandbox. +- Final portable suite: 113 tests, OK, 29 platform skips (`QT_QPA_PLATFORM=offscreen`). +- Native macOS window suite: 21 tests, OK, including fullscreen/focus and show/hide behavior. +- Ruff lint/format, mypy, pip dependency check and git diff whitespace check pass. +- Wheel and sdist rebuilt successfully. The initial artifact mismatch was caused by old dist catalogs after translation updates; the rebuilt package passes resource checks. +- Read-only independent review found one delayed temporary-selection race. Regression test reproduced it; entry point now rechecks worker state, and integration tests pass. +- Running all tests on the native desktop stalled in the existing modal color-dialog integration test; that run was stopped. Integration tests pass offscreen, and native-specific tests pass separately on the real desktop. +- Linux window contracts pass using the Linux code path on this macOS host; a live Linux desktop was not available. diff --git a/docs/superpowers/specs/2026-10-27-system-tray-design.md b/docs/superpowers/specs/2026-10-27-system-tray-design.md new file mode 100644 index 0000000..3859a5b --- /dev/null +++ b/docs/superpowers/specs/2026-10-27-system-tray-design.md @@ -0,0 +1,11 @@ +# Tray and window visibility + +Approved in conversation: replace topmost shortcut behavior with main-window visibility toggle, shared with a system tray menu. X hides the main window. Both windows appear above other applications when shown, preserving native macOS fullscreen behavior and Linux window flags. Hiding either window must not stop OCR/translation or hide the other. + +Menu: dynamic Hide/Show Main Window, Hide/Show Overlay, Select Region, Select Temporary Region (enabled only while running), separator, Quit. Temporary selection retains its ten-second restore. Quit cleans up listeners, timers, overlay and worker, waiting asynchronously for a running worker. + +Approaches: keeping topmost toggling would retain the confusing visible state; duplicating visibility state between menu and hotkey would drift after X. Chosen approach: the control panel owns shared visibility actions; the tray reads real window visibility when opening its menu. Retain serialized hotkey keys for compatibility. Use PySide6 QSystemTrayIcon and existing assets without new dependencies. + +Implementation calls: when no system tray is available, X exits normally to avoid an inaccessible background app; log this fallback. Menu updates cannot re-show an explicitly hidden overlay. Normal application startup still shows both windows. + +Follow-up: add Start and Stop to the tray, using the existing main-window actions and translated labels. Enable Start only when stopped and Stop only when running; disable both during shutdown. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..574879c --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,7 @@ +# Testing + +Run portable tests with `QT_QPA_PLATFORM=offscreen python -m unittest discover -s tests -v`. Native tests are opt-in: in a real macOS desktop session use `USTA_NATIVE_TESTS=1 python -m unittest discover -s tests/native -v` and the native Vision/keyboard classes under tests/unit. Do not set offscreen for native tests. Skips do not prove platform compatibility. Unit tests must not call live translation APIs or download OCR models. + +The sandbox used by desktop automation may deny Unix socket bind and native Cocoa/Vision services. Run the suite outside that sandbox when this occurs; do not skip a failed IPC test as if it passed. The final 23 October 2026 local native run passed all 72 tests without skips, including independent-review regressions. CI uses offscreen Qt and leaves actual fullscreen/permission interactions to opt-in native acceptance. + +After building, `USTA_REQUIRE_ARTIFACTS=1 python -m unittest tests.packaging.test_artifacts -v` checks resource hashes in the wheel/sdist. The package workflow installs a wheel in a fresh environment outside the repository. diff --git a/packaging/appimage/AppRun b/packaging/appimage/AppRun index 4c5b3cd..9702b16 100644 --- a/packaging/appimage/AppRun +++ b/packaging/appimage/AppRun @@ -18,4 +18,4 @@ if [[ ! -x "${PYTHON_BIN}" ]]; then exit 127 fi -exec "${PYTHON_BIN}" -m src.main "$@" +exec "${PYTHON_BIN}" -m usta.main "$@" diff --git a/packaging/macos/INSTALL.txt b/packaging/macos/INSTALL.txt new file mode 100644 index 0000000..4a71a74 --- /dev/null +++ b/packaging/macos/INSTALL.txt @@ -0,0 +1,23 @@ +USTA for macOS — Apple Silicon (arm64), macOS 26 or newer + +Drag USTA.app to Applications, then open USTA from Applications. +The disk image includes Python and the application dependencies. + +See SIGNING.txt in this disk image for its signing and notarization status. +For the signed distribution build, grant permissions once to the copy in Applications. +The first upgrade from an old ad-hoc build can require the grants to be renewed. + +Permissions (System Settings > Privacy & Security): +- Screen & System Audio Recording: required to capture text on screen. +- Accessibility: used by global shortcuts. +- Input Monitoring: may also be required by the keyboard listener. +Quit USTA from its tray menu and reopen it after changing permissions. + +RapidOCR and Apple Vision work locally. EasyOCR downloads its recognition +models on first use and needs an internet connection for that download. +Google/DeepL translation requires an internet connection; DeepL needs an API key. +Tesseract requires a separate installation and its language data; it is not +included in this image. PaddleOCR is an optional engine and is not bundled. + +Logs: ~/Library/Logs/USTA/usta.log +Settings: ~/Library/Application Support/usta/ diff --git a/packaging/macos/certs/usta-self-signed.cer b/packaging/macos/certs/usta-self-signed.cer new file mode 100644 index 0000000..addee0c Binary files /dev/null and b/packaging/macos/certs/usta-self-signed.cer differ diff --git a/packaging/macos/check_bundle.py b/packaging/macos/check_bundle.py new file mode 100644 index 0000000..c621ab1 --- /dev/null +++ b/packaging/macos/check_bundle.py @@ -0,0 +1,104 @@ +"""Offline bundle diagnostics: no screen capture, global listeners or network calls.""" + +import gettext +import io +import json +import logging +import platform +import tempfile +import traceback +from importlib import import_module +from pathlib import Path + + +def check_bundle(output): + report = {"ok": False, "architecture": platform.machine()} + messages = io.StringIO() + handler = logging.StreamHandler(messages) + logger = logging.getLogger("usta") + logger.setLevel(logging.INFO) + logger.addHandler(handler) + try: + import certifi + import easyocr + import ScreenCaptureKit + import torch + from PIL import Image, ImageDraw, ImageFont + from PySide6.QtCore import QRect + from PySide6.QtSvg import QSvgRenderer + from PySide6.QtWidgets import QApplication + + from usta.application.results import PerformanceTimings + from usta.config import OS + from usta.core.ocr.apple_vision_engine import AppleVisionEngine + from usta.core.ocr.rapidocr_engine import RapidOCREngine + from usta.core.registry import OCR_FACTORIES, TRANSLATOR_FACTORIES + from usta.resource_paths import resource_path + from usta.ui.main_window import ControlPanel + from usta.ui.tabs.ocr_tab import OCRTab + from usta.ui.tabs.status_tab import StatusTab + + for module, name in [*OCR_FACTORIES.values(), *TRANSLATOR_FACTORIES.values()]: + assert getattr(import_module(module), name) + assert ControlPanel + assert hasattr( + ScreenCaptureKit.SCScreenshotManager, "captureImageInRect_completionHandler_" + ) + assert Path(certifi.where()).is_file() + assert resource_path("ui/assets/usta.png").is_file() + assert QSvgRenderer(str(resource_path("ui/assets/select-region.svg"))).isValid() + translations = resource_path("resources/translations") + for language in ("tr", "uk"): + translator = gettext.translation("messages", str(translations), languages=[language]) + assert translator.gettext("Region") != "Region" + models = list(resource_path("resources/ocr_models/rapidocr").glob("*.onnx")) + assert len(models) >= 8 + app = QApplication.instance() or QApplication([]) + ocr_tab = OCRTab() + report["os"] = OS + report["screen_engines"] = [ + ocr_tab.combo_screenshot.itemText(index) + for index in range(ocr_tab.combo_screenshot.count()) + ] + report["ocr_engines"] = [ + ocr_tab.combo_ocr.itemText(index) for index in range(ocr_tab.combo_ocr.count()) + ] + assert report["os"] == "Darwin", report + assert report["screen_engines"] == ["MacOS"], report + assert "Apple Vision" in report["ocr_engines"], report + ocr_tab.close() + status = StatusTab() + status.update_timings(PerformanceTimings(0.32, 1.28, 1.82)) + status.update_region(QRect(-1920, 120, 1280, 720)) + assert status.ocr_value.value() == 0.32 + assert status.region_position.text() == "X -1920 Y 120" + with tempfile.TemporaryDirectory(prefix="usta-bundle-check-") as directory: + image_path = Path(directory) / "ocr.png" + image = Image.new("RGB", (640, 140), "white") + ImageDraw.Draw(image).text( + (25, 35), "USTA 12345", fill="black", font=ImageFont.load_default(size=42) + ) + image.save(image_path) + for name, engine in ( + ("rapidocr", RapidOCREngine()), + ("apple_vision", AppleVisionEngine()), + ): + text = engine.read_text(str(image_path)) + report[name] = text + report[f"{name}_error"] = getattr(engine, "_unavailable_reason", None) + assert "USTA" in text.upper(), f"{name} returned {text!r}" + report.update( + ok=True, + bundled_models=len(models), + torch=torch.__version__, + easyocr=easyocr.__version__, + status_height=status.minimumSizeHint().height(), + ) + status.close() + app.processEvents() + except Exception: + report["error"] = traceback.format_exc() + report["log"] = messages.getvalue() + logger.removeHandler(handler) + output.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + return 0 if report["ok"] else 1 diff --git a/packaging/macos/check_permissions.py b/packaging/macos/check_permissions.py new file mode 100644 index 0000000..6ff3f15 --- /dev/null +++ b/packaging/macos/check_permissions.py @@ -0,0 +1,31 @@ +"""Read-only privacy preflight, launched through Finder/Launch Services.""" + +import json +import os +import sys +from pathlib import Path + + +def check_permissions(output: Path): + import ApplicationServices + import Foundation + import Quartz + from PySide6.QtWidgets import QApplication + + from usta._version import __version__ + + app = QApplication.instance() or QApplication(sys.argv[:1]) + bundle = Foundation.NSBundle.mainBundle() + report = { + "version": __version__, + "bundle_id": str(bundle.bundleIdentifier()), + "bundle_path": str(bundle.bundlePath()), + "executable": sys.executable, + "pid": os.getpid(), + "screen_recording": bool(Quartz.CGPreflightScreenCaptureAccess()), + "accessibility": bool(ApplicationServices.AXIsProcessTrusted()), + "input_monitoring": bool(Quartz.CGPreflightListenEventAccess()), + } + output.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") + del app + return 0 diff --git a/packaging/macos/entitlements.plist b/packaging/macos/entitlements.plist new file mode 100644 index 0000000..4efe1ce --- /dev/null +++ b/packaging/macos/entitlements.plist @@ -0,0 +1,8 @@ + + + + + com.apple.security.cs.allow-jit + + + diff --git a/packaging/macos/hooks/hook-rapidocr_onnxruntime.py b/packaging/macos/hooks/hook-rapidocr_onnxruntime.py new file mode 100644 index 0000000..6072b7b --- /dev/null +++ b/packaging/macos/hooks/hook-rapidocr_onnxruntime.py @@ -0,0 +1,9 @@ +"""RapidOCR 1.2 loads its detector/recognizer by bare names from its package path.""" + +from PyInstaller.utils.hooks import collect_data_files, collect_submodules + +hiddenimports = collect_submodules("rapidocr_onnxruntime") +datas = collect_data_files("rapidocr_onnxruntime") +# RapidOCR appends its package directory to sys.path and imports names from YAML. +# Keep real package __init__.py files so these imports do not become empty namespaces. +module_collection_mode = "pyz+py" diff --git a/packaging/macos/launcher.py b/packaging/macos/launcher.py new file mode 100644 index 0000000..5782dd2 --- /dev/null +++ b/packaging/macos/launcher.py @@ -0,0 +1,35 @@ +"""Finder entry point and offline diagnostics for the macOS application bundle.""" + +import logging +import multiprocessing +import sys +from pathlib import Path + + +def main(): + multiprocessing.freeze_support() + if len(sys.argv) == 3 and sys.argv[1] == "--check-bundle": + from check_bundle import check_bundle + + return check_bundle(Path(sys.argv[2])) + + if len(sys.argv) == 3 and sys.argv[1] == "--check-permissions": + from check_permissions import check_permissions + + return check_permissions(Path(sys.argv[2])) + + log_dir = Path.home() / "Library" / "Logs" / "USTA" + log_dir.mkdir(parents=True, exist_ok=True) + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + handlers=[logging.FileHandler(log_dir / "usta.log", encoding="utf-8")], + ) + from usta.main import main as run + + run() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/packaging/macos/self-signed-identity.json b/packaging/macos/self-signed-identity.json new file mode 100644 index 0000000..5ac8f10 --- /dev/null +++ b/packaging/macos/self-signed-identity.json @@ -0,0 +1,6 @@ +{ + "name": "USTA Local Code Signing", + "sha1": "4CCEC0AD3C63C8BEA5622471611F6F3E0FD5A24E", + "sha256": "66E1850381FA0A4F93F7D37A6DE6BBD9D3415439392127FA802E980F8177683A", + "bundle_id": "com.sedataym.usta" +} diff --git a/packaging/macos/signing_policy.py b/packaging/macos/signing_policy.py new file mode 100644 index 0000000..469add2 --- /dev/null +++ b/packaging/macos/signing_policy.py @@ -0,0 +1,168 @@ +"""Require a pinned signing identity; self-signed and ad-hoc modes are explicit.""" + +import argparse +import hashlib +import json +import os +import re +import subprocess +import sys +from pathlib import Path + +BUNDLE_ID = "com.sedataym.usta" + + +def resolve_identity(identity, team, listing, *, allow_ad_hoc=False): + if not identity or identity == "-": + if allow_ad_hoc: + return "-" + raise ValueError( + "Developer ID Application certificate required. Set USTA_CODESIGN_IDENTITY and " + "USTA_SIGNING_TEAM_ID. USTA_ALLOW_ADHOC=1 is for local development only." + ) + if not team or not re.fullmatch(r"[A-Z0-9]{10}", team): + raise ValueError("Set USTA_SIGNING_TEAM_ID to the fixed 10-character Apple Team ID.") + entries = re.findall(r'\d+\)\s+([A-Fa-f0-9]{40})\s+"([^"\n]+)"', listing) + matches = { + (digest.upper(), name) + for digest, name in entries + if digest.lower() == identity.lower() or name == identity + } + if len(matches) != 1: + raise ValueError("Select one valid signing identity with its private key in the keychain.") + digest, name = matches.pop() + if not name.startswith("Developer ID Application:"): + raise ValueError("Distribution requires a Developer ID Application certificate.") + if not name.endswith(f"({team})"): + raise ValueError("Signing certificate Team ID differs from USTA_SIGNING_TEAM_ID.") + return digest + + +def validate_signature(details, team): + if f"Identifier={BUNDLE_ID}\n" not in details: + raise ValueError("The application bundle identifier changed.") + if f"TeamIdentifier={team}\n" not in details: + raise ValueError("The application signing Team ID changed.") + requirement = next((line for line in details.splitlines() if "designated =>" in line), "") + if not requirement or "cdhash" in requirement.lower() or "anchor apple" not in requirement: + raise ValueError( + "The application must have a stable Apple certificate requirement, not a code hash." + ) + if "(runtime)" not in details: + raise ValueError("The signed distribution must enable hardened runtime.") + + +def load_self_signed_pin(): + folder = Path(__file__).resolve().parent + pin = json.loads((folder / "self-signed-identity.json").read_text()) + certificate = (folder / "certs/usta-self-signed.cer").read_bytes() + if ( + hashlib.sha1(certificate).hexdigest().upper() != pin["sha1"] + or hashlib.sha256(certificate).hexdigest().upper() != pin["sha256"] + ): + raise ValueError( + "Pinned self-signed certificate changed; do not generate a new identity for updates." + ) + if pin["bundle_id"] != BUNDLE_ID: + raise ValueError("Pinned bundle identifier changed.") + return pin + + +def resolve_self_signed_identity(identity, fingerprint, listing): + if identity and identity.upper() != fingerprint.upper(): + raise ValueError("Self-signed builds must use the pinned certificate fingerprint.") + entries = re.findall(r'\d+\)\s+([A-Fa-f0-9]{40})\s+"([^"\n]+)"', listing) + if not any(digest.upper() == fingerprint.upper() for digest, _ in entries): + raise ValueError( + "Pinned self-signed identity/private key not available in the keychain. Restore it; do not regenerate it." + ) + return fingerprint.upper() + + +def validate_self_signed_signature(details, fingerprint): + if f"Identifier={BUNDLE_ID}\n" not in details: + raise ValueError("The application bundle identifier changed.") + requirement = next( + (line for line in details.splitlines() if "designated =>" in line), "" + ).lower() + anchored = "certificate leaf" in requirement or "anchor" in requirement + if not anchored or "cdhash" in requirement or f'h"{fingerprint.lower()}"' not in requirement: + raise ValueError( + "Self-signed requirement must bind to the pinned certificate, not a code hash or identifier alone." + ) + if "(runtime)" in details: + raise ValueError("Self-signed Python bundles cannot use Developer ID library validation.") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + action = parser.add_mutually_exclusive_group() + action.add_argument("--verify-app") + action.add_argument("--sign-app") + args = parser.parse_args() + try: + identity = os.environ.get("USTA_CODESIGN_IDENTITY") + team = os.environ.get("USTA_SIGNING_TEAM_ID") + mode = os.environ.get("USTA_SIGNING_MODE", "developer-id") + if mode not in ("developer-id", "self-signed"): + raise ValueError("USTA_SIGNING_MODE must be developer-id or self-signed.") + self_signed = mode == "self-signed" + pin = load_self_signed_pin() if self_signed else None + if args.sign_app: + if not self_signed: + raise ValueError("Post-build signing is only for the explicit self-signed mode.") + listing = subprocess.check_output( + ["security", "find-identity", "-v", "-p", "codesigning"], text=True + ) + selected = resolve_self_signed_identity(identity, pin["sha1"], listing) + subprocess.run( + [ + "codesign", + "--force", + "--deep", + "--options", + "0", + "--timestamp=none", + "--sign", + selected, + "--identifier", + BUNDLE_ID, + args.sign_app, + ], + check=True, + ) + elif args.verify_app: + details = subprocess.run( + ["codesign", "-d", "--verbose=4", "-r-", args.verify_app], + capture_output=True, + text=True, + check=True, + ) + if self_signed: + validate_self_signed_signature(details.stdout + details.stderr, pin["sha1"]) + else: + validate_signature(details.stdout + details.stderr, team) + else: + listing = "" + if self_signed or (identity and identity != "-"): + listing = subprocess.run( + ["security", "find-identity", "-v", "-p", "codesigning"], + capture_output=True, + text=True, + check=True, + ).stdout + if self_signed: + selected = resolve_self_signed_identity(identity, pin["sha1"], listing) + else: + selected = resolve_identity( + identity, team, listing, allow_ad_hoc=os.environ.get("USTA_ALLOW_ADHOC") == "1" + ) + print(selected) + except (ValueError, OSError, subprocess.SubprocessError) as exc: + print(f"Signing preflight failed: {exc}", file=sys.stderr) + return 2 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/packaging/macos/usta.spec b/packaging/macos/usta.spec new file mode 100644 index 0000000..c08a99a --- /dev/null +++ b/packaging/macos/usta.spec @@ -0,0 +1,73 @@ +# -*- mode: python ; coding: utf-8 -*- +import os +import runpy +import subprocess +import sys +from pathlib import Path + +from PyInstaller.utils.hooks import collect_data_files, collect_dynamic_libs, collect_submodules + +root = Path(SPECPATH).resolve().parents[1] +identity = subprocess.check_output( + [sys.executable, str(root / "packaging/macos/signing_policy.py")], text=True +).strip() +identity = None if identity == "-" or os.environ.get("USTA_SIGNING_MODE") == "self-signed" else identity +version = runpy.run_path(str(root / "src/usta/_version.py"))["__version__"] +icon = root / "build/macos/USTA.icns" +datas = collect_data_files("usta") +datas += collect_data_files("easyocr") +hidden = collect_submodules("usta") + [ + "check_bundle", "check_permissions", + # Loaded by name; pkgutil collection skips this namespace-package directory. + "usta.core.platform.macos.capabilities", + "pynput.keyboard._darwin", + "pynput.mouse._darwin", + "AppKit", "Foundation", "Quartz", "Vision", "CoreML", "ApplicationServices", "CoreText", + "ScreenCaptureKit", +] +analysis = Analysis( + [str(root / "packaging/macos/launcher.py")], + pathex=[str(root / "src"), str(root / "packaging/macos")], + # torchvision 0.29+ loads _C_stable.so via torch.ops rather than a Python import. + binaries=collect_dynamic_libs("torchvision", search_patterns=["*.so", "*.dylib"]), + datas=datas, + hiddenimports=hidden, + hookspath=[str(root / "packaging/macos/hooks")], + hooksconfig={}, + runtime_hooks=[], + excludes=["pytest", "IPython", "notebook", "tkinter", "matplotlib", "paddle", "paddleocr"], + noarchive=False, +) +pyz = PYZ(analysis.pure) +exe = EXE( + pyz, analysis.scripts, [], + exclude_binaries=True, + name="USTA", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + console=False, + argv_emulation=False, + target_arch="arm64", + codesign_identity=identity, + entitlements_file=str(root / "packaging/macos/entitlements.plist") if identity else None, +) +collection = COLLECT(exe, analysis.binaries, analysis.datas, strip=False, upx=False, name="USTA") +app = BUNDLE( + collection, + name="USTA.app", + icon=str(icon), + bundle_identifier="com.sedataym.usta", + version=version, + info_plist={ + "CFBundleDisplayName": "USTA", + "CFBundleShortVersionString": version, + "CFBundleVersion": version, + "NSHighResolutionCapable": True, + "NSScreenCaptureUsageDescription": "USTA captures selected screen regions to recognize and translate text.", + "LSMinimumSystemVersion": "26.0", + "NSPrincipalClass": "NSApplication", + "LSApplicationCategoryType": "public.app-category.utilities", + }, +) diff --git a/pyproject.toml b/pyproject.toml index f40a2ac..bad96ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,17 +4,18 @@ build-backend = "setuptools.build_meta" [project] name = "usta" -version = "0.1.7" +dynamic = ["version"] authors = [ { name="Sedat Ayma" }, ] -description = "High-performance, PySide6-based instant translation HUD for Linux." +description = "High-performance, PySide6-based instant translation HUD for Linux and macOS." readme = "README.md" -requires-python = ">=3.8" +requires-python = ">=3.10" classifiers = [ "Programming Language :: Python :: 3", - "License :: OSI Approved :: MIT License", + "License :: OSI Approved :: GPL-3.0 License", "Operating System :: POSIX :: Linux", + "Operating System :: MacOS :: MacOS X", "Topic :: Multimedia :: Video", "Topic :: Utilities", ] @@ -24,12 +25,16 @@ dependencies = [ "easyocr>=1.7.0", "rapidocr-onnxruntime>=1.2.0,<1.3.0", "onnxruntime>=1.17.0", - "paddleocr>=3.6.0", + "deepl>=1.0.0", + "pynput>=1.7.6", "googletrans==4.0.2", "python-dotenv>=1.0.0", "Pillow>=10.0.0", - "dbus-next>=0.2.3", + "dbus-next>=0.2.3; sys_platform == 'linux'", "Babel>=2.12.0", + "pyobjc-framework-Cocoa>=10.0; sys_platform == 'darwin'", + "pyobjc-framework-Vision>=10.0; sys_platform == 'darwin'", + "pyobjc-framework-ScreenCaptureKit>=12.0; sys_platform == 'darwin'", ] [project.urls] @@ -37,12 +42,35 @@ dependencies = [ "Bug Tracker" = "https://github.com/sedataym/usta/issues" [project.scripts] -usta = "src.main:main" +usta = "usta.main:main" [tool.setuptools.packages.find] -where = ["."] -include = ["src*"] +where = ["src"] +include = ["usta*"] [tool.setuptools.package-data] -"src" = ["translations/*/LC_MESSAGES/*.mo"] -"src.ui" = ["assets/*.png"] +"usta" = ["resources/translations/*/LC_MESSAGES/*.mo", "resources/translations/*/LC_MESSAGES/*.po", "resources/ocr_models/rapidocr/*.onnx"] +"usta.ui" = ["assets/*.png", "assets/*.svg"] + +[project.optional-dependencies] +paddle = ["paddleocr>=3.6.0", "paddlepaddle"] +dev = ["ruff", "mypy", "build", "coverage", "tomli; python_version < '3.11'"] + +[tool.setuptools.dynamic] +version = {attr = "usta._version.__version__"} + +[tool.ruff] +target-version = "py310" +line-length = 100 + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F", "I"] + +[tool.ruff.lint.per-file-ignores] +"src/usta/config.py" = ["F401"] + +[tool.mypy] +python_version = "3.10" +files = ["src/usta/application/settings.py", "src/usta/application/results.py", "src/usta/infrastructure/settings_store.py", "src/usta/infrastructure/legacy_settings.py"] +check_untyped_defs = true +follow_imports = "silent" diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..e7ab444 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1 @@ +.[dev] diff --git a/requirements.txt b/requirements.txt index 763e69c..9c558e3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,15 +1 @@ -PySide6>=6.5.0 -pytesseract>=0.3.10 -rapidocr-onnxruntime>=1.2.0,<1.3.0 -onnxruntime>=1.17.0 -easyocr>=1.7.0 -#paddleocr>=3.6.0 -# paddlepaddle (PaddleOCR's native inference backend — uncomment when available for your Python version) -# pip install paddlepaddle (requires Python ≤ 3.12 as of June 2026) -deepl>=1.0.0 -googletrans>=4.0.2 -python-dotenv>=1.0.0 -Pillow>=10.0.0 -dbus-next>=0.2.3 -Babel>=2.12.0 -pynput>=1.7.6 +. diff --git a/run.py b/run.py index 19962be..c1d643a 100644 --- a/run.py +++ b/run.py @@ -1,4 +1,4 @@ -from src.main import main +from usta.main import main if __name__ == "__main__": main() diff --git a/scripts/build-appimage.sh b/scripts/build-appimage.sh index f6f43e4..4d1e492 100755 --- a/scripts/build-appimage.sh +++ b/scripts/build-appimage.sh @@ -35,15 +35,7 @@ EOF exit 1 fi -APP_VERSION="${APP_VERSION:-$(${PYTHON_BIN} - <<'PY' -import re -from pathlib import Path - -config = Path("src/config.py").read_text(encoding="utf-8") -match = re.search(r'^APP_VERSION\s*=\s*["\']([^"\']+)["\']', config, re.M) -print(match.group(1) if match else "0.0.0") -PY -)}" +APP_VERSION="${APP_VERSION:-$(${PYTHON_BIN} -c 'import runpy; print(runpy.run_path("src/usta/_version.py")["__version__"])')}" if ! command -v "${APPIMAGETOOL}" >/dev/null 2>&1; then LOCAL_APPIMAGETOOL="${BUILD_DIR}/appimagetool-${APPIMAGE_ARCH}.AppImage" @@ -86,7 +78,6 @@ mkdir -p \ echo "Installing Python package payload from ${PYTHON_BIN} into ${APPDIR}/usr/app/site-packages" "${PYTHON_BIN}" -m pip install --upgrade pip setuptools wheel -"${PYTHON_BIN}" -m pip install --upgrade --target "${APPDIR}/usr/app/site-packages" -r requirements.txt "${PYTHON_BIN}" -m pip install --upgrade --target "${APPDIR}/usr/app/site-packages" . PYTHON_REAL="$(${PYTHON_BIN} -c 'import sys; print(sys.executable)')" @@ -104,8 +95,8 @@ chmod +x "${APPDIR}/AppRun" "${APPDIR}/usr/bin/python3" cp "${ROOT_DIR}/packaging/appimage/usta.desktop" "${APPDIR}/usta.desktop" cp "${ROOT_DIR}/packaging/appimage/usta.desktop" "${APPDIR}/usr/share/applications/usta.desktop" -cp "${ROOT_DIR}/src/ui/assets/usta.png" "${APPDIR}/usta.png" -cp "${ROOT_DIR}/src/ui/assets/usta.png" "${APPDIR}/usr/share/icons/hicolor/256x256/apps/usta.png" +cp "${ROOT_DIR}/src/usta/ui/assets/usta.png" "${APPDIR}/usta.png" +cp "${ROOT_DIR}/src/usta/ui/assets/usta.png" "${APPDIR}/usr/share/icons/hicolor/256x256/apps/usta.png" OUTPUT="${DIST_DIR}/USTA-${APP_VERSION}-${APPIMAGE_ARCH}.AppImage" rm -f "${OUTPUT}" diff --git a/scripts/build-dmg.sh b/scripts/build-dmg.sh new file mode 100755 index 0000000..9f9e89a --- /dev/null +++ b/scripts/build-dmg.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PYTHON_BIN="${PYTHON_BIN:-${ROOT_DIR}/.venv/bin/python}" +BUILD_DIR="${ROOT_DIR}/build/macos" +DIST_DIR="${ROOT_DIR}/dist/macos" + +if [[ "$(uname -s)" != "Darwin" || "$(uname -m)" != "arm64" ]]; then + echo "This build configuration requires an Apple Silicon Mac." >&2 + exit 1 +fi +"${PYTHON_BIN}" -c 'import PyInstaller' || { + echo "Install the build tool: ${PYTHON_BIN} -m pip install 'pyinstaller>=6.16,<7'" >&2 + exit 1 +} +SIGNING_MODE="${USTA_SIGNING_MODE:-developer-id}" +RESOLVED_IDENTITY="$("${PYTHON_BIN}" "${ROOT_DIR}/packaging/macos/signing_policy.py")" +export USTA_CODESIGN_IDENTITY="${RESOLVED_IDENTITY}" +if [[ "${RESOLVED_IDENTITY}" != "-" && "${SIGNING_MODE}" != "self-signed" ]]; then + : "${USTA_NOTARY_PROFILE:?Set USTA_NOTARY_PROFILE to a notarytool Keychain profile for distribution.}" + xcrun --find notarytool >/dev/null + xcrun --find stapler >/dev/null + # Validate credentials before the expensive build; credentials remain in Keychain. + xcrun notarytool history --keychain-profile "${USTA_NOTARY_PROFILE}" --output-format json >/dev/null +fi +mkdir -p "${BUILD_DIR}" "${DIST_DIR}" +cd "${ROOT_DIR}" +APP_VERSION="$(${PYTHON_BIN} -c 'from usta._version import __version__; print(__version__)')" + +"${PYTHON_BIN}" - "${ROOT_DIR}/src/usta/ui/assets/usta.png" "${BUILD_DIR}/USTA.icns" <<'PY' +import sys +from PIL import Image +Image.open(sys.argv[1]).resize((1024, 1024)).save(sys.argv[2], format="ICNS") +PY +export PYINSTALLER_CONFIG_DIR="${BUILD_DIR}/pyinstaller-cache" +"${PYTHON_BIN}" -m PyInstaller --noconfirm \ + --workpath "${BUILD_DIR}/pyinstaller" --distpath "${DIST_DIR}" \ + "${ROOT_DIR}/packaging/macos/usta.spec" + +if [[ "${SIGNING_MODE}" == "self-signed" ]]; then + "${PYTHON_BIN}" "${ROOT_DIR}/packaging/macos/signing_policy.py" --sign-app "${DIST_DIR}/USTA.app" +fi +codesign --verify --deep --strict "${DIST_DIR}/USTA.app" +if [[ "${RESOLVED_IDENTITY}" != "-" ]]; then + "${PYTHON_BIN}" "${ROOT_DIR}/packaging/macos/signing_policy.py" --verify-app "${DIST_DIR}/USTA.app" +fi +if ! "${DIST_DIR}/USTA.app/Contents/MacOS/USTA" --check-bundle "${BUILD_DIR}/bundle-check.json"; then + cat "${BUILD_DIR}/bundle-check.json" >&2 + exit 1 +fi +cat "${BUILD_DIR}/bundle-check.json" +STAGING_DIR="$(mktemp -d "${BUILD_DIR}/dmg-stage.XXXXXX")" +trap 'rm -rf "${STAGING_DIR}"' EXIT +ditto "${DIST_DIR}/USTA.app" "${STAGING_DIR}/USTA.app" +ln -s /Applications "${STAGING_DIR}/Applications" +cp "${ROOT_DIR}/packaging/macos/INSTALL.txt" "${STAGING_DIR}/INSTALL.txt" +if [[ "${SIGNING_MODE}" == "self-signed" ]]; then + FINAL_DMG_PATH="${DIST_DIR}/USTA-${APP_VERSION}-macOS-arm64-selfsigned.dmg" + cp "${ROOT_DIR}/packaging/macos/certs/usta-self-signed.cer" "${STAGING_DIR}/USTA-Code-Signing.cer" + cat > "${STAGING_DIR}/SIGNING.txt" <<'SIGNING' +Signed with the persistent USTA Local Code Signing certificate; not Apple-notarized. +This is a self-signed test build. Apple does not vouch for the publisher. +All updates must use the SAME certificate and private key; public certificate included. +The private key is never distributed. First-install Gatekeeper/permission approval is required. +SIGNING +elif [[ "${RESOLVED_IDENTITY}" == "-" ]]; then + FINAL_DMG_PATH="${DIST_DIR}/USTA-${APP_VERSION}-macOS-arm64-dev.dmg" + cat > "${STAGING_DIR}/SIGNING.txt" <<'SIGNING' +Local development build: ad-hoc signed, not Apple-notarized. +Updates can invalidate Screen Recording and Input Monitoring grants. +Use the Developer ID release workflow for distribution to other Macs. +SIGNING +else + FINAL_DMG_PATH="${DIST_DIR}/USTA-${APP_VERSION}-macOS-arm64.dmg" + cat > "${STAGING_DIR}/SIGNING.txt" <<'SIGNING' +Distribution build: signed with Developer ID Application and notarized by Apple. +The publisher Team ID and com.sedataym.usta identifier are preserved across releases. +The first transition from an ad-hoc build can require permission to be granted again. +SIGNING +fi +# A rejected/incomplete notarization must never appear under the release filename. +DMG_PATH="${DIST_DIR}/.USTA-${APP_VERSION}-pending.dmg" +hdiutil create -ov -volname "USTA ${APP_VERSION}" -srcfolder "${STAGING_DIR}" \ + -format UDZO -imagekey zlib-level=9 "${DMG_PATH}" +if [[ "${SIGNING_MODE}" == "self-signed" ]]; then + codesign --force --timestamp=none --sign "${RESOLVED_IDENTITY}" "${DMG_PATH}" +fi +if [[ "${RESOLVED_IDENTITY}" != "-" && "${SIGNING_MODE}" != "self-signed" ]]; then + codesign --force --timestamp --sign "${RESOLVED_IDENTITY}" "${DMG_PATH}" + xcrun notarytool submit "${DMG_PATH}" --keychain-profile "${USTA_NOTARY_PROFILE}" \ + --wait --output-format json > "${BUILD_DIR}/notarization.json" + "${PYTHON_BIN}" - "${BUILD_DIR}/notarization.json" <<'PY_NOTARY' +import json +import sys +with open(sys.argv[1]) as report: + result = json.load(report) +if result.get("status") != "Accepted": + raise SystemExit(f"Notarization was not accepted: {result.get('status')}; see {sys.argv[1]}") +PY_NOTARY + # Apple creates tickets for the DMG and its nested application in one submission. + xcrun stapler staple "${DIST_DIR}/USTA.app" + xcrun stapler validate "${DIST_DIR}/USTA.app" + xcrun stapler staple "${DMG_PATH}" + xcrun stapler validate "${DMG_PATH}" + spctl --assess --type open --context context:primary-signature --verbose=2 "${DMG_PATH}" +fi +hdiutil verify "${DMG_PATH}" +mv -f "${DMG_PATH}" "${FINAL_DMG_PATH}" +shasum -a 256 "${FINAL_DMG_PATH}" > "${FINAL_DMG_PATH}.sha256" +echo "Built: ${FINAL_DMG_PATH}" diff --git a/src/core/exceptions.py b/src/core/exceptions.py deleted file mode 100644 index da3dbfc..0000000 --- a/src/core/exceptions.py +++ /dev/null @@ -1,3 +0,0 @@ -class PortalCanceledError(RuntimeError): - """Raised when the user cancels or rejects the XDG Desktop Portal screen selection dialog.""" - pass \ No newline at end of file diff --git a/src/core/ocr/easyocr_engine.py b/src/core/ocr/easyocr_engine.py deleted file mode 100644 index 8300679..0000000 --- a/src/core/ocr/easyocr_engine.py +++ /dev/null @@ -1,33 +0,0 @@ -from src.core.ocr.base_ocr import BaseOCREngine -from src.config import OCR_LANG_MAPPING - -class EasyOCREngine(BaseOCREngine): - def __init__(self): - self.reader = None - self.current_langs = ['en'] - - def set_language(self, lang_code: str): - mapping = OCR_LANG_MAPPING.get(lang_code, OCR_LANG_MAPPING["en"]) - new_lang = mapping["easy"] - - # Always include 'en' as base, but add the target language if different - target_langs = list({'en', new_lang}) - - if target_langs != self.current_langs: - self.current_langs = target_langs - self.reader = None # Trigger re-initialization on next read - print(f"EasyOCREngine: Languages scheduled for update: {self.current_langs}") - - def read_text(self, image_path: str) -> str: - if self.reader is None: - import easyocr - print(f"EasyOCREngine: Initializing reader with {self.current_langs}") - self.reader = easyocr.Reader(self.current_langs, gpu=True) - - try: - res = self.reader.readtext(image_path) - clean = " ".join([r[1] for r in res]) - return " ".join(clean.split()).strip().strip("_").rstrip(":") - except Exception as e: - print(f"EasyOCR Error: {e}") - return "" diff --git a/src/core/ocr/ocr_manager.py b/src/core/ocr/ocr_manager.py deleted file mode 100644 index 1b1e782..0000000 --- a/src/core/ocr/ocr_manager.py +++ /dev/null @@ -1,28 +0,0 @@ -from src.core.ocr.tesseract_engine import TesseractEngine -from src.core.ocr.easyocr_engine import EasyOCREngine -from src.core.ocr.paddleocr_engine import PaddleOCREngine -from src.core.ocr.rapidocr_engine import RapidOCREngine - -class OCRManager: - def __init__(self): - self.engines = { - "Tesseract": TesseractEngine(), - "EasyOCR": EasyOCREngine(), - "PaddleOCR": PaddleOCREngine(), - "RapidOCR": RapidOCREngine() - } - self.current_engine_name = "RapidOCR" - - def set_engine(self, name: str): - if name in self.engines: - self.current_engine_name = name - - def set_language(self, lang_code: str): - for engine in self.engines.values(): - engine.set_language(lang_code) - - def read_text(self, image_path: str) -> str: - engine = self.engines.get(self.current_engine_name) - if engine: - return engine.read_text(image_path) - return "" diff --git a/src/core/platform/__init__.py b/src/core/platform/__init__.py deleted file mode 100644 index e69b351..0000000 --- a/src/core/platform/__init__.py +++ /dev/null @@ -1,35 +0,0 @@ -from src.core.platform.capability_resolver import ( - CONFIG_DIR, - LANGUAGES, - OCR_ENGINES, - OCR_LANG_MAPPING, - PORTAL_ORIENTATION, - SCREENSHOT_ENGINES, - SETTINGS_TOPMOST_HOTKEY, - TEMPORARY_REGION_HOTKEY, - TRANSLATION_ENGINES, - resolve_capabilities, -) -from src.core.platform.system_info import ( - SystemInfo, - get_compositor, - get_desktop_environment, - get_os, -) - -__all__ = [ - "CONFIG_DIR", - "LANGUAGES", - "OCR_ENGINES", - "OCR_LANG_MAPPING", - "PORTAL_ORIENTATION", - "SCREENSHOT_ENGINES", - "SETTINGS_TOPMOST_HOTKEY", - "TEMPORARY_REGION_HOTKEY", - "SystemInfo", - "TRANSLATION_ENGINES", - "get_compositor", - "get_desktop_environment", - "get_os", - "resolve_capabilities", -] diff --git a/src/core/platform/linux/kde/capabilities.py b/src/core/platform/linux/kde/capabilities.py deleted file mode 100644 index 6313921..0000000 --- a/src/core/platform/linux/kde/capabilities.py +++ /dev/null @@ -1,7 +0,0 @@ -"""KDE capability overrides. - -Only define values here when they differ from src.core.platform.capabilities. -""" - -SCREENSHOT_ENGINES = [ "Portal", "Spectacle"] -PORTAL_ORIENTATION = 1 diff --git a/src/core/platform/macos/capabilities.py b/src/core/platform/macos/capabilities.py deleted file mode 100644 index 1985720..0000000 --- a/src/core/platform/macos/capabilities.py +++ /dev/null @@ -1,14 +0,0 @@ -"""macOS capability overrides. - -Only define values here when they differ from src.core.platform.capabilities. -""" - -import os - - -CONFIG_DIR = os.path.join( - os.path.expanduser("~"), - "Library", - "Application Support", - "usta", -) diff --git a/src/core/screenshot/__init__.py b/src/core/screenshot/__init__.py deleted file mode 100644 index 2815141..0000000 --- a/src/core/screenshot/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -from src.core.screenshot.base_screenshot import BaseScreenshot -from src.core.screenshot.spectacle_screenshot import SpectacleScreenshot -from src.core.screenshot.image_processor import ImageProcessor -from src.core.screenshot.screenshot_factory import ScreenshotFactory - -try: - from src.core.screenshot.portal_screenshot import PortalScreenshot -except Exception: - PortalScreenshot = None - -__all__ = [ - "BaseScreenshot", - "PortalScreenshot", - "SpectacleScreenshot", - "ImageProcessor", - "ScreenshotFactory", -] diff --git a/src/core/screenshot/screenshot_factory.py b/src/core/screenshot/screenshot_factory.py deleted file mode 100644 index 6d31258..0000000 --- a/src/core/screenshot/screenshot_factory.py +++ /dev/null @@ -1,25 +0,0 @@ -from src.core.screenshot.base_screenshot import BaseScreenshot -from src.core.screenshot.spectacle_screenshot import SpectacleScreenshot - -try: - from src.core.screenshot.portal_screenshot import PortalScreenshot -except Exception as exc: - PortalScreenshot = None - _PORTAL_IMPORT_ERROR = exc -else: - _PORTAL_IMPORT_ERROR = None - -class ScreenshotFactory: - @staticmethod - def get_engine(engine_name: str = "Portal") -> BaseScreenshot: - if engine_name == "Portal": - if PortalScreenshot is not None and PortalScreenshot.is_available(): - return PortalScreenshot() - - if _PORTAL_IMPORT_ERROR is not None: - print(f"Portal screenshot engine unavailable: {_PORTAL_IMPORT_ERROR}") - print("Falling back to Spectacle screenshot engine.") - return SpectacleScreenshot() - - # Spectacle - return SpectacleScreenshot() diff --git a/src/core/sniper/__init__.py b/src/core/sniper/__init__.py deleted file mode 100644 index d85c6df..0000000 --- a/src/core/sniper/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -from src.core.sniper.base_sniper import BaseSniper -from src.core.sniper.slurp_sniper import SlurpSniper -from src.core.sniper.sniper_factory import SniperFactory - -__all__ = [ - "BaseSniper", - "SlurpSniper", - "SniperFactory", -] diff --git a/src/core/socket_publisher.py b/src/core/socket_publisher.py deleted file mode 100644 index e333e45..0000000 --- a/src/core/socket_publisher.py +++ /dev/null @@ -1,126 +0,0 @@ -import os -import json -import socket -import threading -import time -from src.config import SOCKET_PATH - - -class TranslationPublisher: - """Unix domain socket server that broadcasts translation results to all connected clients. - - Sends JSON messages (one per line) for each translation. - """ - - def __init__(self): - self._server_socket: socket.socket | None = None - self._clients: list[socket.socket] = [] - self._lock = threading.Lock() - self._running = False - self._thread: threading.Thread | None = None - - def start(self) -> None: - """Start the Unix socket server in a background thread.""" - if self._running: - return - - # Clean up stale socket file - if os.path.exists(SOCKET_PATH): - try: - os.unlink(SOCKET_PATH) - except OSError: - pass - - self._server_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - self._server_socket.bind(SOCKET_PATH) - self._server_socket.listen(5) - self._server_socket.settimeout(1.0) # Allows graceful shutdown via _running flag - - self._running = True - self._thread = threading.Thread(target=self._accept_loop, daemon=True) - self._thread.start() - print(f"TranslationPublisher: Listening on {SOCKET_PATH}") - - def stop(self) -> None: - """Stop the server, close all client connections, and clean up the socket file.""" - self._running = False - - if self._thread is not None: - self._thread.join(timeout=2.0) - self._thread = None - - with self._lock: - for client in self._clients: - try: - client.close() - except OSError: - pass - self._clients.clear() - - if self._server_socket is not None: - try: - self._server_socket.close() - except OSError: - pass - self._server_socket = None - - if os.path.exists(SOCKET_PATH): - try: - os.unlink(SOCKET_PATH) - except OSError: - pass - - print("TranslationPublisher: Stopped.") - - def broadcast(self, original: str, translated: str, - source_lang: str = "en", target_lang: str = "tr", - engine: str = "unknown") -> None: - """Send a translation result to all connected clients as a JSON line. - - Args: - original: The original (source) text. - translated: The translated text. - source_lang: Source language code. - target_lang: Target language code. - engine: Name of the translation engine used. - """ - message = json.dumps({ - "original": original, - "translated": translated, - "source": source_lang, - "target": target_lang, - "engine": engine, - "timestamp": time.time(), - }) + "\n" - - data = message.encode("utf-8") - - with self._lock: - disconnected = [] - for client in self._clients: - try: - client.sendall(data) - except (BrokenPipeError, OSError): - disconnected.append(client) - - for client in disconnected: - self._clients.remove(client) - try: - client.close() - except OSError: - pass - - def _accept_loop(self) -> None: - """Main loop that accepts incoming client connections.""" - while self._running: - try: - conn, _ = self._server_socket.accept() - with self._lock: - self._clients.append(conn) - print(f"TranslationPublisher: Client connected ({len(self._clients)} total)") - except socket.timeout: - continue - except OSError: - if self._running: - print("TranslationPublisher: Accept error, retrying…") - break \ No newline at end of file diff --git a/src/core/translation/translator_manager.py b/src/core/translation/translator_manager.py deleted file mode 100644 index 0318474..0000000 --- a/src/core/translation/translator_manager.py +++ /dev/null @@ -1,36 +0,0 @@ -from src.core.translation.google_engine import GoogleEngine -from src.core.translation.deepl_engine import DeepLTranslatorEngine - -class TranslatorManager: - def __init__(self): - self.translators = { - "Google": GoogleEngine(), - "DeepL": DeepLTranslatorEngine() - } - self.current_translator_name = "Google" - self._source_lang = "en" - self._target_lang = "tr" - - def set_translator(self, name: str): - if name in self.translators: - self.current_translator_name = name - - def set_api_key(self, engine: str, api_key: str): - translator = self.translators.get(engine) - if translator and hasattr(translator, "set_api_key"): - translator.set_api_key(api_key) - - def set_languages(self, source: str, target: str): - self._source_lang = source - self._target_lang = target - for translator in self.translators.values(): - translator.set_languages(source, target) - - def get_languages(self) -> tuple[str, str]: - return self._source_lang, self._target_lang - - def translate(self, text: str) -> str: - translator = self.translators.get(self.current_translator_name) - if translator: - return translator.translate(text) - return text diff --git a/src/core/worker.py b/src/core/worker.py deleted file mode 100644 index 305f37c..0000000 --- a/src/core/worker.py +++ /dev/null @@ -1,153 +0,0 @@ -import time -import threading -from threading import Lock -from PySide6.QtCore import QThread, Signal, QRect -from src.core.ocr.ocr_manager import OCRManager -from src.core.translation.translator_manager import TranslatorManager -from src.core.exceptions import PortalCanceledError -from src.core.screenshot import ScreenshotFactory, ImageProcessor -from src.core.socket_publisher import TranslationPublisher -from src.config import IMG_PATH, DPI_SCALE_DEFAULT - -class OCRWorker(QThread): - LOOP_SLEEP_SECONDS = 0.2 - - new_translation = Signal(str) - performance_update = Signal(float) # Loop duration in seconds - translation_status = Signal(bool) # True: translating (API call active), False: idle - running_status = Signal(bool) # True: system started, False: system stopped - - def __init__(self): - super().__init__() - self.capture_rect = QRect(560, 750, 800, 140) - self.dpi_scale = DPI_SCALE_DEFAULT - self.lock = Lock() - self.running = False - self.ocr_manager = OCRManager() - self.translator_manager = TranslatorManager() - self.screenshot_engine = ScreenshotFactory.get_engine() - self.last_text = "" - self._translation_lock = Lock() - self._is_translating = False - self.publisher = TranslationPublisher() - - def set_rect(self, qrect, dpi_scale=None): - with self.lock: - self.capture_rect = QRect(qrect) - if dpi_scale is not None: - self.dpi_scale = dpi_scale - self.last_text = "" - print(f"OCRWorker: New region: {qrect.x()},{qrect.y()} {qrect.width()}x{qrect.height()} DPI: {self.dpi_scale}") - - def set_engine(self, engine_name): - with self.lock: - self.ocr_manager.set_engine(engine_name) - self.last_text = "" - print(f"OCRWorker: Engine: {engine_name}") - - def set_translator(self, translator_name): - with self.lock: - self.translator_manager.set_translator(translator_name) - self.last_text = "" - print(f"OCRWorker: Translator: {translator_name}") - - def set_api_key(self, engine: str, api_key: str): - with self.lock: - self.translator_manager.set_api_key(engine, api_key) - self.last_text = "" - print(f"OCRWorker: API key set for: {engine}") - - def set_screenshot_engine(self, engine_name): - with self.lock: - if hasattr(self.screenshot_engine, "close"): - self.screenshot_engine.close() - self.screenshot_engine = ScreenshotFactory.get_engine(engine_name) - self.last_text = "" - print(f"OCRWorker: Screenshot engine: {engine_name}") - - def set_languages(self, source, target): - with self.lock: - self.translator_manager.set_languages(source, target) - self.ocr_manager.set_language(source) - self.last_text = "" - print(f"OCRWorker: Languages: {source} -> {target}") - - def stop(self): - self.running = False - if self.isRunning(): - self.wait() - if hasattr(self.screenshot_engine, "close"): - self.screenshot_engine.close() - self.publisher.stop() - self.running_status.emit(False) - - def _async_translate(self, text): - with self._translation_lock: - self._is_translating = True - self.translation_status.emit(True) - try: - translated = self.translator_manager.translate(text) - self.new_translation.emit(translated) - source, target = self.translator_manager.get_languages() - self.publisher.broadcast( - original=text, - translated=translated, - source_lang=source, - target_lang=target, - engine=self.translator_manager.current_translator_name, - ) - except Exception as e: - print(f"Translation Error: {e}") - finally: - with self._translation_lock: - self._is_translating = False - self.translation_status.emit(False) - - def run(self): - self.publisher.start() - self.running = True - self.running_status.emit(True) - print(f"OCRWorker: LOOP STARTED.") - while self.running: - try: - start_total = time.perf_counter() - with self.lock: - rect = QRect(self.capture_rect) - current_engine = self.ocr_manager.current_engine_name - - with self._translation_lock: - translating = self._is_translating - - if rect.width() < 10 or rect.height() < 10: - time.sleep(self.LOOP_SLEEP_SECONDS); continue - - # 1. Capture - if not self.screenshot_engine.capture(rect, IMG_PATH, self.dpi_scale): - time.sleep(self.LOOP_SLEEP_SECONDS); continue - - # 2. Prep (Special processing for Tesseract only) - if current_engine == "Tesseract": - ImageProcessor.preprocess_for_tesseract(IMG_PATH, IMG_PATH) - - # 3. OCR - clean = self.ocr_manager.read_text(IMG_PATH) - - # 4. API (Async Translation) - if len(clean) > 2 and clean != self.last_text and not translating: - self.last_text = clean - threading.Thread(target=self._async_translate, args=(clean,), daemon=True).start() - print(f"[{current_engine}] Text: {clean} | Duration: {time.perf_counter()-start_total:.2f}s") - - # Emit performance data - self.performance_update.emit(time.perf_counter() - start_total) - - except PortalCanceledError: - print("OCRWorker: Portal selection canceled, stopping.") - self.running = False - if hasattr(self.screenshot_engine, "close"): - self.screenshot_engine.close() - self.publisher.stop() - self.running_status.emit(False) - except Exception as e: - print(f"Loop Error: {e}") - time.sleep(self.LOOP_SLEEP_SECONDS) \ No newline at end of file diff --git a/src/main.py b/src/main.py deleted file mode 100644 index feaaa5b..0000000 --- a/src/main.py +++ /dev/null @@ -1,30 +0,0 @@ -import sys -import os - -# Add project root (parent of src) to sys.path -project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) -if project_root not in sys.path: - sys.path.insert(0, project_root) - -from PySide6.QtGui import QIcon -from PySide6.QtWidgets import QApplication -from src.ui.main_window import ControlPanel -from src.i18n import init_i18n - -def main(): - # Initialize internationalization - init_i18n() - - os.environ["QT_QPA_PLATFORM"] = "xcb" - - app = QApplication(sys.argv) - - # Set application icon - icon_path = os.path.join(os.path.dirname(__file__), "ui", "assets", "usta.png") - app.setWindowIcon(QIcon(icon_path)) - - panel = ControlPanel() - sys.exit(app.exec()) - -if __name__ == "__main__": - main() diff --git a/src/translations/tr/LC_MESSAGES/messages.mo b/src/translations/tr/LC_MESSAGES/messages.mo deleted file mode 100644 index 0eddca7..0000000 Binary files a/src/translations/tr/LC_MESSAGES/messages.mo and /dev/null differ diff --git a/src/translations/uk/LC_MESSAGES/messages.mo b/src/translations/uk/LC_MESSAGES/messages.mo deleted file mode 100644 index 2b1d95e..0000000 Binary files a/src/translations/uk/LC_MESSAGES/messages.mo and /dev/null differ diff --git a/src/ui/main_window.py b/src/ui/main_window.py deleted file mode 100644 index 3982dcc..0000000 --- a/src/ui/main_window.py +++ /dev/null @@ -1,681 +0,0 @@ -import time -import pickle -import os -from PySide6.QtCore import Qt, QRect, QPoint, QTimer, Signal, Slot -from PySide6.QtGui import QIcon -from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton, QApplication, QColorDialog, QProgressBar, QTabWidget, QMessageBox -from src.core.worker import OCRWorker -from src.ui.overlay_window import TransparentOverlay -from src.core.sniper import SniperFactory -from src.config import LANGUAGES, SETTINGS_FILE, PRESETS_FILE, DPI_SCALE_DEFAULT, SETTINGS_TOPMOST_HOTKEY, TEMPORARY_REGION_HOTKEY, get_language_code -from src.core.shortcut import GlobalHotkey -from src.i18n import _ -from src.ui.tabs import ( - build_about_tab, - build_appearance_tab, - build_ocr_tab, - build_save_tab, - build_hotkey_tab, - build_status_tab, - build_translation_tab, -) - -class ControlPanel(QWidget): - temporary_region_hotkey_pressed = Signal() - - def __init__(self): - super().__init__() - self.worker = OCRWorker() - self.overlay = TransparentOverlay() - self._main_capture_rect = QRect(self.worker.capture_rect) - self._main_dpi_scale = self.worker.dpi_scale - self._temporary_region_active = False - self._temporary_region_selecting = False - self._temporary_region_restore_timer = QTimer(self) - self._temporary_region_restore_timer.setSingleShot(True) - self._temporary_region_restore_timer.timeout.connect(self._restore_main_region) - self._temporary_region_hotkey_armed = True - self.settings_topmost_hotkey = SETTINGS_TOPMOST_HOTKEY - self.temporary_region_hotkey = TEMPORARY_REGION_HOTKEY - self._temporary_region_hotkey = GlobalHotkey( - self.temporary_region_hotkey, - self._emit_temporary_region_hotkey_pressed, - ) - self.temporary_region_hotkey_pressed.connect(self._handle_temporary_region_hotkey_pressed) - self._temporary_region_hotkey.start() - self.setWindowTitle("USTA") - - # Set window icon - icon_path = os.path.join(os.path.dirname(__file__), "assets", "usta.png") - self.setWindowIcon(QIcon(icon_path)) - - # Disable maximization - self.setWindowFlags(self.windowFlags() | Qt.MSWindowsFixedSizeDialogHint) - self.setWindowFlag(Qt.WindowMaximizeButtonHint, False) - self._normal_window_flags = self.windowFlags() - - layout = QVBoxLayout(self) - layout.setSizeConstraint(QVBoxLayout.SetFixedSize) # Fixed size according to content - - # --- Tabs --- - self.tabs = QTabWidget() - self.tabs.addTab(build_status_tab(self), _("Status")) - self.tabs.addTab(build_ocr_tab(self), _("OCR")) - self.tabs.addTab(build_translation_tab(self), _("Translation")) - self.tabs.addTab(build_appearance_tab(self), _("Appearance")) - self.tabs.addTab(build_save_tab(self), _("Save")) - self.tabs.addTab(build_hotkey_tab(self), _("Hotkeys")) - self.tabs.addTab(build_about_tab(self), _("About")) - - - layout.addWidget(self.tabs) - - # --- Performance Indicator (outside tabs) --- - layout.addWidget(QLabel(_("
Performance"))) - self.perf_bar = QProgressBar() - self.perf_bar.setRange(0, 100) - self.perf_bar.setValue(0) - self.perf_bar.setTextVisible(False) - self.perf_bar.setFixedHeight(10) - layout.addWidget(self.perf_bar) - - # --- Controls (outside tabs) --- - layout.addWidget(QLabel(_("Controls"))) - self.btn_reg = QPushButton(_("🖼 Select Region")) - self.btn_reg.setStyleSheet("background-color: #1565C0; color: white; font-weight: bold; padding: 10px;") - self.btn_reg.clicked.connect(self.select_region) - layout.addWidget(self.btn_reg) - - self.btn_start = QPushButton(_("▶ Start")) - self.btn_start.setStyleSheet("background-color: #2E7D32; color: white; font-weight: bold; padding: 10px;") - self.btn_start.clicked.connect(self.start) - layout.addWidget(self.btn_start) - - self.btn_stop = QPushButton(_("■ Stop")) - self.btn_stop.setStyleSheet("background-color: #C62828; color: white; font-weight: bold; padding: 10px;") - self.btn_stop.clicked.connect(self.stop) - layout.addWidget(self.btn_stop) - - self.worker.new_translation.connect(self.overlay.update_text) - self.worker.performance_update.connect(self.update_performance_bar) - self.worker.running_status.connect(self.update_system_status) - self.overlay.main_window_topmost_requested.connect(self.set_settings_always_on_top) - self._settings_always_on_top = False - - # Load settings or set defaults - self.load_settings() - - # Initialize status tab with current rect - self.update_rect_label() - self.refresh_preset_list() - self.show() - - # Align overlay to the right of the control panel with a slight delay - # to ensure the window manager has placed the main window. - QTimer.singleShot(100, self.align_overlay) - - def align_overlay(self): - """Positions the overlay window next to the main window.""" - # Force a refresh of the window's geometry information - QApplication.processEvents() - - # Get the global position of the main window's frame - rect = self.frameGeometry() - target_x = rect.right() + 10 - target_y = rect.top() - - # Move and then show the overlay - self.overlay.move(target_x, target_y) - self.overlay.show() - - # For windows bypassing the WM, move after show is often more reliable - self.overlay.move(target_x, target_y) - - @Slot(bool) - def set_settings_always_on_top(self, enabled): - """Toggle the control panel's game-friendly topmost state from the overlay.""" - self._settings_always_on_top = enabled - - if enabled: - # Keep the control panel decorated by the window manager. - # Qt.Tool / Qt.X11BypassWindowManagerHint can remove borders on some WMs, - # so the settings window uses normal flags plus always-on-top here. - self.setWindowFlags(self._normal_window_flags | Qt.WindowStaysOnTopHint) - self.setWindowFlag(Qt.WindowMaximizeButtonHint, False) - self.setAttribute(Qt.WA_ShowWithoutActivating, True) - self.show() - self.raise_() - self.activateWindow() - else: - self.setAttribute(Qt.WA_ShowWithoutActivating, False) - self.setWindowFlags(self._normal_window_flags) - self.setWindowFlag(Qt.WindowMaximizeButtonHint, False) - self.show() - self.lower() - - def on_font_changed(self, font): - self.overlay.set_font_family(font.family()) - self.save_settings() - - def on_font_size_changed(self, size): - self.overlay.set_font_size(size) - self.save_settings() - - def on_dpi_scale_changed(self, percent_value): - self.worker.dpi_scale = percent_value / 100.0 - self.dpi_locked = True - self.dpi_lock_label.setText("🔒") - self.dpi_lock_label.setToolTip(_("DPI locked (click to unlock)")) - self.save_settings() - - def _toggle_dpi_lock(self, event): - self.dpi_locked = not self.dpi_locked - if self.dpi_locked: - self.dpi_lock_label.setText("🔒") - self.dpi_lock_label.setToolTip(_("DPI locked (click to unlock)")) - else: - self.dpi_lock_label.setText("🔓") - self.dpi_lock_label.setToolTip(_("DPI unlocked (click to lock)")) - return True - - def on_bg_opacity_changed(self, opacity): - self.overlay.set_bg_opacity(opacity) - self.save_settings() - - def save_settings(self): - settings = { - "ocr_engine": self.combo_ocr.currentText(), - "translator_engine": self.combo_translator.currentText(), - "source_lang": self.combo_source.currentText(), - "target_lang": self.combo_target.currentText(), - "font_family": self.font_picker.currentFont().family(), - "font_size": self.font_size_spin.value(), - "font_color": self.overlay.font_color, - "bg_color": self.overlay.bg_color, - "bg_opacity": self.overlay.bg_opacity, - "translator_api_keys": self.api_keys, - "capture_rect": (self.worker.capture_rect.x(), self.worker.capture_rect.y(), - self.worker.capture_rect.width(), self.worker.capture_rect.height()), - "dpi_scale": self.worker.dpi_scale, - "screenshot_engine": self.combo_screenshot.currentText(), - "settings_topmost_hotkey": self.settings_topmost_hotkey, - "temporary_region_hotkey": self.temporary_region_hotkey, - } - try: - with open(SETTINGS_FILE, "wb") as f: - pickle.dump(settings, f) - print("Settings saved.") - except Exception as e: - print(f"Settings save error: {e}") - - def load_settings(self): - if os.path.exists(SETTINGS_FILE): - try: - with open(SETTINGS_FILE, "rb") as f: - s = pickle.load(f) - - # Update UI elements (triggers signals without blocking) - self.combo_ocr.setCurrentText(s.get("ocr_engine", "Tesseract")) - self.combo_translator.setCurrentText(s.get("translator_engine", "Google")) - self.combo_source.setCurrentText(s.get("source_lang", "English")) - self.combo_target.setCurrentText(s.get("target_lang", "Turkish")) - - font_family = s.get("font_family", "Arial") - self.font_picker.setCurrentFont(font_family) - self.overlay.set_font_family(font_family) - - font_size = s.get("font_size", 20) - self.font_size_spin.setValue(font_size) - self.overlay.set_font_size(font_size) - - font_color = s.get("font_color", "white") - self.overlay.set_font_color(font_color) - self.color_sample.setStyleSheet(f"background-color: {font_color}; border: 1px solid gray; border-radius: 4px;") - - bg_color = s.get("bg_color", "#000000") - self.overlay.set_bg_color(bg_color) - self.bg_color_sample.setStyleSheet(f"background-color: {bg_color}; border: 1px solid gray; border-radius: 4px;") - - bg_opacity = s.get("bg_opacity", 180) - self.bg_opacity_spin.setValue(bg_opacity) - self.overlay.set_bg_opacity(bg_opacity) - - # Load DPI scale - dpi_scale = s.get("dpi_scale", DPI_SCALE_DEFAULT) - self.dpi_scale_spin.blockSignals(True) - self.dpi_scale_spin.setValue(dpi_scale * 100) - self.dpi_scale_spin.blockSignals(False) - self.worker.dpi_scale = dpi_scale - self._main_dpi_scale = self.worker.dpi_scale - - rect = s.get("capture_rect") - if rect: - self.worker.set_rect(QRect(rect[0], rect[1], rect[2], rect[3])) - self._main_capture_rect = QRect(self.worker.capture_rect) - self.update_rect_label() - - # Load screenshot engine - screenshot_engine = s.get("screenshot_engine", "Portal") - self.combo_screenshot.setCurrentText(screenshot_engine) - self.worker.set_screenshot_engine(screenshot_engine) - - # Load API keys - self.api_keys = s.get("translator_api_keys", {}) - self._apply_api_key_for_current_engine() - - self._apply_saved_hotkeys( - s.get("settings_topmost_hotkey", SETTINGS_TOPMOST_HOTKEY), - s.get("temporary_region_hotkey", TEMPORARY_REGION_HOTKEY), - ) - - self.update_languages() - print("Settings loaded.") - return - except Exception as e: - print(f"Settings load error: {e}") - - # If file not exists or error occurs, set defaults - self._apply_saved_hotkeys(SETTINGS_TOPMOST_HOTKEY, TEMPORARY_REGION_HOTKEY) - self.update_languages() - - def _apply_saved_hotkeys(self, settings_topmost_hotkey, temporary_region_hotkey): - self._set_settings_topmost_hotkey(settings_topmost_hotkey, save=False, show_error=False) - self._set_temporary_region_hotkey(temporary_region_hotkey, save=False, show_error=False) - self._sync_hotkey_buttons() - - def _sync_hotkey_buttons(self): - if hasattr(self, "settings_topmost_hotkey_button"): - self.settings_topmost_hotkey_button.set_hotkey(self.settings_topmost_hotkey) - if hasattr(self, "temporary_region_hotkey_button"): - self.temporary_region_hotkey_button.set_hotkey(self.temporary_region_hotkey) - - def _set_settings_topmost_hotkey(self, hotkey, save=True, show_error=True): - if self.overlay.set_settings_topmost_hotkey(hotkey): - self.settings_topmost_hotkey = hotkey - self._sync_hotkey_buttons() - if save: - self.save_settings() - return True - - if show_error: - QMessageBox.warning(self, _("Invalid shortcut"), _("Could not register this shortcut. The previous shortcut is still active.")) - self._sync_hotkey_buttons() - return False - - def _set_temporary_region_hotkey(self, hotkey, save=True, show_error=True): - if hotkey == self.temporary_region_hotkey: - self._sync_hotkey_buttons() - return True - - candidate = GlobalHotkey(hotkey, self._emit_temporary_region_hotkey_pressed) - if candidate.start(): - self._temporary_region_hotkey.stop() - self._temporary_region_hotkey = candidate - self.temporary_region_hotkey = hotkey - self._sync_hotkey_buttons() - if save: - self.save_settings() - return True - - if show_error: - QMessageBox.warning(self, _("Invalid shortcut"), _("Could not register this shortcut. The previous shortcut is still active.")) - self._sync_hotkey_buttons() - return False - - def on_settings_topmost_hotkey_changed(self, hotkey): - self._set_settings_topmost_hotkey(hotkey) - - def on_temporary_region_hotkey_changed(self, hotkey): - self._set_temporary_region_hotkey(hotkey) - - def reset_settings_topmost_hotkey(self): - self._set_settings_topmost_hotkey(SETTINGS_TOPMOST_HOTKEY) - - def reset_temporary_region_hotkey(self): - self._set_temporary_region_hotkey(TEMPORARY_REGION_HOTKEY) - - def update_system_status(self, is_running): - if is_running: - self.system_indicator.setStyleSheet( - "background-color: #2E7D32; border-radius: 8px;" - ) - self.system_status_label.setText("Running") - else: - self.system_indicator.setStyleSheet( - "background-color: #C62828; border-radius: 8px;" - ) - self.system_status_label.setText("Stopped") - self.overlay.set_mode(False) - self.perf_bar.setValue(0) - self.perf_bar.setStyleSheet("") - - def update_rect_label(self): - r = self.worker.capture_rect - self.rect_label_status.setText( - _("Region: X:{x} Y:{y} {w}×{h}").format(x=r.x(), y=r.y(), w=r.width(), h=r.height()) - ) - - def update_performance_bar(self, duration): - # Inverse logic: 0.5s -> %100 (Best), 3.0s -> %0 (Worst) - if not self.worker.isRunning(): - return - percent = int(100 - (((duration - 0.5) / 2.5) * 100)) - percent = max(0, min(100, percent)) - - self.perf_bar.setValue(percent) - - # Color determination based on percentage (Health bar logic) - if percent >= 75: - color = "#2E7D32" # Green (Great) - elif percent >= 35: - color = "#F9A825" # Yellow/Orange (Normal) - else: - color = "#C62828" # Red (Poor) - - self.perf_bar.setStyleSheet(f""" - QProgressBar::chunk {{ - background-color: {color}; - border-radius: 2px; - }} - QProgressBar {{ - border: 1px solid gray; - border-radius: 3px; - background: #E0E0E0; - }} - """) - - def update_languages(self): - source_code = get_language_code(self.combo_source.currentText(), "en") - target_code = get_language_code(self.combo_target.currentText(), "tr") - self.worker.set_languages(source_code, target_code) - self.save_settings() - - def choose_color(self): - color = QColorDialog.getColor() - if color.isValid(): - color_name = color.name() - self.overlay.set_font_color(color_name) - self.color_sample.setStyleSheet(f"background-color: {color_name}; border: 1px solid gray; border-radius: 4px;") - self.save_settings() - - def choose_bg_color(self): - color = QColorDialog.getColor() - if color.isValid(): - color_name = color.name() - self.overlay.set_bg_color(color_name) - self.bg_color_sample.setStyleSheet(f"background-color: {color_name}; border: 1px solid gray; border-radius: 4px;") - self.save_settings() - - def _emit_temporary_region_hotkey_pressed(self): - self.temporary_region_hotkey_pressed.emit() - - @Slot() - def _handle_temporary_region_hotkey_pressed(self): - if not self.worker.isRunning() or self._temporary_region_selecting or not self._temporary_region_hotkey_armed: - return - - self._temporary_region_hotkey_armed = False - QTimer.singleShot(200, self._rearm_temporary_region_hotkey) - self.select_temporary_region() - - def _rearm_temporary_region_hotkey(self): - self._temporary_region_hotkey_armed = True - - def _get_selected_region_and_dpi(self): - QApplication.processEvents() - time.sleep(0.2) - - sniper = SniperFactory.get_engine() - rect = sniper.get_region() - if not rect: - return None, None - - detected_dpi = getattr(sniper, "detected_dpi", None) - return rect, detected_dpi - - def _apply_main_region(self, rect, dpi_scale=None): - if not self.dpi_locked and dpi_scale is not None: - self.dpi_scale_spin.blockSignals(True) - self.dpi_scale_spin.setValue(dpi_scale * 100) - self.dpi_scale_spin.blockSignals(False) - self.worker.dpi_scale = dpi_scale - - self.worker.set_rect(rect) - self._main_capture_rect = QRect(self.worker.capture_rect) - self._main_dpi_scale = self.worker.dpi_scale - self.overlay.label.setText(f"Region: {rect.x()},{rect.y()} {rect.width()}x{rect.height()}") - self.update_rect_label() - self.save_settings() - - def _apply_temporary_region(self, rect, dpi_scale=None): - if not self._temporary_region_active: - self._main_capture_rect = QRect(self.worker.capture_rect) - self._main_dpi_scale = self.worker.dpi_scale - - if not self.dpi_locked and dpi_scale is not None: - self.worker.dpi_scale = dpi_scale - - self.worker.set_rect(rect) - self._temporary_region_active = True - self._temporary_region_restore_timer.start(10_000) - self.overlay.label.setText(f"Temporary Region: {rect.x()},{rect.y()} {rect.width()}x{rect.height()} (10s)") - self.update_rect_label() - - def _restore_main_region(self): - if not self._temporary_region_active: - return - - self.worker.dpi_scale = self._main_dpi_scale - self.worker.set_rect(self._main_capture_rect) - self._temporary_region_active = False - self.overlay.label.setText( - f"Region: {self._main_capture_rect.x()},{self._main_capture_rect.y()} " - f"{self._main_capture_rect.width()}x{self._main_capture_rect.height()}" - ) - self.update_rect_label() - - def select_temporary_region(self): - self._temporary_region_selecting = True - try: - rect, detected_dpi = self._get_selected_region_and_dpi() - finally: - self._temporary_region_selecting = False - - if rect: - self._apply_temporary_region(rect, detected_dpi) - - def select_region(self): - rect, detected_dpi = self._get_selected_region_and_dpi() - if rect: - self._temporary_region_restore_timer.stop() - self._temporary_region_active = False - self._apply_main_region(rect, detected_dpi) - - def start(self): - self.overlay.set_mode(True) - if not self.worker.isRunning(): - self.worker.start() - - def stop(self): - self.worker.stop() - self.overlay.set_mode(False) - self.perf_bar.setValue(0) - self.perf_bar.setStyleSheet("") - - def closeEvent(self, event): - self._temporary_region_restore_timer.stop() - self._temporary_region_hotkey.stop() - self.worker.stop() - self.overlay.close() - super().closeEvent(event) - - # ---------- API Key Helpers ---------- - def _apply_api_key_for_current_engine(self): - """Update the API key textbox state and value for the current engine.""" - engine = self.combo_translator.currentText() - key = self.api_keys.get(engine, "") - # Block signals to avoid triggering on_api_key_changed during load - self.api_key_input.blockSignals(True) - self.api_key_input.setText(key) - self.api_key_input.blockSignals(False) - # Push to worker so the engine uses the saved key - if key: - self.worker.set_api_key(engine, key) - # Toggle editable state based on engine (always visible, fixed window size) - self._update_api_key_state(engine) - - def _update_api_key_state(self, engine: str): - """Enable/disable the API key field based on engine, keep it always visible.""" - engines_needing_key = {"DeepL"} - if engine in engines_needing_key: - self.api_key_input.setReadOnly(False) - self.api_key_input.setEnabled(True) - self.api_key_input.setToolTip("") - else: - self.api_key_input.setReadOnly(True) - self.api_key_input.setEnabled(False) - self.api_key_input.setToolTip(_("This engine does not require an API key")) - - def on_translator_changed(self, engine: str): - """Called when the translation engine combo box changes.""" - self._apply_api_key_for_current_engine() - - def on_api_key_changed(self, text: str): - """Called when the user types in the API key text box.""" - engine = self.combo_translator.currentText() - self.api_keys[engine] = text - self.worker.set_api_key(engine, text) - self.save_settings() - - # ---------- Preset Management ---------- - def _load_presets_dict(self): - """Load all presets from pickle file, returns dict.""" - if os.path.exists(PRESETS_FILE): - try: - with open(PRESETS_FILE, "rb") as f: - return pickle.load(f) - except Exception as e: - print(f"Presets load error: {e}") - return {} - - def _save_presets_dict(self, presets): - """Save presets dict to pickle file.""" - try: - with open(PRESETS_FILE, "wb") as f: - pickle.dump(presets, f) - except Exception as e: - print(f"Presets save error: {e}") - - def refresh_preset_list(self): - """Refresh the combo box with preset names.""" - self.preset_combo.clear() - presets = self._load_presets_dict() - for name in sorted(presets.keys()): - self.preset_combo.addItem(name) - - def save_preset(self): - """Save current overlay position/size + capture rect + appearance to a named preset.""" - name = self.preset_name_input.text().strip() - if not name: - QMessageBox.warning(self, "No Name", "Please enter a preset name.") - return - - preset = { - "capture_rect": ( - self.worker.capture_rect.x(), - self.worker.capture_rect.y(), - self.worker.capture_rect.width(), - self.worker.capture_rect.height(), - ), - "overlay_x": self.overlay.pos().x(), - "overlay_y": self.overlay.pos().y(), - "overlay_w": self.overlay.width(), - "overlay_h": self.overlay.height(), - "font_family": self.overlay.font_family, - "font_size": self.overlay.font_size, - "font_color": self.overlay.font_color, - "bg_color": self.overlay.bg_color, - "bg_opacity": self.overlay.bg_opacity, - } - - presets = self._load_presets_dict() - presets[name] = preset - self._save_presets_dict(presets) - self.refresh_preset_list() - self.preset_name_input.clear() - print(f"Preset '{name}' saved.") - - def load_preset(self): - """Load the selected preset from the combo box and apply it.""" - name = self.preset_combo.currentText() - if not name: - QMessageBox.information(self, _("No Selection"), _("Please select a preset to load.")) - return - presets = self._load_presets_dict() - preset = presets.get(name) - if preset is None: - QMessageBox.warning(self, "Not Found", f"Preset '{name}' not found.") - return - - # Apply capture rect - rect_data = preset.get("capture_rect") - if rect_data: - self.worker.set_rect(QRect(rect_data[0], rect_data[1], rect_data[2], rect_data[3])) - self.update_rect_label() - - # Apply overlay position and size - ox = preset.get("overlay_x") - oy = preset.get("overlay_y") - ow = preset.get("overlay_w") - oh = preset.get("overlay_h") - if ox is not None and oy is not None: - self.overlay.move(ox, oy) - if ow is not None and oh is not None: - self.overlay.resize(ow, oh) - - # Apply appearance - ff = preset.get("font_family") - if ff: - self.overlay.set_font_family(ff) - self.font_picker.setCurrentFont(ff) - - fs = preset.get("font_size") - if fs: - self.overlay.set_font_size(fs) - self.font_size_spin.setValue(fs) - - fc = preset.get("font_color") - if fc: - self.overlay.set_font_color(fc) - self.color_sample.setStyleSheet( - f"background-color: {fc}; border: 1px solid gray; border-radius: 4px;" - ) - - bgc = preset.get("bg_color") - if bgc: - self.overlay.set_bg_color(bgc) - self.bg_color_sample.setStyleSheet( - f"background-color: {bgc}; border: 1px solid gray; border-radius: 4px;" - ) - - bgo = preset.get("bg_opacity") - if bgo is not None: - self.overlay.set_bg_opacity(bgo) - self.bg_opacity_spin.setValue(bgo) - - print(f"Preset '{name}' loaded.") - - def delete_preset(self): - """Delete the selected preset.""" - name = self.preset_combo.currentText() - if not name: - QMessageBox.information(self, "No Selection", "Please select a preset to delete.") - return - presets = self._load_presets_dict() - if name in presets: - del presets[name] - self._save_presets_dict(presets) - self.refresh_preset_list() - print(f"Preset '{name}' deleted.") - else: - QMessageBox.warning(self, "Not Found", f"Preset '{name}' not found.") diff --git a/src/ui/tabs/__init__.py b/src/ui/tabs/__init__.py deleted file mode 100644 index aad483c..0000000 --- a/src/ui/tabs/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Control panel tab builders.""" - -from src.ui.tabs.about_tab import build_about_tab -from src.ui.tabs.appearance_tab import build_appearance_tab -from src.ui.tabs.ocr_tab import build_ocr_tab -from src.ui.tabs.save_tab import build_save_tab -from src.ui.tabs.hotkey_tab import build_hotkey_tab -from src.ui.tabs.status_tab import build_status_tab -from src.ui.tabs.translation_tab import build_translation_tab - -__all__ = [ - "build_about_tab", - "build_appearance_tab", - "build_ocr_tab", - "build_save_tab", - "build_hotkey_tab", - "build_status_tab", - "build_translation_tab", -] diff --git a/src/ui/tabs/about_tab.py b/src/ui/tabs/about_tab.py deleted file mode 100644 index 750661a..0000000 --- a/src/ui/tabs/about_tab.py +++ /dev/null @@ -1,19 +0,0 @@ -from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget - -from src.config import APP_VERSION -from src.i18n import _ - - -def build_about_tab(panel): - tab_about = QWidget() - tab_about_layout = QVBoxLayout(tab_about) - about_title = QLabel(_("Universal Screen Translator Application")) - about_title.setStyleSheet("font-size: 14px;") - tab_about_layout.addWidget(about_title) - tab_about_layout.addWidget(QLabel(_("Version: {version}").format(version=APP_VERSION))) - tab_about_layout.addWidget(QLabel("")) - tab_about_layout.addWidget(QLabel(_("A real-time OCR-based translation tool."))) - tab_about_layout.addWidget(QLabel(_("Select a screen region, capture text via OCR,"))) - tab_about_layout.addWidget(QLabel(_("and translate instantly with Google or DeepL."))) - tab_about_layout.addStretch() - return tab_about diff --git a/src/ui/tabs/appearance_tab.py b/src/ui/tabs/appearance_tab.py deleted file mode 100644 index ff67549..0000000 --- a/src/ui/tabs/appearance_tab.py +++ /dev/null @@ -1,57 +0,0 @@ -from PySide6.QtWidgets import QFontComboBox, QHBoxLayout, QLabel, QPushButton, QSpinBox, QVBoxLayout, QWidget - -from src.i18n import _ - - -def build_appearance_tab(panel): - tab_appearance = QWidget() - tab_appearance_layout = QVBoxLayout(tab_appearance) - - h_font = QHBoxLayout() - v_font_family = QVBoxLayout() - v_font_family.addWidget(QLabel(_("Font Family:"))) - panel.font_picker = QFontComboBox() - panel.font_picker.currentFontChanged.connect(panel.on_font_changed) - v_font_family.addWidget(panel.font_picker) - h_font.addLayout(v_font_family) - - v_font_size = QVBoxLayout() - v_font_size.addWidget(QLabel(_("Size:"))) - panel.font_size_spin = QSpinBox() - panel.font_size_spin.setRange(8, 72) - panel.font_size_spin.setValue(panel.overlay.font_size) - panel.font_size_spin.valueChanged.connect(panel.on_font_size_changed) - v_font_size.addWidget(panel.font_size_spin) - h_font.addLayout(v_font_size) - tab_appearance_layout.addLayout(h_font) - - h_colors = QHBoxLayout() - panel.btn_color = QPushButton(_("🎨 Text Color")) - panel.btn_color.clicked.connect(panel.choose_color) - h_colors.addWidget(panel.btn_color) - - panel.color_sample = QLabel() - panel.color_sample.setFixedSize(24, 24) - panel.color_sample.setStyleSheet(f"background-color: {panel.overlay.font_color}; border: 1px solid gray; border-radius: 4px;") - h_colors.addWidget(panel.color_sample) - - panel.btn_bg_color = QPushButton(_("🎨 Background")) - panel.btn_bg_color.clicked.connect(panel.choose_bg_color) - h_colors.addWidget(panel.btn_bg_color) - - panel.bg_color_sample = QLabel() - panel.bg_color_sample.setFixedSize(24, 24) - panel.bg_color_sample.setStyleSheet(f"background-color: {panel.overlay.bg_color}; border: 1px solid gray; border-radius: 4px;") - h_colors.addWidget(panel.bg_color_sample) - tab_appearance_layout.addLayout(h_colors) - - h_bg_opacity = QHBoxLayout() - h_bg_opacity.addWidget(QLabel(_("Background Opacity:"))) - panel.bg_opacity_spin = QSpinBox() - panel.bg_opacity_spin.setRange(0, 255) - panel.bg_opacity_spin.setValue(panel.overlay.bg_opacity) - panel.bg_opacity_spin.valueChanged.connect(panel.on_bg_opacity_changed) - h_bg_opacity.addWidget(panel.bg_opacity_spin) - tab_appearance_layout.addLayout(h_bg_opacity) - tab_appearance_layout.addStretch() - return tab_appearance diff --git a/src/ui/tabs/ocr_tab.py b/src/ui/tabs/ocr_tab.py deleted file mode 100644 index 04e5df8..0000000 --- a/src/ui/tabs/ocr_tab.py +++ /dev/null @@ -1,50 +0,0 @@ -from PySide6.QtCore import Qt -from PySide6.QtWidgets import QComboBox, QDoubleSpinBox, QHBoxLayout, QLabel, QVBoxLayout, QWidget - -from src.config import DPI_SCALE_DEFAULT, OCR_ENGINES, SCREENSHOT_ENGINES -from src.i18n import _ - - -def build_ocr_tab(panel): - tab_ocr = QWidget() - tab_ocr_layout = QVBoxLayout(tab_ocr) - tab_ocr_layout.addWidget(QLabel(_("OCR Engine:"))) - panel.combo_ocr = QComboBox() - panel.combo_ocr.setFocusPolicy(Qt.NoFocus) - panel.combo_ocr.addItems(OCR_ENGINES) - panel.combo_ocr.currentTextChanged.connect(panel.worker.set_engine) - panel.combo_ocr.currentTextChanged.connect(panel.save_settings) - tab_ocr_layout.addWidget(panel.combo_ocr) - - tab_ocr_layout.addWidget(QLabel(_("Screen Engine:"))) - panel.combo_screenshot = QComboBox() - panel.combo_screenshot.setFocusPolicy(Qt.NoFocus) - panel.combo_screenshot.addItems(SCREENSHOT_ENGINES) - panel.combo_screenshot.setCurrentText("Portal") - panel.combo_screenshot.currentTextChanged.connect(panel.worker.set_screenshot_engine) - panel.combo_screenshot.currentTextChanged.connect(panel.save_settings) - tab_ocr_layout.addWidget(panel.combo_screenshot) - - panel.dpi_locked = False - h_dpi = QHBoxLayout() - h_dpi.addWidget(QLabel(_("DPI Scale:"))) - panel.dpi_lock_label = QLabel("🔓") - panel.dpi_lock_label.setToolTip(_("DPI unlocked (click to lock)")) - panel.dpi_lock_label.setCursor(Qt.PointingHandCursor) - panel.dpi_lock_label.mousePressEvent = panel._toggle_dpi_lock - panel.dpi_lock_label.setStyleSheet("font-size: 16px; padding: 0px;") - panel.dpi_lock_label.setFixedWidth(24) - h_dpi.addWidget(panel.dpi_lock_label) - panel.dpi_scale_spin = QDoubleSpinBox() - panel.dpi_scale_spin.setRange(50, 300) - panel.dpi_scale_spin.setSingleStep(25) - panel.dpi_scale_spin.setDecimals(0) - panel.dpi_scale_spin.setSuffix(" %") - panel.dpi_scale_spin.setValue(DPI_SCALE_DEFAULT * 100) - panel.dpi_scale_spin.setToolTip(_("Scale factor for high-DPI monitors.\nAuto-detected when selecting a region.")) - panel.dpi_scale_spin.valueChanged.connect(panel.on_dpi_scale_changed) - h_dpi.addWidget(panel.dpi_scale_spin) - tab_ocr_layout.addLayout(h_dpi) - - tab_ocr_layout.addStretch() - return tab_ocr diff --git a/src/ui/tabs/save_tab.py b/src/ui/tabs/save_tab.py deleted file mode 100644 index 7134762..0000000 --- a/src/ui/tabs/save_tab.py +++ /dev/null @@ -1,37 +0,0 @@ -from PySide6.QtCore import Qt -from PySide6.QtWidgets import QComboBox, QHBoxLayout, QLabel, QLineEdit, QPushButton, QVBoxLayout, QWidget - -from src.i18n import _ - - -def build_save_tab(panel): - tab_save = QWidget() - tab_save_layout = QVBoxLayout(tab_save) - - h_name = QHBoxLayout() - h_name.addWidget(QLabel(_("Preset Name:"))) - panel.preset_name_input = QLineEdit() - panel.preset_name_input.setPlaceholderText(_("Enter preset name...")) - h_name.addWidget(panel.preset_name_input) - tab_save_layout.addLayout(h_name) - - h_buttons = QHBoxLayout() - panel.btn_save_preset = QPushButton(_("💾 Save")) - panel.btn_save_preset.clicked.connect(panel.save_preset) - h_buttons.addWidget(panel.btn_save_preset) - - panel.btn_load_preset = QPushButton(_("📂 Load")) - panel.btn_load_preset.clicked.connect(panel.load_preset) - h_buttons.addWidget(panel.btn_load_preset) - - panel.btn_delete_preset = QPushButton(_("🗑 Delete")) - panel.btn_delete_preset.clicked.connect(panel.delete_preset) - h_buttons.addWidget(panel.btn_delete_preset) - tab_save_layout.addLayout(h_buttons) - - tab_save_layout.addWidget(QLabel(_("Saved Presets:"))) - panel.preset_combo = QComboBox() - panel.preset_combo.setFocusPolicy(Qt.NoFocus) - tab_save_layout.addWidget(panel.preset_combo) - tab_save_layout.addStretch() - return tab_save diff --git a/src/ui/tabs/status_tab.py b/src/ui/tabs/status_tab.py deleted file mode 100644 index b396178..0000000 --- a/src/ui/tabs/status_tab.py +++ /dev/null @@ -1,31 +0,0 @@ -from PySide6.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget - -from src.i18n import _ - - -def build_status_tab(panel): - tab_status = QWidget() - tab_status_layout = QVBoxLayout(tab_status) - tab_status_layout.setSpacing(6) - tab_status_layout.setContentsMargins(8, 8, 8, 8) - - h_system = QHBoxLayout() - h_system.setSpacing(6) - panel.system_indicator = QLabel() - panel.system_indicator.setFixedSize(16, 16) - panel.system_indicator.setStyleSheet( - "background-color: #C62828; border-radius: 8px;" - ) - h_system.addWidget(panel.system_indicator) - panel.system_status_label = QLabel("Stopped") - h_system.addWidget(panel.system_status_label) - h_system.addStretch() - tab_status_layout.addLayout(h_system) - - tab_status_layout.addStretch() - - panel.rect_label_status = QLabel(_("Region: —")) - panel.rect_label_status.setStyleSheet("font-family: monospace;") - tab_status_layout.addWidget(panel.rect_label_status) - - return tab_status diff --git a/src/ui/tabs/translation_tab.py b/src/ui/tabs/translation_tab.py deleted file mode 100644 index 2f28221..0000000 --- a/src/ui/tabs/translation_tab.py +++ /dev/null @@ -1,52 +0,0 @@ -from PySide6.QtCore import Qt -from PySide6.QtWidgets import QComboBox, QHBoxLayout, QLabel, QLineEdit, QVBoxLayout, QWidget - -from src.config import LANGUAGES, TRANSLATION_ENGINES -from src.i18n import _ - - -def build_translation_tab(panel): - tab_translation = QWidget() - tab_translation_layout = QVBoxLayout(tab_translation) - tab_translation_layout.addWidget(QLabel(_("Translation Engine:"))) - panel.combo_translator = QComboBox() - panel.combo_translator.setFocusPolicy(Qt.NoFocus) - panel.combo_translator.addItems(TRANSLATION_ENGINES) - panel.combo_translator.currentTextChanged.connect(panel.worker.set_translator) - panel.combo_translator.currentTextChanged.connect(panel.save_settings) - panel.combo_translator.currentTextChanged.connect(panel.on_translator_changed) - tab_translation_layout.addWidget(panel.combo_translator) - - h_lang = QHBoxLayout() - v_source = QVBoxLayout() - v_source.addWidget(QLabel(_("Source Language:"))) - panel.combo_source = QComboBox() - panel.combo_source.setFocusPolicy(Qt.NoFocus) - panel.combo_source.addItems(list(LANGUAGES.keys())) - panel.combo_source.setCurrentText("English") - panel.combo_source.currentTextChanged.connect(panel.update_languages) - v_source.addWidget(panel.combo_source) - h_lang.addLayout(v_source) - - v_target = QVBoxLayout() - v_target.addWidget(QLabel(_("Target Language:"))) - panel.combo_target = QComboBox() - panel.combo_target.setFocusPolicy(Qt.NoFocus) - panel.combo_target.addItems(list(LANGUAGES.keys())) - panel.combo_target.setCurrentText("Turkish") - panel.combo_target.currentTextChanged.connect(panel.update_languages) - v_target.addWidget(panel.combo_target) - h_lang.addLayout(v_target) - tab_translation_layout.addLayout(h_lang) - - panel.api_key_label = QLabel(_("API Key")) - tab_translation_layout.addWidget(panel.api_key_label) - panel.api_key_input = QLineEdit() - panel.api_key_input.setEchoMode(QLineEdit.Password) - panel.api_key_input.setPlaceholderText(_("Enter API key...")) - panel.api_key_input.textChanged.connect(panel.on_api_key_changed) - tab_translation_layout.addWidget(panel.api_key_input) - panel.api_keys = {} - - tab_translation_layout.addStretch() - return tab_translation diff --git a/src/__init__.py b/src/usta/__init__.py similarity index 100% rename from src/__init__.py rename to src/usta/__init__.py diff --git a/src/usta/__main__.py b/src/usta/__main__.py new file mode 100644 index 0000000..c1d643a --- /dev/null +++ b/src/usta/__main__.py @@ -0,0 +1,4 @@ +from usta.main import main + +if __name__ == "__main__": + main() diff --git a/src/usta/_version.py b/src/usta/_version.py new file mode 100644 index 0000000..74acd0e --- /dev/null +++ b/src/usta/_version.py @@ -0,0 +1 @@ +__version__ = "0.1.12" diff --git a/src/core/__init__.py b/src/usta/application/__init__.py similarity index 100% rename from src/core/__init__.py rename to src/usta/application/__init__.py diff --git a/src/usta/application/controller.py b/src/usta/application/controller.py new file mode 100644 index 0000000..314cf49 --- /dev/null +++ b/src/usta/application/controller.py @@ -0,0 +1,48 @@ +"""Application actions consumed by views; the worker owns mutable engines.""" + + +class ApplicationController: + def __init__(self, worker): + self.worker = worker + + @property + def capture_rect(self): + return self.worker.capture_rect + + @property + def dpi_scale(self): + return self.worker.dpi_scale + + @dpi_scale.setter + def dpi_scale(self, value): + self.worker.dpi_scale = value + + def set_rect(self, rect, dpi_scale=None): + if dpi_scale is None: + self.worker.set_rect(rect) + else: + self.worker.set_rect(rect, dpi_scale) + + def set_engine(self, name): + self.worker.set_engine(name) + + def set_translator(self, name): + self.worker.set_translator(name) + + def set_screenshot_engine(self, name): + self.worker.set_screenshot_engine(name) + + def set_languages(self, source, target): + self.worker.set_languages(source, target) + + def set_api_key(self, engine, key): + self.worker.set_api_key(engine, key) + + def is_running(self): + return self.worker.isRunning() + + def start(self): + self.worker.start() + + def stop(self): + self.worker.stop() diff --git a/src/usta/application/hotkey_controller.py b/src/usta/application/hotkey_controller.py new file mode 100644 index 0000000..5043a91 --- /dev/null +++ b/src/usta/application/hotkey_controller.py @@ -0,0 +1,25 @@ +"""Replace hotkey listeners only after the candidate registered successfully.""" + +from usta.core.shortcut import GlobalHotkey + + +class HotkeyController: + def __init__(self, initial, callback): + self.hotkey = initial + self.callback = callback + self.listener = GlobalHotkey(initial, callback) + self.listener.start() + + def set_hotkey(self, hotkey): + if hotkey == self.hotkey: + return True + candidate = GlobalHotkey(hotkey, self.callback) + if not candidate.start(): + return False + self.listener.stop() + self.listener = candidate + self.hotkey = hotkey + return True + + def close(self): + self.listener.stop() diff --git a/src/usta/application/pipeline.py b/src/usta/application/pipeline.py new file mode 100644 index 0000000..936fd5d --- /dev/null +++ b/src/usta/application/pipeline.py @@ -0,0 +1,215 @@ +"""Single-owner capture loop with immutable translation jobs.""" + +import logging +import threading +import time +from concurrent.futures import ThreadPoolExecutor +from copy import deepcopy + +from usta.application.results import PerformanceTimings, TranslationRequest +from usta.application.settings import AppSettings +from usta.config import get_language_code +from usta.core.exceptions import PortalCanceledError, ScreenCaptureError +from usta.core.ocr.ocr_manager import OCRManager +from usta.core.translation.translator_manager import TranslatorManager + +logger = logging.getLogger(__name__) + + +class Pipeline: + LOOP_SLEEP_SECONDS = 0.2 + + def __init__( + self, + *, + capture_factory, + publisher, + image_path, + on_translation, + on_error, + ocr_factory=OCRManager, + translator_factory=TranslatorManager, + rect_factory=tuple, + preprocess=None, + on_performance=lambda value: None, + on_timings=lambda value: None, + on_translating=lambda value: None, + ): + self.capture_factory = capture_factory + self.publisher = publisher + self.image_path = image_path + self.on_translation = on_translation + self.on_error = on_error + self.ocr_factory = ocr_factory + self.translator_factory = translator_factory + self.rect_factory = rect_factory + self.preprocess = preprocess + self.on_performance = on_performance + self.on_timings = on_timings + self.on_translating = on_translating + self._settings = AppSettings() + self._lock = threading.RLock() + self._stopped = threading.Event() + self._stopped.set() + self._session = 0 + self._executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="usta-translation") + self._future = None + self._job = None + self._job_started = None + self._job_ocr_seconds = None + + @property + def session_id(self): + with self._lock: + return self._session + + @property + def is_stopped(self): + return self._stopped.is_set() + + def is_current(self, session): + with self._lock: + return not self._stopped.is_set() and self._session == session + + @property + def settings(self): + with self._lock: + return deepcopy(self._settings) + + def configure(self, settings: AppSettings) -> None: + with self._lock: + self._settings = deepcopy(settings) + self._session += 1 + + def start(self) -> None: + with self._lock: + self._session += 1 + self._stopped.clear() + + def stop(self) -> None: + with self._lock: + self._session += 1 + self._stopped.set() + + def close(self) -> None: + self.stop() + self._executor.shutdown(wait=False, cancel_futures=True) + + def _translate(self, request, keys): + # This manager is used only by the translation owner thread, never by UI. + translation_started = time.perf_counter() + translator = self.translator_factory() + for name, key in keys.items(): + translator.set_api_key(name, key) + result = translator.translate_request(request) + completed = time.perf_counter() + return result, completed, completed - translation_started + + def _collect(self): + if self._future is None or not self._future.done(): + return + future, job, started = self._future, self._job, self._job_started + ocr_seconds = self._job_ocr_seconds + self._future = self._job = self._job_started = self._job_ocr_seconds = None + with self._lock: + valid = not self._stopped.is_set() and job.session_id == self._session + if not valid: + return + self.on_translating(False) + try: + result, completed, translation_seconds = future.result() + except Exception as exc: + self.on_error(str(exc)) + return + self.on_translation(result.translated_text) + self.publisher.broadcast( + original=job.text, + translated=result.translated_text, + source_lang=job.source_lang, + target_lang=job.target_lang, + engine=job.engine_name, + ) + self.on_timings( + PerformanceTimings(ocr_seconds, translation_seconds, completed - started) + ) + self.on_performance(completed - started) + + def run(self) -> None: + capture = None + applied = -1 + capture_name = None + last_text = "" + ocr = self.ocr_factory() + try: + self.publisher.start() + while not self._stopped.is_set(): + try: + with self._lock: + settings, session = deepcopy(self._settings), self._session + if session != applied: + if capture is None or capture_name != settings.screenshot_engine: + if capture is not None: + capture.close() + capture = self.capture_factory(settings.screenshot_engine) + capture_name = settings.screenshot_engine + ocr.set_engine(settings.ocr_engine) + ocr.set_language( + get_language_code(settings.source_lang, settings.source_lang) + ) + applied = session + last_text = "" + self._collect() + if self._stopped.is_set(): + break + started = time.perf_counter() + if capture.capture( + self.rect_factory(settings.capture_rect), + self.image_path, + settings.dpi_scale, + ): + ocr_started = time.perf_counter() + if settings.ocr_engine == "Tesseract" and self.preprocess: + self.preprocess(self.image_path, self.image_path) + text = ocr.read_result(self.image_path).text + ocr_seconds = time.perf_counter() - ocr_started + with self._lock: + if ( + session == self._session + and not self._stopped.is_set() + and len(text) > 2 + and text != last_text + and self._future is None + ): + request = TranslationRequest( + text, + get_language_code(settings.source_lang, settings.source_lang), + get_language_code(settings.target_lang, settings.target_lang), + settings.translator_engine, + session, + ) + self._job = request + self._job_started = started + self._job_ocr_seconds = ocr_seconds + self._future = self._executor.submit( + self._translate, request, settings.translator_api_keys + ) + last_text = text + self.on_translating(True) + except ScreenCaptureError as exc: + self.stop() + self.on_error(str(exc)) + except PortalCanceledError: + self.stop() + except Exception as exc: + logger.exception("Capture/OCR pipeline failed") + self.on_error(str(exc)) + self._stopped.wait(self.LOOP_SLEEP_SECONDS) + except Exception as exc: + self.on_error(str(exc)) + finally: + try: + if capture is not None: + capture.close() + finally: + self.publisher.stop() + self.on_translating(False) diff --git a/src/usta/application/region_controller.py b/src/usta/application/region_controller.py new file mode 100644 index 0000000..eb4df97 --- /dev/null +++ b/src/usta/application/region_controller.py @@ -0,0 +1,44 @@ +"""Main/temporary capture-region state and its restoration timer.""" + +from PySide6.QtCore import QObject, QRect, QTimer, Signal + + +class RegionController(QObject): + changed = Signal(str) + + def __init__(self, controller, parent=None): + super().__init__(parent) + self.controller = controller + self.main_capture_rect = QRect(controller.capture_rect) + self.main_dpi_scale = controller.dpi_scale + self.temporary_active = False + self.restore_timer = QTimer(self) + self.restore_timer.setSingleShot(True) + self.restore_timer.timeout.connect(self.restore_main_region) + + def apply_main_region(self, rect, dpi_scale=None): + self.restore_timer.stop() + self.temporary_active = False + self.controller.set_rect(rect) + if dpi_scale is not None: + self.controller.dpi_scale = dpi_scale + self.main_capture_rect = QRect(rect) + self.main_dpi_scale = self.controller.dpi_scale + self.changed.emit(f"Region: {rect.x()},{rect.y()} {rect.width()}x{rect.height()}") + + def apply_temporary_region(self, rect, dpi_scale=None): + if not self.temporary_active: + self.main_capture_rect = QRect(self.controller.capture_rect) + self.main_dpi_scale = self.controller.dpi_scale + self.controller.set_rect(rect) + if dpi_scale is not None: + self.controller.dpi_scale = dpi_scale + self.temporary_active = True + self.restore_timer.start(10_000) + self.changed.emit( + f"Temporary Region: {rect.x()},{rect.y()} {rect.width()}x{rect.height()} (10s)" + ) + + def restore_main_region(self): + if self.temporary_active: + self.apply_main_region(self.main_capture_rect, self.main_dpi_scale) diff --git a/src/usta/application/results.py b/src/usta/application/results.py new file mode 100644 index 0000000..9d420bf --- /dev/null +++ b/src/usta/application/results.py @@ -0,0 +1,41 @@ +"""Values crossing OCR/translation application boundaries.""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class PerformanceTimings: + ocr_seconds: float + translation_seconds: float + total_seconds: float + + +@dataclass(frozen=True) +class OCRResult: + text: str + confidence: float | None = None + + +@dataclass(frozen=True) +class TranslationRequest: + text: str + source_lang: str + target_lang: str + engine_name: str + session_id: int + + +@dataclass(frozen=True) +class TranslationResult: + translated_text: str + source_lang: str + target_lang: str + engine_name: str + session_id: int + + +class EngineError(Exception): + def __init__(self, code: str, message: str): + super().__init__(message) + self.code = code + self.message = message diff --git a/src/usta/application/settings.py b/src/usta/application/settings.py new file mode 100644 index 0000000..449fb31 --- /dev/null +++ b/src/usta/application/settings.py @@ -0,0 +1,76 @@ +"""Persistent values, independent of Qt and filesystem access.""" + +import math +from dataclasses import asdict, dataclass, field, fields +from typing import Any + + +@dataclass +class Preset: + capture_rect: tuple[int, int, int, int] = (560, 750, 800, 140) + overlay_x: int = 0 + overlay_y: int = 0 + overlay_w: int = 400 + overlay_h: int = 150 + font_family: str = "Arial" + font_size: int = 20 + font_color: str = "white" + bg_color: str = "#000000" + bg_opacity: int = 180 + extra: dict[str, Any] = field(default_factory=dict, repr=False) + + @classmethod + def from_dict(cls, data: dict[str, Any]): + if not isinstance(data, dict): + raise ValueError("Settings must be an object") + defaults = cls() + known = {f.name for f in fields(cls)} - {"extra"} + values = {} + for name in known: + value = data.get(name, getattr(defaults, name)) + default = getattr(defaults, name) + if name == "capture_rect": + valid = ( + isinstance(value, (list, tuple)) + and len(value) == 4 + and all(type(v) is int for v in value) + ) + value = tuple(value) if valid and value[2] > 0 and value[3] > 0 else default + elif name == "dpi_scale": + value = ( + float(value) + if type(value) in (int, float) and math.isfinite(value) and value > 0 + else default + ) + elif name == "translator_api_keys": + value = ( + {k: v for k, v in value.items() if isinstance(k, str) and isinstance(v, str)} + if isinstance(value, dict) + else {} + ) + elif type(value) is not type(default): + value = default + if name == "font_size" and value <= 0: + value = default + if name == "bg_opacity" and not 0 <= value <= 255: + value = default + values[name] = value + return cls(**values, extra={k: v for k, v in data.items() if k not in known}) + + def to_dict(self) -> dict[str, Any]: + data = asdict(self) + extra = data.pop("extra") + return {**extra, **data} + + +@dataclass +class AppSettings(Preset): + ocr_engine: str = "RapidOCR" + translator_engine: str = "Google" + source_lang: str = "English" + target_lang: str = "Turkish" + translator_api_keys: dict[str, str] = field(default_factory=dict) + dpi_scale: float = 1.0 + screenshot_engine: str = "Portal" + settings_topmost_hotkey: str = "++m" + temporary_region_hotkey: str = "++r" diff --git a/src/usta/application/worker.py b/src/usta/application/worker.py new file mode 100644 index 0000000..fd6f441 --- /dev/null +++ b/src/usta/application/worker.py @@ -0,0 +1,145 @@ +"""Qt signals and thread lifecycle around the application pipeline.""" + +from dataclasses import replace + +from PySide6.QtCore import QRect, QThread, Signal, Slot + +from usta.application.pipeline import Pipeline +from usta.core.screenshot import ImageProcessor, ScreenshotFactory +from usta.infrastructure.paths import CaptureWorkspace +from usta.infrastructure.socket_publisher import TranslationPublisher + + +class OCRWorker(QThread): + LOOP_SLEEP_SECONDS = Pipeline.LOOP_SLEEP_SECONDS + new_translation = Signal(str) + performance_update = Signal(float) + timings_update = Signal(object) + translation_status = Signal(bool) + running_status = Signal(bool) + error_occurred = Signal(str) + _translation_ready = Signal(object) + _error_ready = Signal(object) + _timings_ready = Signal(object) + + def __init__(self): + super().__init__() + self.workspace = CaptureWorkspace() + self.pipeline = Pipeline( + capture_factory=lambda name: ScreenshotFactory.get_engine( + name, self.workspace.fullscreen_path + ), + publisher=TranslationPublisher(), + image_path=self.workspace.image_path, + on_translation=self._queue_translation, + on_error=self._queue_error, + on_performance=self.performance_update.emit, + on_timings=self._queue_timings, + on_translating=self.translation_status.emit, + rect_factory=lambda values: QRect(*values), + preprocess=ImageProcessor.preprocess_for_tesseract, + ) + self._translation_ready.connect(self._deliver_translation) + self._error_ready.connect(self._deliver_error) + self._timings_ready.connect(self._deliver_timings) + self._restart_requested = False + self.finished.connect(self._restart_if_requested) + + def _queue_translation(self, text): + self._translation_ready.emit((self.pipeline.session_id, text)) + + def _queue_error(self, text): + self._error_ready.emit((self.pipeline.session_id, text)) + + def _queue_timings(self, timings): + self._timings_ready.emit((self.pipeline.session_id, timings)) + + @Slot(object) + def _deliver_timings(self, payload): + session, timings = payload + if self.pipeline.is_current(session): + self.timings_update.emit(timings) + + @Slot(object) + def _deliver_translation(self, payload): + session, text = payload + if self.pipeline.is_current(session): + self.new_translation.emit(text) + + @Slot(object) + def _deliver_error(self, payload): + session, text = payload + # Fatal capture errors belong to the just-stopped session as well. + if session == self.pipeline.session_id: + self.error_occurred.emit(text) + + @property + def capture_rect(self): + return QRect(*self.pipeline.settings.capture_rect) + + @property + def dpi_scale(self): + return self.pipeline.settings.dpi_scale + + @dpi_scale.setter + def dpi_scale(self, value): + self.pipeline.configure(replace(self.pipeline.settings, dpi_scale=value)) + + def set_rect(self, qrect, dpi_scale=None): + settings = self.pipeline.settings + self.pipeline.configure( + replace( + settings, + capture_rect=(qrect.x(), qrect.y(), qrect.width(), qrect.height()), + dpi_scale=settings.dpi_scale if dpi_scale is None else dpi_scale, + ) + ) + + def set_engine(self, name): + self.pipeline.configure(replace(self.pipeline.settings, ocr_engine=name)) + + def set_translator(self, name): + self.pipeline.configure(replace(self.pipeline.settings, translator_engine=name)) + + def set_screenshot_engine(self, name): + self.pipeline.configure(replace(self.pipeline.settings, screenshot_engine=name)) + + def set_languages(self, source, target): + self.pipeline.configure( + replace(self.pipeline.settings, source_lang=source, target_lang=target) + ) + + def set_api_key(self, engine, key): + keys = self.pipeline.settings.translator_api_keys + keys[engine] = key + self.pipeline.configure(replace(self.pipeline.settings, translator_api_keys=keys)) + + def start(self): + if self.isRunning(): + self._restart_requested = self.pipeline.is_stopped + return + self.pipeline.start() + super().start() + + def _restart_if_requested(self): + if self._restart_requested: + self._restart_requested = False + self.start() + + def stop(self): + self._restart_requested = False + self.pipeline.stop() + if not self.isRunning(): + self.running_status.emit(False) + + def close(self): + self.stop() + self.pipeline.close() + self.workspace.close() + + def run(self): + self.running_status.emit(True) + try: + self.pipeline.run() + finally: + self.running_status.emit(False) diff --git a/src/config.py b/src/usta/config.py similarity index 78% rename from src/config.py rename to src/usta/config.py index 7923bbe..7997545 100644 --- a/src/config.py +++ b/src/usta/config.py @@ -1,9 +1,8 @@ import os import tempfile -APP_VERSION = "0.1.7" - -from src.core.platform.capability_resolver import ( +from usta._version import __version__ as APP_VERSION +from usta.core.platform.capability_resolver import ( CONFIG_DIR, LANGUAGES, OCR_ENGINES, @@ -14,7 +13,7 @@ TEMPORARY_REGION_HOTKEY, TRANSLATION_ENGINES, ) -from src.core.platform.system_info import ( +from usta.core.platform.system_info import ( get_compositor, get_desktop_environment, get_os, @@ -45,16 +44,15 @@ def get_translation_language_code(language_code: str, provider: str, direction: return language_code return language_code -if not os.path.exists(CONFIG_DIR): - os.makedirs(CONFIG_DIR, exist_ok=True) SETTINGS_FILE = os.path.join(CONFIG_DIR, "settings.pkl") PRESETS_FILE = os.path.join(CONFIG_DIR, "presets.pkl") # Temp Files TEMP_DIR = tempfile.gettempdir() -DEV_SHM ="/dev/shm" -IMG_PATH = os.path.join(DEV_SHM, "usta_snapshot.jpeg") -FULL_SCREEN_TEMP_PATH = os.path.join(DEV_SHM, "usta_full_snap.jpeg") +DEV_SHM = "/dev/shm" +SCREENSHOT_TEMP_DIR = DEV_SHM if os.path.isdir(DEV_SHM) else TEMP_DIR +IMG_PATH = os.path.join(SCREENSHOT_TEMP_DIR, "usta_snapshot.jpeg") +FULL_SCREEN_TEMP_PATH = os.path.join(SCREENSHOT_TEMP_DIR, "usta_full_snap.jpeg") SOCKET_PATH = os.path.join(TEMP_DIR, "usta.sock") SLURP_TEMP_PATH = os.path.join(TEMP_DIR, "slurp_final.txt") diff --git a/src/usta/core/__init__.py b/src/usta/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/usta/core/exceptions.py b/src/usta/core/exceptions.py new file mode 100644 index 0000000..7ef9c86 --- /dev/null +++ b/src/usta/core/exceptions.py @@ -0,0 +1,12 @@ +class PortalCanceledError(RuntimeError): + """Raised when the user cancels or rejects the XDG Desktop Portal screen selection dialog.""" + + pass + + +class ScreenCaptureError(RuntimeError): + """Capture cannot continue until the user retries or fixes the underlying problem.""" + + +class ScreenCapturePermissionError(ScreenCaptureError): + """The current application process does not have screen-recording access.""" diff --git a/src/core/ocr/__init__.py b/src/usta/core/ocr/__init__.py similarity index 100% rename from src/core/ocr/__init__.py rename to src/usta/core/ocr/__init__.py diff --git a/src/usta/core/ocr/apple_vision_engine.py b/src/usta/core/ocr/apple_vision_engine.py new file mode 100644 index 0000000..bf6b1d1 --- /dev/null +++ b/src/usta/core/ocr/apple_vision_engine.py @@ -0,0 +1,90 @@ +"""On-device text recognition using macOS Vision through PyObjC.""" + +import logging +from pathlib import Path + +from usta.core.ocr.base_ocr import BaseOCREngine + +logger = logging.getLogger(__name__) + + +class AppleVisionEngine(BaseOCREngine): + def __init__(self): + self.language = "en" + self._unavailable_reason = None + self._warned_languages = set() + + def set_language(self, lang_code: str): + self.language = lang_code or "auto" + + def _make_request(self, vision): + request = vision.VNRecognizeTextRequest.alloc().init() + request.setRecognitionLevel_(vision.VNRequestTextRecognitionLevelAccurate) + request.setUsesLanguageCorrection_(True) + supported, error = request.supportedRecognitionLanguagesAndReturnError_(None) + if error is not None: + raise RuntimeError(f"Vision language lookup failed: {error}") + supported = [str(language) for language in supported or []] + preferred = {"zh": "zh-Hans"}.get(self.language, self.language).replace("_", "-") + selected = next( + (language for language in supported if language.lower() == preferred.lower()), None + ) + if selected is None: + selected = next( + ( + language + for language in supported + if language.lower().startswith(preferred.lower() + "-") + ), + None, + ) + automatic = preferred == "auto" or selected is None + if automatic: + languages = supported + if preferred != "auto" and preferred not in self._warned_languages: + logger.warning( + "Apple Vision does not support language %s on this macOS; " + "using automatic language detection", + self.language, + ) + self._warned_languages.add(preferred) + else: + languages = [selected] + if selected != "en-US" and "en-US" in supported: + languages.append("en-US") + if languages: + request.setRecognitionLanguages_(languages) + if hasattr(request, "setAutomaticallyDetectsLanguage_"): + request.setAutomaticallyDetectsLanguage_(automatic) + return request + + def read_text(self, image_path: str) -> str: + if self._unavailable_reason: + return "" + try: + import objc + import Vision + from Foundation import NSURL + except ImportError as ex: + self._unavailable_reason = str(ex) + logger.error("Apple Vision requires macOS and pyobjc-framework-Vision: %s", ex) + return "" + + try: + # OCR runs on a QThread, so release each frame's Cocoa objects here. + with objc.autorelease_pool(): + request = self._make_request(Vision) + url = NSURL.fileURLWithPath_(str(Path(image_path).resolve())) + handler = Vision.VNImageRequestHandler.alloc().initWithURL_options_(url, {}) + success, error = handler.performRequests_error_([request], None) + if not success: + raise RuntimeError(str(error or "Vision request failed")) + lines = [] + for observation in request.results() or []: + candidates = observation.topCandidates_(1) + if candidates: + lines.append(str(candidates[0].string())) + return " ".join(" ".join(lines).split()).strip().strip("_").rstrip(":") + except Exception as ex: + logger.error("Apple Vision OCR failed: %s", ex) + return "" diff --git a/src/core/ocr/base_ocr.py b/src/usta/core/ocr/base_ocr.py similarity index 75% rename from src/core/ocr/base_ocr.py rename to src/usta/core/ocr/base_ocr.py index d4a1a4a..3b06917 100644 --- a/src/core/ocr/base_ocr.py +++ b/src/usta/core/ocr/base_ocr.py @@ -1,10 +1,5 @@ -from dataclasses import dataclass from abc import ABC, abstractmethod -@dataclass -class OCRResult: - text: str - confidence: float = 0.0 class BaseOCREngine(ABC): @abstractmethod diff --git a/src/usta/core/ocr/easyocr_engine.py b/src/usta/core/ocr/easyocr_engine.py new file mode 100644 index 0000000..d442ccf --- /dev/null +++ b/src/usta/core/ocr/easyocr_engine.py @@ -0,0 +1,50 @@ +import logging +import warnings + +from usta.config import OCR_LANG_MAPPING +from usta.core.ocr.base_ocr import BaseOCREngine + +logger = logging.getLogger(__name__) + + +class EasyOCREngine(BaseOCREngine): + def __init__(self): + self.reader = None + self.current_langs = ["en"] + + def set_language(self, lang_code: str): + mapping = OCR_LANG_MAPPING.get(lang_code, OCR_LANG_MAPPING["en"]) + new_lang = mapping["easy"] + + # Always include 'en' as base, but add the target language if different + target_langs = list({"en", new_lang}) + + if target_langs != self.current_langs: + self.current_langs = target_langs + self.reader = None # Trigger re-initialization on next read + logger.info(f"EasyOCREngine: Languages scheduled for update: {self.current_langs}") + + def read_text(self, image_path: str) -> str: + if self.reader is None: + import easyocr + + logger.info(f"EasyOCREngine: Initializing reader with {self.current_langs}") + self.reader = easyocr.Reader(self.current_langs, gpu=True) + + try: + with warnings.catch_warnings(): + if str(getattr(self.reader, "device", "")).split(":")[0] == "mps": + # EasyOCR hardcodes pin_memory=True; PyTorch already disables + # it on MPS. Keep GPU inference and silence only that warning. + warnings.filterwarnings( + "ignore", + message=r"'pin_memory' argument is set as true but not supported on MPS", + category=UserWarning, + module=r"torch\.utils\.data\.dataloader", + ) + res = self.reader.readtext(image_path) + clean = " ".join([r[1] for r in res]) + return " ".join(clean.split()).strip().strip("_").rstrip(":") + except Exception as e: + logger.error(f"EasyOCR Error: {e}") + return "" diff --git a/src/usta/core/ocr/ocr_manager.py b/src/usta/core/ocr/ocr_manager.py new file mode 100644 index 0000000..b919d0f --- /dev/null +++ b/src/usta/core/ocr/ocr_manager.py @@ -0,0 +1,33 @@ +import sys + +from usta.application.results import OCRResult +from usta.core.registry import OCR_FACTORIES, LazyEngines + + +class OCRManager: + def __init__(self): + self.language = "en" + factories = { + name: spec + for name, spec in OCR_FACTORIES.items() + if name != "Apple Vision" or sys.platform == "darwin" + } + self.engines = LazyEngines( + factories, lambda name, engine: engine.set_language(self.language) + ) + self.current_engine_name = "RapidOCR" + + def set_engine(self, name: str): + if name in self.engines: + self.current_engine_name = name + + def set_language(self, lang_code: str): + self.language = lang_code + for engine in self.engines.instances.values(): + engine.set_language(lang_code) + + def read_text(self, image_path: str) -> str: + return self.engines[self.current_engine_name].read_text(image_path) + + def read_result(self, image_path: str) -> OCRResult: + return OCRResult(self.read_text(image_path)) diff --git a/src/core/ocr/paddleocr_engine.py b/src/usta/core/ocr/paddleocr_engine.py similarity index 79% rename from src/core/ocr/paddleocr_engine.py rename to src/usta/core/ocr/paddleocr_engine.py index 1aa8885..098cc47 100644 --- a/src/core/ocr/paddleocr_engine.py +++ b/src/usta/core/ocr/paddleocr_engine.py @@ -1,5 +1,9 @@ -from src.core.ocr.base_ocr import BaseOCREngine -from src.config import OCR_LANG_MAPPING +import logging + +from usta.config import OCR_LANG_MAPPING +from usta.core.ocr.base_ocr import BaseOCREngine + +logger = logging.getLogger(__name__) class PaddleOCREngine(BaseOCREngine): @@ -16,25 +20,26 @@ def set_language(self, lang_code: str): self.current_lang = new_lang self.ocr = None # Trigger re-initialization on next read self._unavailable_reason = None - print(f"PaddleOCREngine: Language scheduled for update: {self.current_lang}") + logger.info(f"PaddleOCREngine: Language scheduled for update: {self.current_lang}") def read_text(self, image_path: str) -> str: if self.ocr is None and self._unavailable_reason is None: try: from paddleocr import PaddleOCR - print(f"PaddleOCREngine: Initializing reader with lang={self.current_lang}") + + logger.info(f"PaddleOCREngine: Initializing reader with lang={self.current_lang}") self.ocr = PaddleOCR( lang=self.current_lang, use_textline_orientation=True, ) - except ImportError as e: + except ImportError: self._unavailable_reason = ( "PaddleOCR is not installed. " "Install it with: pip install paddleocr\n" "Note: PaddleOCR also requires paddlepaddle, which may not be " "available for all Python versions (requires Python ≤ 3.12)." ) - print(f"PaddleOCREngine: {self._unavailable_reason}") + logger.info(f"PaddleOCREngine: {self._unavailable_reason}") return "" except RuntimeError as e: self._unavailable_reason = ( @@ -43,11 +48,11 @@ def read_text(self, image_path: str) -> str: "See https://www.paddlepaddle.org.cn/en for installation instructions. " "Note: paddlepaddle may not support your current Python version." ) - print(f"PaddleOCREngine: {self._unavailable_reason}") + logger.info(f"PaddleOCREngine: {self._unavailable_reason}") return "" except Exception as e: self._unavailable_reason = f"PaddleOCR initialization failed: {e}" - print(f"PaddleOCREngine: {self._unavailable_reason}") + logger.info(f"PaddleOCREngine: {self._unavailable_reason}") return "" if self._unavailable_reason: @@ -69,5 +74,5 @@ def read_text(self, image_path: str) -> str: clean = " ".join(lines) return " ".join(clean.split()).strip() except Exception as e: - print(f"PaddleOCR Error: {e}") + logger.error(f"PaddleOCR Error: {e}") return "" diff --git a/src/usta/core/ocr/rapidocr_compat.py b/src/usta/core/ocr/rapidocr_compat.py new file mode 100644 index 0000000..a81d98b --- /dev/null +++ b/src/usta/core/ocr/rapidocr_compat.py @@ -0,0 +1,55 @@ +"""Compatibility adapter for rapidocr-onnxruntime 1.2.x. + +The installed version exposes no public thread-count setting. +""" + +import logging + +logger = logging.getLogger(__name__) + + +def patch_ort_session(cpu_threads: int): + from onnxruntime import GraphOptimizationLevel, InferenceSession, SessionOptions + from rapidocr_onnxruntime.utils import OrtInferSession, get_available_providers, get_device + + def limited_init(session_self, config): + sess_opt = SessionOptions() + sess_opt.log_severity_level = 4 + sess_opt.enable_cpu_mem_arena = False + sess_opt.graph_optimization_level = GraphOptimizationLevel.ORT_ENABLE_ALL + sess_opt.intra_op_num_threads = cpu_threads + sess_opt.inter_op_num_threads = 1 + + cpu_ep = "CPUExecutionProvider" + cpu_provider_options = { + "arena_extend_strategy": "kSameAsRequested", + "intra_op_num_threads": str(cpu_threads), + } + + cuda_ep = "CUDAExecutionProvider" + cuda_provider_options = { + "device_id": 0, + "arena_extend_strategy": "kNextPowerOfTwo", + "cudnn_conv_algo_search": "EXHAUSTIVE", + "do_copy_in_default_stream": True, + } + + ep_list = [] + if config["use_cuda"] and get_device() == "GPU" and cuda_ep in get_available_providers(): + ep_list = [(cuda_ep, cuda_provider_options)] + ep_list.append((cpu_ep, cpu_provider_options)) + + session_self._verify_model(config["model_path"]) + session_self.session = InferenceSession( + config["model_path"], + sess_options=sess_opt, + providers=ep_list, + ) + + if getattr(OrtInferSession.__init__, "_usta_cpu_limited", False): + logger.info("RapidOCREngine: OrtInferSession already patched, skipping") + return + + limited_init._usta_cpu_limited = True + OrtInferSession.__init__ = limited_init + logger.info("RapidOCREngine: OrtInferSession monkey-patched for CPU thread limiting") diff --git a/src/core/ocr/rapidocr_engine.py b/src/usta/core/ocr/rapidocr_engine.py similarity index 63% rename from src/core/ocr/rapidocr_engine.py rename to src/usta/core/ocr/rapidocr_engine.py index 718a120..5d3f20b 100644 --- a/src/core/ocr/rapidocr_engine.py +++ b/src/usta/core/ocr/rapidocr_engine.py @@ -1,14 +1,14 @@ import glob +import logging import os -from src.core.ocr.base_ocr import BaseOCREngine -from src.config import OCR_LANG_MAPPING +from usta.config import OCR_LANG_MAPPING +from usta.core.ocr.base_ocr import BaseOCREngine +from usta.resource_paths import resource_path -_MODELS_DIR = os.path.join( - os.path.dirname(os.path.abspath(__file__)), - "ocr_models", - "rapidocr", -) +logger = logging.getLogger(__name__) + +_MODELS_DIR = str(resource_path("resources/ocr_models/rapidocr")) class RapidOCREngine(BaseOCREngine): @@ -35,10 +35,14 @@ def _get_model_path(lang_code: str) -> str: pattern = os.path.join(_MODELS_DIR, f"{lang_code}_PP-OCR*.onnx") matches = sorted(glob.glob(pattern)) if matches: - print(f"RapidOCREngine: Found recognition model for lang={lang_code}: {os.path.basename(matches[0])}") + logger.info( + f"RapidOCREngine: Found recognition model for lang={lang_code}: {os.path.basename(matches[0])}" + ) return matches[0] - print(f"RapidOCREngine: No recognition model found for lang={lang_code}, using default") + logger.info( + f"RapidOCREngine: No recognition model found for lang={lang_code}, using default" + ) return "" def set_language(self, lang_code: str): @@ -49,7 +53,7 @@ def set_language(self, lang_code: str): self.current_lang = new_lang self.ocr = None # Trigger re-initialization on next read self._unavailable_reason = None - print(f"RapidOCREngine: Language scheduled for update: {self.current_lang}") + logger.info(f"RapidOCREngine: Language scheduled for update: {self.current_lang}") def _apply_cpu_limit(self): thread_count = str(self.cpu_threads) @@ -57,56 +61,9 @@ def _apply_cpu_limit(self): os.environ.setdefault(env_var, thread_count) def _patch_rapidocr_ort_session(self): - from onnxruntime import GraphOptimizationLevel, InferenceSession, SessionOptions - from rapidocr_onnxruntime.utils import OrtInferSession, get_available_providers, get_device - - cpu_threads = self.cpu_threads - - def limited_init(session_self, config): - sess_opt = SessionOptions() - sess_opt.log_severity_level = 4 - sess_opt.enable_cpu_mem_arena = False - sess_opt.graph_optimization_level = GraphOptimizationLevel.ORT_ENABLE_ALL - sess_opt.intra_op_num_threads = cpu_threads - sess_opt.inter_op_num_threads = 1 - - cpu_ep = "CPUExecutionProvider" - cpu_provider_options = { - "arena_extend_strategy": "kSameAsRequested", - "intra_op_num_threads": str(cpu_threads), - } - - cuda_ep = "CUDAExecutionProvider" - cuda_provider_options = { - "device_id": 0, - "arena_extend_strategy": "kNextPowerOfTwo", - "cudnn_conv_algo_search": "EXHAUSTIVE", - "do_copy_in_default_stream": True, - } - - ep_list = [] - if ( - config["use_cuda"] - and get_device() == "GPU" - and cuda_ep in get_available_providers() - ): - ep_list = [(cuda_ep, cuda_provider_options)] - ep_list.append((cpu_ep, cpu_provider_options)) - - session_self._verify_model(config["model_path"]) - session_self.session = InferenceSession( - config["model_path"], - sess_options=sess_opt, - providers=ep_list, - ) + from usta.core.ocr.rapidocr_compat import patch_ort_session - if getattr(OrtInferSession.__init__, "_usta_cpu_limited", False): - print("RapidOCREngine: OrtInferSession already patched, skipping") - return - - limited_init._usta_cpu_limited = True - OrtInferSession.__init__ = limited_init - print("RapidOCREngine: OrtInferSession monkey-patched for CPU thread limiting") + patch_ort_session(self.cpu_threads) def _initialize(self) -> bool: if self.ocr is not None: @@ -122,7 +79,7 @@ def _initialize(self) -> bool: self._patch_rapidocr_ort_session() - print( + logger.info( "RapidOCREngine: Initializing reader " f"with lang={self.current_lang}, cpu_threads={self.cpu_threads}, " "use_text_det=True, use_angle_cls=False" @@ -143,11 +100,11 @@ def _initialize(self) -> bool: "RapidOCR is not installed. " "Install it with: pip install rapidocr-onnxruntime onnxruntime" ) - print(f"RapidOCREngine: {self._unavailable_reason}") + logger.info(f"RapidOCREngine: {self._unavailable_reason}") return False except Exception as e: self._unavailable_reason = f"RapidOCR initialization failed: {e}" - print(f"RapidOCREngine: {self._unavailable_reason}") + logger.info(f"RapidOCREngine: {self._unavailable_reason}") return False def _extract_lines(self, result): @@ -207,5 +164,5 @@ def read_text(self, image_path: str) -> str: clean = " ".join(lines) return " ".join(clean.split()).strip().strip("_").rstrip(":") except Exception as e: - print(f"RapidOCR Error: {e}") - return "" \ No newline at end of file + logger.error(f"RapidOCR Error: {e}") + return "" diff --git a/src/core/ocr/tesseract_engine.py b/src/usta/core/ocr/tesseract_engine.py similarity index 55% rename from src/core/ocr/tesseract_engine.py rename to src/usta/core/ocr/tesseract_engine.py index ac7ba11..343dc32 100644 --- a/src/core/ocr/tesseract_engine.py +++ b/src/usta/core/ocr/tesseract_engine.py @@ -1,7 +1,12 @@ -import subprocess +import logging import re -from src.core.ocr.base_ocr import BaseOCREngine -from src.config import OCR_LANG_MAPPING +import subprocess + +from usta.config import OCR_LANG_MAPPING +from usta.core.ocr.base_ocr import BaseOCREngine + +logger = logging.getLogger(__name__) + class TesseractEngine(BaseOCREngine): def __init__(self): @@ -10,26 +15,28 @@ def __init__(self): def set_language(self, lang_code: str): mapping = OCR_LANG_MAPPING.get(lang_code, OCR_LANG_MAPPING["en"]) self.lang = mapping["tess"] - print(f"TesseractEngine: Language set to {self.lang}") + logger.info(f"TesseractEngine: Language set to {self.lang}") def read_text(self, image_path: str) -> str: - cfg = [ - "tesseract", image_path, "stdout", - "--oem", "1", "--psm", "3", "-l", self.lang - ] - + cfg = ["tesseract", image_path, "stdout", "--oem", "1", "--psm", "3", "-l", self.lang] + # Only add whitelist for English to avoid breaking other languages if self.lang == "eng": - cfg.extend(["-c", "tessedit_char_whitelist=abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.,!?'\"- "]) + cfg.extend( + [ + "-c", + "tessedit_char_whitelist=abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.,!?'\"- ", + ] + ) try: - result = subprocess.run(cfg, capture_output=True, text=True, check=True) + result = subprocess.run(cfg, capture_output=True, text=True, check=True, timeout=15) text = result.stdout - + # Use a more inclusive regex that allows non-Latin characters # This allows common punctuation and spaces, plus any word character (including non-Latin) - clean = re.sub(r'[^\w\s.,!?\'\"\-\(\)]', '', text, flags=re.UNICODE) + clean = re.sub(r"[^\w\s.,!?\'\"\-\(\)]", "", text, flags=re.UNICODE) return " ".join(clean.split()).strip() except Exception as e: - print(f"Tesseract Error: {e}") + logger.error(f"Tesseract Error: {e}") return "" diff --git a/src/usta/core/platform/__init__.py b/src/usta/core/platform/__init__.py new file mode 100644 index 0000000..c500d54 --- /dev/null +++ b/src/usta/core/platform/__init__.py @@ -0,0 +1 @@ +"""Platform helpers; import concrete modules explicitly to avoid startup cycles.""" diff --git a/src/core/platform/capabilities.py b/src/usta/core/platform/capabilities.py similarity index 96% rename from src/core/platform/capabilities.py rename to src/usta/core/platform/capabilities.py index 9270dea..5f7d633 100644 --- a/src/core/platform/capabilities.py +++ b/src/usta/core/platform/capabilities.py @@ -1,8 +1,9 @@ import os +from usta.core.registry import DEFAULT_OCR_NAMES, TRANSLATOR_FACTORIES -OCR_ENGINES = ["RapidOCR","Tesseract", "EasyOCR"] # "PaddleOCR" (uncomment when paddlepaddle is available) -TRANSLATION_ENGINES = ["Google", "DeepL"] +OCR_ENGINES = list(DEFAULT_OCR_NAMES) +TRANSLATION_ENGINES = list(TRANSLATOR_FACTORIES) SCREENSHOT_ENGINES = ["Portal", "Spectacle"] LANGUAGES = { diff --git a/src/core/platform/capability_resolver.py b/src/usta/core/platform/capability_resolver.py similarity index 90% rename from src/core/platform/capability_resolver.py rename to src/usta/core/platform/capability_resolver.py index 7fc0152..74d3b98 100644 --- a/src/core/platform/capability_resolver.py +++ b/src/usta/core/platform/capability_resolver.py @@ -2,9 +2,8 @@ from types import ModuleType from typing import Any, Iterable, Optional -from src.core.platform import capabilities as base_capabilities -from src.core.platform.system_info import get_desktop_environment, get_os - +from usta.core.platform import capabilities as base_capabilities +from usta.core.platform.system_info import get_desktop_environment, get_os CAPABILITY_NAMES = ( "OCR_ENGINES", @@ -56,11 +55,11 @@ def _iter_capability_modules( if platform_package is None: return - module_names = [f"src.core.platform.{platform_package}.capabilities"] + module_names = [f"usta.core.platform.{platform_package}.capabilities"] if platform_package == "linux": desktop_package = _normalize_module_part(desktop_environment) if desktop_package is not None: - module_names.append(f"src.core.platform.linux.{desktop_package}.capabilities") + module_names.append(f"usta.core.platform.linux.{desktop_package}.capabilities") for module_name in module_names: module = _import_optional_module(module_name) diff --git a/src/core/platform/linux/capabilities.py b/src/usta/core/platform/linux/capabilities.py similarity index 69% rename from src/core/platform/linux/capabilities.py rename to src/usta/core/platform/linux/capabilities.py index 6add4c0..4809842 100644 --- a/src/core/platform/linux/capabilities.py +++ b/src/usta/core/platform/linux/capabilities.py @@ -1,11 +1,10 @@ """Linux capability overrides. -Only define values here when they differ from src.core.platform.capabilities. +Only define values here when they differ from usta.core.platform.capabilities. """ import os - CONFIG_DIR = os.path.join( os.environ.get("XDG_CONFIG_HOME", os.path.join(os.path.expanduser("~"), ".config")), "usta", diff --git a/src/core/platform/linux/gnome/capabilities.py b/src/usta/core/platform/linux/gnome/capabilities.py similarity index 53% rename from src/core/platform/linux/gnome/capabilities.py rename to src/usta/core/platform/linux/gnome/capabilities.py index d3f331a..97baab7 100644 --- a/src/core/platform/linux/gnome/capabilities.py +++ b/src/usta/core/platform/linux/gnome/capabilities.py @@ -1,6 +1,6 @@ """GNOME capability overrides. -Only define values here when they differ from src.core.platform.capabilities. +Only define values here when they differ from usta.core.platform.capabilities. """ SCREENSHOT_ENGINES = ["Portal"] diff --git a/src/usta/core/platform/linux/kde/capabilities.py b/src/usta/core/platform/linux/kde/capabilities.py new file mode 100644 index 0000000..fbc018c --- /dev/null +++ b/src/usta/core/platform/linux/kde/capabilities.py @@ -0,0 +1,7 @@ +"""KDE capability overrides. + +Only define values here when they differ from usta.core.platform.capabilities. +""" + +SCREENSHOT_ENGINES = ["Portal", "Spectacle"] +PORTAL_ORIENTATION = 1 diff --git a/src/usta/core/platform/macos/capabilities.py b/src/usta/core/platform/macos/capabilities.py new file mode 100644 index 0000000..5686829 --- /dev/null +++ b/src/usta/core/platform/macos/capabilities.py @@ -0,0 +1,19 @@ +"""macOS capability overrides. + +Only define values here when they differ from usta.core.platform.capabilities. +""" + +import os + +from usta.core.platform.capabilities import OCR_ENGINES as BASE_OCR_ENGINES + +OCR_ENGINES = [*BASE_OCR_ENGINES, "Apple Vision"] + +SCREENSHOT_ENGINES = ["MacOS"] + +CONFIG_DIR = os.path.join( + os.path.expanduser("~"), + "Library", + "Application Support", + "usta", +) diff --git a/src/usta/core/platform/macos/hotkey_listener.py b/src/usta/core/platform/macos/hotkey_listener.py new file mode 100644 index 0000000..88c27fa --- /dev/null +++ b/src/usta/core/platform/macos/hotkey_listener.py @@ -0,0 +1,44 @@ +"""Keep macOS keyboard-layout queries off pynput's listener thread.""" + +import logging +import threading + +from pynput import keyboard +from pynput._util import darwin + + +def has_accessibility_permission() -> bool: + """Inspect trust without opening a prompt or changing macOS permissions.""" + if threading.current_thread() is not threading.main_thread(): + raise RuntimeError("macOS hotkey listeners must be created on the main thread") + return bool(darwin.HIServices.AXIsProcessTrusted()) + + +class _ShortcutLogAdapter(logging.LoggerAdapter): + def log(self, level, message, *args, **kwargs): + # GlobalHotkey already reports this permission state once with guidance. + # Preserve event-tap startup: input-monitoring permission can be separate. + if level == logging.WARNING and str(message).startswith("This process is not trusted!"): + return + super().log(level, message, *args, **kwargs) + + +class MainThreadLayoutListener(keyboard.Listener): + def __init__(self, *args, **kwargs): + if threading.current_thread() is not threading.main_thread(): + raise RuntimeError("macOS hotkey listeners must be created on the main thread") + super().__init__(*args, **kwargs) + self._log = _ShortcutLogAdapter(self._log, {}) + # macOS 26 requires the TIS/TSM layout query on the main thread. The + # context contains copied layout bytes, safe to use on the listener thread. + with darwin.keycode_context() as context: + self._layout_context = context + + def _run(self): + self._context = self._layout_context + try: + # Skip keyboard.Listener._run(), which queries TIS from this thread. + # Keep pynput's existing event tap, callbacks, and shutdown behavior. + darwin.ListenerMixin._run(self) + finally: + self._context = None diff --git a/src/core/platform/system_info.py b/src/usta/core/platform/system_info.py similarity index 99% rename from src/core/platform/system_info.py rename to src/usta/core/platform/system_info.py index 4a81caa..9ef58a5 100644 --- a/src/core/platform/system_info.py +++ b/src/usta/core/platform/system_info.py @@ -3,7 +3,6 @@ import subprocess from typing import Optional - _COMPOSITOR_PROCESSES = { "kwin_wayland": "kwin", "kwin_x11": "kwin", diff --git a/src/core/platform/windows/capabilities.py b/src/usta/core/platform/windows/capabilities.py similarity index 72% rename from src/core/platform/windows/capabilities.py rename to src/usta/core/platform/windows/capabilities.py index 4fffefa..f3011ae 100644 --- a/src/core/platform/windows/capabilities.py +++ b/src/usta/core/platform/windows/capabilities.py @@ -1,11 +1,10 @@ """Windows capability overrides. -Only define values here when they differ from src.core.platform.capabilities. +Only define values here when they differ from usta.core.platform.capabilities. """ import os - CONFIG_DIR = os.path.join( os.environ.get( "APPDATA", diff --git a/src/usta/core/registry.py b/src/usta/core/registry.py new file mode 100644 index 0000000..7d9f59b --- /dev/null +++ b/src/usta/core/registry.py @@ -0,0 +1,41 @@ +"""Names and lazy engine factories; importing this module loads no backend.""" + +from collections.abc import Mapping +from importlib import import_module + +OCR_FACTORIES = { + "RapidOCR": ("usta.core.ocr.rapidocr_engine", "RapidOCREngine"), + "Tesseract": ("usta.core.ocr.tesseract_engine", "TesseractEngine"), + "EasyOCR": ("usta.core.ocr.easyocr_engine", "EasyOCREngine"), + "PaddleOCR": ("usta.core.ocr.paddleocr_engine", "PaddleOCREngine"), + "Apple Vision": ("usta.core.ocr.apple_vision_engine", "AppleVisionEngine"), +} +DEFAULT_OCR_NAMES = ["RapidOCR", "Tesseract", "EasyOCR"] +TRANSLATOR_FACTORIES = { + "Google": ("usta.core.translation.google_engine", "GoogleEngine"), + "DeepL": ("usta.core.translation.deepl_engine", "DeepLTranslatorEngine"), +} + + +class LazyEngines(Mapping): + def __init__(self, factories, initialize): + self.factories = factories + self.initialize = initialize + self.instances = {} + + def __getitem__(self, name): + if name not in self.instances: + module, class_name = self.factories[name] + engine = getattr(import_module(module), class_name)() + self.initialize(name, engine) + self.instances[name] = engine + return self.instances[name] + + def __contains__(self, name): + return name in self.factories + + def __iter__(self): + return iter(self.factories) + + def __len__(self): + return len(self.factories) diff --git a/src/usta/core/screenshot/__init__.py b/src/usta/core/screenshot/__init__.py new file mode 100644 index 0000000..df734f2 --- /dev/null +++ b/src/usta/core/screenshot/__init__.py @@ -0,0 +1,7 @@ +"""Screenshot interfaces; native engines are loaded only when selected.""" + +from usta.core.screenshot.base_screenshot import BaseScreenshot +from usta.core.screenshot.image_processor import ImageProcessor +from usta.core.screenshot.screenshot_factory import ScreenshotFactory + +__all__ = ["BaseScreenshot", "ImageProcessor", "ScreenshotFactory"] diff --git a/src/core/screenshot/base_screenshot.py b/src/usta/core/screenshot/base_screenshot.py similarity index 75% rename from src/core/screenshot/base_screenshot.py rename to src/usta/core/screenshot/base_screenshot.py index 4c1076e..6b7dd85 100644 --- a/src/core/screenshot/base_screenshot.py +++ b/src/usta/core/screenshot/base_screenshot.py @@ -1,8 +1,13 @@ from abc import ABC, abstractmethod + from PySide6.QtCore import QRect + class BaseScreenshot(ABC): @abstractmethod def capture(self, rect: QRect, output_path: str, dpi_scale: float = 1.0) -> bool: """Captures a screenshot of the specified region, applying DPI scaling if needed.""" pass + + def close(self) -> None: + """Release resources; stateless backends need no cleanup.""" diff --git a/src/core/screenshot/image_processor.py b/src/usta/core/screenshot/image_processor.py similarity index 68% rename from src/core/screenshot/image_processor.py rename to src/usta/core/screenshot/image_processor.py index 89a08e5..4d71654 100644 --- a/src/core/screenshot/image_processor.py +++ b/src/usta/core/screenshot/image_processor.py @@ -1,5 +1,6 @@ from PIL import Image, ImageOps + class ImageProcessor: @staticmethod def preprocess_for_tesseract(image_path: str, output_path: str): @@ -7,7 +8,7 @@ def preprocess_for_tesseract(image_path: str, output_path: str): img = img.resize((img.width * 4, img.height * 4), Image.Resampling.NEAREST) img = ImageOps.grayscale(img) img = ImageOps.autocontrast(img) - img = img.point(lambda x: 0 if x < 128 else 255, '1') - img = ImageOps.invert(img.convert('L')) - img = ImageOps.expand(img, border=10, fill='white') + img = img.point(lambda x: 0 if x < 128 else 255, "1") + img = ImageOps.invert(img.convert("L")) + img = ImageOps.expand(img, border=10, fill="white") img.save(output_path) diff --git a/src/usta/core/screenshot/macos_screenshot.py b/src/usta/core/screenshot/macos_screenshot.py new file mode 100644 index 0000000..8d2c682 --- /dev/null +++ b/src/usta/core/screenshot/macos_screenshot.py @@ -0,0 +1,101 @@ +"""Capture inside USTA's process so screen access belongs to the application.""" + +import io +import math +import sys +import threading + +from PIL import Image +from PySide6.QtCore import QRect + +from usta.core.exceptions import ScreenCaptureError, ScreenCapturePermissionError +from usta.core.screenshot.base_screenshot import BaseScreenshot + +_PERMISSION_MESSAGE = ( + "Screen Recording access is unavailable. Enable USTA in System Settings > " + "Privacy & Security > Screen & System Audio Recording, then quit and reopen USTA." +) + + +class MacOSScreenshot(BaseScreenshot): + CAPTURE_TIMEOUT_SECONDS = 5 + + def __init__(self): + self._permission_requested = False + + @staticmethod + def is_available() -> bool: + return sys.platform == "darwin" + + def _capture_region(self, rect): + import AppKit + import objc + import Quartz + import ScreenCaptureKit + + if not Quartz.CGPreflightScreenCaptureAccess(): + if not self._permission_requested: + self._permission_requested = True + Quartz.CGRequestScreenCaptureAccess() + if not Quartz.CGPreflightScreenCaptureAccess(): + raise ScreenCapturePermissionError(_PERMISSION_MESSAGE) + + done = threading.Event() + result = {} + + def completed(image, error): + try: + if error is not None: + if error.code() == ScreenCaptureKit.SCStreamErrorUserDeclined: + raise ScreenCapturePermissionError(_PERMISSION_MESSAGE) + raise ScreenCaptureError(str(error.localizedDescription())) + if image is None: + raise ScreenCaptureError("macOS returned no screen image.") + with objc.autorelease_pool(): + bitmap = AppKit.NSBitmapImageRep.alloc().initWithCGImage_(image) + png = bitmap.representationUsingType_properties_( + AppKit.NSBitmapImageFileTypePNG, {} + ) + if png is None: + raise ScreenCaptureError("macOS could not encode the captured image.") + with Image.open(io.BytesIO(bytes(png))) as frame: + result["image"] = frame.convert("RGB") + except Exception as exc: + result["error"] = exc + finally: + done.set() + + manager = ScreenCaptureKit.SCScreenshotManager + if not hasattr(manager, "captureImageInRect_completionHandler_"): + raise ScreenCaptureError("Native region capture requires macOS 15.2 or newer.") + manager.captureImageInRect_completionHandler_( + Quartz.CGRectMake(rect.x(), rect.y(), rect.width(), rect.height()), completed + ) + if not done.wait(self.CAPTURE_TIMEOUT_SECONDS): + raise ScreenCaptureError("macOS screen capture timed out; stop and try again.") + if "error" in result: + raise result["error"] + return result["image"] + + def capture(self, rect: QRect, output_path: str, dpi_scale: float = 1.0) -> bool: + if ( + rect.width() <= 0 + or rect.height() <= 0 + or not math.isfinite(dpi_scale) + or dpi_scale <= 0 + ): + return False + try: + frame = self._capture_region(rect) + size = ( + max(1, round(rect.width() * dpi_scale)), + max(1, round(rect.height() * dpi_scale)), + ) + if frame.size != size: + frame = frame.resize(size, Image.Resampling.LANCZOS) + frame.save(output_path) + return True + except ScreenCaptureError: + raise + except Exception as exc: + raise ScreenCaptureError(f"macOS screen capture failed: {exc}") from exc diff --git a/src/core/screenshot/portal_screenshot.py b/src/usta/core/screenshot/portal_screenshot.py similarity index 71% rename from src/core/screenshot/portal_screenshot.py rename to src/usta/core/screenshot/portal_screenshot.py index ed2029b..7e684fa 100644 --- a/src/core/screenshot/portal_screenshot.py +++ b/src/usta/core/screenshot/portal_screenshot.py @@ -1,4 +1,5 @@ import asyncio +import logging import os import secrets import threading @@ -8,9 +9,11 @@ from PIL import Image from PySide6.QtCore import QRect -from src.config import FULL_SCREEN_TEMP_PATH, PORTAL_ORIENTATION -from src.core.exceptions import PortalCanceledError -from src.core.screenshot.base_screenshot import BaseScreenshot +from usta.config import FULL_SCREEN_TEMP_PATH, PORTAL_ORIENTATION +from usta.core.exceptions import PortalCanceledError +from usta.core.screenshot.base_screenshot import BaseScreenshot + +logger = logging.getLogger(__name__) try: from dbus_next import BusType, Variant @@ -30,6 +33,7 @@ gi.require_version("Gst", "1.0") from gi.repository import Gst + gi.require_version("GstVideo", "1.0") from gi.repository import GstVideo except ImportError as exc: @@ -54,7 +58,8 @@ class PortalScreenshot(BaseScreenshot): """Long-lived screenshot engine based on Wayland/XDG Desktop Portal.""" - def __init__(self): + def __init__(self, full_screen_path=FULL_SCREEN_TEMP_PATH): + self.full_screen_path = full_screen_path self._lock = threading.RLock() self._loop = asyncio.new_event_loop() self._bus = None @@ -84,18 +89,20 @@ def capture(self, rect: QRect, output_path: str, dpi_scale: float = 1.0) -> bool try: self._ensure_dependencies() if not self._started: - self._loop.run_until_complete(self._start_portal_session()) + self._loop.run_until_complete( + asyncio.wait_for(self._start_portal_session(), timeout=60.0) + ) self._start_gstreamer_pipeline() self._started = True return self._capture_current_frame(rect, output_path, dpi_scale) except PortalCanceledError: - print("Portal selection was canceled by user.") + logger.info("Portal selection was canceled by user.") self.close() raise except Exception as exc: self._last_error = exc - print(f"Portal Capture Error ({type(exc).__name__}): {exc!r}") + logger.error(f"Portal Capture Error ({type(exc).__name__}): {exc!r}") traceback.print_exc() self._print_gstreamer_bus_errors() self.close() @@ -119,9 +126,11 @@ def close(self): if self._session_handle and self._bus is not None: try: - self._loop.run_until_complete(self._close_portal_session()) + self._loop.run_until_complete( + asyncio.wait_for(self._close_portal_session(), timeout=3.0) + ) except Exception as exc: - print(f"Portal Close Error: {exc}") + logger.error(f"Portal Close Error: {exc}") if self._bus is not None: try: @@ -136,7 +145,9 @@ def close(self): def _ensure_dependencies(self): if _DBUS_IMPORT_ERROR is not None: - raise RuntimeError("dbus-next module was not found. Install it with `pip install dbus-next`.") from _DBUS_IMPORT_ERROR + raise RuntimeError( + "dbus-next module was not found. Install it with `pip install dbus-next`." + ) from _DBUS_IMPORT_ERROR if _GST_IMPORT_ERROR is not None or Gst is None: raise RuntimeError( "PyGObject/GStreamer Python module was not found. Install PyGObject, GStreamer, " @@ -156,14 +167,16 @@ async def _add_signal_match(self, sender, path, interface, member): if path is not None: parts.append(f"path='{path}'") - await self._bus.call(Message( - destination=DBUS_NAME, - path=DBUS_PATH, - interface=DBUS_INTERFACE, - member="AddMatch", - signature="s", - body=[",".join(parts)], - )) + await self._bus.call( + Message( + destination=DBUS_NAME, + path=DBUS_PATH, + interface=DBUS_INTERFACE, + member="AddMatch", + signature="s", + body=[",".join(parts)], + ) + ) def _token_to_request_path(self, handle_token): sender = self._bus.unique_name.removeprefix(":").replace(".", "_") @@ -188,14 +201,16 @@ def response_handler(message): await self._add_signal_match(None, expected_request_path, REQUEST_INTERFACE, "Response") try: - reply = await self._bus.call(Message( - destination=BUS_NAME, - path=OBJECT_PATH, - interface=SCREENCAST_INTERFACE, - member=member, - signature=signature, - body=body, - )) + reply = await self._bus.call( + Message( + destination=BUS_NAME, + path=OBJECT_PATH, + interface=SCREENCAST_INTERFACE, + member=member, + signature=signature, + body=body, + ) + ) returned_request_path = reply.body[0] if returned_request_path != expected_request_path: @@ -209,8 +224,12 @@ def response_handler(message): if response_code != 0: if response_code == 1: - raise PortalCanceledError(f"Portal request was canceled or rejected: {member}, response={response_code}") - raise RuntimeError(f"Portal request was canceled or rejected: {member}, response={response_code}") + raise PortalCanceledError( + f"Portal request was canceled or rejected: {member}, response={response_code}" + ) + raise RuntimeError( + f"Portal request was canceled or rejected: {member}, response={response_code}" + ) return results @@ -219,45 +238,71 @@ async def _start_portal_session(self): token = secrets.token_hex(8) create_token = f"create_{token}" - create_results = await self._call_portal_request("CreateSession", "a{sv}", [{ - "session_handle_token": Variant("s", f"session_{token}"), - "handle_token": Variant("s", create_token), - }], create_token) + create_results = await self._call_portal_request( + "CreateSession", + "a{sv}", + [ + { + "session_handle_token": Variant("s", f"session_{token}"), + "handle_token": Variant("s", create_token), + } + ], + create_token, + ) self._session_handle = create_results["session_handle"].value select_token = f"select_{token}" - await self._call_portal_request("SelectSources", "oa{sv}", [self._session_handle, { - "handle_token": Variant("s", select_token), - "types": Variant("u", 3), - "multiple": Variant("b", False), - "cursor_mode": Variant("u", 2), - }], select_token) + await self._call_portal_request( + "SelectSources", + "oa{sv}", + [ + self._session_handle, + { + "handle_token": Variant("s", select_token), + "types": Variant("u", 3), + "multiple": Variant("b", False), + "cursor_mode": Variant("u", 2), + }, + ], + select_token, + ) start_token = f"start_{token}" - start_results = await self._call_portal_request("Start", "osa{sv}", [self._session_handle, "", { - "handle_token": Variant("s", start_token), - }], start_token) + start_results = await self._call_portal_request( + "Start", + "osa{sv}", + [ + self._session_handle, + "", + { + "handle_token": Variant("s", start_token), + }, + ], + start_token, + ) streams = start_results.get("streams") if streams is None or not streams.value: raise RuntimeError("Portal stream started but did not return a `streams` result.") self._node_id, stream_properties = streams.value[0] - print(f"Portal PipeWire node_id={self._node_id}") + logger.info(f"Portal PipeWire node_id={self._node_id}") for key, value in stream_properties.items(): - print(f" {key}={value.value!r}") + logger.debug(f" {key}={value.value!r}") self._pipewire_fd = await self._open_pipewire_remote() async def _open_pipewire_remote(self): - reply = await self._bus.call(Message( - destination=BUS_NAME, - path=OBJECT_PATH, - interface=SCREENCAST_INTERFACE, - member="OpenPipeWireRemote", - signature="oa{sv}", - body=[self._session_handle, {}], - )) + reply = await self._bus.call( + Message( + destination=BUS_NAME, + path=OBJECT_PATH, + interface=SCREENCAST_INTERFACE, + member="OpenPipeWireRemote", + signature="oa{sv}", + body=[self._session_handle, {}], + ) + ) fd_index = reply.body[0] try: @@ -270,12 +315,14 @@ async def _open_pipewire_remote(self): return fd async def _close_portal_session(self): - await self._bus.call(Message( - destination=BUS_NAME, - path=self._session_handle, - interface=SESSION_INTERFACE, - member="Close", - )) + await self._bus.call( + Message( + destination=BUS_NAME, + path=self._session_handle, + interface=SESSION_INTERFACE, + member="Close", + ) + ) def _start_gstreamer_pipeline(self): if not self._gst_initialized: @@ -302,14 +349,18 @@ def _start_gstreamer_pipeline(self): self._appsink = self._pipeline.get_by_name("sink") state_result = self._pipeline.set_state(Gst.State.PLAYING) if state_result == Gst.StateChangeReturn.FAILURE: - raise RuntimeError(f"GStreamer pipeline could not switch to PLAYING state: {pipeline_description}") + raise RuntimeError( + f"GStreamer pipeline could not switch to PLAYING state: {pipeline_description}" + ) self._wait_for_pipeline_ready() - print(f"Portal GStreamer pipeline is ready: {pipeline_description}") + logger.info(f"Portal GStreamer pipeline is ready: {pipeline_description}") return except Exception as exc: last_error = exc - print(f"Portal GStreamer pipeline attempt failed ({type(exc).__name__}): {exc!r}") + logger.error( + f"Portal GStreamer pipeline attempt failed ({type(exc).__name__}): {exc!r}" + ) self._print_gstreamer_bus_errors() if self._pipeline is not None: self._pipeline.set_state(Gst.State.NULL) @@ -346,10 +397,10 @@ def _print_gstreamer_bus_errors(self): break if message.type == Gst.MessageType.ERROR: err, debug = message.parse_error() - print(f"Portal GStreamer ERROR: {err}; debug={debug}") + logger.error(f"Portal GStreamer ERROR: {err}; debug={debug}") elif message.type == Gst.MessageType.WARNING: err, debug = message.parse_warning() - print(f"Portal GStreamer WARNING: {err}; debug={debug}") + logger.info(f"Portal GStreamer WARNING: {err}; debug={debug}") def _build_frame_image(self, sample: Any) -> Image.Image: buffer = sample.get_buffer() @@ -368,18 +419,24 @@ def _build_frame_image(self, sample: Any) -> Image.Image: try: if video_format == "RGB": - return Image.frombytes("RGB", (width, height), map_info.data, "raw", "RGB", stride, orientation) + return Image.frombytes( + "RGB", (width, height), map_info.data, "raw", "RGB", stride, orientation + ) if video_format == "BGR": - return Image.frombytes("RGB", (width, height), map_info.data, "raw", "BGR", stride, orientation) + return Image.frombytes( + "RGB", (width, height), map_info.data, "raw", "BGR", stride, orientation + ) raise RuntimeError(f"Unsupported GStreamer video format: {video_format}") finally: buffer.unmap(map_info) - def _save_sample_region(self, sample: Any, rect: QRect, output_path: str, dpi_scale: float) -> bool: + def _save_sample_region( + self, sample: Any, rect: QRect, output_path: str, dpi_scale: float + ) -> bool: image = self._build_frame_image(sample) - self._save_image_atomically(image, FULL_SCREEN_TEMP_PATH) + self._save_image_atomically(image, self.full_screen_path) width, height = image.size x1 = max(0, int(rect.x() * dpi_scale)) @@ -407,7 +464,7 @@ def _save_image_atomically(self, image: Image.Image, output_path: str) -> None: def _capture_current_frame(self, rect: QRect, output_path: str, dpi_scale: float) -> bool: sample = self._appsink.emit("try-pull-sample", int(CAPTURE_TIMEOUT_SECONDS * Gst.SECOND)) if sample is None: - print("Waiting for Portal GStreamer sample...") + logger.info("Waiting for Portal GStreamer sample...") return False return self._save_sample_region(sample, rect, output_path, dpi_scale) diff --git a/src/usta/core/screenshot/screenshot_factory.py b/src/usta/core/screenshot/screenshot_factory.py new file mode 100644 index 0000000..4311476 --- /dev/null +++ b/src/usta/core/screenshot/screenshot_factory.py @@ -0,0 +1,30 @@ +"""Load only the selected platform's native screenshot implementation.""" + +import logging +import platform + +from usta.config import FULL_SCREEN_TEMP_PATH +from usta.core.screenshot.base_screenshot import BaseScreenshot + +logger = logging.getLogger(__name__) + + +class ScreenshotFactory: + @staticmethod + def get_engine( + engine_name: str = "Portal", full_screen_path=FULL_SCREEN_TEMP_PATH + ) -> BaseScreenshot: + if platform.system() == "Darwin": + from usta.core.screenshot.macos_screenshot import MacOSScreenshot + + if MacOSScreenshot.is_available(): + return MacOSScreenshot() + if engine_name == "Portal": + from usta.core.screenshot.portal_screenshot import PortalScreenshot + + if PortalScreenshot.is_available(): + return PortalScreenshot(full_screen_path) + logger.warning("Portal screenshot engine unavailable; falling back to Spectacle") + from usta.core.screenshot.spectacle_screenshot import SpectacleScreenshot + + return SpectacleScreenshot(full_screen_path) diff --git a/src/core/screenshot/spectacle_screenshot.py b/src/usta/core/screenshot/spectacle_screenshot.py similarity index 53% rename from src/core/screenshot/spectacle_screenshot.py rename to src/usta/core/screenshot/spectacle_screenshot.py index 7ee2fbf..d08d196 100644 --- a/src/core/screenshot/spectacle_screenshot.py +++ b/src/usta/core/screenshot/spectacle_screenshot.py @@ -1,26 +1,38 @@ +import logging import os import subprocess + from PIL import Image from PySide6.QtCore import QRect -from src.core.screenshot.base_screenshot import BaseScreenshot -from src.config import FULL_SCREEN_TEMP_PATH + +from usta.config import FULL_SCREEN_TEMP_PATH +from usta.core.screenshot.base_screenshot import BaseScreenshot + +logger = logging.getLogger(__name__) + class SpectacleScreenshot(BaseScreenshot): + def __init__(self, full_screen_path=FULL_SCREEN_TEMP_PATH): + self.full_screen_path = full_screen_path + def capture(self, rect: QRect, output_path: str, dpi_scale: float = 1.0) -> bool: - if os.path.exists(FULL_SCREEN_TEMP_PATH): - os.remove(FULL_SCREEN_TEMP_PATH) - + if os.path.exists(self.full_screen_path): + os.remove(self.full_screen_path) + try: # Use spectacle for KDE/Wayland subprocess.run( - ["spectacle", "-b", "-f", "-n", "-o", FULL_SCREEN_TEMP_PATH], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True + ["spectacle", "-b", "-f", "-n", "-o", self.full_screen_path], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=True, + timeout=15, ) - - if not os.path.exists(FULL_SCREEN_TEMP_PATH): + + if not os.path.exists(self.full_screen_path): return False - - with Image.open(FULL_SCREEN_TEMP_PATH) as full_img: + + with Image.open(self.full_screen_path) as full_img: # Apply DPI scaling to convert logical coordinates to physical pixels x1 = max(0, int(rect.x() * dpi_scale)) y1 = max(0, int(rect.y() * dpi_scale)) @@ -30,5 +42,5 @@ def capture(self, rect: QRect, output_path: str, dpi_scale: float = 1.0) -> bool crop.save(output_path) return True except Exception as e: - print(f"Spectacle Capture Error: {e}") + logger.error(f"Spectacle Capture Error: {e}") return False diff --git a/src/core/shortcut.py b/src/usta/core/shortcut.py similarity index 67% rename from src/core/shortcut.py rename to src/usta/core/shortcut.py index cbb6703..cf3bd08 100644 --- a/src/core/shortcut.py +++ b/src/usta/core/shortcut.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +import sys from typing import Callable try: @@ -9,6 +10,7 @@ keyboard = None logger = logging.getLogger(__name__) +_permission_warning_emitted = False class GlobalHotkey: @@ -39,6 +41,7 @@ def _on_release(self, key): self._is_pressed = False def start(self) -> bool: + global _permission_warning_emitted if keyboard is None: logger.warning("Global hotkey support is unavailable because pynput is not installed.") return False @@ -50,7 +53,26 @@ def start(self) -> bool: hotkey_keys = keyboard.HotKey.parse(self.hotkey) self._hotkey_keys = frozenset(hotkey_keys) self._hotkey = keyboard.HotKey(hotkey_keys, self._on_activate) - self._listener = keyboard.Listener( + listener_type = keyboard.Listener + if sys.platform == "darwin": + from usta.core.platform.macos.hotkey_listener import ( + MainThreadLayoutListener, + has_accessibility_permission, + ) + + if not has_accessibility_permission(): + if not _permission_warning_emitted: + logger.warning( + "macOS Accessibility permission is missing; global shortcuts may be " + "unavailable. Open System Settings > Privacy & Security > Accessibility, " + "allow the app/terminal running USTA, then restart USTA. Python: %s", + sys.executable, + ) + _permission_warning_emitted = True + else: + _permission_warning_emitted = False + listener_type = MainThreadLayoutListener + self._listener = listener_type( on_press=self._on_press, on_release=self._on_release, ) diff --git a/src/core/translation/__init__.py b/src/usta/core/translation/__init__.py similarity index 100% rename from src/core/translation/__init__.py rename to src/usta/core/translation/__init__.py diff --git a/src/core/translation/base_translator.py b/src/usta/core/translation/base_translator.py similarity index 64% rename from src/core/translation/base_translator.py rename to src/usta/core/translation/base_translator.py index b0b2a94..d526a14 100644 --- a/src/core/translation/base_translator.py +++ b/src/usta/core/translation/base_translator.py @@ -1,12 +1,5 @@ -from dataclasses import dataclass from abc import ABC, abstractmethod -@dataclass -class TranslationResult: - translated_text: str - source_lang: str = "en" - target_lang: str = "tr" - engine_name: str = "unknown" class BaseTranslator(ABC): @abstractmethod diff --git a/src/core/translation/deepl_engine.py b/src/usta/core/translation/deepl_engine.py similarity index 62% rename from src/core/translation/deepl_engine.py rename to src/usta/core/translation/deepl_engine.py index 0c04467..da7942a 100644 --- a/src/core/translation/deepl_engine.py +++ b/src/usta/core/translation/deepl_engine.py @@ -1,15 +1,18 @@ +import logging import os + import deepl -from src.config import get_translation_language_code -from src.core.translation.base_translator import BaseTranslator + +from usta.application.results import EngineError +from usta.config import get_translation_language_code +from usta.core.translation.base_translator import BaseTranslator + +logger = logging.getLogger(__name__) class DeepLTranslatorEngine(BaseTranslator): - def __init__(self, api_key=None, source='en', target='tr'): - self.api_key = api_key or os.getenv( - "DEEPL_API_KEY", - "" - ) + def __init__(self, api_key=None, source="en", target="tr"): + self.api_key = api_key or os.getenv("DEEPL_API_KEY", "") self.source = source self.target = target self._translator = None @@ -20,7 +23,7 @@ def translator(self): try: self._translator = deepl.Translator(self.api_key) except Exception as e: - print(f"DeepL Initialization Error: {e}") + raise EngineError("initialization", "DeepL could not be initialized") from e return self._translator def set_api_key(self, api_key: str): @@ -33,7 +36,9 @@ def set_languages(self, source: str, target: str): def translate(self, text: str) -> str: if not self.api_key or self.api_key == "YOUR_DEEPL_API_KEY": - return "Error: DeepL API key is missing. Please define DEEPL_API_KEY." + raise EngineError( + "missing_key", "DeepL API key is missing. Please define DEEPL_API_KEY." + ) try: if self.translator: @@ -45,7 +50,8 @@ def translate(self, text: str) -> str: result = self.translator.translate_text(text, **translate_kwargs) return result.text - return "Error: DeepL could not be initialized." + raise EngineError("initialization", "DeepL could not be initialized.") except Exception as e: - print(f"DeepL Translation Error: {e}") - return f"Error: {e}" + raise EngineError( + "translation", "DeepL translation failed; check credentials and connection." + ) from e diff --git a/src/core/translation/google_engine.py b/src/usta/core/translation/google_engine.py similarity index 66% rename from src/core/translation/google_engine.py rename to src/usta/core/translation/google_engine.py index 06a8493..59a6b21 100644 --- a/src/core/translation/google_engine.py +++ b/src/usta/core/translation/google_engine.py @@ -1,11 +1,17 @@ import asyncio +import logging + from googletrans import Translator -from src.config import get_translation_language_code -from src.core.translation.base_translator import BaseTranslator + +from usta.application.results import EngineError +from usta.config import get_translation_language_code +from usta.core.translation.base_translator import BaseTranslator + +logger = logging.getLogger(__name__) class GoogleEngine(BaseTranslator): - def __init__(self, source='en', target='tr'): + def __init__(self, source="en", target="tr"): self.source = source self.target = target @@ -25,5 +31,6 @@ async def translate_text(): return asyncio.run(translate_text()) except Exception as e: - print(f"Google Translation Error: {e}") - return f"Error: {e}" + raise EngineError( + "translation", "Google translation failed; check the connection." + ) from e diff --git a/src/usta/core/translation/translator_manager.py b/src/usta/core/translation/translator_manager.py new file mode 100644 index 0000000..5a355c7 --- /dev/null +++ b/src/usta/core/translation/translator_manager.py @@ -0,0 +1,48 @@ +from usta.application.results import TranslationRequest, TranslationResult +from usta.core.registry import TRANSLATOR_FACTORIES, LazyEngines + + +class TranslatorManager: + def __init__(self): + self._source_lang = "en" + self._target_lang = "tr" + self._api_keys = {} + self.translators = LazyEngines(TRANSLATOR_FACTORIES, self._initialize) + self.current_translator_name = "Google" + + def _initialize(self, name, translator): + translator.set_languages(self._source_lang, self._target_lang) + if name in self._api_keys and hasattr(translator, "set_api_key"): + translator.set_api_key(self._api_keys[name]) + + def set_translator(self, name: str): + if name in self.translators: + self.current_translator_name = name + + def set_api_key(self, engine: str, api_key: str): + self._api_keys[engine] = api_key + translator = self.translators.instances.get(engine) + if translator and hasattr(translator, "set_api_key"): + translator.set_api_key(api_key) + + def set_languages(self, source: str, target: str): + self._source_lang, self._target_lang = source, target + for translator in self.translators.instances.values(): + translator.set_languages(source, target) + + def get_languages(self) -> tuple[str, str]: + return self._source_lang, self._target_lang + + def translate(self, text: str) -> str: + return self.translators[self.current_translator_name].translate(text) + + def translate_request(self, request: TranslationRequest) -> TranslationResult: + self.set_translator(request.engine_name) + self.set_languages(request.source_lang, request.target_lang) + return TranslationResult( + self.translate(request.text), + request.source_lang, + request.target_lang, + request.engine_name, + request.session_id, + ) diff --git a/src/i18n.py b/src/usta/i18n.py similarity index 86% rename from src/i18n.py rename to src/usta/i18n.py index a66f04b..c636f89 100644 --- a/src/i18n.py +++ b/src/usta/i18n.py @@ -1,9 +1,14 @@ import gettext -import os import locale +import logging +import os import unicodedata import warnings +from usta.resource_paths import resource_path + +logger = logging.getLogger(__name__) + # Global translator object _translator = None @@ -12,11 +17,7 @@ def _strip_invisible_characters(value): """Remove Unicode control/format characters from locale values.""" - return "".join( - character - for character in value - if unicodedata.category(character)[0] != "C" - ) + return "".join(character for character in value if unicodedata.category(character)[0] != "C") def _normalize_locale(value): @@ -72,17 +73,17 @@ def _get_locale_candidates(): return candidates + def init_i18n(locale_dir=None, domain="messages"): """ Initialize internationalization. If locale_dir is not provided, it defaults to 'src/translations'. """ global _translator - + if locale_dir is None: # Default translation directory: src/translations - base_dir = os.path.dirname(os.path.abspath(__file__)) - locale_dir = os.path.join(base_dir, "translations") + locale_dir = str(resource_path("resources/translations")) locale_candidates = _get_locale_candidates() @@ -90,13 +91,14 @@ def init_i18n(locale_dir=None, domain="messages"): try: languages = locale_candidates or None _translator = gettext.translation(domain, locale_dir, languages=languages, fallback=True) - _translator.install() # This installs _() into builtins + _translator.install() # This installs _() into builtins except Exception as e: - print(f"Warning: Could not load translation for {locale_candidates}: {e}") + logger.info(f"Warning: Could not load translation for {locale_candidates}: {e}") # Fallback to null translation _translator = gettext.NullTranslations() _translator.install() + def _(message): """ Helper function to translate a message. diff --git a/src/usta/infrastructure/__init__.py b/src/usta/infrastructure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/usta/infrastructure/legacy_settings.py b/src/usta/infrastructure/legacy_settings.py new file mode 100644 index 0000000..b6441c4 --- /dev/null +++ b/src/usta/infrastructure/legacy_settings.py @@ -0,0 +1,23 @@ +"""Read only primitive values from the application's previous local format.""" + +import pickle +from pathlib import Path + + +class PrimitiveUnpickler(pickle.Unpickler): + def find_class(self, module, name): + raise ValueError("Legacy settings cannot contain executable globals") + + def persistent_load(self, pid): + raise ValueError("Legacy settings cannot contain persistent references") + + +def load_legacy(path: Path) -> dict: + try: + with path.open("rb") as stream: + value = PrimitiveUnpickler(stream).load() + if not isinstance(value, dict): + raise ValueError("Legacy settings must be an object") + return value + except (pickle.UnpicklingError, EOFError, TypeError) as exc: + raise ValueError("Invalid legacy settings") from exc diff --git a/src/usta/infrastructure/logging_setup.py b/src/usta/infrastructure/logging_setup.py new file mode 100644 index 0000000..26185c5 --- /dev/null +++ b/src/usta/infrastructure/logging_setup.py @@ -0,0 +1,8 @@ +"""Application-owned logging configuration.""" + +import logging + + +def configure_logging(level: str = "INFO") -> None: + logging.basicConfig(level=level, format="%(levelname)s %(name)s: %(message)s") + logging.getLogger("httpx").setLevel(logging.WARNING) diff --git a/src/usta/infrastructure/paths.py b/src/usta/infrastructure/paths.py new file mode 100644 index 0000000..5cfba31 --- /dev/null +++ b/src/usta/infrastructure/paths.py @@ -0,0 +1,40 @@ +"""Runtime path policy; resolving paths does not create files.""" + +import tempfile +from dataclasses import dataclass +from pathlib import Path + +from usta.core.platform.capability_resolver import CONFIG_DIR + + +@dataclass(frozen=True) +class RuntimePaths: + config_dir: Path + capture_dir: Path + socket_path: Path + + +def resolve_paths() -> RuntimePaths: + temporary = Path(tempfile.gettempdir()) + return RuntimePaths( + Path(CONFIG_DIR), + Path("/dev/shm") if Path("/dev/shm").is_dir() else temporary, + temporary / "usta.sock", + ) + + +def prepare_paths(paths: RuntimePaths) -> None: + paths.config_dir.mkdir(parents=True, exist_ok=True) + + +class CaptureWorkspace: + """Only this object's files are removed during cleanup.""" + + def __init__(self): + base = resolve_paths().capture_dir + self._directory = tempfile.TemporaryDirectory(prefix="usta-", dir=base) + self.image_path = str(Path(self._directory.name) / "snapshot.jpeg") + self.fullscreen_path = str(Path(self._directory.name) / "full-screen.jpeg") + + def close(self) -> None: + self._directory.cleanup() diff --git a/src/usta/infrastructure/settings_store.py b/src/usta/infrastructure/settings_store.py new file mode 100644 index 0000000..fd86f0d --- /dev/null +++ b/src/usta/infrastructure/settings_store.py @@ -0,0 +1,100 @@ +"""Versioned, atomic settings storage with non-destructive legacy migration.""" + +import json +import logging +import os +import tempfile +from pathlib import Path + +from usta.application.settings import AppSettings, Preset +from usta.infrastructure.legacy_settings import load_legacy + +logger = logging.getLogger(__name__) + + +class SettingsStore: + def __init__(self, directory: Path): + self.directory = Path(directory) + self._blocked: set[str] = set() + + def _read(self, name: str) -> dict: + path = self.directory / f"{name}.json" + try: + if path.exists(): + content = json.loads(path.read_text(encoding="utf-8")) + if ( + not isinstance(content, dict) + or content.get("schema_version") != 1 + or not isinstance(content.get("data"), dict) + ): + raise ValueError("Unsupported settings format; original file preserved") + self._blocked.discard(name) + return content["data"] + legacy = self.directory / f"{name}.pkl" + return load_legacy(legacy) if legacy.exists() else {} + except (ValueError, OSError): + self._blocked.add(name) + raise + + def _write(self, name: str, data: dict) -> None: + if name in self._blocked: + raise ValueError("Cannot overwrite unreadable settings; original file preserved") + # Validate existing JSON even when save was called before load. + path = self.directory / f"{name}.json" + if path.exists(): + if name == "presets": + self.load_presets() + else: + self._read(name) + if name in self._blocked: + raise ValueError("Cannot overwrite malformed presets; original file preserved") + self.directory.mkdir(parents=True, exist_ok=True) + descriptor, temporary = tempfile.mkstemp(suffix=".tmp", dir=self.directory) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + json.dump( + {"schema_version": 1, "data": data}, stream, ensure_ascii=False, allow_nan=False + ) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + def load(self) -> AppSettings: + settings = AppSettings.from_dict(self._read("settings")) + if ( + not (self.directory / "settings.json").exists() + and (self.directory / "settings.pkl").exists() + ): + self.save(settings) + return AppSettings.from_dict(self._read("settings")) + return settings + + def save(self, settings: AppSettings) -> None: + self._write("settings", settings.to_dict()) + + def load_presets(self) -> dict[str, Preset]: + presets = {} + for name, value in self._read("presets").items(): + try: + if not isinstance(name, str): + raise ValueError("Preset names must be strings") + presets[name] = Preset.from_dict(value) + except ValueError: + self._blocked.add("presets") + logger.warning( + "Malformed preset found; valid entries recovered, original file protected" + ) + if ( + "presets" not in self._blocked + and not (self.directory / "presets.json").exists() + and (self.directory / "presets.pkl").exists() + ): + self.save_presets(presets) + return {name: Preset.from_dict(value) for name, value in self._read("presets").items()} + return presets + + def save_presets(self, presets: dict[str, Preset]) -> None: + self._write("presets", {name: value.to_dict() for name, value in presets.items()}) diff --git a/src/usta/infrastructure/socket_publisher.py b/src/usta/infrastructure/socket_publisher.py new file mode 100644 index 0000000..f9dc3d4 --- /dev/null +++ b/src/usta/infrastructure/socket_publisher.py @@ -0,0 +1,150 @@ +"""Bounded, non-blocking Unix socket translation broadcast.""" + +import errno +import json +import logging +import queue +import select +import socket +import stat +import threading +import time +from pathlib import Path + +from usta.config import SOCKET_PATH + +logger = logging.getLogger(__name__) + + +class TranslationPublisher: + MAX_PENDING_BYTES = 1024 * 1024 + + def __init__(self, path=None): + self.path = Path(path or SOCKET_PATH) + self._messages = queue.Queue(maxsize=256) + self._stopped = threading.Event() + self._stopped.set() + self._thread = None + self._server = None + self._identity = None + + def start(self): + if self._thread is not None and self._thread.is_alive(): + return + if self.path.exists(): + identity = self.path.lstat() + if not stat.S_ISSOCK(identity.st_mode): + raise OSError(errno.EADDRINUSE, "IPC path is not a socket") + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as probe: + probe.settimeout(0.2) + try: + probe.connect(str(self.path)) + except OSError as exc: + if exc.errno != errno.ECONNREFUSED: + raise + else: + raise OSError(errno.EADDRINUSE, "USTA IPC socket is already in use") + if self.path.lstat().st_ino != identity.st_ino: + raise OSError(errno.EADDRINUSE, "IPC path changed during startup") + self.path.unlink() + server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + server.bind(str(self.path)) + server.listen(5) + server.setblocking(False) + except OSError: + server.close() + raise + self._server = server + self._identity = self.path.stat().st_ino + self._messages = queue.Queue(maxsize=256) + self._stopped.clear() + self._thread = threading.Thread(target=self._run, name="usta-ipc", daemon=True) + self._thread.start() + + def stop(self): + self._stopped.set() + if self._thread is not None: + self._thread.join(timeout=2) + if self._thread.is_alive(): + logger.error("IPC thread did not stop within deadline") + return + self._thread = None + if self._server is not None: + self._server.close() + self._server = None + if self._identity is not None: + try: + if self.path.lstat().st_ino == self._identity: + self.path.unlink() + except FileNotFoundError: + pass + self._identity = None + + def broadcast(self, original, translated, source_lang="en", target_lang="tr", engine="unknown"): + if self._stopped.is_set(): + return + data = ( + json.dumps( + { + "original": original, + "translated": translated, + "source": source_lang, + "target": target_lang, + "engine": engine, + "timestamp": time.time(), + } + ) + + "\n" + ).encode("utf-8") + try: + self._messages.put_nowait(data) + except queue.Full: + logger.warning("IPC queue full; dropping broadcast") + + def _run(self): + clients = {} + try: + while not self._stopped.is_set(): + readable, writable, _ = select.select( + [self._server], [c for c, pending in clients.items() if pending], [], 0.05 + ) + if readable: + while True: + try: + client, _ = self._server.accept() + client.setblocking(False) + clients[client] = bytearray() + except BlockingIOError: + break + while True: + try: + data = self._messages.get_nowait() + except queue.Empty: + break + for client in list(clients): + pending = clients[client] + if len(pending) + len(data) > self.MAX_PENDING_BYTES: + client.close() + del clients[client] + else: + pending.extend(data) + for client in writable: + if client not in clients: + continue + try: + sent = client.send(clients[client]) + if sent == 0: + raise ConnectionError("IPC peer closed") + del clients[client][:sent] + except BlockingIOError: + pass + except OSError: + client.close() + del clients[client] + except OSError: + if not self._stopped.is_set(): + logger.exception("IPC loop failed") + finally: + for client in clients: + client.close() diff --git a/src/usta/main.py b/src/usta/main.py new file mode 100644 index 0000000..e8559e5 --- /dev/null +++ b/src/usta/main.py @@ -0,0 +1,36 @@ +import os +import sys + +from PySide6.QtGui import QIcon +from PySide6.QtWidgets import QApplication + +from usta.config import OS +from usta.i18n import init_i18n +from usta.infrastructure.logging_setup import configure_logging +from usta.infrastructure.paths import prepare_paths, resolve_paths +from usta.ui.main_window import ControlPanel + + +def main(): + configure_logging() + prepare_paths(resolve_paths()) + # Initialize internationalization + init_i18n() + + if OS == "Linux": + os.environ.setdefault("QT_QPA_PLATFORM", "xcb") + + app = QApplication(sys.argv) + app.setStyle("Fusion") + app.setQuitOnLastWindowClosed(False) + + # Set application icon + icon_path = os.path.join(os.path.dirname(__file__), "ui", "assets", "usta.png") + app.setWindowIcon(QIcon(icon_path)) + + app.control_panel = ControlPanel() # Retain the top-level widget for the event loop. + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/src/usta/resource_paths.py b/src/usta/resource_paths.py new file mode 100644 index 0000000..3a5bcc0 --- /dev/null +++ b/src/usta/resource_paths.py @@ -0,0 +1,16 @@ +"""Resolve installed resources, including resources from zipped packages.""" + +import atexit +from contextlib import ExitStack +from functools import lru_cache +from importlib.resources import as_file, files +from pathlib import Path + +_contexts = ExitStack() +atexit.register(_contexts.close) + + +@lru_cache(maxsize=None) +def resource_path(relative: str) -> Path: + """Keep extracted resources alive until application shutdown.""" + return _contexts.enter_context(as_file(files("usta").joinpath(relative))) diff --git a/src/core/ocr/ocr_models/rapidocr/arabic_PP-OCRv4_rec_infer.onnx b/src/usta/resources/ocr_models/rapidocr/arabic_PP-OCRv4_rec_infer.onnx similarity index 100% rename from src/core/ocr/ocr_models/rapidocr/arabic_PP-OCRv4_rec_infer.onnx rename to src/usta/resources/ocr_models/rapidocr/arabic_PP-OCRv4_rec_infer.onnx diff --git a/src/core/ocr/ocr_models/rapidocr/ch_PP-OCRv4_rec_infer.onnx b/src/usta/resources/ocr_models/rapidocr/ch_PP-OCRv4_rec_infer.onnx similarity index 100% rename from src/core/ocr/ocr_models/rapidocr/ch_PP-OCRv4_rec_infer.onnx rename to src/usta/resources/ocr_models/rapidocr/ch_PP-OCRv4_rec_infer.onnx diff --git a/src/core/ocr/ocr_models/rapidocr/cyrillic_PP-OCRv3_rec_infer.onnx b/src/usta/resources/ocr_models/rapidocr/cyrillic_PP-OCRv3_rec_infer.onnx similarity index 100% rename from src/core/ocr/ocr_models/rapidocr/cyrillic_PP-OCRv3_rec_infer.onnx rename to src/usta/resources/ocr_models/rapidocr/cyrillic_PP-OCRv3_rec_infer.onnx diff --git a/src/core/ocr/ocr_models/rapidocr/en_PP-OCRv4_rec_infer.onnx b/src/usta/resources/ocr_models/rapidocr/en_PP-OCRv4_rec_infer.onnx similarity index 100% rename from src/core/ocr/ocr_models/rapidocr/en_PP-OCRv4_rec_infer.onnx rename to src/usta/resources/ocr_models/rapidocr/en_PP-OCRv4_rec_infer.onnx diff --git a/src/core/ocr/ocr_models/rapidocr/japan_PP-OCRv4_rec_infer.onnx b/src/usta/resources/ocr_models/rapidocr/japan_PP-OCRv4_rec_infer.onnx similarity index 100% rename from src/core/ocr/ocr_models/rapidocr/japan_PP-OCRv4_rec_infer.onnx rename to src/usta/resources/ocr_models/rapidocr/japan_PP-OCRv4_rec_infer.onnx diff --git a/src/core/ocr/ocr_models/rapidocr/korean_PP-OCRv4_rec_infer.onnx b/src/usta/resources/ocr_models/rapidocr/korean_PP-OCRv4_rec_infer.onnx similarity index 100% rename from src/core/ocr/ocr_models/rapidocr/korean_PP-OCRv4_rec_infer.onnx rename to src/usta/resources/ocr_models/rapidocr/korean_PP-OCRv4_rec_infer.onnx diff --git a/src/core/ocr/ocr_models/rapidocr/latin_PP-OCRv5_rec_mobile.onnx b/src/usta/resources/ocr_models/rapidocr/latin_PP-OCRv5_rec_mobile.onnx similarity index 100% rename from src/core/ocr/ocr_models/rapidocr/latin_PP-OCRv5_rec_mobile.onnx rename to src/usta/resources/ocr_models/rapidocr/latin_PP-OCRv5_rec_mobile.onnx diff --git a/src/core/ocr/ocr_models/rapidocr/th_PP-OCRv5_rec_mobile_infer.onnx b/src/usta/resources/ocr_models/rapidocr/th_PP-OCRv5_rec_mobile_infer.onnx similarity index 100% rename from src/core/ocr/ocr_models/rapidocr/th_PP-OCRv5_rec_mobile_infer.onnx rename to src/usta/resources/ocr_models/rapidocr/th_PP-OCRv5_rec_mobile_infer.onnx diff --git a/src/usta/resources/translations/tr/LC_MESSAGES/messages.mo b/src/usta/resources/translations/tr/LC_MESSAGES/messages.mo new file mode 100644 index 0000000..1126e14 Binary files /dev/null and b/src/usta/resources/translations/tr/LC_MESSAGES/messages.mo differ diff --git a/src/translations/tr/LC_MESSAGES/messages.po b/src/usta/resources/translations/tr/LC_MESSAGES/messages.po similarity index 69% rename from src/translations/tr/LC_MESSAGES/messages.po rename to src/usta/resources/translations/tr/LC_MESSAGES/messages.po index 2f01ca7..a4904d8 100644 --- a/src/translations/tr/LC_MESSAGES/messages.po +++ b/src/usta/resources/translations/tr/LC_MESSAGES/messages.po @@ -163,14 +163,23 @@ msgstr "Kısayol Tuşları" msgid "Settings topmost key:" msgstr "Ayarları üstte tutma tuşu:" -msgid "Temporary region key:" -msgstr "Geçici bölge tuşu:" +msgid "Temporary region:" +msgstr "Geçici bölge:" -msgid "Use this shortcut to bring the main window to the front while playing a game or watching a video." -msgstr "Oyun oynarken veya video izlerken ana pencereyi ön plana getirmek için bu kısayolu kullanın." +msgid "" +"Use this shortcut to bring the main window to the front while playing a " +"game or watching a video." +msgstr "" +"Oyun oynarken veya video izlerken ana pencereyi ön plana getirmek için bu" +" kısayolu kullanın." -msgid "Use this shortcut when you want to select an area outside your current selection. The temporary area you select will be active for 10 seconds." -msgstr "Mevcut seçiminizin dışında başka bir alan seçmek istediğinizde bu kısayolu kullanın. Seçtiğiniz geçici alan 10 saniye boyunca aktif olacaktır." +msgid "" +"Use this shortcut when you want to select an area outside your current " +"selection. The temporary area you select will be active for 10 seconds." +msgstr "" +"Mevcut seçiminizin dışında başka bir alan seçmek istediğinizde bu " +"kısayolu kullanın. Seçtiğiniz geçici alan 10 saniye boyunca aktif " +"olacaktır." msgid "Reset" msgstr "Sıfırla" @@ -182,7 +191,9 @@ msgid "Click to set shortcut" msgstr "Kısayol atamak için tıkla" msgid "Click a shortcut field, then press the key combination you want to use." -msgstr "Bir kısayol alanına tıklayın, ardından kullanmak istediğiniz tuş kombinasyonuna basın." +msgstr "" +"Bir kısayol alanına tıklayın, ardından kullanmak istediğiniz tuş " +"kombinasyonuna basın." msgid "Invalid shortcut" msgstr "Geçersiz kısayol" @@ -234,3 +245,69 @@ msgstr "Lütfen yüklemek için bir hazır ayar seçin." #: src/ui/result_popup.py:101 msgid "Translation will appear here." msgstr "Çeviri burada görünecek." + +msgid "Running" +msgstr "Çalışıyor" + +msgid "Stopped" +msgstr "Durduruldu" + +msgid "No Name" +msgstr "İsim Yok" + +msgid "Please enter a preset name." +msgstr "Lütfen bir ön ayar adı girin." + +msgid "Not Found" +msgstr "Bulunamadı" + +msgid "Please select a preset to delete." +msgstr "Lütfen silinecek bir ön ayar seçin." + +#, python-brace-format +msgid "Preset '{name}' not found." +msgstr "'{name}' ön ayarı bulunamadı." + +#, python-brace-format +msgid "OCR: {ocr} s | Translation: {translation} s | Total: {total} s" +msgstr "OCR: {ocr} sn | Çeviri: {translation} sn | Toplam: {total} sn" + +msgid "Total includes screen capture and preprocessing." +msgstr "Toplam süre ekran yakalama ve ön işlemeyi de içerir." + +msgid "Select Region" +msgstr "Bölge Seç" + + +msgid "Hide Main Window" +msgstr "Ana ekranı gizle" + +msgid "Show Main Window" +msgstr "Ana ekranı göster" + +msgid "Hide Overlay" +msgstr "Overlay ekranını gizle" + +msgid "Show Overlay" +msgstr "Overlay ekranını göster" + +msgid "Select Temporary Region" +msgstr "Geçici bölge seç" + +msgid "Quit Application" +msgstr "Uygulamayı kapat" + +msgid "Main window:" +msgstr "Ana ekranı göster/gizle:" + +msgid "Show or hide the main window. When shown, it stays above other windows." +msgstr "Ana ekranı gösterir veya gizler. Gösterildiğinde diğer pencerelerin üzerinde kalır." + +msgid "Total" +msgstr "Toplam" + +msgid "Region" +msgstr "Bölge" + +msgid "You can access the USTA menu here." +msgstr "USTA menüsüne buradan ulaşabilirsiniz." diff --git a/src/usta/resources/translations/uk/LC_MESSAGES/messages.mo b/src/usta/resources/translations/uk/LC_MESSAGES/messages.mo new file mode 100644 index 0000000..9cc8c18 Binary files /dev/null and b/src/usta/resources/translations/uk/LC_MESSAGES/messages.mo differ diff --git a/src/translations/uk/LC_MESSAGES/messages.po b/src/usta/resources/translations/uk/LC_MESSAGES/messages.po similarity index 73% rename from src/translations/uk/LC_MESSAGES/messages.po rename to src/usta/resources/translations/uk/LC_MESSAGES/messages.po index 49293fa..a694637 100644 --- a/src/translations/uk/LC_MESSAGES/messages.po +++ b/src/usta/resources/translations/uk/LC_MESSAGES/messages.po @@ -81,7 +81,9 @@ msgstr "Недійсне сполучення клавіш" #: src/ui/main_window.py:301 src/ui/main_window.py:321 msgid "Could not register this shortcut. The previous shortcut is still active." -msgstr "Не вдалося зареєструвати це сполучення клавіш. Попереднє сполучення все ще активне." +msgstr "" +"Не вдалося зареєструвати це сполучення клавіш. Попереднє сполучення все " +"ще активне." #: src/ui/main_window.py:355 #, python-brace-format @@ -166,8 +168,8 @@ msgid "" "Use this shortcut to bring the main window to the front while playing a " "game or watching a video." msgstr "" -"Використовуйте це сполучення клавіш, щоб вивести головне вікно на передній " -"план під час гри або перегляду відео." +"Використовуйте це сполучення клавіш, щоб вивести головне вікно на " +"передній план під час гри або перегляду відео." #: src/ui/tabs/hotkey_tab.py:157 msgid "" @@ -175,19 +177,22 @@ msgid "" "selection. The temporary area you select will be active for 10 seconds." msgstr "" "Використовуйте це сполучення клавіш, коли потрібно вибрати область поза " -"поточним виділенням. Вибрана тимчасова область буде активною протягом 10 секунд." +"поточним виділенням. Вибрана тимчасова область буде активною протягом 10 " +"секунд." #: src/ui/tabs/hotkey_tab.py:165 msgid "Settings topmost key:" msgstr "Закріплення екрана:" #: src/ui/tabs/hotkey_tab.py:175 -msgid "Temporary region key:" -msgstr "Клавіша тимч. області:" +msgid "Temporary region:" +msgstr "Тимчасова область:" #: src/ui/tabs/hotkey_tab.py:181 msgid "Click a shortcut field, then press the key combination you want to use." -msgstr "Натисніть поле сполучення клавіш, а потім натисніть комбінацію клавіш, яку хочете використовувати." +msgstr "" +"Натисніть поле сполучення клавіш, а потім натисніть комбінацію клавіш, " +"яку хочете використовувати." #: src/ui/tabs/ocr_tab.py:11 msgid "OCR Engine:" @@ -256,3 +261,69 @@ msgstr "API-ключ" #: src/ui/tabs/translation_tab.py:46 msgid "Enter API key..." msgstr "Введіть API-ключ..." + +msgid "Running" +msgstr "Працює" + +msgid "Stopped" +msgstr "Зупинено" + +msgid "No Name" +msgstr "Немає назви" + +msgid "Please enter a preset name." +msgstr "Введіть назву набору налаштувань." + +msgid "Not Found" +msgstr "Не знайдено" + +msgid "Please select a preset to delete." +msgstr "Виберіть набір налаштувань для видалення." + +#, python-brace-format +msgid "Preset '{name}' not found." +msgstr "Набір налаштувань '{name}' не знайдено." + +#, python-brace-format +msgid "OCR: {ocr} s | Translation: {translation} s | Total: {total} s" +msgstr "OCR: {ocr} с | Переклад: {translation} с | Разом: {total} с" + +msgid "Total includes screen capture and preprocessing." +msgstr "Загальний час також включає захоплення екрана та попередню обробку." + +msgid "Select Region" +msgstr "Вибрати область" + + +msgid "Hide Main Window" +msgstr "Приховати головне вікно" + +msgid "Show Main Window" +msgstr "Показати головне вікно" + +msgid "Hide Overlay" +msgstr "Приховати накладення" + +msgid "Show Overlay" +msgstr "Показати накладення" + +msgid "Select Temporary Region" +msgstr "Вибрати тимчасову область" + +msgid "Quit Application" +msgstr "Вийти з програми" + +msgid "Main window:" +msgstr "Головне вікно:" + +msgid "Show or hide the main window. When shown, it stays above other windows." +msgstr "Показати або приховати головне вікно. Коли воно показане, воно залишається поверх інших вікон." + +msgid "Total" +msgstr "Загалом" + +msgid "Region" +msgstr "Область" + +msgid "You can access the USTA menu here." +msgstr "Меню USTA доступне тут." diff --git a/src/ui/__init__.py b/src/usta/ui/__init__.py similarity index 100% rename from src/ui/__init__.py rename to src/usta/ui/__init__.py diff --git a/src/usta/ui/assets/select-region.svg b/src/usta/ui/assets/select-region.svg new file mode 100644 index 0000000..5dfa1d0 --- /dev/null +++ b/src/usta/ui/assets/select-region.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/ui/assets/usta.png b/src/usta/ui/assets/usta.png similarity index 100% rename from src/ui/assets/usta.png rename to src/usta/ui/assets/usta.png diff --git a/src/ui/assets/usta.xcf b/src/usta/ui/assets/usta.xcf similarity index 100% rename from src/ui/assets/usta.xcf rename to src/usta/ui/assets/usta.xcf diff --git a/src/ui/assets/usta_cover.png b/src/usta/ui/assets/usta_cover.png similarity index 100% rename from src/ui/assets/usta_cover.png rename to src/usta/ui/assets/usta_cover.png diff --git a/src/usta/ui/main_window.py b/src/usta/ui/main_window.py new file mode 100644 index 0000000..f20da18 --- /dev/null +++ b/src/usta/ui/main_window.py @@ -0,0 +1,555 @@ +import logging +import os + +from PySide6.QtCore import QSignalBlocker, QSize, Qt, QTimer, Signal, Slot +from PySide6.QtGui import QColor, QIcon +from PySide6.QtWidgets import ( + QApplication, + QColorDialog, + QLabel, + QMessageBox, + QProgressBar, + QPushButton, + QTabWidget, + QVBoxLayout, + QWidget, +) + +from usta.application.controller import ApplicationController +from usta.application.hotkey_controller import HotkeyController +from usta.application.region_controller import RegionController +from usta.application.worker import OCRWorker +from usta.config import ( + OS, + SETTINGS_TOPMOST_HOTKEY, + TEMPORARY_REGION_HOTKEY, + get_language_code, +) +from usta.i18n import _ +from usta.resource_paths import resource_path +from usta.ui.overlay_window import TransparentOverlay +from usta.ui.region_selection import SniperFactory +from usta.ui.settings_presenter import SettingsPresenter +from usta.ui.system_tray import SystemTray +from usta.ui.tabs import ( + AboutTab, + AppearanceTab, + HotkeyTab, + OCRTab, + SaveTab, + StatusTab, + TranslationTab, +) + +logger = logging.getLogger(__name__) + + +class ControlPanel(QWidget): + temporary_region_hotkey_pressed = Signal() + + def __init__(self): + super().__init__() + self._closing = False + self._waiting_for_worker = False + self._shutdown_complete = False + self._region_selecting = False + self._loading_settings = True + self.settings_presenter = SettingsPresenter(self) + self.worker = OCRWorker() + self.overlay = TransparentOverlay() + self.controller = ApplicationController(self.worker) + self.region_controller = RegionController(self.controller, self) + self.region_controller.changed.connect(self._region_changed) + self._temporary_region_selecting = False + self._temporary_region_hotkey_armed = True + self.settings_topmost_hotkey = SETTINGS_TOPMOST_HOTKEY + self.temporary_region_hotkey = TEMPORARY_REGION_HOTKEY + self.hotkey_controller = HotkeyController( + self.temporary_region_hotkey, + self._emit_temporary_region_hotkey_pressed, + ) + self.temporary_region_hotkey_pressed.connect(self._handle_temporary_region_hotkey_pressed) + self.setWindowTitle("USTA") + + # Set window icon + icon_path = os.path.join(os.path.dirname(__file__), "assets", "usta.png") + self.setWindowIcon(QIcon(icon_path)) + + # Disable maximization + self.setWindowFlags(self.windowFlags() | Qt.MSWindowsFixedSizeDialogHint) + self.setWindowFlag(Qt.WindowMaximizeButtonHint, False) + self._macos_hotkey_window = None + if OS == "Darwin": + from usta.ui.platform.macos.hotkeys import MacOSHotkeyWindow + + # A decorated NSPanel can join another app's fullscreen Space. + self.setWindowFlags((self.windowFlags() & ~Qt.WindowType_Mask) | Qt.Tool) + self.setAttribute(Qt.WA_MacAlwaysShowToolWindow, True) + self.setAttribute(Qt.WA_ShowWithoutActivating, True) + self._macos_hotkey_window = MacOSHotkeyWindow(self) + self._normal_window_flags = self.windowFlags() + + layout = QVBoxLayout(self) + layout.setSizeConstraint(QVBoxLayout.SetFixedSize) # Fixed size according to content + + # --- Tabs --- + self.tabs = QTabWidget() + self.api_keys = {} + self.dpi_locked = False + self.ocr_tab = OCRTab() + self.translation_tab = TranslationTab() + self.appearance_tab = AppearanceTab() + self.hotkey_tab = HotkeyTab() + self.save_tab = SaveTab() + self.status_tab = StatusTab() + self.about_tab = AboutTab() + self.ocr_tab.engine_changed.connect(self.controller.set_engine) + self.ocr_tab.screen_engine_changed.connect(self.controller.set_screenshot_engine) + self.ocr_tab.changed.connect(self.save_settings) + self.ocr_tab.dpi_changed.connect(self.on_dpi_scale_changed) + self.ocr_tab.toggle_dpi_requested.connect(self._toggle_dpi_lock) + self.translation_tab.translator_changed.connect(self.controller.set_translator) + self.translation_tab.changed.connect(self.save_settings) + self.translation_tab.translator_selected.connect( + self.settings_presenter.on_translator_changed + ) + self.translation_tab.language_changed.connect(self.update_languages) + self.translation_tab.api_key_changed.connect(self.settings_presenter.on_api_key_changed) + self.appearance_tab.font_changed.connect(self.on_font_changed) + self.appearance_tab.font_size_changed.connect(self.on_font_size_changed) + self.appearance_tab.text_color_requested.connect(self.choose_color) + self.appearance_tab.background_color_requested.connect(self.choose_bg_color) + self.appearance_tab.opacity_changed.connect(self.on_bg_opacity_changed) + self.hotkey_tab.settings_hotkey_changed.connect(self.on_settings_topmost_hotkey_changed) + self.hotkey_tab.region_hotkey_changed.connect(self.on_temporary_region_hotkey_changed) + self.hotkey_tab.reset_settings_requested.connect(self.reset_settings_topmost_hotkey) + self.hotkey_tab.reset_region_requested.connect(self.reset_temporary_region_hotkey) + self.save_tab.save_requested.connect(self.settings_presenter.save_preset) + self.save_tab.load_requested.connect(self.settings_presenter.load_preset) + self.save_tab.delete_requested.connect(self.settings_presenter.delete_preset) + + self.tabs.addTab(self.status_tab, _("Status")) + self.tabs.addTab(self.ocr_tab, _("OCR")) + self.tabs.addTab(self.translation_tab, _("Translation")) + self.tabs.addTab(self.appearance_tab, _("Appearance")) + self.tabs.addTab(self.save_tab, _("Save")) + self.tabs.addTab(self.hotkey_tab, _("Hotkeys")) + self.tabs.addTab(self.about_tab, _("About")) + + layout.addWidget(self.tabs) + + # --- Performance Indicator (outside tabs) --- + layout.addWidget(QLabel(_("
Performance"))) + self.perf_bar = QProgressBar() + self.perf_bar.setRange(0, 100) + self.perf_bar.setValue(0) + self.perf_bar.setTextVisible(False) + self.perf_bar.setFixedHeight(10) + layout.addWidget(self.perf_bar) + + # --- Controls (outside tabs) --- + layout.addWidget(QLabel(_("Controls"))) + self.btn_reg = QPushButton(_("Select Region")) + self.btn_reg.setIcon(QIcon(str(resource_path("ui/assets/select-region.svg")))) + self.btn_reg.setIconSize(QSize(20, 20)) + self.btn_reg.setStyleSheet( + "background-color: #1565C0; color: white; font-weight: bold; padding: 10px;" + ) + self.btn_reg.clicked.connect(self.select_region) + layout.addWidget(self.btn_reg) + + self.btn_start = QPushButton(_("▶ Start")) + self.btn_start.setStyleSheet( + "background-color: #2E7D32; color: white; font-weight: bold; padding: 10px;" + ) + self.btn_start.clicked.connect(self.start) + layout.addWidget(self.btn_start) + + self.btn_stop = QPushButton(_("■ Stop")) + self.btn_stop.setStyleSheet( + "background-color: #C62828; color: white; font-weight: bold; padding: 10px;" + ) + self.btn_stop.clicked.connect(self.stop) + layout.addWidget(self.btn_stop) + + self.worker.error_occurred.connect(self.overlay.show_error) + self.worker.new_translation.connect(self.overlay.update_text) + self.worker.performance_update.connect(self.update_performance_bar) + self.worker.timings_update.connect(self.status_tab.update_timings) + self.worker.running_status.connect(self.update_system_status) + self.overlay.main_window_visibility_toggle_requested.connect(self.toggle_main_window) + + # Load settings or set defaults + self.load_settings() + + # Initialize status tab with current rect + self.update_rect_label() + self.refresh_preset_list() + self.tray = SystemTray(self) + self.show_main_window() + self.overlay.show() + + # Align overlay to the right of the control panel with a slight delay + # to ensure the window manager has placed the main window. + QTimer.singleShot(100, self.align_overlay) + + def align_overlay(self): + """Positions the overlay window next to the main window.""" + if self._closing: + return + + # Get the global position of the main window's frame + rect = self.frameGeometry() + target_x = rect.right() + 10 + target_y = rect.top() + + self.overlay.move(target_x, target_y) + + @Slot() + def toggle_main_window(self): + if self._closing: + return + if self.isVisible() and not self.isMinimized(): + self.hide_main_window() + else: + self.show_main_window() + + def show_main_window(self): + if self._closing: + return + self.setWindowState(self.windowState() & ~Qt.WindowMinimized) + if self._macos_hotkey_window is not None: + self._macos_hotkey_window.show() + return + # Retain normal window-manager decorations on Linux. + flags = self._normal_window_flags | Qt.WindowStaysOnTopHint + if self.windowFlags() != flags: + self.setWindowFlags(flags) + self.show() + self.raise_() + self.activateWindow() + + def hide_main_window(self): + self.hide() + if self._macos_hotkey_window is not None: + self._macos_hotkey_window.stop() + + @Slot() + def toggle_overlay(self): + if self._closing: + return + if self.overlay.isVisible(): + self.overlay.hide() + else: + self.overlay.show() + if self.overlay._macos_rules is not None: + self.overlay._macos_rules.order_front_without_activation() + else: + self.overlay.raise_() + + def on_font_changed(self, font): + self.overlay.set_font_family(font.family()) + self.save_settings() + + def on_font_size_changed(self, size): + self.overlay.set_font_size(size) + self.save_settings() + + def on_dpi_scale_changed(self, percent_value): + self.controller.dpi_scale = percent_value / 100.0 + self.dpi_locked = True + self.ocr_tab.dpi_lock_label.setText("🔒") + self.ocr_tab.dpi_lock_label.setToolTip(_("DPI locked (click to unlock)")) + self.save_settings() + + def _toggle_dpi_lock(self, event): + self.dpi_locked = not self.dpi_locked + if self.dpi_locked: + self.ocr_tab.dpi_lock_label.setText("🔒") + self.ocr_tab.dpi_lock_label.setToolTip(_("DPI locked (click to unlock)")) + else: + self.ocr_tab.dpi_lock_label.setText("🔓") + self.ocr_tab.dpi_lock_label.setToolTip(_("DPI unlocked (click to lock)")) + return True + + def on_bg_opacity_changed(self, opacity): + self.overlay.set_bg_opacity(opacity) + self.save_settings() + + def _apply_saved_hotkeys(self, settings_topmost_hotkey, temporary_region_hotkey): + self._set_settings_topmost_hotkey(settings_topmost_hotkey, save=False, show_error=False) + self._set_temporary_region_hotkey(temporary_region_hotkey, save=False, show_error=False) + self._sync_hotkey_buttons() + + def _sync_hotkey_buttons(self): + if hasattr(self.hotkey_tab, "settings_topmost_hotkey_button"): + self.hotkey_tab.settings_topmost_hotkey_button.set_hotkey(self.settings_topmost_hotkey) + if hasattr(self.hotkey_tab, "temporary_region_hotkey_button"): + self.hotkey_tab.temporary_region_hotkey_button.set_hotkey(self.temporary_region_hotkey) + + def _set_settings_topmost_hotkey(self, hotkey, save=True, show_error=True): + if self.overlay.set_settings_topmost_hotkey(hotkey): + self.settings_topmost_hotkey = hotkey + self._sync_hotkey_buttons() + if save: + self.save_settings() + return True + + if show_error: + QMessageBox.warning( + self, + _("Invalid shortcut"), + _("Could not register this shortcut. The previous shortcut is still active."), + ) + self._sync_hotkey_buttons() + return False + + def _set_temporary_region_hotkey(self, hotkey, save=True, show_error=True): + if hotkey == self.temporary_region_hotkey: + self._sync_hotkey_buttons() + return True + + if self.hotkey_controller.set_hotkey(hotkey): + self.temporary_region_hotkey = hotkey + self._sync_hotkey_buttons() + if save: + self.save_settings() + return True + + if show_error: + QMessageBox.warning( + self, + _("Invalid shortcut"), + _("Could not register this shortcut. The previous shortcut is still active."), + ) + self._sync_hotkey_buttons() + return False + + def on_settings_topmost_hotkey_changed(self, hotkey): + self._set_settings_topmost_hotkey(hotkey) + + def on_temporary_region_hotkey_changed(self, hotkey): + self._set_temporary_region_hotkey(hotkey) + + def reset_settings_topmost_hotkey(self): + self._set_settings_topmost_hotkey(SETTINGS_TOPMOST_HOTKEY) + + def reset_temporary_region_hotkey(self): + self._set_temporary_region_hotkey(TEMPORARY_REGION_HOTKEY) + + def update_system_status(self, is_running): + self.overlay.set_mode(is_running) + if is_running: + self.status_tab.system_indicator.setStyleSheet( + "background-color: #2E7D32; border-radius: 4px;" + ) + self.status_tab.system_status_label.setText(_("Running")) + else: + self.status_tab.system_indicator.setStyleSheet( + "background-color: #C62828; border-radius: 4px;" + ) + self.status_tab.system_status_label.setText(_("Stopped")) + self.status_tab.reset_timings() + self.perf_bar.setValue(0) + self.perf_bar.setStyleSheet("") + + def update_rect_label(self): + self.status_tab.update_region(self.controller.capture_rect) + + def update_performance_bar(self, duration): + # Capture + preprocessing + OCR + translation: 0.5s -> 100%, 3.0s -> 0%. + if not self.worker.isRunning(): + return + percent = int(100 - (((duration - 0.5) / 2.5) * 100)) + percent = max(0, min(100, percent)) + + self.perf_bar.setValue(percent) + + # Color determination based on percentage (Health bar logic) + if percent >= 75: + color = "#2E7D32" # Green (Great) + elif percent >= 35: + color = "#F9A825" # Yellow/Orange (Normal) + else: + color = "#C62828" # Red (Poor) + + self.perf_bar.setStyleSheet(f""" + QProgressBar::chunk {{ + background-color: {color}; + border-radius: 2px; + }} + QProgressBar {{ + border: 1px solid gray; + border-radius: 3px; + background: #E0E0E0; + }} + """) + + def update_languages(self): + source_code = get_language_code(self.translation_tab.combo_source.currentText(), "en") + target_code = get_language_code(self.translation_tab.combo_target.currentText(), "tr") + self.controller.set_languages(source_code, target_code) + self.save_settings() + + def choose_color(self): + # Parent dialogs to the Tool panel so closing them cannot quit the app. + color = QColorDialog.getColor(QColor(self.overlay.font_color), self) + if color.isValid(): + color_name = color.name() + self.overlay.set_font_color(color_name) + self.appearance_tab.color_sample.setStyleSheet( + f"background-color: {color_name}; border: 1px solid gray; border-radius: 4px;" + ) + self.save_settings() + + def choose_bg_color(self): + color = QColorDialog.getColor(QColor(self.overlay.bg_color), self) + if color.isValid(): + color_name = color.name() + self.overlay.set_bg_color(color_name) + self.appearance_tab.bg_color_sample.setStyleSheet( + f"background-color: {color_name}; border: 1px solid gray; border-radius: 4px;" + ) + self.save_settings() + + def _emit_temporary_region_hotkey_pressed(self): + self.temporary_region_hotkey_pressed.emit() + + @Slot() + def _handle_temporary_region_hotkey_pressed(self): + if ( + self._closing + or self._region_selecting + or not self.worker.isRunning() + or self._temporary_region_selecting + or not self._temporary_region_hotkey_armed + ): + return + + self._temporary_region_hotkey_armed = False + QTimer.singleShot(200, self._rearm_temporary_region_hotkey) + QTimer.singleShot(200, self.select_temporary_region) + + def _rearm_temporary_region_hotkey(self): + self._temporary_region_hotkey_armed = True + + def _get_selected_region_and_dpi(self): + QApplication.processEvents() + + sniper = SniperFactory.get_engine() + rect = sniper.get_region() + if not rect: + return None, None + + detected_dpi = getattr(sniper, "detected_dpi", None) + return rect, detected_dpi + + def _region_changed(self, text): + self.overlay.label.setText(text) + self.update_rect_label() + + def _apply_main_region(self, rect, dpi_scale=None): + if self.dpi_locked: + dpi_scale = None + if dpi_scale is not None: + blocker = QSignalBlocker(self.ocr_tab.dpi_scale_spin) + self.ocr_tab.dpi_scale_spin.setValue(dpi_scale * 100) + del blocker + self.region_controller.apply_main_region(rect, dpi_scale) + self.save_settings() + + def _apply_temporary_region(self, rect, dpi_scale=None): + self.region_controller.apply_temporary_region(rect, None if self.dpi_locked else dpi_scale) + + def _restore_main_region(self): + self.region_controller.restore_main_region() + + def select_temporary_region(self): + if ( + self._closing + or not self.worker.isRunning() + or self._temporary_region_selecting + or self._region_selecting + ): + return + self._temporary_region_selecting = True + try: + rect, detected_dpi = self._get_selected_region_and_dpi() + finally: + self._temporary_region_selecting = False + + if rect: + self._apply_temporary_region(rect, detected_dpi) + + def select_region(self): + if self._closing or self._region_selecting or self._temporary_region_selecting: + return + self._region_selecting = True + QTimer.singleShot(200, self._select_region_now) + + def _select_region_now(self): + try: + if self._closing: + return + rect, detected_dpi = self._get_selected_region_and_dpi() + if rect: + self._apply_main_region(rect, detected_dpi) + finally: + self._region_selecting = False + + def start(self): + self.overlay.set_mode(True) + self.controller.start() + + def stop(self): + self.controller.stop() + self.overlay.set_mode(False) + self.perf_bar.setValue(0) + self.perf_bar.setStyleSheet("") + + @Slot() + def request_quit(self): + self._closing = True + self.close() + + def closeEvent(self, event): + if self._shutdown_complete: + event.accept() + return + if not self._closing and self.tray.available: + event.ignore() + self.hide_main_window() + return + self._closing = True + if self.worker.isRunning(): + event.ignore() + if not self._waiting_for_worker: + self._waiting_for_worker = True + self.worker.finished.connect(self.close) + self.controller.stop() + return + self.worker.close() + if self._macos_hotkey_window is not None: + self._macos_hotkey_window.stop() + self.region_controller.restore_timer.stop() + self.hotkey_controller.close() + self.controller.stop() + self.overlay.close() + self.tray.close() + self._shutdown_complete = True + super().closeEvent(event) + if event.isAccepted(): + QApplication.instance().quit() + + # ---------- API Key Helpers ---------- + + # ---------- Preset Management ---------- + + def save_settings(self): + self.settings_presenter.save_settings() + + def load_settings(self): + self.settings_presenter.load_settings() + + def refresh_preset_list(self): + self.settings_presenter.refresh_preset_list() diff --git a/src/ui/overlay_window.py b/src/usta/ui/overlay_window.py similarity index 50% rename from src/ui/overlay_window.py rename to src/usta/ui/overlay_window.py index 1965289..93c320c 100644 --- a/src/ui/overlay_window.py +++ b/src/usta/ui/overlay_window.py @@ -1,9 +1,11 @@ from PySide6.QtCore import Qt, QTimer, Signal, Slot -from PySide6.QtGui import QColor, QPainter, QPainterPath, QPen -from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel -from src.config import SETTINGS_TOPMOST_HOTKEY -from src.core.shortcut import GlobalHotkey -from src.i18n import _ +from PySide6.QtGui import QColor, QCursor, QPainter, QPainterPath, QPen +from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget + +from usta.config import OS, SETTINGS_TOPMOST_HOTKEY +from usta.core.shortcut import GlobalHotkey +from usta.i18n import _ + class CornerLabel(QLabel): def __init__(self, parent=None): @@ -30,7 +32,9 @@ def paintEvent(self, event): painter = QPainter(self) painter.setRenderHint(QPainter.Antialiasing, True) - painter.setPen(QPen(color, self.corner_line_thickness, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)) + painter.setPen( + QPen(color, self.corner_line_thickness, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin) + ) offset = self.corner_line_thickness / 2 left = offset @@ -75,26 +79,40 @@ def paintEvent(self, event): class TransparentOverlay(QWidget): - main_window_topmost_requested = Signal(bool) + RESIZE_MARGIN = 20 + MINIMUM_RESIZE_WIDTH = 200 + MINIMUM_RESIZE_HEIGHT = 60 + + main_window_visibility_toggle_requested = Signal() settings_topmost_hotkey_pressed = Signal() def __init__(self): super().__init__() self.setWindowTitle("USTA_TRANSLATION_OVERLAY") - self.setWindowFlags(Qt.WindowStaysOnTopHint | - Qt.FramelessWindowHint | - Qt.WindowDoesNotAcceptFocus | - Qt.Tool | - Qt.X11BypassWindowManagerHint) + window_flags = ( + Qt.WindowStaysOnTopHint | Qt.FramelessWindowHint | Qt.WindowDoesNotAcceptFocus | Qt.Tool + ) + if OS == "Linux": + window_flags |= Qt.X11BypassWindowManagerHint + self.setWindowFlags(window_flags) self.setAttribute(Qt.WA_TranslucentBackground, True) self.setAttribute(Qt.WA_ShowWithoutActivating) + self._macos_rules = None + self._macos_cursor = None + if OS == "Darwin": + from usta.ui.platform.macos.overlay import MacOSOverlayRules + from usta.ui.platform.macos.overlay_cursor import MacOSOverlayCursor + + self.setAttribute(Qt.WA_MacAlwaysShowToolWindow, True) + self._macos_rules = MacOSOverlayRules(self) + self._macos_cursor = MacOSOverlayCursor(self) self.setMouseTracking(True) self.resize(800, 200) - + self.layout = QVBoxLayout(self) self.layout.setContentsMargins(0, 0, 0, 0) - + # Style variables self.font_family = "Arial" self.font_size = 20 @@ -102,16 +120,16 @@ def __init__(self): self.bg_color = "#000000" self.bg_opacity = 180 self._show_corner_lines = False - + + self._showing_error = False self.label = CornerLabel(self) - self.label.setText("Translation will appear here.") + self.label.setText(_("Translation will appear here.")) self.label.setAttribute(Qt.WA_TransparentForMouseEvents) self.label.setWordWrap(True) self.label.setAlignment(Qt.AlignCenter) self.update_style() self.layout.addWidget(self.label) - self._main_window_topmost_requested = False self._settings_topmost_hotkey_armed = True self.settings_topmost_hotkey = SETTINGS_TOPMOST_HOTKEY self._settings_topmost_hotkey = GlobalHotkey( @@ -120,20 +138,31 @@ def __init__(self): ) self.settings_topmost_hotkey_pressed.connect(self._handle_settings_topmost_hotkey_pressed) self._settings_topmost_hotkey.start() - + self.hide_timer = QTimer(self) self.hide_timer.setSingleShot(True) self.hide_timer.timeout.connect(lambda: self.label.setText("")) - self.set_mode(False) - self.timer = QTimer(self) - self.timer.timeout.connect(self.raise_) - self.timer.start(2000) - - # Manual drag state (X11BypassWindowManagerHint bypasses WM) - self._drag_mode = None # "move" or "resize" + # Manual drag state (X11BypassWindowManagerHint bypasses WM). + self._drag_mode = None + self._drag_edges = Qt.Edges() self._drag_start_pos = None self._drag_start_geometry = None + self.set_mode(False) + + def showEvent(self, event): + super().showEvent(event) + if self._macos_rules is not None: + self._macos_rules.start() + if self._macos_cursor is not None: + self._macos_cursor.start() + + def hideEvent(self, event): + if self._macos_rules is not None: + self._macos_rules.stop() + if self._macos_cursor is not None: + self._macos_cursor.stop() + super().hideEvent(event) def _emit_settings_topmost_hotkey_pressed(self): self.settings_topmost_hotkey_pressed.emit() @@ -144,7 +173,7 @@ def _handle_settings_topmost_hotkey_pressed(self): return self._settings_topmost_hotkey_armed = False - self._toggle_main_window_topmost() + self._toggle_main_window_visibility() QTimer.singleShot(200, self._rearm_settings_topmost_hotkey) def _rearm_settings_topmost_hotkey(self): @@ -164,14 +193,23 @@ def update_style(self): self.label.setStyleSheet(style) self.label.update() + def _set_hovered(self, hovered): + self._show_corner_lines = hovered + self.label.set_corner_lines_visible(hovered) + def enterEvent(self, event): - self._show_corner_lines = True - self.label.set_corner_lines_visible(True) + self._set_hovered(True) + if self._drag_mode is None: + self._update_hover_cursor(self.mapFromGlobal(QCursor.pos())) super().enterEvent(event) def leaveEvent(self, event): - self._show_corner_lines = False - self.label.set_corner_lines_visible(False) + self._set_hovered(False) + if self._drag_mode is None: + if self._macos_cursor is not None: + self._macos_cursor.mouse_exited() + self.unsetCursor() + self.label.unsetCursor() super().leaveEvent(event) @Slot(str) @@ -201,13 +239,22 @@ def set_bg_opacity(self, opacity): @Slot(str) def update_text(self, text): + self._showing_error = False self.label.setText(text) - self.raise_() + if self.isVisible(): + if self._macos_rules is not None: + self._macos_rules.order_front_without_activation() + else: + self.raise_() self.hide_timer.start(10000) - def _toggle_main_window_topmost(self): - self._main_window_topmost_requested = not self._main_window_topmost_requested - self.main_window_topmost_requested.emit(self._main_window_topmost_requested) + @Slot(str) + def show_error(self, text): + self.update_text(text) + self._showing_error = True + + def _toggle_main_window_visibility(self): + self.main_window_visibility_toggle_requested.emit() def set_settings_topmost_hotkey(self, hotkey): if hotkey == self.settings_topmost_hotkey: @@ -223,58 +270,114 @@ def set_settings_topmost_hotkey(self, hotkey): return True def closeEvent(self, event): + if self._macos_cursor is not None: + self._macos_cursor.stop() + if self._macos_rules is not None: + self._macos_rules.stop() self._settings_topmost_hotkey.stop() super().closeEvent(event) def set_mode(self, scan): self.setAttribute(Qt.WA_TransparentForMouseEvents, False) - self.setCursor(Qt.ArrowCursor if scan else Qt.SizeAllCursor) + if self._drag_mode is None: + self._update_hover_cursor(self.mapFromGlobal(QCursor.pos())) if scan: + if self._showing_error: + self._showing_error = False + self.label.setText(_("Translation will appear here.")) self.hide_timer.start(10000) else: self.hide_timer.stop() - self.label.setText(_("Translation will appear here.")) + if not self._showing_error: + self.label.setText(_("Translation will appear here.")) + + def _resize_edges_at(self, position): + edges = Qt.Edges() + if not self.rect().contains(position): + return edges + if position.x() < self.RESIZE_MARGIN: + edges |= Qt.LeftEdge + elif position.x() >= self.width() - self.RESIZE_MARGIN: + edges |= Qt.RightEdge + if position.y() < self.RESIZE_MARGIN: + edges |= Qt.TopEdge + elif position.y() >= self.height() - self.RESIZE_MARGIN: + edges |= Qt.BottomEdge + return edges + + @staticmethod + def _cursor_for_edges(edges): + horizontal = bool(edges & (Qt.LeftEdge | Qt.RightEdge)) + vertical = bool(edges & (Qt.TopEdge | Qt.BottomEdge)) + if horizontal and vertical: + if bool(edges & Qt.LeftEdge) == bool(edges & Qt.TopEdge): + return Qt.SizeFDiagCursor + return Qt.SizeBDiagCursor + if horizontal: + return Qt.SizeHorCursor + if vertical: + return Qt.SizeVerCursor + return Qt.SizeAllCursor + + def _set_interaction_cursor(self, cursor): + self.setCursor(cursor) + # Keep the full-size text child consistent with its interactive parent. + self.label.setCursor(cursor) + if self._macos_cursor is not None: + self._macos_cursor.apply_cursor(cursor) + + def _update_hover_cursor(self, position): + cursor = ( + self._cursor_for_edges(self._resize_edges_at(position)) + if self.rect().contains(position) + else Qt.ArrowCursor + ) + self._set_interaction_cursor(cursor) def mousePressEvent(self, event): - if event.button() == Qt.LeftButton: - self._drag_start_pos = event.globalPosition().toPoint() - self._drag_start_geometry = self.geometry() - if event.position().x() > self.width() - 20 and event.position().y() > self.height() - 20: - self._drag_mode = "resize" - self.setCursor(Qt.SizeFDiagCursor) - else: - self._drag_mode = "move" - self.setCursor(Qt.SizeAllCursor) - event.accept() + if event.button() != Qt.LeftButton: + super().mousePressEvent(event) + return + self._drag_start_pos = event.globalPosition().toPoint() + self._drag_start_geometry = self.geometry() + self._drag_edges = self._resize_edges_at(event.position().toPoint()) + self._drag_mode = "resize" if self._drag_edges else "move" + self._set_interaction_cursor(self._cursor_for_edges(self._drag_edges)) + event.accept() def mouseMoveEvent(self, event): if self._drag_mode is None: - if event.position().x() > self.width() - 20 and event.position().y() > self.height() - 20: - self.setCursor(Qt.SizeFDiagCursor) - else: - self.setCursor(Qt.SizeAllCursor) + self._update_hover_cursor(event.position().toPoint()) return - if self._drag_start_pos is None: return - delta = event.globalPosition().toPoint() - self._drag_start_pos + geometry = self._drag_start_geometry if self._drag_mode == "move": - self.move(self._drag_start_geometry.x() + delta.x(), - self._drag_start_geometry.y() + delta.y()) - elif self._drag_mode == "resize": - new_w = max(200, self._drag_start_geometry.width() + delta.x()) - new_h = max(60, self._drag_start_geometry.height() + delta.y()) - self.resize(new_w, new_h) + self.move(geometry.topLeft() + delta) + else: + # Anchor the opposite edges while enforcing the existing size limits. + geometry = self._drag_start_geometry.adjusted(0, 0, 0, 0) + min_width = max(self.MINIMUM_RESIZE_WIDTH, self.minimumWidth()) + min_height = max(self.MINIMUM_RESIZE_HEIGHT, self.minimumHeight()) + if self._drag_edges & Qt.LeftEdge: + geometry.setLeft(min(geometry.left() + delta.x(), geometry.right() - min_width + 1)) + elif self._drag_edges & Qt.RightEdge: + geometry.setWidth(max(min_width, geometry.width() + delta.x())) + if self._drag_edges & Qt.TopEdge: + geometry.setTop(min(geometry.top() + delta.y(), geometry.bottom() - min_height + 1)) + elif self._drag_edges & Qt.BottomEdge: + geometry.setHeight(max(min_height, geometry.height() + delta.y())) + self.setGeometry(geometry) event.accept() def mouseReleaseEvent(self, event): - if event.button() == Qt.LeftButton: - self._drag_mode = None - self._drag_start_pos = None - self._drag_start_geometry = None - if event.position().x() > self.width() - 20 and event.position().y() > self.height() - 20: - self.setCursor(Qt.SizeFDiagCursor) - else: - self.setCursor(Qt.SizeAllCursor) - event.accept() + if event.button() != Qt.LeftButton: + super().mouseReleaseEvent(event) + return + self._drag_mode = None + self._drag_edges = Qt.Edges() + self._drag_start_pos = None + self._drag_start_geometry = None + self._update_hover_cursor(event.position().toPoint()) + event.accept() diff --git a/src/usta/ui/platform/__init__.py b/src/usta/ui/platform/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/usta/ui/platform/macos/__init__.py b/src/usta/ui/platform/macos/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/usta/ui/platform/macos/hotkeys.py b/src/usta/ui/platform/macos/hotkeys.py new file mode 100644 index 0000000..544d669 --- /dev/null +++ b/src/usta/ui/platform/macos/hotkeys.py @@ -0,0 +1,108 @@ +"""Native panel actions for macOS settings and region-selection shortcuts.""" + +import logging +import os + +from usta.ui.platform.macos.overlay import MacOSOverlayRules + +logger = logging.getLogger(__name__) + + +class MacOSHotkeyWindow: + def __init__(self, widget): + self.widget = widget + self.rules = MacOSOverlayRules(widget) + self._normal_behavior = None + self._previous_app = None + try: + import AppKit + + window = self._window() + self._normal_behavior = window.collectionBehavior() + # Prepare before the first show, not after a fullscreen transition. + if window.isKindOfClass_(AppKit.NSPanel): + window.setStyleMask_( + window.styleMask() | AppKit.NSWindowStyleMaskNonactivatingPanel + ) + except Exception as ex: + logger.error("Could not prepare macOS hotkey panel: %s", ex) + + def _window(self): + import objc + + return objc.objc_object(c_void_p=int(self.widget.winId())).window() + + def show(self, focus=True): + # Showing a Qt tool window can change activation before makeKeyWindow. + # Remember the caller's app before any native window/show operations. + self._remember_application() + self.rules.start() + self.rules.prepare() + self.widget.show() + self.rules.order_front_without_activation() + if focus: + self.focus() + + def focus(self): + try: + self._remember_application() + # Interactive settings/selection need keyboard input. Unlike Qt's + # activateWindow/raise path, the panel stays in the fullscreen Space. + self._window().makeKeyWindow() + except Exception as ex: + logger.error("Could not focus macOS hotkey panel: %s", ex) + + def _remember_application(self): + try: + import AppKit + + previous = AppKit.NSWorkspace.sharedWorkspace().frontmostApplication() + if previous is not None and previous.processIdentifier() != os.getpid(): + self._previous_app = previous + except Exception as ex: + logger.error("Could not remember application for macOS hotkey action: %s", ex) + + def lower(self): + self.rules.stop() + try: + import AppKit + + window = self._window() + window.resignKeyWindow() + if self._normal_behavior is not None: + window.setCollectionBehavior_(self._normal_behavior) + window.setLevel_(AppKit.NSNormalWindowLevel) + window.orderBack_(None) + except Exception as ex: + logger.error("Could not lower macOS hotkey panel: %s", ex) + self._restore_focus() + + def _restore_focus(self): + previous, self._previous_app = self._previous_app, None + if previous is None: + return + try: + import AppKit + + current = AppKit.NSWorkspace.sharedWorkspace().frontmostApplication() + if ( + current is not None + and current.processIdentifier() == os.getpid() + and not previous.isTerminated() + ): + # macOS 14+ uses cooperative activation; the old ignore-other- + # apps flag no longer guarantees handing focus back. + if hasattr(AppKit.NSApp, "yieldActivationToApplication_"): + AppKit.NSApp.yieldActivationToApplication_(previous) + previous.activateFromApplication_options_( + AppKit.NSRunningApplication.currentApplication(), + 0, + ) + else: + previous.activateWithOptions_(AppKit.NSApplicationActivateIgnoringOtherApps) + except Exception as ex: + logger.error("Could not restore focus after macOS hotkey action: %s", ex) + + def stop(self): + self.rules.stop() + self._restore_focus() diff --git a/src/usta/ui/platform/macos/overlay.py b/src/usta/ui/platform/macos/overlay.py new file mode 100644 index 0000000..359b3d4 --- /dev/null +++ b/src/usta/ui/platform/macos/overlay.py @@ -0,0 +1,138 @@ +"""Keep a Qt overlay's Cocoa rules in sync with macOS window transitions.""" + +import logging +import weakref + +from PySide6.QtCore import QEvent, QObject, QTimer + +logger = logging.getLogger(__name__) + + +def _remove_observers(observers): + for center, token in observers: + center.removeObserver_(token) + observers.clear() + + +class MacOSOverlayRules(QObject): + def __init__(self, widget): + super().__init__(widget) + self._widget = widget + self._observers = [] + self._running = False + self._immediate = QTimer(self) + self._settled = QTimer(self) + for timer in (self._immediate, self._settled): + timer.setSingleShot(True) + timer.timeout.connect(self._apply) + widget.installEventFilter(self) + # The cleanup closure must not retain this QObject or the widget. + observers = self._observers + self.destroyed.connect(lambda: _remove_observers(observers)) + + def start(self): + if self._running: + self._schedule() + return + try: + import AppKit + + reference = weakref.ref(self) + + def changed(_notification): + rules = reference() + if rules is not None: + rules._schedule() + + workspace = AppKit.NSWorkspace.sharedWorkspace().notificationCenter() + local = AppKit.NSNotificationCenter.defaultCenter() + notifications = ( + (workspace, AppKit.NSWorkspaceActiveSpaceDidChangeNotification), + (workspace, AppKit.NSWorkspaceDidActivateApplicationNotification), + (local, AppKit.NSWindowDidEnterFullScreenNotification), + (local, AppKit.NSWindowDidExitFullScreenNotification), + ) + for center, name in notifications: + token = center.addObserverForName_object_queue_usingBlock_( + name, + None, + AppKit.NSOperationQueue.mainQueue(), + changed, + ) + self._observers.append((center, token)) + self._running = True + self._schedule() + except Exception as ex: + _remove_observers(self._observers) + logger.info(f"macOS overlay bildirimleri başlatılamadı: {ex}") + + def stop(self): + self._running = False + self._immediate.stop() + self._settled.stop() + _remove_observers(self._observers) + + def eventFilter(self, watched, event): + if event.type() in (QEvent.Show, QEvent.WinIdChange, QEvent.WindowStateChange): + self._schedule() + return super().eventFilter(watched, event) + + def _schedule(self): + if self._running: + self._immediate.start(0) + # Qt/Cocoa may update native flags later in the transition. + self._settled.start(350) + + def order_front_without_activation(self): + if not self._widget.isVisible(): + return + try: + import objc + + view = objc.objc_object(c_void_p=int(self._widget.winId())) + window = view.window() + if window is not None: + # Qt.raise_ also activates NSApp, stealing fullscreen focus. + window.orderFrontRegardless() + except Exception as ex: + logger.info(f"macOS overlay öne getirilemedi: {ex}") + + def prepare(self): + """Configure a panel before ordering it into an existing fullscreen Space.""" + self._apply(allow_hidden=True) + + def _apply(self, allow_hidden=False): + if not self._running or (not allow_hidden and not self._widget.isVisible()): + return + try: + import AppKit + import objc + + # Qt's macOS winId is an NSView; resolve its current owning window. + view = objc.objc_object(c_void_p=int(self._widget.winId())) + window = view.window() + if window is None: + return + if self._widget.hasMouseTracking(): + # Nonactivating overlays need hover events before any mouse press. + window.setAcceptsMouseMovedEvents_(True) + # Qt.Tool creates an NSPanel, but WindowDoesNotAcceptFocus does not + # give it Cocoa's nonactivating style. Without that style the panel + # remains on the desktop when another app enters a fullscreen Space. + if window.isKindOfClass_(AppKit.NSPanel): + style = window.styleMask() | AppKit.NSWindowStyleMaskNonactivatingPanel + if style != window.styleMask(): + window.setStyleMask_(style) + behavior = ( + AppKit.NSWindowCollectionBehaviorCanJoinAllSpaces + | AppKit.NSWindowCollectionBehaviorFullScreenAuxiliary + | AppKit.NSWindowCollectionBehaviorStationary + ) + # Older macOS/PyObjC versions may not expose this behavior. + behavior |= getattr(AppKit, "NSWindowCollectionBehaviorCanJoinAllApplications", 0) + # Build the mask explicitly: primary/auxiliary conflict with joining all apps. + window.setCollectionBehavior_(behavior) + window.setLevel_(AppKit.NSStatusWindowLevel) + window.setHidesOnDeactivate_(False) + except Exception as ex: + logger.info(f"macOS pencere kuralları uygulanamadı: {ex}") diff --git a/src/usta/ui/platform/macos/overlay_cursor.py b/src/usta/ui/platform/macos/overlay_cursor.py new file mode 100644 index 0000000..30c044b --- /dev/null +++ b/src/usta/ui/platform/macos/overlay_cursor.py @@ -0,0 +1,189 @@ +"""Cursor tracking for nonactivating overlays, including while NSApp is inactive. + +Qt/Cocoa skips mouseMovedImpl/cursorUpdate for an inactive application. An +always-active native tracking area updates only this overlay's cursor and never +activates the application or monitors events from other applications. +""" + +import weakref + +import AppKit +import objc +from PySide6.QtCore import QBuffer, QIODevice, QObject, QPoint, Qt +from PySide6.QtGui import QColor, QPainter, QPainterPath, QPen, QPixmap + + +def _remove_tracking_area(state): + view, area = state["view"], state["area"] + if view is not None and area is not None: + view.removeTrackingArea_(area) + state.update(view=None, area=None, owner=None) + _restore_cursor(state) + + +def _restore_cursor(state): + previous, applied = state["previous_cursor"], state["applied_cursor"] + if previous is not None and AppKit.NSCursor.currentCursor() == applied: + previous.set() + state.update(previous_cursor=None, applied_cursor=None) + + +class _USTAOverlayTrackingOwner(AppKit.NSObject): + def mouseEntered_(self, event): + if controller := self.reference(): + controller.mouse_moved(event) + + def mouseMoved_(self, event): + if controller := self.reference(): + controller.mouse_moved(event) + + def mouseExited_(self, event): + if controller := self.reference(): + controller.mouse_exited() + + +def _diagonal_cursor(shape): + """Public-API fallback for macOS versions predating frame-resize cursors.""" + pixmap = QPixmap(48, 48) + pixmap.setDevicePixelRatio(2) + pixmap.fill(Qt.transparent) + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.Antialiasing) + painter.translate(12, 12) + painter.rotate(45 if shape == Qt.SizeFDiagCursor else -45) + path = QPainterPath() + path.moveTo(-8, 0) + path.lineTo(8, 0) + for x, inward in ((-8, 1), (8, -1)): + path.moveTo(x + inward * 4, -4) + path.lineTo(x, 0) + path.lineTo(x + inward * 4, 4) + for color, width in (("white", 4.5), ("black", 2.0)): + painter.strokePath( + path, QPen(QColor(color), width, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin) + ) + painter.end() + buffer = QBuffer() + buffer.open(QIODevice.WriteOnly) + pixmap.save(buffer, "PNG") + image = AppKit.NSImage.alloc().initWithData_(bytes(buffer.data())) + image.setSize_((24, 24)) + return AppKit.NSCursor.alloc().initWithImage_hotSpot_(image, (12, 12)) + + +class MacOSOverlayCursor(QObject): + def __init__(self, widget): + super().__init__(widget) + self._widget = weakref.ref(widget) + self._hovering = False + self._cursors = {} + self._state = dict( + view=None, area=None, owner=None, previous_cursor=None, applied_cursor=None + ) + state = self._state + self.destroyed.connect(lambda: _remove_tracking_area(state)) + + def start(self): + widget = self._widget() + view = objc.objc_object(c_void_p=int(widget.winId())) + if self._state["view"] == view: + return + self.stop() + owner = _USTAOverlayTrackingOwner.alloc().init() + owner.reference = weakref.ref(self) + options = ( + AppKit.NSTrackingMouseEnteredAndExited + | AppKit.NSTrackingMouseMoved + | AppKit.NSTrackingActiveAlways + | AppKit.NSTrackingInVisibleRect + | AppKit.NSTrackingEnabledDuringMouseDrag + ) + area = AppKit.NSTrackingArea.alloc().initWithRect_options_owner_userInfo_( + view.bounds(), options, owner, None + ) + self._state.update(view=view, area=area, owner=owner) + view.addTrackingArea_(area) + + def stop(self): + self._hovering = False + _remove_tracking_area(self._state) + + def mouse_moved(self, event): + widget, view = self._widget(), self._state["view"] + if widget is None or view is None or not widget.isVisible(): + return + window = view.window() + if window is None: + return + if widget._drag_mode is None: + screen_point = window.convertPointToScreen_(event.locationInWindow()) + top_window = AppKit.NSWindow.windowNumberAtPoint_belowWindowWithWindowNumber_( + screen_point, 0 + ) + if top_window != window.windowNumber(): + self.mouse_exited() + return + point = view.convertPoint_fromView_(event.locationInWindow(), None) + position = QPoint( + round(point.x), round(point.y if view.isFlipped() else widget.height() - point.y) + ) + if not widget.rect().contains(position): + self.mouse_exited() + return + self._hovering = True + widget._set_hovered(True) + if widget._drag_mode is None: + widget._update_hover_cursor(position) + else: + self.apply_cursor(widget.cursor().shape()) + + def mouse_exited(self): + widget = self._widget() + if widget is not None and widget._drag_mode is not None: + return # Keep the drag cursor until release, even outside the window. + self._hovering = False + if widget is not None: + widget._set_hovered(False) + widget.unsetCursor() + widget.label.unsetCursor() + _restore_cursor(self._state) + + def apply_cursor(self, shape): + if shape == Qt.ArrowCursor: + self.mouse_exited() + return + widget = self._widget() + if widget is None or (not self._hovering and widget._drag_mode is None): + return + if self._state["previous_cursor"] is None: + self._state["previous_cursor"] = AppKit.NSCursor.currentCursor() + dragging = widget._drag_mode == "move" + key = (shape, dragging) + if key not in self._cursors: + self._cursors[key] = self._make_cursor(shape, dragging) + cursor = self._cursors[key] + cursor.set() # QWidget.setCursor alone is ignored while NSApp is inactive. + self._state["applied_cursor"] = cursor + + @staticmethod + def _make_cursor(shape, dragging): + if shape == Qt.SizeAllCursor: + return ( + AppKit.NSCursor.closedHandCursor() if dragging else AppKit.NSCursor.openHandCursor() + ) + if shape == Qt.SizeHorCursor: + return AppKit.NSCursor.resizeLeftRightCursor() + if shape == Qt.SizeVerCursor: + return AppKit.NSCursor.resizeUpDownCursor() + if shape in (Qt.SizeFDiagCursor, Qt.SizeBDiagCursor): + if hasattr(AppKit.NSCursor, "frameResizeCursorFromPosition_inDirections_"): + position = ( + AppKit.NSCursorFrameResizePositionTopLeft + if shape == Qt.SizeFDiagCursor + else AppKit.NSCursorFrameResizePositionTopRight + ) + return AppKit.NSCursor.frameResizeCursorFromPosition_inDirections_( + position, AppKit.NSCursorFrameResizeDirectionsAll + ) + return _diagonal_cursor(shape) + return AppKit.NSCursor.arrowCursor() diff --git a/src/usta/ui/region_selection/__init__.py b/src/usta/ui/region_selection/__init__.py new file mode 100644 index 0000000..021a777 --- /dev/null +++ b/src/usta/ui/region_selection/__init__.py @@ -0,0 +1,9 @@ +from usta.ui.region_selection.base_sniper import BaseSniper +from usta.ui.region_selection.slurp_sniper import SlurpSniper +from usta.ui.region_selection.sniper_factory import SniperFactory + +__all__ = [ + "BaseSniper", + "SlurpSniper", + "SniperFactory", +] diff --git a/src/core/sniper/base_sniper.py b/src/usta/ui/region_selection/base_sniper.py similarity index 99% rename from src/core/sniper/base_sniper.py rename to src/usta/ui/region_selection/base_sniper.py index 9e9a1d9..b5d85c2 100644 --- a/src/core/sniper/base_sniper.py +++ b/src/usta/ui/region_selection/base_sniper.py @@ -1,6 +1,8 @@ from abc import ABC, abstractmethod + from PySide6.QtCore import QRect + class BaseSniper(ABC): @abstractmethod def get_region(self) -> QRect | None: diff --git a/src/core/sniper/core_sniper.py b/src/usta/ui/region_selection/core_sniper.py similarity index 69% rename from src/core/sniper/core_sniper.py rename to src/usta/ui/region_selection/core_sniper.py index 203bd55..4e4a32c 100644 --- a/src/core/sniper/core_sniper.py +++ b/src/usta/ui/region_selection/core_sniper.py @@ -1,11 +1,15 @@ -import os -from PySide6.QtCore import Qt, QRect, QPoint, Signal, QObject, QEventLoop -from PySide6.QtGui import QPainter, QColor, QPen +import sys + +from PySide6.QtCore import QEventLoop, QObject, QPoint, QRect, Qt, Signal +from PySide6.QtGui import QColor, QCursor, QPainter, QPen from PySide6.QtWidgets import QApplication, QWidget -from src.core.sniper.base_sniper import BaseSniper + +from usta.ui.region_selection.base_sniper import BaseSniper + class SelectionManager(QObject): """Manages the selection state across multiple monitors.""" + selectionChanged = Signal() selectionFinished = Signal(QRect) @@ -33,26 +37,33 @@ def finishSelection(self): rect = QRect(self.start_pos, self.end_pos).normalized() self.selectionFinished.emit(rect) + class OverlayWindow(QWidget): """An overlay window for a single screen.""" + def __init__(self, screen, manager): super().__init__() self.manager = manager self.screen_geometry = screen.geometry() - - self.setWindowFlags( - Qt.FramelessWindowHint | - Qt.WindowStaysOnTopHint | - Qt.X11BypassWindowManagerHint | - Qt.Tool - ) + + flags = Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool + if sys.platform != "darwin": + flags |= Qt.X11BypassWindowManagerHint + self.setWindowFlags(flags) self.setAttribute(Qt.WA_TranslucentBackground) - + self._macos_hotkey_window = None + if sys.platform == "darwin": + from usta.ui.platform.macos.hotkeys import MacOSHotkeyWindow + + self.setAttribute(Qt.WA_ShowWithoutActivating) + self.setAttribute(Qt.WA_MacAlwaysShowToolWindow) + self._macos_hotkey_window = MacOSHotkeyWindow(self) + self.setGeometry(self.screen_geometry) self.setCursor(Qt.CrossCursor) self.setMouseTracking(True) self.setFocusPolicy(Qt.StrongFocus) - + self.manager.selectionChanged.connect(self.update) def paintEvent(self, event): @@ -62,7 +73,7 @@ def paintEvent(self, event): if self.manager.is_selecting: global_rect = QRect(self.manager.start_pos, self.manager.end_pos).normalized() local_intersect = global_rect.intersected(self.screen_geometry) - + if not local_intersect.isEmpty(): local_rect = local_intersect.translated(-self.screen_geometry.topLeft()) painter.setCompositionMode(QPainter.CompositionMode_Clear) @@ -87,6 +98,12 @@ def keyPressEvent(self, event): if event.key() == Qt.Key_Escape: self.manager.selectionFinished.emit(QRect()) + def closeEvent(self, event): + if self._macos_hotkey_window is not None: + self._macos_hotkey_window.stop() + super().closeEvent(event) + + class CoreSniper(BaseSniper): def __init__(self): super().__init__() @@ -109,15 +126,29 @@ def on_finished(rect): windows = [] for screen in QApplication.screens(): win = OverlayWindow(screen, manager) - win.show() - win.activateWindow() - win.raise_() + if win._macos_hotkey_window is not None: + win._macos_hotkey_window.show(focus=False) + else: + win.show() + win.activateWindow() + win.raise_() windows.append(win) + if sys.platform == "darwin" and windows: + target = next( + (win for win in windows if win.screen_geometry.contains(QCursor.pos())), windows[0] + ) + target._macos_hotkey_window.focus() + loop.exec() - + for win in windows: win.close() + if sys.platform == "darwin": + # Native helpers retain the widget in Python reference cycles. + # Schedule C++ destruction on the GUI thread before a later OCR + # allocation can collect those cycles on its worker thread. + win.deleteLater() rect = result_rect[0] if rect is not None: @@ -127,5 +158,5 @@ def on_finished(rect): if screen.geometry().contains(center): self.detected_dpi = screen.devicePixelRatio() break - - return rect \ No newline at end of file + + return rect diff --git a/src/core/sniper/slurp_sniper.py b/src/usta/ui/region_selection/slurp_sniper.py similarity index 81% rename from src/core/sniper/slurp_sniper.py rename to src/usta/ui/region_selection/slurp_sniper.py index a028c8d..a177a11 100644 --- a/src/core/sniper/slurp_sniper.py +++ b/src/usta/ui/region_selection/slurp_sniper.py @@ -1,22 +1,28 @@ +import logging import os import time + from PySide6.QtCore import QRect from PySide6.QtWidgets import QApplication -from src.core.sniper.base_sniper import BaseSniper -from src.config import SLURP_TEMP_PATH + +from usta.config import SLURP_TEMP_PATH +from usta.ui.region_selection.base_sniper import BaseSniper + +logger = logging.getLogger(__name__) + class SlurpSniper(BaseSniper): def get_region(self) -> QRect | None: """Selects a region on the screen using slurp.""" if os.path.exists(SLURP_TEMP_PATH): os.remove(SLURP_TEMP_PATH) - + try: # Run slurp and write output to file os.system(f"slurp -f '%x,%y %w,%h' > {SLURP_TEMP_PATH} 2>/dev/null &") start = time.time() output = "" - while time.time() - start < 30: # 30 seconds timeout + while time.time() - start < 30: # 30 seconds timeout QApplication.processEvents() if os.path.exists(SLURP_TEMP_PATH) and os.path.getsize(SLURP_TEMP_PATH) > 0: time.sleep(0.1) @@ -24,7 +30,7 @@ def get_region(self) -> QRect | None: output = f.read().strip() break time.sleep(0.1) - + if output: p = output.split() if len(p) >= 2: @@ -32,7 +38,7 @@ def get_region(self) -> QRect | None: w, h = map(int, p[1].split(",")) return QRect(x, y, w, h) except Exception as e: - print(f"Region Selection Error (slurp): {e}") + logger.error(f"Region Selection Error (slurp): {e}") finally: if os.path.exists(SLURP_TEMP_PATH): os.remove(SLURP_TEMP_PATH) diff --git a/src/core/sniper/sniper_factory.py b/src/usta/ui/region_selection/sniper_factory.py similarity index 56% rename from src/core/sniper/sniper_factory.py rename to src/usta/ui/region_selection/sniper_factory.py index f0cbf83..64f949f 100644 --- a/src/core/sniper/sniper_factory.py +++ b/src/usta/ui/region_selection/sniper_factory.py @@ -1,11 +1,11 @@ -from src.core.sniper.base_sniper import BaseSniper -from src.core.sniper.slurp_sniper import SlurpSniper -from src.core.sniper.core_sniper import CoreSniper +from usta.ui.region_selection.base_sniper import BaseSniper +from usta.ui.region_selection.core_sniper import CoreSniper + class SniperFactory: @staticmethod def get_engine() -> BaseSniper: # Currently only Slurp (Wayland) is supported. # Future implementations can check OS/DE and return the appropriate engine. - #return SlurpSniper() + # return SlurpSniper() return CoreSniper() diff --git a/src/usta/ui/settings_presenter.py b/src/usta/ui/settings_presenter.py new file mode 100644 index 0000000..f6390d1 --- /dev/null +++ b/src/usta/ui/settings_presenter.py @@ -0,0 +1,321 @@ +"""Settings and preset presentation, separate from window lifecycle.""" + +import logging +from pathlib import Path + +from PySide6.QtCore import QRect, QSignalBlocker +from PySide6.QtGui import QFont +from PySide6.QtWidgets import QMessageBox, QWidget + +from usta.application.settings import AppSettings, Preset +from usta.config import ( + CONFIG_DIR, + DPI_SCALE_DEFAULT, + SETTINGS_TOPMOST_HOTKEY, + TEMPORARY_REGION_HOTKEY, +) +from usta.i18n import _ +from usta.infrastructure.settings_store import SettingsStore + +logger = logging.getLogger(__name__) + + +class SettingsPresenter: + def __init__(self, view, store=None): + self.view = view + self._loaded_settings = AppSettings() + self.store = store if store is not None else SettingsStore(Path(CONFIG_DIR)) + + def save_settings(self): + if self.view._loading_settings: + return + settings = { + "ocr_engine": self.view.ocr_tab.combo_ocr.currentText(), + "translator_engine": self.view.translation_tab.combo_translator.currentText(), + "source_lang": self.view.translation_tab.combo_source.currentText(), + "target_lang": self.view.translation_tab.combo_target.currentText(), + "font_family": self.view.appearance_tab.font_picker.currentFont().family(), + "font_size": self.view.appearance_tab.font_size_spin.value(), + "font_color": self.view.overlay.font_color, + "bg_color": self.view.overlay.bg_color, + "bg_opacity": self.view.overlay.bg_opacity, + "translator_api_keys": self.view.api_keys, + "capture_rect": ( + self.view.controller.capture_rect.x(), + self.view.controller.capture_rect.y(), + self.view.controller.capture_rect.width(), + self.view.controller.capture_rect.height(), + ), + "dpi_scale": self.view.controller.dpi_scale, + "screenshot_engine": self.view.ocr_tab.combo_screenshot.currentText(), + "settings_topmost_hotkey": self.view.settings_topmost_hotkey, + "temporary_region_hotkey": self.view.temporary_region_hotkey, + } + try: + updated = AppSettings.from_dict({**self._loaded_settings.to_dict(), **settings}) + self.store.save(updated) + self._loaded_settings = updated + logger.info("Settings saved.") + except Exception as e: + logger.error(f"Settings save error: {e}") + + def load_settings(self): + self.view._loading_settings = True + blockers = [QSignalBlocker(widget) for widget in self.view.findChildren(QWidget)] + try: + self._loaded_settings = self.store.load() + s = self._loaded_settings.to_dict() + # Apply UI values while signals and persistence are blocked. + self.view.ocr_tab.combo_ocr.setCurrentText(s.get("ocr_engine", "Tesseract")) + self.view.translation_tab.combo_translator.setCurrentText( + s.get("translator_engine", "Google") + ) + self.view.translation_tab.combo_source.setCurrentText(s.get("source_lang", "English")) + self.view.translation_tab.combo_target.setCurrentText(s.get("target_lang", "Turkish")) + + font_family = s.get("font_family", "Arial") + self.view.appearance_tab.font_picker.setCurrentFont(QFont(font_family)) + self.view.overlay.set_font_family(font_family) + + font_size = s.get("font_size", 20) + self.view.appearance_tab.font_size_spin.setValue(font_size) + self.view.overlay.set_font_size(font_size) + + font_color = s.get("font_color", "white") + self.view.overlay.set_font_color(font_color) + self.view.appearance_tab.color_sample.setStyleSheet( + f"background-color: {font_color}; border: 1px solid gray; border-radius: 4px;" + ) + + bg_color = s.get("bg_color", "#000000") + self.view.overlay.set_bg_color(bg_color) + self.view.appearance_tab.bg_color_sample.setStyleSheet( + f"background-color: {bg_color}; border: 1px solid gray; border-radius: 4px;" + ) + + bg_opacity = s.get("bg_opacity", 180) + self.view.appearance_tab.bg_opacity_spin.setValue(bg_opacity) + self.view.overlay.set_bg_opacity(bg_opacity) + + # Load DPI scale + dpi_scale = s.get("dpi_scale", DPI_SCALE_DEFAULT) + + self.view.ocr_tab.dpi_scale_spin.setValue(dpi_scale * 100) + + self.view.controller.dpi_scale = dpi_scale + self.view.region_controller.main_dpi_scale = self.view.controller.dpi_scale + + rect = s.get("capture_rect") + if rect: + self.view.controller.set_rect(QRect(rect[0], rect[1], rect[2], rect[3])) + self.view.region_controller.main_capture_rect = QRect( + self.view.controller.capture_rect + ) + self.view.update_rect_label() + + # Load screenshot engine + screenshot_engine = s.get("screenshot_engine", "Portal") + self.view.ocr_tab.combo_screenshot.setCurrentText(screenshot_engine) + self.view.controller.set_screenshot_engine(screenshot_engine) + + # Load API keys + self.view.api_keys = s.get("translator_api_keys", {}) + self._apply_api_key_for_current_engine() + + self.view._apply_saved_hotkeys( + s.get("settings_topmost_hotkey", SETTINGS_TOPMOST_HOTKEY), + s.get("temporary_region_hotkey", TEMPORARY_REGION_HOTKEY), + ) + + self.view.controller.set_engine(self.view.ocr_tab.combo_ocr.currentText()) + self.view.controller.set_translator( + self.view.translation_tab.combo_translator.currentText() + ) + self.view.update_languages() + except (OSError, ValueError) as exc: + logger.error(f"Settings load error: {exc}") + self.view._apply_saved_hotkeys(SETTINGS_TOPMOST_HOTKEY, TEMPORARY_REGION_HOTKEY) + self.view.update_languages() + finally: + del blockers + self.view._loading_settings = False + + def _apply_api_key_for_current_engine(self): + """Update the API key textbox state and value for the current engine.""" + engine = self.view.translation_tab.combo_translator.currentText() + key = self.view.api_keys.get(engine, "") + # Block signals to avoid triggering on_api_key_changed during load + self.view.translation_tab.api_key_input.blockSignals(True) + self.view.translation_tab.api_key_input.setText(key) + self.view.translation_tab.api_key_input.blockSignals(False) + # Push to worker so the engine uses the saved key + if key: + self.view.controller.set_api_key(engine, key) + # Toggle editable state based on engine (always visible, fixed window size) + self._update_api_key_state(engine) + + def _update_api_key_state(self, engine: str): + """Enable/disable the API key field based on engine, keep it always visible.""" + engines_needing_key = {"DeepL"} + if engine in engines_needing_key: + self.view.translation_tab.api_key_input.setReadOnly(False) + self.view.translation_tab.api_key_input.setEnabled(True) + self.view.translation_tab.api_key_input.setToolTip("") + else: + self.view.translation_tab.api_key_input.setReadOnly(True) + self.view.translation_tab.api_key_input.setEnabled(False) + self.view.translation_tab.api_key_input.setToolTip( + _("This engine does not require an API key") + ) + + def on_translator_changed(self, engine: str): + """Called when the translation engine combo box changes.""" + self._apply_api_key_for_current_engine() + + def on_api_key_changed(self, text: str): + """Called when the user types in the API key text box.""" + engine = self.view.translation_tab.combo_translator.currentText() + self.view.api_keys[engine] = text + self.view.controller.set_api_key(engine, text) + self.save_settings() + + def _load_presets_dict(self): + try: + return {name: preset.to_dict() for name, preset in self.store.load_presets().items()} + except (OSError, ValueError) as exc: + logger.error(f"Presets load error: {exc}") + return {} + + def _save_presets_dict(self, presets): + try: + self.store.save_presets( + {name: Preset.from_dict(value) for name, value in presets.items()} + ) + return True + except (OSError, ValueError) as exc: + logger.error(f"Presets save error: {exc}") + return False + + def refresh_preset_list(self): + """Refresh the combo box with preset names.""" + self.view.save_tab.preset_combo.clear() + presets = self._load_presets_dict() + for name in sorted(presets.keys()): + self.view.save_tab.preset_combo.addItem(name) + + def save_preset(self): + """Save current overlay position/size + capture rect + appearance to a named preset.""" + name = self.view.save_tab.preset_name_input.text().strip() + if not name: + QMessageBox.warning(self.view, _("No Name"), _("Please enter a preset name.")) + return + + preset = { + "capture_rect": ( + self.view.controller.capture_rect.x(), + self.view.controller.capture_rect.y(), + self.view.controller.capture_rect.width(), + self.view.controller.capture_rect.height(), + ), + "overlay_x": self.view.overlay.pos().x(), + "overlay_y": self.view.overlay.pos().y(), + "overlay_w": self.view.overlay.width(), + "overlay_h": self.view.overlay.height(), + "font_family": self.view.overlay.font_family, + "font_size": self.view.overlay.font_size, + "font_color": self.view.overlay.font_color, + "bg_color": self.view.overlay.bg_color, + "bg_opacity": self.view.overlay.bg_opacity, + } + + presets = self._load_presets_dict() + presets[name] = preset + if not self._save_presets_dict(presets): + return + self.refresh_preset_list() + self.view.save_tab.preset_name_input.clear() + logger.info(f"Preset '{name}' saved.") + + def load_preset(self): + """Load the selected preset from the combo box and apply it.""" + name = self.view.save_tab.preset_combo.currentText() + if not name: + QMessageBox.information( + self.view, _("No Selection"), _("Please select a preset to load.") + ) + return + presets = self._load_presets_dict() + preset = presets.get(name) + if preset is None: + QMessageBox.warning( + self.view, _("Not Found"), _("Preset '{name}' not found.").format(name=name) + ) + return + + # Apply capture rect + rect_data = preset.get("capture_rect") + if rect_data: + self.view.region_controller.apply_main_region(QRect(*rect_data)) + self.view.update_rect_label() + + # Apply overlay position and size + ox = preset.get("overlay_x") + oy = preset.get("overlay_y") + ow = preset.get("overlay_w") + oh = preset.get("overlay_h") + if ox is not None and oy is not None: + self.view.overlay.move(ox, oy) + if ow is not None and oh is not None: + self.view.overlay.resize(ow, oh) + + # Apply appearance + ff = preset.get("font_family") + if ff: + self.view.overlay.set_font_family(ff) + self.view.appearance_tab.font_picker.setCurrentFont(QFont(ff)) + + fs = preset.get("font_size") + if fs: + self.view.overlay.set_font_size(fs) + self.view.appearance_tab.font_size_spin.setValue(fs) + + fc = preset.get("font_color") + if fc: + self.view.overlay.set_font_color(fc) + self.view.appearance_tab.color_sample.setStyleSheet( + f"background-color: {fc}; border: 1px solid gray; border-radius: 4px;" + ) + + bgc = preset.get("bg_color") + if bgc: + self.view.overlay.set_bg_color(bgc) + self.view.appearance_tab.bg_color_sample.setStyleSheet( + f"background-color: {bgc}; border: 1px solid gray; border-radius: 4px;" + ) + + bgo = preset.get("bg_opacity") + if bgo is not None: + self.view.overlay.set_bg_opacity(bgo) + self.view.appearance_tab.bg_opacity_spin.setValue(bgo) + + logger.info(f"Preset '{name}' loaded.") + + def delete_preset(self): + """Delete the selected preset.""" + name = self.view.save_tab.preset_combo.currentText() + if not name: + QMessageBox.information( + self.view, _("No Selection"), _("Please select a preset to delete.") + ) + return + presets = self._load_presets_dict() + if name in presets: + del presets[name] + if not self._save_presets_dict(presets): + return + self.refresh_preset_list() + logger.info(f"Preset '{name}' deleted.") + else: + QMessageBox.warning( + self.view, _("Not Found"), _("Preset '{name}' not found.").format(name=name) + ) diff --git a/src/usta/ui/system_tray.py b/src/usta/ui/system_tray.py new file mode 100644 index 0000000..8cda928 --- /dev/null +++ b/src/usta/ui/system_tray.py @@ -0,0 +1,119 @@ +"""System tray actions backed by the control panel's shared window actions.""" + +import logging + +from PySide6.QtCore import QObject, QRect, QTimer +from PySide6.QtGui import QCursor, QGuiApplication +from PySide6.QtWidgets import QMenu, QSystemTrayIcon + +from usta.config import OS +from usta.i18n import _ +from usta.ui.tray_hint import TrayHint + +logger = logging.getLogger(__name__) + + +class SystemTray(QObject): + def __init__(self, panel): + super().__init__(panel) + self.panel = panel + self.menu = QMenu(panel) + self.main_action = self.menu.addAction("", panel.toggle_main_window) + self.overlay_action = self.menu.addAction("", panel.toggle_overlay) + self.menu.addSeparator() + self.start_action = self.menu.addAction(_("▶ Start"), panel.start) + self.stop_action = self.menu.addAction(_("■ Stop"), panel.stop) + self.menu.addSeparator() + self.region_action = self.menu.addAction(_("Select Region"), panel.select_region) + self.temporary_region_action = self.menu.addAction( + _("Select Temporary Region"), panel._handle_temporary_region_hotkey_pressed + ) + self.menu.addSeparator() + self.quit_action = self.menu.addAction(_("Quit Application"), panel.request_quit) + self.menu.aboutToShow.connect(self.refresh) + self.icon = QSystemTrayIcon(panel.windowIcon(), self) + self.icon.setToolTip("USTA") + self.icon.setContextMenu(self.menu) + self.refresh() + self.icon.show() + self.hint = TrayHint() + self.destroyed.connect(self.hint.deleteLater) + self._hint_shown = False + self._hint_attempts = 0 + self.startup_timer = QTimer(self) + self.startup_timer.setSingleShot(True) + self.startup_timer.timeout.connect(self._show_startup_hint) + self.startup_timer.start(500) + if not self.available: + logger.warning("System tray is unavailable; closing the main window will exit USTA.") + + def _show_startup_hint(self): + self.startup_timer.stop() + if self.panel._closing or self._hint_shown or not self.available: + return + if self.hint.show_at(self._icon_geometry()): + self._hint_shown = True + elif self._hint_attempts < 5: + self._hint_attempts += 1 + self.startup_timer.start(200) + + def _icon_geometry(self): + anchor = self.icon.geometry() + if not anchor.isEmpty() or OS != "Darwin" or QGuiApplication.platformName() != "cocoa": + return anchor + try: + import AppKit + + # Qt can return an empty tray rect on macOS. Read only our own + # status-level windows through public AppKit APIs, without AX access. + frame = AppKit.NSScreen.screens()[0].frame() + top = frame.origin.y + frame.size.height + candidates = [] + for window in AppKit.NSApp.windows(): + frame = window.frame() + if ( + window.level() == AppKit.NSStatusWindowLevel + and 0 < frame.size.width <= 128 + and 0 < frame.size.height <= 64 + ): + candidates.append( + QRect( + round(frame.origin.x), + round(top - frame.origin.y - frame.size.height), + round(frame.size.width), + round(frame.size.height), + ) + ) + screen = QGuiApplication.screenAt(QCursor.pos()) or QGuiApplication.primaryScreen() + if screen is not None: + for candidate in candidates: + if screen.geometry().intersects(candidate): + return candidate + if candidates: + return candidates[0] + except Exception as exc: + logger.debug("Tray hint anchor unavailable: %s", exc) + return anchor + + def close(self): + self.startup_timer.stop() + self.hint.close() + self.icon.hide() + + @property + def available(self): + return QSystemTrayIcon.isSystemTrayAvailable() + + def refresh(self): + panel = self.panel + main_visible = panel.isVisible() and not panel.isMinimized() + self.main_action.setText(_("Hide Main Window") if main_visible else _("Show Main Window")) + self.overlay_action.setText( + _("Hide Overlay") if panel.overlay.isVisible() else _("Show Overlay") + ) + running = panel.worker.isRunning() + self.start_action.setEnabled(not running and not panel._closing) + self.stop_action.setEnabled(running and not panel._closing) + selecting = panel._temporary_region_selecting or panel._region_selecting + self.region_action.setEnabled(not selecting and not panel._closing) + self.temporary_region_action.setEnabled(running and not selecting and not panel._closing) diff --git a/src/usta/ui/tabs/__init__.py b/src/usta/ui/tabs/__init__.py new file mode 100644 index 0000000..390d48f --- /dev/null +++ b/src/usta/ui/tabs/__init__.py @@ -0,0 +1,17 @@ +from usta.ui.tabs.about_tab import AboutTab +from usta.ui.tabs.appearance_tab import AppearanceTab +from usta.ui.tabs.hotkey_tab import HotkeyTab +from usta.ui.tabs.ocr_tab import OCRTab +from usta.ui.tabs.save_tab import SaveTab +from usta.ui.tabs.status_tab import StatusTab +from usta.ui.tabs.translation_tab import TranslationTab + +__all__ = [ + "OCRTab", + "TranslationTab", + "AppearanceTab", + "HotkeyTab", + "SaveTab", + "StatusTab", + "AboutTab", +] diff --git a/src/usta/ui/tabs/about_tab.py b/src/usta/ui/tabs/about_tab.py new file mode 100644 index 0000000..251326b --- /dev/null +++ b/src/usta/ui/tabs/about_tab.py @@ -0,0 +1,19 @@ +from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget + +from usta.config import APP_VERSION +from usta.i18n import _ + + +class AboutTab(QWidget): + def __init__(self): + super().__init__() + tab_about_layout = QVBoxLayout(self) + about_title = QLabel(_("Universal Screen Translator Application")) + about_title.setStyleSheet("font-size: 14px;") + tab_about_layout.addWidget(about_title) + tab_about_layout.addWidget(QLabel(_("Version: {version}").format(version=APP_VERSION))) + tab_about_layout.addWidget(QLabel("")) + tab_about_layout.addWidget(QLabel(_("A real-time OCR-based translation tool."))) + tab_about_layout.addWidget(QLabel(_("Select a screen region, capture text via OCR,"))) + tab_about_layout.addWidget(QLabel(_("and translate instantly with Google or DeepL."))) + tab_about_layout.addStretch() diff --git a/src/usta/ui/tabs/appearance_tab.py b/src/usta/ui/tabs/appearance_tab.py new file mode 100644 index 0000000..a042f47 --- /dev/null +++ b/src/usta/ui/tabs/appearance_tab.py @@ -0,0 +1,78 @@ +from PySide6.QtCore import Signal +from PySide6.QtWidgets import ( + QFontComboBox, + QHBoxLayout, + QLabel, + QPushButton, + QSpinBox, + QVBoxLayout, + QWidget, +) + +from usta.application.settings import AppSettings +from usta.i18n import _ + + +class AppearanceTab(QWidget): + font_changed = Signal(object) + font_size_changed = Signal(int) + text_color_requested = Signal() + background_color_requested = Signal() + opacity_changed = Signal(int) + + def __init__(self, settings=None): + super().__init__() + settings = settings or AppSettings() + tab_appearance_layout = QVBoxLayout(self) + + h_font = QHBoxLayout() + v_font_family = QVBoxLayout() + v_font_family.addWidget(QLabel(_("Font Family:"))) + self.font_picker = QFontComboBox() + self.font_picker.currentFontChanged.connect(self.font_changed.emit) + v_font_family.addWidget(self.font_picker) + h_font.addLayout(v_font_family) + + v_font_size = QVBoxLayout() + v_font_size.addWidget(QLabel(_("Size:"))) + self.font_size_spin = QSpinBox() + self.font_size_spin.setRange(8, 72) + self.font_size_spin.setValue(settings.font_size) + self.font_size_spin.valueChanged.connect(self.font_size_changed.emit) + v_font_size.addWidget(self.font_size_spin) + h_font.addLayout(v_font_size) + tab_appearance_layout.addLayout(h_font) + + h_colors = QHBoxLayout() + self.btn_color = QPushButton(_("🎨 Text Color")) + self.btn_color.clicked.connect(self.text_color_requested) + h_colors.addWidget(self.btn_color) + + self.color_sample = QLabel() + self.color_sample.setFixedSize(24, 24) + self.color_sample.setStyleSheet( + f"background-color: {settings.font_color}; border: 1px solid gray; border-radius: 4px;" + ) + h_colors.addWidget(self.color_sample) + + self.btn_bg_color = QPushButton(_("🎨 Background")) + self.btn_bg_color.clicked.connect(self.background_color_requested) + h_colors.addWidget(self.btn_bg_color) + + self.bg_color_sample = QLabel() + self.bg_color_sample.setFixedSize(24, 24) + self.bg_color_sample.setStyleSheet( + f"background-color: {settings.bg_color}; border: 1px solid gray; border-radius: 4px;" + ) + h_colors.addWidget(self.bg_color_sample) + tab_appearance_layout.addLayout(h_colors) + + h_bg_opacity = QHBoxLayout() + h_bg_opacity.addWidget(QLabel(_("Background Opacity:"))) + self.bg_opacity_spin = QSpinBox() + self.bg_opacity_spin.setRange(0, 255) + self.bg_opacity_spin.setValue(settings.bg_opacity) + self.bg_opacity_spin.valueChanged.connect(self.opacity_changed.emit) + h_bg_opacity.addWidget(self.bg_opacity_spin) + tab_appearance_layout.addLayout(h_bg_opacity) + tab_appearance_layout.addStretch() diff --git a/src/ui/tabs/hotkey_tab.py b/src/usta/ui/tabs/hotkey_tab.py similarity index 68% rename from src/ui/tabs/hotkey_tab.py rename to src/usta/ui/tabs/hotkey_tab.py index e058abf..231cdb2 100644 --- a/src/ui/tabs/hotkey_tab.py +++ b/src/usta/ui/tabs/hotkey_tab.py @@ -2,8 +2,8 @@ from PySide6.QtGui import QKeySequence from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget -from src.i18n import _ - +from usta.config import SETTINGS_TOPMOST_HOTKEY, TEMPORARY_REGION_HOTKEY +from usta.i18n import _ HOTKEY_BUTTON_WIDTH = 120 RESET_BUTTON_WIDTH = 60 @@ -145,41 +145,50 @@ def _add_hotkey_row(layout, label_text, button, reset_callback, tooltip): layout.addLayout(row) -def build_hotkey_tab(panel): - tab_settings = QWidget() - tab_settings_layout = QVBoxLayout(tab_settings) - tab_settings_layout.addWidget(QLabel(_("Hotkeys"))) - - settings_topmost_tooltip = _( - "Use this shortcut to bring the main window to the front while playing a game or watching a video." - ) - temporary_region_tooltip = _( - "Use this shortcut when you want to select an area outside your current selection. " - "The temporary area you select will be active for 10 seconds." - ) - - panel.settings_topmost_hotkey_button = HotkeyCaptureButton(panel.settings_topmost_hotkey) - panel.settings_topmost_hotkey_button.hotkeyChanged.connect(panel.on_settings_topmost_hotkey_changed) - _add_hotkey_row( - tab_settings_layout, - _("Settings topmost key:"), - panel.settings_topmost_hotkey_button, - panel.reset_settings_topmost_hotkey, - settings_topmost_tooltip, - ) - - panel.temporary_region_hotkey_button = HotkeyCaptureButton(panel.temporary_region_hotkey) - panel.temporary_region_hotkey_button.hotkeyChanged.connect(panel.on_temporary_region_hotkey_changed) - _add_hotkey_row( - tab_settings_layout, - _("Temporary region key:"), - panel.temporary_region_hotkey_button, - panel.reset_temporary_region_hotkey, - temporary_region_tooltip, - ) - - hint = QLabel(_("Click a shortcut field, then press the key combination you want to use.")) - hint.setWordWrap(True) - tab_settings_layout.addWidget(hint) - tab_settings_layout.addStretch() - return tab_settings +class HotkeyTab(QWidget): + settings_hotkey_changed = Signal(str) + region_hotkey_changed = Signal(str) + reset_settings_requested = Signal() + reset_region_requested = Signal() + + def __init__( + self, + settings_topmost_hotkey=SETTINGS_TOPMOST_HOTKEY, + temporary_region_hotkey=TEMPORARY_REGION_HOTKEY, + ): + super().__init__() + tab_settings_layout = QVBoxLayout(self) + tab_settings_layout.addWidget(QLabel(_("Hotkeys"))) + + settings_topmost_tooltip = _( + "Show or hide the main window. When shown, it stays above other windows." + ) + temporary_region_tooltip = _( + "Use this shortcut when you want to select an area outside your current selection. " + "The temporary area you select will be active for 10 seconds." + ) + + self.settings_topmost_hotkey_button = HotkeyCaptureButton(settings_topmost_hotkey) + self.settings_topmost_hotkey_button.hotkeyChanged.connect(self.settings_hotkey_changed.emit) + _add_hotkey_row( + tab_settings_layout, + _("Main window:"), + self.settings_topmost_hotkey_button, + self.reset_settings_requested, + settings_topmost_tooltip, + ) + + self.temporary_region_hotkey_button = HotkeyCaptureButton(temporary_region_hotkey) + self.temporary_region_hotkey_button.hotkeyChanged.connect(self.region_hotkey_changed.emit) + _add_hotkey_row( + tab_settings_layout, + _("Temporary region:"), + self.temporary_region_hotkey_button, + self.reset_region_requested, + temporary_region_tooltip, + ) + + hint = QLabel(_("Click a shortcut field, then press the key combination you want to use.")) + hint.setWordWrap(True) + tab_settings_layout.addWidget(hint) + tab_settings_layout.addStretch() diff --git a/src/usta/ui/tabs/ocr_tab.py b/src/usta/ui/tabs/ocr_tab.py new file mode 100644 index 0000000..2d8f1e7 --- /dev/null +++ b/src/usta/ui/tabs/ocr_tab.py @@ -0,0 +1,58 @@ +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import QComboBox, QDoubleSpinBox, QHBoxLayout, QLabel, QVBoxLayout, QWidget + +from usta.config import DPI_SCALE_DEFAULT, OCR_ENGINES, SCREENSHOT_ENGINES +from usta.i18n import _ + + +class OCRTab(QWidget): + engine_changed = Signal(str) + screen_engine_changed = Signal(str) + changed = Signal() + dpi_changed = Signal(float) + toggle_dpi_requested = Signal(object) + + def __init__(self): + super().__init__() + tab_ocr_layout = QVBoxLayout(self) + tab_ocr_layout.addWidget(QLabel(_("OCR Engine:"))) + self.combo_ocr = QComboBox() + self.combo_ocr.setFocusPolicy(Qt.NoFocus) + self.combo_ocr.addItems(OCR_ENGINES) + self.combo_ocr.currentTextChanged.connect(self.engine_changed.emit) + self.combo_ocr.currentTextChanged.connect(self.changed) + tab_ocr_layout.addWidget(self.combo_ocr) + + tab_ocr_layout.addWidget(QLabel(_("Screen Engine:"))) + self.combo_screenshot = QComboBox() + self.combo_screenshot.setFocusPolicy(Qt.NoFocus) + self.combo_screenshot.addItems(SCREENSHOT_ENGINES) + if SCREENSHOT_ENGINES: + self.combo_screenshot.setCurrentText(SCREENSHOT_ENGINES[0]) + self.combo_screenshot.currentTextChanged.connect(self.screen_engine_changed.emit) + self.combo_screenshot.currentTextChanged.connect(self.changed) + tab_ocr_layout.addWidget(self.combo_screenshot) + + h_dpi = QHBoxLayout() + h_dpi.addWidget(QLabel(_("DPI Scale:"))) + self.dpi_lock_label = QLabel("🔓") + self.dpi_lock_label.setToolTip(_("DPI unlocked (click to lock)")) + self.dpi_lock_label.setCursor(Qt.PointingHandCursor) + self.dpi_lock_label.mousePressEvent = self.toggle_dpi_requested.emit + self.dpi_lock_label.setStyleSheet("font-size: 16px; padding: 0px;") + self.dpi_lock_label.setFixedWidth(24) + h_dpi.addWidget(self.dpi_lock_label) + self.dpi_scale_spin = QDoubleSpinBox() + self.dpi_scale_spin.setRange(50, 300) + self.dpi_scale_spin.setSingleStep(25) + self.dpi_scale_spin.setDecimals(0) + self.dpi_scale_spin.setSuffix(" %") + self.dpi_scale_spin.setValue(DPI_SCALE_DEFAULT * 100) + self.dpi_scale_spin.setToolTip( + _("Scale factor for high-DPI monitors.\nAuto-detected when selecting a region.") + ) + self.dpi_scale_spin.valueChanged.connect(self.dpi_changed.emit) + h_dpi.addWidget(self.dpi_scale_spin) + tab_ocr_layout.addLayout(h_dpi) + + tab_ocr_layout.addStretch() diff --git a/src/usta/ui/tabs/save_tab.py b/src/usta/ui/tabs/save_tab.py new file mode 100644 index 0000000..e9fd0b6 --- /dev/null +++ b/src/usta/ui/tabs/save_tab.py @@ -0,0 +1,49 @@ +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import ( + QComboBox, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QVBoxLayout, + QWidget, +) + +from usta.i18n import _ + + +class SaveTab(QWidget): + save_requested = Signal() + load_requested = Signal() + delete_requested = Signal() + + def __init__(self): + super().__init__() + tab_save_layout = QVBoxLayout(self) + + h_name = QHBoxLayout() + h_name.addWidget(QLabel(_("Preset Name:"))) + self.preset_name_input = QLineEdit() + self.preset_name_input.setPlaceholderText(_("Enter preset name...")) + h_name.addWidget(self.preset_name_input) + tab_save_layout.addLayout(h_name) + + h_buttons = QHBoxLayout() + self.btn_save_preset = QPushButton(_("💾 Save")) + self.btn_save_preset.clicked.connect(self.save_requested) + h_buttons.addWidget(self.btn_save_preset) + + self.btn_load_preset = QPushButton(_("📂 Load")) + self.btn_load_preset.clicked.connect(self.load_requested) + h_buttons.addWidget(self.btn_load_preset) + + self.btn_delete_preset = QPushButton(_("🗑 Delete")) + self.btn_delete_preset.clicked.connect(self.delete_requested) + h_buttons.addWidget(self.btn_delete_preset) + tab_save_layout.addLayout(h_buttons) + + tab_save_layout.addWidget(QLabel(_("Saved Presets:"))) + self.preset_combo = QComboBox() + self.preset_combo.setFocusPolicy(Qt.NoFocus) + tab_save_layout.addWidget(self.preset_combo) + tab_save_layout.addStretch() diff --git a/src/usta/ui/tabs/status_tab.py b/src/usta/ui/tabs/status_tab.py new file mode 100644 index 0000000..88ca88e --- /dev/null +++ b/src/usta/ui/tabs/status_tab.py @@ -0,0 +1,131 @@ +from PySide6.QtCore import Qt +from PySide6.QtGui import QFontDatabase, QPalette +from PySide6.QtWidgets import ( + QFrame, + QGridLayout, + QHBoxLayout, + QLabel, + QLCDNumber, + QVBoxLayout, + QWidget, +) + +from usta.i18n import _ + + +class StatusTab(QWidget): + def __init__(self): + super().__init__() + self._layout = QVBoxLayout(self) + self._layout.setSpacing(6) + self._layout.setContentsMargins(8, 8, 8, 8) + + h_system = QHBoxLayout() + h_system.setSpacing(6) + h_system.setContentsMargins(2, 0, 2, 0) + self.system_indicator = QLabel() + self.system_indicator.setFixedSize(8, 8) + self.system_indicator.setStyleSheet("background-color: #C62828; border-radius: 4px;") + h_system.addWidget(self.system_indicator) + self.system_status_label = QLabel(_("Stopped")) + status_font = self.system_status_label.font() + status_font.setBold(True) + self.system_status_label.setFont(status_font) + h_system.addWidget(self.system_status_label) + h_system.addStretch() + self._layout.addLayout(h_system) + self._layout.addStretch() + + self._metrics = QGridLayout() + self._metrics.setContentsMargins(2, 0, 2, 0) + self._metrics.setHorizontalSpacing(8) + self._metrics.setVerticalSpacing(3) + self._metrics.setColumnStretch(1, 1) + self._layout.addLayout(self._metrics) + self.ocr_value = self._add_timing_row(0, _("OCR")) + self.translation_value = self._add_timing_row(1, _("Translation")) + self.total_value = self._add_timing_row(2, _("Total"), total=True) + self.total_value.setToolTip(_("Total includes screen capture and preprocessing.")) + self.reset_timings() + + separator = QFrame() + separator.setFixedHeight(1) + separator.setStyleSheet("background-color: palette(mid);") + self._layout.addWidget(separator) + region = QHBoxLayout() + region.setContentsMargins(2, 0, 2, 0) + region.addWidget(QLabel(_("Region")), alignment=Qt.AlignTop) + region.addStretch() + values = QVBoxLayout() + values.setSpacing(2) + number_font = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont) + fixed_families = [ + family for family in QFontDatabase.families() if QFontDatabase.isFixedPitch(family) + ] + if number_font.family() not in fixed_families and fixed_families: + preferred = ("Menlo", "DejaVu Sans Mono", "Liberation Mono", "Consolas") + number_font.setFamily( + next( + (family for family in preferred if family in fixed_families), fixed_families[0] + ) + ) + number_font.setPointSize(10) + self.region_position = QLabel("—") + self.region_size = QLabel("—") + for value in (self.region_position, self.region_size): + value.setFont(number_font) + value.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + value.setTextInteractionFlags(Qt.TextSelectableByMouse) + values.addWidget(value) + region.addLayout(values) + self._layout.addLayout(region) + + def _add_timing_row(self, row, title, total=False): + caption = QLabel(title) + font = caption.font() + font.setBold(total) + caption.setFont(font) + self._metrics.addWidget(caption, row, 0) + display = QLCDNumber() + display.setFrameShape(QFrame.NoFrame) + display.setSegmentStyle(QLCDNumber.Flat) + display.setSmallDecimalPoint(True) + display.setFixedSize(96, 20) + display.setAccessibleName(title) + if total: + accent = display.palette() + accent.setColor(QPalette.WindowText, accent.color(QPalette.Highlight)) + display.setPalette(accent) + caption.setPalette(accent) + self._metrics.addWidget(display, row, 2, alignment=Qt.AlignRight) + unit = QLabel("s") + unit_palette = unit.palette() + unit_palette.setColor( + QPalette.WindowText, unit_palette.color(QPalette.Disabled, QPalette.WindowText) + ) + unit.setPalette(unit_palette) + self._metrics.addWidget(unit, row, 3) + return display + + def _set_timings_text(self, ocr, translation, total): + for display, text in zip( + (self.ocr_value, self.translation_value, self.total_value), + (ocr, translation, total), + ): + display.setDigitCount(max(6, len(text.replace(".", "")))) + display.display(text) + display.setAccessibleDescription(f"{text} s" if text != "--.--" else "—") + + def update_timings(self, timings): + self._set_timings_text( + f"{timings.ocr_seconds:.2f}", + f"{timings.translation_seconds:.2f}", + f"{timings.total_seconds:.2f}", + ) + + def reset_timings(self): + self._set_timings_text("--.--", "--.--", "--.--") + + def update_region(self, rect): + self.region_position.setText(f"X {rect.x()} Y {rect.y()}") + self.region_size.setText(f"{rect.width()} × {rect.height()} px") diff --git a/src/usta/ui/tabs/translation_tab.py b/src/usta/ui/tabs/translation_tab.py new file mode 100644 index 0000000..3ced6dd --- /dev/null +++ b/src/usta/ui/tabs/translation_tab.py @@ -0,0 +1,57 @@ +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import QComboBox, QHBoxLayout, QLabel, QLineEdit, QVBoxLayout, QWidget + +from usta.config import LANGUAGES, TRANSLATION_ENGINES +from usta.i18n import _ + + +class TranslationTab(QWidget): + translator_changed = Signal(str) + changed = Signal() + translator_selected = Signal(str) + language_changed = Signal() + api_key_changed = Signal(str) + + def __init__(self): + super().__init__() + tab_translation_layout = QVBoxLayout(self) + tab_translation_layout.addWidget(QLabel(_("Translation Engine:"))) + self.combo_translator = QComboBox() + self.combo_translator.setFocusPolicy(Qt.NoFocus) + self.combo_translator.addItems(TRANSLATION_ENGINES) + self.combo_translator.currentTextChanged.connect(self.translator_changed.emit) + self.combo_translator.currentTextChanged.connect(self.changed) + self.combo_translator.currentTextChanged.connect(self.translator_selected.emit) + tab_translation_layout.addWidget(self.combo_translator) + + h_lang = QHBoxLayout() + v_source = QVBoxLayout() + v_source.addWidget(QLabel(_("Source Language:"))) + self.combo_source = QComboBox() + self.combo_source.setFocusPolicy(Qt.NoFocus) + self.combo_source.addItems(list(LANGUAGES.keys())) + self.combo_source.setCurrentText("English") + self.combo_source.currentTextChanged.connect(self.language_changed) + v_source.addWidget(self.combo_source) + h_lang.addLayout(v_source) + + v_target = QVBoxLayout() + v_target.addWidget(QLabel(_("Target Language:"))) + self.combo_target = QComboBox() + self.combo_target.setFocusPolicy(Qt.NoFocus) + self.combo_target.addItems(list(LANGUAGES.keys())) + self.combo_target.setCurrentText("Turkish") + self.combo_target.currentTextChanged.connect(self.language_changed) + v_target.addWidget(self.combo_target) + h_lang.addLayout(v_target) + tab_translation_layout.addLayout(h_lang) + + self.api_key_label = QLabel(_("API Key")) + tab_translation_layout.addWidget(self.api_key_label) + self.api_key_input = QLineEdit() + self.api_key_input.setEchoMode(QLineEdit.Password) + self.api_key_input.setPlaceholderText(_("Enter API key...")) + self.api_key_input.textChanged.connect(self.api_key_changed.emit) + tab_translation_layout.addWidget(self.api_key_input) + + tab_translation_layout.addStretch() diff --git a/src/usta/ui/tray_hint.py b/src/usta/ui/tray_hint.py new file mode 100644 index 0000000..b64621a --- /dev/null +++ b/src/usta/ui/tray_hint.py @@ -0,0 +1,101 @@ +"""A tray-anchored startup hint with an application-controlled lifetime.""" + +from PySide6.QtCore import QPointF, QRectF, Qt, QTimer +from PySide6.QtGui import QGuiApplication, QPainter, QPainterPath, QPalette, QPen, QPolygonF +from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget + +from usta.config import OS +from usta.i18n import _ + + +class TrayHint(QWidget): + def __init__(self): + super().__init__( + None, + Qt.Tool + | Qt.FramelessWindowHint + | Qt.WindowStaysOnTopHint + | Qt.WindowDoesNotAcceptFocus, + ) + self.setAttribute(Qt.WA_ShowWithoutActivating, True) + self.setAttribute(Qt.WA_TranslucentBackground, True) + self.setFocusPolicy(Qt.NoFocus) + self.setFixedWidth(280) + self._arrow_top = True + self._arrow_x = 140 + self._macos_rules = None + if OS == "Darwin" and QGuiApplication.platformName() == "cocoa": + from usta.ui.platform.macos.overlay import MacOSOverlayRules + + self.setAttribute(Qt.WA_MacAlwaysShowToolWindow, True) + self._macos_rules = MacOSOverlayRules(self) + self.content = QVBoxLayout(self) + self.content.setSpacing(4) + title = QLabel("USTA") + font = title.font() + font.setBold(True) + title.setFont(font) + self.content.addWidget(title) + self.message = QLabel(_("You can access the USTA menu here.")) + self.message.setWordWrap(True) + self.content.addWidget(self.message) + self.dismiss_timer = QTimer(self) + self.dismiss_timer.setSingleShot(True) + self.dismiss_timer.setTimerType(Qt.PreciseTimer) + self.dismiss_timer.timeout.connect(self.hide) + + def show_at(self, anchor): + screen = QGuiApplication.screenAt(anchor.center()) or QGuiApplication.primaryScreen() + if screen is None or anchor.isEmpty(): + return False + available = screen.availableGeometry() + self._arrow_top = anchor.center().y() < available.center().y() + self.content.setContentsMargins( + 14, 22 if self._arrow_top else 14, 14, 14 if self._arrow_top else 22 + ) + self.adjustSize() + x = max( + available.left() + 4, + min(anchor.center().x() - self.width() // 2, available.right() - self.width() - 3), + ) + y = anchor.bottom() + 4 if self._arrow_top else anchor.top() - self.height() - 4 + y = max(available.top(), min(y, available.bottom() - self.height() + 1)) + self._arrow_x = max(18, min(anchor.center().x() - x, self.width() - 18)) + self.move(x, y) + if self._macos_rules is not None: + self._macos_rules.start() + self._macos_rules.prepare() + self.show() + if self._macos_rules is not None: + self._macos_rules.order_front_without_activation() + self.dismiss_timer.start(10_000) + return True + + def hideEvent(self, event): + self.dismiss_timer.stop() + if self._macos_rules is not None: + self._macos_rules.stop() + super().hideEvent(event) + + def paintEvent(self, event): + body = QRectF(self.rect()).adjusted( + 0.5, 8.5 if self._arrow_top else 0.5, -0.5, -0.5 if self._arrow_top else -8.5 + ) + path = QPainterPath() + path.addRoundedRect(body, 8, 8) + edge = body.top() if self._arrow_top else body.bottom() + tip = 0.5 if self._arrow_top else self.height() - 0.5 + path.addPolygon( + QPolygonF( + [ + QPointF(self._arrow_x - 7, edge), + QPointF(self._arrow_x, tip), + QPointF(self._arrow_x + 7, edge), + ] + ) + ) + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + painter.setPen(QPen(self.palette().color(QPalette.Mid), 1)) + painter.setBrush(self.palette().color(QPalette.Window)) + painter.drawPath(path.simplified()) diff --git a/tests/helpers/macos_fullscreen_host.py b/tests/helpers/macos_fullscreen_host.py new file mode 100644 index 0000000..421daf8 --- /dev/null +++ b/tests/helpers/macos_fullscreen_host.py @@ -0,0 +1,69 @@ +"""Separate, focused app for testing native macOS fullscreen Spaces.""" + +import json +import sys +from pathlib import Path + +import AppKit +import objc +from PySide6.QtCore import QTimer +from PySide6.QtWidgets import QApplication, QLabel + +app = QApplication([]) +state_file = Path(sys.argv[1]) +exit_file = Path(sys.argv[2]) +widget = QLabel("USTA fullscreen integration test — closes automatically") +widget.setStyleSheet("background: #224488; color: white; font-size: 24px") +widget.setGeometry(100, 100, 900, 600) +widget.show() +window = objc.objc_object(c_void_p=int(widget.winId())).window() +center = AppKit.NSNotificationCenter.defaultCenter() + + +def report(_notification): + state_file.write_text( + json.dumps( + { + "fullscreen": bool(window.styleMask() & AppKit.NSWindowStyleMaskFullScreen), + "active": bool(AppKit.NSApp.isActive()), + } + ) + ) + + +tokens = [ + center.addObserverForName_object_queue_usingBlock_( + name, + window, + None, + report, + ) + for name in ( + AppKit.NSWindowDidEnterFullScreenNotification, + AppKit.NSWindowDidExitFullScreenNotification, + ) +] + + +def enter_fullscreen(): + window.setCollectionBehavior_(AppKit.NSWindowCollectionBehaviorFullScreenPrimary) + window.makeKeyAndOrderFront_(None) + AppKit.NSApp.activateIgnoringOtherApps_(True) + # The same native transition invoked by the green titlebar button. + window.toggleFullScreen_(None) + + +def check_exit_request(): + if exit_file.exists(): + exit_timer.stop() + window.toggleFullScreen_(None) + + +exit_timer = QTimer() +exit_timer.timeout.connect(check_exit_request) +exit_timer.start(100) +QTimer.singleShot(500, enter_fullscreen) +QTimer.singleShot(20000, app.quit) +app.exec() +for token in tokens: + center.removeObserver_(token) diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_control_panel.py b/tests/integration/test_control_panel.py new file mode 100644 index 0000000..968d729 --- /dev/null +++ b/tests/integration/test_control_panel.py @@ -0,0 +1,412 @@ +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from PySide6.QtCore import QRect, Qt, QTimer +from PySide6.QtGui import QColor +from PySide6.QtTest import QSignalSpy, QTest +from PySide6.QtWidgets import QApplication, QColorDialog, QSystemTrayIcon + +from usta.application.settings import AppSettings, Preset +from usta.infrastructure.settings_store import SettingsStore + + +class ControlPanelTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.app = QApplication.instance() or QApplication([]) + + def setUp(self): + from usta.ui.main_window import ControlPanel + + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.store = SettingsStore(Path(self.tmp.name)) + self.store.save( + AppSettings(font_size=27, target_lang="German", translator_api_keys={"DeepL": "secret"}) + ) + self.stack = __import__("contextlib").ExitStack() + self.addCleanup(self.stack.close) + for name in ["usta.ui.main_window.OS", "usta.ui.overlay_window.OS"]: + self.stack.enter_context(mock.patch(name, "Linux")) + self.stack.enter_context( + mock.patch("usta.core.shortcut.GlobalHotkey.start", return_value=False) + ) + self.stack.enter_context( + mock.patch("usta.ui.settings_presenter.SettingsStore", return_value=self.store) + ) + self.save_spy = self.stack.enter_context( + mock.patch.object(self.store, "save", wraps=self.store.save) + ) + self.panel = ControlPanel() + self.addCleanup(lambda: self.panel.request_quit()) + + def test_startup_tray_hint_expires_without_hiding_windows_or_tray(self): + tray = self.panel.tray + self.panel.overlay.show() + with ( + mock.patch.object(QSystemTrayIcon, "isSystemTrayAvailable", return_value=True), + mock.patch.object(QSystemTrayIcon, "geometry", return_value=QRect(770, 0, 22, 22)), + ): + tray._show_startup_hint() + self.assertTrue(tray.hint.isVisible()) + self.assertTrue(tray.hint.windowFlags() & Qt.WindowDoesNotAcceptFocus) + self.assertGreater(tray.hint.dismiss_timer.remainingTime(), 9500) + self.assertLessEqual(tray.hint.dismiss_timer.remainingTime(), 10000) + tray.hint.dismiss_timer.start(1) + QTest.qWait(20) + self.assertFalse(tray.hint.isVisible()) + self.assertTrue(tray.icon.isVisible()) + self.assertTrue(self.panel.isVisible()) + self.assertTrue(self.panel.overlay.isVisible()) + + def test_macos_hint_anchor_uses_own_status_window_when_qt_geometry_is_empty(self): + from types import SimpleNamespace + + def frame(x, y, width, height): + return SimpleNamespace( + origin=SimpleNamespace(x=x, y=y), size=SimpleNamespace(width=width, height=height) + ) + + native = SimpleNamespace( + NSStatusWindowLevel=25, + NSScreen=SimpleNamespace( + screens=lambda: [SimpleNamespace(frame=lambda: frame(0, 0, 800, 800))] + ), + NSApp=SimpleNamespace( + windows=lambda: [ + SimpleNamespace(level=lambda: 0, frame=lambda: frame(0, 0, 400, 400)), + SimpleNamespace(level=lambda: 25, frame=lambda: frame(100, 770, 25, 30)), + ] + ), + ) + with ( + mock.patch.object(QSystemTrayIcon, "geometry", return_value=QRect()), + mock.patch("usta.ui.system_tray.OS", "Darwin"), + mock.patch("usta.ui.system_tray.QGuiApplication.platformName", return_value="cocoa"), + mock.patch.dict("sys.modules", {"AppKit": native}), + ): + self.assertEqual(self.panel.tray._icon_geometry(), QRect(100, 0, 25, 30)) + + def test_shutdown_cancels_pending_startup_hint(self): + tray = self.panel.tray + self.panel.request_quit() + self.assertFalse(tray.startup_timer.isActive()) + with mock.patch.object(QSystemTrayIcon, "isSystemTrayAvailable", return_value=True): + tray._show_startup_hint() + self.assertFalse(tray.hint.isVisible()) + + def test_tray_shortcut_and_x_share_real_visibility(self): + panel = self.panel + with mock.patch.object(QSystemTrayIcon, "isSystemTrayAvailable", return_value=True): + panel.overlay.show() + panel.overlay.settings_topmost_hotkey_pressed.emit() + self.assertFalse(panel.isVisible()) + self.assertTrue(panel.overlay.isVisible()) + panel.tray.main_action.trigger() + self.assertTrue(panel.isVisible()) + self.assertTrue(panel.windowFlags() & Qt.WindowStaysOnTopHint) + panel.close() + self.assertFalse(panel.isVisible()) + self.assertTrue(panel.overlay.isVisible()) + self.assertFalse(panel._closing) + panel.overlay._rearm_settings_topmost_hotkey() + panel.overlay.settings_topmost_hotkey_pressed.emit() + self.assertTrue(panel.isVisible()) + panel.tray.refresh() + self.assertEqual(panel.tray.main_action.text(), "Hide Main Window") + + def test_delayed_startup_alignment_does_not_reveal_hidden_overlay(self): + self.panel.overlay.hide() + self.panel.align_overlay() + self.assertFalse(self.panel.overlay.isVisible()) + + def test_minimized_main_window_is_restored_by_toggle(self): + self.panel.showMinimized() + self.panel.toggle_main_window() + self.assertTrue(self.panel.isVisible()) + self.assertFalse(self.panel.isMinimized()) + self.assertTrue(self.panel.windowFlags() & Qt.WindowStaysOnTopHint) + + def test_overlay_stays_hidden_when_new_translation_arrives(self): + panel = self.panel + panel.overlay.show() + panel.tray.overlay_action.trigger() + panel.overlay.update_text("still translating") + self.assertFalse(panel.overlay.isVisible()) + self.assertTrue(panel.isVisible()) + self.assertEqual(panel.overlay.label.text(), "still translating") + panel.tray.refresh() + self.assertEqual(panel.tray.overlay_action.text(), "Show Overlay") + panel.tray.overlay_action.trigger() + self.assertTrue(panel.overlay.isVisible()) + self.assertTrue(panel.overlay.windowFlags() & Qt.WindowStaysOnTopHint) + + def test_tray_start_stop_follow_worker_state_and_disable_during_quit(self): + panel = self.panel + panel.tray.refresh() + self.assertTrue(panel.tray.start_action.isEnabled()) + self.assertFalse(panel.tray.stop_action.isEnabled()) + with mock.patch.object(panel.worker, "isRunning", return_value=True): + panel.tray.refresh() + self.assertFalse(panel.tray.start_action.isEnabled()) + self.assertTrue(panel.tray.stop_action.isEnabled()) + panel._closing = True + panel.tray.refresh() + self.assertFalse(panel.tray.start_action.isEnabled()) + self.assertFalse(panel.tray.stop_action.isEnabled()) + + def test_tray_temporary_region_requires_running_worker(self): + panel = self.panel + panel.tray.refresh() + self.assertFalse(panel.tray.temporary_region_action.isEnabled()) + with mock.patch.object(panel.worker, "isRunning", return_value=True): + panel.tray.refresh() + self.assertTrue(panel.tray.temporary_region_action.isEnabled()) + panel._temporary_region_selecting = True + panel.tray.refresh() + self.assertFalse(panel.tray.temporary_region_action.isEnabled()) + + def test_stopping_before_delayed_temporary_selection_does_not_open_selector(self): + panel = self.panel + with ( + mock.patch.object(panel.worker, "isRunning", return_value=True) as running, + mock.patch.object( + panel, "_get_selected_region_and_dpi", return_value=(QRect(1, 2, 100, 50), None) + ) as select, + ): + panel.tray.refresh() + panel.tray.temporary_region_action.trigger() + running.return_value = False + QTest.qWait(250) + select.assert_not_called() + self.assertFalse(panel.region_controller.temporary_active) + + def test_quit_waits_for_worker_then_cleans_up_hidden_windows(self): + panel = self.panel + panel.overlay.show() + panel.hide() + with ( + mock.patch.object(panel.worker, "isRunning", return_value=True), + mock.patch.object(panel.controller, "stop") as stop, + mock.patch.object(panel.worker, "close") as close_worker, + mock.patch.object(QApplication, "quit") as quit_app, + ): + panel.tray.quit_action.trigger() + self.assertTrue(panel._closing) + stop.assert_called_once() + close_worker.assert_not_called() + quit_app.assert_not_called() + # A second quit request must not register another finished handler. + panel.request_quit() + stop.assert_called_once() + with ( + mock.patch.object(panel.worker, "isRunning", return_value=False), + mock.patch.object(QApplication, "quit") as quit_app, + ): + panel.worker.finished.emit() + self.assertFalse(panel.overlay.isVisible()) + self.assertFalse(panel.tray.icon.isVisible()) + quit_app.assert_called_once() + + def test_x_exits_when_system_tray_is_unavailable(self): + panel = self.panel + panel.overlay.show() + with ( + mock.patch.object(QSystemTrayIcon, "isSystemTrayAvailable", return_value=False), + mock.patch.object(QApplication, "quit") as quit_app, + ): + panel.close() + self.assertFalse(panel.overlay.isVisible()) + quit_app.assert_called_once() + + def test_load_does_not_write_and_user_change_writes_once(self): + self.save_spy.assert_not_called() + self.assertEqual(self.panel.appearance_tab.font_size_spin.value(), 27) + self.assertEqual(self.panel.translation_tab.combo_target.currentText(), "German") + self.panel.appearance_tab.font_size_spin.setValue(28) + self.save_spy.assert_called_once() + self.assertEqual(self.store.load().translator_api_keys, {"DeepL": "secret"}) + + def test_color_dialog_accept_and_cancel_keep_tool_panel_alive(self): + # macOS uses a Tool panel, which does not count towards Qt's last window. + self.panel.setWindowFlag(Qt.Tool, True) + self.panel.show() + self.app.processEvents() + for button, field, sample in ( + ( + self.panel.appearance_tab.btn_color, + "font_color", + self.panel.appearance_tab.color_sample, + ), + ( + self.panel.appearance_tab.btn_bg_color, + "bg_color", + self.panel.appearance_tab.bg_color_sample, + ), + ): + for accept in (True, False): + with self.subTest(field=field, accept=accept): + before = getattr(self.store.load(), field) + last_closed = QSignalSpy(self.app.lastWindowClosed) + self.save_spy.reset_mock() + + def finish_dialog(): + dialog = next( + w + for w in self.app.topLevelWidgets() + if isinstance(w, QColorDialog) and w.isVisible() + ) + dialog.setCurrentColor(QColor("#123456" if accept else "#abcdef")) + if accept: + dialog.accept() + else: + dialog.reject() + + def choose(): + QTimer.singleShot(0, finish_dialog) + button.click() + + QTimer.singleShot(0, choose) + exit_timer = QTimer() + exit_timer.setSingleShot(True) + exit_timer.timeout.connect(lambda: self.app.exit(0)) + exit_timer.start(50) + self.app.exec() + exit_timer.stop() + self.assertEqual(last_closed.count(), 0) + self.assertTrue(self.panel.isVisible()) + expected = "#123456" if accept else before + self.assertEqual(getattr(self.panel.overlay, field), expected) + self.assertEqual(getattr(self.store.load(), field), expected) + self.assertIn(expected, sample.styleSheet()) + self.assertEqual(self.save_spy.call_count, 1 if accept else 0) + + def test_preset_cancels_temporary_restore(self): + panel = self.panel + self.store.save_presets({"game": Preset(capture_rect=(-100, 30, 600, 80))}) + panel.refresh_preset_list() + panel._apply_temporary_region(QRect(10, 10, 100, 30), 2.0) + panel.settings_presenter.load_preset() + panel.region_controller.restore_timer.timeout.emit() + self.assertFalse(panel.region_controller.temporary_active) + self.assertEqual(panel.worker.capture_rect, QRect(-100, 30, 600, 80)) + + def test_ui_save_preserves_unknown_migrated_fields(self): + settings = AppSettings.from_dict( + {"unknown_valid_field": {"format": "keep"}, "font_size": 26} + ) + self.store.save(settings) + self.panel.load_settings() + self.panel.appearance_tab.font_size_spin.setValue(29) + self.assertEqual(self.store.load().to_dict()["unknown_valid_field"], {"format": "keep"}) + + def test_presenter_cannot_erase_valid_presets_when_one_entry_is_bad(self): + import json + + path = Path(self.tmp.name) / "presets.json" + raw = json.dumps( + {"schema_version": 1, "data": {"valuable": {"font_size": 31}, "broken": None}} + ) + path.write_text(raw) + self.panel.refresh_preset_list() + self.assertIn( + "valuable", + [ + self.panel.save_tab.preset_combo.itemText(i) + for i in range(self.panel.save_tab.preset_combo.count()) + ], + ) + self.panel.save_tab.preset_name_input.setText("new") + self.panel.settings_presenter.save_preset() + self.assertEqual(path.read_text(), raw) + + def test_capture_error_remains_visible_after_worker_stops_and_clears_on_restart(self): + panel = self.panel + panel.worker.error_occurred.emit("Screen Recording unavailable") + panel.update_system_status(False) + self.assertEqual(panel.overlay.label.text(), "Screen Recording unavailable") + with mock.patch.object(panel.controller, "start"): + panel.start() + self.assertNotEqual(panel.overlay.label.text(), "Screen Recording unavailable") + + def test_running_status_restores_overlay_mode_after_queued_restart(self): + with mock.patch.object(self.panel.overlay, "set_mode") as mode: + self.panel.update_system_status(False) + self.panel.update_system_status(True) + self.assertEqual(mode.call_args_list, [mock.call(False), mock.call(True)]) + + def test_status_does_not_increase_main_window_height(self): + panel = self.panel + other_tab_height = max( + panel.tabs.widget(index).minimumSizeHint().height() + for index in range(panel.tabs.count()) + if panel.tabs.widget(index) is not panel.status_tab + ) + self.assertLessEqual(panel.status_tab.minimumSizeHint().height(), other_tab_height) + + def test_region_status_displays_coordinates_and_dimensions_separately(self): + from PySide6.QtGui import QFontInfo + + tab = self.panel.status_tab + self.panel._apply_main_region(QRect(-1920, 120, 1280, 720)) + self.assertEqual(tab.region_position.text(), "X -1920 Y 120") + self.assertEqual(tab.region_size.text(), "1280 × 720 px") + for label in (tab.region_position, tab.region_size): + self.assertTrue(QFontInfo(label.font()).fixedPitch()) + + def test_stage_timings_update_individual_digital_displays_and_reset(self): + from usta.application.results import PerformanceTimings + + tab = self.panel.status_tab + self.panel.worker.timings_update.emit(PerformanceTimings(0.32, 1.28, 1.82)) + self.assertAlmostEqual(tab.ocr_value.value(), 0.32) + self.assertAlmostEqual(tab.translation_value.value(), 1.28) + self.assertAlmostEqual(tab.total_value.value(), 1.82) + self.panel.update_system_status(False) + for display in (tab.ocr_value, tab.translation_value, tab.total_value): + self.assertEqual(display.value(), 0) + self.assertIn("—", display.accessibleDescription()) + + def test_region_button_uses_selection_icon_instead_of_emoji(self): + self.assertNotIn("🖼", self.panel.btn_reg.text()) + self.assertFalse(self.panel.btn_reg.icon().isNull()) + self.assertFalse(self.panel.btn_reg.icon().pixmap(20, 20).isNull()) + + def test_source_language_selection_updates_worker_and_saves_once(self): + self.save_spy.reset_mock() + with mock.patch("sys.excepthook") as unhandled: + self.panel.translation_tab.combo_source.setCurrentText("Spanish") + self.app.processEvents() + unhandled.assert_not_called() + self.assertEqual(self.panel.worker.pipeline.settings.source_lang, "es") + self.assertEqual(self.store.load().source_lang, "Spanish") + self.save_spy.assert_called_once() + + def test_ocr_selection_updates_worker_and_persists(self): + self.save_spy.reset_mock() + with mock.patch("sys.excepthook") as unhandled: + self.panel.ocr_tab.combo_ocr.setCurrentText("EasyOCR") + self.app.processEvents() + unhandled.assert_not_called() + self.assertEqual(self.panel.worker.pipeline.settings.ocr_engine, "EasyOCR") + self.assertEqual(self.store.load().ocr_engine, "EasyOCR") + self.save_spy.assert_called_once() + + def test_target_and_provider_changes_update_worker_and_persist_once(self): + tab = self.panel.translation_tab + cases = ( + (tab.combo_target, "Japanese", "target_lang", "ja"), + (tab.combo_translator, "DeepL", "translator_engine", "DeepL"), + ) + for combo, selection, field, worker_value in cases: + with self.subTest(field=field): + self.save_spy.reset_mock() + with mock.patch("sys.excepthook") as unhandled: + combo.setCurrentText(selection) + self.app.processEvents() + unhandled.assert_not_called() + self.assertEqual(getattr(self.panel.worker.pipeline.settings, field), worker_value) + self.assertEqual(getattr(self.store.load(), field), selection) + self.save_spy.assert_called_once() diff --git a/tests/integration/test_linux_windows.py b/tests/integration/test_linux_windows.py new file mode 100644 index 0000000..d3d6d01 --- /dev/null +++ b/tests/integration/test_linux_windows.py @@ -0,0 +1,78 @@ +import sys +import unittest +from unittest import mock + +from PySide6.QtCore import QRect, Qt +from PySide6.QtWidgets import QApplication + +from tests.native.test_macos_hotkey_actions import pump +from usta.ui.region_selection.core_sniper import OverlayWindow, SelectionManager + + +class LinuxWindowContracts(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.app = QApplication.instance() or QApplication([]) + cls.app.setQuitOnLastWindowClosed(False) + + def make_control_panel(self, os_name): + from contextlib import ExitStack + + from usta.ui.main_window import ControlPanel + + stack = ExitStack() + self.addCleanup(stack.close) + stack.enter_context(mock.patch("usta.ui.main_window.OS", os_name)) + stack.enter_context(mock.patch("usta.ui.overlay_window.OS", os_name)) + stack.enter_context(mock.patch("usta.core.shortcut.GlobalHotkey.start", return_value=False)) + stack.enter_context(mock.patch.object(ControlPanel, "load_settings")) + stack.enter_context(mock.patch.object(ControlPanel, "save_settings")) + stack.enter_context(mock.patch.object(ControlPanel, "refresh_preset_list")) + quit_app = stack.enter_context(mock.patch.object(QApplication, "quit")) + worker = stack.enter_context(mock.patch("usta.ui.main_window.OCRWorker")).return_value + worker.isRunning.return_value = False + worker.capture_rect = QRect(10, 20, 200, 50) + worker.dpi_scale = 1.0 + worker.set_rect.side_effect = lambda rect: setattr(worker, "capture_rect", QRect(rect)) + panel = ControlPanel() + pump(150) # Finish the normal startup overlay-position timer. + + def close(): + panel.request_quit() + panel.overlay.deleteLater() + panel.deleteLater() + pump() + + self.addCleanup(close) + return panel, quit_app + + def test_linux_visibility_action_preserves_topmost_and_decorations(self): + panel, quit_app = self.make_control_panel("Linux") + self.assertIsNone(panel._macos_hotkey_window) + self.assertIsNone(panel.overlay._macos_rules) + with ( + mock.patch.object(panel, "raise_") as raise_window, + mock.patch.object(panel, "activateWindow") as activate, + ): + panel.hide_main_window() + panel.show_main_window() + self.assertTrue(panel.windowFlags() & Qt.WindowStaysOnTopHint) + raise_window.assert_called_once() + activate.assert_called_once() + panel.hide_main_window() + self.assertFalse(panel.isVisible()) + self.assertTrue(panel.windowFlags() & Qt.WindowStaysOnTopHint) + self.assertFalse(panel.windowFlags() & Qt.FramelessWindowHint) + quit_app.assert_not_called() + + def test_linux_region_selector_keeps_x11_flags_without_native_imports(self): + with ( + mock.patch("usta.ui.region_selection.core_sniper.sys.platform", "linux"), + mock.patch.dict(sys.modules, {"usta.ui.platform.macos.hotkeys": None}), + ): + manager = SelectionManager() + window = OverlayWindow(self.app.primaryScreen(), manager) + self.assertTrue(window.windowFlags() & Qt.X11BypassWindowManagerHint) + self.assertIsNone(window._macos_hotkey_window) + window.deleteLater() + pump() diff --git a/tests/integration/test_overlay_interaction.py b/tests/integration/test_overlay_interaction.py new file mode 100644 index 0000000..ba6a3ad --- /dev/null +++ b/tests/integration/test_overlay_interaction.py @@ -0,0 +1,131 @@ +"""Hover feedback matches actual overlay move/resize operations.""" + +import unittest +from unittest.mock import patch + +from PySide6.QtCore import QEvent, QPoint, QPointF, Qt +from PySide6.QtGui import QMouseEvent +from PySide6.QtWidgets import QApplication + +from usta.ui.overlay_window import TransparentOverlay + + +class OverlayInteractionTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.app = QApplication.instance() or QApplication([]) + + def setUp(self): + self.os_patch = patch("usta.ui.overlay_window.OS", "Linux") + self.hotkey_patch = patch("usta.core.shortcut.GlobalHotkey.start", return_value=False) + self.os_patch.start() + self.hotkey_patch.start() + self.addCleanup(self.os_patch.stop) + self.addCleanup(self.hotkey_patch.stop) + self.overlay = TransparentOverlay() + self.overlay.setGeometry(100, 100, 400, 200) + self.overlay.show() + self.app.processEvents() + self.addCleanup(self.overlay.close) + + def mouse(self, kind, local, *, global_pos=None, buttons=Qt.NoButton): + position = QPoint(*local) + global_pos = global_pos or self.overlay.mapToGlobal(position) + button = ( + Qt.LeftButton + if kind in (QEvent.MouseButtonPress, QEvent.MouseButtonRelease) + else Qt.NoButton + ) + event = QMouseEvent( + kind, QPointF(position), QPointF(global_pos), button, buttons, Qt.NoModifier + ) + self.app.sendEvent(self.overlay, event) + + def test_all_edges_and_corners_show_the_matching_cursor_in_both_modes(self): + cases = [ + ((2, 100), Qt.SizeHorCursor), + ((398, 100), Qt.SizeHorCursor), + ((200, 2), Qt.SizeVerCursor), + ((200, 198), Qt.SizeVerCursor), + ((2, 2), Qt.SizeFDiagCursor), + ((398, 198), Qt.SizeFDiagCursor), + ((398, 2), Qt.SizeBDiagCursor), + ((2, 198), Qt.SizeBDiagCursor), + ((200, 100), Qt.SizeAllCursor), + ] + for scan in (False, True): + self.overlay.set_mode(scan) + for position, expected in cases: + with self.subTest(scan=scan, position=position): + self.mouse(QEvent.MouseMove, position) + self.assertEqual(self.overlay.cursor().shape(), expected) + self.assertEqual(self.overlay.label.cursor().shape(), expected) + + def test_left_top_resize_preserves_opposite_corner(self): + before = self.overlay.geometry() + local = (2, 2) + origin = self.overlay.mapToGlobal(QPoint(*local)) + self.mouse(QEvent.MouseButtonPress, local, buttons=Qt.LeftButton) + self.mouse( + QEvent.MouseMove, local, global_pos=origin + QPoint(-40, -30), buttons=Qt.LeftButton + ) + after = self.overlay.geometry() + self.assertEqual(after.bottomRight(), before.bottomRight()) + self.assertEqual(after.width(), before.width() + 40) + self.assertEqual(after.height(), before.height() + 30) + self.assertEqual(self.overlay.cursor().shape(), Qt.SizeFDiagCursor) + + def test_left_resize_respects_minimum_width_and_keeps_right_edge(self): + before = self.overlay.geometry() + origin = self.overlay.mapToGlobal(QPoint(2, 100)) + self.mouse(QEvent.MouseButtonPress, (2, 100), buttons=Qt.LeftButton) + self.mouse( + QEvent.MouseMove, (2, 100), global_pos=origin + QPoint(1000, 0), buttons=Qt.LeftButton + ) + self.assertEqual(self.overlay.width(), 200) + self.assertEqual(self.overlay.geometry().right(), before.right()) + + def test_center_drag_moves_without_resizing_and_release_refreshes_cursor(self): + before = self.overlay.geometry() + origin = self.overlay.mapToGlobal(QPoint(200, 100)) + self.mouse(QEvent.MouseButtonPress, (200, 100), buttons=Qt.LeftButton) + self.mouse( + QEvent.MouseMove, (200, 100), global_pos=origin + QPoint(30, 20), buttons=Qt.LeftButton + ) + self.assertEqual(self.overlay.geometry().size(), before.size()) + self.assertEqual(self.overlay.pos(), before.topLeft() + QPoint(30, 20)) + self.mouse(QEvent.MouseButtonRelease, (2, 100)) + self.assertEqual(self.overlay.cursor().shape(), Qt.SizeHorCursor) + + def test_entering_edge_refreshes_cursor_without_an_extra_move(self): + self.overlay.set_mode(True) + edge = self.overlay.mapToGlobal(QPoint(2, 100)) + with patch("usta.ui.overlay_window.QCursor.pos", return_value=edge): + self.app.sendEvent(self.overlay, QEvent(QEvent.Enter)) + self.assertEqual(self.overlay.cursor().shape(), Qt.SizeHorCursor) + + def test_bottom_right_resize_preserves_existing_behavior_and_minimum_height(self): + before = self.overlay.geometry() + origin = self.overlay.mapToGlobal(QPoint(398, 198)) + self.mouse(QEvent.MouseButtonPress, (398, 198), buttons=Qt.LeftButton) + self.mouse( + QEvent.MouseMove, (398, 198), global_pos=origin + QPoint(50, 20), buttons=Qt.LeftButton + ) + self.assertEqual(self.overlay.geometry().topLeft(), before.topLeft()) + self.assertEqual(self.overlay.width(), before.width() + 50) + self.assertEqual(self.overlay.height(), before.height() + 20) + self.mouse( + QEvent.MouseMove, + (398, 198), + global_pos=origin + QPoint(-1000, -1000), + buttons=Qt.LeftButton, + ) + self.assertEqual(self.overlay.width(), 200) + self.assertEqual(self.overlay.height(), 60) + + def test_leaving_overlay_clears_hover_cursor(self): + self.mouse(QEvent.MouseMove, (2, 100)) + self.assertEqual(self.overlay.cursor().shape(), Qt.SizeHorCursor) + self.app.sendEvent(self.overlay, QEvent(QEvent.Leave)) + self.assertEqual(self.overlay.cursor().shape(), Qt.ArrowCursor) + self.assertEqual(self.overlay.label.cursor().shape(), Qt.ArrowCursor) diff --git a/tests/integration/test_tab_signals.py b/tests/integration/test_tab_signals.py new file mode 100644 index 0000000..88928a0 --- /dev/null +++ b/tests/integration/test_tab_signals.py @@ -0,0 +1,76 @@ +"""Qt forwards widget signals without leaking arguments into no-argument signals.""" + +import unittest +from unittest.mock import patch + +from PySide6.QtTest import QSignalSpy +from PySide6.QtWidgets import QApplication, QPushButton + +from usta.i18n import _ +from usta.ui.tabs import AppearanceTab, HotkeyTab, OCRTab, SaveTab, TranslationTab + + +class TabSignalTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.app = QApplication.instance() or QApplication([]) + + def test_ocr_and_capture_selections_emit_value_and_one_change(self): + tab = OCRTab() + self.addCleanup(tab.close) + for combo, forwarded in ( + (tab.combo_ocr, tab.engine_changed), + (tab.combo_screenshot, tab.screen_engine_changed), + ): + if combo.count() < 2: + combo.addItem("Other capture backend") + changed, value = QSignalSpy(tab.changed), QSignalSpy(forwarded) + with patch("sys.excepthook") as unhandled: + combo.setCurrentIndex((combo.currentIndex() + 1) % combo.count()) + self.app.processEvents() + unhandled.assert_not_called() + self.assertEqual(changed.count(), 1) + self.assertEqual(value.count(), 1) + self.assertEqual(value.at(0), [combo.currentText()]) + + def test_source_target_and_provider_selections_forward_without_typeerror(self): + tab = TranslationTab() + self.addCleanup(tab.close) + for combo in (tab.combo_source, tab.combo_target): + changed = QSignalSpy(tab.language_changed) + with patch("sys.excepthook") as unhandled: + combo.setCurrentIndex((combo.currentIndex() + 1) % combo.count()) + self.app.processEvents() + unhandled.assert_not_called() + self.assertEqual(changed.count(), 1) + changed = QSignalSpy(tab.changed) + selected = QSignalSpy(tab.translator_selected) + with patch("sys.excepthook") as unhandled: + tab.combo_translator.setCurrentText("DeepL") + self.app.processEvents() + unhandled.assert_not_called() + self.assertEqual(changed.count(), 1) + self.assertEqual(selected.at(0), ["DeepL"]) + + def test_action_buttons_drop_clicked_boolean(self): + save, appearance, hotkeys = SaveTab(), AppearanceTab(), HotkeyTab() + for tab in (save, appearance, hotkeys): + self.addCleanup(tab.close) + resets = [b for b in hotkeys.findChildren(QPushButton) if b.text() == _("Reset")] + cases = [ + (save.btn_save_preset, save.save_requested), + (save.btn_load_preset, save.load_requested), + (save.btn_delete_preset, save.delete_requested), + (appearance.btn_color, appearance.text_color_requested), + (appearance.btn_bg_color, appearance.background_color_requested), + (resets[0], hotkeys.reset_settings_requested), + (resets[1], hotkeys.reset_region_requested), + ] + for button, signal in cases: + with self.subTest(button=button.text()): + requested = QSignalSpy(signal) + with patch("sys.excepthook") as unhandled: + button.click() + self.app.processEvents() + unhandled.assert_not_called() + self.assertEqual(requested.count(), 1) diff --git a/tests/integration/test_worker_signals.py b/tests/integration/test_worker_signals.py new file mode 100644 index 0000000..4d8d955 --- /dev/null +++ b/tests/integration/test_worker_signals.py @@ -0,0 +1,84 @@ +import threading +import unittest + +from PySide6.QtCore import QCoreApplication + +from usta.application.worker import OCRWorker + + +class WorkerSignalsTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.app = QCoreApplication.instance() or QCoreApplication([]) + + def test_queued_result_is_discarded_if_stopped_before_gui_delivery(self): + worker = OCRWorker() + self.addCleanup(worker.close) + received = [] + worker.new_translation.connect(received.append) + worker.pipeline.start() + producer = threading.Thread(target=lambda: worker.pipeline.on_translation("old result")) + producer.start() + producer.join() + worker.stop() + self.app.processEvents() + self.assertEqual(received, []) + + def test_repeated_start_does_not_schedule_unrequested_restart(self): + worker = OCRWorker() + self.addCleanup(worker.close) + worker.pipeline.start() + from unittest.mock import patch + + with patch.object(worker, "isRunning", return_value=True): + worker.start() + self.assertFalse(worker._restart_requested) + + def test_queued_timings_are_discarded_after_stop(self): + worker = OCRWorker() + self.addCleanup(worker.close) + received = [] + worker.timings_update.connect(received.append) + worker.pipeline.start() + producer = threading.Thread(target=lambda: worker.pipeline.on_timings("old timings")) + producer.start() + producer.join() + worker.stop() + self.app.processEvents() + self.assertEqual(received, []) + + def test_fatal_capture_error_reaches_gui_after_pipeline_stops(self): + from unittest.mock import Mock + + from usta.core.exceptions import ScreenCapturePermissionError + + worker = OCRWorker() + self.addCleanup(worker.close) + received = [] + worker.error_occurred.connect(received.append) + capture = Mock() + capture.capture.side_effect = ScreenCapturePermissionError("Screen Recording unavailable") + worker.pipeline.capture_factory = lambda name: capture + worker.pipeline.ocr_factory = Mock + worker.pipeline.publisher = Mock() + worker.pipeline.start() + producer = threading.Thread(target=worker.pipeline.run) + producer.start() + producer.join(timeout=2) + self.assertFalse(producer.is_alive()) + self.app.processEvents() + self.assertEqual(received, ["Screen Recording unavailable"]) + + def test_queued_error_is_discarded_after_a_new_session_starts(self): + worker = OCRWorker() + self.addCleanup(worker.close) + received = [] + worker.error_occurred.connect(received.append) + worker.pipeline.start() + producer = threading.Thread(target=lambda: worker.pipeline.on_error("old error")) + producer.start() + producer.join() + worker.stop() + worker.pipeline.start() + self.app.processEvents() + self.assertEqual(received, []) diff --git a/tests/native/__init__.py b/tests/native/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/native/test_macos_capture.py b/tests/native/test_macos_capture.py new file mode 100644 index 0000000..7699195 --- /dev/null +++ b/tests/native/test_macos_capture.py @@ -0,0 +1,77 @@ +"""Opt-in real ScreenCaptureKit check; never opens a permission request.""" + +import importlib.util +import os +import sys +import tempfile +import threading +import time +import unittest +from pathlib import Path +from unittest.mock import patch + +from PIL import Image +from PySide6.QtCore import Qt +from PySide6.QtTest import QTest +from PySide6.QtWidgets import QApplication, QWidget + + +@unittest.skipUnless( + sys.platform == "darwin" + and os.environ.get("USTA_NATIVE_TESTS") == "1" + and importlib.util.find_spec("ScreenCaptureKit") is not None, + "Requires native macOS and ScreenCaptureKit", +) +class NativeMacOSCaptureTests(unittest.TestCase): + def test_worker_captures_test_window_without_external_process_or_permission_request(self): + import Quartz + + from usta.core.screenshot.macos_screenshot import MacOSScreenshot + + if not Quartz.CGPreflightScreenCaptureAccess(): + self.skipTest("Test runner lacks Screen Recording access; permission was not requested") + app = QApplication.instance() or QApplication([]) + window = QWidget() + window.setWindowFlags(Qt.Tool | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint) + window.setStyleSheet("background: white;") + window.setGeometry(100, 100, 160, 80) + window.show() + window.raise_() + QTest.qWait(300) + region = window.frameGeometry() + outcome = {} + with tempfile.TemporaryDirectory(prefix="usta-native-capture-") as directory: + output = Path(directory) / "frame.png" + + def capture(): + try: + outcome["ok"] = MacOSScreenshot().capture(region, str(output), 2) + except Exception as exc: + outcome["error"] = exc + + with ( + patch.object( + Quartz, "CGRequestScreenCaptureAccess", side_effect=AssertionError("request") + ), + patch("PIL.ImageGrab.grab", side_effect=AssertionError("external capture")), + ): + worker = threading.Thread(target=capture) + worker.start() + deadline = time.monotonic() + 7 + while worker.is_alive() and time.monotonic() < deadline: + QTest.qWait(10) + worker.join(timeout=0.1) + window.close() + app.processEvents() + self.assertFalse(worker.is_alive(), "ScreenCaptureKit did not finish") + self.assertNotIn("error", outcome, outcome.get("error")) + self.assertTrue(outcome.get("ok")) + with Image.open(output) as image: + self.assertEqual(image.size, (region.width() * 2, region.height() * 2)) + self.assertTrue( + all( + channel > 240 + for channel in image.getpixel((image.width // 2, image.height // 2)) + ), + f"region={region}, center={image.getpixel((image.width // 2, image.height // 2))}", + ) diff --git a/tests/native/test_macos_hotkey_actions.py b/tests/native/test_macos_hotkey_actions.py new file mode 100644 index 0000000..a632f66 --- /dev/null +++ b/tests/native/test_macos_hotkey_actions.py @@ -0,0 +1,382 @@ +import gc +import json +import os +import subprocess +import sys +import tempfile +import threading +import time +import unittest +import weakref +from pathlib import Path +from unittest import mock + +from PySide6.QtCore import QCoreApplication, QEvent, QEventLoop, QPoint, QRect, Qt, QTimer +from PySide6.QtTest import QTest +from PySide6.QtWidgets import QApplication, QWidget + +from usta.ui.platform.macos.hotkeys import MacOSHotkeyWindow +from usta.ui.region_selection.core_sniper import CoreSniper, OverlayWindow, SelectionManager + + +def pump(milliseconds=40): + loop = QEventLoop() + QTimer.singleShot(milliseconds, loop.quit) + loop.exec() + + +@unittest.skipUnless( + os.environ.get("USTA_NATIVE_TESTS") == "1" and sys.platform == "darwin", "Requires macOS" +) +class MacOSHotkeyActionTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + import AppKit + import objc + + cls.cocoa = AppKit + cls.objc = objc + cls.app = QApplication.instance() or QApplication([]) + cls.app.setQuitOnLastWindowClosed(False) + + def make_control_panel(self, os_name): + from contextlib import ExitStack + + from usta.ui.main_window import ControlPanel + + stack = ExitStack() + self.addCleanup(stack.close) + stack.enter_context(mock.patch("usta.ui.main_window.OS", os_name)) + stack.enter_context(mock.patch("usta.ui.overlay_window.OS", os_name)) + stack.enter_context(mock.patch("usta.core.shortcut.GlobalHotkey.start", return_value=False)) + stack.enter_context(mock.patch.object(ControlPanel, "load_settings")) + stack.enter_context(mock.patch.object(ControlPanel, "save_settings")) + stack.enter_context(mock.patch.object(ControlPanel, "refresh_preset_list")) + quit_app = stack.enter_context(mock.patch.object(QApplication, "quit")) + worker = stack.enter_context(mock.patch("usta.ui.main_window.OCRWorker")).return_value + worker.isRunning.return_value = False + worker.capture_rect = QRect(10, 20, 200, 50) + worker.dpi_scale = 1.0 + worker.set_rect.side_effect = lambda rect: setattr(worker, "capture_rect", QRect(rect)) + panel = ControlPanel() + pump(150) # Finish the normal startup overlay-position timer. + + def close(): + panel.request_quit() + panel.overlay.deleteLater() + panel.deleteLater() + pump() + + self.addCleanup(close) + return panel, quit_app + + def test_linux_visibility_action_preserves_topmost_and_decorations(self): + panel, quit_app = self.make_control_panel("Linux") + self.assertIsNone(panel._macos_hotkey_window) + self.assertIsNone(panel.overlay._macos_rules) + with ( + mock.patch.object(panel, "raise_") as raise_window, + mock.patch.object(panel, "activateWindow") as activate, + ): + panel.hide_main_window() + panel.show_main_window() + self.assertTrue(panel.windowFlags() & Qt.WindowStaysOnTopHint) + raise_window.assert_called_once() + activate.assert_called_once() + panel.hide_main_window() + self.assertFalse(panel.isVisible()) + self.assertTrue(panel.windowFlags() & Qt.WindowStaysOnTopHint) + self.assertFalse(panel.windowFlags() & Qt.FramelessWindowHint) + quit_app.assert_not_called() + + def test_linux_region_selector_keeps_x11_flags_without_native_imports(self): + with ( + mock.patch("usta.ui.region_selection.core_sniper.sys.platform", "linux"), + mock.patch.dict(sys.modules, {"usta.ui.platform.macos.hotkeys": None}), + ): + manager = SelectionManager() + window = OverlayWindow(self.app.primaryScreen(), manager) + self.assertTrue(window.windowFlags() & Qt.X11BypassWindowManagerHint) + self.assertIsNone(window._macos_hotkey_window) + window.deleteLater() + pump() + + def test_region_windows_are_destroyed_on_gui_thread_before_worker_collection(self): + import shiboken6 + + for cancel in (False, True): + with self.subTest(cancel=cancel): + windows = [] + destroyed_on = [] + gui_thread = threading.get_ident() + + def finish(): + windows.extend( + w + for w in self.app.topLevelWidgets() + if isinstance(w, OverlayWindow) and w.isVisible() + ) + for window in windows: + window.destroyed.connect( + lambda: destroyed_on.append(threading.get_ident()), + Qt.DirectConnection, + ) + windows[0].manager.selectionFinished.emit( + QRect() if cancel else QRect(50, 50, 150, 50) + ) + + QTimer.singleShot(100, finish) + rect = CoreSniper().get_region() + self.assertEqual(rect, None if cancel else QRect(50, 50, 150, 50)) + # close() only hides QWidget. Native resources must also be + # deleted even while Python references/cycles still exist. + QCoreApplication.sendPostedEvents(None, QEvent.DeferredDelete) + try: + self.assertTrue(windows) + self.assertTrue( + all(not shiboken6.isValid(w) for w in windows), + "Closed selection windows retain live native widgets", + ) + self.assertEqual(destroyed_on, [gui_thread] * len(windows)) + windows.clear() + worker = threading.Thread(target=gc.collect) + worker.start() + worker.join(timeout=5) + self.assertFalse(worker.is_alive()) + pump() + finally: + for window in windows: + if shiboken6.isValid(window): + window.deleteLater() + QCoreApplication.sendPostedEvents(None, QEvent.DeferredDelete) + + def test_worker_collection_cannot_destroy_pending_region_windows(self): + import shiboken6 + + references = [] + destroyed_on = [] + gui_thread = threading.get_ident() + + def finish(): + for window in self.app.topLevelWidgets(): + if isinstance(window, OverlayWindow) and window.isVisible(): + references.append(weakref.ref(window)) + window.destroyed.connect( + lambda: destroyed_on.append(threading.get_ident()), + Qt.DirectConnection, + ) + window.manager.selectionFinished.emit(QRect(50, 50, 150, 50)) + + was_enabled = gc.isenabled() + gc.disable() + try: + QTimer.singleShot(100, finish) + self.assertEqual(CoreSniper().get_region(), QRect(50, 50, 150, 50)) + # Simulate OCR startup collecting Python cycles immediately, before + # the GUI has even had time to process its deferred-delete events. + worker = threading.Thread(target=gc.collect) + worker.start() + worker.join(timeout=5) + self.assertFalse(worker.is_alive()) + self.assertEqual(destroyed_on, []) + QCoreApplication.sendPostedEvents(None, QEvent.DeferredDelete) + self.assertTrue(references) + self.assertEqual(destroyed_on, [gui_thread] * len(references)) + self.assertTrue( + all(ref() is None or not shiboken6.isValid(ref()) for ref in references) + ) + pump() + finally: + if was_enabled: + gc.enable() + + def test_macos_controls_use_native_action_and_restore_temporary_region(self): + panel, quit_app = self.make_control_panel("Darwin") + self.assertEqual(panel.windowType(), Qt.Tool) + with ( + mock.patch.object( + panel._macos_hotkey_window, "show", wraps=panel._macos_hotkey_window.show + ) as show, + mock.patch.object( + panel._macos_hotkey_window, "stop", wraps=panel._macos_hotkey_window.stop + ) as stop, + mock.patch.object(panel, "raise_") as raise_window, + mock.patch.object(panel, "activateWindow") as activate, + ): + panel.overlay._toggle_main_window_visibility() + self.assertFalse(panel.isVisible()) + stop.assert_called_once() + panel.overlay._toggle_main_window_visibility() + self.assertTrue(panel.isVisible()) + show.assert_called_once() + raise_window.assert_not_called() + activate.assert_not_called() + original = QRect(panel.worker.capture_rect) + panel._apply_temporary_region(QRect(100, 100, 300, 80), 2.0) + panel._apply_temporary_region(QRect(200, 200, 300, 80), 1.5) + self.assertGreater(panel.region_controller.restore_timer.remainingTime(), 9000) + panel.region_controller.restore_timer.timeout.emit() + self.assertEqual(panel.worker.capture_rect, original) + self.assertEqual(panel.worker.dpi_scale, 1.0) + self.assertFalse(panel.region_controller.temporary_active) + with mock.patch.object(panel, "_get_selected_region_and_dpi", return_value=(None, None)): + panel.select_temporary_region() + self.assertEqual(panel.worker.capture_rect, original) + self.assertFalse(panel._temporary_region_selecting) + panel.request_quit() + quit_app.assert_called_once() + + def test_settings_and_region_selection_preserve_fullscreen_space(self): + with tempfile.TemporaryDirectory() as directory: + state = Path(directory) / "state" + exit_file = Path(directory) / "exit" + helper = Path(__file__).parents[1] / "helpers" / "macos_fullscreen_host.py" + process = subprocess.Popen([sys.executable, str(helper), str(state), str(exit_file)]) + panel = QWidget() + panel.setWindowFlags(Qt.Tool | Qt.WindowTitleHint | Qt.WindowCloseButtonHint) + panel.setAttribute(Qt.WA_ShowWithoutActivating) + panel.setAttribute(Qt.WA_MacAlwaysShowToolWindow) + panel.setGeometry(180, 180, 320, 180) + actions = MacOSHotkeyWindow(panel) + try: + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + pump() + if state.exists(): + try: + if json.loads(state.read_text())["fullscreen"]: + break + except json.JSONDecodeError: + pass + else: + self.fail("Fullscreen host did not enter its Space") + actions.show() + pump(450) + native = self.objc.objc_object(c_void_p=int(panel.winId())).window() + self.assertTrue(native.isOnActiveSpace()) + self.assertTrue(native.isKindOfClass_(self.cocoa.NSPanel)) + self.assertTrue(native.styleMask() & self.cocoa.NSWindowStyleMaskTitled) + import Quartz + + on_screen = Quartz.CGWindowListCopyWindowInfo( + Quartz.kCGWindowListOptionOnScreenOnly, 0 + ) + self.assertTrue( + any(w["kCGWindowOwnerPID"] == process.pid for w in on_screen), + "Showing settings switched away from the fullscreen Space", + ) + # A panel can become key while its owning app remains inactive. + self.assertIn( + self.cocoa.NSWorkspace.sharedWorkspace() + .frontmostApplication() + .processIdentifier(), + (os.getpid(), process.pid), + ) + self.assertTrue(native.isKeyWindow()) + actions.lower() + deadline = time.monotonic() + 2 + while time.monotonic() < deadline: + pump() + if ( + self.cocoa.NSWorkspace.sharedWorkspace() + .frontmostApplication() + .processIdentifier() + == process.pid + ): + break + self.assertEqual(native.level(), self.cocoa.NSNormalWindowLevel) + self.assertEqual( + self.cocoa.NSWorkspace.sharedWorkspace() + .frontmostApplication() + .processIdentifier(), + process.pid, + ) + observations = [] + + def select(): + windows = [ + w + for w in self.app.topLevelWidgets() + if isinstance(w, OverlayWindow) and w.isVisible() + ] + if not windows: + observations.append(False) + return + target = next( + (w for w in windows if w.screen_geometry.contains(QPoint(150, 150))), + windows[0], + ) + n = self.objc.objc_object(c_void_p=int(target.winId())).window() + observations.append(n.isOnActiveSpace()) + on_screen = Quartz.CGWindowListCopyWindowInfo( + Quartz.kCGWindowListOptionOnScreenOnly, 0 + ) + observations.append( + any(w["kCGWindowOwnerPID"] == process.pid for w in on_screen) + ) + QTest.mousePress(target, Qt.LeftButton, pos=QPoint(50, 50)) + QTest.mouseMove(target, QPoint(200, 100)) + QTest.mouseRelease(target, Qt.LeftButton, pos=QPoint(200, 100)) + + QTimer.singleShot(150, select) + # A watchdog avoids a stuck nested loop on failure. + watchdog = QTimer() + watchdog.setSingleShot(True) + watchdog.timeout.connect( + lambda: [ + w.manager.selectionFinished.emit(QRect()) + for w in self.app.topLevelWidgets() + if isinstance(w, OverlayWindow) and w.isVisible() + ] + ) + watchdog.start(1500) + try: + rect = CoreSniper().get_region() + finally: + watchdog.stop() + self.assertIsNotNone(rect) + self.assertEqual(observations, [True, True]) + self.assertEqual(rect.size().width(), 151) + self.assertEqual(rect.size().height(), 51) + deadline = time.monotonic() + 2 + while time.monotonic() < deadline: + pump() + if ( + self.cocoa.NSWorkspace.sharedWorkspace() + .frontmostApplication() + .processIdentifier() + == process.pid + ): + break + self.assertEqual( + self.cocoa.NSWorkspace.sharedWorkspace() + .frontmostApplication() + .processIdentifier(), + process.pid, + ) + + def cancel(): + for window in self.app.topLevelWidgets(): + if isinstance(window, OverlayWindow) and window.isVisible(): + QTest.keyClick(window, Qt.Key_Escape) + break + + QTimer.singleShot(150, cancel) + watchdog.start(1500) + try: + self.assertIsNone(CoreSniper().get_region()) + finally: + watchdog.stop() + self.assertFalse( + any( + isinstance(w, OverlayWindow) and w.isVisible() + for w in self.app.topLevelWidgets() + ) + ) + finally: + actions.stop() + panel.close() + panel.deleteLater() + process.terminate() + process.wait(timeout=5) + pump() diff --git a/tests/native/test_macos_overlay.py b/tests/native/test_macos_overlay.py new file mode 100644 index 0000000..6cb31b0 --- /dev/null +++ b/tests/native/test_macos_overlay.py @@ -0,0 +1,215 @@ +"""Native macOS integration tests; require a WindowServer session.""" + +import json +import os +import subprocess +import sys +import tempfile +import time +import unittest +from pathlib import Path +from unittest import mock + +from PySide6.QtCore import QEvent, QEventLoop, Qt, QTimer +from PySide6.QtWidgets import QApplication, QWidget + +from usta.ui.platform.macos.overlay import MacOSOverlayRules + + +def wait_for_events(milliseconds=30): + loop = QEventLoop() + QTimer.singleShot(milliseconds, loop.quit) + loop.exec() + + +@unittest.skipUnless( + os.environ.get("USTA_NATIVE_TESTS") == "1" and sys.platform == "darwin", "Requires macOS Cocoa" +) +class MacOSOverlayTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + import AppKit + import objc + + cls.cocoa = AppKit + cls.objc = objc + cls.app = QApplication.instance() or QApplication([]) + cls.app.setQuitOnLastWindowClosed(False) + + def setUp(self): + self.widget = QWidget() + self.widget.setWindowFlags(Qt.Tool | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint) + self.widget.setAttribute(Qt.WA_ShowWithoutActivating) + self.widget.resize(120, 40) + self.widget.move(150, 150) + self.widget.show() + self.rules = MacOSOverlayRules(self.widget) + self.rules.start() + wait_for_events() + + def tearDown(self): + self.rules.stop() + self.widget.close() + self.widget.deleteLater() + wait_for_events() + + def native_window(self): + return self.objc.objc_object(c_void_p=int(self.widget.winId())).window() + + def assert_rules(self): + window = self.native_window() + behavior = int(window.collectionBehavior()) + self.assertEqual(window.level(), 25) + self.assertEqual(behavior & 273, 273) + if hasattr(self.cocoa, "NSWindowCollectionBehaviorCanJoinAllApplications"): + self.assertTrue(behavior & 262144) + self.assertFalse(behavior & (65536 | 131072)) + self.assertFalse(window.hidesOnDeactivate()) + self.assertTrue(int(window.styleMask()) & 128) + + def reset_rules(self): + window = self.native_window() + window.setStyleMask_(window.styleMask() & ~128) + window.setCollectionBehavior_(0) + window.setLevel_(0) + window.setHidesOnDeactivate_(True) + + def test_sets_fullscreen_and_all_applications_rules(self): + self.assert_rules() + + def test_overlay_remains_on_screen_during_another_apps_native_fullscreen(self): + import Quartz + + from usta.ui.overlay_window import TransparentOverlay + + self.rules.stop() + self.widget.close() + self.widget.deleteLater() + # Exercise the real translation update path without global keyboard hooks. + with mock.patch("usta.ui.overlay_window.GlobalHotkey", autospec=True): + self.widget = TransparentOverlay() + self.widget.move(150, 150) + self.widget.show() + self.rules = self.widget._macos_rules + wait_for_events() + + helper = Path(__file__).parents[1] / "helpers" / "macos_fullscreen_host.py" + with tempfile.TemporaryDirectory() as directory: + state_file = Path(directory) / "state.json" + exit_file = Path(directory) / "exit" + process = subprocess.Popen( + [sys.executable, str(helper), str(state_file), str(exit_file)] + ) + try: + + def wait_for_fullscreen(expected): + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + wait_for_events(50) + if state_file.exists(): + try: + state = json.loads(state_file.read_text()) + except json.JSONDecodeError: + continue + if state["fullscreen"] == expected: + self.assertTrue(state["active"], "Fullscreen host must be focused") + return + self.assertIsNone(process.poll(), "Fullscreen host exited unexpectedly") + self.fail("Native fullscreen transition did not complete") + + wait_for_fullscreen(True) + wait_for_events(500) + native = self.native_window() + self.assertTrue(native.isOnActiveSpace(), "Overlay stayed on the desktop Space") + windows = Quartz.CGWindowListCopyWindowInfo( + Quartz.kCGWindowListOptionOnScreenOnly, 0 + ) + self.assertIn(native.windowNumber(), [w["kCGWindowNumber"] for w in windows]) + frontmost = self.cocoa.NSWorkspace.sharedWorkspace().frontmostApplication() + self.assertEqual(frontmost.processIdentifier(), process.pid) + self.assert_rules() + for text in ("First translation", "Second translation", "Third translation"): + self.widget.update_text(text) + wait_for_events(150) + frontmost = self.cocoa.NSWorkspace.sharedWorkspace().frontmostApplication() + self.assertEqual( + frontmost.processIdentifier(), + process.pid, + "Translation update stole fullscreen focus", + ) + self.assertEqual(self.widget.label.text(), text) + self.assertTrue(self.widget.hide_timer.isActive()) + self.assertTrue(native.isOnActiveSpace()) + exit_file.touch() + wait_for_fullscreen(False) + wait_for_events(500) + self.assertTrue(native.isOnActiveSpace()) + self.assert_rules() + finally: + process.terminate() + process.wait(timeout=5) + + def test_older_cocoa_keeps_existing_fullscreen_rules(self): + cocoa = self.cocoa + + class OlderCocoa: + def __getattr__(self, name): + if name == "NSWindowCollectionBehaviorCanJoinAllApplications": + raise AttributeError(name) + return getattr(cocoa, name) + + # PyObjC lazily reloads deleted constants, so simulate the older module. + with mock.patch.dict(sys.modules, {"AppKit": OlderCocoa()}): + self.reset_rules() + self.app.sendEvent(self.widget, QEvent(QEvent.WindowStateChange)) + wait_for_events() + self.assertEqual(int(self.native_window().collectionBehavior()), 273) + self.assertEqual(self.native_window().level(), 25) + + def test_space_transition_restores_rules_after_late_native_reset(self): + center = self.cocoa.NSWorkspace.sharedWorkspace().notificationCenter() + center.postNotificationName_object_( + self.cocoa.NSWorkspaceActiveSpaceDidChangeNotification, None + ) + wait_for_events() + self.reset_rules() + wait_for_events(450) + self.assert_rules() + + def test_window_state_change_restores_rules(self): + self.reset_rules() + self.app.sendEvent(self.widget, QEvent(QEvent.WindowStateChange)) + wait_for_events() + self.assert_rules() + + def test_fullscreen_notifications_restore_rules(self): + center = self.cocoa.NSNotificationCenter.defaultCenter() + for name in ( + self.cocoa.NSWindowDidEnterFullScreenNotification, + self.cocoa.NSWindowDidExitFullScreenNotification, + ): + with self.subTest(notification=name): + self.reset_rules() + center.postNotificationName_object_(name, self.native_window()) + wait_for_events() + self.assert_rules() + + def test_stop_cancels_pending_restore_and_restart_works(self): + center = self.cocoa.NSWorkspace.sharedWorkspace().notificationCenter() + name = self.cocoa.NSWorkspaceActiveSpaceDidChangeNotification + center.postNotificationName_object_(name, None) + self.rules.stop() + self.reset_rules() + center.postNotificationName_object_(name, None) + wait_for_events(450) + self.assertEqual(self.native_window().level(), 0) + self.rules.start() + wait_for_events() + self.assert_rules() + + def test_mouse_tracking_overlay_accepts_native_hover_events(self): + self.widget.setMouseTracking(True) + window = self.native_window() + window.setAcceptsMouseMovedEvents_(False) + self.rules._apply() + self.assertTrue(window.acceptsMouseMovedEvents()) diff --git a/tests/native/test_macos_overlay_cursor.py b/tests/native/test_macos_overlay_cursor.py new file mode 100644 index 0000000..e501ba2 --- /dev/null +++ b/tests/native/test_macos_overlay_cursor.py @@ -0,0 +1,156 @@ +"""Native hover delivery and NSCursor updates without Qt mouse-move events.""" + +import os +import sys +import unittest +from unittest.mock import patch + +from PySide6.QtCore import QEventLoop, Qt, QTimer +from PySide6.QtWidgets import QApplication + +from usta.ui.overlay_window import TransparentOverlay + + +def pump(): + loop = QEventLoop() + QTimer.singleShot(30, loop.quit) + loop.exec() + + +@unittest.skipUnless( + os.environ.get("USTA_NATIVE_TESTS") == "1" and sys.platform == "darwin", "Requires native macOS" +) +class NativeOverlayCursorTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + import AppKit + import objc + + cls.cocoa, cls.objc = AppKit, objc + cls.app = QApplication.instance() or QApplication([]) + + def setUp(self): + with patch("usta.core.shortcut.GlobalHotkey.start", return_value=False): + self.overlay = TransparentOverlay() + self.overlay.setGeometry(160, 160, 400, 200) + self.overlay.show() + pump() + self.addCleanup(self.overlay.close) + + def native_event(self, x, y): + view = self.objc.objc_object(c_void_p=int(self.overlay.winId())) + point = (x, y if view.isFlipped() else self.overlay.height() - y) + location = view.convertPoint_toView_(point, None) + return self.cocoa.NSEvent.mouseEventWithType_location_modifierFlags_timestamp_windowNumber_context_eventNumber_clickCount_pressure_( + self.cocoa.NSEventTypeMouseMoved, + location, + 0, + 0.0, + view.window().windowNumber(), + None, + 0, + 0, + 0.0, + ) + + def test_tracking_is_always_active_and_is_removed_on_hide(self): + helper = self.overlay._macos_cursor + area = helper._state["area"] + self.assertTrue(area.options() & self.cocoa.NSTrackingActiveAlways) + self.assertTrue(area.options() & self.cocoa.NSTrackingMouseMoved) + self.assertIn(area, helper._state["view"].trackingAreas()) + self.overlay.hide() + self.assertIsNone(helper._state["area"]) + self.overlay.show() + pump() + self.assertIsNotNone(helper._state["area"]) + + def test_native_hover_sets_displayed_cursor_before_press_without_activating_app(self): + helper = self.overlay._macos_cursor + # Exercise the native callback directly: Qt omits it when NSApp is inactive. + # Native cursor/tracking objects remain real; no input-event injection needed. + active = bool(self.cocoa.NSApp.isActive()) + cases = [ + ((2, 100), Qt.SizeHorCursor, self.cocoa.NSCursor.resizeLeftRightCursor()), + ((200, 2), Qt.SizeVerCursor, self.cocoa.NSCursor.resizeUpDownCursor()), + ((200, 100), Qt.SizeAllCursor, self.cocoa.NSCursor.openHandCursor()), + ] + for (x, y), shape, native in cases: + with self.subTest(shape=shape): + self.cocoa.NSCursor.arrowCursor().set() + helper._state["owner"].mouseMoved_(self.native_event(x, y)) + self.assertIsNone(self.overlay._drag_mode) + self.assertEqual(self.overlay.cursor().shape(), shape) + self.assertEqual(self.cocoa.NSCursor.currentCursor(), native) + self.assertEqual(bool(self.cocoa.NSApp.isActive()), active) + helper._state["owner"].mouseExited_(self.native_event(401, 100)) + self.assertEqual(self.overlay.cursor().shape(), Qt.ArrowCursor) + self.assertFalse(helper._hovering) + + def test_dragging_move_uses_closed_hand_and_stop_restores_previous_cursor(self): + helper = self.overlay._macos_cursor + helper._state["owner"].mouseMoved_(self.native_event(200, 100)) + self.overlay._drag_mode = "move" + helper._state["owner"].mouseMoved_(self.native_event(210, 110)) + self.assertEqual( + self.cocoa.NSCursor.currentCursor(), self.cocoa.NSCursor.closedHandCursor() + ) + helper.stop() + self.assertFalse(helper._hovering) + self.assertIsNone(helper._state["area"]) + + def test_diagonal_cursors_have_a_public_api_fallback_for_older_macos(self): + from types import SimpleNamespace + + native_cursor = self.cocoa.NSCursor + helper = self.overlay._macos_cursor + with patch.object(self.cocoa, "NSCursor", SimpleNamespace(alloc=native_cursor.alloc)): + for shape in (Qt.SizeFDiagCursor, Qt.SizeBDiagCursor): + cursor = helper._make_cursor(shape, False) + self.assertIsNotNone(cursor.image().TIFFRepresentation()) + self.assertEqual(tuple(cursor.image().size()), (24.0, 24.0)) + + def test_obscured_overlay_does_not_override_another_windows_cursor(self): + helper = self.overlay._macos_cursor + from types import SimpleNamespace + + with patch.object( + self.cocoa, + "NSWindow", + SimpleNamespace( + windowNumberAtPoint_belowWindowWithWindowNumber_=lambda point, below: -1 + ), + ): + self.cocoa.NSCursor.arrowCursor().set() + helper._state["owner"].mouseMoved_(self.native_event(2, 100)) + self.assertFalse(helper._hovering) + self.assertEqual(self.cocoa.NSCursor.currentCursor(), self.cocoa.NSCursor.arrowCursor()) + + def test_repeated_start_does_not_duplicate_tracking_area(self): + helper = self.overlay._macos_cursor + original = helper._state["area"] + helper.start() + helper.start() + self.assertEqual(helper._state["area"], original) + owned = [ + area + for area in helper._state["view"].trackingAreas() + if area.owner() == helper._state["owner"] + ] + self.assertEqual(len(owned), 1) + + def test_native_corner_hover_applies_diagonal_cursor_without_press(self): + helper = self.overlay._macos_cursor + for position, shape in ( + ((2, 2), Qt.SizeFDiagCursor), + ((398, 2), Qt.SizeBDiagCursor), + ((2, 198), Qt.SizeBDiagCursor), + ((398, 198), Qt.SizeFDiagCursor), + ): + with self.subTest(position=position): + helper._state["owner"].mouseMoved_(self.native_event(*position)) + self.assertIsNone(self.overlay._drag_mode) + self.assertEqual(self.overlay.cursor().shape(), shape) + self.assertEqual( + self.cocoa.NSCursor.currentCursor(), helper._cursors[(shape, False)] + ) diff --git a/tests/packaging/__init__.py b/tests/packaging/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/packaging/test_artifacts.py b/tests/packaging/test_artifacts.py new file mode 100644 index 0000000..eaa36e5 --- /dev/null +++ b/tests/packaging/test_artifacts.py @@ -0,0 +1,56 @@ +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +class PackageLayoutTests(unittest.TestCase): + def test_real_package_and_launchers(self): + self.assertTrue((ROOT / "src/usta/__main__.py").is_file()) + self.assertNotIn("sys.path.insert", (ROOT / "src/usta/main.py").read_text()) + self.assertIn("usta.main", (ROOT / "run.py").read_text()) + + def test_bundled_resources_are_declared(self): + try: + import tomllib + except ImportError: + import tomli as tomllib + data = tomllib.loads((ROOT / "pyproject.toml").read_text()) + self.assertIn( + "resources/ocr_models/rapidocr/*.onnx", + data["tool"]["setuptools"]["package-data"]["usta"], + ) + + def test_built_wheel_and_sdist_contain_identical_resources(self): + import hashlib + import os + import tarfile + import zipfile + + from usta._version import __version__ + + wheels = list((ROOT / "dist").glob(f"usta-{__version__}-*.whl")) + sdists = list((ROOT / "dist").glob(f"usta-{__version__}.tar.gz")) + if not wheels or not sdists: + if os.environ.get("USTA_REQUIRE_ARTIFACTS") == "1": + self.fail("Build artifacts required; run python -m build first") + self.skipTest("Run python -m build to inspect distribution artifacts") + with zipfile.ZipFile(wheels[-1]) as wheel, tarfile.open(sdists[-1]) as sdist: + icon = ROOT / "src/usta/ui/assets/select-region.svg" + self.assertEqual(wheel.read("usta/ui/assets/select-region.svg"), icon.read_bytes()) + icon_member = next( + m + for m in sdist.getmembers() + if m.name.endswith("/usta/ui/assets/select-region.svg") + ) + self.assertEqual(sdist.extractfile(icon_member).read(), icon.read_bytes()) + for resource in (ROOT / "src/usta/resources").rglob("*"): + if not resource.is_file(): + continue + suffix = str(resource.relative_to(ROOT / "src")) + expected = hashlib.sha256(resource.read_bytes()).digest() + self.assertEqual(hashlib.sha256(wheel.read(suffix)).digest(), expected) + member = next(m for m in sdist.getmembers() if m.name.endswith("/" + suffix)) + self.assertEqual( + hashlib.sha256(sdist.extractfile(member).read()).digest(), expected + ) diff --git a/tests/packaging/test_macos_bundle.py b/tests/packaging/test_macos_bundle.py new file mode 100644 index 0000000..b730847 --- /dev/null +++ b/tests/packaging/test_macos_bundle.py @@ -0,0 +1,33 @@ +"""Opt-in acceptance check against the frozen application, not the source environment.""" + +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +@unittest.skipUnless( + sys.platform == "darwin" and os.environ.get("USTA_REQUIRE_MACOS_APP") == "1", + "Requires an explicitly selected macOS application build", +) +class MacOSBundleTests(unittest.TestCase): + def test_frozen_ocr_tab_exposes_native_macos_engines(self): + default_app = Path(__file__).resolve().parents[2] / "dist/macos/USTA.app" + app = Path(os.environ.get("USTA_MACOS_APP", str(default_app))) + with tempfile.TemporaryDirectory(prefix="usta-macos-test-") as directory: + output = Path(directory) / "report.json" + process = subprocess.run( + [str(app / "Contents/MacOS/USTA"), "--check-bundle", str(output)], + capture_output=True, + text=True, + timeout=90, + ) + self.assertTrue(output.is_file(), process.stderr) + report = json.loads(output.read_text()) + self.assertEqual(process.returncode, 0, report.get("error", process.stderr)) + self.assertEqual(report.get("os"), "Darwin") + self.assertEqual(report.get("screen_engines"), ["MacOS"]) + self.assertIn("Apple Vision", report.get("ocr_engines", [])) diff --git a/tests/packaging/test_metadata.py b/tests/packaging/test_metadata.py new file mode 100644 index 0000000..3634976 --- /dev/null +++ b/tests/packaging/test_metadata.py @@ -0,0 +1,25 @@ +import unittest +from pathlib import Path + +try: + import tomllib +except ImportError: + import tomli as tomllib + +ROOT = Path(__file__).resolve().parents[2] + + +class MetadataTests(unittest.TestCase): + def test_runtime_dependencies_are_complete(self): + data = tomllib.loads((ROOT / "pyproject.toml").read_text()) + deps = data["project"]["dependencies"] + self.assertTrue(any(d.startswith("deepl>=") for d in deps)) + self.assertTrue(any(d.startswith("pynput>=") for d in deps)) + self.assertFalse(any(d.startswith("paddleocr") for d in deps)) + self.assertIn("paddle", data["project"]["optional-dependencies"]) + self.assertEqual((ROOT / "requirements.txt").read_text().strip(), ".") + + def test_appimage_version_uses_checkout_version_source(self): + script = (ROOT / "scripts/build-appimage.sh").read_text() + self.assertIn("src/usta/_version.py", script) + self.assertNotIn("from usta._version import __version__", script) diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_apple_vision_engine.py b/tests/unit/test_apple_vision_engine.py new file mode 100644 index 0000000..6060133 --- /dev/null +++ b/tests/unit/test_apple_vision_engine.py @@ -0,0 +1,107 @@ +import importlib.util +import os +import sys +import tempfile +import unittest +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from unittest import mock + +from PIL import Image, ImageDraw, ImageFont + +from usta.core.ocr.apple_vision_engine import AppleVisionEngine +from usta.core.ocr.ocr_manager import OCRManager +from usta.core.platform.capability_resolver import resolve_capabilities + + +class VisionRegistrationTests(unittest.TestCase): + def test_missing_vision_dependency_is_reported_once_without_crashing(self): + engine = AppleVisionEngine() + with mock.patch.dict(sys.modules, {"Vision": None}): + with self.assertLogs("usta.core.ocr.apple_vision_engine", level="ERROR") as logs: + self.assertEqual(engine.read_text("image.png"), "") + self.assertEqual(engine.read_text("image.png"), "") + self.assertEqual(len(logs.output), 1) + + def test_engine_is_offered_only_on_macos(self): + self.assertIn("Apple Vision", resolve_capabilities("Darwin", "")["OCR_ENGINES"]) + for platform in ("Linux", "Windows"): + self.assertNotIn("Apple Vision", resolve_capabilities(platform, "")["OCR_ENGINES"]) + with mock.patch("usta.core.ocr.ocr_manager.sys.platform", "linux"): + self.assertNotIn("Apple Vision", OCRManager().engines) + + @unittest.skipUnless( + os.environ.get("USTA_NATIVE_TESTS") == "1" and sys.platform == "darwin", "Requires macOS" + ) + def test_manager_selects_vision_and_propagates_language(self): + manager = OCRManager() + manager.set_engine("Apple Vision") + manager.set_language("ko") + self.assertEqual(manager.current_engine_name, "Apple Vision") + self.assertEqual(manager.engines["Apple Vision"].language, "ko") + + +@unittest.skipUnless( + os.environ.get("USTA_NATIVE_TESTS") == "1" + and sys.platform == "darwin" + and importlib.util.find_spec("Vision"), + "Requires macOS and pyobjc-framework-Vision", +) +class NativeVisionTests(unittest.TestCase): + def test_recognizes_english_from_file_with_spaces(self): + engine = AppleVisionEngine() + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "OCR image.png" + image = Image.new("RGB", (900, 180), "white") + font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 60) + ImageDraw.Draw(image).text((35, 45), "USTA Vision Test 123", font=font, fill="black") + image.save(path) + # Match the worker's background-thread usage, including pool cleanup. + with ThreadPoolExecutor(max_workers=1) as executor: + for _ in range(3): + text = executor.submit(engine.read_text, str(path)).result(timeout=15) + self.assertEqual(text, "USTA Vision Test 123") + + def test_korean_and_auto_recognize_korean(self): + import Vision + + request = AppleVisionEngine()._make_request(Vision) + supported, _ = request.supportedRecognitionLanguagesAndReturnError_(None) + if "ko-KR" not in supported: + self.skipTest("Korean recognition is unavailable on this macOS version") + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "korean.png" + image = Image.new("RGB", (1000, 180), "white") + font = ImageFont.truetype("/System/Library/Fonts/AppleSDGothicNeo.ttc", 65) + ImageDraw.Draw(image).text((35, 40), "안녕하세요 감사합니다", font=font, fill="black") + image.save(path) + for language in ("ko", "auto"): + with self.subTest(language=language): + engine = AppleVisionEngine() + engine.set_language(language) + text = engine.read_text(str(path)) + self.assertEqual(text.replace(" ", ""), "안녕하세요감사합니다") + + def test_blank_image_returns_empty_text(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "blank.png" + Image.new("RGB", (600, 120), "white").save(path) + self.assertEqual(AppleVisionEngine().read_text(str(path)), "") + + def test_invalid_image_returns_empty_text_without_crashing(self): + with self.assertLogs("usta.core.ocr.apple_vision_engine", level="ERROR"): + self.assertEqual(AppleVisionEngine().read_text("/nonexistent/usta-image.png"), "") + + def test_unsupported_language_uses_auto_and_warns_once(self): + import Vision + + engine = AppleVisionEngine() + engine.set_language("zz-XX") + with self.assertLogs("usta.core.ocr.apple_vision_engine", level="WARNING") as logs: + request = engine._make_request(Vision) + engine._make_request(Vision) + self.assertEqual(len(logs.output), 1) + supported, _ = request.supportedRecognitionLanguagesAndReturnError_(None) + self.assertEqual(list(request.recognitionLanguages()), list(supported)) + if hasattr(request, "automaticallyDetectsLanguage"): + self.assertTrue(request.automaticallyDetectsLanguage()) diff --git a/tests/unit/test_architecture.py b/tests/unit/test_architecture.py new file mode 100644 index 0000000..68c50bd --- /dev/null +++ b/tests/unit/test_architecture.py @@ -0,0 +1,22 @@ +import ast +import unittest +from pathlib import Path + +import usta + +ROOT = Path(usta.__file__).parent + + +class ArchitectureTests(unittest.TestCase): + def test_core_has_no_ui_imports(self): + for p in (ROOT / "core").rglob("*.py"): + tree = ast.parse(p.read_text()) + for n in ast.walk(tree): + if isinstance(n, ast.ImportFrom): + self.assertFalse((n.module or "").startswith("usta.ui"), str(p)) + + def test_tabs_do_not_access_worker_or_mutate_panel(self): + for p in (ROOT / "ui/tabs").glob("*.py"): + source = p.read_text() + self.assertNotIn("panel.worker", source, str(p)) + self.assertNotIn("panel.", source, str(p)) diff --git a/tests/unit/test_bootstrap.py b/tests/unit/test_bootstrap.py new file mode 100644 index 0000000..a7f16cb --- /dev/null +++ b/tests/unit/test_bootstrap.py @@ -0,0 +1,14 @@ +import subprocess +import sys +import unittest + + +class BootstrapTests(unittest.TestCase): + def test_config_import_has_no_filesystem_or_stdout_side_effects(self): + code = """from unittest.mock import patch +with patch('os.makedirs', side_effect=AssertionError('import created directory')): + import usta.config +""" + result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, "") diff --git a/tests/unit/test_easyocr_engine.py b/tests/unit/test_easyocr_engine.py new file mode 100644 index 0000000..3d4c610 --- /dev/null +++ b/tests/unit/test_easyocr_engine.py @@ -0,0 +1,74 @@ +import unittest +import warnings + +from usta.core.ocr.easyocr_engine import EasyOCREngine + +PIN_MEMORY_WARNING = ( + "'pin_memory' argument is set as true but not supported on MPS now, " + "device pinned memory won't be used." +) + + +class WarningReader: + """External reader boundary emitting the warnings seen from PyTorch.""" + + def __init__(self, device): + self.device = device + + def readtext(self, image_path): + warnings.warn_explicit( + PIN_MEMORY_WARNING, + UserWarning, + "dataloader.py", + 759, + module="torch.utils.data.dataloader", + ) + warnings.warn_explicit( + "Other OCR warning", + UserWarning, + "dataloader.py", + 760, + module="torch.utils.data.dataloader", + ) + return [(None, " Hello world: ", 0.95)] + + +class EasyOCRWarningTests(unittest.TestCase): + def test_mps_filters_only_known_pin_memory_warning_and_preserves_text(self): + engine = EasyOCREngine() + engine.reader = WarningReader("mps") + with warnings.catch_warnings(record=True) as recorded: + warnings.simplefilter("always") + result = engine.read_text("image.png") + self.assertEqual(result, "Hello world") + self.assertEqual([str(w.message) for w in recorded], ["Other OCR warning"]) + self.assertEqual(engine.reader.device, "mps") + + def test_cpu_and_cuda_warning_behavior_is_unchanged(self): + for device in ("cpu", "cuda:0"): + with self.subTest(device=device): + engine = EasyOCREngine() + engine.reader = WarningReader(device) + with warnings.catch_warnings(record=True) as recorded: + warnings.simplefilter("always") + engine.read_text("image.png") + self.assertEqual( + [str(w.message) for w in recorded], [PIN_MEMORY_WARNING, "Other OCR warning"] + ) + + def test_warning_filter_is_restored_after_mps_read(self): + engine = EasyOCREngine() + engine.reader = WarningReader("mps") + with warnings.catch_warnings(record=True) as recorded: + warnings.simplefilter("always") + engine.read_text("image.png") + warnings.warn_explicit( + PIN_MEMORY_WARNING, + UserWarning, + "dataloader.py", + 759, + module="torch.utils.data.dataloader", + ) + self.assertEqual( + [str(w.message) for w in recorded], ["Other OCR warning", PIN_MEMORY_WARNING] + ) diff --git a/tests/unit/test_engine_contracts.py b/tests/unit/test_engine_contracts.py new file mode 100644 index 0000000..0eb172a --- /dev/null +++ b/tests/unit/test_engine_contracts.py @@ -0,0 +1,33 @@ +import unittest + +from usta.application.results import EngineError +from usta.core.ocr.ocr_manager import OCRManager +from usta.core.translation.deepl_engine import DeepLTranslatorEngine + + +class EngineContracts(unittest.TestCase): + def test_missing_key_is_error_not_translation(self): + with self.assertRaises(EngineError): + DeepLTranslatorEngine(api_key="").translate("hello") + + def test_manager_constructs_only_selected_engine_and_applies_language(self): + manager = OCRManager() + manager.set_language("ko") + self.assertEqual(manager.engines.instances, {}) + manager.set_engine("EasyOCR") + self.assertEqual(manager.engines.instances, {}) + reader = manager.engines["EasyOCR"] + self.assertIn("ko", reader.current_langs) + self.assertEqual(set(manager.engines.instances), {"EasyOCR"}) + + def test_capture_close_is_idempotent(self): + from usta.core.screenshot.spectacle_screenshot import SpectacleScreenshot + + engine = SpectacleScreenshot() + engine.close() + engine.close() + + def test_deepl_client_uses_supported_constructor(self): + engine = DeepLTranslatorEngine(api_key="test-key") + self.assertIsNotNone(engine.translator) + engine.translator.close() diff --git a/tests/test_i18n.py b/tests/unit/test_i18n.py similarity index 89% rename from tests/test_i18n.py rename to tests/unit/test_i18n.py index c7f60b0..0f816ff 100644 --- a/tests/test_i18n.py +++ b/tests/unit/test_i18n.py @@ -2,12 +2,13 @@ import unittest from unittest import mock -from src import i18n - +from usta import i18n TRANSLATIONS_DIR = os.path.join( - os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "src", + "usta", + "resources", "translations", ) diff --git a/tests/unit/test_logging_setup.py b/tests/unit/test_logging_setup.py new file mode 100644 index 0000000..ae7dac8 --- /dev/null +++ b/tests/unit/test_logging_setup.py @@ -0,0 +1,26 @@ +"""HTTP request URLs must not appear in routine console logs.""" + +import io +import logging +import unittest +from unittest.mock import patch + +from usta.infrastructure.logging_setup import configure_logging + + +class LoggingSetupTests(unittest.TestCase): + def test_http_info_is_hidden_but_warnings_and_app_info_remain(self): + output = io.StringIO() + handler = logging.StreamHandler(output) + root = logging.getLogger() + http = logging.getLogger("httpx") + original_level = http.level + self.addCleanup(http.setLevel, original_level) + with patch.object(root, "handlers", [handler]), patch.object(root, "level", logging.INFO): + configure_logging() + http.info("HTTP Request: GET https://example.invalid/?q=private-text") + http.warning("Connection warning") + logging.getLogger("usta").info("Application started") + self.assertNotIn("private-text", output.getvalue()) + self.assertIn("Connection warning", output.getvalue()) + self.assertIn("Application started", output.getvalue()) diff --git a/tests/unit/test_macos_screenshot.py b/tests/unit/test_macos_screenshot.py new file mode 100644 index 0000000..298fe26 --- /dev/null +++ b/tests/unit/test_macos_screenshot.py @@ -0,0 +1,111 @@ +import io +import tempfile +import unittest +from contextlib import nullcontext +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock, patch + +from PIL import Image +from PySide6.QtCore import QRect + +from usta.application.pipeline import Pipeline +from usta.core.exceptions import ScreenCaptureError, ScreenCapturePermissionError +from usta.core.screenshot.macos_screenshot import MacOSScreenshot + + +class MacOSCaptureTests(unittest.TestCase): + def native_modules(self, *, allowed=True, error=None): + data = io.BytesIO() + Image.new("RGB", (80, 40), "white").save(data, format="PNG") + bitmap = Mock() + bitmap.representationUsingType_properties_.return_value = data.getvalue() + appkit = Mock(NSBitmapImageFileTypePNG=4) + appkit.NSBitmapImageRep.alloc.return_value.initWithCGImage_.return_value = bitmap + quartz = SimpleNamespace( + CGPreflightScreenCaptureAccess=Mock(return_value=allowed), + CGRequestScreenCaptureAccess=Mock(return_value=False), + CGRectMake=lambda x, y, width, height: ((x, y), (width, height)), + ) + manager = Mock() + manager.captureImageInRect_completionHandler_.side_effect = lambda rect, callback: callback( + object(), error + ) + return { + "Quartz": quartz, + "ScreenCaptureKit": SimpleNamespace( + SCScreenshotManager=manager, SCStreamErrorUserDeclined=-3801 + ), + "AppKit": appkit, + "objc": SimpleNamespace(autorelease_pool=nullcontext), + } + + def test_missing_access_requests_once_and_never_starts_capture(self): + modules = self.native_modules(allowed=False) + engine = MacOSScreenshot() + with patch.dict("sys.modules", modules): + for _ in range(2): + with self.assertRaises(ScreenCapturePermissionError): + engine.capture(QRect(0, 0, 40, 20), "unused") + modules["Quartz"].CGRequestScreenCaptureAccess.assert_called_once() + modules[ + "ScreenCaptureKit" + ].SCScreenshotManager.captureImageInRect_completionHandler_.assert_not_called() + + def test_granted_access_captures_in_process_with_negative_coordinates_and_dpi(self): + modules = self.native_modules() + with tempfile.TemporaryDirectory() as directory, patch.dict("sys.modules", modules): + output = Path(directory) / "capture.jpeg" + with patch("PIL.ImageGrab.grab", side_effect=AssertionError("external grab used")): + self.assertTrue(MacOSScreenshot().capture(QRect(-20, 30, 40, 20), str(output), 2)) + with Image.open(output) as image: + self.assertEqual(image.size, (80, 40)) + self.assertEqual(image.mode, "RGB") + native_call = modules[ + "ScreenCaptureKit" + ].SCScreenshotManager.captureImageInRect_completionHandler_ + self.assertEqual(native_call.call_args.args[0], ((-20, 30), (40, 20))) + modules["Quartz"].CGRequestScreenCaptureAccess.assert_not_called() + + def test_native_permission_denial_is_fatal_even_if_preflight_was_granted(self): + error = SimpleNamespace(code=lambda: -3801, localizedDescription=lambda: "denied") + with patch.dict("sys.modules", self.native_modules(error=error)): + with self.assertRaises(ScreenCapturePermissionError): + MacOSScreenshot().capture(QRect(0, 0, 40, 20), "unused") + + def test_capture_timeout_stops_without_writing_a_late_frame(self): + modules = self.native_modules() + modules[ + "ScreenCaptureKit" + ].SCScreenshotManager.captureImageInRect_completionHandler_.side_effect = None + with ( + patch.dict("sys.modules", modules), + patch("usta.core.screenshot.macos_screenshot.threading.Event.wait", return_value=False), + ): + with self.assertRaises(ScreenCaptureError): + MacOSScreenshot().capture(QRect(0, 0, 40, 20), "unused") + + def test_pipeline_stops_after_one_capture_permission_failure(self): + capture = Mock() + capture.capture.side_effect = ScreenCapturePermissionError("Screen Recording unavailable") + errors = [] + + def on_error(message): + errors.append(message) + if len(errors) == 2: + pipeline.stop() # Bound a broken retry loop so the regression cannot hang. + + pipeline = Pipeline( + capture_factory=lambda name: capture, + publisher=Mock(), + image_path="unused", + ocr_factory=Mock, + on_translation=Mock(), + on_error=on_error, + ) + self.addCleanup(pipeline.close) + pipeline.start() + pipeline.run() + self.assertEqual(errors, ["Screen Recording unavailable"]) + self.assertEqual(capture.capture.call_count, 1) + capture.close.assert_called_once() diff --git a/tests/unit/test_macos_signing.py b/tests/unit/test_macos_signing.py new file mode 100644 index 0000000..6261de1 --- /dev/null +++ b/tests/unit/test_macos_signing.py @@ -0,0 +1,72 @@ +import importlib.util +import unittest +from pathlib import Path + +SPEC = importlib.util.spec_from_file_location( + "usta_signing_policy", Path(__file__).resolve().parents[2] / "packaging/macos/signing_policy.py" +) +policy = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(policy) + + +class MacOSSigningTests(unittest.TestCase): + def test_release_cannot_silently_fall_back_to_ad_hoc(self): + with self.assertRaisesRegex(ValueError, "Developer ID"): + policy.resolve_identity(None, None, "") + + def test_ad_hoc_requires_explicit_development_opt_in(self): + self.assertEqual(policy.resolve_identity(None, None, "", allow_ad_hoc=True), "-") + with self.assertRaises(ValueError): + policy.resolve_identity("-", None, "") + + def test_selects_valid_developer_id_by_name_and_checks_team(self): + listing = " 1) " + "A" * 40 + ' "Developer ID Application: Test Publisher (ABCDE12345)"\n' + result = policy.resolve_identity( + "Developer ID Application: Test Publisher (ABCDE12345)", "ABCDE12345", listing + ) + self.assertEqual(result, "A" * 40) + with self.assertRaisesRegex(ValueError, "Team"): + policy.resolve_identity("A" * 40, "WRONG12345", listing) + + def test_rejects_other_certificate_types_and_missing_private_keys(self): + listing = " 1) " + "B" * 40 + ' "Apple Development: Test Publisher (ABCDE12345)"\n' + with self.assertRaisesRegex(ValueError, "Developer ID"): + policy.resolve_identity("B" * 40, "ABCDE12345", listing) + with self.assertRaisesRegex(ValueError, "keychain"): + policy.resolve_identity("C" * 40, "ABCDE12345", listing) + + def test_signed_bundle_must_keep_bundle_id_team_and_stable_requirement(self): + details = ( + "Identifier=com.sedataym.usta\nTeamIdentifier=ABCDE12345\nflags=0x10000(runtime)\n" + ) + requirement = 'designated => identifier "com.sedataym.usta" and anchor apple generic and certificate leaf[subject.OU] = "ABCDE12345"' + policy.validate_signature(details + requirement, "ABCDE12345") + for invalid in ( + details.replace("com.sedataym.usta", "other.app") + requirement, + details.replace("ABCDE12345", "OTHER12345") + requirement, + details + 'designated => cdhash H"abcd"', + details.replace("flags=0x10000(runtime)", "flags=0x2(adhoc)") + requirement, + ): + with self.subTest(invalid=invalid), self.assertRaises(ValueError): + policy.validate_signature(invalid, "ABCDE12345") + + def test_self_signed_identity_is_pinned_and_not_silently_regenerated(self): + listing = " 1) " + "D" * 40 + ' "USTA Local Code Signing"\n' + self.assertEqual(policy.resolve_self_signed_identity(None, "D" * 40, listing), "D" * 40) + with self.assertRaises(ValueError): + policy.resolve_self_signed_identity("E" * 40, "D" * 40, listing) + with self.assertRaises(ValueError): + policy.resolve_self_signed_identity(None, "D" * 40, "") + + def test_self_signed_requirement_must_bind_to_the_pinned_certificate(self): + details = "Identifier=com.sedataym.usta\nTeamIdentifier=not set\nflags=0x0(none)\n" + requirement = 'designated => identifier "com.sedataym.usta" and anchor H"' + "D" * 40 + '"' + policy.validate_self_signed_signature(details + requirement, "D" * 40) + for invalid in ( + details + 'designated => identifier "com.sedataym.usta"', + details + 'designated => cdhash H"' + "D" * 40 + '"', + details + requirement.replace("D" * 40, "E" * 40), + details.replace("flags=0x0(none)", "flags=0x10000(runtime)") + requirement, + ): + with self.subTest(invalid=invalid), self.assertRaises(ValueError): + policy.validate_self_signed_signature(invalid, "D" * 40) diff --git a/tests/unit/test_pipeline_lifecycle.py b/tests/unit/test_pipeline_lifecycle.py new file mode 100644 index 0000000..5738f9c --- /dev/null +++ b/tests/unit/test_pipeline_lifecycle.py @@ -0,0 +1,93 @@ +import threading +import time +import unittest +from dataclasses import replace +from unittest.mock import Mock + +from usta.application.pipeline import Pipeline + + +class PipelineLifecycleTests(unittest.TestCase): + def make_pipeline(self): + entered, release, finished = threading.Event(), threading.Event(), threading.Event() + translator = Mock() + + def translate(request): + from usta.application.results import TranslationResult + + entered.set() + release.wait(3) + finished.set() + return TranslationResult( + "translated", + request.source_lang, + request.target_lang, + request.engine_name, + request.session_id, + ) + + translator.translate_request.side_effect = translate + capture = Mock() + capture.capture.return_value = True + ocr = Mock() + ocr.read_result.return_value = Mock(text="hello") + publisher = Mock() + result = Mock() + errors = Mock() + pipeline = Pipeline( + ocr_factory=lambda: ocr, + capture_factory=lambda name: capture, + translator_factory=lambda: translator, + publisher=publisher, + on_translation=result, + on_error=errors, + image_path="/tmp/fake.jpeg", + ) + return pipeline, entered, release, finished, result, publisher, errors + + def test_stop_does_not_wait_for_api_and_suppresses_late_result(self): + pipeline, entered, release, finished, result, publisher, errors = self.make_pipeline() + thread = threading.Thread(target=pipeline.run) + pipeline.start() + thread.start() + self.assertTrue(entered.wait(2)) + start = time.monotonic() + pipeline.stop() + thread.join(1) + self.assertFalse(thread.is_alive()) + self.assertLess(time.monotonic() - start, 1) + release.set() + self.assertTrue(finished.wait(2)) + pipeline.close() + result.assert_not_called() + publisher.broadcast.assert_not_called() + + def test_reconfigure_discards_previous_session_metadata(self): + pipeline, entered, release, finished, result, publisher, errors = self.make_pipeline() + pipeline.start() + thread = threading.Thread(target=pipeline.run) + thread.start() + self.assertTrue(entered.wait(2)) + pipeline.configure(replace(pipeline.settings, target_lang="German")) + release.set() + self.assertTrue(finished.wait(2)) + pipeline.stop() + thread.join(2) + pipeline.close() + result.assert_not_called() + publisher.broadcast.assert_not_called() + + def test_engine_error_never_becomes_translation(self): + from usta.application.results import EngineError + + pipeline, entered, release, finished, result, publisher, errors = self.make_pipeline() + pipeline.translator_factory = lambda: Mock( + translate_request=Mock(side_effect=EngineError("api", "failed")) + ) + errors.side_effect = lambda message: pipeline.stop() + pipeline.start() + pipeline.run() + pipeline.close() + errors.assert_called_once() + result.assert_not_called() + publisher.broadcast.assert_not_called() diff --git a/tests/unit/test_pipeline_performance.py b/tests/unit/test_pipeline_performance.py new file mode 100644 index 0000000..06d19e6 --- /dev/null +++ b/tests/unit/test_pipeline_performance.py @@ -0,0 +1,136 @@ +"""Performance reflects the capture-to-translation latency of one valid job.""" + +import unittest +from concurrent.futures import Future +from dataclasses import replace +from unittest.mock import Mock, patch + +from usta.application.pipeline import Pipeline +from usta.application.results import TranslationResult + + +class DeferredExecutor: + def submit(self, function, *args): + self.function, self.args = function, args + self.future = Future() + return self.future + + def complete(self): + self.future.set_result(self.function(*self.args)) + + def shutdown(self, **kwargs): + pass + + +class PipelinePerformanceTests(unittest.TestCase): + def make_pipeline(self): + clock = [0.0] + capture = Mock() + capture.capture.side_effect = lambda *args: clock.__setitem__(0, 0.2) or True + ocr = Mock() + + def read(path): + clock[0] = 0.5 + return Mock(text="hello") + + ocr.read_result.side_effect = read + translator = Mock() + + def translate(request): + clock[0] = 2.0 + return TranslationResult("merhaba", "en", "tr", "Google", request.session_id) + + translator.translate_request.side_effect = translate + performance = Mock() + pipeline = Pipeline( + capture_factory=lambda name: capture, + publisher=Mock(), + ocr_factory=lambda: ocr, + translator_factory=lambda: translator, + image_path="unused", + on_translation=Mock(), + on_error=Mock(), + on_performance=performance, + ) + pipeline._executor.shutdown(wait=False) + executor = DeferredExecutor() + pipeline._executor = executor + self.addCleanup(pipeline.close) + return pipeline, executor, clock, performance + + def test_capture_ocr_and_translation_are_timed_without_polling_delay(self): + pipeline, executor, clock, performance = self.make_pipeline() + early = [] + + def between_iterations(timeout): + early.extend(performance.call_args_list) + executor.complete() + clock[0] = 20.0 # Late collection must not inflate API response time. + + performance.side_effect = lambda duration: pipeline.stop() + pipeline.start() + with ( + patch("usta.application.pipeline.time.perf_counter", side_effect=lambda: clock[0]), + patch.object(pipeline._stopped, "wait", side_effect=between_iterations), + ): + pipeline.run() + self.assertEqual(early, []) + performance.assert_called_once_with(2.0) # 0.2 capture + 0.3 OCR + 1.5 translation. + + def test_stale_translation_does_not_update_performance(self): + pipeline, executor, clock, performance = self.make_pipeline() + + def between_iterations(timeout): + executor.complete() + pipeline.configure(replace(pipeline.settings, target_lang="German")) + pipeline._collect() + pipeline.stop() + + pipeline.start() + with ( + patch("usta.application.pipeline.time.perf_counter", side_effect=lambda: clock[0]), + patch.object(pipeline._stopped, "wait", side_effect=between_iterations), + ): + pipeline.run() + performance.assert_not_called() + + def test_unchanged_text_does_not_overwrite_total_with_ocr_only_duration(self): + pipeline, executor, clock, performance = self.make_pipeline() + waits = [] + + def between_iterations(timeout): + waits.append(timeout) + if len(waits) == 1: + executor.complete() + else: + pipeline.stop() + + pipeline.start() + with ( + patch("usta.application.pipeline.time.perf_counter", side_effect=lambda: clock[0]), + patch.object(pipeline._stopped, "wait", side_effect=between_iterations), + ): + pipeline.run() + performance.assert_called_once_with(2.0) + + def test_stage_timings_match_the_same_completed_translation(self): + pipeline, executor, clock, performance = self.make_pipeline() + timings = Mock() + pipeline.on_timings = timings + + def between_iterations(timeout): + executor.complete() + clock[0] = 20.0 + + performance.side_effect = lambda duration: pipeline.stop() + pipeline.start() + with ( + patch("usta.application.pipeline.time.perf_counter", side_effect=lambda: clock[0]), + patch.object(pipeline._stopped, "wait", side_effect=between_iterations), + ): + pipeline.run() + timings.assert_called_once() + measured = timings.call_args.args[0] + self.assertAlmostEqual(measured.ocr_seconds, 0.3) + self.assertAlmostEqual(measured.translation_seconds, 1.5) + self.assertAlmostEqual(measured.total_seconds, 2.0) diff --git a/tests/unit/test_rapidocr_compat.py b/tests/unit/test_rapidocr_compat.py new file mode 100644 index 0000000..320eacf --- /dev/null +++ b/tests/unit/test_rapidocr_compat.py @@ -0,0 +1,23 @@ +import unittest +from unittest.mock import Mock, patch + +from usta.core.ocr.rapidocr_compat import patch_ort_session + + +class RapidOCRCompatibilityTests(unittest.TestCase): + def test_patch_preserves_thread_count_and_installs_only_once(self): + import onnxruntime + from rapidocr_onnxruntime.utils import OrtInferSession + + original = OrtInferSession.__init__ + self.addCleanup(setattr, OrtInferSession, "__init__", original) + with patch.object(onnxruntime, "InferenceSession") as inference: + patch_ort_session(1) + replacement = OrtInferSession.__init__ + patch_ort_session(1) + self.assertIs(OrtInferSession.__init__, replacement) + replacement(Mock(), {"use_cuda": False, "model_path": "fake.onnx"}) + options = inference.call_args.kwargs["sess_options"] + self.assertEqual(options.intra_op_num_threads, 1) + self.assertEqual(options.inter_op_num_threads, 1) + self.assertEqual(inference.call_args.kwargs["providers"][0][0], "CPUExecutionProvider") diff --git a/tests/unit/test_runtime_paths.py b/tests/unit/test_runtime_paths.py new file mode 100644 index 0000000..6ddaf70 --- /dev/null +++ b/tests/unit/test_runtime_paths.py @@ -0,0 +1,16 @@ +import unittest +from pathlib import Path + +from usta.infrastructure.paths import CaptureWorkspace + + +class RuntimePathTests(unittest.TestCase): + def test_capture_workspaces_are_private_and_independent(self): + first, second = CaptureWorkspace(), CaptureWorkspace() + self.addCleanup(first.close) + self.addCleanup(second.close) + self.assertNotEqual(first.image_path, second.image_path) + Path(first.image_path).write_text("frame") + first.close() + self.assertFalse(Path(first.image_path).exists()) + self.assertTrue(Path(second.image_path).parent.is_dir()) diff --git a/tests/unit/test_screenshot_contracts.py b/tests/unit/test_screenshot_contracts.py new file mode 100644 index 0000000..e043651 --- /dev/null +++ b/tests/unit/test_screenshot_contracts.py @@ -0,0 +1,39 @@ +import unittest +from unittest.mock import patch + +from PySide6.QtCore import QRect + +from usta.core.screenshot.macos_screenshot import MacOSScreenshot +from usta.core.screenshot.portal_screenshot import PortalScreenshot +from usta.core.screenshot.screenshot_factory import ScreenshotFactory + + +class ScreenshotContracts(unittest.TestCase): + def test_linux_portal_unavailable_preserves_spectacle_fallback(self): + from usta.core.screenshot.spectacle_screenshot import SpectacleScreenshot + + with ( + patch("platform.system", return_value="Linux"), + patch.object(PortalScreenshot, "is_available", return_value=False), + ): + self.assertIsInstance(ScreenshotFactory.get_engine("Portal"), SpectacleScreenshot) + + def test_macos_uses_native_capture_regardless_of_saved_linux_engine(self): + with ( + patch("platform.system", return_value="Darwin"), + patch.object(MacOSScreenshot, "is_available", return_value=True), + ): + self.assertIsInstance(ScreenshotFactory.get_engine("Portal"), MacOSScreenshot) + + def test_portal_cancel_propagates_and_closes_resources(self): + from usta.core.exceptions import PortalCanceledError + + engine = PortalScreenshot() + self.addCleanup(engine._loop.close) + with ( + patch.object(engine, "_ensure_dependencies", side_effect=PortalCanceledError()), + patch.object(engine, "close") as close, + ): + with self.assertRaises(PortalCanceledError): + engine.capture(QRect(0, 0, 100, 50), "/tmp/not-written") + close.assert_called_once() diff --git a/tests/unit/test_settings_store.py b/tests/unit/test_settings_store.py new file mode 100644 index 0000000..21867d8 --- /dev/null +++ b/tests/unit/test_settings_store.py @@ -0,0 +1,98 @@ +import json +import os +import pickle +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from usta.application.settings import AppSettings, Preset +from usta.infrastructure.settings_store import SettingsStore + + +class SettingsStoreTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.root = Path(self.directory.name) + self.store = SettingsStore(self.root) + + def test_roundtrip_and_legacy_migration_preserve_values(self): + original = { + "capture_rect": (-300, 20, 400, 60), + "dpi_scale": 2.0, + "translator_api_keys": {"DeepL": "private"}, + "font_color": "#abcdef", + } + path = self.root / "settings.pkl" + path.write_bytes(pickle.dumps(original)) + loaded = self.store.load() + self.assertEqual(loaded.capture_rect, (-300, 20, 400, 60)) + self.assertEqual(loaded.translator_api_keys, original["translator_api_keys"]) + self.assertEqual(loaded.font_color, "#abcdef") + self.assertEqual(path.read_bytes(), pickle.dumps(original)) + self.assertEqual(self.store.load(), loaded) + self.assertEqual(os.stat(self.root / "settings.json").st_mode & 0o777, 0o600) + + def test_failed_replace_preserves_previous_settings(self): + self.store.save(AppSettings(font_size=24)) + with patch("usta.infrastructure.settings_store.os.replace", side_effect=OSError("full")): + with self.assertRaises(OSError): + self.store.save(AppSettings(font_size=30)) + self.assertEqual(self.store.load().font_size, 24) + self.assertEqual(list(self.root.glob("*.tmp")), []) + + def test_future_schema_and_corruption_are_not_overwritten(self): + path = self.root / "settings.json" + for raw in ('{"schema_version":99,"data":{}}', "{broken"): + path.write_text(raw) + with self.assertRaises(ValueError): + self.store.load() + with self.assertRaises(ValueError): + self.store.save(AppSettings()) + self.assertEqual(path.read_text(), raw) + + def test_legacy_globals_are_rejected_without_execution(self): + (self.root / "settings.pkl").write_bytes(b'cos\nsystem\n(S"false"\ntR.') + with self.assertRaises(ValueError): + self.store.load() + self.assertFalse((self.root / "settings.json").exists()) + + def test_presets_and_missing_fields(self): + (self.root / "presets.pkl").write_bytes( + pickle.dumps({"game": {"capture_rect": (-10, 5, 600, 90)}}) + ) + presets = self.store.load_presets() + self.assertEqual(presets["game"].capture_rect, (-10, 5, 600, 90)) + self.store.save_presets(presets) + self.assertEqual(self.store.load_presets(), presets) + self.assertEqual( + AppSettings.from_dict({"unknown": "retained"}).to_dict()["unknown"], "retained" + ) + + def test_invalid_field_falls_back_without_changing_valid_coordinates(self): + s = AppSettings.from_dict( + {"dpi_scale": float("nan"), "font_size": "bad", "capture_rect": [-20, 3, 100, 30]} + ) + self.assertEqual(s.dpi_scale, 1.0) + self.assertEqual(s.font_size, 20) + self.assertEqual(s.capture_rect, (-20, 3, 100, 30)) + + def test_malformed_preset_preserves_valid_entries_and_blocks_overwrite(self): + path = self.root / "presets.json" + raw = json.dumps( + {"schema_version": 1, "data": {"valuable": {"font_size": 31}, "broken": None}} + ) + path.write_text(raw) + self.assertEqual(self.store.load_presets()["valuable"].font_size, 31) + with self.assertRaises(ValueError): + self.store.save_presets({"new": Preset()}) + self.assertEqual(path.read_text(), raw) + + def test_save_without_load_cannot_replace_malformed_presets(self): + path = self.root / "presets.json" + raw = json.dumps({"schema_version": 1, "data": {"valuable": {}, "broken": None}}) + path.write_text(raw) + with self.assertRaises(ValueError): + self.store.save_presets({"new": Preset()}) + self.assertEqual(path.read_text(), raw) diff --git a/tests/unit/test_shortcut.py b/tests/unit/test_shortcut.py new file mode 100644 index 0000000..ab77279 --- /dev/null +++ b/tests/unit/test_shortcut.py @@ -0,0 +1,143 @@ +import contextlib +import os +import sys +import threading +import unittest +from unittest import mock + +from usta.core.shortcut import GlobalHotkey, keyboard + + +class PlatformHotkeyTests(unittest.TestCase): + def test_linux_and_windows_keep_the_existing_pynput_listener(self): + for platform in ("linux", "win32"): + with self.subTest(platform=platform): + backend = mock.Mock() + backend.HotKey.parse.return_value = {"ctrl", "x"} + with ( + mock.patch("usta.core.shortcut.sys.platform", platform), + mock.patch("usta.core.shortcut.keyboard", backend), + mock.patch.dict( + sys.modules, + { + "usta.core.platform.macos.hotkey_listener": None, + }, + ), + ): + hotkey = GlobalHotkey("+x", lambda: None) + self.assertTrue(hotkey.start()) + backend.Listener.assert_called_once_with( + on_press=hotkey._on_press, + on_release=hotkey._on_release, + ) + listener = backend.Listener.return_value + listener.start.assert_called_once() + hotkey.stop() + listener.stop.assert_called_once() + + +@unittest.skipUnless( + os.environ.get("USTA_NATIVE_TESTS") == "1" + and sys.platform == "darwin" + and keyboard is not None, + "Requires macOS pynput backend", +) +class MacOSHotkeyThreadTests(unittest.TestCase): + def test_rebinding_reads_keyboard_layout_on_main_thread_only(self): + layout_threads = [] + listener_threads = [] + contexts = [] + + @contextlib.contextmanager + def keyboard_layout(): + layout_threads.append(threading.current_thread()) + yield (40, b"copied keyboard layout") + + def listen(listener): + listener_threads.append(threading.current_thread()) + contexts.append(listener._context) + listener._mark_ready() + + # Substitute OS calls, leaving actual listener construction/threading intact. + with ( + mock.patch("pynput.keyboard._darwin.keycode_context", keyboard_layout), + mock.patch("pynput._util.darwin.keycode_context", keyboard_layout), + mock.patch("pynput._util.darwin.ListenerMixin._run", listen), + mock.patch( + "usta.core.platform.macos.hotkey_listener.has_accessibility_permission", + return_value=True, + ), + ): + for combination in ("++m", "++x", "++k"): + hotkey = GlobalHotkey(combination, lambda: None) + self.assertTrue(hotkey.start()) + hotkey._listener.join(timeout=2) + hotkey.stop() + self.assertEqual(layout_threads, [threading.main_thread()] * 3) + self.assertEqual(len(listener_threads), 3) + self.assertTrue(all(thread is not threading.main_thread() for thread in listener_threads)) + self.assertEqual(contexts, [(40, b"copied keyboard layout")] * 3) + + def test_off_main_thread_registration_fails_safely(self): + result = [] + hotkey = GlobalHotkey("+x", lambda: None) + with mock.patch("pynput._util.darwin.keycode_context") as layout: + with self.assertLogs("usta.core.shortcut", level="ERROR"): + thread = threading.Thread(target=lambda: result.append(hotkey.start())) + thread.start() + thread.join(timeout=2) + layout.assert_not_called() + self.assertEqual(result, [False]) + self.assertIsNone(hotkey._listener) + + +class MacOSPermissionTests(unittest.TestCase): + def test_missing_accessibility_warns_once_without_blocking_event_taps(self): + import types + + import usta.core.shortcut as shortcut + + module = types.ModuleType("usta.core.platform.macos.hotkey_listener") + module.MainThreadLayoutListener = mock.Mock() + module.has_accessibility_permission = mock.Mock(return_value=False) + with ( + mock.patch.object(shortcut.sys, "platform", "darwin"), + mock.patch.object( + shortcut, + "keyboard", + mock.Mock(HotKey=mock.Mock(parse=mock.Mock(return_value={"ctrl", "x"}))), + ), + mock.patch.object(shortcut, "_permission_warning_emitted", False, create=True), + mock.patch.dict(sys.modules, {"usta.core.platform.macos.hotkey_listener": module}), + ): + with self.assertLogs("usta.core.shortcut", level="WARNING") as logs: + self.assertTrue(GlobalHotkey("+x", lambda: None).start()) + self.assertTrue(GlobalHotkey("+y", lambda: None).start()) + self.assertEqual(len(logs.output), 1) + self.assertIn("Accessibility", logs.output[0]) + self.assertEqual(module.MainThreadLayoutListener.call_count, 2) + + def test_new_listener_rechecks_permission_after_grant(self): + import types + + import usta.core.shortcut as shortcut + + module = types.ModuleType("usta.core.platform.macos.hotkey_listener") + module.MainThreadLayoutListener = mock.Mock() + module.has_accessibility_permission = mock.Mock(side_effect=[False, True]) + with ( + mock.patch.object(shortcut.sys, "platform", "darwin"), + mock.patch.object( + shortcut, + "keyboard", + mock.Mock(HotKey=mock.Mock(parse=mock.Mock(return_value={"ctrl", "x"}))), + ), + mock.patch.object(shortcut, "_permission_warning_emitted", True, create=True), + mock.patch.dict(sys.modules, {"usta.core.platform.macos.hotkey_listener": module}), + ): + hotkey = GlobalHotkey("+x", lambda: None) + self.assertTrue(hotkey.start()) + hotkey.stop() + self.assertTrue(hotkey.start()) + self.assertEqual(module.MainThreadLayoutListener.return_value.start.call_count, 2) + hotkey.stop() diff --git a/tests/unit/test_socket_publisher.py b/tests/unit/test_socket_publisher.py new file mode 100644 index 0000000..d3c02a8 --- /dev/null +++ b/tests/unit/test_socket_publisher.py @@ -0,0 +1,70 @@ +import json +import socket +import tempfile +import unittest +from pathlib import Path + +from usta.infrastructure.socket_publisher import TranslationPublisher + + +class PublisherTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.path = Path(self.tmp.name) / "usta.sock" + self.publisher = TranslationPublisher(self.path) + self.addCleanup(self.publisher.stop) + + def connect(self): + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.settimeout(2) + s.connect(str(self.path)) + self.addCleanup(s.close) + return s + + def test_broadcast_and_restart_preserve_json_contract(self): + self.publisher.start() + a = self.connect() + b = self.connect() + # Poll via broadcast/read: acceptance is owned by I/O thread. + self.publisher.broadcast("hello", "merhaba", "en", "tr", "Google") + for client in (a, b): + data = json.loads(client.recv(65536).splitlines()[0]) + self.assertEqual( + set(data), {"original", "translated", "source", "target", "engine", "timestamp"} + ) + self.assertEqual(data["translated"], "merhaba") + self.publisher.stop() + self.assertFalse(self.path.exists()) + self.publisher.start() + self.assertTrue(self.path.exists()) + + def test_second_server_does_not_unlink_active_socket(self): + self.publisher.start() + inode = self.path.stat().st_ino + other = TranslationPublisher(self.path) + with self.assertRaises(OSError): + other.start() + other.stop() + self.assertEqual(self.path.stat().st_ino, inode) + self.connect() + + def test_regular_file_is_not_removed(self): + self.path.write_text("keep") + with self.assertRaises(OSError): + self.publisher.start() + self.publisher.stop() + self.assertEqual(self.path.read_text(), "keep") + + def test_slow_or_disconnected_reader_cannot_block_broadcast_or_stop(self): + import time + + self.publisher.start() + reader = self.connect() + started = time.monotonic() + for _ in range(10): + self.publisher.broadcast("x" * 200000, "text") + self.assertLess(time.monotonic() - started, 1) + reader.close() + self.publisher.stop() + self.assertLess(time.monotonic() - started, 2)