From 4db551441fe6e2819bacb384e664ef174600be0d Mon Sep 17 00:00:00 2001 From: sedataym Date: Fri, 25 Sep 2026 16:40:26 +0300 Subject: [PATCH 01/24] adding macos support --- pyproject.toml | 1 + requirements.txt | 1 + src/config.py | 7 +-- src/core/platform/macos/capabilities.py | 2 + src/core/screenshot/__init__.py | 6 +++ src/core/screenshot/macos_screenshot.py | 34 +++++++++++++++ src/core/screenshot/screenshot_factory.py | 17 ++++++++ src/main.py | 5 ++- src/ui/overlay_window.py | 53 +++++++++++++++++++---- src/ui/tabs/ocr_tab.py | 3 +- 10 files changed, 115 insertions(+), 14 deletions(-) create mode 100644 src/core/screenshot/macos_screenshot.py diff --git a/pyproject.toml b/pyproject.toml index acfe203..4705405 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [ "Pillow>=10.0.0", "dbus-next>=0.2.3", "Babel>=2.12.0", + "pyobjc-framework-Cocoa>=10.0; sys_platform == 'darwin'", ] [project.urls] diff --git a/requirements.txt b/requirements.txt index 1281a1b..cc530e0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,3 +13,4 @@ Pillow>=10.0.0 dbus-next>=0.2.3 Babel>=2.12.0 pynput>=1.7.6 +pyobjc-framework-Cocoa>=10.0; sys_platform == "darwin" diff --git a/src/config.py b/src/config.py index 7923bbe..c724649 100644 --- a/src/config.py +++ b/src/config.py @@ -53,8 +53,9 @@ def get_translation_language_code(language_code: str, provider: str, direction: # 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/core/platform/macos/capabilities.py b/src/core/platform/macos/capabilities.py index 1985720..d733712 100644 --- a/src/core/platform/macos/capabilities.py +++ b/src/core/platform/macos/capabilities.py @@ -6,6 +6,8 @@ import os +SCREENSHOT_ENGINES = ["MacOS"] + CONFIG_DIR = os.path.join( os.path.expanduser("~"), "Library", diff --git a/src/core/screenshot/__init__.py b/src/core/screenshot/__init__.py index 2815141..9280dc6 100644 --- a/src/core/screenshot/__init__.py +++ b/src/core/screenshot/__init__.py @@ -3,6 +3,11 @@ from src.core.screenshot.image_processor import ImageProcessor from src.core.screenshot.screenshot_factory import ScreenshotFactory +try: + from src.core.screenshot.macos_screenshot import MacOSScreenshot +except Exception: + MacOSScreenshot = None + try: from src.core.screenshot.portal_screenshot import PortalScreenshot except Exception: @@ -10,6 +15,7 @@ __all__ = [ "BaseScreenshot", + "MacOSScreenshot", "PortalScreenshot", "SpectacleScreenshot", "ImageProcessor", diff --git a/src/core/screenshot/macos_screenshot.py b/src/core/screenshot/macos_screenshot.py new file mode 100644 index 0000000..5b5b6f0 --- /dev/null +++ b/src/core/screenshot/macos_screenshot.py @@ -0,0 +1,34 @@ +import sys + +from PIL import ImageGrab +from PySide6.QtCore import QRect + +from src.core.screenshot.base_screenshot import BaseScreenshot + + +class MacOSScreenshot(BaseScreenshot): + @staticmethod + def is_available() -> bool: + return sys.platform == "darwin" + + def capture(self, rect: QRect, output_path: str, dpi_scale: float = 1.0) -> bool: + try: + full_img = ImageGrab.grab(all_screens=True) + + x1 = max(0, int(rect.x() * dpi_scale)) + y1 = max(0, int(rect.y() * dpi_scale)) + x2 = min(full_img.width, int((rect.x() + rect.width()) * dpi_scale)) + y2 = min(full_img.height, int((rect.y() + rect.height()) * dpi_scale)) + + if x2 <= x1 or y2 <= y1: + return False + + crop = full_img.crop((x1, y1, x2, y2)) + if crop.mode != "RGB": + crop = crop.convert("RGB") + + crop.save(output_path) + return True + except Exception as e: + print(f"macOS ekran görüntüsü alınamadı: {e}") + return False diff --git a/src/core/screenshot/screenshot_factory.py b/src/core/screenshot/screenshot_factory.py index 6d31258..e288d07 100644 --- a/src/core/screenshot/screenshot_factory.py +++ b/src/core/screenshot/screenshot_factory.py @@ -1,6 +1,16 @@ +import platform + from src.core.screenshot.base_screenshot import BaseScreenshot from src.core.screenshot.spectacle_screenshot import SpectacleScreenshot +try: + from src.core.screenshot.macos_screenshot import MacOSScreenshot +except Exception as exc: + MacOSScreenshot = None + _MACOS_IMPORT_ERROR = exc +else: + _MACOS_IMPORT_ERROR = None + try: from src.core.screenshot.portal_screenshot import PortalScreenshot except Exception as exc: @@ -12,6 +22,13 @@ class ScreenshotFactory: @staticmethod def get_engine(engine_name: str = "Portal") -> BaseScreenshot: + if platform.system() == "Darwin": + if MacOSScreenshot is not None and MacOSScreenshot.is_available(): + return MacOSScreenshot() + + if _MACOS_IMPORT_ERROR is not None: + print(f"macOS ekran görüntüsü motoru kullanılamıyor: {_MACOS_IMPORT_ERROR}") + if engine_name == "Portal": if PortalScreenshot is not None and PortalScreenshot.is_available(): return PortalScreenshot() diff --git a/src/main.py b/src/main.py index feaaa5b..6a5c47f 100644 --- a/src/main.py +++ b/src/main.py @@ -8,6 +8,7 @@ from PySide6.QtGui import QIcon from PySide6.QtWidgets import QApplication +from src.config import OS from src.ui.main_window import ControlPanel from src.i18n import init_i18n @@ -15,9 +16,11 @@ def main(): # Initialize internationalization init_i18n() - os.environ["QT_QPA_PLATFORM"] = "xcb" + if OS == "Linux": + os.environ.setdefault("QT_QPA_PLATFORM", "xcb") app = QApplication(sys.argv) + app.setStyle("Fusion") # Set application icon icon_path = os.path.join(os.path.dirname(__file__), "ui", "assets", "usta.png") diff --git a/src/ui/overlay_window.py b/src/ui/overlay_window.py index 1965289..01544eb 100644 --- a/src/ui/overlay_window.py +++ b/src/ui/overlay_window.py @@ -1,7 +1,7 @@ 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.config import OS, SETTINGS_TOPMOST_HOTKEY from src.core.shortcut import GlobalHotkey from src.i18n import _ @@ -81,11 +81,13 @@ class TransparentOverlay(QWidget): 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) @@ -126,15 +128,48 @@ def __init__(self): 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" self._drag_start_pos = None self._drag_start_geometry = None + def showEvent(self, event): + super().showEvent(event) + if OS == "Darwin": + QTimer.singleShot(0, self._apply_macos_window_rules) + + def _apply_macos_window_rules(self): + if OS != "Darwin": + return + + try: + import AppKit + except ImportError as ex: + print(f"macOS pencere kuralları uygulanamadı: Cocoa bağımlılığı bulunamadı ({ex})") + return + + try: + self.winId() + window = next( + (candidate for candidate in AppKit.NSApp.windows() + if str(candidate.title()) == "USTA_TRANSLATION_OVERLAY"), + None, + ) + if window is None: + print("macOS pencere kuralları uygulanamadı: overlay penceresi bulunamadı") + return + + behavior = (AppKit.NSWindowCollectionBehaviorCanJoinAllSpaces | + AppKit.NSWindowCollectionBehaviorFullScreenAuxiliary | + AppKit.NSWindowCollectionBehaviorStationary) + window.setLevel_(AppKit.NSStatusWindowLevel) + window.setCollectionBehavior_(behavior) + window.setHidesOnDeactivate_(False) + window.setReleasedWhenClosed_(False) + except Exception as ex: + print(f"macOS pencere kuralları uygulanamadı: {ex}") + def _emit_settings_topmost_hotkey_pressed(self): self.settings_topmost_hotkey_pressed.emit() diff --git a/src/ui/tabs/ocr_tab.py b/src/ui/tabs/ocr_tab.py index 04e5df8..b6af4ca 100644 --- a/src/ui/tabs/ocr_tab.py +++ b/src/ui/tabs/ocr_tab.py @@ -20,7 +20,8 @@ def build_ocr_tab(panel): panel.combo_screenshot = QComboBox() panel.combo_screenshot.setFocusPolicy(Qt.NoFocus) panel.combo_screenshot.addItems(SCREENSHOT_ENGINES) - panel.combo_screenshot.setCurrentText("Portal") + if SCREENSHOT_ENGINES: + panel.combo_screenshot.setCurrentText(SCREENSHOT_ENGINES[0]) 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) From 30d4dd398d801473af7f174b7dfd864d3937cddc Mon Sep 17 00:00:00 2001 From: sedataym Date: Fri, 23 Oct 2026 14:24:31 +0300 Subject: [PATCH 02/24] macos fix --- README.md | 12 ++ pyproject.toml | 1 + requirements.txt | 1 + src/config.py | 1 + src/core/ocr/apple_vision_engine.py | 79 +++++++++ src/core/ocr/easyocr_engine.py | 14 +- src/core/ocr/ocr_manager.py | 5 + src/core/platform/macos/capabilities.py | 4 + src/core/platform/macos/hotkey_listener.py | 25 +++ src/core/shortcut.py | 7 +- src/ui/macos_overlay.py | 122 +++++++++++++ src/ui/overlay_window.py | 47 ++--- tests/helpers/macos_fullscreen_host.py | 57 +++++++ tests/test_apple_vision_engine.py | 98 +++++++++++ tests/test_easyocr_engine.py | 57 +++++++ tests/test_macos_overlay.py | 190 +++++++++++++++++++++ tests/test_shortcut.py | 74 ++++++++ 17 files changed, 758 insertions(+), 36 deletions(-) create mode 100644 src/core/ocr/apple_vision_engine.py create mode 100644 src/core/platform/macos/hotkey_listener.py create mode 100644 src/ui/macos_overlay.py create mode 100644 tests/helpers/macos_fullscreen_host.py create mode 100644 tests/test_apple_vision_engine.py create mode 100644 tests/test_easyocr_engine.py create mode 100644 tests/test_macos_overlay.py create mode 100644 tests/test_shortcut.py diff --git a/README.md b/README.md index 83e0cf3..3cd65f3 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,18 @@ 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. + ## 🛠️ Tech Stack * **GUI:** PySide6 * **OCR:** pytesseract, easyocr diff --git a/pyproject.toml b/pyproject.toml index 8683151..f7e3865 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ dependencies = [ "dbus-next>=0.2.3", "Babel>=2.12.0", "pyobjc-framework-Cocoa>=10.0; sys_platform == 'darwin'", + "pyobjc-framework-Vision>=10.0; sys_platform == 'darwin'", ] [project.urls] diff --git a/requirements.txt b/requirements.txt index 5345058..7121ae9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,3 +14,4 @@ dbus-next>=0.2.3 Babel>=2.12.0 pynput>=1.7.6 pyobjc-framework-Cocoa>=10.0; sys_platform == "darwin" +pyobjc-framework-Vision>=10.0; sys_platform == "darwin" diff --git a/src/config.py b/src/config.py index c724649..10ac381 100644 --- a/src/config.py +++ b/src/config.py @@ -53,6 +53,7 @@ def get_translation_language_code(language_code: str, provider: str, direction: # Temp Files TEMP_DIR = tempfile.gettempdir() +print(TEMP_DIR) 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") diff --git a/src/core/ocr/apple_vision_engine.py b/src/core/ocr/apple_vision_engine.py new file mode 100644 index 0000000..d61e94a --- /dev/null +++ b/src/core/ocr/apple_vision_engine.py @@ -0,0 +1,79 @@ +"""On-device text recognition using macOS Vision through PyObjC.""" +import logging +from pathlib import Path + +from src.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/easyocr_engine.py b/src/core/ocr/easyocr_engine.py index 8300679..2e58b2a 100644 --- a/src/core/ocr/easyocr_engine.py +++ b/src/core/ocr/easyocr_engine.py @@ -1,3 +1,5 @@ +import warnings + from src.core.ocr.base_ocr import BaseOCREngine from src.config import OCR_LANG_MAPPING @@ -25,7 +27,17 @@ def read_text(self, image_path: str) -> str: self.reader = easyocr.Reader(self.current_langs, gpu=True) try: - res = self.reader.readtext(image_path) + 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: diff --git a/src/core/ocr/ocr_manager.py b/src/core/ocr/ocr_manager.py index 1b1e782..a2557da 100644 --- a/src/core/ocr/ocr_manager.py +++ b/src/core/ocr/ocr_manager.py @@ -1,7 +1,10 @@ +import sys + 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 +from src.core.ocr.apple_vision_engine import AppleVisionEngine class OCRManager: def __init__(self): @@ -11,6 +14,8 @@ def __init__(self): "PaddleOCR": PaddleOCREngine(), "RapidOCR": RapidOCREngine() } + if sys.platform == "darwin": + self.engines["Apple Vision"] = AppleVisionEngine() self.current_engine_name = "RapidOCR" def set_engine(self, name: str): diff --git a/src/core/platform/macos/capabilities.py b/src/core/platform/macos/capabilities.py index d733712..07c2a1c 100644 --- a/src/core/platform/macos/capabilities.py +++ b/src/core/platform/macos/capabilities.py @@ -5,6 +5,10 @@ import os +from src.core.platform.capabilities import OCR_ENGINES as BASE_OCR_ENGINES + + +OCR_ENGINES = [*BASE_OCR_ENGINES, "Apple Vision"] SCREENSHOT_ENGINES = ["MacOS"] diff --git a/src/core/platform/macos/hotkey_listener.py b/src/core/platform/macos/hotkey_listener.py new file mode 100644 index 0000000..0174dc9 --- /dev/null +++ b/src/core/platform/macos/hotkey_listener.py @@ -0,0 +1,25 @@ +"""Keep macOS keyboard-layout queries off pynput's listener thread.""" +import threading + +from pynput import keyboard +from pynput._util import darwin + + +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) + # 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/shortcut.py b/src/core/shortcut.py index cbb6703..8682b15 100644 --- a/src/core/shortcut.py +++ b/src/core/shortcut.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +import sys from typing import Callable try: @@ -50,7 +51,11 @@ 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 src.core.platform.macos.hotkey_listener import MainThreadLayoutListener + listener_type = MainThreadLayoutListener + self._listener = listener_type( on_press=self._on_press, on_release=self._on_release, ) diff --git a/src/ui/macos_overlay.py b/src/ui/macos_overlay.py new file mode 100644 index 0000000..b2902da --- /dev/null +++ b/src/ui/macos_overlay.py @@ -0,0 +1,122 @@ +"""Keep a Qt overlay's Cocoa rules in sync with macOS window transitions.""" +import weakref + +from PySide6.QtCore import QEvent, QObject, QTimer + + +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) + print(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: + print(f"macOS overlay öne getirilemedi: {ex}") + + def _apply(self): + if not self._running or 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 + # 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: + print(f"macOS pencere kuralları uygulanamadı: {ex}") diff --git a/src/ui/overlay_window.py b/src/ui/overlay_window.py index 01544eb..360a5d4 100644 --- a/src/ui/overlay_window.py +++ b/src/ui/overlay_window.py @@ -91,6 +91,11 @@ def __init__(self): self.setAttribute(Qt.WA_TranslucentBackground, True) self.setAttribute(Qt.WA_ShowWithoutActivating) + self._macos_rules = None + if OS == "Darwin": + from src.ui.macos_overlay import MacOSOverlayRules + self.setAttribute(Qt.WA_MacAlwaysShowToolWindow, True) + self._macos_rules = MacOSOverlayRules(self) self.setMouseTracking(True) self.resize(800, 200) @@ -136,39 +141,8 @@ def __init__(self): def showEvent(self, event): super().showEvent(event) - if OS == "Darwin": - QTimer.singleShot(0, self._apply_macos_window_rules) - - def _apply_macos_window_rules(self): - if OS != "Darwin": - return - - try: - import AppKit - except ImportError as ex: - print(f"macOS pencere kuralları uygulanamadı: Cocoa bağımlılığı bulunamadı ({ex})") - return - - try: - self.winId() - window = next( - (candidate for candidate in AppKit.NSApp.windows() - if str(candidate.title()) == "USTA_TRANSLATION_OVERLAY"), - None, - ) - if window is None: - print("macOS pencere kuralları uygulanamadı: overlay penceresi bulunamadı") - return - - behavior = (AppKit.NSWindowCollectionBehaviorCanJoinAllSpaces | - AppKit.NSWindowCollectionBehaviorFullScreenAuxiliary | - AppKit.NSWindowCollectionBehaviorStationary) - window.setLevel_(AppKit.NSStatusWindowLevel) - window.setCollectionBehavior_(behavior) - window.setHidesOnDeactivate_(False) - window.setReleasedWhenClosed_(False) - except Exception as ex: - print(f"macOS pencere kuralları uygulanamadı: {ex}") + if self._macos_rules is not None: + self._macos_rules.start() def _emit_settings_topmost_hotkey_pressed(self): self.settings_topmost_hotkey_pressed.emit() @@ -237,7 +211,10 @@ def set_bg_opacity(self, opacity): @Slot(str) def update_text(self, text): self.label.setText(text) - self.raise_() + 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): @@ -258,6 +235,8 @@ def set_settings_topmost_hotkey(self, hotkey): return True def closeEvent(self, event): + if self._macos_rules is not None: + self._macos_rules.stop() self._settings_topmost_hotkey.stop() super().closeEvent(event) diff --git a/tests/helpers/macos_fullscreen_host.py b/tests/helpers/macos_fullscreen_host.py new file mode 100644 index 0000000..5aeeade --- /dev/null +++ b/tests/helpers/macos_fullscreen_host.py @@ -0,0 +1,57 @@ +"""Separate, focused app for testing native macOS fullscreen Spaces.""" +import json +from pathlib import Path +import sys + +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/test_apple_vision_engine.py b/tests/test_apple_vision_engine.py new file mode 100644 index 0000000..162367b --- /dev/null +++ b/tests/test_apple_vision_engine.py @@ -0,0 +1,98 @@ +import importlib.util +from pathlib import Path +import sys +import tempfile +from concurrent.futures import ThreadPoolExecutor +import unittest +from unittest import mock + +from PIL import Image, ImageDraw, ImageFont + +from src.core.ocr.apple_vision_engine import AppleVisionEngine +from src.core.ocr.ocr_manager import OCRManager +from src.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('src.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('src.core.ocr.ocr_manager.sys.platform', 'linux'): + self.assertNotIn('Apple Vision', OCRManager().engines) + + @unittest.skipUnless(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(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('src.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('src.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/test_easyocr_engine.py b/tests/test_easyocr_engine.py new file mode 100644 index 0000000..3a3d1d7 --- /dev/null +++ b/tests/test_easyocr_engine.py @@ -0,0 +1,57 @@ +import unittest +import warnings + +from src.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/test_macos_overlay.py b/tests/test_macos_overlay.py new file mode 100644 index 0000000..fb4ca70 --- /dev/null +++ b/tests/test_macos_overlay.py @@ -0,0 +1,190 @@ +"""Native macOS integration tests; require a WindowServer session.""" +import sys +import json +from pathlib import Path +import subprocess +import tempfile +import time +import unittest +from unittest import mock + +from PySide6.QtCore import QEvent, QEventLoop, QTimer, Qt +from PySide6.QtWidgets import QApplication, QWidget + +from src.ui.macos_overlay import MacOSOverlayRules + + +def wait_for_events(milliseconds=30): + loop = QEventLoop() + QTimer.singleShot(milliseconds, loop.quit) + loop.exec() + + +@unittest.skipUnless(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 src.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('src.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__).parent / '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() diff --git a/tests/test_shortcut.py b/tests/test_shortcut.py new file mode 100644 index 0000000..3af192a --- /dev/null +++ b/tests/test_shortcut.py @@ -0,0 +1,74 @@ +import contextlib +import sys +import threading +import unittest +from unittest import mock + +from src.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('src.core.shortcut.sys.platform', platform), \ + mock.patch('src.core.shortcut.keyboard', backend), \ + mock.patch.dict(sys.modules, { + 'src.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(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): + 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('src.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) From d6cb3e74048ce41483a8ce0275d106ad5ee42df8 Mon Sep 17 00:00:00 2001 From: sedataym Date: Fri, 23 Oct 2026 14:47:04 +0300 Subject: [PATCH 03/24] macos overlay problem fix --- src/core/sniper/core_sniper.py | 40 +++- src/ui/macos_hotkeys.py | 95 +++++++++ src/ui/macos_overlay.py | 8 +- src/ui/main_window.py | 22 ++- tests/test_macos_hotkey_actions.py | 304 +++++++++++++++++++++++++++++ 5 files changed, 459 insertions(+), 10 deletions(-) create mode 100644 src/ui/macos_hotkeys.py create mode 100644 tests/test_macos_hotkey_actions.py diff --git a/src/core/sniper/core_sniper.py b/src/core/sniper/core_sniper.py index 203bd55..38e0086 100644 --- a/src/core/sniper/core_sniper.py +++ b/src/core/sniper/core_sniper.py @@ -1,6 +1,7 @@ import os +import sys from PySide6.QtCore import Qt, QRect, QPoint, Signal, QObject, QEventLoop -from PySide6.QtGui import QPainter, QColor, QPen +from PySide6.QtGui import QPainter, QColor, QPen, QCursor from PySide6.QtWidgets import QApplication, QWidget from src.core.sniper.base_sniper import BaseSniper @@ -40,13 +41,21 @@ def __init__(self, screen, manager): self.manager = manager self.screen_geometry = screen.geometry() - self.setWindowFlags( + flags = ( Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | - Qt.X11BypassWindowManagerHint | 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 src.ui.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) @@ -87,6 +96,11 @@ 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 +123,27 @@ 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: @@ -128,4 +154,4 @@ def on_finished(rect): self.detected_dpi = screen.devicePixelRatio() break - return rect \ No newline at end of file + return rect diff --git a/src/ui/macos_hotkeys.py b/src/ui/macos_hotkeys.py new file mode 100644 index 0000000..7e5c05a --- /dev/null +++ b/src/ui/macos_hotkeys.py @@ -0,0 +1,95 @@ +"""Native panel actions for macOS settings and region-selection shortcuts.""" +import logging +import os + +from src.ui.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/ui/macos_overlay.py b/src/ui/macos_overlay.py index b2902da..70d904b 100644 --- a/src/ui/macos_overlay.py +++ b/src/ui/macos_overlay.py @@ -90,8 +90,12 @@ def order_front_without_activation(self): except Exception as ex: print(f"macOS overlay öne getirilemedi: {ex}") - def _apply(self): - if not self._running or not self._widget.isVisible(): + 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 diff --git a/src/ui/main_window.py b/src/ui/main_window.py index 3982dcc..c87ac72 100644 --- a/src/ui/main_window.py +++ b/src/ui/main_window.py @@ -7,7 +7,7 @@ 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.config import OS, 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 ( @@ -52,6 +52,14 @@ def __init__(self): # Disable maximization self.setWindowFlags(self.windowFlags() | Qt.MSWindowsFixedSizeDialogHint) self.setWindowFlag(Qt.WindowMaximizeButtonHint, False) + self._macos_hotkey_window = None + if OS == "Darwin": + from src.ui.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) @@ -136,6 +144,13 @@ 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 self._macos_hotkey_window is not None: + if enabled: + self._macos_hotkey_window.show() + else: + self._macos_hotkey_window.lower() + return + if enabled: # Keep the control panel decorated by the window manager. # Qt.Tool / Qt.X11BypassWindowManagerHint can remove borders on some WMs, @@ -503,11 +518,16 @@ def stop(self): self.perf_bar.setStyleSheet("") def closeEvent(self, event): + if self._macos_hotkey_window is not None: + self._macos_hotkey_window.stop() self._temporary_region_restore_timer.stop() self._temporary_region_hotkey.stop() self.worker.stop() self.overlay.close() super().closeEvent(event) + if self._macos_hotkey_window is not None and event.isAccepted(): + # Qt.Tool panels do not trigger quitOnLastWindowClosed. + QApplication.instance().quit() # ---------- API Key Helpers ---------- def _apply_api_key_for_current_engine(self): diff --git a/tests/test_macos_hotkey_actions.py b/tests/test_macos_hotkey_actions.py new file mode 100644 index 0000000..f379e37 --- /dev/null +++ b/tests/test_macos_hotkey_actions.py @@ -0,0 +1,304 @@ +import json +import gc +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import time +import threading +import unittest +import weakref +from unittest import mock + +from PySide6.QtCore import QCoreApplication, QEvent, QEventLoop, QTimer, Qt, QPoint, QRect +from PySide6.QtTest import QTest +from PySide6.QtWidgets import QApplication, QWidget + +from src.core.sniper.core_sniper import CoreSniper, OverlayWindow, SelectionManager +from src.ui.macos_hotkeys import MacOSHotkeyWindow + + +def pump(milliseconds=40): + loop = QEventLoop() + QTimer.singleShot(milliseconds, loop.quit) + loop.exec() + + +@unittest.skipUnless(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 src.ui.main_window import ControlPanel + + stack = ExitStack() + self.addCleanup(stack.close) + stack.enter_context(mock.patch('src.ui.main_window.OS', os_name)) + stack.enter_context(mock.patch('src.ui.overlay_window.OS', os_name)) + stack.enter_context(mock.patch('src.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('src.ui.main_window.OCRWorker')).return_value + 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(): + if panel.isVisible(): + panel.close() + panel.overlay.deleteLater() + panel.deleteLater() + pump() + self.addCleanup(close) + return panel, quit_app + + def test_linux_settings_action_keeps_original_window_behavior(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.set_settings_always_on_top(True) + self.assertTrue(panel.windowFlags() & Qt.WindowStaysOnTopHint) + raise_window.assert_called_once() + activate.assert_called_once() + panel.set_settings_always_on_top(False) + self.assertFalse(panel.windowFlags() & Qt.WindowStaysOnTopHint) + panel.close() + quit_app.assert_not_called() + + def test_linux_region_selector_keeps_x11_flags_without_native_imports(self): + with mock.patch('src.core.sniper.core_sniper.sys.platform', 'linux'), \ + mock.patch.dict(sys.modules, {'src.ui.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') as show, \ + mock.patch.object(panel._macos_hotkey_window, 'lower') as lower, \ + mock.patch.object(panel, 'raise_') as raise_window, \ + mock.patch.object(panel, 'activateWindow') as activate: + panel.overlay._toggle_main_window_topmost() + show.assert_called_once() + panel.overlay._toggle_main_window_topmost() + lower.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._temporary_region_restore_timer.remainingTime(), 9000) + panel._temporary_region_restore_timer.timeout.emit() + self.assertEqual(panel.worker.capture_rect, original) + self.assertEqual(panel.worker.dpi_scale, 1.0) + self.assertFalse(panel._temporary_region_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.close() + 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__).parent / '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() From e8279d7e850ed6784dff74275deb2552bdcc9639 Mon Sep 17 00:00:00 2001 From: sedataym Date: Fri, 23 Oct 2026 15:14:21 +0300 Subject: [PATCH 04/24] test: record portable baseline and isolate native desktop acceptance --- docs/compatibility.md | 11 + docs/development/cleanup-progress.md | 17 ++ .../plans/2026-10-23-project-cleanup.md | 257 ++++++++++++++++++ docs/testing.md | 3 + tests/integration/__init__.py | 0 tests/integration/test_linux_windows.py | 71 +++++ tests/native/__init__.py | 0 .../{ => native}/test_macos_hotkey_actions.py | 2 +- tests/{ => native}/test_macos_overlay.py | 3 +- tests/packaging/__init__.py | 0 tests/unit/__init__.py | 0 tests/{ => unit}/test_apple_vision_engine.py | 5 +- tests/{ => unit}/test_easyocr_engine.py | 0 tests/{ => unit}/test_i18n.py | 2 +- tests/{ => unit}/test_shortcut.py | 3 +- 15 files changed, 368 insertions(+), 6 deletions(-) create mode 100644 docs/compatibility.md create mode 100644 docs/development/cleanup-progress.md create mode 100644 docs/superpowers/plans/2026-10-23-project-cleanup.md create mode 100644 docs/testing.md create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/test_linux_windows.py create mode 100644 tests/native/__init__.py rename tests/{ => native}/test_macos_hotkey_actions.py (99%) rename tests/{ => native}/test_macos_overlay.py (98%) create mode 100644 tests/packaging/__init__.py create mode 100644 tests/unit/__init__.py rename tests/{ => unit}/test_apple_vision_engine.py (94%) rename tests/{ => unit}/test_easyocr_engine.py (100%) rename tests/{ => unit}/test_i18n.py (93%) rename tests/{ => unit}/test_shortcut.py (96%) diff --git a/docs/compatibility.md b/docs/compatibility.md new file mode 100644 index 0000000..e7df874 --- /dev/null +++ b/docs/compatibility.md @@ -0,0 +1,11 @@ +# 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) | Run during each phase | Baseline sandbox cannot access WindowServer/Vision recognition; unverified | +| 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. diff --git a/docs/development/cleanup-progress.md b/docs/development/cleanup-progress.md new file mode 100644 index 0000000..64780a4 --- /dev/null +++ b/docs/development/cleanup-progress.md @@ -0,0 +1,17 @@ +# 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. 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..18f97e4 --- /dev/null +++ b/docs/superpowers/plans/2026-10-23-project-cleanup.md @@ -0,0 +1,257 @@ +# 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. + +## 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. + +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] `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. + +- [ ] 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. +- [ ] 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. +- [ ] `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. + +- [ ] 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ı. +- [ ] 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. +- [ ] 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. +- [ ] 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. + +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] 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. + +- [ ] 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. +- [ ] 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. +- [ ] `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. +- [ ] 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. +- [ ] `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. + +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] 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. + +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] 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. + +- [ ] 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. +- [ ] 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. +- [ ] 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. + +- [ ] İ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. +- [ ] 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. +- [ ] 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. +- [ ] `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. + +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] `.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. +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] Eski ayar/presetler korunarak yükleniyor; durdurulan/eski iş overlay veya IPC'ye sonuç gönderemiyor. +- [ ] 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. +- [ ] 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. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..861ba39 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,3 @@ +# 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. 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_linux_windows.py b/tests/integration/test_linux_windows.py new file mode 100644 index 0000000..6be150e --- /dev/null +++ b/tests/integration/test_linux_windows.py @@ -0,0 +1,71 @@ +import sys + +import unittest +from unittest import mock +from PySide6.QtCore import Qt, QRect +from PySide6.QtWidgets import QApplication +from src.core.sniper.core_sniper import OverlayWindow, SelectionManager +from tests.native.test_macos_hotkey_actions import pump + +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 src.ui.main_window import ControlPanel + + stack = ExitStack() + self.addCleanup(stack.close) + stack.enter_context(mock.patch('src.ui.main_window.OS', os_name)) + stack.enter_context(mock.patch('src.ui.overlay_window.OS', os_name)) + stack.enter_context(mock.patch('src.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('src.ui.main_window.OCRWorker')).return_value + 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(): + if panel.isVisible(): + panel.close() + panel.overlay.deleteLater() + panel.deleteLater() + pump() + self.addCleanup(close) + return panel, quit_app + + + def test_linux_settings_action_keeps_original_window_behavior(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.set_settings_always_on_top(True) + self.assertTrue(panel.windowFlags() & Qt.WindowStaysOnTopHint) + raise_window.assert_called_once() + activate.assert_called_once() + panel.set_settings_always_on_top(False) + self.assertFalse(panel.windowFlags() & Qt.WindowStaysOnTopHint) + panel.close() + quit_app.assert_not_called() + + + def test_linux_region_selector_keeps_x11_flags_without_native_imports(self): + with mock.patch('src.core.sniper.core_sniper.sys.platform', 'linux'), \ + mock.patch.dict(sys.modules, {'src.ui.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/native/__init__.py b/tests/native/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_macos_hotkey_actions.py b/tests/native/test_macos_hotkey_actions.py similarity index 99% rename from tests/test_macos_hotkey_actions.py rename to tests/native/test_macos_hotkey_actions.py index f379e37..d565b62 100644 --- a/tests/test_macos_hotkey_actions.py +++ b/tests/native/test_macos_hotkey_actions.py @@ -25,7 +25,7 @@ def pump(milliseconds=40): loop.exec() -@unittest.skipUnless(sys.platform == 'darwin', 'Requires macOS') +@unittest.skipUnless(os.environ.get('USTA_NATIVE_TESTS') == '1' and sys.platform == 'darwin', 'Requires macOS') class MacOSHotkeyActionTests(unittest.TestCase): @classmethod def setUpClass(cls): diff --git a/tests/test_macos_overlay.py b/tests/native/test_macos_overlay.py similarity index 98% rename from tests/test_macos_overlay.py rename to tests/native/test_macos_overlay.py index fb4ca70..2070415 100644 --- a/tests/test_macos_overlay.py +++ b/tests/native/test_macos_overlay.py @@ -1,3 +1,4 @@ +import os """Native macOS integration tests; require a WindowServer session.""" import sys import json @@ -20,7 +21,7 @@ def wait_for_events(milliseconds=30): loop.exec() -@unittest.skipUnless(sys.platform == 'darwin', 'Requires macOS Cocoa') +@unittest.skipUnless(os.environ.get('USTA_NATIVE_TESTS') == '1' and sys.platform == 'darwin', 'Requires macOS Cocoa') class MacOSOverlayTests(unittest.TestCase): @classmethod def setUpClass(cls): diff --git a/tests/packaging/__init__.py b/tests/packaging/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_apple_vision_engine.py b/tests/unit/test_apple_vision_engine.py similarity index 94% rename from tests/test_apple_vision_engine.py rename to tests/unit/test_apple_vision_engine.py index 162367b..03ae017 100644 --- a/tests/test_apple_vision_engine.py +++ b/tests/unit/test_apple_vision_engine.py @@ -1,3 +1,4 @@ +import os import importlib.util from pathlib import Path import sys @@ -29,7 +30,7 @@ def test_engine_is_offered_only_on_macos(self): with mock.patch('src.core.ocr.ocr_manager.sys.platform', 'linux'): self.assertNotIn('Apple Vision', OCRManager().engines) - @unittest.skipUnless(sys.platform == 'darwin', 'Requires macOS') + @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') @@ -38,7 +39,7 @@ def test_manager_selects_vision_and_propagates_language(self): self.assertEqual(manager.engines['Apple Vision'].language, 'ko') -@unittest.skipUnless(sys.platform == 'darwin' and importlib.util.find_spec('Vision'), +@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): diff --git a/tests/test_easyocr_engine.py b/tests/unit/test_easyocr_engine.py similarity index 100% rename from tests/test_easyocr_engine.py rename to tests/unit/test_easyocr_engine.py diff --git a/tests/test_i18n.py b/tests/unit/test_i18n.py similarity index 93% rename from tests/test_i18n.py rename to tests/unit/test_i18n.py index c7f60b0..e2fca50 100644 --- a/tests/test_i18n.py +++ b/tests/unit/test_i18n.py @@ -6,7 +6,7 @@ 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", "translations", ) diff --git a/tests/test_shortcut.py b/tests/unit/test_shortcut.py similarity index 96% rename from tests/test_shortcut.py rename to tests/unit/test_shortcut.py index 3af192a..d596cd7 100644 --- a/tests/test_shortcut.py +++ b/tests/unit/test_shortcut.py @@ -1,3 +1,4 @@ +import os import contextlib import sys import threading @@ -29,7 +30,7 @@ def test_linux_and_windows_keep_the_existing_pynput_listener(self): listener.stop.assert_called_once() -@unittest.skipUnless(sys.platform == 'darwin' and keyboard is not None, +@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): From ea48730d91917997e7226d64171aa9edcfb1c555 Mon Sep 17 00:00:00 2001 From: sedataym Date: Fri, 23 Oct 2026 15:14:59 +0300 Subject: [PATCH 05/24] build: unify dependency declarations and version metadata --- docs/development/cleanup-progress.md | 2 ++ pyproject.toml | 14 +++++++++++--- requirements-dev.txt | 1 + requirements.txt | 18 +----------------- scripts/build-appimage.sh | 11 +---------- src/_version.py | 1 + src/config.py | 2 +- tests/packaging/test_metadata.py | 15 +++++++++++++++ 8 files changed, 33 insertions(+), 31 deletions(-) create mode 100644 requirements-dev.txt create mode 100644 src/_version.py create mode 100644 tests/packaging/test_metadata.py diff --git a/docs/development/cleanup-progress.md b/docs/development/cleanup-progress.md index 64780a4..0fd6a41 100644 --- a/docs/development/cleanup-progress.md +++ b/docs/development/cleanup-progress.md @@ -15,3 +15,5 @@ Host: macOS-26.5.2-arm64-arm-64bit-Mach-O; Python 3.14.7 - 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. diff --git a/pyproject.toml b/pyproject.toml index f7e3865..b13dc3a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,13 +4,13 @@ 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." readme = "README.md" -requires-python = ">=3.8" +requires-python = ">=3.10" classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", @@ -24,7 +24,8 @@ 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", @@ -48,3 +49,10 @@ include = ["src*"] [tool.setuptools.package-data] "src" = ["translations/*/LC_MESSAGES/*.mo"] "src.ui" = ["assets/*.png"] + +[project.optional-dependencies] +paddle = ["paddleocr>=3.6.0", "paddlepaddle"] +dev = ["ruff", "mypy", "build", "coverage"] + +[tool.setuptools.dynamic] +version = {attr = "src._version.__version__"} 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 7121ae9..9c558e3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,17 +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 -pyobjc-framework-Cocoa>=10.0; sys_platform == "darwin" -pyobjc-framework-Vision>=10.0; sys_platform == "darwin" +. diff --git a/scripts/build-appimage.sh b/scripts/build-appimage.sh index f6f43e4..0a15ca8 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 'from src._version import __version__; print(__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)')" diff --git a/src/_version.py b/src/_version.py new file mode 100644 index 0000000..f1380ee --- /dev/null +++ b/src/_version.py @@ -0,0 +1 @@ +__version__ = "0.1.7" diff --git a/src/config.py b/src/config.py index 10ac381..d68c031 100644 --- a/src/config.py +++ b/src/config.py @@ -1,7 +1,7 @@ import os import tempfile -APP_VERSION = "0.1.7" +from src._version import __version__ as APP_VERSION from src.core.platform.capability_resolver import ( CONFIG_DIR, diff --git a/tests/packaging/test_metadata.py b/tests/packaging/test_metadata.py new file mode 100644 index 0000000..da9cd6c --- /dev/null +++ b/tests/packaging/test_metadata.py @@ -0,0 +1,15 @@ +import unittest +from pathlib import Path +import 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(), '.') From ecaf7c9310f422166f1d85cfde9f4ecd08a13bbd Mon Sep 17 00:00:00 2001 From: sedataym Date: Fri, 23 Oct 2026 15:15:59 +0300 Subject: [PATCH 06/24] refactor: install application as usta package with bundled resources --- docs/development/cleanup-progress.md | 2 ++ packaging/appimage/AppRun | 2 +- pyproject.toml | 12 ++++----- run.py | 2 +- scripts/build-appimage.sh | 6 ++--- src/core/screenshot/__init__.py | 23 ------------------ src/core/sniper/__init__.py | 9 ------- src/ui/tabs/__init__.py | 19 --------------- src/{ => usta}/__init__.py | 0 src/usta/__main__.py | 4 +++ src/{ => usta}/_version.py | 0 src/{ => usta}/config.py | 6 ++--- src/{ => usta}/core/__init__.py | 0 src/{ => usta}/core/exceptions.py | 0 src/{ => usta}/core/ocr/__init__.py | 0 .../core/ocr/apple_vision_engine.py | 2 +- src/{ => usta}/core/ocr/base_ocr.py | 0 src/{ => usta}/core/ocr/easyocr_engine.py | 4 +-- src/{ => usta}/core/ocr/ocr_manager.py | 10 ++++---- src/{ => usta}/core/ocr/paddleocr_engine.py | 4 +-- src/{ => usta}/core/ocr/rapidocr_engine.py | 14 +++++------ src/{ => usta}/core/ocr/tesseract_engine.py | 4 +-- src/{ => usta}/core/platform/__init__.py | 4 +-- src/{ => usta}/core/platform/capabilities.py | 0 .../core/platform/capability_resolver.py | 8 +++--- .../core/platform/linux/capabilities.py | 2 +- .../core/platform/linux/gnome/capabilities.py | 2 +- .../core/platform/linux/kde/capabilities.py | 2 +- .../core/platform/macos/capabilities.py | 4 +-- .../core/platform/macos/hotkey_listener.py | 0 src/{ => usta}/core/platform/system_info.py | 0 .../core/platform/windows/capabilities.py | 2 +- src/usta/core/screenshot/__init__.py | 23 ++++++++++++++++++ .../core/screenshot/base_screenshot.py | 0 .../core/screenshot/image_processor.py | 0 .../core/screenshot/macos_screenshot.py | 2 +- .../core/screenshot/portal_screenshot.py | 6 ++--- .../core/screenshot/screenshot_factory.py | 8 +++--- .../core/screenshot/spectacle_screenshot.py | 4 +-- src/{ => usta}/core/shortcut.py | 2 +- src/usta/core/sniper/__init__.py | 9 +++++++ src/{ => usta}/core/sniper/base_sniper.py | 0 src/{ => usta}/core/sniper/core_sniper.py | 4 +-- src/{ => usta}/core/sniper/slurp_sniper.py | 4 +-- src/{ => usta}/core/sniper/sniper_factory.py | 6 ++--- src/{ => usta}/core/socket_publisher.py | 2 +- src/{ => usta}/core/translation/__init__.py | 0 .../core/translation/base_translator.py | 0 .../core/translation/deepl_engine.py | 4 +-- .../core/translation/google_engine.py | 4 +-- .../core/translation/translator_manager.py | 4 +-- src/{ => usta}/core/worker.py | 12 ++++----- src/{ => usta}/i18n.py | 5 ++-- src/{ => usta}/main.py | 11 +++------ src/usta/resource_paths.py | 14 +++++++++++ .../rapidocr/arabic_PP-OCRv4_rec_infer.onnx | Bin .../rapidocr/ch_PP-OCRv4_rec_infer.onnx | Bin .../rapidocr/cyrillic_PP-OCRv3_rec_infer.onnx | Bin .../rapidocr/en_PP-OCRv4_rec_infer.onnx | Bin .../rapidocr/japan_PP-OCRv4_rec_infer.onnx | Bin .../rapidocr/korean_PP-OCRv4_rec_infer.onnx | Bin .../rapidocr/latin_PP-OCRv5_rec_mobile.onnx | Bin .../th_PP-OCRv5_rec_mobile_infer.onnx | Bin .../translations/tr/LC_MESSAGES/messages.mo | Bin .../translations/tr/LC_MESSAGES/messages.po | 0 .../translations/uk/LC_MESSAGES/messages.mo | Bin .../translations/uk/LC_MESSAGES/messages.po | 0 src/{ => usta}/ui/__init__.py | 0 src/{ => usta}/ui/assets/usta.png | Bin src/{ => usta}/ui/assets/usta.xcf | Bin src/{ => usta}/ui/assets/usta_cover.png | Bin src/{ => usta}/ui/macos_hotkeys.py | 2 +- src/{ => usta}/ui/macos_overlay.py | 0 src/{ => usta}/ui/main_window.py | 16 ++++++------ src/{ => usta}/ui/overlay_window.py | 8 +++--- src/usta/ui/tabs/__init__.py | 19 +++++++++++++++ src/{ => usta}/ui/tabs/about_tab.py | 4 +-- src/{ => usta}/ui/tabs/appearance_tab.py | 2 +- src/{ => usta}/ui/tabs/hotkey_tab.py | 2 +- src/{ => usta}/ui/tabs/ocr_tab.py | 4 +-- src/{ => usta}/ui/tabs/save_tab.py | 2 +- src/{ => usta}/ui/tabs/status_tab.py | 2 +- src/{ => usta}/ui/tabs/translation_tab.py | 4 +-- tests/integration/test_linux_windows.py | 16 ++++++------ tests/native/test_macos_hotkey_actions.py | 18 +++++++------- tests/native/test_macos_overlay.py | 6 ++--- tests/packaging/test_artifacts.py | 16 ++++++++++++ tests/unit/test_apple_vision_engine.py | 14 +++++------ tests/unit/test_easyocr_engine.py | 2 +- tests/unit/test_i18n.py | 4 +-- tests/unit/test_shortcut.py | 10 ++++---- 91 files changed, 226 insertions(+), 196 deletions(-) delete mode 100644 src/core/screenshot/__init__.py delete mode 100644 src/core/sniper/__init__.py delete mode 100644 src/ui/tabs/__init__.py rename src/{ => usta}/__init__.py (100%) create mode 100644 src/usta/__main__.py rename src/{ => usta}/_version.py (100%) rename src/{ => usta}/config.py (92%) rename src/{ => usta}/core/__init__.py (100%) rename src/{ => usta}/core/exceptions.py (100%) rename src/{ => usta}/core/ocr/__init__.py (100%) rename src/{ => usta}/core/ocr/apple_vision_engine.py (98%) rename src/{ => usta}/core/ocr/base_ocr.py (100%) rename src/{ => usta}/core/ocr/easyocr_engine.py (95%) rename src/{ => usta}/core/ocr/ocr_manager.py (74%) rename src/{ => usta}/core/ocr/paddleocr_engine.py (97%) rename src/{ => usta}/core/ocr/rapidocr_engine.py (97%) rename src/{ => usta}/core/ocr/tesseract_engine.py (93%) rename src/{ => usta}/core/platform/__init__.py (86%) rename src/{ => usta}/core/platform/capabilities.py (100%) rename src/{ => usta}/core/platform/capability_resolver.py (90%) rename src/{ => usta}/core/platform/linux/capabilities.py (69%) rename src/{ => usta}/core/platform/linux/gnome/capabilities.py (53%) rename src/{ => usta}/core/platform/linux/kde/capabilities.py (56%) rename src/{ => usta}/core/platform/macos/capabilities.py (61%) rename src/{ => usta}/core/platform/macos/hotkey_listener.py (100%) rename src/{ => usta}/core/platform/system_info.py (100%) rename src/{ => usta}/core/platform/windows/capabilities.py (72%) create mode 100644 src/usta/core/screenshot/__init__.py rename src/{ => usta}/core/screenshot/base_screenshot.py (100%) rename src/{ => usta}/core/screenshot/image_processor.py (100%) rename src/{ => usta}/core/screenshot/macos_screenshot.py (94%) rename src/{ => usta}/core/screenshot/portal_screenshot.py (98%) rename src/{ => usta}/core/screenshot/screenshot_factory.py (80%) rename src/{ => usta}/core/screenshot/spectacle_screenshot.py (92%) rename src/{ => usta}/core/shortcut.py (96%) create mode 100644 src/usta/core/sniper/__init__.py rename src/{ => usta}/core/sniper/base_sniper.py (100%) rename src/{ => usta}/core/sniper/core_sniper.py (97%) rename src/{ => usta}/core/sniper/slurp_sniper.py (93%) rename src/{ => usta}/core/sniper/sniper_factory.py (63%) rename src/{ => usta}/core/socket_publisher.py (99%) rename src/{ => usta}/core/translation/__init__.py (100%) rename src/{ => usta}/core/translation/base_translator.py (100%) rename src/{ => usta}/core/translation/deepl_engine.py (93%) rename src/{ => usta}/core/translation/google_engine.py (88%) rename src/{ => usta}/core/translation/translator_manager.py (89%) rename src/{ => usta}/core/worker.py (94%) rename src/{ => usta}/i18n.py (96%) rename src/{ => usta}/main.py (65%) create mode 100644 src/usta/resource_paths.py rename src/{core/ocr => usta/resources}/ocr_models/rapidocr/arabic_PP-OCRv4_rec_infer.onnx (100%) rename src/{core/ocr => usta/resources}/ocr_models/rapidocr/ch_PP-OCRv4_rec_infer.onnx (100%) rename src/{core/ocr => usta/resources}/ocr_models/rapidocr/cyrillic_PP-OCRv3_rec_infer.onnx (100%) rename src/{core/ocr => usta/resources}/ocr_models/rapidocr/en_PP-OCRv4_rec_infer.onnx (100%) rename src/{core/ocr => usta/resources}/ocr_models/rapidocr/japan_PP-OCRv4_rec_infer.onnx (100%) rename src/{core/ocr => usta/resources}/ocr_models/rapidocr/korean_PP-OCRv4_rec_infer.onnx (100%) rename src/{core/ocr => usta/resources}/ocr_models/rapidocr/latin_PP-OCRv5_rec_mobile.onnx (100%) rename src/{core/ocr => usta/resources}/ocr_models/rapidocr/th_PP-OCRv5_rec_mobile_infer.onnx (100%) rename src/{ => usta/resources}/translations/tr/LC_MESSAGES/messages.mo (100%) rename src/{ => usta/resources}/translations/tr/LC_MESSAGES/messages.po (100%) rename src/{ => usta/resources}/translations/uk/LC_MESSAGES/messages.mo (100%) rename src/{ => usta/resources}/translations/uk/LC_MESSAGES/messages.po (100%) rename src/{ => usta}/ui/__init__.py (100%) rename src/{ => usta}/ui/assets/usta.png (100%) rename src/{ => usta}/ui/assets/usta.xcf (100%) rename src/{ => usta}/ui/assets/usta_cover.png (100%) rename src/{ => usta}/ui/macos_hotkeys.py (98%) rename src/{ => usta}/ui/macos_overlay.py (100%) rename src/{ => usta}/ui/main_window.py (98%) rename src/{ => usta}/ui/overlay_window.py (98%) create mode 100644 src/usta/ui/tabs/__init__.py rename src/{ => usta}/ui/tabs/about_tab.py (92%) rename src/{ => usta}/ui/tabs/appearance_tab.py (98%) rename src/{ => usta}/ui/tabs/hotkey_tab.py (99%) rename src/{ => usta}/ui/tabs/ocr_tab.py (95%) rename src/{ => usta}/ui/tabs/save_tab.py (98%) rename src/{ => usta}/ui/tabs/status_tab.py (97%) rename src/{ => usta}/ui/tabs/translation_tab.py (96%) create mode 100644 tests/packaging/test_artifacts.py diff --git a/docs/development/cleanup-progress.md b/docs/development/cleanup-progress.md index 0fd6a41..1e3ff85 100644 --- a/docs/development/cleanup-progress.md +++ b/docs/development/cleanup-progress.md @@ -17,3 +17,5 @@ Host: macOS-26.5.2-arm64-arm-64bit-Mach-O; Python 3.14.7 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. 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/pyproject.toml b/pyproject.toml index b13dc3a..e63bb76 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,19 +40,19 @@ 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"] [project.optional-dependencies] paddle = ["paddleocr>=3.6.0", "paddlepaddle"] dev = ["ruff", "mypy", "build", "coverage"] [tool.setuptools.dynamic] -version = {attr = "src._version.__version__"} +version = {attr = "usta._version.__version__"} 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 0a15ca8..b13a4d3 100755 --- a/scripts/build-appimage.sh +++ b/scripts/build-appimage.sh @@ -35,7 +35,7 @@ EOF exit 1 fi -APP_VERSION="${APP_VERSION:-$(${PYTHON_BIN} -c 'from src._version import __version__; print(__version__)')}" +APP_VERSION="${APP_VERSION:-$(${PYTHON_BIN} -c 'from usta._version import __version__; print(__version__)')}" if ! command -v "${APPIMAGETOOL}" >/dev/null 2>&1; then LOCAL_APPIMAGETOOL="${BUILD_DIR}/appimagetool-${APPIMAGE_ARCH}.AppImage" @@ -95,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/src/core/screenshot/__init__.py b/src/core/screenshot/__init__.py deleted file mode 100644 index 9280dc6..0000000 --- a/src/core/screenshot/__init__.py +++ /dev/null @@ -1,23 +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.macos_screenshot import MacOSScreenshot -except Exception: - MacOSScreenshot = None - -try: - from src.core.screenshot.portal_screenshot import PortalScreenshot -except Exception: - PortalScreenshot = None - -__all__ = [ - "BaseScreenshot", - "MacOSScreenshot", - "PortalScreenshot", - "SpectacleScreenshot", - "ImageProcessor", - "ScreenshotFactory", -] 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/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/__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/_version.py b/src/usta/_version.py similarity index 100% rename from src/_version.py rename to src/usta/_version.py diff --git a/src/config.py b/src/usta/config.py similarity index 92% rename from src/config.py rename to src/usta/config.py index d68c031..f15bce9 100644 --- a/src/config.py +++ b/src/usta/config.py @@ -1,9 +1,9 @@ import os import tempfile -from src._version import __version__ as APP_VERSION +from usta._version import __version__ as APP_VERSION -from src.core.platform.capability_resolver import ( +from usta.core.platform.capability_resolver import ( CONFIG_DIR, LANGUAGES, OCR_ENGINES, @@ -14,7 +14,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, diff --git a/src/core/__init__.py b/src/usta/core/__init__.py similarity index 100% rename from src/core/__init__.py rename to src/usta/core/__init__.py diff --git a/src/core/exceptions.py b/src/usta/core/exceptions.py similarity index 100% rename from src/core/exceptions.py rename to src/usta/core/exceptions.py 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/core/ocr/apple_vision_engine.py b/src/usta/core/ocr/apple_vision_engine.py similarity index 98% rename from src/core/ocr/apple_vision_engine.py rename to src/usta/core/ocr/apple_vision_engine.py index d61e94a..8eb329b 100644 --- a/src/core/ocr/apple_vision_engine.py +++ b/src/usta/core/ocr/apple_vision_engine.py @@ -2,7 +2,7 @@ import logging from pathlib import Path -from src.core.ocr.base_ocr import BaseOCREngine +from usta.core.ocr.base_ocr import BaseOCREngine logger = logging.getLogger(__name__) diff --git a/src/core/ocr/base_ocr.py b/src/usta/core/ocr/base_ocr.py similarity index 100% rename from src/core/ocr/base_ocr.py rename to src/usta/core/ocr/base_ocr.py diff --git a/src/core/ocr/easyocr_engine.py b/src/usta/core/ocr/easyocr_engine.py similarity index 95% rename from src/core/ocr/easyocr_engine.py rename to src/usta/core/ocr/easyocr_engine.py index 2e58b2a..d8117a2 100644 --- a/src/core/ocr/easyocr_engine.py +++ b/src/usta/core/ocr/easyocr_engine.py @@ -1,7 +1,7 @@ import warnings -from src.core.ocr.base_ocr import BaseOCREngine -from src.config import OCR_LANG_MAPPING +from usta.core.ocr.base_ocr import BaseOCREngine +from usta.config import OCR_LANG_MAPPING class EasyOCREngine(BaseOCREngine): def __init__(self): diff --git a/src/core/ocr/ocr_manager.py b/src/usta/core/ocr/ocr_manager.py similarity index 74% rename from src/core/ocr/ocr_manager.py rename to src/usta/core/ocr/ocr_manager.py index a2557da..435d57c 100644 --- a/src/core/ocr/ocr_manager.py +++ b/src/usta/core/ocr/ocr_manager.py @@ -1,10 +1,10 @@ import sys -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 -from src.core.ocr.apple_vision_engine import AppleVisionEngine +from usta.core.ocr.tesseract_engine import TesseractEngine +from usta.core.ocr.easyocr_engine import EasyOCREngine +from usta.core.ocr.paddleocr_engine import PaddleOCREngine +from usta.core.ocr.rapidocr_engine import RapidOCREngine +from usta.core.ocr.apple_vision_engine import AppleVisionEngine class OCRManager: def __init__(self): diff --git a/src/core/ocr/paddleocr_engine.py b/src/usta/core/ocr/paddleocr_engine.py similarity index 97% rename from src/core/ocr/paddleocr_engine.py rename to src/usta/core/ocr/paddleocr_engine.py index 1aa8885..f82dae5 100644 --- a/src/core/ocr/paddleocr_engine.py +++ b/src/usta/core/ocr/paddleocr_engine.py @@ -1,5 +1,5 @@ -from src.core.ocr.base_ocr import BaseOCREngine -from src.config import OCR_LANG_MAPPING +from usta.core.ocr.base_ocr import BaseOCREngine +from usta.config import OCR_LANG_MAPPING class PaddleOCREngine(BaseOCREngine): diff --git a/src/core/ocr/rapidocr_engine.py b/src/usta/core/ocr/rapidocr_engine.py similarity index 97% rename from src/core/ocr/rapidocr_engine.py rename to src/usta/core/ocr/rapidocr_engine.py index 718a120..90bd698 100644 --- a/src/core/ocr/rapidocr_engine.py +++ b/src/usta/core/ocr/rapidocr_engine.py @@ -1,14 +1,12 @@ import glob import os -from src.core.ocr.base_ocr import BaseOCREngine -from src.config import OCR_LANG_MAPPING - -_MODELS_DIR = os.path.join( - os.path.dirname(os.path.abspath(__file__)), - "ocr_models", - "rapidocr", -) +from usta.core.ocr.base_ocr import BaseOCREngine +from usta.config import OCR_LANG_MAPPING + +from usta.resource_paths import resource_path + +_MODELS_DIR = str(resource_path("resources/ocr_models/rapidocr")) class RapidOCREngine(BaseOCREngine): diff --git a/src/core/ocr/tesseract_engine.py b/src/usta/core/ocr/tesseract_engine.py similarity index 93% rename from src/core/ocr/tesseract_engine.py rename to src/usta/core/ocr/tesseract_engine.py index ac7ba11..cdc8681 100644 --- a/src/core/ocr/tesseract_engine.py +++ b/src/usta/core/ocr/tesseract_engine.py @@ -1,7 +1,7 @@ import subprocess import re -from src.core.ocr.base_ocr import BaseOCREngine -from src.config import OCR_LANG_MAPPING +from usta.core.ocr.base_ocr import BaseOCREngine +from usta.config import OCR_LANG_MAPPING class TesseractEngine(BaseOCREngine): def __init__(self): diff --git a/src/core/platform/__init__.py b/src/usta/core/platform/__init__.py similarity index 86% rename from src/core/platform/__init__.py rename to src/usta/core/platform/__init__.py index e69b351..7b29bec 100644 --- a/src/core/platform/__init__.py +++ b/src/usta/core/platform/__init__.py @@ -1,4 +1,4 @@ -from src.core.platform.capability_resolver import ( +from usta.core.platform.capability_resolver import ( CONFIG_DIR, LANGUAGES, OCR_ENGINES, @@ -10,7 +10,7 @@ TRANSLATION_ENGINES, resolve_capabilities, ) -from src.core.platform.system_info import ( +from usta.core.platform.system_info import ( SystemInfo, get_compositor, get_desktop_environment, diff --git a/src/core/platform/capabilities.py b/src/usta/core/platform/capabilities.py similarity index 100% rename from src/core/platform/capabilities.py rename to src/usta/core/platform/capabilities.py 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..941d385 100644 --- a/src/core/platform/capability_resolver.py +++ b/src/usta/core/platform/capability_resolver.py @@ -2,8 +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 = ( @@ -56,11 +56,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..63d8a34 100644 --- a/src/core/platform/linux/capabilities.py +++ b/src/usta/core/platform/linux/capabilities.py @@ -1,6 +1,6 @@ """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 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/core/platform/linux/kde/capabilities.py b/src/usta/core/platform/linux/kde/capabilities.py similarity index 56% rename from src/core/platform/linux/kde/capabilities.py rename to src/usta/core/platform/linux/kde/capabilities.py index 6313921..a17f4eb 100644 --- a/src/core/platform/linux/kde/capabilities.py +++ b/src/usta/core/platform/linux/kde/capabilities.py @@ -1,6 +1,6 @@ """KDE 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", "Spectacle"] diff --git a/src/core/platform/macos/capabilities.py b/src/usta/core/platform/macos/capabilities.py similarity index 61% rename from src/core/platform/macos/capabilities.py rename to src/usta/core/platform/macos/capabilities.py index 07c2a1c..012eba3 100644 --- a/src/core/platform/macos/capabilities.py +++ b/src/usta/core/platform/macos/capabilities.py @@ -1,11 +1,11 @@ """macOS 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 -from src.core.platform.capabilities import OCR_ENGINES as BASE_OCR_ENGINES +from usta.core.platform.capabilities import OCR_ENGINES as BASE_OCR_ENGINES OCR_ENGINES = [*BASE_OCR_ENGINES, "Apple Vision"] diff --git a/src/core/platform/macos/hotkey_listener.py b/src/usta/core/platform/macos/hotkey_listener.py similarity index 100% rename from src/core/platform/macos/hotkey_listener.py rename to src/usta/core/platform/macos/hotkey_listener.py diff --git a/src/core/platform/system_info.py b/src/usta/core/platform/system_info.py similarity index 100% rename from src/core/platform/system_info.py rename to src/usta/core/platform/system_info.py 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..6e88154 100644 --- a/src/core/platform/windows/capabilities.py +++ b/src/usta/core/platform/windows/capabilities.py @@ -1,6 +1,6 @@ """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 diff --git a/src/usta/core/screenshot/__init__.py b/src/usta/core/screenshot/__init__.py new file mode 100644 index 0000000..4c4618a --- /dev/null +++ b/src/usta/core/screenshot/__init__.py @@ -0,0 +1,23 @@ +from usta.core.screenshot.base_screenshot import BaseScreenshot +from usta.core.screenshot.spectacle_screenshot import SpectacleScreenshot +from usta.core.screenshot.image_processor import ImageProcessor +from usta.core.screenshot.screenshot_factory import ScreenshotFactory + +try: + from usta.core.screenshot.macos_screenshot import MacOSScreenshot +except Exception: + MacOSScreenshot = None + +try: + from usta.core.screenshot.portal_screenshot import PortalScreenshot +except Exception: + PortalScreenshot = None + +__all__ = [ + "BaseScreenshot", + "MacOSScreenshot", + "PortalScreenshot", + "SpectacleScreenshot", + "ImageProcessor", + "ScreenshotFactory", +] diff --git a/src/core/screenshot/base_screenshot.py b/src/usta/core/screenshot/base_screenshot.py similarity index 100% rename from src/core/screenshot/base_screenshot.py rename to src/usta/core/screenshot/base_screenshot.py diff --git a/src/core/screenshot/image_processor.py b/src/usta/core/screenshot/image_processor.py similarity index 100% rename from src/core/screenshot/image_processor.py rename to src/usta/core/screenshot/image_processor.py diff --git a/src/core/screenshot/macos_screenshot.py b/src/usta/core/screenshot/macos_screenshot.py similarity index 94% rename from src/core/screenshot/macos_screenshot.py rename to src/usta/core/screenshot/macos_screenshot.py index 5b5b6f0..73eb9b1 100644 --- a/src/core/screenshot/macos_screenshot.py +++ b/src/usta/core/screenshot/macos_screenshot.py @@ -3,7 +3,7 @@ from PIL import ImageGrab from PySide6.QtCore import QRect -from src.core.screenshot.base_screenshot import BaseScreenshot +from usta.core.screenshot.base_screenshot import BaseScreenshot class MacOSScreenshot(BaseScreenshot): diff --git a/src/core/screenshot/portal_screenshot.py b/src/usta/core/screenshot/portal_screenshot.py similarity index 98% rename from src/core/screenshot/portal_screenshot.py rename to src/usta/core/screenshot/portal_screenshot.py index ed2029b..f732af5 100644 --- a/src/core/screenshot/portal_screenshot.py +++ b/src/usta/core/screenshot/portal_screenshot.py @@ -8,9 +8,9 @@ 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 try: from dbus_next import BusType, Variant diff --git a/src/core/screenshot/screenshot_factory.py b/src/usta/core/screenshot/screenshot_factory.py similarity index 80% rename from src/core/screenshot/screenshot_factory.py rename to src/usta/core/screenshot/screenshot_factory.py index e288d07..ec27d93 100644 --- a/src/core/screenshot/screenshot_factory.py +++ b/src/usta/core/screenshot/screenshot_factory.py @@ -1,10 +1,10 @@ import platform -from src.core.screenshot.base_screenshot import BaseScreenshot -from src.core.screenshot.spectacle_screenshot import SpectacleScreenshot +from usta.core.screenshot.base_screenshot import BaseScreenshot +from usta.core.screenshot.spectacle_screenshot import SpectacleScreenshot try: - from src.core.screenshot.macos_screenshot import MacOSScreenshot + from usta.core.screenshot.macos_screenshot import MacOSScreenshot except Exception as exc: MacOSScreenshot = None _MACOS_IMPORT_ERROR = exc @@ -12,7 +12,7 @@ _MACOS_IMPORT_ERROR = None try: - from src.core.screenshot.portal_screenshot import PortalScreenshot + from usta.core.screenshot.portal_screenshot import PortalScreenshot except Exception as exc: PortalScreenshot = None _PORTAL_IMPORT_ERROR = exc diff --git a/src/core/screenshot/spectacle_screenshot.py b/src/usta/core/screenshot/spectacle_screenshot.py similarity index 92% rename from src/core/screenshot/spectacle_screenshot.py rename to src/usta/core/screenshot/spectacle_screenshot.py index 7ee2fbf..f381cce 100644 --- a/src/core/screenshot/spectacle_screenshot.py +++ b/src/usta/core/screenshot/spectacle_screenshot.py @@ -2,8 +2,8 @@ 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.core.screenshot.base_screenshot import BaseScreenshot +from usta.config import FULL_SCREEN_TEMP_PATH class SpectacleScreenshot(BaseScreenshot): def capture(self, rect: QRect, output_path: str, dpi_scale: float = 1.0) -> bool: diff --git a/src/core/shortcut.py b/src/usta/core/shortcut.py similarity index 96% rename from src/core/shortcut.py rename to src/usta/core/shortcut.py index 8682b15..f18d40d 100644 --- a/src/core/shortcut.py +++ b/src/usta/core/shortcut.py @@ -53,7 +53,7 @@ def start(self) -> bool: self._hotkey = keyboard.HotKey(hotkey_keys, self._on_activate) listener_type = keyboard.Listener if sys.platform == "darwin": - from src.core.platform.macos.hotkey_listener import MainThreadLayoutListener + from usta.core.platform.macos.hotkey_listener import MainThreadLayoutListener listener_type = MainThreadLayoutListener self._listener = listener_type( on_press=self._on_press, diff --git a/src/usta/core/sniper/__init__.py b/src/usta/core/sniper/__init__.py new file mode 100644 index 0000000..acf1686 --- /dev/null +++ b/src/usta/core/sniper/__init__.py @@ -0,0 +1,9 @@ +from usta.core.sniper.base_sniper import BaseSniper +from usta.core.sniper.slurp_sniper import SlurpSniper +from usta.core.sniper.sniper_factory import SniperFactory + +__all__ = [ + "BaseSniper", + "SlurpSniper", + "SniperFactory", +] diff --git a/src/core/sniper/base_sniper.py b/src/usta/core/sniper/base_sniper.py similarity index 100% rename from src/core/sniper/base_sniper.py rename to src/usta/core/sniper/base_sniper.py diff --git a/src/core/sniper/core_sniper.py b/src/usta/core/sniper/core_sniper.py similarity index 97% rename from src/core/sniper/core_sniper.py rename to src/usta/core/sniper/core_sniper.py index 38e0086..f58a457 100644 --- a/src/core/sniper/core_sniper.py +++ b/src/usta/core/sniper/core_sniper.py @@ -3,7 +3,7 @@ from PySide6.QtCore import Qt, QRect, QPoint, Signal, QObject, QEventLoop from PySide6.QtGui import QPainter, QColor, QPen, QCursor from PySide6.QtWidgets import QApplication, QWidget -from src.core.sniper.base_sniper import BaseSniper +from usta.core.sniper.base_sniper import BaseSniper class SelectionManager(QObject): """Manages the selection state across multiple monitors.""" @@ -52,7 +52,7 @@ def __init__(self, screen, manager): self.setAttribute(Qt.WA_TranslucentBackground) self._macos_hotkey_window = None if sys.platform == "darwin": - from src.ui.macos_hotkeys import MacOSHotkeyWindow + from usta.ui.macos_hotkeys import MacOSHotkeyWindow self.setAttribute(Qt.WA_ShowWithoutActivating) self.setAttribute(Qt.WA_MacAlwaysShowToolWindow) self._macos_hotkey_window = MacOSHotkeyWindow(self) diff --git a/src/core/sniper/slurp_sniper.py b/src/usta/core/sniper/slurp_sniper.py similarity index 93% rename from src/core/sniper/slurp_sniper.py rename to src/usta/core/sniper/slurp_sniper.py index a028c8d..1ac36a3 100644 --- a/src/core/sniper/slurp_sniper.py +++ b/src/usta/core/sniper/slurp_sniper.py @@ -2,8 +2,8 @@ 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.core.sniper.base_sniper import BaseSniper +from usta.config import SLURP_TEMP_PATH class SlurpSniper(BaseSniper): def get_region(self) -> QRect | None: diff --git a/src/core/sniper/sniper_factory.py b/src/usta/core/sniper/sniper_factory.py similarity index 63% rename from src/core/sniper/sniper_factory.py rename to src/usta/core/sniper/sniper_factory.py index f0cbf83..2b168d0 100644 --- a/src/core/sniper/sniper_factory.py +++ b/src/usta/core/sniper/sniper_factory.py @@ -1,6 +1,6 @@ -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.core.sniper.base_sniper import BaseSniper +from usta.core.sniper.slurp_sniper import SlurpSniper +from usta.core.sniper.core_sniper import CoreSniper class SniperFactory: @staticmethod diff --git a/src/core/socket_publisher.py b/src/usta/core/socket_publisher.py similarity index 99% rename from src/core/socket_publisher.py rename to src/usta/core/socket_publisher.py index e333e45..21a1c18 100644 --- a/src/core/socket_publisher.py +++ b/src/usta/core/socket_publisher.py @@ -3,7 +3,7 @@ import socket import threading import time -from src.config import SOCKET_PATH +from usta.config import SOCKET_PATH class TranslationPublisher: 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 100% rename from src/core/translation/base_translator.py rename to src/usta/core/translation/base_translator.py diff --git a/src/core/translation/deepl_engine.py b/src/usta/core/translation/deepl_engine.py similarity index 93% rename from src/core/translation/deepl_engine.py rename to src/usta/core/translation/deepl_engine.py index 0c04467..16b30f2 100644 --- a/src/core/translation/deepl_engine.py +++ b/src/usta/core/translation/deepl_engine.py @@ -1,7 +1,7 @@ import os import deepl -from src.config import get_translation_language_code -from src.core.translation.base_translator import BaseTranslator +from usta.config import get_translation_language_code +from usta.core.translation.base_translator import BaseTranslator class DeepLTranslatorEngine(BaseTranslator): diff --git a/src/core/translation/google_engine.py b/src/usta/core/translation/google_engine.py similarity index 88% rename from src/core/translation/google_engine.py rename to src/usta/core/translation/google_engine.py index 06a8493..ffc9c24 100644 --- a/src/core/translation/google_engine.py +++ b/src/usta/core/translation/google_engine.py @@ -1,7 +1,7 @@ import asyncio from googletrans import Translator -from src.config import get_translation_language_code -from src.core.translation.base_translator import BaseTranslator +from usta.config import get_translation_language_code +from usta.core.translation.base_translator import BaseTranslator class GoogleEngine(BaseTranslator): diff --git a/src/core/translation/translator_manager.py b/src/usta/core/translation/translator_manager.py similarity index 89% rename from src/core/translation/translator_manager.py rename to src/usta/core/translation/translator_manager.py index 0318474..1001bfb 100644 --- a/src/core/translation/translator_manager.py +++ b/src/usta/core/translation/translator_manager.py @@ -1,5 +1,5 @@ -from src.core.translation.google_engine import GoogleEngine -from src.core.translation.deepl_engine import DeepLTranslatorEngine +from usta.core.translation.google_engine import GoogleEngine +from usta.core.translation.deepl_engine import DeepLTranslatorEngine class TranslatorManager: def __init__(self): diff --git a/src/core/worker.py b/src/usta/core/worker.py similarity index 94% rename from src/core/worker.py rename to src/usta/core/worker.py index 305f37c..31b0d9c 100644 --- a/src/core/worker.py +++ b/src/usta/core/worker.py @@ -2,12 +2,12 @@ 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 +from usta.core.ocr.ocr_manager import OCRManager +from usta.core.translation.translator_manager import TranslatorManager +from usta.core.exceptions import PortalCanceledError +from usta.core.screenshot import ScreenshotFactory, ImageProcessor +from usta.core.socket_publisher import TranslationPublisher +from usta.config import IMG_PATH, DPI_SCALE_DEFAULT class OCRWorker(QThread): LOOP_SLEEP_SECONDS = 0.2 diff --git a/src/i18n.py b/src/usta/i18n.py similarity index 96% rename from src/i18n.py rename to src/usta/i18n.py index a66f04b..8eaa0df 100644 --- a/src/i18n.py +++ b/src/usta/i18n.py @@ -1,3 +1,5 @@ +from usta.resource_paths import resource_path + import gettext import os import locale @@ -81,8 +83,7 @@ def init_i18n(locale_dir=None, domain="messages"): 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() diff --git a/src/main.py b/src/usta/main.py similarity index 65% rename from src/main.py rename to src/usta/main.py index 6a5c47f..19485c4 100644 --- a/src/main.py +++ b/src/usta/main.py @@ -1,16 +1,11 @@ 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.config import OS -from src.ui.main_window import ControlPanel -from src.i18n import init_i18n +from usta.config import OS +from usta.ui.main_window import ControlPanel +from usta.i18n import init_i18n def main(): # Initialize internationalization diff --git a/src/usta/resource_paths.py b/src/usta/resource_paths.py new file mode 100644 index 0000000..bd1abff --- /dev/null +++ b/src/usta/resource_paths.py @@ -0,0 +1,14 @@ +"""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/translations/tr/LC_MESSAGES/messages.mo b/src/usta/resources/translations/tr/LC_MESSAGES/messages.mo similarity index 100% rename from src/translations/tr/LC_MESSAGES/messages.mo rename to src/usta/resources/translations/tr/LC_MESSAGES/messages.mo diff --git a/src/translations/tr/LC_MESSAGES/messages.po b/src/usta/resources/translations/tr/LC_MESSAGES/messages.po similarity index 100% rename from src/translations/tr/LC_MESSAGES/messages.po rename to src/usta/resources/translations/tr/LC_MESSAGES/messages.po diff --git a/src/translations/uk/LC_MESSAGES/messages.mo b/src/usta/resources/translations/uk/LC_MESSAGES/messages.mo similarity index 100% rename from src/translations/uk/LC_MESSAGES/messages.mo rename to src/usta/resources/translations/uk/LC_MESSAGES/messages.mo diff --git a/src/translations/uk/LC_MESSAGES/messages.po b/src/usta/resources/translations/uk/LC_MESSAGES/messages.po similarity index 100% rename from src/translations/uk/LC_MESSAGES/messages.po rename to src/usta/resources/translations/uk/LC_MESSAGES/messages.po 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/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/ui/macos_hotkeys.py b/src/usta/ui/macos_hotkeys.py similarity index 98% rename from src/ui/macos_hotkeys.py rename to src/usta/ui/macos_hotkeys.py index 7e5c05a..af86731 100644 --- a/src/ui/macos_hotkeys.py +++ b/src/usta/ui/macos_hotkeys.py @@ -2,7 +2,7 @@ import logging import os -from src.ui.macos_overlay import MacOSOverlayRules +from usta.ui.macos_overlay import MacOSOverlayRules logger = logging.getLogger(__name__) diff --git a/src/ui/macos_overlay.py b/src/usta/ui/macos_overlay.py similarity index 100% rename from src/ui/macos_overlay.py rename to src/usta/ui/macos_overlay.py diff --git a/src/ui/main_window.py b/src/usta/ui/main_window.py similarity index 98% rename from src/ui/main_window.py rename to src/usta/ui/main_window.py index c87ac72..764692a 100644 --- a/src/ui/main_window.py +++ b/src/usta/ui/main_window.py @@ -4,13 +4,13 @@ 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 OS, 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 ( +from usta.core.worker import OCRWorker +from usta.ui.overlay_window import TransparentOverlay +from usta.core.sniper import SniperFactory +from usta.config import OS, LANGUAGES, SETTINGS_FILE, PRESETS_FILE, DPI_SCALE_DEFAULT, SETTINGS_TOPMOST_HOTKEY, TEMPORARY_REGION_HOTKEY, get_language_code +from usta.core.shortcut import GlobalHotkey +from usta.i18n import _ +from usta.ui.tabs import ( build_about_tab, build_appearance_tab, build_ocr_tab, @@ -54,7 +54,7 @@ def __init__(self): self.setWindowFlag(Qt.WindowMaximizeButtonHint, False) self._macos_hotkey_window = None if OS == "Darwin": - from src.ui.macos_hotkeys import MacOSHotkeyWindow + from usta.ui.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) diff --git a/src/ui/overlay_window.py b/src/usta/ui/overlay_window.py similarity index 98% rename from src/ui/overlay_window.py rename to src/usta/ui/overlay_window.py index 360a5d4..5cc7e35 100644 --- a/src/ui/overlay_window.py +++ b/src/usta/ui/overlay_window.py @@ -1,9 +1,9 @@ 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 OS, SETTINGS_TOPMOST_HOTKEY -from src.core.shortcut import GlobalHotkey -from src.i18n import _ +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): @@ -93,7 +93,7 @@ def __init__(self): self.setAttribute(Qt.WA_ShowWithoutActivating) self._macos_rules = None if OS == "Darwin": - from src.ui.macos_overlay import MacOSOverlayRules + from usta.ui.macos_overlay import MacOSOverlayRules self.setAttribute(Qt.WA_MacAlwaysShowToolWindow, True) self._macos_rules = MacOSOverlayRules(self) self.setMouseTracking(True) diff --git a/src/usta/ui/tabs/__init__.py b/src/usta/ui/tabs/__init__.py new file mode 100644 index 0000000..4991886 --- /dev/null +++ b/src/usta/ui/tabs/__init__.py @@ -0,0 +1,19 @@ +"""Control panel tab builders.""" + +from usta.ui.tabs.about_tab import build_about_tab +from usta.ui.tabs.appearance_tab import build_appearance_tab +from usta.ui.tabs.ocr_tab import build_ocr_tab +from usta.ui.tabs.save_tab import build_save_tab +from usta.ui.tabs.hotkey_tab import build_hotkey_tab +from usta.ui.tabs.status_tab import build_status_tab +from usta.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/usta/ui/tabs/about_tab.py similarity index 92% rename from src/ui/tabs/about_tab.py rename to src/usta/ui/tabs/about_tab.py index 750661a..59144c2 100644 --- a/src/ui/tabs/about_tab.py +++ b/src/usta/ui/tabs/about_tab.py @@ -1,7 +1,7 @@ from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget -from src.config import APP_VERSION -from src.i18n import _ +from usta.config import APP_VERSION +from usta.i18n import _ def build_about_tab(panel): diff --git a/src/ui/tabs/appearance_tab.py b/src/usta/ui/tabs/appearance_tab.py similarity index 98% rename from src/ui/tabs/appearance_tab.py rename to src/usta/ui/tabs/appearance_tab.py index ff67549..7ef6f44 100644 --- a/src/ui/tabs/appearance_tab.py +++ b/src/usta/ui/tabs/appearance_tab.py @@ -1,6 +1,6 @@ from PySide6.QtWidgets import QFontComboBox, QHBoxLayout, QLabel, QPushButton, QSpinBox, QVBoxLayout, QWidget -from src.i18n import _ +from usta.i18n import _ def build_appearance_tab(panel): diff --git a/src/ui/tabs/hotkey_tab.py b/src/usta/ui/tabs/hotkey_tab.py similarity index 99% rename from src/ui/tabs/hotkey_tab.py rename to src/usta/ui/tabs/hotkey_tab.py index e058abf..e464cd1 100644 --- a/src/ui/tabs/hotkey_tab.py +++ b/src/usta/ui/tabs/hotkey_tab.py @@ -2,7 +2,7 @@ from PySide6.QtGui import QKeySequence from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget -from src.i18n import _ +from usta.i18n import _ HOTKEY_BUTTON_WIDTH = 120 diff --git a/src/ui/tabs/ocr_tab.py b/src/usta/ui/tabs/ocr_tab.py similarity index 95% rename from src/ui/tabs/ocr_tab.py rename to src/usta/ui/tabs/ocr_tab.py index b6af4ca..9f7b57e 100644 --- a/src/ui/tabs/ocr_tab.py +++ b/src/usta/ui/tabs/ocr_tab.py @@ -1,8 +1,8 @@ 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 _ +from usta.config import DPI_SCALE_DEFAULT, OCR_ENGINES, SCREENSHOT_ENGINES +from usta.i18n import _ def build_ocr_tab(panel): diff --git a/src/ui/tabs/save_tab.py b/src/usta/ui/tabs/save_tab.py similarity index 98% rename from src/ui/tabs/save_tab.py rename to src/usta/ui/tabs/save_tab.py index 7134762..443edca 100644 --- a/src/ui/tabs/save_tab.py +++ b/src/usta/ui/tabs/save_tab.py @@ -1,7 +1,7 @@ from PySide6.QtCore import Qt from PySide6.QtWidgets import QComboBox, QHBoxLayout, QLabel, QLineEdit, QPushButton, QVBoxLayout, QWidget -from src.i18n import _ +from usta.i18n import _ def build_save_tab(panel): diff --git a/src/ui/tabs/status_tab.py b/src/usta/ui/tabs/status_tab.py similarity index 97% rename from src/ui/tabs/status_tab.py rename to src/usta/ui/tabs/status_tab.py index b396178..4c6e6dd 100644 --- a/src/ui/tabs/status_tab.py +++ b/src/usta/ui/tabs/status_tab.py @@ -1,6 +1,6 @@ from PySide6.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget -from src.i18n import _ +from usta.i18n import _ def build_status_tab(panel): diff --git a/src/ui/tabs/translation_tab.py b/src/usta/ui/tabs/translation_tab.py similarity index 96% rename from src/ui/tabs/translation_tab.py rename to src/usta/ui/tabs/translation_tab.py index 2f28221..0463588 100644 --- a/src/ui/tabs/translation_tab.py +++ b/src/usta/ui/tabs/translation_tab.py @@ -1,8 +1,8 @@ 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 _ +from usta.config import LANGUAGES, TRANSLATION_ENGINES +from usta.i18n import _ def build_translation_tab(panel): diff --git a/tests/integration/test_linux_windows.py b/tests/integration/test_linux_windows.py index 6be150e..ea70e35 100644 --- a/tests/integration/test_linux_windows.py +++ b/tests/integration/test_linux_windows.py @@ -4,7 +4,7 @@ from unittest import mock from PySide6.QtCore import Qt, QRect from PySide6.QtWidgets import QApplication -from src.core.sniper.core_sniper import OverlayWindow, SelectionManager +from usta.core.sniper.core_sniper import OverlayWindow, SelectionManager from tests.native.test_macos_hotkey_actions import pump class LinuxWindowContracts(unittest.TestCase): @@ -15,18 +15,18 @@ def setUpClass(cls): def make_control_panel(self, os_name): from contextlib import ExitStack - from src.ui.main_window import ControlPanel + from usta.ui.main_window import ControlPanel stack = ExitStack() self.addCleanup(stack.close) - stack.enter_context(mock.patch('src.ui.main_window.OS', os_name)) - stack.enter_context(mock.patch('src.ui.overlay_window.OS', os_name)) - stack.enter_context(mock.patch('src.core.shortcut.GlobalHotkey.start', return_value=False)) + 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('src.ui.main_window.OCRWorker')).return_value + worker = stack.enter_context(mock.patch('usta.ui.main_window.OCRWorker')).return_value 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)) @@ -59,8 +59,8 @@ def test_linux_settings_action_keeps_original_window_behavior(self): def test_linux_region_selector_keeps_x11_flags_without_native_imports(self): - with mock.patch('src.core.sniper.core_sniper.sys.platform', 'linux'), \ - mock.patch.dict(sys.modules, {'src.ui.macos_hotkeys': None}): + with mock.patch('usta.core.sniper.core_sniper.sys.platform', 'linux'), \ + mock.patch.dict(sys.modules, {'usta.ui.macos_hotkeys': None}): manager = SelectionManager() window = OverlayWindow(self.app.primaryScreen(), manager) self.assertTrue(window.windowFlags() & Qt.X11BypassWindowManagerHint) diff --git a/tests/native/test_macos_hotkey_actions.py b/tests/native/test_macos_hotkey_actions.py index d565b62..05105ad 100644 --- a/tests/native/test_macos_hotkey_actions.py +++ b/tests/native/test_macos_hotkey_actions.py @@ -15,8 +15,8 @@ from PySide6.QtTest import QTest from PySide6.QtWidgets import QApplication, QWidget -from src.core.sniper.core_sniper import CoreSniper, OverlayWindow, SelectionManager -from src.ui.macos_hotkeys import MacOSHotkeyWindow +from usta.core.sniper.core_sniper import CoreSniper, OverlayWindow, SelectionManager +from usta.ui.macos_hotkeys import MacOSHotkeyWindow def pump(milliseconds=40): @@ -38,18 +38,18 @@ def setUpClass(cls): def make_control_panel(self, os_name): from contextlib import ExitStack - from src.ui.main_window import ControlPanel + from usta.ui.main_window import ControlPanel stack = ExitStack() self.addCleanup(stack.close) - stack.enter_context(mock.patch('src.ui.main_window.OS', os_name)) - stack.enter_context(mock.patch('src.ui.overlay_window.OS', os_name)) - stack.enter_context(mock.patch('src.core.shortcut.GlobalHotkey.start', return_value=False)) + 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('src.ui.main_window.OCRWorker')).return_value + worker = stack.enter_context(mock.patch('usta.ui.main_window.OCRWorker')).return_value 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)) @@ -80,8 +80,8 @@ def test_linux_settings_action_keeps_original_window_behavior(self): quit_app.assert_not_called() def test_linux_region_selector_keeps_x11_flags_without_native_imports(self): - with mock.patch('src.core.sniper.core_sniper.sys.platform', 'linux'), \ - mock.patch.dict(sys.modules, {'src.ui.macos_hotkeys': None}): + with mock.patch('usta.core.sniper.core_sniper.sys.platform', 'linux'), \ + mock.patch.dict(sys.modules, {'usta.ui.macos_hotkeys': None}): manager = SelectionManager() window = OverlayWindow(self.app.primaryScreen(), manager) self.assertTrue(window.windowFlags() & Qt.X11BypassWindowManagerHint) diff --git a/tests/native/test_macos_overlay.py b/tests/native/test_macos_overlay.py index 2070415..8f6348e 100644 --- a/tests/native/test_macos_overlay.py +++ b/tests/native/test_macos_overlay.py @@ -12,7 +12,7 @@ from PySide6.QtCore import QEvent, QEventLoop, QTimer, Qt from PySide6.QtWidgets import QApplication, QWidget -from src.ui.macos_overlay import MacOSOverlayRules +from usta.ui.macos_overlay import MacOSOverlayRules def wait_for_events(milliseconds=30): @@ -75,13 +75,13 @@ def test_sets_fullscreen_and_all_applications_rules(self): def test_overlay_remains_on_screen_during_another_apps_native_fullscreen(self): import Quartz - from src.ui.overlay_window import TransparentOverlay + 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('src.ui.overlay_window.GlobalHotkey', autospec=True): + with mock.patch('usta.ui.overlay_window.GlobalHotkey', autospec=True): self.widget = TransparentOverlay() self.widget.move(150, 150) self.widget.show() diff --git a/tests/packaging/test_artifacts.py b/tests/packaging/test_artifacts.py new file mode 100644 index 0000000..0b65970 --- /dev/null +++ b/tests/packaging/test_artifacts.py @@ -0,0 +1,16 @@ +import unittest +from pathlib import Path +import importlib.util + +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): + import tomllib + data = tomllib.loads((ROOT / 'pyproject.toml').read_text()) + self.assertIn('resources/ocr_models/rapidocr/*.onnx', data['tool']['setuptools']['package-data']['usta']) diff --git a/tests/unit/test_apple_vision_engine.py b/tests/unit/test_apple_vision_engine.py index 03ae017..803e29c 100644 --- a/tests/unit/test_apple_vision_engine.py +++ b/tests/unit/test_apple_vision_engine.py @@ -9,16 +9,16 @@ from PIL import Image, ImageDraw, ImageFont -from src.core.ocr.apple_vision_engine import AppleVisionEngine -from src.core.ocr.ocr_manager import OCRManager -from src.core.platform.capability_resolver import resolve_capabilities +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('src.core.ocr.apple_vision_engine', level='ERROR') as logs: + 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) @@ -27,7 +27,7 @@ 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('src.core.ocr.ocr_manager.sys.platform', 'linux'): + 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') @@ -82,14 +82,14 @@ def test_blank_image_returns_empty_text(self): self.assertEqual(AppleVisionEngine().read_text(str(path)), '') def test_invalid_image_returns_empty_text_without_crashing(self): - with self.assertLogs('src.core.ocr.apple_vision_engine', level='ERROR'): + 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('src.core.ocr.apple_vision_engine', level='WARNING') as logs: + 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) diff --git a/tests/unit/test_easyocr_engine.py b/tests/unit/test_easyocr_engine.py index 3a3d1d7..3feaeff 100644 --- a/tests/unit/test_easyocr_engine.py +++ b/tests/unit/test_easyocr_engine.py @@ -1,7 +1,7 @@ import unittest import warnings -from src.core.ocr.easyocr_engine import EasyOCREngine +from usta.core.ocr.easyocr_engine import EasyOCREngine PIN_MEMORY_WARNING = ( diff --git a/tests/unit/test_i18n.py b/tests/unit/test_i18n.py index e2fca50..0304d19 100644 --- a/tests/unit/test_i18n.py +++ b/tests/unit/test_i18n.py @@ -2,12 +2,12 @@ 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.dirname(os.path.abspath(__file__)))), - "src", + "src", "usta", "resources", "translations", ) diff --git a/tests/unit/test_shortcut.py b/tests/unit/test_shortcut.py index d596cd7..20e915e 100644 --- a/tests/unit/test_shortcut.py +++ b/tests/unit/test_shortcut.py @@ -5,7 +5,7 @@ import unittest from unittest import mock -from src.core.shortcut import GlobalHotkey, keyboard +from usta.core.shortcut import GlobalHotkey, keyboard class PlatformHotkeyTests(unittest.TestCase): @@ -14,10 +14,10 @@ def test_linux_and_windows_keep_the_existing_pynput_listener(self): with self.subTest(platform=platform): backend = mock.Mock() backend.HotKey.parse.return_value = {'ctrl', 'x'} - with mock.patch('src.core.shortcut.sys.platform', platform), \ - mock.patch('src.core.shortcut.keyboard', backend), \ + with mock.patch('usta.core.shortcut.sys.platform', platform), \ + mock.patch('usta.core.shortcut.keyboard', backend), \ mock.patch.dict(sys.modules, { - 'src.core.platform.macos.hotkey_listener': None, + 'usta.core.platform.macos.hotkey_listener': None, }): hotkey = GlobalHotkey('+x', lambda: None) self.assertTrue(hotkey.start()) @@ -66,7 +66,7 @@ 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('src.core.shortcut', level='ERROR'): + with self.assertLogs('usta.core.shortcut', level='ERROR'): thread = threading.Thread(target=lambda: result.append(hotkey.start())) thread.start() thread.join(timeout=2) From 92747a62d7e6652932fdeed6bf54b03d1ae83e5f Mon Sep 17 00:00:00 2001 From: sedataym Date: Fri, 23 Oct 2026 15:18:01 +0300 Subject: [PATCH 07/24] refactor: migrate settings and presets to atomic versioned storage --- docs/development/cleanup-progress.md | 3 + src/usta/application/__init__.py | 0 src/usta/application/settings.py | 61 ++++++++ src/usta/infrastructure/__init__.py | 0 src/usta/infrastructure/legacy_settings.py | 20 +++ src/usta/infrastructure/settings_store.py | 66 ++++++++ src/usta/ui/main_window.py | 174 ++++++++++----------- tests/unit/test_settings_store.py | 64 ++++++++ 8 files changed, 301 insertions(+), 87 deletions(-) create mode 100644 src/usta/application/__init__.py create mode 100644 src/usta/application/settings.py create mode 100644 src/usta/infrastructure/__init__.py create mode 100644 src/usta/infrastructure/legacy_settings.py create mode 100644 src/usta/infrastructure/settings_store.py create mode 100644 tests/unit/test_settings_store.py diff --git a/docs/development/cleanup-progress.md b/docs/development/cleanup-progress.md index 1e3ff85..c15ca59 100644 --- a/docs/development/cleanup-progress.md +++ b/docs/development/cleanup-progress.md @@ -19,3 +19,6 @@ Task 1: portable baseline 33 tests, 21 native skips, 0 failures. Initial unrestr 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. diff --git a/src/usta/application/__init__.py b/src/usta/application/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/usta/application/settings.py b/src/usta/application/settings.py new file mode 100644 index 0000000..4e71110 --- /dev/null +++ b/src/usta/application/settings.py @@ -0,0 +1,61 @@ +"""Persistent values, independent of Qt and filesystem access.""" +from dataclasses import asdict, dataclass, field, fields +import math +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/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..ac41fe2 --- /dev/null +++ b/src/usta/infrastructure/legacy_settings.py @@ -0,0 +1,20 @@ +"""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/settings_store.py b/src/usta/infrastructure/settings_store.py new file mode 100644 index 0000000..e920ddb --- /dev/null +++ b/src/usta/infrastructure/settings_store.py @@ -0,0 +1,66 @@ +"""Versioned, atomic settings storage with non-destructive legacy migration.""" +import json +import os +import tempfile +from pathlib import Path +from usta.application.settings import AppSettings, Preset +from usta.infrastructure.legacy_settings import load_legacy + +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(): + self._read(name) + 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 = {name: Preset.from_dict(value) for name, value in self._read('presets').items()} + if 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/ui/main_window.py b/src/usta/ui/main_window.py index 764692a..53ba14b 100644 --- a/src/usta/ui/main_window.py +++ b/src/usta/ui/main_window.py @@ -1,8 +1,10 @@ import time -import pickle +from pathlib import Path +from usta.application.settings import AppSettings, Preset +from usta.infrastructure.settings_store import SettingsStore import os -from PySide6.QtCore import Qt, QRect, QPoint, QTimer, Signal, Slot -from PySide6.QtGui import QIcon +from PySide6.QtCore import Qt, QRect, QPoint, QTimer, Signal, Slot, QSignalBlocker +from PySide6.QtGui import QIcon, QFont from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton, QApplication, QColorDialog, QProgressBar, QTabWidget, QMessageBox from usta.core.worker import OCRWorker from usta.ui.overlay_window import TransparentOverlay @@ -25,6 +27,8 @@ class ControlPanel(QWidget): def __init__(self): super().__init__() + self._loading_settings = True + self.settings_store = SettingsStore(Path(SETTINGS_FILE).parent) self.worker = OCRWorker() self.overlay = TransparentOverlay() self._main_capture_rect = QRect(self.worker.capture_rect) @@ -198,6 +202,8 @@ def on_bg_opacity_changed(self, opacity): self.save_settings() def save_settings(self): + if self._loading_settings: + return settings = { "ocr_engine": self.combo_ocr.currentText(), "translator_engine": self.combo_translator.currentText(), @@ -217,81 +223,80 @@ def save_settings(self): "temporary_region_hotkey": self.temporary_region_hotkey, } try: - with open(SETTINGS_FILE, "wb") as f: - pickle.dump(settings, f) + self.settings_store.save(AppSettings.from_dict(settings)) 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() + self._loading_settings = True + blockers = [QSignalBlocker(widget) for widget in self.findChildren(QWidget)] + try: + s = self.settings_store.load().to_dict() + # 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(QFont(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.setValue(dpi_scale * 100) + + 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.worker.set_engine(self.combo_ocr.currentText()) + self.worker.set_translator(self.combo_translator.currentText()) + self.update_languages() + except (OSError, ValueError) as exc: + print(f"Settings load error: {exc}") + self._apply_saved_hotkeys(SETTINGS_TOPMOST_HOTKEY, TEMPORARY_REGION_HOTKEY) + self.update_languages() + finally: + del blockers + self._loading_settings = False def _apply_saved_hotkeys(self, settings_topmost_hotkey, temporary_region_hotkey): self._set_settings_topmost_hotkey(settings_topmost_hotkey, save=False, show_error=False) @@ -569,22 +574,17 @@ def on_api_key_changed(self, text: str): # ---------- 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 {} + try: + return {name: preset.to_dict() for name, preset in self.settings_store.load_presets().items()} + except (OSError, ValueError) as exc: + print(f"Presets load error: {exc}") + 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}") + self.settings_store.save_presets({name: Preset.from_dict(value) for name, value in presets.items()}) + except (OSError, ValueError) as exc: + print(f"Presets save error: {exc}") def refresh_preset_list(self): """Refresh the combo box with preset names.""" @@ -657,7 +657,7 @@ def load_preset(self): ff = preset.get("font_family") if ff: self.overlay.set_font_family(ff) - self.font_picker.setCurrentFont(ff) + self.font_picker.setCurrentFont(QFont(ff)) fs = preset.get("font_size") if fs: diff --git a/tests/unit/test_settings_store.py b/tests/unit/test_settings_store.py new file mode 100644 index 0000000..02e36cb --- /dev/null +++ b/tests/unit/test_settings_store.py @@ -0,0 +1,64 @@ +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)) From e6ce41342540e1aaeb56045ac13a20ed18363361 Mon Sep 17 00:00:00 2001 From: sedataym Date: Fri, 23 Oct 2026 15:18:56 +0300 Subject: [PATCH 08/24] refactor: make bootstrap and logging explicit --- docs/development/cleanup-progress.md | 2 ++ src/usta/config.py | 4 --- src/usta/core/ocr/easyocr_engine.py | 9 +++-- src/usta/core/ocr/paddleocr_engine.py | 15 ++++---- src/usta/core/ocr/rapidocr_engine.py | 21 ++++++----- src/usta/core/ocr/tesseract_engine.py | 7 ++-- src/usta/core/platform/__init__.py | 36 +------------------ src/usta/core/screenshot/macos_screenshot.py | 5 ++- src/usta/core/screenshot/portal_screenshot.py | 23 ++++++------ .../core/screenshot/screenshot_factory.py | 9 +++-- .../core/screenshot/spectacle_screenshot.py | 5 ++- src/usta/core/sniper/slurp_sniper.py | 5 ++- src/usta/core/socket_publisher.py | 11 +++--- src/usta/core/translation/deepl_engine.py | 7 ++-- src/usta/core/translation/google_engine.py | 5 ++- src/usta/core/worker.py | 25 +++++++------ src/usta/i18n.py | 5 ++- src/usta/infrastructure/logging_setup.py | 5 +++ src/usta/infrastructure/paths.py | 18 ++++++++++ src/usta/main.py | 5 +++ src/usta/ui/macos_overlay.py | 9 +++-- src/usta/ui/main_window.py | 19 +++++----- tests/unit/test_bootstrap.py | 13 +++++++ 23 files changed, 158 insertions(+), 105 deletions(-) create mode 100644 src/usta/infrastructure/logging_setup.py create mode 100644 src/usta/infrastructure/paths.py create mode 100644 tests/unit/test_bootstrap.py diff --git a/docs/development/cleanup-progress.md b/docs/development/cleanup-progress.md index c15ca59..87bf687 100644 --- a/docs/development/cleanup-progress.md +++ b/docs/development/cleanup-progress.md @@ -22,3 +22,5 @@ Task 3: package layout tests failed before move and pass after; 36 portable test 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. diff --git a/src/usta/config.py b/src/usta/config.py index f15bce9..fa330e7 100644 --- a/src/usta/config.py +++ b/src/usta/config.py @@ -45,15 +45,11 @@ 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() -print(TEMP_DIR) 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") diff --git a/src/usta/core/ocr/easyocr_engine.py b/src/usta/core/ocr/easyocr_engine.py index d8117a2..41e5e42 100644 --- a/src/usta/core/ocr/easyocr_engine.py +++ b/src/usta/core/ocr/easyocr_engine.py @@ -1,8 +1,11 @@ +import logging import warnings from usta.core.ocr.base_ocr import BaseOCREngine from usta.config import OCR_LANG_MAPPING +logger = logging.getLogger(__name__) + class EasyOCREngine(BaseOCREngine): def __init__(self): self.reader = None @@ -18,12 +21,12 @@ def set_language(self, lang_code: str): 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}") + 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 - print(f"EasyOCREngine: Initializing reader with {self.current_langs}") + logger.info(f"EasyOCREngine: Initializing reader with {self.current_langs}") self.reader = easyocr.Reader(self.current_langs, gpu=True) try: @@ -41,5 +44,5 @@ def read_text(self, image_path: str) -> str: clean = " ".join([r[1] for r in res]) return " ".join(clean.split()).strip().strip("_").rstrip(":") except Exception as e: - print(f"EasyOCR Error: {e}") + logger.error(f"EasyOCR Error: {e}") return "" diff --git a/src/usta/core/ocr/paddleocr_engine.py b/src/usta/core/ocr/paddleocr_engine.py index f82dae5..70eb932 100644 --- a/src/usta/core/ocr/paddleocr_engine.py +++ b/src/usta/core/ocr/paddleocr_engine.py @@ -1,6 +1,9 @@ +import logging from usta.core.ocr.base_ocr import BaseOCREngine from usta.config import OCR_LANG_MAPPING +logger = logging.getLogger(__name__) + class PaddleOCREngine(BaseOCREngine): def __init__(self): @@ -16,13 +19,13 @@ 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, @@ -34,7 +37,7 @@ def read_text(self, image_path: str) -> str: "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 +46,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 +72,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_engine.py b/src/usta/core/ocr/rapidocr_engine.py index 90bd698..d478bf9 100644 --- a/src/usta/core/ocr/rapidocr_engine.py +++ b/src/usta/core/ocr/rapidocr_engine.py @@ -1,3 +1,4 @@ +import logging import glob import os @@ -6,6 +7,8 @@ from usta.resource_paths import resource_path +logger = logging.getLogger(__name__) + _MODELS_DIR = str(resource_path("resources/ocr_models/rapidocr")) @@ -33,10 +36,10 @@ 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): @@ -47,7 +50,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) @@ -99,12 +102,12 @@ def limited_init(session_self, config): ) if getattr(OrtInferSession.__init__, "_usta_cpu_limited", False): - print("RapidOCREngine: OrtInferSession already patched, skipping") + logger.info("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") + logger.info("RapidOCREngine: OrtInferSession monkey-patched for CPU thread limiting") def _initialize(self) -> bool: if self.ocr is not None: @@ -120,7 +123,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" @@ -141,11 +144,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): @@ -205,5 +208,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}") + logger.error(f"RapidOCR Error: {e}") return "" \ No newline at end of file diff --git a/src/usta/core/ocr/tesseract_engine.py b/src/usta/core/ocr/tesseract_engine.py index cdc8681..0d2beb4 100644 --- a/src/usta/core/ocr/tesseract_engine.py +++ b/src/usta/core/ocr/tesseract_engine.py @@ -1,8 +1,11 @@ +import logging import subprocess import re from usta.core.ocr.base_ocr import BaseOCREngine from usta.config import OCR_LANG_MAPPING +logger = logging.getLogger(__name__) + class TesseractEngine(BaseOCREngine): def __init__(self): self.lang = "eng" @@ -10,7 +13,7 @@ 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 = [ @@ -31,5 +34,5 @@ def read_text(self, image_path: str) -> str: 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 index 7b29bec..c500d54 100644 --- a/src/usta/core/platform/__init__.py +++ b/src/usta/core/platform/__init__.py @@ -1,35 +1 @@ -from usta.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 usta.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", -] +"""Platform helpers; import concrete modules explicitly to avoid startup cycles.""" diff --git a/src/usta/core/screenshot/macos_screenshot.py b/src/usta/core/screenshot/macos_screenshot.py index 73eb9b1..bd0b116 100644 --- a/src/usta/core/screenshot/macos_screenshot.py +++ b/src/usta/core/screenshot/macos_screenshot.py @@ -1,3 +1,4 @@ +import logging import sys from PIL import ImageGrab @@ -5,6 +6,8 @@ from usta.core.screenshot.base_screenshot import BaseScreenshot +logger = logging.getLogger(__name__) + class MacOSScreenshot(BaseScreenshot): @staticmethod @@ -30,5 +33,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"macOS ekran görüntüsü alınamadı: {e}") + logger.info(f"macOS ekran görüntüsü alınamadı: {e}") return False diff --git a/src/usta/core/screenshot/portal_screenshot.py b/src/usta/core/screenshot/portal_screenshot.py index f732af5..1d91336 100644 --- a/src/usta/core/screenshot/portal_screenshot.py +++ b/src/usta/core/screenshot/portal_screenshot.py @@ -1,3 +1,4 @@ +import logging import asyncio import os import secrets @@ -12,6 +13,8 @@ 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 from dbus_next.aio import MessageBus @@ -90,12 +93,12 @@ def capture(self, rect: QRect, output_path: str, dpi_scale: float = 1.0) -> bool 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() @@ -121,7 +124,7 @@ def close(self): try: self._loop.run_until_complete(self._close_portal_session()) except Exception as exc: - print(f"Portal Close Error: {exc}") + logger.error(f"Portal Close Error: {exc}") if self._bus is not None: try: @@ -243,9 +246,9 @@ async def _start_portal_session(self): 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() @@ -305,11 +308,11 @@ def _start_gstreamer_pipeline(self): 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 +349,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() @@ -407,7 +410,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 index ec27d93..624353a 100644 --- a/src/usta/core/screenshot/screenshot_factory.py +++ b/src/usta/core/screenshot/screenshot_factory.py @@ -1,8 +1,11 @@ +import logging import platform from usta.core.screenshot.base_screenshot import BaseScreenshot from usta.core.screenshot.spectacle_screenshot import SpectacleScreenshot +logger = logging.getLogger(__name__) + try: from usta.core.screenshot.macos_screenshot import MacOSScreenshot except Exception as exc: @@ -27,15 +30,15 @@ def get_engine(engine_name: str = "Portal") -> BaseScreenshot: return MacOSScreenshot() if _MACOS_IMPORT_ERROR is not None: - print(f"macOS ekran görüntüsü motoru kullanılamıyor: {_MACOS_IMPORT_ERROR}") + logger.error(f"macOS ekran görüntüsü motoru kullanılamıyor: {_MACOS_IMPORT_ERROR}") 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.") + logger.error(f"Portal screenshot engine unavailable: {_PORTAL_IMPORT_ERROR}") + logger.info("Falling back to Spectacle screenshot engine.") return SpectacleScreenshot() # Spectacle diff --git a/src/usta/core/screenshot/spectacle_screenshot.py b/src/usta/core/screenshot/spectacle_screenshot.py index f381cce..9d8d79f 100644 --- a/src/usta/core/screenshot/spectacle_screenshot.py +++ b/src/usta/core/screenshot/spectacle_screenshot.py @@ -1,3 +1,4 @@ +import logging import os import subprocess from PIL import Image @@ -5,6 +6,8 @@ from usta.core.screenshot.base_screenshot import BaseScreenshot from usta.config import FULL_SCREEN_TEMP_PATH +logger = logging.getLogger(__name__) + class SpectacleScreenshot(BaseScreenshot): def capture(self, rect: QRect, output_path: str, dpi_scale: float = 1.0) -> bool: if os.path.exists(FULL_SCREEN_TEMP_PATH): @@ -30,5 +33,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/usta/core/sniper/slurp_sniper.py b/src/usta/core/sniper/slurp_sniper.py index 1ac36a3..c2fa9ca 100644 --- a/src/usta/core/sniper/slurp_sniper.py +++ b/src/usta/core/sniper/slurp_sniper.py @@ -1,3 +1,4 @@ +import logging import os import time from PySide6.QtCore import QRect @@ -5,6 +6,8 @@ from usta.core.sniper.base_sniper import BaseSniper from usta.config import SLURP_TEMP_PATH +logger = logging.getLogger(__name__) + class SlurpSniper(BaseSniper): def get_region(self) -> QRect | None: """Selects a region on the screen using slurp.""" @@ -32,7 +35,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/usta/core/socket_publisher.py b/src/usta/core/socket_publisher.py index 21a1c18..10820b2 100644 --- a/src/usta/core/socket_publisher.py +++ b/src/usta/core/socket_publisher.py @@ -1,3 +1,4 @@ +import logging import os import json import socket @@ -5,6 +6,8 @@ import time from usta.config import SOCKET_PATH +logger = logging.getLogger(__name__) + class TranslationPublisher: """Unix domain socket server that broadcasts translation results to all connected clients. @@ -39,7 +42,7 @@ def start(self) -> None: self._running = True self._thread = threading.Thread(target=self._accept_loop, daemon=True) self._thread.start() - print(f"TranslationPublisher: Listening on {SOCKET_PATH}") + logger.info(f"TranslationPublisher: Listening on {SOCKET_PATH}") def stop(self) -> None: """Stop the server, close all client connections, and clean up the socket file.""" @@ -70,7 +73,7 @@ def stop(self) -> None: except OSError: pass - print("TranslationPublisher: Stopped.") + logger.info("TranslationPublisher: Stopped.") def broadcast(self, original: str, translated: str, source_lang: str = "en", target_lang: str = "tr", @@ -117,10 +120,10 @@ def _accept_loop(self) -> None: conn, _ = self._server_socket.accept() with self._lock: self._clients.append(conn) - print(f"TranslationPublisher: Client connected ({len(self._clients)} total)") + logger.info(f"TranslationPublisher: Client connected ({len(self._clients)} total)") except socket.timeout: continue except OSError: if self._running: - print("TranslationPublisher: Accept error, retrying…") + logger.error("TranslationPublisher: Accept error, retrying…") break \ No newline at end of file diff --git a/src/usta/core/translation/deepl_engine.py b/src/usta/core/translation/deepl_engine.py index 16b30f2..fd10816 100644 --- a/src/usta/core/translation/deepl_engine.py +++ b/src/usta/core/translation/deepl_engine.py @@ -1,8 +1,11 @@ +import logging import os import deepl 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'): @@ -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}") + logger.error(f"DeepL Initialization Error: {e}") return self._translator def set_api_key(self, api_key: str): @@ -47,5 +50,5 @@ def translate(self, text: str) -> str: return result.text return "Error: DeepL could not be initialized." except Exception as e: - print(f"DeepL Translation Error: {e}") + logger.error(f"DeepL Translation Error: {e}") return f"Error: {e}" diff --git a/src/usta/core/translation/google_engine.py b/src/usta/core/translation/google_engine.py index ffc9c24..2f52b7b 100644 --- a/src/usta/core/translation/google_engine.py +++ b/src/usta/core/translation/google_engine.py @@ -1,8 +1,11 @@ +import logging import asyncio from googletrans import Translator 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'): @@ -25,5 +28,5 @@ async def translate_text(): return asyncio.run(translate_text()) except Exception as e: - print(f"Google Translation Error: {e}") + logger.error(f"Google Translation Error: {e}") return f"Error: {e}" diff --git a/src/usta/core/worker.py b/src/usta/core/worker.py index 31b0d9c..ce9f597 100644 --- a/src/usta/core/worker.py +++ b/src/usta/core/worker.py @@ -1,3 +1,4 @@ +import logging import time import threading from threading import Lock @@ -9,6 +10,8 @@ from usta.core.socket_publisher import TranslationPublisher from usta.config import IMG_PATH, DPI_SCALE_DEFAULT +logger = logging.getLogger(__name__) + class OCRWorker(QThread): LOOP_SLEEP_SECONDS = 0.2 @@ -37,25 +40,25 @@ def set_rect(self, qrect, dpi_scale=None): 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}") + logger.info(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}") + logger.info(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}") + logger.info(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}") + logger.debug(f"OCRWorker: API key set for: {engine}") def set_screenshot_engine(self, engine_name): with self.lock: @@ -63,14 +66,14 @@ def set_screenshot_engine(self, engine_name): self.screenshot_engine.close() self.screenshot_engine = ScreenshotFactory.get_engine(engine_name) self.last_text = "" - print(f"OCRWorker: Screenshot engine: {engine_name}") + logger.info(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}") + logger.info(f"OCRWorker: Languages: {source} -> {target}") def stop(self): self.running = False @@ -97,7 +100,7 @@ def _async_translate(self, text): engine=self.translator_manager.current_translator_name, ) except Exception as e: - print(f"Translation Error: {e}") + logger.error(f"Translation Error: {e}") finally: with self._translation_lock: self._is_translating = False @@ -107,7 +110,7 @@ def run(self): self.publisher.start() self.running = True self.running_status.emit(True) - print(f"OCRWorker: LOOP STARTED.") + logger.info(f"OCRWorker: LOOP STARTED.") while self.running: try: start_total = time.perf_counter() @@ -136,18 +139,18 @@ def run(self): 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") + logger.debug(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.") + logger.info("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}") + logger.error(f"Loop Error: {e}") time.sleep(self.LOOP_SLEEP_SECONDS) \ No newline at end of file diff --git a/src/usta/i18n.py b/src/usta/i18n.py index 8eaa0df..ee39985 100644 --- a/src/usta/i18n.py +++ b/src/usta/i18n.py @@ -1,3 +1,4 @@ +import logging from usta.resource_paths import resource_path import gettext @@ -6,6 +7,8 @@ import unicodedata import warnings +logger = logging.getLogger(__name__) + # Global translator object _translator = None @@ -93,7 +96,7 @@ def init_i18n(locale_dir=None, domain="messages"): _translator = gettext.translation(domain, locale_dir, languages=languages, fallback=True) _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() diff --git a/src/usta/infrastructure/logging_setup.py b/src/usta/infrastructure/logging_setup.py new file mode 100644 index 0000000..beb2ae1 --- /dev/null +++ b/src/usta/infrastructure/logging_setup.py @@ -0,0 +1,5 @@ +"""Application-owned logging configuration.""" +import logging + +def configure_logging(level: str = 'INFO') -> None: + logging.basicConfig(level=level, format='%(levelname)s %(name)s: %(message)s') diff --git a/src/usta/infrastructure/paths.py b/src/usta/infrastructure/paths.py new file mode 100644 index 0000000..94d86f4 --- /dev/null +++ b/src/usta/infrastructure/paths.py @@ -0,0 +1,18 @@ +"""Runtime path policy; resolving paths does not create files.""" +from dataclasses import dataclass +from pathlib import Path +import tempfile +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) diff --git a/src/usta/main.py b/src/usta/main.py index 19485c4..3f8cab7 100644 --- a/src/usta/main.py +++ b/src/usta/main.py @@ -7,7 +7,12 @@ from usta.ui.main_window import ControlPanel from usta.i18n import init_i18n +from usta.infrastructure.paths import resolve_paths, prepare_paths +from usta.infrastructure.logging_setup import configure_logging + def main(): + configure_logging() + prepare_paths(resolve_paths()) # Initialize internationalization init_i18n() diff --git a/src/usta/ui/macos_overlay.py b/src/usta/ui/macos_overlay.py index 70d904b..cdbe3af 100644 --- a/src/usta/ui/macos_overlay.py +++ b/src/usta/ui/macos_overlay.py @@ -1,8 +1,11 @@ """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: @@ -57,7 +60,7 @@ def changed(_notification): self._schedule() except Exception as ex: _remove_observers(self._observers) - print(f"macOS overlay bildirimleri başlatılamadı: {ex}") + logger.info(f"macOS overlay bildirimleri başlatılamadı: {ex}") def stop(self): self._running = False @@ -88,7 +91,7 @@ def order_front_without_activation(self): # Qt.raise_ also activates NSApp, stealing fullscreen focus. window.orderFrontRegardless() except Exception as ex: - print(f"macOS overlay öne getirilemedi: {ex}") + logger.info(f"macOS overlay öne getirilemedi: {ex}") def prepare(self): """Configure a panel before ordering it into an existing fullscreen Space.""" @@ -123,4 +126,4 @@ def _apply(self, allow_hidden=False): window.setLevel_(AppKit.NSStatusWindowLevel) window.setHidesOnDeactivate_(False) except Exception as ex: - print(f"macOS pencere kuralları uygulanamadı: {ex}") + logger.info(f"macOS pencere kuralları uygulanamadı: {ex}") diff --git a/src/usta/ui/main_window.py b/src/usta/ui/main_window.py index 53ba14b..54b9eb0 100644 --- a/src/usta/ui/main_window.py +++ b/src/usta/ui/main_window.py @@ -1,3 +1,4 @@ +import logging import time from pathlib import Path from usta.application.settings import AppSettings, Preset @@ -22,6 +23,8 @@ build_translation_tab, ) +logger = logging.getLogger(__name__) + class ControlPanel(QWidget): temporary_region_hotkey_pressed = Signal() @@ -224,9 +227,9 @@ def save_settings(self): } try: self.settings_store.save(AppSettings.from_dict(settings)) - print("Settings saved.") + logger.info("Settings saved.") except Exception as e: - print(f"Settings save error: {e}") + logger.error(f"Settings save error: {e}") def load_settings(self): self._loading_settings = True @@ -291,7 +294,7 @@ def load_settings(self): self.worker.set_translator(self.combo_translator.currentText()) self.update_languages() except (OSError, ValueError) as exc: - print(f"Settings load error: {exc}") + logger.error(f"Settings load error: {exc}") self._apply_saved_hotkeys(SETTINGS_TOPMOST_HOTKEY, TEMPORARY_REGION_HOTKEY) self.update_languages() finally: @@ -577,14 +580,14 @@ def _load_presets_dict(self): try: return {name: preset.to_dict() for name, preset in self.settings_store.load_presets().items()} except (OSError, ValueError) as exc: - print(f"Presets load error: {exc}") + logger.error(f"Presets load error: {exc}") return {} def _save_presets_dict(self, presets): try: self.settings_store.save_presets({name: Preset.from_dict(value) for name, value in presets.items()}) except (OSError, ValueError) as exc: - print(f"Presets save error: {exc}") + logger.error(f"Presets save error: {exc}") def refresh_preset_list(self): """Refresh the combo box with preset names.""" @@ -623,7 +626,7 @@ def save_preset(self): self._save_presets_dict(presets) self.refresh_preset_list() self.preset_name_input.clear() - print(f"Preset '{name}' saved.") + logger.info(f"Preset '{name}' saved.") def load_preset(self): """Load the selected preset from the combo box and apply it.""" @@ -683,7 +686,7 @@ def load_preset(self): self.overlay.set_bg_opacity(bgo) self.bg_opacity_spin.setValue(bgo) - print(f"Preset '{name}' loaded.") + logger.info(f"Preset '{name}' loaded.") def delete_preset(self): """Delete the selected preset.""" @@ -696,6 +699,6 @@ def delete_preset(self): del presets[name] self._save_presets_dict(presets) self.refresh_preset_list() - print(f"Preset '{name}' deleted.") + logger.info(f"Preset '{name}' deleted.") else: QMessageBox.warning(self, "Not Found", f"Preset '{name}' not found.") diff --git a/tests/unit/test_bootstrap.py b/tests/unit/test_bootstrap.py new file mode 100644 index 0000000..30fef68 --- /dev/null +++ b/tests/unit/test_bootstrap.py @@ -0,0 +1,13 @@ +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, '') From ef9818f7108588f387cb37da801c8d13ec04b769 Mon Sep 17 00:00:00 2001 From: sedataym Date: Fri, 23 Oct 2026 15:20:30 +0300 Subject: [PATCH 09/24] refactor: unify engine contracts and isolate backend compatibility --- docs/development/cleanup-progress.md | 2 + src/usta/application/results.py | 29 ++++++++++ src/usta/core/ocr/base_ocr.py | 5 -- src/usta/core/ocr/ocr_manager.py | 32 ++++------ src/usta/core/ocr/rapidocr_compat.py | 58 +++++++++++++++++++ src/usta/core/ocr/rapidocr_engine.py | 52 +---------------- src/usta/core/platform/capabilities.py | 6 +- src/usta/core/registry.py | 39 +++++++++++++ src/usta/core/screenshot/base_screenshot.py | 3 + src/usta/core/translation/base_translator.py | 7 --- src/usta/core/translation/deepl_engine.py | 12 ++-- src/usta/core/translation/google_engine.py | 4 +- .../core/translation/translator_manager.py | 41 +++++++------ tests/unit/test_engine_contracts.py | 24 ++++++++ 14 files changed, 204 insertions(+), 110 deletions(-) create mode 100644 src/usta/application/results.py create mode 100644 src/usta/core/ocr/rapidocr_compat.py create mode 100644 src/usta/core/registry.py create mode 100644 tests/unit/test_engine_contracts.py diff --git a/docs/development/cleanup-progress.md b/docs/development/cleanup-progress.md index 87bf687..0e5064e 100644 --- a/docs/development/cleanup-progress.md +++ b/docs/development/cleanup-progress.md @@ -24,3 +24,5 @@ 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. diff --git a/src/usta/application/results.py b/src/usta/application/results.py new file mode 100644 index 0000000..3ab9d8e --- /dev/null +++ b/src/usta/application/results.py @@ -0,0 +1,29 @@ +"""Values crossing OCR/translation application boundaries.""" +from dataclasses import dataclass + +@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/core/ocr/base_ocr.py b/src/usta/core/ocr/base_ocr.py index d4a1a4a..3b06917 100644 --- a/src/usta/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/ocr_manager.py b/src/usta/core/ocr/ocr_manager.py index 435d57c..d48e169 100644 --- a/src/usta/core/ocr/ocr_manager.py +++ b/src/usta/core/ocr/ocr_manager.py @@ -1,33 +1,25 @@ import sys - -from usta.core.ocr.tesseract_engine import TesseractEngine -from usta.core.ocr.easyocr_engine import EasyOCREngine -from usta.core.ocr.paddleocr_engine import PaddleOCREngine -from usta.core.ocr.rapidocr_engine import RapidOCREngine -from usta.core.ocr.apple_vision_engine import AppleVisionEngine +from usta.core.registry import LazyEngines, OCR_FACTORIES +from usta.application.results import OCRResult class OCRManager: def __init__(self): - self.engines = { - "Tesseract": TesseractEngine(), - "EasyOCR": EasyOCREngine(), - "PaddleOCR": PaddleOCREngine(), - "RapidOCR": RapidOCREngine() - } - if sys.platform == "darwin": - self.engines["Apple Vision"] = AppleVisionEngine() - self.current_engine_name = "RapidOCR" + 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): - for engine in self.engines.values(): + self.language = lang_code + for engine in self.engines.instances.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 "" + 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/usta/core/ocr/rapidocr_compat.py b/src/usta/core/ocr/rapidocr_compat.py new file mode 100644 index 0000000..098d5c7 --- /dev/null +++ b/src/usta/core/ocr/rapidocr_compat.py @@ -0,0 +1,58 @@ +"""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/usta/core/ocr/rapidocr_engine.py b/src/usta/core/ocr/rapidocr_engine.py index d478bf9..4710749 100644 --- a/src/usta/core/ocr/rapidocr_engine.py +++ b/src/usta/core/ocr/rapidocr_engine.py @@ -58,56 +58,8 @@ 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, - ) - - 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") + from usta.core.ocr.rapidocr_compat import patch_ort_session + patch_ort_session(self.cpu_threads) def _initialize(self) -> bool: if self.ocr is not None: diff --git a/src/usta/core/platform/capabilities.py b/src/usta/core/platform/capabilities.py index 9270dea..5fb38a9 100644 --- a/src/usta/core/platform/capabilities.py +++ b/src/usta/core/platform/capabilities.py @@ -1,8 +1,10 @@ import os -OCR_ENGINES = ["RapidOCR","Tesseract", "EasyOCR"] # "PaddleOCR" (uncomment when paddlepaddle is available) -TRANSLATION_ENGINES = ["Google", "DeepL"] +from usta.core.registry import DEFAULT_OCR_NAMES, TRANSLATOR_FACTORIES + +OCR_ENGINES = list(DEFAULT_OCR_NAMES) +TRANSLATION_ENGINES = list(TRANSLATOR_FACTORIES) SCREENSHOT_ENGINES = ["Portal", "Spectacle"] LANGUAGES = { diff --git a/src/usta/core/registry.py b/src/usta/core/registry.py new file mode 100644 index 0000000..96f8c40 --- /dev/null +++ b/src/usta/core/registry.py @@ -0,0 +1,39 @@ +"""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/base_screenshot.py b/src/usta/core/screenshot/base_screenshot.py index 4c1076e..387a71b 100644 --- a/src/usta/core/screenshot/base_screenshot.py +++ b/src/usta/core/screenshot/base_screenshot.py @@ -6,3 +6,6 @@ class BaseScreenshot(ABC): 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/usta/core/translation/base_translator.py b/src/usta/core/translation/base_translator.py index b0b2a94..d526a14 100644 --- a/src/usta/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/usta/core/translation/deepl_engine.py b/src/usta/core/translation/deepl_engine.py index fd10816..c1eac92 100644 --- a/src/usta/core/translation/deepl_engine.py +++ b/src/usta/core/translation/deepl_engine.py @@ -1,6 +1,7 @@ import logging import os import deepl +from usta.application.results import EngineError from usta.config import get_translation_language_code from usta.core.translation.base_translator import BaseTranslator @@ -21,9 +22,9 @@ def __init__(self, api_key=None, source='en', target='tr'): def translator(self): if self._translator is None: try: - self._translator = deepl.Translator(self.api_key) + self._translator = deepl.Translator(self.api_key, timeout=10.0, max_network_retries=0) except Exception as e: - logger.error(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): @@ -36,7 +37,7 @@ 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: @@ -48,7 +49,6 @@ 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: - logger.error(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/usta/core/translation/google_engine.py b/src/usta/core/translation/google_engine.py index 2f52b7b..d73db2b 100644 --- a/src/usta/core/translation/google_engine.py +++ b/src/usta/core/translation/google_engine.py @@ -1,5 +1,6 @@ import logging import asyncio +from usta.application.results import EngineError from googletrans import Translator from usta.config import get_translation_language_code from usta.core.translation.base_translator import BaseTranslator @@ -28,5 +29,4 @@ async def translate_text(): return asyncio.run(translate_text()) except Exception as e: - logger.error(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 index 1001bfb..15b9973 100644 --- a/src/usta/core/translation/translator_manager.py +++ b/src/usta/core/translation/translator_manager.py @@ -1,36 +1,41 @@ -from usta.core.translation.google_engine import GoogleEngine -from usta.core.translation.deepl_engine import DeepLTranslatorEngine +from usta.core.registry import LazyEngines, TRANSLATOR_FACTORIES +from usta.application.results import TranslationRequest, TranslationResult class TranslatorManager: def __init__(self): - self.translators = { - "Google": GoogleEngine(), - "DeepL": DeepLTranslatorEngine() - } - self.current_translator_name = "Google" - self._source_lang = "en" - self._target_lang = "tr" + 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): - translator = self.translators.get(engine) - if translator and hasattr(translator, "set_api_key"): + 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 = source - self._target_lang = target - for translator in self.translators.values(): + 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: - translator = self.translators.get(self.current_translator_name) - if translator: - return translator.translate(text) - return text + 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/tests/unit/test_engine_contracts.py b/tests/unit/test_engine_contracts.py new file mode 100644 index 0000000..c93d7a1 --- /dev/null +++ b/tests/unit/test_engine_contracts.py @@ -0,0 +1,24 @@ +import unittest +from unittest.mock import patch, Mock +from usta.core.translation.deepl_engine import DeepLTranslatorEngine +from usta.core.ocr.ocr_manager import OCRManager +from usta.application.results import EngineError + +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() From 32949f37000ea7686e90fa9912cd5a57f5e7c8db Mon Sep 17 00:00:00 2001 From: sedataym Date: Fri, 23 Oct 2026 15:22:59 +0300 Subject: [PATCH 10/24] fix: own pipeline jobs and suppress stale results without blocking UI --- docs/development/cleanup-progress.md | 2 + src/usta/application/pipeline.py | 151 +++++++++++++++++ src/usta/application/worker.py | 88 ++++++++++ src/usta/core/ocr/tesseract_engine.py | 2 +- src/usta/core/screenshot/portal_screenshot.py | 4 +- .../core/screenshot/spectacle_screenshot.py | 2 +- src/usta/core/translation/deepl_engine.py | 2 +- src/usta/core/worker.py | 156 ------------------ src/usta/ui/main_window.py | 9 +- tests/integration/test_linux_windows.py | 1 + tests/native/test_macos_hotkey_actions.py | 1 + tests/unit/test_engine_contracts.py | 5 + tests/unit/test_pipeline_lifecycle.py | 54 ++++++ 13 files changed, 315 insertions(+), 162 deletions(-) create mode 100644 src/usta/application/pipeline.py create mode 100644 src/usta/application/worker.py delete mode 100644 src/usta/core/worker.py create mode 100644 tests/unit/test_pipeline_lifecycle.py diff --git a/docs/development/cleanup-progress.md b/docs/development/cleanup-progress.md index 0e5064e..d99b610 100644 --- a/docs/development/cleanup-progress.md +++ b/docs/development/cleanup-progress.md @@ -26,3 +26,5 @@ Task 4: six storage regression tests passed; full portable suite 42 tests green 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. diff --git a/src/usta/application/pipeline.py b/src/usta/application/pipeline.py new file mode 100644 index 0000000..7c24159 --- /dev/null +++ b/src/usta/application/pipeline.py @@ -0,0 +1,151 @@ +"""Single-owner capture loop with immutable translation jobs.""" +from concurrent.futures import ThreadPoolExecutor +from copy import deepcopy +import logging +import threading +import time + +from usta.application.results import TranslationRequest +from usta.application.settings import AppSettings +from usta.config import get_language_code +from usta.core.exceptions import PortalCanceledError +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_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_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 + + @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. + translator = self.translator_factory() + for name, key in keys.items(): + translator.set_api_key(name, key) + return translator.translate_request(request) + + def _collect(self): + if self._future is None or not self._future.done(): + return + future, job = self._future, self._job + self._future = self._job = 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 = 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) + + 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(): + started = time.perf_counter() + 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 + if capture.capture(self.rect_factory(settings.capture_rect), self.image_path, settings.dpi_scale): + if settings.ocr_engine == 'Tesseract' and self.preprocess: + self.preprocess(self.image_path, self.image_path) + text = ocr.read_result(self.image_path).text + 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._future = self._executor.submit(self._translate, request, settings.translator_api_keys) + last_text = text + self.on_translating(True) + self.on_performance(time.perf_counter() - started) + 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/worker.py b/src/usta/application/worker.py new file mode 100644 index 0000000..cb0b47f --- /dev/null +++ b/src/usta/application/worker.py @@ -0,0 +1,88 @@ +"""Qt signals and thread lifecycle around the application pipeline.""" +from dataclasses import replace +from PySide6.QtCore import QThread, Signal, QRect +from usta.application.pipeline import Pipeline +from usta.config import IMG_PATH +from usta.core.screenshot import ScreenshotFactory, ImageProcessor +from usta.core.socket_publisher import TranslationPublisher + +class OCRWorker(QThread): + LOOP_SLEEP_SECONDS = Pipeline.LOOP_SLEEP_SECONDS + new_translation = Signal(str) + performance_update = Signal(float) + translation_status = Signal(bool) + running_status = Signal(bool) + error_occurred = Signal(str) + + def __init__(self): + super().__init__() + self.pipeline = Pipeline(capture_factory=ScreenshotFactory.get_engine, + publisher=TranslationPublisher(), image_path=IMG_PATH, + on_translation=self.new_translation.emit, on_error=self.error_occurred.emit, + on_performance=self.performance_update.emit, on_translating=self.translation_status.emit, + rect_factory=lambda values: QRect(*values), preprocess=ImageProcessor.preprocess_for_tesseract) + self._restart_requested = False + self.finished.connect(self._restart_if_requested) + + @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 = True + 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() + + def run(self): + self.running_status.emit(True) + try: + self.pipeline.run() + finally: + self.running_status.emit(False) diff --git a/src/usta/core/ocr/tesseract_engine.py b/src/usta/core/ocr/tesseract_engine.py index 0d2beb4..227f51a 100644 --- a/src/usta/core/ocr/tesseract_engine.py +++ b/src/usta/core/ocr/tesseract_engine.py @@ -26,7 +26,7 @@ def read_text(self, image_path: str) -> str: 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 diff --git a/src/usta/core/screenshot/portal_screenshot.py b/src/usta/core/screenshot/portal_screenshot.py index 1d91336..c5765b1 100644 --- a/src/usta/core/screenshot/portal_screenshot.py +++ b/src/usta/core/screenshot/portal_screenshot.py @@ -87,7 +87,7 @@ 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 @@ -122,7 +122,7 @@ 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: logger.error(f"Portal Close Error: {exc}") diff --git a/src/usta/core/screenshot/spectacle_screenshot.py b/src/usta/core/screenshot/spectacle_screenshot.py index 9d8d79f..3290bf2 100644 --- a/src/usta/core/screenshot/spectacle_screenshot.py +++ b/src/usta/core/screenshot/spectacle_screenshot.py @@ -17,7 +17,7 @@ def capture(self, rect: QRect, output_path: str, dpi_scale: float = 1.0) -> bool # Use spectacle for KDE/Wayland subprocess.run( ["spectacle", "-b", "-f", "-n", "-o", FULL_SCREEN_TEMP_PATH], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True, timeout=15 ) if not os.path.exists(FULL_SCREEN_TEMP_PATH): diff --git a/src/usta/core/translation/deepl_engine.py b/src/usta/core/translation/deepl_engine.py index c1eac92..4b294a1 100644 --- a/src/usta/core/translation/deepl_engine.py +++ b/src/usta/core/translation/deepl_engine.py @@ -22,7 +22,7 @@ def __init__(self, api_key=None, source='en', target='tr'): def translator(self): if self._translator is None: try: - self._translator = deepl.Translator(self.api_key, timeout=10.0, max_network_retries=0) + self._translator = deepl.Translator(self.api_key) except Exception as e: raise EngineError("initialization", "DeepL could not be initialized") from e return self._translator diff --git a/src/usta/core/worker.py b/src/usta/core/worker.py deleted file mode 100644 index ce9f597..0000000 --- a/src/usta/core/worker.py +++ /dev/null @@ -1,156 +0,0 @@ -import logging -import time -import threading -from threading import Lock -from PySide6.QtCore import QThread, Signal, QRect -from usta.core.ocr.ocr_manager import OCRManager -from usta.core.translation.translator_manager import TranslatorManager -from usta.core.exceptions import PortalCanceledError -from usta.core.screenshot import ScreenshotFactory, ImageProcessor -from usta.core.socket_publisher import TranslationPublisher -from usta.config import IMG_PATH, DPI_SCALE_DEFAULT - -logger = logging.getLogger(__name__) - -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 = "" - logger.info(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 = "" - logger.info(f"OCRWorker: Engine: {engine_name}") - - def set_translator(self, translator_name): - with self.lock: - self.translator_manager.set_translator(translator_name) - self.last_text = "" - logger.info(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 = "" - logger.debug(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 = "" - logger.info(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 = "" - logger.info(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: - logger.error(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) - logger.info(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() - logger.debug(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: - logger.info("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: - logger.error(f"Loop Error: {e}") - time.sleep(self.LOOP_SLEEP_SECONDS) \ No newline at end of file diff --git a/src/usta/ui/main_window.py b/src/usta/ui/main_window.py index 54b9eb0..3620823 100644 --- a/src/usta/ui/main_window.py +++ b/src/usta/ui/main_window.py @@ -7,7 +7,7 @@ from PySide6.QtCore import Qt, QRect, QPoint, QTimer, Signal, Slot, QSignalBlocker from PySide6.QtGui import QIcon, QFont from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton, QApplication, QColorDialog, QProgressBar, QTabWidget, QMessageBox -from usta.core.worker import OCRWorker +from usta.application.worker import OCRWorker from usta.ui.overlay_window import TransparentOverlay from usta.core.sniper import SniperFactory from usta.config import OS, LANGUAGES, SETTINGS_FILE, PRESETS_FILE, DPI_SCALE_DEFAULT, SETTINGS_TOPMOST_HOTKEY, TEMPORARY_REGION_HOTKEY, get_language_code @@ -111,6 +111,7 @@ def __init__(self): self.btn_stop.clicked.connect(self.stop) layout.addWidget(self.btn_stop) + self.worker.error_occurred.connect(self.overlay.update_text) 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) @@ -526,6 +527,12 @@ def stop(self): self.perf_bar.setStyleSheet("") def closeEvent(self, event): + if self.worker.isRunning(): + event.ignore() + self.worker.finished.connect(self.close) + self.worker.stop() + return + self.worker.close() if self._macos_hotkey_window is not None: self._macos_hotkey_window.stop() self._temporary_region_restore_timer.stop() diff --git a/tests/integration/test_linux_windows.py b/tests/integration/test_linux_windows.py index ea70e35..7276b27 100644 --- a/tests/integration/test_linux_windows.py +++ b/tests/integration/test_linux_windows.py @@ -27,6 +27,7 @@ def make_control_panel(self, os_name): 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)) diff --git a/tests/native/test_macos_hotkey_actions.py b/tests/native/test_macos_hotkey_actions.py index 05105ad..4e84ec2 100644 --- a/tests/native/test_macos_hotkey_actions.py +++ b/tests/native/test_macos_hotkey_actions.py @@ -50,6 +50,7 @@ def make_control_panel(self, os_name): 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)) diff --git a/tests/unit/test_engine_contracts.py b/tests/unit/test_engine_contracts.py index c93d7a1..0790ae0 100644 --- a/tests/unit/test_engine_contracts.py +++ b/tests/unit/test_engine_contracts.py @@ -22,3 +22,8 @@ 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/unit/test_pipeline_lifecycle.py b/tests/unit/test_pipeline_lifecycle.py new file mode 100644 index 0000000..02529b1 --- /dev/null +++ b/tests/unit/test_pipeline_lifecycle.py @@ -0,0 +1,54 @@ +from dataclasses import replace +import threading +import time +import unittest +from unittest.mock import Mock +from usta.application.pipeline import Pipeline +from usta.application.settings import AppSettings + +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() From 8c1557b17d4af0fb3b5ea1be757d2e71221f546b Mon Sep 17 00:00:00 2001 From: sedataym Date: Fri, 23 Oct 2026 15:32:29 +0300 Subject: [PATCH 11/24] refactor: separate views, settings presentation and capture controllers --- docs/development/cleanup-progress.md | 2 + src/usta/application/controller.py | 46 ++ src/usta/application/hotkey_controller.py | 23 + src/usta/application/region_controller.py | 40 ++ src/usta/core/sniper/__init__.py | 9 - src/usta/ui/main_window.py | 467 +++++------------- src/usta/ui/overlay_window.py | 2 +- src/usta/ui/platform/__init__.py | 0 src/usta/ui/platform/macos/__init__.py | 0 .../macos/hotkeys.py} | 2 +- .../macos/overlay.py} | 0 src/usta/ui/region_selection/__init__.py | 9 + .../region_selection}/base_sniper.py | 0 .../region_selection}/core_sniper.py | 4 +- .../region_selection}/slurp_sniper.py | 2 +- .../region_selection}/sniper_factory.py | 6 +- src/usta/ui/settings_presenter.py | 290 +++++++++++ src/usta/ui/tabs/__init__.py | 26 +- src/usta/ui/tabs/about_tab.py | 30 +- src/usta/ui/tabs/appearance_tab.py | 109 ++-- src/usta/ui/tabs/hotkey_tab.py | 85 ++-- src/usta/ui/tabs/ocr_tab.py | 90 ++-- src/usta/ui/tabs/save_tab.py | 69 +-- src/usta/ui/tabs/status_tab.py | 50 +- src/usta/ui/tabs/translation_tab.py | 92 ++-- tests/integration/test_control_panel.py | 45 ++ tests/integration/test_linux_windows.py | 6 +- tests/native/test_macos_hotkey_actions.py | 16 +- tests/native/test_macos_overlay.py | 4 +- tests/unit/test_architecture.py | 18 + 30 files changed, 906 insertions(+), 636 deletions(-) create mode 100644 src/usta/application/controller.py create mode 100644 src/usta/application/hotkey_controller.py create mode 100644 src/usta/application/region_controller.py delete mode 100644 src/usta/core/sniper/__init__.py create mode 100644 src/usta/ui/platform/__init__.py create mode 100644 src/usta/ui/platform/macos/__init__.py rename src/usta/ui/{macos_hotkeys.py => platform/macos/hotkeys.py} (98%) rename src/usta/ui/{macos_overlay.py => platform/macos/overlay.py} (100%) create mode 100644 src/usta/ui/region_selection/__init__.py rename src/usta/{core/sniper => ui/region_selection}/base_sniper.py (100%) rename src/usta/{core/sniper => ui/region_selection}/core_sniper.py (97%) rename src/usta/{core/sniper => ui/region_selection}/slurp_sniper.py (96%) rename src/usta/{core/sniper => ui/region_selection}/sniper_factory.py (60%) create mode 100644 src/usta/ui/settings_presenter.py create mode 100644 tests/integration/test_control_panel.py create mode 100644 tests/unit/test_architecture.py diff --git a/docs/development/cleanup-progress.md b/docs/development/cleanup-progress.md index d99b610..2f91d87 100644 --- a/docs/development/cleanup-progress.md +++ b/docs/development/cleanup-progress.md @@ -28,3 +28,5 @@ Task 5: import side-effect test reproduced stdout output before removal and pass 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. diff --git a/src/usta/application/controller.py b/src/usta/application/controller.py new file mode 100644 index 0000000..2206cd0 --- /dev/null +++ b/src/usta/application/controller.py @@ -0,0 +1,46 @@ +"""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..1aef9be --- /dev/null +++ b/src/usta/application/hotkey_controller.py @@ -0,0 +1,23 @@ +"""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/region_controller.py b/src/usta/application/region_controller.py new file mode 100644 index 0000000..ae7183a --- /dev/null +++ b/src/usta/application/region_controller.py @@ -0,0 +1,40 @@ +"""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/core/sniper/__init__.py b/src/usta/core/sniper/__init__.py deleted file mode 100644 index acf1686..0000000 --- a/src/usta/core/sniper/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -from usta.core.sniper.base_sniper import BaseSniper -from usta.core.sniper.slurp_sniper import SlurpSniper -from usta.core.sniper.sniper_factory import SniperFactory - -__all__ = [ - "BaseSniper", - "SlurpSniper", - "SniperFactory", -] diff --git a/src/usta/ui/main_window.py b/src/usta/ui/main_window.py index 3620823..67fa74d 100644 --- a/src/usta/ui/main_window.py +++ b/src/usta/ui/main_window.py @@ -1,6 +1,9 @@ +from usta.application.controller import ApplicationController +from usta.application.region_controller import RegionController +from usta.application.hotkey_controller import HotkeyController import logging import time -from pathlib import Path +from usta.ui.settings_presenter import SettingsPresenter from usta.application.settings import AppSettings, Preset from usta.infrastructure.settings_store import SettingsStore import os @@ -9,18 +12,18 @@ from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton, QApplication, QColorDialog, QProgressBar, QTabWidget, QMessageBox from usta.application.worker import OCRWorker from usta.ui.overlay_window import TransparentOverlay -from usta.core.sniper import SniperFactory +from usta.ui.region_selection import SniperFactory from usta.config import OS, LANGUAGES, SETTINGS_FILE, PRESETS_FILE, DPI_SCALE_DEFAULT, SETTINGS_TOPMOST_HOTKEY, TEMPORARY_REGION_HOTKEY, get_language_code from usta.core.shortcut import GlobalHotkey from usta.i18n import _ from usta.ui.tabs import ( - build_about_tab, - build_appearance_tab, - build_ocr_tab, - build_save_tab, - build_hotkey_tab, - build_status_tab, - build_translation_tab, + AboutTab, + AppearanceTab, + OCRTab, + SaveTab, + HotkeyTab, + StatusTab, + TranslationTab, ) logger = logging.getLogger(__name__) @@ -31,25 +34,21 @@ class ControlPanel(QWidget): def __init__(self): super().__init__() self._loading_settings = True - self.settings_store = SettingsStore(Path(SETTINGS_FILE).parent) + self.settings_presenter = SettingsPresenter(self) 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.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_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.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._temporary_region_hotkey.start() self.setWindowTitle("USTA") # Set window icon @@ -61,7 +60,7 @@ def __init__(self): self.setWindowFlag(Qt.WindowMaximizeButtonHint, False) self._macos_hotkey_window = None if OS == "Darwin": - from usta.ui.macos_hotkeys import MacOSHotkeyWindow + 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) @@ -74,13 +73,45 @@ def __init__(self): # --- 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")) + 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) @@ -185,122 +216,27 @@ def on_font_size_changed(self, size): self.save_settings() def on_dpi_scale_changed(self, percent_value): - self.worker.dpi_scale = percent_value / 100.0 + self.controller.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.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.dpi_lock_label.setText("🔒") - self.dpi_lock_label.setToolTip(_("DPI locked (click to unlock)")) + self.ocr_tab.dpi_lock_label.setText("🔒") + self.ocr_tab.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)")) + 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 save_settings(self): - if self._loading_settings: - return - 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: - self.settings_store.save(AppSettings.from_dict(settings)) - logger.info("Settings saved.") - except Exception as e: - logger.error(f"Settings save error: {e}") - def load_settings(self): - self._loading_settings = True - blockers = [QSignalBlocker(widget) for widget in self.findChildren(QWidget)] - try: - s = self.settings_store.load().to_dict() - # 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(QFont(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.setValue(dpi_scale * 100) - - 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.worker.set_engine(self.combo_ocr.currentText()) - self.worker.set_translator(self.combo_translator.currentText()) - self.update_languages() - except (OSError, ValueError) as exc: - logger.error(f"Settings load error: {exc}") - self._apply_saved_hotkeys(SETTINGS_TOPMOST_HOTKEY, TEMPORARY_REGION_HOTKEY) - self.update_languages() - finally: - del blockers - self._loading_settings = False def _apply_saved_hotkeys(self, settings_topmost_hotkey, temporary_region_hotkey): self._set_settings_topmost_hotkey(settings_topmost_hotkey, save=False, show_error=False) @@ -308,10 +244,10 @@ def _apply_saved_hotkeys(self, settings_topmost_hotkey, temporary_region_hotkey) 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) + 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): @@ -331,10 +267,7 @@ def _set_temporary_region_hotkey(self, hotkey, save=True, show_error=True): 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 + if self.hotkey_controller.set_hotkey(hotkey): self.temporary_region_hotkey = hotkey self._sync_hotkey_buttons() if save: @@ -360,22 +293,22 @@ def reset_temporary_region_hotkey(self): def update_system_status(self, is_running): if is_running: - self.system_indicator.setStyleSheet( + self.status_tab.system_indicator.setStyleSheet( "background-color: #2E7D32; border-radius: 8px;" ) - self.system_status_label.setText("Running") + self.status_tab.system_status_label.setText("Running") else: - self.system_indicator.setStyleSheet( + self.status_tab.system_indicator.setStyleSheet( "background-color: #C62828; border-radius: 8px;" ) - self.system_status_label.setText("Stopped") + self.status_tab.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( + r = self.controller.capture_rect + self.status_tab.rect_label_status.setText( _("Region: X:{x} Y:{y} {w}×{h}").format(x=r.x(), y=r.y(), w=r.width(), h=r.height()) ) @@ -409,9 +342,9 @@ def update_performance_bar(self, duration): """) 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) + 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): @@ -419,7 +352,7 @@ def choose_color(self): 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.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): @@ -427,7 +360,7 @@ def choose_bg_color(self): 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.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): @@ -440,14 +373,13 @@ def _handle_temporary_region_hotkey_pressed(self): self._temporary_region_hotkey_armed = False QTimer.singleShot(200, self._rearm_temporary_region_hotkey) - self.select_temporary_region() + 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() - time.sleep(0.2) sniper = SniperFactory.get_engine() rect = sniper.get_region() @@ -457,46 +389,25 @@ def _get_selected_region_and_dpi(self): 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()}") + 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): - 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() + self.region_controller.apply_temporary_region(rect, None if self.dpi_locked else dpi_scale) 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() + self.region_controller.restore_main_region() def select_temporary_region(self): self._temporary_region_selecting = True @@ -509,19 +420,20 @@ def select_temporary_region(self): self._apply_temporary_region(rect, detected_dpi) def select_region(self): + QTimer.singleShot(200, self._select_region_now) + + def _select_region_now(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() + self.controller.start() def stop(self): - self.worker.stop() + self.controller.stop() self.overlay.set_mode(False) self.perf_bar.setValue(0) self.perf_bar.setStyleSheet("") @@ -530,14 +442,14 @@ def closeEvent(self, event): if self.worker.isRunning(): event.ignore() self.worker.finished.connect(self.close) - self.worker.stop() + self.controller.stop() return self.worker.close() if self._macos_hotkey_window is not None: self._macos_hotkey_window.stop() - self._temporary_region_restore_timer.stop() - self._temporary_region_hotkey.stop() - self.worker.stop() + self.region_controller.restore_timer.stop() + self.hotkey_controller.close() + self.controller.stop() self.overlay.close() super().closeEvent(event) if self._macos_hotkey_window is not None and event.isAccepted(): @@ -545,167 +457,22 @@ def closeEvent(self, event): QApplication.instance().quit() # ---------- 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): - try: - return {name: preset.to_dict() for name, preset in self.settings_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.settings_store.save_presets({name: Preset.from_dict(value) for name, value in presets.items()}) - except (OSError, ValueError) as exc: - logger.error(f"Presets save error: {exc}") - 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() - logger.info(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(QFont(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) - - logger.info(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() - logger.info(f"Preset '{name}' deleted.") - else: - QMessageBox.warning(self, "Not Found", f"Preset '{name}' not found.") + + + + 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/usta/ui/overlay_window.py b/src/usta/ui/overlay_window.py index 5cc7e35..228ff35 100644 --- a/src/usta/ui/overlay_window.py +++ b/src/usta/ui/overlay_window.py @@ -93,7 +93,7 @@ def __init__(self): self.setAttribute(Qt.WA_ShowWithoutActivating) self._macos_rules = None if OS == "Darwin": - from usta.ui.macos_overlay import MacOSOverlayRules + from usta.ui.platform.macos.overlay import MacOSOverlayRules self.setAttribute(Qt.WA_MacAlwaysShowToolWindow, True) self._macos_rules = MacOSOverlayRules(self) self.setMouseTracking(True) 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/macos_hotkeys.py b/src/usta/ui/platform/macos/hotkeys.py similarity index 98% rename from src/usta/ui/macos_hotkeys.py rename to src/usta/ui/platform/macos/hotkeys.py index af86731..6cebe55 100644 --- a/src/usta/ui/macos_hotkeys.py +++ b/src/usta/ui/platform/macos/hotkeys.py @@ -2,7 +2,7 @@ import logging import os -from usta.ui.macos_overlay import MacOSOverlayRules +from usta.ui.platform.macos.overlay import MacOSOverlayRules logger = logging.getLogger(__name__) diff --git a/src/usta/ui/macos_overlay.py b/src/usta/ui/platform/macos/overlay.py similarity index 100% rename from src/usta/ui/macos_overlay.py rename to src/usta/ui/platform/macos/overlay.py 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/usta/core/sniper/base_sniper.py b/src/usta/ui/region_selection/base_sniper.py similarity index 100% rename from src/usta/core/sniper/base_sniper.py rename to src/usta/ui/region_selection/base_sniper.py diff --git a/src/usta/core/sniper/core_sniper.py b/src/usta/ui/region_selection/core_sniper.py similarity index 97% rename from src/usta/core/sniper/core_sniper.py rename to src/usta/ui/region_selection/core_sniper.py index f58a457..26beb0a 100644 --- a/src/usta/core/sniper/core_sniper.py +++ b/src/usta/ui/region_selection/core_sniper.py @@ -3,7 +3,7 @@ from PySide6.QtCore import Qt, QRect, QPoint, Signal, QObject, QEventLoop from PySide6.QtGui import QPainter, QColor, QPen, QCursor from PySide6.QtWidgets import QApplication, QWidget -from usta.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.""" @@ -52,7 +52,7 @@ def __init__(self, screen, manager): self.setAttribute(Qt.WA_TranslucentBackground) self._macos_hotkey_window = None if sys.platform == "darwin": - from usta.ui.macos_hotkeys import MacOSHotkeyWindow + from usta.ui.platform.macos.hotkeys import MacOSHotkeyWindow self.setAttribute(Qt.WA_ShowWithoutActivating) self.setAttribute(Qt.WA_MacAlwaysShowToolWindow) self._macos_hotkey_window = MacOSHotkeyWindow(self) diff --git a/src/usta/core/sniper/slurp_sniper.py b/src/usta/ui/region_selection/slurp_sniper.py similarity index 96% rename from src/usta/core/sniper/slurp_sniper.py rename to src/usta/ui/region_selection/slurp_sniper.py index c2fa9ca..9f8ae76 100644 --- a/src/usta/core/sniper/slurp_sniper.py +++ b/src/usta/ui/region_selection/slurp_sniper.py @@ -3,7 +3,7 @@ import time from PySide6.QtCore import QRect from PySide6.QtWidgets import QApplication -from usta.core.sniper.base_sniper import BaseSniper +from usta.ui.region_selection.base_sniper import BaseSniper from usta.config import SLURP_TEMP_PATH logger = logging.getLogger(__name__) diff --git a/src/usta/core/sniper/sniper_factory.py b/src/usta/ui/region_selection/sniper_factory.py similarity index 60% rename from src/usta/core/sniper/sniper_factory.py rename to src/usta/ui/region_selection/sniper_factory.py index 2b168d0..3779b66 100644 --- a/src/usta/core/sniper/sniper_factory.py +++ b/src/usta/ui/region_selection/sniper_factory.py @@ -1,6 +1,6 @@ -from usta.core.sniper.base_sniper import BaseSniper -from usta.core.sniper.slurp_sniper import SlurpSniper -from usta.core.sniper.core_sniper import CoreSniper +from usta.ui.region_selection.base_sniper import BaseSniper +from usta.ui.region_selection.slurp_sniper import SlurpSniper +from usta.ui.region_selection.core_sniper import CoreSniper class SniperFactory: @staticmethod diff --git a/src/usta/ui/settings_presenter.py b/src/usta/ui/settings_presenter.py new file mode 100644 index 0000000..0eb501a --- /dev/null +++ b/src/usta/ui/settings_presenter.py @@ -0,0 +1,290 @@ +"""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 QWidget, QMessageBox +from usta.config import CONFIG_DIR, DPI_SCALE_DEFAULT, SETTINGS_TOPMOST_HOTKEY, TEMPORARY_REGION_HOTKEY +from usta.application.settings import AppSettings, Preset +from usta.infrastructure.settings_store import SettingsStore +from usta.i18n import _ +logger = logging.getLogger(__name__) + +class SettingsPresenter: + def __init__(self, view, store=None): + self.view = view + 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: + self.store.save(AppSettings.from_dict(settings)) + 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: + s = self.store.load().to_dict() + # Update UI elements (triggers signals without blocking) + 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()}) + except (OSError, ValueError) as exc: + logger.error(f"Presets save error: {exc}") + + + 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, "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 + self._save_presets_dict(presets) + 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, _("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.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, "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() + logger.info(f"Preset '{name}' deleted.") + else: + QMessageBox.warning(self, "Not Found", f"Preset '{name}' not found.") + + diff --git a/src/usta/ui/tabs/__init__.py b/src/usta/ui/tabs/__init__.py index 4991886..0bf0cf9 100644 --- a/src/usta/ui/tabs/__init__.py +++ b/src/usta/ui/tabs/__init__.py @@ -1,19 +1,7 @@ -"""Control panel tab builders.""" - -from usta.ui.tabs.about_tab import build_about_tab -from usta.ui.tabs.appearance_tab import build_appearance_tab -from usta.ui.tabs.ocr_tab import build_ocr_tab -from usta.ui.tabs.save_tab import build_save_tab -from usta.ui.tabs.hotkey_tab import build_hotkey_tab -from usta.ui.tabs.status_tab import build_status_tab -from usta.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", -] +from usta.ui.tabs.ocr_tab import OCRTab +from usta.ui.tabs.translation_tab import TranslationTab +from usta.ui.tabs.appearance_tab import AppearanceTab +from usta.ui.tabs.hotkey_tab import HotkeyTab +from usta.ui.tabs.save_tab import SaveTab +from usta.ui.tabs.status_tab import StatusTab +from usta.ui.tabs.about_tab import AboutTab diff --git a/src/usta/ui/tabs/about_tab.py b/src/usta/ui/tabs/about_tab.py index 59144c2..6bcce7f 100644 --- a/src/usta/ui/tabs/about_tab.py +++ b/src/usta/ui/tabs/about_tab.py @@ -4,16 +4,20 @@ from usta.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 +from PySide6.QtCore import Signal + + +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 index 7ef6f44..71cb0cc 100644 --- a/src/usta/ui/tabs/appearance_tab.py +++ b/src/usta/ui/tabs/appearance_tab.py @@ -3,55 +3,66 @@ from usta.i18n import _ -def build_appearance_tab(panel): - tab_appearance = QWidget() - tab_appearance_layout = QVBoxLayout(tab_appearance) +from usta.application.settings import AppSettings +from PySide6.QtCore import Signal - 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) +class AppearanceTab(QWidget): + font_changed = Signal(object) + font_size_changed = Signal(int) + text_color_requested = Signal() + background_color_requested = Signal() + opacity_changed = Signal(int) - 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 + 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.emit) + 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.emit) + 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/usta/ui/tabs/hotkey_tab.py b/src/usta/ui/tabs/hotkey_tab.py index e464cd1..0ee9a74 100644 --- a/src/usta/ui/tabs/hotkey_tab.py +++ b/src/usta/ui/tabs/hotkey_tab.py @@ -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 +from usta.config import SETTINGS_TOPMOST_HOTKEY, TEMPORARY_REGION_HOTKEY +from PySide6.QtCore import Signal + + +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 = _( + "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." + ) + + 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, + _("Settings topmost key:"), + self.settings_topmost_hotkey_button, + self.reset_settings_requested.emit, + 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 key:"), + self.temporary_region_hotkey_button, + self.reset_region_requested.emit, + 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 index 9f7b57e..3c7e8be 100644 --- a/src/usta/ui/tabs/ocr_tab.py +++ b/src/usta/ui/tabs/ocr_tab.py @@ -5,47 +5,55 @@ from usta.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) +from PySide6.QtCore import Signal - tab_ocr_layout.addWidget(QLabel(_("Screen Engine:"))) - panel.combo_screenshot = QComboBox() - panel.combo_screenshot.setFocusPolicy(Qt.NoFocus) - panel.combo_screenshot.addItems(SCREENSHOT_ENGINES) - if SCREENSHOT_ENGINES: - panel.combo_screenshot.setCurrentText(SCREENSHOT_ENGINES[0]) - 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) +class OCRTab(QWidget): + engine_changed = Signal(str) + screen_engine_changed = Signal(str) + changed = Signal() + dpi_changed = Signal(float) + toggle_dpi_requested = Signal(object) - tab_ocr_layout.addStretch() - return tab_ocr + 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.emit) + 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.emit) + 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 index 443edca..1946214 100644 --- a/src/usta/ui/tabs/save_tab.py +++ b/src/usta/ui/tabs/save_tab.py @@ -4,34 +4,41 @@ from usta.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 +from PySide6.QtCore import Signal + + +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.emit) + h_buttons.addWidget(self.btn_save_preset) + + self.btn_load_preset = QPushButton(_("📂 Load")) + self.btn_load_preset.clicked.connect(self.load_requested.emit) + h_buttons.addWidget(self.btn_load_preset) + + self.btn_delete_preset = QPushButton(_("🗑 Delete")) + self.btn_delete_preset.clicked.connect(self.delete_requested.emit) + 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 index 4c6e6dd..5b9e015 100644 --- a/src/usta/ui/tabs/status_tab.py +++ b/src/usta/ui/tabs/status_tab.py @@ -3,29 +3,33 @@ from usta.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) +from PySide6.QtCore import Signal - 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() +class StatusTab(QWidget): - 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 + def __init__(self): + super().__init__() + tab_status_layout = QVBoxLayout(self) + tab_status_layout.setSpacing(6) + tab_status_layout.setContentsMargins(8, 8, 8, 8) + + h_system = QHBoxLayout() + h_system.setSpacing(6) + self.system_indicator = QLabel() + self.system_indicator.setFixedSize(16, 16) + self.system_indicator.setStyleSheet( + "background-color: #C62828; border-radius: 8px;" + ) + h_system.addWidget(self.system_indicator) + self.system_status_label = QLabel("Stopped") + h_system.addWidget(self.system_status_label) + h_system.addStretch() + tab_status_layout.addLayout(h_system) + + tab_status_layout.addStretch() + + self.rect_label_status = QLabel(_("Region: —")) + self.rect_label_status.setStyleSheet("font-family: monospace;") + tab_status_layout.addWidget(self.rect_label_status) + diff --git a/src/usta/ui/tabs/translation_tab.py b/src/usta/ui/tabs/translation_tab.py index 0463588..d84682f 100644 --- a/src/usta/ui/tabs/translation_tab.py +++ b/src/usta/ui/tabs/translation_tab.py @@ -5,48 +5,56 @@ from usta.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) +from PySide6.QtCore import Signal - 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) +class TranslationTab(QWidget): + translator_changed = Signal(str) + changed = Signal() + translator_selected = Signal(str) + language_changed = Signal() + api_key_changed = Signal(str) - 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 + 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.emit) + 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.emit) + 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.emit) + 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/tests/integration/test_control_panel.py b/tests/integration/test_control_panel.py new file mode 100644 index 0000000..701028d --- /dev/null +++ b/tests/integration/test_control_panel.py @@ -0,0 +1,45 @@ +import tempfile +import unittest +from pathlib import Path +from unittest import mock +from PySide6.QtWidgets import QApplication +from PySide6.QtCore import QRect +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(self.panel.close) + + 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_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)) diff --git a/tests/integration/test_linux_windows.py b/tests/integration/test_linux_windows.py index 7276b27..f2e07ad 100644 --- a/tests/integration/test_linux_windows.py +++ b/tests/integration/test_linux_windows.py @@ -4,7 +4,7 @@ from unittest import mock from PySide6.QtCore import Qt, QRect from PySide6.QtWidgets import QApplication -from usta.core.sniper.core_sniper import OverlayWindow, SelectionManager +from usta.ui.region_selection.core_sniper import OverlayWindow, SelectionManager from tests.native.test_macos_hotkey_actions import pump class LinuxWindowContracts(unittest.TestCase): @@ -60,8 +60,8 @@ def test_linux_settings_action_keeps_original_window_behavior(self): def test_linux_region_selector_keeps_x11_flags_without_native_imports(self): - with mock.patch('usta.core.sniper.core_sniper.sys.platform', 'linux'), \ - mock.patch.dict(sys.modules, {'usta.ui.macos_hotkeys': None}): + 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) diff --git a/tests/native/test_macos_hotkey_actions.py b/tests/native/test_macos_hotkey_actions.py index 4e84ec2..a0d6f52 100644 --- a/tests/native/test_macos_hotkey_actions.py +++ b/tests/native/test_macos_hotkey_actions.py @@ -15,8 +15,8 @@ from PySide6.QtTest import QTest from PySide6.QtWidgets import QApplication, QWidget -from usta.core.sniper.core_sniper import CoreSniper, OverlayWindow, SelectionManager -from usta.ui.macos_hotkeys import MacOSHotkeyWindow +from usta.ui.region_selection.core_sniper import CoreSniper, OverlayWindow, SelectionManager +from usta.ui.platform.macos.hotkeys import MacOSHotkeyWindow def pump(milliseconds=40): @@ -81,8 +81,8 @@ def test_linux_settings_action_keeps_original_window_behavior(self): quit_app.assert_not_called() def test_linux_region_selector_keeps_x11_flags_without_native_imports(self): - with mock.patch('usta.core.sniper.core_sniper.sys.platform', 'linux'), \ - mock.patch.dict(sys.modules, {'usta.ui.macos_hotkeys': None}): + 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) @@ -188,11 +188,11 @@ def test_macos_controls_use_native_action_and_restore_temporary_region(self): 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._temporary_region_restore_timer.remainingTime(), 9000) - panel._temporary_region_restore_timer.timeout.emit() + 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._temporary_region_active) + 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) @@ -204,7 +204,7 @@ 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__).parent / 'helpers' / 'macos_fullscreen_host.py' + 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) diff --git a/tests/native/test_macos_overlay.py b/tests/native/test_macos_overlay.py index 8f6348e..b9f98a3 100644 --- a/tests/native/test_macos_overlay.py +++ b/tests/native/test_macos_overlay.py @@ -12,7 +12,7 @@ from PySide6.QtCore import QEvent, QEventLoop, QTimer, Qt from PySide6.QtWidgets import QApplication, QWidget -from usta.ui.macos_overlay import MacOSOverlayRules +from usta.ui.platform.macos.overlay import MacOSOverlayRules def wait_for_events(milliseconds=30): @@ -88,7 +88,7 @@ def test_overlay_remains_on_screen_during_another_apps_native_fullscreen(self): self.rules = self.widget._macos_rules wait_for_events() - helper = Path(__file__).parent / 'helpers' / 'macos_fullscreen_host.py' + 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' diff --git a/tests/unit/test_architecture.py b/tests/unit/test_architecture.py new file mode 100644 index 0000000..1f2d73b --- /dev/null +++ b/tests/unit/test_architecture.py @@ -0,0 +1,18 @@ +import ast +from pathlib import Path +import unittest +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)) From 7ba74cf7edc8c0125b3c8b9a12cb120df64f56ba Mon Sep 17 00:00:00 2001 From: sedataym Date: Fri, 23 Oct 2026 15:34:42 +0300 Subject: [PATCH 12/24] fix: bound IPC broadcasts and isolate capture workspaces --- docs/development/cleanup-progress.md | 2 + src/usta/application/worker.py | 10 +- src/usta/core/screenshot/__init__.py | 21 +-- src/usta/core/screenshot/portal_screenshot.py | 5 +- .../core/screenshot/screenshot_factory.py | 49 ++----- .../core/screenshot/spectacle_screenshot.py | 13 +- src/usta/core/socket_publisher.py | 129 ----------------- src/usta/infrastructure/paths.py | 11 ++ src/usta/infrastructure/socket_publisher.py | 134 ++++++++++++++++++ tests/unit/test_runtime_paths.py | 13 ++ tests/unit/test_socket_publisher.py | 56 ++++++++ 11 files changed, 248 insertions(+), 195 deletions(-) delete mode 100644 src/usta/core/socket_publisher.py create mode 100644 src/usta/infrastructure/socket_publisher.py create mode 100644 tests/unit/test_runtime_paths.py create mode 100644 tests/unit/test_socket_publisher.py diff --git a/docs/development/cleanup-progress.md b/docs/development/cleanup-progress.md index 2f91d87..eb6a9f8 100644 --- a/docs/development/cleanup-progress.md +++ b/docs/development/cleanup-progress.md @@ -30,3 +30,5 @@ Task 6: three new engine contract tests plus full suite pass (46 tests, 21 skips 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. diff --git a/src/usta/application/worker.py b/src/usta/application/worker.py index cb0b47f..2b457d7 100644 --- a/src/usta/application/worker.py +++ b/src/usta/application/worker.py @@ -2,9 +2,9 @@ from dataclasses import replace from PySide6.QtCore import QThread, Signal, QRect from usta.application.pipeline import Pipeline -from usta.config import IMG_PATH +from usta.infrastructure.paths import CaptureWorkspace from usta.core.screenshot import ScreenshotFactory, ImageProcessor -from usta.core.socket_publisher import TranslationPublisher +from usta.infrastructure.socket_publisher import TranslationPublisher class OCRWorker(QThread): LOOP_SLEEP_SECONDS = Pipeline.LOOP_SLEEP_SECONDS @@ -16,8 +16,9 @@ class OCRWorker(QThread): def __init__(self): super().__init__() - self.pipeline = Pipeline(capture_factory=ScreenshotFactory.get_engine, - publisher=TranslationPublisher(), image_path=IMG_PATH, + 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.new_translation.emit, on_error=self.error_occurred.emit, on_performance=self.performance_update.emit, on_translating=self.translation_status.emit, rect_factory=lambda values: QRect(*values), preprocess=ImageProcessor.preprocess_for_tesseract) @@ -79,6 +80,7 @@ def stop(self): def close(self): self.stop() self.pipeline.close() + self.workspace.close() def run(self): self.running_status.emit(True) diff --git a/src/usta/core/screenshot/__init__.py b/src/usta/core/screenshot/__init__.py index 4c4618a..9547aa3 100644 --- a/src/usta/core/screenshot/__init__.py +++ b/src/usta/core/screenshot/__init__.py @@ -1,23 +1,6 @@ +"""Screenshot interfaces; native engines are loaded only when selected.""" from usta.core.screenshot.base_screenshot import BaseScreenshot -from usta.core.screenshot.spectacle_screenshot import SpectacleScreenshot from usta.core.screenshot.image_processor import ImageProcessor from usta.core.screenshot.screenshot_factory import ScreenshotFactory -try: - from usta.core.screenshot.macos_screenshot import MacOSScreenshot -except Exception: - MacOSScreenshot = None - -try: - from usta.core.screenshot.portal_screenshot import PortalScreenshot -except Exception: - PortalScreenshot = None - -__all__ = [ - "BaseScreenshot", - "MacOSScreenshot", - "PortalScreenshot", - "SpectacleScreenshot", - "ImageProcessor", - "ScreenshotFactory", -] +__all__ = ['BaseScreenshot', 'ImageProcessor', 'ScreenshotFactory'] diff --git a/src/usta/core/screenshot/portal_screenshot.py b/src/usta/core/screenshot/portal_screenshot.py index c5765b1..c4d7dc1 100644 --- a/src/usta/core/screenshot/portal_screenshot.py +++ b/src/usta/core/screenshot/portal_screenshot.py @@ -57,7 +57,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 @@ -382,7 +383,7 @@ def _build_frame_image(self, sample: Any) -> Image.Image: 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)) diff --git a/src/usta/core/screenshot/screenshot_factory.py b/src/usta/core/screenshot/screenshot_factory.py index 624353a..b1f9d97 100644 --- a/src/usta/core/screenshot/screenshot_factory.py +++ b/src/usta/core/screenshot/screenshot_factory.py @@ -1,45 +1,22 @@ +"""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 -from usta.core.screenshot.spectacle_screenshot import SpectacleScreenshot logger = logging.getLogger(__name__) -try: - from usta.core.screenshot.macos_screenshot import MacOSScreenshot -except Exception as exc: - MacOSScreenshot = None - _MACOS_IMPORT_ERROR = exc -else: - _MACOS_IMPORT_ERROR = None - -try: - from usta.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 platform.system() == "Darwin": - if MacOSScreenshot is not None and MacOSScreenshot.is_available(): + 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 _MACOS_IMPORT_ERROR is not None: - logger.error(f"macOS ekran görüntüsü motoru kullanılamıyor: {_MACOS_IMPORT_ERROR}") - - if engine_name == "Portal": - if PortalScreenshot is not None and PortalScreenshot.is_available(): - return PortalScreenshot() - - if _PORTAL_IMPORT_ERROR is not None: - logger.error(f"Portal screenshot engine unavailable: {_PORTAL_IMPORT_ERROR}") - logger.info("Falling back to Spectacle screenshot engine.") - return SpectacleScreenshot() - - # Spectacle - return SpectacleScreenshot() + 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/usta/core/screenshot/spectacle_screenshot.py b/src/usta/core/screenshot/spectacle_screenshot.py index 3290bf2..7360140 100644 --- a/src/usta/core/screenshot/spectacle_screenshot.py +++ b/src/usta/core/screenshot/spectacle_screenshot.py @@ -9,21 +9,24 @@ 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], + ["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)) diff --git a/src/usta/core/socket_publisher.py b/src/usta/core/socket_publisher.py deleted file mode 100644 index 10820b2..0000000 --- a/src/usta/core/socket_publisher.py +++ /dev/null @@ -1,129 +0,0 @@ -import logging -import os -import json -import socket -import threading -import time -from usta.config import SOCKET_PATH - -logger = logging.getLogger(__name__) - - -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() - logger.info(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 - - logger.info("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) - logger.info(f"TranslationPublisher: Client connected ({len(self._clients)} total)") - except socket.timeout: - continue - except OSError: - if self._running: - logger.error("TranslationPublisher: Accept error, retrying…") - break \ No newline at end of file diff --git a/src/usta/infrastructure/paths.py b/src/usta/infrastructure/paths.py index 94d86f4..ebcf8a8 100644 --- a/src/usta/infrastructure/paths.py +++ b/src/usta/infrastructure/paths.py @@ -16,3 +16,14 @@ def resolve_paths() -> RuntimePaths: 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/socket_publisher.py b/src/usta/infrastructure/socket_publisher.py new file mode 100644 index 0000000..d390b57 --- /dev/null +++ b/src/usta/infrastructure/socket_publisher.py @@ -0,0 +1,134 @@ +"""Bounded, non-blocking Unix socket translation broadcast.""" +import errno +import json +import logging +from pathlib import Path +import queue +import select +import socket +import stat +import threading +import time +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/tests/unit/test_runtime_paths.py b/tests/unit/test_runtime_paths.py new file mode 100644 index 0000000..e79cfbd --- /dev/null +++ b/tests/unit/test_runtime_paths.py @@ -0,0 +1,13 @@ +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_socket_publisher.py b/tests/unit/test_socket_publisher.py new file mode 100644 index 0000000..76e333a --- /dev/null +++ b/tests/unit/test_socket_publisher.py @@ -0,0 +1,56 @@ +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) From b58f15ae1e6ff027bf7a651c2cd0127a22c29b81 Mon Sep 17 00:00:00 2001 From: sedataym Date: Fri, 23 Oct 2026 15:37:35 +0300 Subject: [PATCH 13/24] chore: standardize style, translations and contributor documentation --- .github/ISSUE_TEMPLATE/bug_report.yml | 17 ++ .github/pull_request_template.md | 7 + .gitignore | 7 + CHANGELOG.md | 13 ++ CONTRIBUTING.md | 31 +++ README.md | 10 +- docs/appimage.md | 13 +- docs/architecture.md | 18 ++ docs/development.md | 11 ++ docs/development/cleanup-progress.md | 2 + docs/model-provenance.md | 5 + pyproject.toml | 18 +- src/usta/application/controller.py | 2 + src/usta/application/hotkey_controller.py | 2 + src/usta/application/pipeline.py | 75 +++++-- src/usta/application/region_controller.py | 8 +- src/usta/application/results.py | 5 + src/usta/application/settings.py | 59 +++--- src/usta/application/worker.py | 39 +++- src/usta/config.py | 2 +- src/usta/core/exceptions.py | 3 +- src/usta/core/ocr/apple_vision_engine.py | 23 ++- src/usta/core/ocr/easyocr_engine.py | 16 +- src/usta/core/ocr/ocr_manager.py | 18 +- src/usta/core/ocr/paddleocr_engine.py | 6 +- src/usta/core/ocr/rapidocr_compat.py | 11 +- src/usta/core/ocr/rapidocr_engine.py | 16 +- src/usta/core/ocr/tesseract_engine.py | 24 ++- src/usta/core/platform/capabilities.py | 1 - src/usta/core/platform/capability_resolver.py | 1 - src/usta/core/platform/linux/capabilities.py | 1 - .../core/platform/linux/kde/capabilities.py | 2 +- src/usta/core/platform/macos/capabilities.py | 1 - .../core/platform/macos/hotkey_listener.py | 1 + src/usta/core/platform/system_info.py | 1 - .../core/platform/windows/capabilities.py | 1 - src/usta/core/registry.py | 18 +- src/usta/core/screenshot/__init__.py | 3 +- src/usta/core/screenshot/base_screenshot.py | 2 + src/usta/core/screenshot/image_processor.py | 7 +- src/usta/core/screenshot/portal_screenshot.py | 161 ++++++++++----- .../core/screenshot/screenshot_factory.py | 16 +- .../core/screenshot/spectacle_screenshot.py | 16 +- src/usta/core/shortcut.py | 1 + src/usta/core/translation/deepl_engine.py | 17 +- src/usta/core/translation/google_engine.py | 12 +- .../core/translation/translator_manager.py | 21 +- src/usta/i18n.py | 20 +- src/usta/infrastructure/legacy_settings.py | 13 +- src/usta/infrastructure/logging_setup.py | 6 +- src/usta/infrastructure/paths.py | 21 +- src/usta/infrastructure/settings_store.py | 53 +++-- src/usta/infrastructure/socket_publisher.py | 50 +++-- src/usta/main.py | 20 +- src/usta/resource_paths.py | 4 +- .../translations/tr/LC_MESSAGES/messages.mo | Bin 4493 -> 4605 bytes .../translations/tr/LC_MESSAGES/messages.po | 44 ++++- .../translations/uk/LC_MESSAGES/messages.mo | Bin 5506 -> 6022 bytes .../translations/uk/LC_MESSAGES/messages.po | 38 +++- src/usta/ui/main_window.py | 144 ++++++++------ src/usta/ui/overlay_window.py | 46 +++-- src/usta/ui/platform/macos/hotkeys.py | 19 +- src/usta/ui/platform/macos/overlay.py | 14 +- src/usta/ui/region_selection/base_sniper.py | 2 + src/usta/ui/region_selection/core_sniper.py | 35 ++-- src/usta/ui/region_selection/slurp_sniper.py | 11 +- .../ui/region_selection/sniper_factory.py | 4 +- src/usta/ui/settings_presenter.py | 129 +++++++----- src/usta/ui/tabs/__init__.py | 16 +- src/usta/ui/tabs/about_tab.py | 4 - src/usta/ui/tabs/appearance_tab.py | 38 ++-- src/usta/ui/tabs/hotkey_tab.py | 20 +- src/usta/ui/tabs/ocr_tab.py | 15 +- src/usta/ui/tabs/save_tab.py | 25 ++- src/usta/ui/tabs/status_tab.py | 17 +- src/usta/ui/tabs/translation_tab.py | 13 +- tests/helpers/macos_fullscreen_host.py | 32 ++- tests/integration/test_control_panel.py | 50 +++-- tests/integration/test_linux_windows.py | 47 +++-- tests/native/test_macos_hotkey_actions.py | 184 ++++++++++++------ tests/native/test_macos_overlay.py | 69 ++++--- tests/packaging/test_artifacts.py | 20 +- tests/packaging/test_metadata.py | 21 +- tests/unit/test_apple_vision_engine.py | 88 +++++---- tests/unit/test_architecture.py | 22 ++- tests/unit/test_bootstrap.py | 9 +- tests/unit/test_easyocr_engine.py | 65 ++++--- tests/unit/test_engine_contracts.py | 28 +-- tests/unit/test_i18n.py | 5 +- tests/unit/test_pipeline_lifecycle.py | 101 +++++++--- tests/unit/test_runtime_paths.py | 11 +- tests/unit/test_settings_store.py | 56 ++++-- tests/unit/test_shortcut.py | 54 +++-- tests/unit/test_socket_publisher.py | 62 +++--- 94 files changed, 1636 insertions(+), 853 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/pull_request_template.md create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 docs/architecture.md create mode 100644 docs/development.md create mode 100644 docs/model-provenance.md 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/.gitignore b/.gitignore index c7ff63c..b69ade2 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,10 @@ target/ #Ipython Notebook .ipynb_checkpoints + +# Local environments and tooling +.venv/ +.ruff_cache/ +.mypy_cache/ +.pytest_cache/ +.superpowers/ 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/README.md b/README.md index 3cd65f3..e92ea5a 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. --- @@ -219,6 +219,10 @@ 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. +## 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 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/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 index eb6a9f8..8d46eff 100644 --- a/docs/development/cleanup-progress.md +++ b/docs/development/cleanup-progress.md @@ -32,3 +32,5 @@ Task 7: Event-controlled delayed API, reconfiguration and EngineError tests pass 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. 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/pyproject.toml b/pyproject.toml index e63bb76..60a9898 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,7 +52,23 @@ include = ["usta*"] [project.optional-dependencies] paddle = ["paddleocr>=3.6.0", "paddlepaddle"] -dev = ["ruff", "mypy", "build", "coverage"] +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/src/usta/application/controller.py b/src/usta/application/controller.py index 2206cd0..314cf49 100644 --- a/src/usta/application/controller.py +++ b/src/usta/application/controller.py @@ -1,4 +1,6 @@ """Application actions consumed by views; the worker owns mutable engines.""" + + class ApplicationController: def __init__(self, worker): self.worker = worker diff --git a/src/usta/application/hotkey_controller.py b/src/usta/application/hotkey_controller.py index 1aef9be..5043a91 100644 --- a/src/usta/application/hotkey_controller.py +++ b/src/usta/application/hotkey_controller.py @@ -1,6 +1,8 @@ """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 diff --git a/src/usta/application/pipeline.py b/src/usta/application/pipeline.py index 7c24159..7ffaef8 100644 --- a/src/usta/application/pipeline.py +++ b/src/usta/application/pipeline.py @@ -1,9 +1,10 @@ """Single-owner capture loop with immutable translation jobs.""" -from concurrent.futures import ThreadPoolExecutor -from copy import deepcopy + import logging import threading import time +from concurrent.futures import ThreadPoolExecutor +from copy import deepcopy from usta.application.results import TranslationRequest from usta.application.settings import AppSettings @@ -14,13 +15,25 @@ 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_translating=lambda value: None): + 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_translating=lambda value: None, + ): self.capture_factory = capture_factory self.publisher = publisher self.image_path = image_path @@ -37,7 +50,7 @@ def __init__(self, *, capture_factory, publisher, image_path, on_translation, self._stopped = threading.Event() self._stopped.set() self._session = 0 - self._executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix='usta-translation') + self._executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="usta-translation") self._future = None self._job = None @@ -88,15 +101,19 @@ def _collect(self): 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.publisher.broadcast( + original=job.text, + translated=result.translated_text, + source_lang=job.source_lang, + target_lang=job.target_lang, + engine=job.engine_name, + ) def run(self) -> None: capture = None applied = -1 capture_name = None - last_text = '' + last_text = "" ocr = self.ocr_factory() try: self.publisher.start() @@ -112,32 +129,48 @@ def run(self) -> None: 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)) + ocr.set_language( + get_language_code(settings.source_lang, settings.source_lang) + ) applied = session - last_text = '' + last_text = "" self._collect() if self._stopped.is_set(): break - if capture.capture(self.rect_factory(settings.capture_rect), self.image_path, settings.dpi_scale): - if settings.ocr_engine == 'Tesseract' and self.preprocess: + if capture.capture( + self.rect_factory(settings.capture_rect), + self.image_path, + settings.dpi_scale, + ): + if settings.ocr_engine == "Tesseract" and self.preprocess: self.preprocess(self.image_path, self.image_path) text = ocr.read_result(self.image_path).text 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, + 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) + settings.translator_engine, + session, + ) self._job = request - self._future = self._executor.submit(self._translate, request, settings.translator_api_keys) + self._future = self._executor.submit( + self._translate, request, settings.translator_api_keys + ) last_text = text self.on_translating(True) self.on_performance(time.perf_counter() - started) except PortalCanceledError: self.stop() except Exception as exc: - logger.exception('Capture/OCR pipeline failed') + logger.exception("Capture/OCR pipeline failed") self.on_error(str(exc)) self._stopped.wait(self.LOOP_SLEEP_SECONDS) except Exception as exc: diff --git a/src/usta/application/region_controller.py b/src/usta/application/region_controller.py index ae7183a..eb4df97 100644 --- a/src/usta/application/region_controller.py +++ b/src/usta/application/region_controller.py @@ -1,6 +1,8 @@ """Main/temporary capture-region state and its restoration timer.""" + from PySide6.QtCore import QObject, QRect, QTimer, Signal + class RegionController(QObject): changed = Signal(str) @@ -22,7 +24,7 @@ def apply_main_region(self, rect, dpi_scale=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()}') + 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: @@ -33,7 +35,9 @@ def apply_temporary_region(self, rect, dpi_scale=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)') + 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: diff --git a/src/usta/application/results.py b/src/usta/application/results.py index 3ab9d8e..288530c 100644 --- a/src/usta/application/results.py +++ b/src/usta/application/results.py @@ -1,11 +1,14 @@ """Values crossing OCR/translation application boundaries.""" + from dataclasses import dataclass + @dataclass(frozen=True) class OCRResult: text: str confidence: float | None = None + @dataclass(frozen=True) class TranslationRequest: text: str @@ -14,6 +17,7 @@ class TranslationRequest: engine_name: str session_id: int + @dataclass(frozen=True) class TranslationResult: translated_text: str @@ -22,6 +26,7 @@ class TranslationResult: engine_name: str session_id: int + class EngineError(Exception): def __init__(self, code: str, message: str): super().__init__(message) diff --git a/src/usta/application/settings.py b/src/usta/application/settings.py index 4e71110..449fb31 100644 --- a/src/usta/application/settings.py +++ b/src/usta/application/settings.py @@ -1,8 +1,10 @@ """Persistent values, independent of Qt and filesystem access.""" -from dataclasses import asdict, dataclass, field, fields + 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) @@ -10,52 +12,65 @@ class Preset: overlay_y: int = 0 overlay_w: int = 400 overlay_h: int = 150 - font_family: str = 'Arial' + font_family: str = "Arial" font_size: int = 20 - font_color: str = 'white' - bg_color: str = '#000000' + 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') + raise ValueError("Settings must be an object") defaults = cls() - known = {f.name for f in fields(cls)} - {'extra'} + 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) + 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 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: + if name == "font_size" and value <= 0: value = default - if name == 'bg_opacity' and not 0 <= value <= 255: + 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') + 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' + 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' + 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 index 2b457d7..98470d8 100644 --- a/src/usta/application/worker.py +++ b/src/usta/application/worker.py @@ -1,11 +1,15 @@ """Qt signals and thread lifecycle around the application pipeline.""" + from dataclasses import replace -from PySide6.QtCore import QThread, Signal, QRect + +from PySide6.QtCore import QRect, QThread, Signal + from usta.application.pipeline import Pipeline +from usta.core.screenshot import ImageProcessor, ScreenshotFactory from usta.infrastructure.paths import CaptureWorkspace -from usta.core.screenshot import ScreenshotFactory, ImageProcessor from usta.infrastructure.socket_publisher import TranslationPublisher + class OCRWorker(QThread): LOOP_SLEEP_SECONDS = Pipeline.LOOP_SLEEP_SECONDS new_translation = Signal(str) @@ -17,11 +21,19 @@ class OCRWorker(QThread): 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.new_translation.emit, on_error=self.error_occurred.emit, - on_performance=self.performance_update.emit, on_translating=self.translation_status.emit, - rect_factory=lambda values: QRect(*values), preprocess=ImageProcessor.preprocess_for_tesseract) + 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.new_translation.emit, + on_error=self.error_occurred.emit, + on_performance=self.performance_update.emit, + on_translating=self.translation_status.emit, + rect_factory=lambda values: QRect(*values), + preprocess=ImageProcessor.preprocess_for_tesseract, + ) self._restart_requested = False self.finished.connect(self._restart_if_requested) @@ -39,8 +51,13 @@ def dpi_scale(self, 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)) + 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)) @@ -52,7 +69,9 @@ 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)) + 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 diff --git a/src/usta/config.py b/src/usta/config.py index fa330e7..7997545 100644 --- a/src/usta/config.py +++ b/src/usta/config.py @@ -2,7 +2,6 @@ import tempfile from usta._version import __version__ as APP_VERSION - from usta.core.platform.capability_resolver import ( CONFIG_DIR, LANGUAGES, @@ -45,6 +44,7 @@ def get_translation_language_code(language_code: str, provider: str, direction: return language_code return language_code + SETTINGS_FILE = os.path.join(CONFIG_DIR, "settings.pkl") PRESETS_FILE = os.path.join(CONFIG_DIR, "presets.pkl") diff --git a/src/usta/core/exceptions.py b/src/usta/core/exceptions.py index da3dbfc..cdac5d0 100644 --- a/src/usta/core/exceptions.py +++ b/src/usta/core/exceptions.py @@ -1,3 +1,4 @@ class PortalCanceledError(RuntimeError): """Raised when the user cancels or rejects the XDG Desktop Portal screen selection dialog.""" - pass \ No newline at end of file + + pass diff --git a/src/usta/core/ocr/apple_vision_engine.py b/src/usta/core/ocr/apple_vision_engine.py index 8eb329b..bf6b1d1 100644 --- a/src/usta/core/ocr/apple_vision_engine.py +++ b/src/usta/core/ocr/apple_vision_engine.py @@ -1,4 +1,5 @@ """On-device text recognition using macOS Vision through PyObjC.""" + import logging from pathlib import Path @@ -25,17 +26,27 @@ def _make_request(self, vision): 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) + 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) + 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) + 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] diff --git a/src/usta/core/ocr/easyocr_engine.py b/src/usta/core/ocr/easyocr_engine.py index 41e5e42..d442ccf 100644 --- a/src/usta/core/ocr/easyocr_engine.py +++ b/src/usta/core/ocr/easyocr_engine.py @@ -1,34 +1,36 @@ import logging import warnings -from usta.core.ocr.base_ocr import BaseOCREngine 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'] + 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}) - + 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 + 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": diff --git a/src/usta/core/ocr/ocr_manager.py b/src/usta/core/ocr/ocr_manager.py index d48e169..b919d0f 100644 --- a/src/usta/core/ocr/ocr_manager.py +++ b/src/usta/core/ocr/ocr_manager.py @@ -1,13 +1,21 @@ import sys -from usta.core.registry import LazyEngines, OCR_FACTORIES + 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' + 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: diff --git a/src/usta/core/ocr/paddleocr_engine.py b/src/usta/core/ocr/paddleocr_engine.py index 70eb932..098cc47 100644 --- a/src/usta/core/ocr/paddleocr_engine.py +++ b/src/usta/core/ocr/paddleocr_engine.py @@ -1,6 +1,7 @@ import logging -from usta.core.ocr.base_ocr import BaseOCREngine + from usta.config import OCR_LANG_MAPPING +from usta.core.ocr.base_ocr import BaseOCREngine logger = logging.getLogger(__name__) @@ -25,12 +26,13 @@ def read_text(self, image_path: str) -> str: if self.ocr is None and self._unavailable_reason is None: try: from paddleocr import PaddleOCR + 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" diff --git a/src/usta/core/ocr/rapidocr_compat.py b/src/usta/core/ocr/rapidocr_compat.py index 098d5c7..a81d98b 100644 --- a/src/usta/core/ocr/rapidocr_compat.py +++ b/src/usta/core/ocr/rapidocr_compat.py @@ -2,14 +2,16 @@ 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 @@ -33,11 +35,7 @@ def limited_init(session_self, config): } ep_list = [] - if ( - config["use_cuda"] - and get_device() == "GPU" - and cuda_ep in get_available_providers() - ): + 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)) @@ -55,4 +53,3 @@ def limited_init(session_self, config): limited_init._usta_cpu_limited = True OrtInferSession.__init__ = limited_init logger.info("RapidOCREngine: OrtInferSession monkey-patched for CPU thread limiting") - diff --git a/src/usta/core/ocr/rapidocr_engine.py b/src/usta/core/ocr/rapidocr_engine.py index 4710749..5d3f20b 100644 --- a/src/usta/core/ocr/rapidocr_engine.py +++ b/src/usta/core/ocr/rapidocr_engine.py @@ -1,10 +1,9 @@ -import logging import glob +import logging import os -from usta.core.ocr.base_ocr import BaseOCREngine from usta.config import OCR_LANG_MAPPING - +from usta.core.ocr.base_ocr import BaseOCREngine from usta.resource_paths import resource_path logger = logging.getLogger(__name__) @@ -36,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: - logger.info(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] - logger.info(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): @@ -59,6 +62,7 @@ def _apply_cpu_limit(self): def _patch_rapidocr_ort_session(self): from usta.core.ocr.rapidocr_compat import patch_ort_session + patch_ort_session(self.cpu_threads) def _initialize(self) -> bool: @@ -161,4 +165,4 @@ def read_text(self, image_path: str) -> str: return " ".join(clean.split()).strip().strip("_").rstrip(":") except Exception as e: logger.error(f"RapidOCR Error: {e}") - return "" \ No newline at end of file + return "" diff --git a/src/usta/core/ocr/tesseract_engine.py b/src/usta/core/ocr/tesseract_engine.py index 227f51a..343dc32 100644 --- a/src/usta/core/ocr/tesseract_engine.py +++ b/src/usta/core/ocr/tesseract_engine.py @@ -1,11 +1,13 @@ import logging -import subprocess import re -from usta.core.ocr.base_ocr import BaseOCREngine +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): self.lang = "eng" @@ -16,22 +18,24 @@ def set_language(self, lang_code: str): 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, 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: logger.error(f"Tesseract Error: {e}") diff --git a/src/usta/core/platform/capabilities.py b/src/usta/core/platform/capabilities.py index 5fb38a9..5f7d633 100644 --- a/src/usta/core/platform/capabilities.py +++ b/src/usta/core/platform/capabilities.py @@ -1,6 +1,5 @@ import os - from usta.core.registry import DEFAULT_OCR_NAMES, TRANSLATOR_FACTORIES OCR_ENGINES = list(DEFAULT_OCR_NAMES) diff --git a/src/usta/core/platform/capability_resolver.py b/src/usta/core/platform/capability_resolver.py index 941d385..74d3b98 100644 --- a/src/usta/core/platform/capability_resolver.py +++ b/src/usta/core/platform/capability_resolver.py @@ -5,7 +5,6 @@ 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", "TRANSLATION_ENGINES", diff --git a/src/usta/core/platform/linux/capabilities.py b/src/usta/core/platform/linux/capabilities.py index 63d8a34..4809842 100644 --- a/src/usta/core/platform/linux/capabilities.py +++ b/src/usta/core/platform/linux/capabilities.py @@ -5,7 +5,6 @@ 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/usta/core/platform/linux/kde/capabilities.py b/src/usta/core/platform/linux/kde/capabilities.py index a17f4eb..fbc018c 100644 --- a/src/usta/core/platform/linux/kde/capabilities.py +++ b/src/usta/core/platform/linux/kde/capabilities.py @@ -3,5 +3,5 @@ Only define values here when they differ from usta.core.platform.capabilities. """ -SCREENSHOT_ENGINES = [ "Portal", "Spectacle"] +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 index 012eba3..5686829 100644 --- a/src/usta/core/platform/macos/capabilities.py +++ b/src/usta/core/platform/macos/capabilities.py @@ -7,7 +7,6 @@ from usta.core.platform.capabilities import OCR_ENGINES as BASE_OCR_ENGINES - OCR_ENGINES = [*BASE_OCR_ENGINES, "Apple Vision"] SCREENSHOT_ENGINES = ["MacOS"] diff --git a/src/usta/core/platform/macos/hotkey_listener.py b/src/usta/core/platform/macos/hotkey_listener.py index 0174dc9..7298bb7 100644 --- a/src/usta/core/platform/macos/hotkey_listener.py +++ b/src/usta/core/platform/macos/hotkey_listener.py @@ -1,4 +1,5 @@ """Keep macOS keyboard-layout queries off pynput's listener thread.""" + import threading from pynput import keyboard diff --git a/src/usta/core/platform/system_info.py b/src/usta/core/platform/system_info.py index 4a81caa..9ef58a5 100644 --- a/src/usta/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/usta/core/platform/windows/capabilities.py b/src/usta/core/platform/windows/capabilities.py index 6e88154..f3011ae 100644 --- a/src/usta/core/platform/windows/capabilities.py +++ b/src/usta/core/platform/windows/capabilities.py @@ -5,7 +5,6 @@ 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 index 96f8c40..7d9f59b 100644 --- a/src/usta/core/registry.py +++ b/src/usta/core/registry.py @@ -1,20 +1,22 @@ """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'), + "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'] +DEFAULT_OCR_NAMES = ["RapidOCR", "Tesseract", "EasyOCR"] TRANSLATOR_FACTORIES = { - 'Google': ('usta.core.translation.google_engine', 'GoogleEngine'), - 'DeepL': ('usta.core.translation.deepl_engine', 'DeepLTranslatorEngine'), + "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 diff --git a/src/usta/core/screenshot/__init__.py b/src/usta/core/screenshot/__init__.py index 9547aa3..df734f2 100644 --- a/src/usta/core/screenshot/__init__.py +++ b/src/usta/core/screenshot/__init__.py @@ -1,6 +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'] +__all__ = ["BaseScreenshot", "ImageProcessor", "ScreenshotFactory"] diff --git a/src/usta/core/screenshot/base_screenshot.py b/src/usta/core/screenshot/base_screenshot.py index 387a71b..6b7dd85 100644 --- a/src/usta/core/screenshot/base_screenshot.py +++ b/src/usta/core/screenshot/base_screenshot.py @@ -1,6 +1,8 @@ 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: diff --git a/src/usta/core/screenshot/image_processor.py b/src/usta/core/screenshot/image_processor.py index 89a08e5..4d71654 100644 --- a/src/usta/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/portal_screenshot.py b/src/usta/core/screenshot/portal_screenshot.py index c4d7dc1..7e684fa 100644 --- a/src/usta/core/screenshot/portal_screenshot.py +++ b/src/usta/core/screenshot/portal_screenshot.py @@ -1,5 +1,5 @@ -import logging import asyncio +import logging import os import secrets import threading @@ -33,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: @@ -88,7 +89,9 @@ 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(asyncio.wait_for(self._start_portal_session(), timeout=60.0)) + self._loop.run_until_complete( + asyncio.wait_for(self._start_portal_session(), timeout=60.0) + ) self._start_gstreamer_pipeline() self._started = True @@ -123,7 +126,9 @@ def close(self): if self._session_handle and self._bus is not None: try: - self._loop.run_until_complete(asyncio.wait_for(self._close_portal_session(), timeout=3.0)) + self._loop.run_until_complete( + asyncio.wait_for(self._close_portal_session(), timeout=3.0) + ) except Exception as exc: logger.error(f"Portal Close Error: {exc}") @@ -140,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, " @@ -160,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(".", "_") @@ -192,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: @@ -213,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 @@ -223,24 +238,48 @@ 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: @@ -254,14 +293,16 @@ async def _start_portal_session(self): 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: @@ -274,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: @@ -306,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() logger.info(f"Portal GStreamer pipeline is ready: {pipeline_description}") return except Exception as exc: last_error = exc - logger.error(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) @@ -372,16 +419,22 @@ 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, self.full_screen_path) width, height = image.size diff --git a/src/usta/core/screenshot/screenshot_factory.py b/src/usta/core/screenshot/screenshot_factory.py index b1f9d97..4311476 100644 --- a/src/usta/core/screenshot/screenshot_factory.py +++ b/src/usta/core/screenshot/screenshot_factory.py @@ -1,22 +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': + 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': + 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') + 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/usta/core/screenshot/spectacle_screenshot.py b/src/usta/core/screenshot/spectacle_screenshot.py index 7360140..d08d196 100644 --- a/src/usta/core/screenshot/spectacle_screenshot.py +++ b/src/usta/core/screenshot/spectacle_screenshot.py @@ -1,13 +1,16 @@ import logging import os import subprocess + from PIL import Image from PySide6.QtCore import QRect -from usta.core.screenshot.base_screenshot import BaseScreenshot + 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 @@ -15,17 +18,20 @@ def __init__(self, full_screen_path=FULL_SCREEN_TEMP_PATH): def capture(self, rect: QRect, output_path: str, dpi_scale: float = 1.0) -> bool: 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", self.full_screen_path], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True, timeout=15 + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=True, + timeout=15, ) - + if not os.path.exists(self.full_screen_path): return False - + 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)) diff --git a/src/usta/core/shortcut.py b/src/usta/core/shortcut.py index f18d40d..c9217f3 100644 --- a/src/usta/core/shortcut.py +++ b/src/usta/core/shortcut.py @@ -54,6 +54,7 @@ def start(self) -> bool: listener_type = keyboard.Listener if sys.platform == "darwin": from usta.core.platform.macos.hotkey_listener import MainThreadLayoutListener + listener_type = MainThreadLayoutListener self._listener = listener_type( on_press=self._on_press, diff --git a/src/usta/core/translation/deepl_engine.py b/src/usta/core/translation/deepl_engine.py index 4b294a1..da7942a 100644 --- a/src/usta/core/translation/deepl_engine.py +++ b/src/usta/core/translation/deepl_engine.py @@ -1,6 +1,8 @@ import logging import os + import deepl + from usta.application.results import EngineError from usta.config import get_translation_language_code from usta.core.translation.base_translator import BaseTranslator @@ -9,11 +11,8 @@ 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 @@ -37,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": - raise EngineError("missing_key", "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: @@ -51,4 +52,6 @@ def translate(self, text: str) -> str: return result.text raise EngineError("initialization", "DeepL could not be initialized.") except Exception as e: - raise EngineError("translation", "DeepL translation failed; check credentials and connection.") from e + raise EngineError( + "translation", "DeepL translation failed; check credentials and connection." + ) from e diff --git a/src/usta/core/translation/google_engine.py b/src/usta/core/translation/google_engine.py index d73db2b..59a6b21 100644 --- a/src/usta/core/translation/google_engine.py +++ b/src/usta/core/translation/google_engine.py @@ -1,7 +1,9 @@ -import logging import asyncio -from usta.application.results import EngineError +import logging + from googletrans import Translator + +from usta.application.results import EngineError from usta.config import get_translation_language_code from usta.core.translation.base_translator import BaseTranslator @@ -9,7 +11,7 @@ class GoogleEngine(BaseTranslator): - def __init__(self, source='en', target='tr'): + def __init__(self, source="en", target="tr"): self.source = source self.target = target @@ -29,4 +31,6 @@ async def translate_text(): return asyncio.run(translate_text()) except Exception as e: - raise EngineError("translation", "Google translation failed; check the connection.") from 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 index 15b9973..5a355c7 100644 --- a/src/usta/core/translation/translator_manager.py +++ b/src/usta/core/translation/translator_manager.py @@ -1,17 +1,18 @@ -from usta.core.registry import LazyEngines, TRANSLATOR_FACTORIES 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._source_lang = "en" + self._target_lang = "tr" self._api_keys = {} self.translators = LazyEngines(TRANSLATOR_FACTORIES, self._initialize) - self.current_translator_name = 'Google' + 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'): + 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): @@ -21,7 +22,7 @@ def set_translator(self, name: str): 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'): + if translator and hasattr(translator, "set_api_key"): translator.set_api_key(api_key) def set_languages(self, source: str, target: str): @@ -38,4 +39,10 @@ def translate(self, text: str) -> str: 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) + return TranslationResult( + self.translate(request.text), + request.source_lang, + request.target_lang, + request.engine_name, + request.session_id, + ) diff --git a/src/usta/i18n.py b/src/usta/i18n.py index ee39985..c636f89 100644 --- a/src/usta/i18n.py +++ b/src/usta/i18n.py @@ -1,12 +1,12 @@ -import logging -from usta.resource_paths import resource_path - 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 @@ -17,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): @@ -77,13 +73,14 @@ 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 locale_dir = str(resource_path("resources/translations")) @@ -94,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: 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/legacy_settings.py b/src/usta/infrastructure/legacy_settings.py index ac41fe2..b6441c4 100644 --- a/src/usta/infrastructure/legacy_settings.py +++ b/src/usta/infrastructure/legacy_settings.py @@ -1,20 +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') + raise ValueError("Legacy settings cannot contain executable globals") def persistent_load(self, pid): - raise ValueError('Legacy settings cannot contain persistent references') + raise ValueError("Legacy settings cannot contain persistent references") + def load_legacy(path: Path) -> dict: try: - with path.open('rb') as stream: + with path.open("rb") as stream: value = PrimitiveUnpickler(stream).load() if not isinstance(value, dict): - raise ValueError('Legacy settings must be an object') + raise ValueError("Legacy settings must be an object") return value except (pickle.UnpicklingError, EOFError, TypeError) as exc: - raise ValueError('Invalid legacy settings') from exc + raise ValueError("Invalid legacy settings") from exc diff --git a/src/usta/infrastructure/logging_setup.py b/src/usta/infrastructure/logging_setup.py index beb2ae1..25c77f4 100644 --- a/src/usta/infrastructure/logging_setup.py +++ b/src/usta/infrastructure/logging_setup.py @@ -1,5 +1,7 @@ """Application-owned logging configuration.""" + import logging -def configure_logging(level: str = 'INFO') -> None: - logging.basicConfig(level=level, format='%(levelname)s %(name)s: %(message)s') + +def configure_logging(level: str = "INFO") -> None: + logging.basicConfig(level=level, format="%(levelname)s %(name)s: %(message)s") diff --git a/src/usta/infrastructure/paths.py b/src/usta/infrastructure/paths.py index ebcf8a8..5cfba31 100644 --- a/src/usta/infrastructure/paths.py +++ b/src/usta/infrastructure/paths.py @@ -1,29 +1,40 @@ """Runtime path policy; resolving paths does not create files.""" + +import tempfile from dataclasses import dataclass from pathlib import Path -import tempfile + 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') + 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') + 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 index e920ddb..64efc92 100644 --- a/src/usta/infrastructure/settings_store.py +++ b/src/usta/infrastructure/settings_store.py @@ -1,26 +1,33 @@ """Versioned, atomic settings storage with non-destructive legacy migration.""" + import json import os import tempfile from pathlib import Path + from usta.application.settings import AppSettings, Preset from usta.infrastructure.legacy_settings import load_legacy + 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' + 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') + 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 content["data"] + legacy = self.directory / f"{name}.pkl" return load_legacy(legacy) if legacy.exists() else {} except (ValueError, OSError): self._blocked.add(name) @@ -28,16 +35,18 @@ def _read(self, name: str) -> dict: def _write(self, name: str, data: dict) -> None: if name in self._blocked: - raise ValueError('Cannot overwrite unreadable settings; original file preserved') + 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' + path = self.directory / f"{name}.json" if path.exists(): self._read(name) self.directory.mkdir(parents=True, exist_ok=True) - descriptor, temporary = tempfile.mkstemp(suffix='.tmp', dir=self.directory) + 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) + 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) @@ -46,21 +55,27 @@ def _write(self, name: str, data: dict) -> None: 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(): + 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 AppSettings.from_dict(self._read("settings")) return settings def save(self, settings: AppSettings) -> None: - self._write('settings', settings.to_dict()) + self._write("settings", settings.to_dict()) def load_presets(self) -> dict[str, Preset]: - presets = {name: Preset.from_dict(value) for name, value in self._read('presets').items()} - if not (self.directory / 'presets.json').exists() and (self.directory / 'presets.pkl').exists(): + presets = {name: Preset.from_dict(value) for name, value in self._read("presets").items()} + if ( + 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 {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()}) + 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 index d390b57..f9dc3d4 100644 --- a/src/usta/infrastructure/socket_publisher.py +++ b/src/usta/infrastructure/socket_publisher.py @@ -1,18 +1,21 @@ """Bounded, non-blocking Unix socket translation broadcast.""" + import errno import json import logging -from pathlib import Path 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 @@ -31,7 +34,7 @@ def start(self): 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') + 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: @@ -40,9 +43,9 @@ def start(self): if exc.errno != errno.ECONNREFUSED: raise else: - raise OSError(errno.EADDRINUSE, 'USTA IPC socket is already in use') + 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') + raise OSError(errno.EADDRINUSE, "IPC path changed during startup") self.path.unlink() server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) try: @@ -56,7 +59,7 @@ def start(self): 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 = threading.Thread(target=self._run, name="usta-ipc", daemon=True) self._thread.start() def stop(self): @@ -64,7 +67,7 @@ def stop(self): 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') + logger.error("IPC thread did not stop within deadline") return self._thread = None if self._server is not None: @@ -78,23 +81,34 @@ def stop(self): pass self._identity = None - def broadcast(self, original, translated, source_lang='en', target_lang='tr', engine='unknown'): + 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') + 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') + 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) + readable, writable, _ = select.select( + [self._server], [c for c, pending in clients.items() if pending], [], 0.05 + ) if readable: while True: try: @@ -111,7 +125,8 @@ def _run(self): for client in list(clients): pending = clients[client] if len(pending) + len(data) > self.MAX_PENDING_BYTES: - client.close(); del clients[client] + client.close() + del clients[client] else: pending.extend(data) for client in writable: @@ -120,15 +135,16 @@ def _run(self): try: sent = client.send(clients[client]) if sent == 0: - raise ConnectionError('IPC peer closed') + raise ConnectionError("IPC peer closed") del clients[client][:sent] except BlockingIOError: pass except OSError: - client.close(); del clients[client] + client.close() + del clients[client] except OSError: if not self._stopped.is_set(): - logger.exception('IPC loop failed') + logger.exception("IPC loop failed") finally: for client in clients: client.close() diff --git a/src/usta/main.py b/src/usta/main.py index 3f8cab7..ca41756 100644 --- a/src/usta/main.py +++ b/src/usta/main.py @@ -1,33 +1,35 @@ -import sys import os +import sys from PySide6.QtGui import QIcon from PySide6.QtWidgets import QApplication + from usta.config import OS -from usta.ui.main_window import ControlPanel from usta.i18n import init_i18n - -from usta.infrastructure.paths import resolve_paths, prepare_paths 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") - + # Set application icon icon_path = os.path.join(os.path.dirname(__file__), "ui", "assets", "usta.png") app.setWindowIcon(QIcon(icon_path)) - - panel = ControlPanel() + + 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 index bd1abff..3a5bcc0 100644 --- a/src/usta/resource_paths.py +++ b/src/usta/resource_paths.py @@ -1,4 +1,5 @@ """Resolve installed resources, including resources from zipped packages.""" + import atexit from contextlib import ExitStack from functools import lru_cache @@ -8,7 +9,8 @@ _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))) + return _contexts.enter_context(as_file(files("usta").joinpath(relative))) diff --git a/src/usta/resources/translations/tr/LC_MESSAGES/messages.mo b/src/usta/resources/translations/tr/LC_MESSAGES/messages.mo index 0eddca758c3dc12950e6272a784e556fb322b88a..d7c97202effa14c3988e693e8c6a3a235d04dee1 100644 GIT binary patch delta 1474 zcmZ|NUuaWT9Ki8o?HbpZ)YjFKP3c)Ht(!|sx>i^jb878GMs@A>uu?~_-rFuVy(u^M zKGeAd21E9sjAi131r_lRbq}i@o2U$H(y`%-Lc5|!HxOlmDLxbxrsDUfSFH~V$^D#j zekbSmJLjHVwfAeY(-p;g6rXqbEtX#qk6E9^#Y&aYUxHzbVhrELMc9Y+_$3B#7+=5> zSc)fcA?EQtoWe@1X3~14vZ{%OO#A|+fUkXz-~#%2d=Tj zMdC|wF2*p34fqPagRf#IR+C@tpz$&TLnsM;#&Rs+68r;Yff*#%qwG>Z2@f`-!YC)R z5I10r-|xpL{T(Pf9!B~9S(J_aiY4S%Q#AOhX+LlspdGR#9><&VAv%t~N_hd9ln0{=$Y@qgGohvIlYaw4BH7{To* zci=meLi3owOBli+$0+MXP+n~ zv>nNzzVRJGDfk#l!qX@T$Nl&Plsj<6@Bf9G{=Zm5Jfx28SYR2$c}%TKSfdy}@+tTv@ABV%dH zb}dI6dcd(VmaA=6h&_pA9xd}M=%%&Fko$i#a-l#)dHrXy#^?3gCUp;~bCrSiK)Yw# zNxL`K6Sx@YaMJ?=mN~j|UT0C=htai;Wk{{j7Q?lgbwj*iWi-yWNhg|`8xzapZ^q-f zU8Q4lJ-27{gW$JCy6{6L*{?rN_o)smf7EZT@lqa{4YM$kyAg`4+LCm1{wG@-g9iI! zRQD#Gq#aZ5=g+u3maQ|%6bG@@>eJ6n&B&5=EO$SYsD8SaCkwI4mRWh;2zQ0@JB?If dWPE>NWH9aIc7|`3ws?-|IbO>2M%}VV(L)n*_8kBK delta 1365 zcmY+@Pe@cz6vy#1qmKWYu`Fw>OiM>i(;2gxgh7g+#KIP8WQk-VCYb|jZ2~9A!c7@I zYSShRYSV5I3PPx)MG)AcmC&X?m}LuB{h>eK-}qc~nDajGzBljPd(XY^cj=AN#Jl|T z+eS2yTgeX|v*Y+EjRUceZkC6iF@Qg?7PB(UHewVjumv;m67sWA4mmi6>u~~);C(E> zFW6+3FpqELkIkq7w7U-BT0Mvx@E)$gX;eqg-TSZH>+`5FFJLZy#bW&K-VgH7I@%#D z!YJm_zqND1V5}RNob{n*I)Kf1*=@hX8rpABGf2x?d9DyO&|;)7i@5D5>VHjGfnBJ9 zT}M@95{u~H9&pl)vv?EBvsVT%jp}d~xn;}9&wg>xl4MX{S%A78at&iX?FLkb$1uvW zo97HYIYt;T(L{+Yghbyrj ztFaT+;W(7Fzr968Pw8?N_YUZmTjo_450=* zidu?s)O#ne6Q__@*k6pGKacwB1&!2E4|Jkl)Pvd+anyh=qDFoNciU|+*p?)F>PBfwza?hIG8>e0S-0Q=rJurqWWooec{1j#6 z9#S(^L`Z!xtD()OK`2xmO+Xu614`6$P${WGC$$S~#;tCgCXlNm~yQDiqOk}j5@i4sNhVmXPFv$C%Bkfj(zR^-Fl z#VCZR9(;(^%`|K+Frd?|kRXxqUO!oPBCp<3wE8 z1;uX@SBhLSDE|1Rgem1DPQ@5pin+KR=iwpD#Ab}ZP7KFxoP~WDkAql#I>J{MCfr@fu~o7tF-y$mtE_p`@|^Wn;Ur0_*TRKEpVyso(mlQI=JCqau#O@I4Jf9@u0UFbB4CNj8j9X zGxTdnVM#@eu1efmxqetb;H`EW1YT{MyWXf&J2#ifrEN8;sttXeDm|=M)ajaTcy*6f zq1AfyI^C-o%#iEr4^NJ$WDf6CJa27GR3M^uN^#Kulp=dHHD?ARM2&P(j@0c$ZrZVKFX4Tt^$ D2Ng1| delta 1011 zcmYMzUr19?9KiA4#kKi&-A!$qIO`B*cpMYN*HKsS5_yL#qbB+V`N&Tb=TXO%l5Gs5sEM_sR-_B7 znP1M(>Bljg!Zq~cXyNvRlgLM&&=lcw)Gc{qTsHUDjhm>?TMX6-EAS!P8o?0pd&(Wf zb4*BHS+sCyY^nYz*_%~5Q^Dm;e=aT4qB1?t3W$fNQXbxX=>B5Q9dJI{NvGF$Eob@fSh+{#O9fj)A8rHy diff --git a/src/usta/resources/translations/uk/LC_MESSAGES/messages.po b/src/usta/resources/translations/uk/LC_MESSAGES/messages.po index 49293fa..5bd7a1d 100644 --- a/src/usta/resources/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,7 +177,8 @@ 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:" @@ -187,7 +190,9 @@ 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,26 @@ 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}' не знайдено." + diff --git a/src/usta/ui/main_window.py b/src/usta/ui/main_window.py index 67fa74d..c68223b 100644 --- a/src/usta/ui/main_window.py +++ b/src/usta/ui/main_window.py @@ -1,33 +1,47 @@ -from usta.application.controller import ApplicationController -from usta.application.region_controller import RegionController -from usta.application.hotkey_controller import HotkeyController import logging -import time -from usta.ui.settings_presenter import SettingsPresenter -from usta.application.settings import AppSettings, Preset -from usta.infrastructure.settings_store import SettingsStore import os -from PySide6.QtCore import Qt, QRect, QPoint, QTimer, Signal, Slot, QSignalBlocker -from PySide6.QtGui import QIcon, QFont -from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton, QApplication, QColorDialog, QProgressBar, QTabWidget, QMessageBox + +from PySide6.QtCore import QSignalBlocker, Qt, QTimer, Signal, Slot +from PySide6.QtGui import 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.ui.overlay_window import TransparentOverlay from usta.ui.region_selection import SniperFactory -from usta.config import OS, LANGUAGES, SETTINGS_FILE, PRESETS_FILE, DPI_SCALE_DEFAULT, SETTINGS_TOPMOST_HOTKEY, TEMPORARY_REGION_HOTKEY, get_language_code -from usta.core.shortcut import GlobalHotkey -from usta.i18n import _ +from usta.ui.settings_presenter import SettingsPresenter from usta.ui.tabs import ( AboutTab, AppearanceTab, + HotkeyTab, OCRTab, SaveTab, - HotkeyTab, StatusTab, TranslationTab, ) logger = logging.getLogger(__name__) + class ControlPanel(QWidget): temporary_region_hotkey_pressed = Signal() @@ -50,27 +64,28 @@ def __init__(self): ) 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 - + layout.setSizeConstraint(QVBoxLayout.SetFixedSize) # Fixed size according to content + # --- Tabs --- self.tabs = QTabWidget() self.api_keys = {} @@ -89,7 +104,9 @@ def __init__(self): 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.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) @@ -113,7 +130,6 @@ def __init__(self): self.tabs.addTab(self.hotkey_tab, _("Hotkeys")) self.tabs.addTab(self.about_tab, _("About")) - layout.addWidget(self.tabs) # --- Performance Indicator (outside tabs) --- @@ -124,31 +140,37 @@ def __init__(self): 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.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.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.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.update_text) 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() @@ -156,7 +178,7 @@ def __init__(self): 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) @@ -165,16 +187,16 @@ 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) @@ -236,8 +258,6 @@ 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) @@ -258,7 +278,11 @@ def _set_settings_topmost_hotkey(self, hotkey, save=True, show_error=True): return True if show_error: - QMessageBox.warning(self, _("Invalid shortcut"), _("Could not register this shortcut. The previous shortcut is still active.")) + QMessageBox.warning( + self, + _("Invalid shortcut"), + _("Could not register this shortcut. The previous shortcut is still active."), + ) self._sync_hotkey_buttons() return False @@ -275,7 +299,11 @@ def _set_temporary_region_hotkey(self, hotkey, save=True, show_error=True): return True if show_error: - QMessageBox.warning(self, _("Invalid shortcut"), _("Could not register this shortcut. The previous shortcut is still active.")) + QMessageBox.warning( + self, + _("Invalid shortcut"), + _("Could not register this shortcut. The previous shortcut is still active."), + ) self._sync_hotkey_buttons() return False @@ -296,12 +324,12 @@ def update_system_status(self, is_running): self.status_tab.system_indicator.setStyleSheet( "background-color: #2E7D32; border-radius: 8px;" ) - self.status_tab.system_status_label.setText("Running") + self.status_tab.system_status_label.setText(_("Running")) else: self.status_tab.system_indicator.setStyleSheet( "background-color: #C62828; border-radius: 8px;" ) - self.status_tab.system_status_label.setText("Stopped") + self.status_tab.system_status_label.setText(_("Stopped")) self.overlay.set_mode(False) self.perf_bar.setValue(0) self.perf_bar.setStyleSheet("") @@ -318,17 +346,17 @@ def update_performance_bar(self, duration): 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) + color = "#2E7D32" # Green (Great) elif percent >= 35: - color = "#F9A825" # Yellow/Orange (Normal) + color = "#F9A825" # Yellow/Orange (Normal) else: - color = "#C62828" # Red (Poor) - + color = "#C62828" # Red (Poor) + self.perf_bar.setStyleSheet(f""" QProgressBar::chunk {{ background-color: {color}; @@ -352,7 +380,9 @@ def choose_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.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): @@ -360,7 +390,9 @@ def choose_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.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): @@ -368,7 +400,11 @@ def _emit_temporary_region_hotkey_pressed(self): @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: + if ( + not self.worker.isRunning() + or self._temporary_region_selecting + or not self._temporary_region_hotkey_armed + ): return self._temporary_region_hotkey_armed = False @@ -427,7 +463,7 @@ def _select_region_now(self): if rect: self._apply_main_region(rect, detected_dpi) - def start(self): + def start(self): self.overlay.set_mode(True) if not self.worker.isRunning(): self.controller.start() @@ -455,19 +491,11 @@ def closeEvent(self, event): if self._macos_hotkey_window is not None and event.isAccepted(): # Qt.Tool panels do not trigger quitOnLastWindowClosed. QApplication.instance().quit() - - # ---------- API Key Helpers ---------- - - + # ---------- API Key Helpers ---------- # ---------- Preset Management ---------- - - - - - def save_settings(self): self.settings_presenter.save_settings() diff --git a/src/usta/ui/overlay_window.py b/src/usta/ui/overlay_window.py index 228ff35..69477c6 100644 --- a/src/usta/ui/overlay_window.py +++ b/src/usta/ui/overlay_window.py @@ -1,10 +1,12 @@ from PySide6.QtCore import Qt, QTimer, Signal, Slot from PySide6.QtGui import QColor, QPainter, QPainterPath, QPen -from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel +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): super().__init__(parent) @@ -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 @@ -81,10 +85,9 @@ class TransparentOverlay(QWidget): def __init__(self): super().__init__() self.setWindowTitle("USTA_TRANSLATION_OVERLAY") - window_flags = (Qt.WindowStaysOnTopHint | - Qt.FramelessWindowHint | - Qt.WindowDoesNotAcceptFocus | - Qt.Tool) + window_flags = ( + Qt.WindowStaysOnTopHint | Qt.FramelessWindowHint | Qt.WindowDoesNotAcceptFocus | Qt.Tool + ) if OS == "Linux": window_flags |= Qt.X11BypassWindowManagerHint self.setWindowFlags(window_flags) @@ -94,14 +97,15 @@ def __init__(self): self._macos_rules = None if OS == "Darwin": from usta.ui.platform.macos.overlay import MacOSOverlayRules + self.setAttribute(Qt.WA_MacAlwaysShowToolWindow, True) self._macos_rules = MacOSOverlayRules(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 @@ -109,9 +113,9 @@ def __init__(self): self.bg_color = "#000000" self.bg_opacity = 180 self._show_corner_lines = 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) @@ -127,7 +131,7 @@ 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("")) @@ -253,7 +257,10 @@ 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: + if ( + event.position().x() > self.width() - 20 + and event.position().y() > self.height() - 20 + ): self._drag_mode = "resize" self.setCursor(Qt.SizeFDiagCursor) else: @@ -263,7 +270,10 @@ def mousePressEvent(self, event): def mouseMoveEvent(self, event): if self._drag_mode is None: - if event.position().x() > self.width() - 20 and event.position().y() > self.height() - 20: + if ( + event.position().x() > self.width() - 20 + and event.position().y() > self.height() - 20 + ): self.setCursor(Qt.SizeFDiagCursor) else: self.setCursor(Qt.SizeAllCursor) @@ -274,8 +284,9 @@ def mouseMoveEvent(self, event): delta = event.globalPosition().toPoint() - self._drag_start_pos if self._drag_mode == "move": - self.move(self._drag_start_geometry.x() + delta.x(), - self._drag_start_geometry.y() + delta.y()) + 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()) @@ -287,7 +298,10 @@ def mouseReleaseEvent(self, event): 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: + if ( + event.position().x() > self.width() - 20 + and event.position().y() > self.height() - 20 + ): self.setCursor(Qt.SizeFDiagCursor) else: self.setCursor(Qt.SizeAllCursor) diff --git a/src/usta/ui/platform/macos/hotkeys.py b/src/usta/ui/platform/macos/hotkeys.py index 6cebe55..544d669 100644 --- a/src/usta/ui/platform/macos/hotkeys.py +++ b/src/usta/ui/platform/macos/hotkeys.py @@ -1,4 +1,5 @@ """Native panel actions for macOS settings and region-selection shortcuts.""" + import logging import os @@ -15,16 +16,20 @@ def __init__(self, widget): 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) + 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): @@ -50,6 +55,7 @@ def focus(self): 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 @@ -60,6 +66,7 @@ def lower(self): self.rules.stop() try: import AppKit + window = self._window() window.resignKeyWindow() if self._normal_behavior is not None: @@ -76,14 +83,20 @@ def _restore_focus(self): return try: import AppKit + current = AppKit.NSWorkspace.sharedWorkspace().frontmostApplication() - if current is not None and current.processIdentifier() == os.getpid() and not previous.isTerminated(): + 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, + AppKit.NSRunningApplication.currentApplication(), + 0, ) else: previous.activateWithOptions_(AppKit.NSApplicationActivateIgnoringOtherApps) diff --git a/src/usta/ui/platform/macos/overlay.py b/src/usta/ui/platform/macos/overlay.py index cdbe3af..aecd0fc 100644 --- a/src/usta/ui/platform/macos/overlay.py +++ b/src/usta/ui/platform/macos/overlay.py @@ -1,4 +1,5 @@ """Keep a Qt overlay's Cocoa rules in sync with macOS window transitions.""" + import logging import weakref @@ -53,7 +54,10 @@ def changed(_notification): ) for center, name in notifications: token = center.addObserverForName_object_queue_usingBlock_( - name, None, AppKit.NSOperationQueue.mainQueue(), changed, + name, + None, + AppKit.NSOperationQueue.mainQueue(), + changed, ) self._observers.append((center, token)) self._running = True @@ -116,9 +120,11 @@ def _apply(self, allow_hidden=False): style = window.styleMask() | AppKit.NSWindowStyleMaskNonactivatingPanel if style != window.styleMask(): window.setStyleMask_(style) - behavior = (AppKit.NSWindowCollectionBehaviorCanJoinAllSpaces | - AppKit.NSWindowCollectionBehaviorFullScreenAuxiliary | - AppKit.NSWindowCollectionBehaviorStationary) + 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. diff --git a/src/usta/ui/region_selection/base_sniper.py b/src/usta/ui/region_selection/base_sniper.py index 9e9a1d9..b5d85c2 100644 --- a/src/usta/ui/region_selection/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/usta/ui/region_selection/core_sniper.py b/src/usta/ui/region_selection/core_sniper.py index 26beb0a..4e4a32c 100644 --- a/src/usta/ui/region_selection/core_sniper.py +++ b/src/usta/ui/region_selection/core_sniper.py @@ -1,12 +1,15 @@ -import os import sys -from PySide6.QtCore import Qt, QRect, QPoint, Signal, QObject, QEventLoop -from PySide6.QtGui import QPainter, QColor, QPen, QCursor + +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 usta.ui.region_selection.base_sniper import BaseSniper + class SelectionManager(QObject): """Manages the selection state across multiple monitors.""" + selectionChanged = Signal() selectionFinished = Signal(QRect) @@ -34,18 +37,16 @@ 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() - - flags = ( - Qt.FramelessWindowHint | - Qt.WindowStaysOnTopHint | - Qt.Tool - ) + + flags = Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool if sys.platform != "darwin": flags |= Qt.X11BypassWindowManagerHint self.setWindowFlags(flags) @@ -53,15 +54,16 @@ def __init__(self, screen, manager): 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): @@ -71,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) @@ -101,6 +103,7 @@ def closeEvent(self, event): self._macos_hotkey_window.stop() super().closeEvent(event) + class CoreSniper(BaseSniper): def __init__(self): super().__init__() @@ -132,11 +135,13 @@ def on_finished(rect): 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 = 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": @@ -153,5 +158,5 @@ def on_finished(rect): if screen.geometry().contains(center): self.detected_dpi = screen.devicePixelRatio() break - + return rect diff --git a/src/usta/ui/region_selection/slurp_sniper.py b/src/usta/ui/region_selection/slurp_sniper.py index 9f8ae76..a177a11 100644 --- a/src/usta/ui/region_selection/slurp_sniper.py +++ b/src/usta/ui/region_selection/slurp_sniper.py @@ -1,25 +1,28 @@ import logging import os import time + from PySide6.QtCore import QRect from PySide6.QtWidgets import QApplication -from usta.ui.region_selection.base_sniper import BaseSniper + 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) @@ -27,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: diff --git a/src/usta/ui/region_selection/sniper_factory.py b/src/usta/ui/region_selection/sniper_factory.py index 3779b66..64f949f 100644 --- a/src/usta/ui/region_selection/sniper_factory.py +++ b/src/usta/ui/region_selection/sniper_factory.py @@ -1,11 +1,11 @@ from usta.ui.region_selection.base_sniper import BaseSniper -from usta.ui.region_selection.slurp_sniper import SlurpSniper 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 index 0eb501a..afda716 100644 --- a/src/usta/ui/settings_presenter.py +++ b/src/usta/ui/settings_presenter.py @@ -1,15 +1,25 @@ """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 QWidget, QMessageBox -from usta.config import CONFIG_DIR, DPI_SCALE_DEFAULT, SETTINGS_TOPMOST_HOTKEY, TEMPORARY_REGION_HOTKEY +from PySide6.QtWidgets import QMessageBox, QWidget + from usta.application.settings import AppSettings, Preset -from usta.infrastructure.settings_store import SettingsStore +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 @@ -29,8 +39,12 @@ def save_settings(self): "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()), + "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, @@ -42,7 +56,6 @@ def save_settings(self): 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)] @@ -50,60 +63,70 @@ def load_settings(self): s = self.store.load().to_dict() # Update UI elements (triggers signals without blocking) 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_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;") - + 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;") - + 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.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.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}") @@ -113,7 +136,6 @@ def load_settings(self): 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() @@ -128,7 +150,6 @@ def _apply_api_key_for_current_engine(self): # 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"} @@ -139,14 +160,14 @@ def _update_api_key_state(self, engine: str): 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")) - + 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() @@ -154,7 +175,6 @@ def on_api_key_changed(self, text: str): 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()} @@ -162,14 +182,14 @@ def _load_presets_dict(self): 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()}) + self.store.save_presets( + {name: Preset.from_dict(value) for name, value in presets.items()} + ) except (OSError, ValueError) as exc: logger.error(f"Presets save error: {exc}") - def refresh_preset_list(self): """Refresh the combo box with preset names.""" self.view.save_tab.preset_combo.clear() @@ -177,14 +197,13 @@ def refresh_preset_list(self): 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, "No Name", "Please enter a preset name.") + QMessageBox.warning(self.view, _("No Name"), _("Please enter a preset name.")) return - + preset = { "capture_rect": ( self.view.controller.capture_rect.x(), @@ -202,7 +221,7 @@ def save_preset(self): "bg_color": self.view.overlay.bg_color, "bg_opacity": self.view.overlay.bg_opacity, } - + presets = self._load_presets_dict() presets[name] = preset self._save_presets_dict(presets) @@ -210,25 +229,28 @@ def save_preset(self): 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, _("No Selection"), _("Please select a preset to load.")) + 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, "Not Found", f"Preset '{name}' not found.") + 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") @@ -238,45 +260,46 @@ def load_preset(self): 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.") + 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, "No Selection", "Please select a preset to delete.") + QMessageBox.information( + self.view, _("No Selection"), _("Please select a preset to delete.") + ) return presets = self._load_presets_dict() if name in presets: @@ -285,6 +308,6 @@ def delete_preset(self): self.refresh_preset_list() logger.info(f"Preset '{name}' deleted.") else: - QMessageBox.warning(self, "Not Found", f"Preset '{name}' not found.") - - + QMessageBox.warning( + self.view, _("Not Found"), _("Preset '{name}' not found.").format(name=name) + ) diff --git a/src/usta/ui/tabs/__init__.py b/src/usta/ui/tabs/__init__.py index 0bf0cf9..390d48f 100644 --- a/src/usta/ui/tabs/__init__.py +++ b/src/usta/ui/tabs/__init__.py @@ -1,7 +1,17 @@ -from usta.ui.tabs.ocr_tab import OCRTab -from usta.ui.tabs.translation_tab import TranslationTab +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.about_tab import AboutTab +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 index 6bcce7f..251326b 100644 --- a/src/usta/ui/tabs/about_tab.py +++ b/src/usta/ui/tabs/about_tab.py @@ -4,11 +4,7 @@ from usta.i18n import _ -from PySide6.QtCore import Signal - - class AboutTab(QWidget): - def __init__(self): super().__init__() tab_about_layout = QVBoxLayout(self) diff --git a/src/usta/ui/tabs/appearance_tab.py b/src/usta/ui/tabs/appearance_tab.py index 71cb0cc..4c532ba 100644 --- a/src/usta/ui/tabs/appearance_tab.py +++ b/src/usta/ui/tabs/appearance_tab.py @@ -1,10 +1,16 @@ -from PySide6.QtWidgets import QFontComboBox, QHBoxLayout, QLabel, QPushButton, QSpinBox, QVBoxLayout, QWidget - -from usta.i18n import _ - +from PySide6.QtCore import Signal +from PySide6.QtWidgets import ( + QFontComboBox, + QHBoxLayout, + QLabel, + QPushButton, + QSpinBox, + QVBoxLayout, + QWidget, +) from usta.application.settings import AppSettings -from PySide6.QtCore import Signal +from usta.i18n import _ class AppearanceTab(QWidget): @@ -18,7 +24,7 @@ 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:"))) @@ -26,7 +32,7 @@ def __init__(self, settings=None): 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() @@ -36,27 +42,31 @@ def __init__(self, settings=None): 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.emit) 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;") + 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.emit) 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;") + 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() diff --git a/src/usta/ui/tabs/hotkey_tab.py b/src/usta/ui/tabs/hotkey_tab.py index 0ee9a74..8be8625 100644 --- a/src/usta/ui/tabs/hotkey_tab.py +++ b/src/usta/ui/tabs/hotkey_tab.py @@ -2,9 +2,9 @@ from PySide6.QtGui import QKeySequence from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget +from usta.config import SETTINGS_TOPMOST_HOTKEY, TEMPORARY_REGION_HOTKEY from usta.i18n import _ - HOTKEY_BUTTON_WIDTH = 120 RESET_BUTTON_WIDTH = 60 @@ -145,21 +145,21 @@ def _add_hotkey_row(layout, label_text, button, reset_callback, tooltip): layout.addLayout(row) -from usta.config import SETTINGS_TOPMOST_HOTKEY, TEMPORARY_REGION_HOTKEY -from PySide6.QtCore import Signal - - 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): + 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 = _( "Use this shortcut to bring the main window to the front while playing a game or watching a video." ) @@ -167,7 +167,7 @@ def __init__(self, settings_topmost_hotkey=SETTINGS_TOPMOST_HOTKEY, temporary_re "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( @@ -177,7 +177,7 @@ def __init__(self, settings_topmost_hotkey=SETTINGS_TOPMOST_HOTKEY, temporary_re self.reset_settings_requested.emit, 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( @@ -187,7 +187,7 @@ def __init__(self, settings_topmost_hotkey=SETTINGS_TOPMOST_HOTKEY, temporary_re self.reset_region_requested.emit, 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) diff --git a/src/usta/ui/tabs/ocr_tab.py b/src/usta/ui/tabs/ocr_tab.py index 3c7e8be..d869e13 100644 --- a/src/usta/ui/tabs/ocr_tab.py +++ b/src/usta/ui/tabs/ocr_tab.py @@ -1,13 +1,10 @@ -from PySide6.QtCore import Qt +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 _ -from PySide6.QtCore import Signal - - class OCRTab(QWidget): engine_changed = Signal(str) screen_engine_changed = Signal(str) @@ -25,7 +22,7 @@ def __init__(self): self.combo_ocr.currentTextChanged.connect(self.engine_changed.emit) self.combo_ocr.currentTextChanged.connect(self.changed.emit) tab_ocr_layout.addWidget(self.combo_ocr) - + tab_ocr_layout.addWidget(QLabel(_("Screen Engine:"))) self.combo_screenshot = QComboBox() self.combo_screenshot.setFocusPolicy(Qt.NoFocus) @@ -35,7 +32,7 @@ def __init__(self): self.combo_screenshot.currentTextChanged.connect(self.screen_engine_changed.emit) self.combo_screenshot.currentTextChanged.connect(self.changed.emit) tab_ocr_layout.addWidget(self.combo_screenshot) - + h_dpi = QHBoxLayout() h_dpi.addWidget(QLabel(_("DPI Scale:"))) self.dpi_lock_label = QLabel("🔓") @@ -51,9 +48,11 @@ def __init__(self): 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.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 index 1946214..b72f223 100644 --- a/src/usta/ui/tabs/save_tab.py +++ b/src/usta/ui/tabs/save_tab.py @@ -1,12 +1,17 @@ -from PySide6.QtCore import Qt -from PySide6.QtWidgets import QComboBox, QHBoxLayout, QLabel, QLineEdit, QPushButton, QVBoxLayout, QWidget +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import ( + QComboBox, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QVBoxLayout, + QWidget, +) from usta.i18n import _ -from PySide6.QtCore import Signal - - class SaveTab(QWidget): save_requested = Signal() load_requested = Signal() @@ -15,28 +20,28 @@ class SaveTab(QWidget): 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.emit) h_buttons.addWidget(self.btn_save_preset) - + self.btn_load_preset = QPushButton(_("📂 Load")) self.btn_load_preset.clicked.connect(self.load_requested.emit) h_buttons.addWidget(self.btn_load_preset) - + self.btn_delete_preset = QPushButton(_("🗑 Delete")) self.btn_delete_preset.clicked.connect(self.delete_requested.emit) 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) diff --git a/src/usta/ui/tabs/status_tab.py b/src/usta/ui/tabs/status_tab.py index 5b9e015..05373ab 100644 --- a/src/usta/ui/tabs/status_tab.py +++ b/src/usta/ui/tabs/status_tab.py @@ -3,33 +3,26 @@ from usta.i18n import _ -from PySide6.QtCore import Signal - - class StatusTab(QWidget): - def __init__(self): super().__init__() tab_status_layout = QVBoxLayout(self) tab_status_layout.setSpacing(6) tab_status_layout.setContentsMargins(8, 8, 8, 8) - + h_system = QHBoxLayout() h_system.setSpacing(6) self.system_indicator = QLabel() self.system_indicator.setFixedSize(16, 16) - self.system_indicator.setStyleSheet( - "background-color: #C62828; border-radius: 8px;" - ) + self.system_indicator.setStyleSheet("background-color: #C62828; border-radius: 8px;") h_system.addWidget(self.system_indicator) - self.system_status_label = QLabel("Stopped") + self.system_status_label = QLabel(_("Stopped")) h_system.addWidget(self.system_status_label) h_system.addStretch() tab_status_layout.addLayout(h_system) - + tab_status_layout.addStretch() - + self.rect_label_status = QLabel(_("Region: —")) self.rect_label_status.setStyleSheet("font-family: monospace;") tab_status_layout.addWidget(self.rect_label_status) - diff --git a/src/usta/ui/tabs/translation_tab.py b/src/usta/ui/tabs/translation_tab.py index d84682f..6d8e1dc 100644 --- a/src/usta/ui/tabs/translation_tab.py +++ b/src/usta/ui/tabs/translation_tab.py @@ -1,13 +1,10 @@ -from PySide6.QtCore import Qt +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 _ -from PySide6.QtCore import Signal - - class TranslationTab(QWidget): translator_changed = Signal(str) changed = Signal() @@ -26,7 +23,7 @@ def __init__(self): self.combo_translator.currentTextChanged.connect(self.changed.emit) 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:"))) @@ -37,7 +34,7 @@ def __init__(self): self.combo_source.currentTextChanged.connect(self.language_changed.emit) v_source.addWidget(self.combo_source) h_lang.addLayout(v_source) - + v_target = QVBoxLayout() v_target.addWidget(QLabel(_("Target Language:"))) self.combo_target = QComboBox() @@ -48,7 +45,7 @@ def __init__(self): 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() @@ -56,5 +53,5 @@ def __init__(self): 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/tests/helpers/macos_fullscreen_host.py b/tests/helpers/macos_fullscreen_host.py index 5aeeade..421daf8 100644 --- a/tests/helpers/macos_fullscreen_host.py +++ b/tests/helpers/macos_fullscreen_host.py @@ -1,14 +1,14 @@ """Separate, focused app for testing native macOS fullscreen Spaces.""" + import json -from pathlib import Path 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]) @@ -21,16 +21,28 @@ def report(_notification): - state_file.write_text(json.dumps({ - "fullscreen": bool(window.styleMask() & AppKit.NSWindowStyleMaskFullScreen), - "active": bool(AppKit.NSApp.isActive()), - })) + 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)] +tokens = [ + center.addObserverForName_object_queue_usingBlock_( + name, + window, + None, + report, + ) + for name in ( + AppKit.NSWindowDidEnterFullScreenNotification, + AppKit.NSWindowDidExitFullScreenNotification, + ) +] def enter_fullscreen(): diff --git a/tests/integration/test_control_panel.py b/tests/integration/test_control_panel.py index 701028d..3b724d6 100644 --- a/tests/integration/test_control_panel.py +++ b/tests/integration/test_control_panel.py @@ -2,11 +2,14 @@ import unittest from pathlib import Path from unittest import mock -from PySide6.QtWidgets import QApplication + from PySide6.QtCore import QRect +from PySide6.QtWidgets import QApplication + from usta.application.settings import AppSettings, Preset from usta.infrastructure.settings_store import SettingsStore + class ControlPanelTests(unittest.TestCase): @classmethod def setUpClass(cls): @@ -14,32 +17,43 @@ def setUpClass(cls): 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.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(self.panel.close) 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.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'}) + self.assertEqual(self.store.load().translator_api_keys, {"DeepL": "secret"}) def test_preset_cancels_temporary_restore(self): - panel=self.panel - self.store.save_presets({'game':Preset(capture_rect=(-100,30,600,80))}) + 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._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)) + self.assertEqual(panel.worker.capture_rect, QRect(-100, 30, 600, 80)) diff --git a/tests/integration/test_linux_windows.py b/tests/integration/test_linux_windows.py index f2e07ad..c7883be 100644 --- a/tests/integration/test_linux_windows.py +++ b/tests/integration/test_linux_windows.py @@ -1,11 +1,13 @@ import sys - import unittest from unittest import mock -from PySide6.QtCore import Qt, QRect + +from PySide6.QtCore import QRect, Qt from PySide6.QtWidgets import QApplication -from usta.ui.region_selection.core_sniper import OverlayWindow, SelectionManager + from tests.native.test_macos_hotkey_actions import pump +from usta.ui.region_selection.core_sniper import OverlayWindow, SelectionManager + class LinuxWindowContracts(unittest.TestCase): @classmethod @@ -15,40 +17,44 @@ def setUpClass(cls): 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 + 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)) + 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(): if panel.isVisible(): panel.close() panel.overlay.deleteLater() panel.deleteLater() pump() + self.addCleanup(close) return panel, quit_app - def test_linux_settings_action_keeps_original_window_behavior(self): - panel, quit_app = self.make_control_panel('Linux') + 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: + with ( + mock.patch.object(panel, "raise_") as raise_window, + mock.patch.object(panel, "activateWindow") as activate, + ): panel.set_settings_always_on_top(True) self.assertTrue(panel.windowFlags() & Qt.WindowStaysOnTopHint) raise_window.assert_called_once() @@ -58,15 +64,14 @@ def test_linux_settings_action_keeps_original_window_behavior(self): panel.close() 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}): + 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/native/test_macos_hotkey_actions.py b/tests/native/test_macos_hotkey_actions.py index a0d6f52..b5f2f19 100644 --- a/tests/native/test_macos_hotkey_actions.py +++ b/tests/native/test_macos_hotkey_actions.py @@ -1,22 +1,22 @@ -import json import gc +import json import os -from pathlib import Path import subprocess import sys import tempfile -import time import threading +import time import unittest import weakref +from pathlib import Path from unittest import mock -from PySide6.QtCore import QCoreApplication, QEvent, QEventLoop, QTimer, Qt, QPoint, QRect +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.region_selection.core_sniper import CoreSniper, OverlayWindow, SelectionManager from usta.ui.platform.macos.hotkeys import MacOSHotkeyWindow +from usta.ui.region_selection.core_sniper import CoreSniper, OverlayWindow, SelectionManager def pump(milliseconds=40): @@ -25,12 +25,15 @@ def pump(milliseconds=40): loop.exec() -@unittest.skipUnless(os.environ.get('USTA_NATIVE_TESTS') == '1' and sys.platform == 'darwin', 'Requires macOS') +@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([]) @@ -38,39 +41,44 @@ def setUpClass(cls): 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 + 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)) + 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(): if panel.isVisible(): panel.close() panel.overlay.deleteLater() panel.deleteLater() pump() + self.addCleanup(close) return panel, quit_app def test_linux_settings_action_keeps_original_window_behavior(self): - panel, quit_app = self.make_control_panel('Linux') + 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: + with ( + mock.patch.object(panel, "raise_") as raise_window, + mock.patch.object(panel, "activateWindow") as activate, + ): panel.set_settings_always_on_top(True) self.assertTrue(panel.windowFlags() & Qt.WindowStaysOnTopHint) raise_window.assert_called_once() @@ -81,8 +89,10 @@ def test_linux_settings_action_keeps_original_window_behavior(self): 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}): + 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) @@ -100,15 +110,19 @@ def test_region_windows_are_destroyed_on_gui_thread_before_worker_collection(sel gui_thread = threading.get_ident() def finish(): - windows.extend(w for w in self.app.topLevelWidgets() - if isinstance(w, OverlayWindow) and w.isVisible()) + 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)) + QRect() if cancel else QRect(50, 50, 150, 50) + ) QTimer.singleShot(100, finish) rect = CoreSniper().get_region() @@ -118,8 +132,10 @@ def finish(): 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.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) @@ -165,20 +181,23 @@ def finish(): 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)) + 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') + panel, quit_app = self.make_control_panel("Darwin") self.assertEqual(panel.windowType(), Qt.Tool) - with mock.patch.object(panel._macos_hotkey_window, 'show') as show, \ - mock.patch.object(panel._macos_hotkey_window, 'lower') as lower, \ - mock.patch.object(panel, 'raise_') as raise_window, \ - mock.patch.object(panel, 'activateWindow') as activate: + with ( + mock.patch.object(panel._macos_hotkey_window, "show") as show, + mock.patch.object(panel._macos_hotkey_window, "lower") as lower, + mock.patch.object(panel, "raise_") as raise_window, + mock.patch.object(panel, "activateWindow") as activate, + ): panel.overlay._toggle_main_window_topmost() show.assert_called_once() panel.overlay._toggle_main_window_topmost() @@ -193,7 +212,7 @@ def test_macos_controls_use_native_action_and_restore_temporary_region(self): 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)): + 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) @@ -202,9 +221,9 @@ def test_macos_controls_use_native_action_and_restore_temporary_region(self): 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' + 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) @@ -218,12 +237,12 @@ def test_settings_and_region_selection_preserve_fullscreen_space(self): pump() if state.exists(): try: - if json.loads(state.read_text())['fullscreen']: + if json.loads(state.read_text())["fullscreen"]: break except json.JSONDecodeError: pass else: - self.fail('Fullscreen host did not enter its Space') + 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() @@ -231,43 +250,78 @@ def test_settings_and_region_selection_preserve_fullscreen_space(self): 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') + + 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.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: + 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) + 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()] + 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]) + 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)) + 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.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() @@ -280,22 +334,38 @@ def select(): deadline = time.monotonic() + 2 while time.monotonic() < deadline: pump() - if self.cocoa.NSWorkspace.sharedWorkspace().frontmostApplication().processIdentifier() == process.pid: + if ( + self.cocoa.NSWorkspace.sharedWorkspace() + .frontmostApplication() + .processIdentifier() + == process.pid + ): break - self.assertEqual(self.cocoa.NSWorkspace.sharedWorkspace().frontmostApplication().processIdentifier(), process.pid) + 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())) + self.assertFalse( + any( + isinstance(w, OverlayWindow) and w.isVisible() + for w in self.app.topLevelWidgets() + ) + ) finally: actions.stop() panel.close() diff --git a/tests/native/test_macos_overlay.py b/tests/native/test_macos_overlay.py index b9f98a3..c6065e9 100644 --- a/tests/native/test_macos_overlay.py +++ b/tests/native/test_macos_overlay.py @@ -1,15 +1,16 @@ -import os """Native macOS integration tests; require a WindowServer session.""" -import sys + import json -from pathlib import Path +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, QTimer, Qt +from PySide6.QtCore import QEvent, QEventLoop, Qt, QTimer from PySide6.QtWidgets import QApplication, QWidget from usta.ui.platform.macos.overlay import MacOSOverlayRules @@ -21,12 +22,15 @@ def wait_for_events(milliseconds=30): loop.exec() -@unittest.skipUnless(os.environ.get('USTA_NATIVE_TESTS') == '1' and sys.platform == 'darwin', 'Requires macOS Cocoa') +@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([]) @@ -57,7 +61,7 @@ def assert_rules(self): behavior = int(window.collectionBehavior()) self.assertEqual(window.level(), 25) self.assertEqual(behavior & 273, 273) - if hasattr(self.cocoa, 'NSWindowCollectionBehaviorCanJoinAllApplications'): + if hasattr(self.cocoa, "NSWindowCollectionBehaviorCanJoinAllApplications"): self.assertTrue(behavior & 262144) self.assertFalse(behavior & (65536 | 131072)) self.assertFalse(window.hidesOnDeactivate()) @@ -75,25 +79,29 @@ def test_sets_fullscreen_and_all_applications_rules(self): 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): + 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' + 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)]) + 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: @@ -103,27 +111,32 @@ def wait_for_fullscreen(expected): state = json.loads(state_file.read_text()) except json.JSONDecodeError: continue - if state['fullscreen'] == expected: - self.assertTrue(state['active'], 'Fullscreen host must be focused') + 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') + 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]) + 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'): + 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( + 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()) @@ -141,12 +154,12 @@ def test_older_cocoa_keeps_existing_fullscreen_rules(self): class OlderCocoa: def __getattr__(self, name): - if name == 'NSWindowCollectionBehaviorCanJoinAllApplications': + 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()}): + with mock.patch.dict(sys.modules, {"AppKit": OlderCocoa()}): self.reset_rules() self.app.sendEvent(self.widget, QEvent(QEvent.WindowStateChange)) wait_for_events() @@ -155,7 +168,9 @@ def __getattr__(self, name): def test_space_transition_restores_rules_after_late_native_reset(self): center = self.cocoa.NSWorkspace.sharedWorkspace().notificationCenter() - center.postNotificationName_object_(self.cocoa.NSWorkspaceActiveSpaceDidChangeNotification, None) + center.postNotificationName_object_( + self.cocoa.NSWorkspaceActiveSpaceDidChangeNotification, None + ) wait_for_events() self.reset_rules() wait_for_events(450) @@ -169,8 +184,10 @@ def test_window_state_change_restores_rules(self): def test_fullscreen_notifications_restore_rules(self): center = self.cocoa.NSNotificationCenter.defaultCenter() - for name in (self.cocoa.NSWindowDidEnterFullScreenNotification, - self.cocoa.NSWindowDidExitFullScreenNotification): + for name in ( + self.cocoa.NSWindowDidEnterFullScreenNotification, + self.cocoa.NSWindowDidExitFullScreenNotification, + ): with self.subTest(notification=name): self.reset_rules() center.postNotificationName_object_(name, self.native_window()) diff --git a/tests/packaging/test_artifacts.py b/tests/packaging/test_artifacts.py index 0b65970..1739f12 100644 --- a/tests/packaging/test_artifacts.py +++ b/tests/packaging/test_artifacts.py @@ -1,16 +1,22 @@ import unittest from pathlib import Path -import importlib.util 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()) + 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): - import tomllib - data = tomllib.loads((ROOT / 'pyproject.toml').read_text()) - self.assertIn('resources/ocr_models/rapidocr/*.onnx', data['tool']['setuptools']['package-data']['usta']) + 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"], + ) diff --git a/tests/packaging/test_metadata.py b/tests/packaging/test_metadata.py index da9cd6c..c95ee94 100644 --- a/tests/packaging/test_metadata.py +++ b/tests/packaging/test_metadata.py @@ -1,15 +1,20 @@ import unittest from pathlib import Path -import tomllib + +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(), '.') + 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(), ".") diff --git a/tests/unit/test_apple_vision_engine.py b/tests/unit/test_apple_vision_engine.py index 803e29c..6060133 100644 --- a/tests/unit/test_apple_vision_engine.py +++ b/tests/unit/test_apple_vision_engine.py @@ -1,10 +1,10 @@ -import os import importlib.util -from pathlib import Path +import os import sys import tempfile -from concurrent.futures import ThreadPoolExecutor import unittest +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path from unittest import mock from PIL import Image, ImageDraw, ImageFont @@ -17,83 +17,91 @@ 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'), '') + 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) + 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') + @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') + 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') +@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') + 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') + 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') + 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') + 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'): + 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(' ', ''), '안녕하세요감사합니다') + 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)), '') + 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'), '') + 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: + 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'): + if hasattr(request, "automaticallyDetectsLanguage"): self.assertTrue(request.automaticallyDetectsLanguage()) diff --git a/tests/unit/test_architecture.py b/tests/unit/test_architecture.py index 1f2d73b..68c50bd 100644 --- a/tests/unit/test_architecture.py +++ b/tests/unit/test_architecture.py @@ -1,18 +1,22 @@ import ast -from pathlib import Path import unittest +from pathlib import Path + import usta -ROOT=Path(usta.__file__).parent +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 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)) + 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)) + 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 index 30fef68..a7f16cb 100644 --- a/tests/unit/test_bootstrap.py +++ b/tests/unit/test_bootstrap.py @@ -2,12 +2,13 @@ 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 + 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) +""" + result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True) self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(result.stdout, '') + self.assertEqual(result.stdout, "") diff --git a/tests/unit/test_easyocr_engine.py b/tests/unit/test_easyocr_engine.py index 3feaeff..3d4c610 100644 --- a/tests/unit/test_easyocr_engine.py +++ b/tests/unit/test_easyocr_engine.py @@ -3,7 +3,6 @@ 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." @@ -12,46 +11,64 @@ 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)] + 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') + 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') + 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'): + 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']) + 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') + 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]) + 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 index 0790ae0..0eb172a 100644 --- a/tests/unit/test_engine_contracts.py +++ b/tests/unit/test_engine_contracts.py @@ -1,29 +1,33 @@ import unittest -from unittest.mock import patch, Mock -from usta.core.translation.deepl_engine import DeepLTranslatorEngine -from usta.core.ocr.ocr_manager import OCRManager + 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') + 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') + manager.set_language("ko") self.assertEqual(manager.engines.instances, {}) - manager.set_engine('EasyOCR') + 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'}) + 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() + + engine = SpectacleScreenshot() + engine.close() + engine.close() def test_deepl_client_uses_supported_constructor(self): - engine = DeepLTranslatorEngine(api_key='test-key') + engine = DeepLTranslatorEngine(api_key="test-key") self.assertIsNotNone(engine.translator) engine.translator.close() diff --git a/tests/unit/test_i18n.py b/tests/unit/test_i18n.py index 0304d19..0f816ff 100644 --- a/tests/unit/test_i18n.py +++ b/tests/unit/test_i18n.py @@ -4,10 +4,11 @@ from usta import i18n - TRANSLATIONS_DIR = os.path.join( os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), - "src", "usta", "resources", + "src", + "usta", + "resources", "translations", ) diff --git a/tests/unit/test_pipeline_lifecycle.py b/tests/unit/test_pipeline_lifecycle.py index 02529b1..5738f9c 100644 --- a/tests/unit/test_pipeline_lifecycle.py +++ b/tests/unit/test_pipeline_lifecycle.py @@ -1,54 +1,93 @@ -from dataclasses import replace import threading import time import unittest +from dataclasses import replace from unittest.mock import Mock + from usta.application.pipeline import Pipeline -from usta.application.settings import AppSettings + class PipelineLifecycleTests(unittest.TestCase): def make_pipeline(self): entered, release, finished = threading.Event(), threading.Event(), threading.Event() - translator=Mock() + translator = Mock() + def translate(request): from usta.application.results import TranslationResult - entered.set();release.wait(3) + + 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 + 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() + 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) + 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)) + self.assertLess(time.monotonic() - start, 1) + release.set() + self.assertTrue(finished.wait(2)) pipeline.close() - result.assert_not_called();publisher.broadcast.assert_not_called() + 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() + 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() + 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() + + 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_runtime_paths.py b/tests/unit/test_runtime_paths.py index e79cfbd..6ddaf70 100644 --- a/tests/unit/test_runtime_paths.py +++ b/tests/unit/test_runtime_paths.py @@ -1,13 +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, 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_settings_store.py b/tests/unit/test_settings_store.py index 02e36cb..800b880 100644 --- a/tests/unit/test_settings_store.py +++ b/tests/unit/test_settings_store.py @@ -1,13 +1,14 @@ -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.application.settings import AppSettings from usta.infrastructure.settings_store import SettingsStore + class SettingsStoreTests(unittest.TestCase): def setUp(self): self.directory = tempfile.TemporaryDirectory() @@ -16,49 +17,62 @@ def setUp(self): 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' + 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(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) + 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 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')), []) + 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 = 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()) + 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()) + (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)}})) + (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.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') + 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]}) + 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)) diff --git a/tests/unit/test_shortcut.py b/tests/unit/test_shortcut.py index 20e915e..5e1ed55 100644 --- a/tests/unit/test_shortcut.py +++ b/tests/unit/test_shortcut.py @@ -1,5 +1,5 @@ -import os import contextlib +import os import sys import threading import unittest @@ -10,19 +10,25 @@ class PlatformHotkeyTests(unittest.TestCase): def test_linux_and_windows_keep_the_existing_pynput_listener(self): - for platform in ('linux', 'win32'): + 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) + 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, + on_press=hotkey._on_press, + on_release=hotkey._on_release, ) listener = backend.Listener.return_value listener.start.assert_called_once() @@ -30,8 +36,12 @@ def test_linux_and_windows_keep_the_existing_pynput_listener(self): 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') +@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 = [] @@ -41,7 +51,7 @@ def test_rebinding_reads_keyboard_layout_on_main_thread_only(self): @contextlib.contextmanager def keyboard_layout(): layout_threads.append(threading.current_thread()) - yield (40, b'copied keyboard layout') + yield (40, b"copied keyboard layout") def listen(listener): listener_threads.append(threading.current_thread()) @@ -49,10 +59,12 @@ def listen(listener): 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): - for combination in ('++m', '++x', '++k'): + 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), + ): + for combination in ("++m", "++x", "++k"): hotkey = GlobalHotkey(combination, lambda: None) self.assertTrue(hotkey.start()) hotkey._listener.join(timeout=2) @@ -60,13 +72,13 @@ def listen(listener): 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) + 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'): + 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) diff --git a/tests/unit/test_socket_publisher.py b/tests/unit/test_socket_publisher.py index 76e333a..d3c02a8 100644 --- a/tests/unit/test_socket_publisher.py +++ b/tests/unit/test_socket_publisher.py @@ -3,54 +3,68 @@ 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.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)) + 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() + 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()) + 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() + 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.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.path.write_text("keep") + with self.assertRaises(OSError): + self.publisher.start() self.publisher.stop() - self.assertEqual(self.path.read_text(),'keep') + 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() + reader = self.connect() + started = time.monotonic() for _ in range(10): - self.publisher.broadcast('x'*200000,'text') - self.assertLess(time.monotonic()-started,1) + self.publisher.broadcast("x" * 200000, "text") + self.assertLess(time.monotonic() - started, 1) reader.close() self.publisher.stop() - self.assertLess(time.monotonic()-started,2) + self.assertLess(time.monotonic() - started, 2) From 61f55ea97c18a5cee6481500f3ec315a7ce499c5 Mon Sep 17 00:00:00 2001 From: sedataym Date: Fri, 23 Oct 2026 15:41:53 +0300 Subject: [PATCH 14/24] ci: verify distributions, platform contracts and native acceptance --- .github/workflows/ci.yml | 28 +++++++++++++++++++ .github/workflows/package.yml | 26 ++++++++++++++++++ :memory:.ses | 2 ++ MANIFEST.in | 7 +++++ docs/compatibility.md | 4 ++- docs/development/cleanup-progress.md | 2 ++ docs/testing.md | 4 +++ src/usta/application/pipeline.py | 13 +++++++++ src/usta/application/worker.py | 30 +++++++++++++++++--- src/usta/ui/main_window.py | 10 ++++--- tests/integration/test_worker_signals.py | 35 ++++++++++++++++++++++++ tests/packaging/test_artifacts.py | 24 ++++++++++++++++ tests/unit/test_rapidocr_compat.py | 23 ++++++++++++++++ 13 files changed, 199 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/package.yml create mode 100644 :memory:.ses create mode 100644 MANIFEST.in create mode 100644 tests/integration/test_worker_signals.py create mode 100644 tests/unit/test_rapidocr_compat.py 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..181db57 --- /dev/null +++ b/.github/workflows/package.yml @@ -0,0 +1,26 @@ +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: 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/:memory:.ses b/:memory:.ses new file mode 100644 index 0000000..cea2896 --- /dev/null +++ b/:memory:.ses @@ -0,0 +1,2 @@ +1792759152173 +0fd44be9-31f6-4a2e-80fd-fccec7a1ab7b diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..180eae6 --- /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 diff --git a/docs/compatibility.md b/docs/compatibility.md index e7df874..e2953b7 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -4,8 +4,10 @@ The cleanup preserves the existing Linux KDE/GNOME capture paths and macOS Cocoa | Environment | Automated contracts | Native acceptance | | --- | --- | --- | -| macOS / Python 3.14 (local environment) | Run during each phase | Baseline sandbox cannot access WindowServer/Vision recognition; unverified | +| 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/cleanup-progress.md b/docs/development/cleanup-progress.md index 8d46eff..79afa69 100644 --- a/docs/development/cleanup-progress.md +++ b/docs/development/cleanup-progress.md @@ -34,3 +34,5 @@ Task 8: architecture tests failed on core→UI imports and panel mutation, pass 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. diff --git a/docs/testing.md b/docs/testing.md index 861ba39..1a0a2da 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,3 +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 23 October 2026 local native run passed all 59 tests before final acceptance regressions were added. 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/src/usta/application/pipeline.py b/src/usta/application/pipeline.py index 7ffaef8..11258c7 100644 --- a/src/usta/application/pipeline.py +++ b/src/usta/application/pipeline.py @@ -54,6 +54,19 @@ def __init__( self._future = None self._job = 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: diff --git a/src/usta/application/worker.py b/src/usta/application/worker.py index 98470d8..531f046 100644 --- a/src/usta/application/worker.py +++ b/src/usta/application/worker.py @@ -2,7 +2,7 @@ from dataclasses import replace -from PySide6.QtCore import QRect, QThread, Signal +from PySide6.QtCore import QRect, QThread, Signal, Slot from usta.application.pipeline import Pipeline from usta.core.screenshot import ImageProcessor, ScreenshotFactory @@ -17,6 +17,8 @@ class OCRWorker(QThread): translation_status = Signal(bool) running_status = Signal(bool) error_occurred = Signal(str) + _translation_ready = Signal(object) + _error_ready = Signal(object) def __init__(self): super().__init__() @@ -27,16 +29,36 @@ def __init__(self): ), publisher=TranslationPublisher(), image_path=self.workspace.image_path, - on_translation=self.new_translation.emit, - on_error=self.error_occurred.emit, + on_translation=self._queue_translation, + on_error=self._queue_error, on_performance=self.performance_update.emit, 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._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)) + + @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 + if self.pipeline.is_current(session): + self.error_occurred.emit(text) + @property def capture_rect(self): return QRect(*self.pipeline.settings.capture_rect) @@ -80,7 +102,7 @@ def set_api_key(self, engine, key): def start(self): if self.isRunning(): - self._restart_requested = True + self._restart_requested = self.pipeline.is_stopped return self.pipeline.start() super().start() diff --git a/src/usta/ui/main_window.py b/src/usta/ui/main_window.py index c68223b..c5f6481 100644 --- a/src/usta/ui/main_window.py +++ b/src/usta/ui/main_window.py @@ -47,6 +47,7 @@ class ControlPanel(QWidget): def __init__(self): super().__init__() + self._closing = False self._loading_settings = True self.settings_presenter = SettingsPresenter(self) self.worker = OCRWorker() @@ -465,8 +466,7 @@ def _select_region_now(self): def start(self): self.overlay.set_mode(True) - if not self.worker.isRunning(): - self.controller.start() + self.controller.start() def stop(self): self.controller.stop() @@ -477,8 +477,10 @@ def stop(self): def closeEvent(self, event): if self.worker.isRunning(): event.ignore() - self.worker.finished.connect(self.close) - self.controller.stop() + if not self._closing: + self._closing = True + self.worker.finished.connect(self.close) + self.controller.stop() return self.worker.close() if self._macos_hotkey_window is not None: diff --git a/tests/integration/test_worker_signals.py b/tests/integration/test_worker_signals.py new file mode 100644 index 0000000..7b418e3 --- /dev/null +++ b/tests/integration/test_worker_signals.py @@ -0,0 +1,35 @@ +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) diff --git a/tests/packaging/test_artifacts.py b/tests/packaging/test_artifacts.py index 1739f12..2882d1b 100644 --- a/tests/packaging/test_artifacts.py +++ b/tests/packaging/test_artifacts.py @@ -20,3 +20,27 @@ def test_bundled_resources_are_declared(self): "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 + + wheels = list((ROOT / "dist").glob("*.whl")) + sdists = list((ROOT / "dist").glob("*.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: + 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/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") From bb131d27d03904c07dde3e4fb6a199744c8bd244 Mon Sep 17 00:00:00 2001 From: sedataym Date: Fri, 23 Oct 2026 15:49:10 +0300 Subject: [PATCH 15/24] fix: preserve recovered settings and close final review regressions --- .github/workflows/package.yml | 1 + .gitignore | 3 + :memory:.ses | 2 - docs/development/cleanup-progress.md | 24 +++++ .../plans/2026-10-23-project-cleanup.md | 102 +++++++++--------- docs/testing.md | 2 +- scripts/build-appimage.sh | 2 +- src/usta/infrastructure/settings_store.py | 25 ++++- src/usta/ui/main_window.py | 2 +- src/usta/ui/settings_presenter.py | 18 +++- tests/integration/test_control_panel.py | 35 ++++++ tests/packaging/test_metadata.py | 5 + tests/unit/test_screenshot_contracts.py | 39 +++++++ tests/unit/test_settings_store.py | 22 +++- 14 files changed, 219 insertions(+), 63 deletions(-) delete mode 100644 :memory:.ses create mode 100644 tests/unit/test_screenshot_contracts.py diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 181db57..bd8a41b 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -10,6 +10,7 @@ jobs: - 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 diff --git a/.gitignore b/.gitignore index b69ade2..a650b2f 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,6 @@ target/ .mypy_cache/ .pytest_cache/ .superpowers/ + +# Local native/runtime scratch +/:memory:.ses diff --git a/:memory:.ses b/:memory:.ses deleted file mode 100644 index cea2896..0000000 --- a/:memory:.ses +++ /dev/null @@ -1,2 +0,0 @@ -1792759152173 -0fd44be9-31f6-4a2e-80fd-fccec7a1ab7b diff --git a/docs/development/cleanup-progress.md b/docs/development/cleanup-progress.md index 79afa69..caa8dad 100644 --- a/docs/development/cleanup-progress.md +++ b/docs/development/cleanup-progress.md @@ -36,3 +36,27 @@ Task 9: private capture workspace and real Unix socket tests pass outside sandbo 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. diff --git a/docs/superpowers/plans/2026-10-23-project-cleanup.md b/docs/superpowers/plans/2026-10-23-project-cleanup.md index 18f97e4..74baf3b 100644 --- a/docs/superpowers/plans/2026-10-23-project-cleanup.md +++ b/docs/superpowers/plans/2026-10-23-project-cleanup.md @@ -10,6 +10,8 @@ **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. @@ -93,11 +95,11 @@ Bu yapı görevler boyunca oluşacak; başlangıçta boş klasörler üretilmeye **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. -- [ ] 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. -- [ ] 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. -- [ ] 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. -- [ ] 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. -- [ ] `python -m unittest discover -s tests/unit -v` ve ilgili integration komutları geçsin; değişikliği ayrı committe kaydet. +- [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 @@ -105,10 +107,10 @@ Bu yapı görevler boyunca oluşacak; başlangıçta boş klasörler üretilmeye **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. -- [ ] 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. +- [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. -- [ ] 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. -- [ ] `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. +- [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ı @@ -117,10 +119,10 @@ Bu yapı görevler boyunca oluşacak; başlangıçta boş klasörler üretilmeye **Arayüz:** `usta`, `python -m usta`, `python run.py` aynı `usta.main:main()` fonksiyonuna ulaşacak. Kaynaklar `importlib.resources` ile çözülecek. -- [ ] 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ı. -- [ ] 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. -- [ ] 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. -- [ ] AppRun'u `python -m usta` ile başlat; ikon yollarını ve Babel katalog üretimini güncelle. `run.py` uyumluluk girişini koru. +- [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 @@ -129,11 +131,11 @@ Bu yapı görevler boyunca oluşacak; başlangıçta boş klasörler üretilmeye **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. -- [ ] 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. -- [ ] 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. -- [ ] 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. -- [ ] 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. -- [ ] 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. +- [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 @@ -141,11 +143,11 @@ Bu yapı görevler boyunca oluşacak; başlangıçta boş klasörler üretilmeye **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. -- [ ] 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. -- [ ] 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. -- [ ] `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. -- [ ] 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. -- [ ] `python -m unittest discover -s tests/unit -v` ve başlangıç integration testi geçsin; commit oluştur. +- [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 @@ -153,11 +155,11 @@ Bu yapı görevler boyunca oluşacak; başlangıçta boş klasörler üretilmeye **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. -- [ ] 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. -- [ ] 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. -- [ ] 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. -- [ ] 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. -- [ ] 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. +- [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ış @@ -165,10 +167,10 @@ Bu yapı görevler boyunca oluşacak; başlangıçta boş klasörler üretilmeye **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. -- [ ] 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. -- [ ] 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. -- [ ] 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. -- [ ] 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. +- [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ı @@ -177,9 +179,9 @@ Bu yapı görevler boyunca oluşacak; başlangıçta boş klasörler üretilmeye **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. -- [ ] 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. -- [ ] 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. -- [ ] 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. +- [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. @@ -189,10 +191,10 @@ Bu yapı görevler boyunca oluşacak; başlangıçta boş klasörler üretilmeye **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. -- [ ] İ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. -- [ ] 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. -- [ ] 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. -- [ ] `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. +- [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ı @@ -200,11 +202,11 @@ Bu yapı görevler boyunca oluşacak; başlangıçta boş klasörler üretilmeye **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. -- [ ] 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. -- [ ] 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. -- [ ] 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. -- [ ] 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. -- [ ] `.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. +- [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ü @@ -213,11 +215,11 @@ Bu yapı görevler boyunca oluşacak; başlangıçta boş klasörler üretilmeye **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. -- [ ] 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. +- [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. -- [ ] 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. +- [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. -- [ ] 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. +- [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 @@ -246,12 +248,14 @@ Her görev ayrı review/commit sınırıdır; gerekirse içindeki mekanik ve dav - [ ] 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. -- [ ] Eski ayar/presetler korunarak yükleniyor; durdurulan/eski iş overlay veya IPC'ye sonuç gönderemiyor. -- [ ] Ana pencere dosya işlemi yapmıyor; sekmeler worker'a doğrudan erişmiyor; core motorlarından UI'a import yok. +- [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. -- [ ] README/katkı/mimari/test belgeleri gerçek komutlarla uyumlu; yeni geliştirici temiz clone üzerinden kurulumu tekrarlayabiliyor. +- [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/testing.md b/docs/testing.md index 1a0a2da..574879c 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -2,6 +2,6 @@ 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 23 October 2026 local native run passed all 59 tests before final acceptance regressions were added. CI uses offscreen Qt and leaves actual fullscreen/permission interactions to opt-in native acceptance. +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/scripts/build-appimage.sh b/scripts/build-appimage.sh index b13a4d3..4d1e492 100755 --- a/scripts/build-appimage.sh +++ b/scripts/build-appimage.sh @@ -35,7 +35,7 @@ EOF exit 1 fi -APP_VERSION="${APP_VERSION:-$(${PYTHON_BIN} -c 'from usta._version import __version__; print(__version__)')}" +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" diff --git a/src/usta/infrastructure/settings_store.py b/src/usta/infrastructure/settings_store.py index 64efc92..fd86f0d 100644 --- a/src/usta/infrastructure/settings_store.py +++ b/src/usta/infrastructure/settings_store.py @@ -1,6 +1,7 @@ """Versioned, atomic settings storage with non-destructive legacy migration.""" import json +import logging import os import tempfile from pathlib import Path @@ -8,6 +9,8 @@ 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): @@ -39,7 +42,12 @@ def _write(self, name: str, data: dict) -> None: # Validate existing JSON even when save was called before load. path = self.directory / f"{name}.json" if path.exists(): - self._read(name) + 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: @@ -68,9 +76,20 @@ def save(self, settings: AppSettings) -> None: self._write("settings", settings.to_dict()) def load_presets(self) -> dict[str, Preset]: - presets = {name: Preset.from_dict(value) for name, value in self._read("presets").items()} + 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 ( - not (self.directory / "presets.json").exists() + "presets" not in self._blocked + and not (self.directory / "presets.json").exists() and (self.directory / "presets.pkl").exists() ): self.save_presets(presets) diff --git a/src/usta/ui/main_window.py b/src/usta/ui/main_window.py index c5f6481..e278334 100644 --- a/src/usta/ui/main_window.py +++ b/src/usta/ui/main_window.py @@ -321,6 +321,7 @@ 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: 8px;" @@ -331,7 +332,6 @@ def update_system_status(self, is_running): "background-color: #C62828; border-radius: 8px;" ) self.status_tab.system_status_label.setText(_("Stopped")) - self.overlay.set_mode(False) self.perf_bar.setValue(0) self.perf_bar.setStyleSheet("") diff --git a/src/usta/ui/settings_presenter.py b/src/usta/ui/settings_presenter.py index afda716..f6390d1 100644 --- a/src/usta/ui/settings_presenter.py +++ b/src/usta/ui/settings_presenter.py @@ -23,6 +23,7 @@ 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): @@ -51,7 +52,9 @@ def save_settings(self): "temporary_region_hotkey": self.view.temporary_region_hotkey, } try: - self.store.save(AppSettings.from_dict(settings)) + 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}") @@ -60,8 +63,9 @@ def load_settings(self): self.view._loading_settings = True blockers = [QSignalBlocker(widget) for widget in self.view.findChildren(QWidget)] try: - s = self.store.load().to_dict() - # Update UI elements (triggers signals without blocking) + 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") @@ -187,8 +191,10 @@ def _save_presets_dict(self, presets): 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.""" @@ -224,7 +230,8 @@ def save_preset(self): presets = self._load_presets_dict() presets[name] = preset - self._save_presets_dict(presets) + 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.") @@ -304,7 +311,8 @@ def delete_preset(self): presets = self._load_presets_dict() if name in presets: del presets[name] - self._save_presets_dict(presets) + if not self._save_presets_dict(presets): + return self.refresh_preset_list() logger.info(f"Preset '{name}' deleted.") else: diff --git a/tests/integration/test_control_panel.py b/tests/integration/test_control_panel.py index 3b724d6..fbc2581 100644 --- a/tests/integration/test_control_panel.py +++ b/tests/integration/test_control_panel.py @@ -57,3 +57,38 @@ def test_preset_cancels_temporary_restore(self): 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_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)]) diff --git a/tests/packaging/test_metadata.py b/tests/packaging/test_metadata.py index c95ee94..3634976 100644 --- a/tests/packaging/test_metadata.py +++ b/tests/packaging/test_metadata.py @@ -18,3 +18,8 @@ def test_runtime_dependencies_are_complete(self): 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/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 index 800b880..21867d8 100644 --- a/tests/unit/test_settings_store.py +++ b/tests/unit/test_settings_store.py @@ -1,3 +1,4 @@ +import json import os import pickle import tempfile @@ -5,7 +6,7 @@ from pathlib import Path from unittest.mock import patch -from usta.application.settings import AppSettings +from usta.application.settings import AppSettings, Preset from usta.infrastructure.settings_store import SettingsStore @@ -76,3 +77,22 @@ def test_invalid_field_falls_back_without_changing_valid_coordinates(self): 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) From 99a3f423d330b2869ade8c8943c488e1df1441d1 Mon Sep 17 00:00:00 2001 From: sedataym Date: Fri, 23 Oct 2026 15:57:35 +0300 Subject: [PATCH 16/24] fix: clarify macOS shortcut permission and use system fixed font --- README.md | 6 ++ docs/development/cleanup-progress.md | 4 ++ .../core/platform/macos/hotkey_listener.py | 18 ++++++ src/usta/core/shortcut.py | 20 ++++++- src/usta/ui/tabs/status_tab.py | 3 +- tests/integration/test_control_panel.py | 8 +++ tests/unit/test_shortcut.py | 56 +++++++++++++++++++ 7 files changed, 112 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e92ea5a..93b854a 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,12 @@ 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. diff --git a/docs/development/cleanup-progress.md b/docs/development/cleanup-progress.md index caa8dad..e9cdbf1 100644 --- a/docs/development/cleanup-progress.md +++ b/docs/development/cleanup-progress.md @@ -60,3 +60,7 @@ No review findings deferred. Linux desktop/AppImage and remote CI remain externa - `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. diff --git a/src/usta/core/platform/macos/hotkey_listener.py b/src/usta/core/platform/macos/hotkey_listener.py index 7298bb7..88c27fa 100644 --- a/src/usta/core/platform/macos/hotkey_listener.py +++ b/src/usta/core/platform/macos/hotkey_listener.py @@ -1,16 +1,34 @@ """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: diff --git a/src/usta/core/shortcut.py b/src/usta/core/shortcut.py index c9217f3..cf3bd08 100644 --- a/src/usta/core/shortcut.py +++ b/src/usta/core/shortcut.py @@ -10,6 +10,7 @@ keyboard = None logger = logging.getLogger(__name__) +_permission_warning_emitted = False class GlobalHotkey: @@ -40,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 @@ -53,8 +55,22 @@ def start(self) -> bool: self._hotkey = keyboard.HotKey(hotkey_keys, self._on_activate) listener_type = keyboard.Listener if sys.platform == "darwin": - from usta.core.platform.macos.hotkey_listener import MainThreadLayoutListener - + 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, diff --git a/src/usta/ui/tabs/status_tab.py b/src/usta/ui/tabs/status_tab.py index 05373ab..7abbf05 100644 --- a/src/usta/ui/tabs/status_tab.py +++ b/src/usta/ui/tabs/status_tab.py @@ -1,3 +1,4 @@ +from PySide6.QtGui import QFontDatabase from PySide6.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget from usta.i18n import _ @@ -24,5 +25,5 @@ def __init__(self): tab_status_layout.addStretch() self.rect_label_status = QLabel(_("Region: —")) - self.rect_label_status.setStyleSheet("font-family: monospace;") + self.rect_label_status.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)) tab_status_layout.addWidget(self.rect_label_status) diff --git a/tests/integration/test_control_panel.py b/tests/integration/test_control_panel.py index fbc2581..3696048 100644 --- a/tests/integration/test_control_panel.py +++ b/tests/integration/test_control_panel.py @@ -92,3 +92,11 @@ def test_running_status_restores_overlay_mode_after_queued_restart(self): 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_region_status_uses_available_system_fixed_font(self): + from PySide6.QtGui import QFontDatabase + + label = self.panel.status_tab.rect_label_status + fixed = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont) + self.assertEqual(label.font().family(), fixed.family()) + self.assertNotIn("monospace", label.styleSheet().lower()) diff --git a/tests/unit/test_shortcut.py b/tests/unit/test_shortcut.py index 5e1ed55..ab77279 100644 --- a/tests/unit/test_shortcut.py +++ b/tests/unit/test_shortcut.py @@ -63,6 +63,10 @@ def listen(listener): 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) @@ -85,3 +89,55 @@ def test_off_main_thread_registration_fails_safely(self): 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() From ed181507aef588d84931e309a84f9635113ce0d2 Mon Sep 17 00:00:00 2001 From: sedataym Date: Fri, 23 Oct 2026 16:11:13 +0300 Subject: [PATCH 17/24] fix --- src/usta/application/pipeline.py | 15 +-- src/usta/infrastructure/logging_setup.py | 1 + src/usta/ui/main_window.py | 2 +- tests/unit/test_logging_setup.py | 26 ++++++ tests/unit/test_pipeline_performance.py | 114 +++++++++++++++++++++++ 5 files changed, 151 insertions(+), 7 deletions(-) create mode 100644 tests/unit/test_logging_setup.py create mode 100644 tests/unit/test_pipeline_performance.py diff --git a/src/usta/application/pipeline.py b/src/usta/application/pipeline.py index 11258c7..a8438f6 100644 --- a/src/usta/application/pipeline.py +++ b/src/usta/application/pipeline.py @@ -53,6 +53,7 @@ def __init__( self._executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="usta-translation") self._future = None self._job = None + self._job_started = None @property def session_id(self): @@ -96,20 +97,21 @@ def _translate(self, request, keys): translator = self.translator_factory() for name, key in keys.items(): translator.set_api_key(name, key) - return translator.translate_request(request) + result = translator.translate_request(request) + return result, time.perf_counter() def _collect(self): if self._future is None or not self._future.done(): return - future, job = self._future, self._job - self._future = self._job = None + future, job, started = self._future, self._job, self._job_started + self._future = self._job = self._job_started = 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 = future.result() + result, completed = future.result() except Exception as exc: self.on_error(str(exc)) return @@ -121,6 +123,7 @@ def _collect(self): target_lang=job.target_lang, engine=job.engine_name, ) + self.on_performance(completed - started) def run(self) -> None: capture = None @@ -131,7 +134,6 @@ def run(self) -> None: try: self.publisher.start() while not self._stopped.is_set(): - started = time.perf_counter() try: with self._lock: settings, session = deepcopy(self._settings), self._session @@ -150,6 +152,7 @@ def run(self) -> None: self._collect() if self._stopped.is_set(): break + started = time.perf_counter() if capture.capture( self.rect_factory(settings.capture_rect), self.image_path, @@ -174,12 +177,12 @@ def run(self) -> None: session, ) self._job = request + self._job_started = started self._future = self._executor.submit( self._translate, request, settings.translator_api_keys ) last_text = text self.on_translating(True) - self.on_performance(time.perf_counter() - started) except PortalCanceledError: self.stop() except Exception as exc: diff --git a/src/usta/infrastructure/logging_setup.py b/src/usta/infrastructure/logging_setup.py index 25c77f4..26185c5 100644 --- a/src/usta/infrastructure/logging_setup.py +++ b/src/usta/infrastructure/logging_setup.py @@ -5,3 +5,4 @@ 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/ui/main_window.py b/src/usta/ui/main_window.py index e278334..a1ed5bd 100644 --- a/src/usta/ui/main_window.py +++ b/src/usta/ui/main_window.py @@ -342,7 +342,7 @@ def update_rect_label(self): ) def update_performance_bar(self, duration): - # Inverse logic: 0.5s -> %100 (Best), 3.0s -> %0 (Worst) + # 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)) 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_pipeline_performance.py b/tests/unit/test_pipeline_performance.py new file mode 100644 index 0000000..9473041 --- /dev/null +++ b/tests/unit/test_pipeline_performance.py @@ -0,0 +1,114 @@ +"""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) From 5441d81b7dd8e52dcf5a61865f01e6e6b4c15cb8 Mon Sep 17 00:00:00 2001 From: sedataym Date: Fri, 23 Oct 2026 17:21:05 +0300 Subject: [PATCH 18/24] overlay fix --- MANIFEST.in | 2 +- docs/development/cleanup-progress.md | 10 + pyproject.toml | 2 +- src/usta/application/pipeline.py | 20 +- src/usta/application/results.py | 7 + src/usta/application/worker.py | 13 ++ .../translations/tr/LC_MESSAGES/messages.mo | Bin 4605 -> 4911 bytes .../translations/tr/LC_MESSAGES/messages.po | 10 + .../translations/uk/LC_MESSAGES/messages.mo | Bin 6022 -> 6428 bytes .../translations/uk/LC_MESSAGES/messages.po | 10 + src/usta/ui/assets/select-region.svg | 6 + src/usta/ui/main_window.py | 9 +- src/usta/ui/overlay_window.py | 165 ++++++++++----- src/usta/ui/platform/macos/overlay.py | 3 + src/usta/ui/platform/macos/overlay_cursor.py | 189 ++++++++++++++++++ src/usta/ui/tabs/appearance_tab.py | 4 +- src/usta/ui/tabs/hotkey_tab.py | 4 +- src/usta/ui/tabs/ocr_tab.py | 4 +- src/usta/ui/tabs/save_tab.py | 6 +- src/usta/ui/tabs/status_tab.py | 24 +++ src/usta/ui/tabs/translation_tab.py | 6 +- tests/integration/test_control_panel.py | 60 ++++++ tests/integration/test_overlay_interaction.py | 131 ++++++++++++ tests/integration/test_tab_signals.py | 76 +++++++ tests/integration/test_worker_signals.py | 13 ++ tests/native/test_macos_overlay.py | 7 + tests/native/test_macos_overlay_cursor.py | 156 +++++++++++++++ tests/packaging/test_artifacts.py | 8 + tests/unit/test_pipeline_performance.py | 22 ++ 29 files changed, 896 insertions(+), 71 deletions(-) create mode 100644 src/usta/ui/assets/select-region.svg create mode 100644 src/usta/ui/platform/macos/overlay_cursor.py create mode 100644 tests/integration/test_overlay_interaction.py create mode 100644 tests/integration/test_tab_signals.py create mode 100644 tests/native/test_macos_overlay_cursor.py diff --git a/MANIFEST.in b/MANIFEST.in index 180eae6..2035fe0 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -4,4 +4,4 @@ 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 +recursive-include src/usta/ui/assets *.png *.svg diff --git a/docs/development/cleanup-progress.md b/docs/development/cleanup-progress.md index e9cdbf1..a71e3cb 100644 --- a/docs/development/cleanup-progress.md +++ b/docs/development/cleanup-progress.md @@ -64,3 +64,13 @@ No review findings deferred. Linux desktop/AppImage and remote CI remain externa ## 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/pyproject.toml b/pyproject.toml index 60a9898..fe2673d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ include = ["usta*"] [tool.setuptools.package-data] "usta" = ["resources/translations/*/LC_MESSAGES/*.mo", "resources/translations/*/LC_MESSAGES/*.po", "resources/ocr_models/rapidocr/*.onnx"] -"usta.ui" = ["assets/*.png"] +"usta.ui" = ["assets/*.png", "assets/*.svg"] [project.optional-dependencies] paddle = ["paddleocr>=3.6.0", "paddlepaddle"] diff --git a/src/usta/application/pipeline.py b/src/usta/application/pipeline.py index a8438f6..f694c08 100644 --- a/src/usta/application/pipeline.py +++ b/src/usta/application/pipeline.py @@ -6,7 +6,7 @@ from concurrent.futures import ThreadPoolExecutor from copy import deepcopy -from usta.application.results import TranslationRequest +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 @@ -32,6 +32,7 @@ def __init__( 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 @@ -44,6 +45,7 @@ def __init__( 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() @@ -54,6 +56,7 @@ def __init__( self._future = None self._job = None self._job_started = None + self._job_ocr_seconds = None @property def session_id(self): @@ -94,24 +97,27 @@ def close(self) -> None: 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) - return result, time.perf_counter() + 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 - self._future = self._job = self._job_started = None + 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 = future.result() + result, completed, translation_seconds = future.result() except Exception as exc: self.on_error(str(exc)) return @@ -123,6 +129,9 @@ def _collect(self): 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: @@ -158,9 +167,11 @@ def run(self) -> None: 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 @@ -178,6 +189,7 @@ def run(self) -> None: ) 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 ) diff --git a/src/usta/application/results.py b/src/usta/application/results.py index 288530c..9d420bf 100644 --- a/src/usta/application/results.py +++ b/src/usta/application/results.py @@ -3,6 +3,13 @@ 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 diff --git a/src/usta/application/worker.py b/src/usta/application/worker.py index 531f046..ea61241 100644 --- a/src/usta/application/worker.py +++ b/src/usta/application/worker.py @@ -14,11 +14,13 @@ 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__() @@ -32,12 +34,14 @@ def __init__(self): 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) @@ -47,6 +51,15 @@ def _queue_translation(self, 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 diff --git a/src/usta/resources/translations/tr/LC_MESSAGES/messages.mo b/src/usta/resources/translations/tr/LC_MESSAGES/messages.mo index d7c97202effa14c3988e693e8c6a3a235d04dee1..c0f41763e40fb3e258893894934287d47360fa2e 100644 GIT binary patch delta 1394 zcmZY7T}V_x6u|NETk|tbLn|69Rw=R~A1u&7DMJd;B!a%Yu7g}$?{4?5L6`{^2qH#E z5h?U21r@j?TD{Z~64XOQK_No+QWzBIOM%q?)CG$c*WaBpbLX5nJIkBz1^VXlldmd1 zb$r+JtxS~B%I8qBQn?HdV-B9g3JhTpj$#>3U>d&16r9IZ_z4Se5$iFH$wF+zgG%)& z!-Fimi&DU}?_11cyohTsIaR4FEJjHbK$)-b$NT*Gddwi+f=PG^GqDY8(7|U0Y!pOlQeoszb@PwIsa}*0^a2F1s zbYL1~-x=gp^)ie8ujOHuz$sipxdTU7y$2&GmEXp4oW^Z9kCHHlNl8?WQdo^|14_Y1 zQ4-oH2|a)Q0?OSO%%T7C;xGZZ95+zoI}G3t+<~REN8(K=Cw5UIG97B2W0m}SiluP#lC~qr)F_Z)|D2ZR8oMaB!TYd54%f1=3LE?od zmnu*YD@opxSkqY7yjS;xgKn?(^m*OlT8+QS_Z*R~T3b+;Q&dqLM2M+dD)wA(e>a@uvoH4!%)G@fTW9Tl<4^r@78YH%nb zK48w;uKiyDGS?E0gsiT=N^oKe*#ilgweiVNhtWq&e0=0g?o`#Eb=r$RBC+Y@Ao`ru lY4NV5&l(+{bhQ2SX2^7zbGB|b+K!JK*LEvn<#}%segVLv%ohLv delta 1095 zcmYk)O-R&17{~EvZT-8p?&@kOmSU}Lo7G8Cd(i=vB%)kTRds6?Tl!%~M{==;k#wCv7jW}g4f^UVBby7hN+x=6THRxAD^WUc-XjaL%5u@0S3EYphID~C@0n0FhrT7px;}fjGEFQr;Zp9`ZI%<}- z9y)sPJgR`p&U;wJIEz~_hZVSvO7z{u|G06bw-K+ya^fwx2@@E`F5Hd>a3>C66ZvhF z&Ne0{Pzh!*hV$5nOQ;2kNUnd`Re(psW-N+2nHucJxEr6vcE+Qq9cNJge}>xFEA+^3 zc{+Tp-~y|tKm5QpjD$7{=s_K2AF8lpIEce|1s73;q}aX^41Yf)T?dAI!xdxJdAqP zcPlvmUOF$BQ02dH7lv77FLt96CQ*q-kQ{c^Ie{wpJ}TiHD&b2PUqIb~cW(RvcQF2f ztr&@N{`=^3lU;!nYNr=bM?H=@>IbOAZ%~OAku~f+axLsDs*oa{z%tHR@f3F8In+9L zP|rU{t@k=jN0%aps(i)y8MWhY7)OsA!*#IT$dq-WZgU?d@g&~Fr!GFgF7_}!gIaeC zmGBm-uu0@mi_f~iTW8({KB6v9G3NINTT1*X?@-`c$oD^m9+&#d(My5rLsc{W@mMbK E7g#21e*gdg diff --git a/src/usta/resources/translations/tr/LC_MESSAGES/messages.po b/src/usta/resources/translations/tr/LC_MESSAGES/messages.po index e5301fe..b089879 100644 --- a/src/usta/resources/translations/tr/LC_MESSAGES/messages.po +++ b/src/usta/resources/translations/tr/LC_MESSAGES/messages.po @@ -268,3 +268,13 @@ msgstr "Lütfen silinecek bir ön ayar seçin." 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ç" + diff --git a/src/usta/resources/translations/uk/LC_MESSAGES/messages.mo b/src/usta/resources/translations/uk/LC_MESSAGES/messages.mo index d5094ad8bdda750552cebc9ac6551bd837cd84a1..35ba542cb0ce7b69e4e030f03b6e6e1edc4a10d1 100644 GIT binary patch delta 1475 zcmZA0TS!zv7{KxIZklP9nRanX&7j)CB)gyyYuc5{dr+5JW}vkU>E=$cHXZ{XacmB<}v^o0&87%{OP;mtQLmy~~Wh zp!jq0naignR-(zjZShK_6K=<8xE~$p!C80}i?9!q@C_#5drZMkI0MJ93X>Sj!X3Ct zsgN>g$i&+y2@Ki3#0=swoQd&?N=?Nal#Ldlj63Xjy**xqQ|Pb3I6Q!lthD0;Y1CiSahQ%lJdG9Tq>fjx1Lb7-WVaJbZ98y2@oQXyzfck=pp0_2 zRalH}l*(O1jkoOh8OpmcnnC?}R(+&nEB-`zNt|5#c5Fq7P27O#ltJE+U08}9EW{fq zi9SJcR_{%2_>%DGIW33U;0k_{UyxQ$*2sZi+?ef;@2EWnZ z_qmNgpx#^Oh?FE}C2##F2djl7%x-hQI&Kb_!{&$?Hv9k2WMa~FhVGd6&3lviSS?+f z)8|HNGkRjXqFLFIlo|Okp-aq*@ENgAn^E(wIi#(VW|%dsR(gk7VNjcWvX|94K5|~O zZrD1($O94>;Hju}MoT_q6($UiKdTv*DHFN&GpSS4(aR*hncU`3W_QFfb5Gn~!8sVw delta 1100 zcmYk*OK4L;6vpwBG{$I~Hfb7TZEI?%RO~|x#s_M#s8|C55yS^>1a~4xMBNl4E~HU# zp%koiCFmlcNH1zP6(Lf(5OJYxsgQ*fQE*jX3nBXdnpqfb<~L{N&YhVv_l~D;Y$;Tl z1Ea=oAOCj!{S^QG+5=`y#2wg(eb|qCa1EZrZoG~wv4ksd0$1ZC#;}Zsa1P^`*I#vUQ?S;bz*Y2jVIB5kJ*Ke@2XF(P!X)$C zFqL&QjH4!afl+*o9XN}6zz<}u<=M4>AO)MTMpR~En8lQf&to6)Flxso)c9AZjZI^a z`E8C0msMQHXVie-*p1;(Z2=inDhE*uJBDZQBHqRi*oKO}iKp;6 zR`39hhROeZDhuJ-&MuM1Bg94L0MX}Fjn7aS`+}WVb#anB>mA4- zO|}aM@G$0ZBtrhxFiXQA#yNPs3&(K>=5ZsIP^o{1tjQLUP1tv2=yKY$uqf%9!fm(~ zmr&2GXK)whkiA+S)qkx(MQ1dIde8&c;o&CYH@F>ty8d211!ZI}_Tp)Li$!epnVldF zN`0Phb2r{Zx@;P`>>~xIW=p8AEmftW)U`26Dcy?NNem~MU4HLz@SyKb$nzFM Yv5>bvI^(}P5^eA@EfJqrj-Rai2XyRhssI20 diff --git a/src/usta/resources/translations/uk/LC_MESSAGES/messages.po b/src/usta/resources/translations/uk/LC_MESSAGES/messages.po index 5bd7a1d..dafba03 100644 --- a/src/usta/resources/translations/uk/LC_MESSAGES/messages.po +++ b/src/usta/resources/translations/uk/LC_MESSAGES/messages.po @@ -284,3 +284,13 @@ msgstr "Виберіть набір налаштувань для видален 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 "Вибрати область" + 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/usta/ui/main_window.py b/src/usta/ui/main_window.py index a1ed5bd..03c3445 100644 --- a/src/usta/ui/main_window.py +++ b/src/usta/ui/main_window.py @@ -1,7 +1,7 @@ import logging import os -from PySide6.QtCore import QSignalBlocker, Qt, QTimer, Signal, Slot +from PySide6.QtCore import QSignalBlocker, QSize, Qt, QTimer, Signal, Slot from PySide6.QtGui import QIcon from PySide6.QtWidgets import ( QApplication, @@ -26,6 +26,7 @@ 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 @@ -144,7 +145,9 @@ def __init__(self): # --- Controls (outside tabs) --- layout.addWidget(QLabel(_("Controls"))) - self.btn_reg = QPushButton(_("🖼 Select Region")) + 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;" ) @@ -168,6 +171,7 @@ def __init__(self): self.worker.error_occurred.connect(self.overlay.update_text) 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_topmost_requested.connect(self.set_settings_always_on_top) self._settings_always_on_top = False @@ -332,6 +336,7 @@ def update_system_status(self, is_running): "background-color: #C62828; border-radius: 8px;" ) self.status_tab.system_status_label.setText(_("Stopped")) + self.status_tab.reset_timings() self.perf_bar.setValue(0) self.perf_bar.setStyleSheet("") diff --git a/src/usta/ui/overlay_window.py b/src/usta/ui/overlay_window.py index 69477c6..9e0ed0c 100644 --- a/src/usta/ui/overlay_window.py +++ b/src/usta/ui/overlay_window.py @@ -1,5 +1,5 @@ from PySide6.QtCore import Qt, QTimer, Signal, Slot -from PySide6.QtGui import QColor, QPainter, QPainterPath, QPen +from PySide6.QtGui import QColor, QCursor, QPainter, QPainterPath, QPen from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget from usta.config import OS, SETTINGS_TOPMOST_HOTKEY @@ -79,6 +79,10 @@ def paintEvent(self, event): class TransparentOverlay(QWidget): + RESIZE_MARGIN = 20 + MINIMUM_RESIZE_WIDTH = 200 + MINIMUM_RESIZE_HEIGHT = 60 + main_window_topmost_requested = Signal(bool) settings_topmost_hotkey_pressed = Signal() @@ -95,11 +99,14 @@ def __init__(self): 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) @@ -136,17 +143,24 @@ def __init__(self): self.hide_timer.setSingleShot(True) self.hide_timer.timeout.connect(lambda: self.label.setText("")) - self.set_mode(False) - - # 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_cursor is not None: + self._macos_cursor.stop() + super().hideEvent(event) def _emit_settings_topmost_hotkey_pressed(self): self.settings_topmost_hotkey_pressed.emit() @@ -177,14 +191,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) @@ -239,6 +262,8 @@ 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() @@ -246,63 +271,101 @@ def closeEvent(self, 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: self.hide_timer.start(10000) else: self.hide_timer.stop() 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/macos/overlay.py b/src/usta/ui/platform/macos/overlay.py index aecd0fc..359b3d4 100644 --- a/src/usta/ui/platform/macos/overlay.py +++ b/src/usta/ui/platform/macos/overlay.py @@ -113,6 +113,9 @@ def _apply(self, allow_hidden=False): 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. 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/tabs/appearance_tab.py b/src/usta/ui/tabs/appearance_tab.py index 4c532ba..a042f47 100644 --- a/src/usta/ui/tabs/appearance_tab.py +++ b/src/usta/ui/tabs/appearance_tab.py @@ -45,7 +45,7 @@ def __init__(self, settings=None): h_colors = QHBoxLayout() self.btn_color = QPushButton(_("🎨 Text Color")) - self.btn_color.clicked.connect(self.text_color_requested.emit) + self.btn_color.clicked.connect(self.text_color_requested) h_colors.addWidget(self.btn_color) self.color_sample = QLabel() @@ -56,7 +56,7 @@ def __init__(self, settings=None): h_colors.addWidget(self.color_sample) self.btn_bg_color = QPushButton(_("🎨 Background")) - self.btn_bg_color.clicked.connect(self.background_color_requested.emit) + self.btn_bg_color.clicked.connect(self.background_color_requested) h_colors.addWidget(self.btn_bg_color) self.bg_color_sample = QLabel() diff --git a/src/usta/ui/tabs/hotkey_tab.py b/src/usta/ui/tabs/hotkey_tab.py index 8be8625..a96e4fd 100644 --- a/src/usta/ui/tabs/hotkey_tab.py +++ b/src/usta/ui/tabs/hotkey_tab.py @@ -174,7 +174,7 @@ def __init__( tab_settings_layout, _("Settings topmost key:"), self.settings_topmost_hotkey_button, - self.reset_settings_requested.emit, + self.reset_settings_requested, settings_topmost_tooltip, ) @@ -184,7 +184,7 @@ def __init__( tab_settings_layout, _("Temporary region key:"), self.temporary_region_hotkey_button, - self.reset_region_requested.emit, + self.reset_region_requested, temporary_region_tooltip, ) diff --git a/src/usta/ui/tabs/ocr_tab.py b/src/usta/ui/tabs/ocr_tab.py index d869e13..2d8f1e7 100644 --- a/src/usta/ui/tabs/ocr_tab.py +++ b/src/usta/ui/tabs/ocr_tab.py @@ -20,7 +20,7 @@ def __init__(self): 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.emit) + self.combo_ocr.currentTextChanged.connect(self.changed) tab_ocr_layout.addWidget(self.combo_ocr) tab_ocr_layout.addWidget(QLabel(_("Screen Engine:"))) @@ -30,7 +30,7 @@ def __init__(self): 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.emit) + self.combo_screenshot.currentTextChanged.connect(self.changed) tab_ocr_layout.addWidget(self.combo_screenshot) h_dpi = QHBoxLayout() diff --git a/src/usta/ui/tabs/save_tab.py b/src/usta/ui/tabs/save_tab.py index b72f223..e9fd0b6 100644 --- a/src/usta/ui/tabs/save_tab.py +++ b/src/usta/ui/tabs/save_tab.py @@ -30,15 +30,15 @@ def __init__(self): h_buttons = QHBoxLayout() self.btn_save_preset = QPushButton(_("💾 Save")) - self.btn_save_preset.clicked.connect(self.save_requested.emit) + 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.emit) + 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.emit) + self.btn_delete_preset.clicked.connect(self.delete_requested) h_buttons.addWidget(self.btn_delete_preset) tab_save_layout.addLayout(h_buttons) diff --git a/src/usta/ui/tabs/status_tab.py b/src/usta/ui/tabs/status_tab.py index 7abbf05..84e5789 100644 --- a/src/usta/ui/tabs/status_tab.py +++ b/src/usta/ui/tabs/status_tab.py @@ -24,6 +24,30 @@ def __init__(self): tab_status_layout.addStretch() + self.timings_label = QLabel() + self.timings_label.setWordWrap(False) + self.timings_label.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)) + self.timings_label.setToolTip(_("Total includes screen capture and preprocessing.")) + self.reset_timings() + tab_status_layout.addWidget(self.timings_label) + self.rect_label_status = QLabel(_("Region: —")) self.rect_label_status.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)) tab_status_layout.addWidget(self.rect_label_status) + + def _set_timings_text(self, ocr, translation, total): + self.timings_label.setText( + _("OCR: {ocr} s | Translation: {translation} s | Total: {total} s").format( + ocr=ocr, translation=translation, total=total + ) + ) + + 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("—", "—", "—") diff --git a/src/usta/ui/tabs/translation_tab.py b/src/usta/ui/tabs/translation_tab.py index 6d8e1dc..3ced6dd 100644 --- a/src/usta/ui/tabs/translation_tab.py +++ b/src/usta/ui/tabs/translation_tab.py @@ -20,7 +20,7 @@ def __init__(self): 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.emit) + self.combo_translator.currentTextChanged.connect(self.changed) self.combo_translator.currentTextChanged.connect(self.translator_selected.emit) tab_translation_layout.addWidget(self.combo_translator) @@ -31,7 +31,7 @@ def __init__(self): 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.emit) + self.combo_source.currentTextChanged.connect(self.language_changed) v_source.addWidget(self.combo_source) h_lang.addLayout(v_source) @@ -41,7 +41,7 @@ def __init__(self): 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.emit) + 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) diff --git a/tests/integration/test_control_panel.py b/tests/integration/test_control_panel.py index 3696048..de4da59 100644 --- a/tests/integration/test_control_panel.py +++ b/tests/integration/test_control_panel.py @@ -100,3 +100,63 @@ def test_region_status_uses_available_system_fixed_font(self): fixed = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont) self.assertEqual(label.font().family(), fixed.family()) self.assertNotIn("monospace", label.styleSheet().lower()) + + def test_stage_timings_are_single_line_above_region(self): + from types import SimpleNamespace + + tab = self.panel.status_tab + self.panel.worker.timings_update.emit( + SimpleNamespace(ocr_seconds=0.32, translation_seconds=1.28, total_seconds=1.82) + ) + text = tab.timings_label.text() + for value in ("0.32", "1.28", "1.82"): + self.assertIn(value, text) + self.assertNotIn("\n", text) + self.assertFalse(tab.timings_label.wordWrap()) + self.assertLess( + tab.layout().indexOf(tab.timings_label), tab.layout().indexOf(tab.rect_label_status) + ) + self.panel.update_system_status(False) + self.assertNotIn("1.82", tab.timings_label.text()) + + 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_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 index 7b418e3..e56ad06 100644 --- a/tests/integration/test_worker_signals.py +++ b/tests/integration/test_worker_signals.py @@ -33,3 +33,16 @@ def test_repeated_start_does_not_schedule_unrequested_restart(self): 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, []) diff --git a/tests/native/test_macos_overlay.py b/tests/native/test_macos_overlay.py index c6065e9..6cb31b0 100644 --- a/tests/native/test_macos_overlay.py +++ b/tests/native/test_macos_overlay.py @@ -206,3 +206,10 @@ def test_stop_cancels_pending_restore_and_restart_works(self): 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/test_artifacts.py b/tests/packaging/test_artifacts.py index 2882d1b..95978ce 100644 --- a/tests/packaging/test_artifacts.py +++ b/tests/packaging/test_artifacts.py @@ -34,6 +34,14 @@ def test_built_wheel_and_sdist_contain_identical_resources(self): 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 diff --git a/tests/unit/test_pipeline_performance.py b/tests/unit/test_pipeline_performance.py index 9473041..06d19e6 100644 --- a/tests/unit/test_pipeline_performance.py +++ b/tests/unit/test_pipeline_performance.py @@ -112,3 +112,25 @@ def between_iterations(timeout): ): 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) From 2212e1eb3a653752ea7fdea23a5c4563ad64c116 Mon Sep 17 00:00:00 2001 From: sedataym Date: Tue, 27 Oct 2026 10:38:30 +0300 Subject: [PATCH 19/24] color dialog error fix --- src/usta/ui/main_window.py | 7 +-- tests/integration/test_control_panel.py | 60 ++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/src/usta/ui/main_window.py b/src/usta/ui/main_window.py index 03c3445..997a744 100644 --- a/src/usta/ui/main_window.py +++ b/src/usta/ui/main_window.py @@ -2,7 +2,7 @@ import os from PySide6.QtCore import QSignalBlocker, QSize, Qt, QTimer, Signal, Slot -from PySide6.QtGui import QIcon +from PySide6.QtGui import QColor, QIcon from PySide6.QtWidgets import ( QApplication, QColorDialog, @@ -382,7 +382,8 @@ def update_languages(self): self.save_settings() def choose_color(self): - color = QColorDialog.getColor() + # 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) @@ -392,7 +393,7 @@ def choose_color(self): self.save_settings() def choose_bg_color(self): - color = QColorDialog.getColor() + color = QColorDialog.getColor(QColor(self.overlay.bg_color), self) if color.isValid(): color_name = color.name() self.overlay.set_bg_color(color_name) diff --git a/tests/integration/test_control_panel.py b/tests/integration/test_control_panel.py index de4da59..4393a5f 100644 --- a/tests/integration/test_control_panel.py +++ b/tests/integration/test_control_panel.py @@ -3,8 +3,10 @@ from pathlib import Path from unittest import mock -from PySide6.QtCore import QRect -from PySide6.QtWidgets import QApplication +from PySide6.QtCore import QRect, Qt, QTimer +from PySide6.QtGui import QColor +from PySide6.QtTest import QSignalSpy +from PySide6.QtWidgets import QApplication, QColorDialog from usta.application.settings import AppSettings, Preset from usta.infrastructure.settings_store import SettingsStore @@ -48,6 +50,60 @@ def test_load_does_not_write_and_user_change_writes_once(self): 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))}) From b7c6ecc269950b07294311a76d2c3b87e7073da5 Mon Sep 17 00:00:00 2001 From: sedataym Date: Tue, 27 Oct 2026 13:59:24 +0300 Subject: [PATCH 20/24] add tray menu --- README.md | 19 +++ .../plans/2026-10-27-system-tray.md | 61 ++++++++ .../specs/2026-10-27-system-tray-design.md | 11 ++ src/usta/main.py | 1 + .../translations/tr/LC_MESSAGES/messages.mo | Bin 4911 -> 5461 bytes .../translations/tr/LC_MESSAGES/messages.po | 28 +++- .../translations/uk/LC_MESSAGES/messages.mo | Bin 6428 -> 7179 bytes .../translations/uk/LC_MESSAGES/messages.po | 28 +++- src/usta/ui/main_window.py | 126 +++++++++++------ src/usta/ui/overlay_window.py | 21 +-- src/usta/ui/system_tray.py | 55 ++++++++ src/usta/ui/tabs/hotkey_tab.py | 6 +- tests/integration/test_control_panel.py | 130 +++++++++++++++++- tests/integration/test_linux_windows.py | 15 +- tests/native/test_macos_hotkey_actions.py | 33 +++-- 15 files changed, 453 insertions(+), 81 deletions(-) create mode 100644 docs/superpowers/plans/2026-10-27-system-tray.md create mode 100644 docs/superpowers/specs/2026-10-27-system-tray-design.md create mode 100644 src/usta/ui/system_tray.py diff --git a/README.md b/README.md index 93b854a..eed52a0 100644 --- a/README.md +++ b/README.md @@ -248,3 +248,22 @@ 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 + +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. 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/src/usta/main.py b/src/usta/main.py index ca41756..e8559e5 100644 --- a/src/usta/main.py +++ b/src/usta/main.py @@ -22,6 +22,7 @@ def main(): 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") diff --git a/src/usta/resources/translations/tr/LC_MESSAGES/messages.mo b/src/usta/resources/translations/tr/LC_MESSAGES/messages.mo index c0f41763e40fb3e258893894934287d47360fa2e..e4fae02f803e2157616aa5dffe9d2ee2377053f7 100644 GIT binary patch delta 1743 zcmZwGUuauZ9Ki9D*)?soZCcl^-4xFz>!j1P+moFVm<*cD6`kEyDWmK*IV6{+x#8wE zF$0N;qC@C|VY(sJw^b078P+*a7;T@VWeED%&6kO&v+9#m#{B-0GiA__+|NCKf4}o5 zpZ9&=U%uYia8~hop5G(HdMjBjHXUc@OOL^LrpRvV~{ne zJxG(9>x(V6JMRSCFr|!Q)g&sb6IOC9@+ezZWM^F4rq4E1$=`_%-rX>pUdj9V90e zCM(&oPMpAgluLXDhwuXORX^}}0@ra5_K>Z_JJ`tL@gfZ;QC612aeNnL;vca# z0m@~&jk4mq))oe{Xw`)~aTh*~agj2A0%c1RU1*Pjo4p}HHJf5kIuM(K9I|0+=LUjURJ$iQc(G* zy{D20&ov)2(>c#~{8HtQ@JKT474M5WDi!yl)(W1l(~?S%HhNm}t4E9Kv9!rbBE{^J znvnUxDdn{@=@pFj=5Ne})VK);}6t^?XRZW216akOR1TRGwqa^Zpz6yLFI$??;^uyY2Hn^dU9zYlQKGA zNkvA>8%(uf?0OWnL3h{J1ygeBdHYd4T<_{;lJ4@y9DvdC!m@KFn=rn~7@y5r`s%dU WLi3c9S-$8;D_0`1>Yd2GP5%P3vM+@I delta 1200 zcmYMyT}abW7{~E5ow}vYoT=CgZU%E0X>S*Gp+QoX6vL7TDpQw`D6ZTVbx{Kg0udwr z6p=#L6%{xNmJlR_6m?NnkeHBBBnAa~QxNt2#ev4Q&;PtW&w2jmZO#2qYS!<*YWOtr zTg`7vmRkQmd)>wq(cFhVJc_m0i)A>1)i{QEIE^_ths*FQmf#{bVIG5}7{T4fq)ZnN zns^VDK*stGm(yOv73j`2rT{BYfkLS9THD@k$D7bge=BBV8|GstHeesF!n+tGzInz& zB^?X667$(zfkIe}n{f^9L~RsBaxyXGYm)qNICBnFp#f~hE4Dp@g|ruNJ^n;hpxVPe z#5eUk=p>Cu4yF~EV-DN?C~83ht8oxF;20{QPpA@qML0KUp`Z3ORKQ!7zpP(+?OVmlGth1;J%wrY)!L3+DmjCV$63awU zw?2;Tco|1<{|`H1i1cdc7)1q`L{`uxA|uKe_OqjLH(tuOBE_fKX-p{mB$i2 zU1#HFPv7Z|-tOap_=#A5BAQI3=X3L1j>|Ldyzw+RXS}=I0p_J2dna<7J4Kc0&qZ}E bCsaJ;6#6617k_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 " @@ -278,3 +278,27 @@ 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." diff --git a/src/usta/resources/translations/uk/LC_MESSAGES/messages.mo b/src/usta/resources/translations/uk/LC_MESSAGES/messages.mo index 35ba542cb0ce7b69e4e030f03b6e6e1edc4a10d1..6b6c6cf1d68a0827d8f933b37293c1aea00d122c 100644 GIT binary patch delta 1924 zcma*nUu;uV9Ki8YoOBb|x-rCBkqePvWLBIJ8ImO!66%)4KM<$v#f~*;QO#iyO$UHq(h(!24wSfx}Q-Ah=Dvlzth@pcT{s?=(1MlWth zzN(wcZ5YMHn8Yo(50~KQ7*Z;uzM&x#gYzp1w75QlK3Ry3_%bfUH{J2mZhsOb@W<}> zMR$DG9skbl|AIBl`x9@$8Wt@ezFI`1l>t8n@L61e7B0slcqdMvB;a5@UO`FlCtQY} zYIz`QkUgn2$Sw6CQY_ViQo&Yi!w%^aU%f$N5x#>W&{rN%#X?jWf6wuVO|r_EUd3qg5z7X~8gd;z2x%4H#mwB-VxG zq=u1as6#04$T8P5?s&oV6O{ED`KqtEWIam#DE0R!~)98^(9KCwbW}pu0z?m ziBh4L-2P!S=)a8zjN#9#3a94AXT(obAMe-!JeH_5AZBnS08%1e_%`3JQJrSxwhUp2u+61eP+{}#x#Rn2-H zi}gf|j&Lk)bjIR6W=e%*;E91qqBopYA=7#xl1{3};{)N|SdWp6nh9&iPD_`bJZq(~>yvb~z)J6NFDkG4Kk!0h+wVN*Jv-+)&pA6^b)zCO>2Y2a zUI9Lf`K(RR=>KbnQzVD50kd&G`Z0tBcpb|zfi8T9Y4{Q6;1`^SGgyx<9?r)`+#(W@ zb{abH4r&7TZC_(9@eJmpGhJjZ`cM}tLw)YIJP$Lj{UeA6Q~JIp_ce3YNDxZzZHw{BA!MMdML9d zP>KwdJs8naHPg^DK4{yE+JDye66yqls0$8Z!og9Yk2plRmg#uZyKxIO(KlFyx_z3m z9=%v+$1PdZ-=L#|j=OjPYcW6_2Qh}avtqK_iIui7TuS^FSKuGi1WG8Qo^3r=U=X!( z18CrFJAQ$BH>Pr_KfjgFbZp1psFx(b!8c$OH8yb*=1>N`BfGE?Ls*J8Q4<|Ua+XQd z9e+orlv&gSGa2*<{kQ%g>Y0t8PWZs?e~!zEKjAve zV!M4YN7Sl4BWf8J0*D}|BNGESkM+f>D^m4XpWl0X2KeHotkeys"))) settings_topmost_tooltip = _( - "Use this shortcut to bring the main window to the front while playing a game or watching a video." + "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. " @@ -172,7 +172,7 @@ def __init__( self.settings_topmost_hotkey_button.hotkeyChanged.connect(self.settings_hotkey_changed.emit) _add_hotkey_row( tab_settings_layout, - _("Settings topmost key:"), + _("Main window:"), self.settings_topmost_hotkey_button, self.reset_settings_requested, settings_topmost_tooltip, @@ -182,7 +182,7 @@ def __init__( self.temporary_region_hotkey_button.hotkeyChanged.connect(self.region_hotkey_changed.emit) _add_hotkey_row( tab_settings_layout, - _("Temporary region key:"), + _("Temporary region:"), self.temporary_region_hotkey_button, self.reset_region_requested, temporary_region_tooltip, diff --git a/tests/integration/test_control_panel.py b/tests/integration/test_control_panel.py index 4393a5f..191c4ea 100644 --- a/tests/integration/test_control_panel.py +++ b/tests/integration/test_control_panel.py @@ -5,8 +5,8 @@ from PySide6.QtCore import QRect, Qt, QTimer from PySide6.QtGui import QColor -from PySide6.QtTest import QSignalSpy -from PySide6.QtWidgets import QApplication, QColorDialog +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 @@ -40,7 +40,131 @@ def setUp(self): mock.patch.object(self.store, "save", wraps=self.store.save) ) self.panel = ControlPanel() - self.addCleanup(self.panel.close) + self.addCleanup(lambda: self.panel.request_quit()) + + 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() diff --git a/tests/integration/test_linux_windows.py b/tests/integration/test_linux_windows.py index c7883be..d3d6d01 100644 --- a/tests/integration/test_linux_windows.py +++ b/tests/integration/test_linux_windows.py @@ -38,8 +38,7 @@ def make_control_panel(self, os_name): pump(150) # Finish the normal startup overlay-position timer. def close(): - if panel.isVisible(): - panel.close() + panel.request_quit() panel.overlay.deleteLater() panel.deleteLater() pump() @@ -47,7 +46,7 @@ def close(): self.addCleanup(close) return panel, quit_app - def test_linux_settings_action_keeps_original_window_behavior(self): + 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) @@ -55,13 +54,15 @@ def test_linux_settings_action_keeps_original_window_behavior(self): mock.patch.object(panel, "raise_") as raise_window, mock.patch.object(panel, "activateWindow") as activate, ): - panel.set_settings_always_on_top(True) + panel.hide_main_window() + panel.show_main_window() self.assertTrue(panel.windowFlags() & Qt.WindowStaysOnTopHint) raise_window.assert_called_once() activate.assert_called_once() - panel.set_settings_always_on_top(False) - self.assertFalse(panel.windowFlags() & Qt.WindowStaysOnTopHint) - panel.close() + 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): diff --git a/tests/native/test_macos_hotkey_actions.py b/tests/native/test_macos_hotkey_actions.py index b5f2f19..a632f66 100644 --- a/tests/native/test_macos_hotkey_actions.py +++ b/tests/native/test_macos_hotkey_actions.py @@ -62,8 +62,7 @@ def make_control_panel(self, os_name): pump(150) # Finish the normal startup overlay-position timer. def close(): - if panel.isVisible(): - panel.close() + panel.request_quit() panel.overlay.deleteLater() panel.deleteLater() pump() @@ -71,7 +70,7 @@ def close(): self.addCleanup(close) return panel, quit_app - def test_linux_settings_action_keeps_original_window_behavior(self): + 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) @@ -79,13 +78,15 @@ def test_linux_settings_action_keeps_original_window_behavior(self): mock.patch.object(panel, "raise_") as raise_window, mock.patch.object(panel, "activateWindow") as activate, ): - panel.set_settings_always_on_top(True) + panel.hide_main_window() + panel.show_main_window() self.assertTrue(panel.windowFlags() & Qt.WindowStaysOnTopHint) raise_window.assert_called_once() activate.assert_called_once() - panel.set_settings_always_on_top(False) - self.assertFalse(panel.windowFlags() & Qt.WindowStaysOnTopHint) - panel.close() + 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): @@ -193,15 +194,21 @@ 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") as show, - mock.patch.object(panel._macos_hotkey_window, "lower") as lower, + 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_topmost() + 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() - panel.overlay._toggle_main_window_topmost() - lower.assert_called_once() raise_window.assert_not_called() activate.assert_not_called() original = QRect(panel.worker.capture_rect) @@ -216,7 +223,7 @@ def test_macos_controls_use_native_action_and_restore_temporary_region(self): panel.select_temporary_region() self.assertEqual(panel.worker.capture_rect, original) self.assertFalse(panel._temporary_region_selecting) - panel.close() + panel.request_quit() quit_app.assert_called_once() def test_settings_and_region_selection_preserve_fullscreen_space(self): From 3c177597b55c95361afbdaf632d2c25b56cff2d7 Mon Sep 17 00:00:00 2001 From: sedataym Date: Tue, 27 Oct 2026 14:17:50 +0300 Subject: [PATCH 21/24] tab panel fix --- .../translations/tr/LC_MESSAGES/messages.mo | Bin 5461 -> 5520 bytes .../translations/tr/LC_MESSAGES/messages.po | 6 + .../translations/uk/LC_MESSAGES/messages.mo | Bin 7179 -> 7254 bytes .../translations/uk/LC_MESSAGES/messages.po | 6 + src/usta/ui/main_window.py | 9 +- src/usta/ui/tabs/status_tab.py | 126 ++++++++++++++---- tests/integration/test_control_panel.py | 46 ++++--- 7 files changed, 143 insertions(+), 50 deletions(-) diff --git a/src/usta/resources/translations/tr/LC_MESSAGES/messages.mo b/src/usta/resources/translations/tr/LC_MESSAGES/messages.mo index e4fae02f803e2157616aa5dffe9d2ee2377053f7..b614667217d64b1cc4fcb40fe95a6c8395fe1d36 100644 GIT binary patch delta 1308 zcmYMzPe{{Y9LMpmQ}e&mnPs}L3AH&{DGO4HiHMnjK?^D+WCeBTkR&D+!4GUuAc#<% ziXb#Pb#Wv_f*0%1p-av>L?=rKhXp|t7`;FCKx6y8p5OQRJ-_ew`Lk?vy2bkxD7bHY z`uPRulURf|kgrW}DaAWjgLknPpJFX;;4w4L z3b*}x@C2%W0p}Q2X&`RLSuDpFF8@ zrjyDut3y?Ion_dClc>Y?1hoRo*oI$`uLY?>38P3&b`Z64oj8PNP-ki$JMbOywF;_> zV9?L{x6tSzpoCXZi6&8nJw_Gu4C`>w#Xq2Cu!=exKT(AQ*dG-VM2!!l4qM#aA3~kA zZrpJQ>d*OL?s$W6_7@)%oOUmhp0kl@Ep#& z{u+WB=Y?o&CBRk!U8on2xcD?`hO;<`i>MdZGe|E8q0UeYHS@SLfh>ykVG|CZ#@)nr zyoDT2&lYIt!6nq+Zy8hg4Iki1{`~6CVH7t}i6ZQi3W%d-+=(oj^}G0x^RkPNp$_S7 mv?CW1-NRQ#M}{*i1#S6r#ksu9T1ih)W^Z+~FVkC7>H7;17ItI+ delta 1268 zcmYMzPe{{Y9LMqR=5o4PwyA5=qFSi2k<(6%U|l3f8AOnV$XK1c6*Q_#tfGh!j3^8x zylN11Xqa^fLOR7(bm*vdG5TkVPFaM#KlMQ4x8LjcJkRfWzTfB1?^g0{qPP_HO&Gr; z{I~HhcvbrUtNP4ZDAsT@E@Cf!!3cJ4Fzdm6=*LmyveVoGIF7A&4hQiDw&QEeniXw@ z2ff(a_%DI9a~QX%Bevl++=NrEe#VvOQGs8$`l_p6boHNH`8ziA-a0m5Go3aP--0}3 zsEA=Fp2S^Pz#VuOJMb|ofj1b!B~*gna65XMG$4JDIav~EvVLT-ER9;h3?9a#Dihx( zc?jYxZpAs=i#60z{Xzu_k-id$;~=I`fi9rhFCmk&GAi(dtDi#sfPLd_(Nr*H(z_!O0SmY0>-2$GXsN5-%+>WoY}@4NaZ&IJt9UPUfj=9c%Eeb)Lb zvmvG*!yM{xT|~`%7W?rTa#@|50{%vFGCx^q#lo1w1nLl%FpV?FW$(D{!8#@}O16qO z(8BsFP>u>^RzM|m8KZdH)jvSZU>f!%l> zQ#f684K-BYuc)n1LFG1X+LCV6fCH$&$51OWirVvWRDx%446nNOWmLaa)VOQdhe2M} zxWx<)Y8XS!>@1GrP1L~e@$Uer!?uQ+@wziWXC`g!7{@N$k6F|qyyDtRsIO}Z^Eik1 z%#K@xk6Qyg!UQg$0<59}|3J;u$3C$*7DmKeQmfzl2X4%B<^TWy diff --git a/src/usta/resources/translations/tr/LC_MESSAGES/messages.po b/src/usta/resources/translations/tr/LC_MESSAGES/messages.po index 7745ae7..86008f4 100644 --- a/src/usta/resources/translations/tr/LC_MESSAGES/messages.po +++ b/src/usta/resources/translations/tr/LC_MESSAGES/messages.po @@ -302,3 +302,9 @@ 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" diff --git a/src/usta/resources/translations/uk/LC_MESSAGES/messages.mo b/src/usta/resources/translations/uk/LC_MESSAGES/messages.mo index 6b6c6cf1d68a0827d8f933b37293c1aea00d122c..5e4fe2022eaf646b1fc37b847842937238bf6378 100644 GIT binary patch delta 1313 zcmYM!TS(MF6vy$i?P^|g+tf5OFBo>wG9PL~u`AgHB?Keur3*#9B!pcE>JK3$1QApQ z5)ouYMK3NbRF)JGSg`q`B1C~iBI_nmZ)x@Y*+9##pP94spE+}8)=S}`u=gnxxNf`} z_$=hpK1;p-uZe(J7TpBS!D(E98A)cjScB!*gtPGs^0xE*Ny84DhgY#4?_&;5;#M=y zl9K-oY(yo{;yj5#J&4)ZjT!jV#oxOAFlym1EMRqf1sY9R{d#P zknQyNIDgEi|A8`;;yBJ+HtMB}YcPxlQ5Cp}s?-D5e}%>LV;IFr+>H0grVHb!ja{IO zJFzQB{ndyQScJ9IXF2XgC2$cd@F6OJVbmS@gVk8THgq={QAczXi_vra+o;PHMOEY( z?!b4bJD2N`P6Lf*RNxM7z){r1WnBF=xDJ)zDP&XDiHq?rGSuF?_-9mQz9DEcHrSwm{Qp8JDd%wy65 z*0+2bH4KC?g8Q%pTW}R#!6M9{0+_%cPN4$)hAYsMs~0i{$;m2^TecA?mc>yOtigJ0 zP@nbfDvf-+i_5SN*W)OvRKHLQ1qojPlwvJzKrPgYx}QXnvkuh4oo>7f74Rc0$3d*b zX-p~NF!k3Sm7y|;<3Zeu9rzSOSjWQ(tO<$9+K_kHCDa+Y;e6o6`<=t6`Qymf-gD{o zn0;0K!wlRe{Zi~l9j>>iNd43+hSjLdkD)4b()BN6G5uSZ!6Dp%Cy3@c4xkd-PkDCY zQRe`zq#p=U{|XvW>Z1S}aUCX60o+5KiAk)&FQ~InLOR--tyqk^T)!E0xY|$^X~zV1 zq0ZPRY``@{rT+00jqNo0P%oav%^2dyE5dp#!z0KoyND!dcTg4SMUt`Ss07CG8UDaR z>>)~R=}WA{Y1DdQ{&rVms)@#08mCYT-N%PGf$V~%sJC9!j@t8X)Pj%Q_#kTGH&~6o zF^QX)r0052i437C`wqv@&%TE}w%f8am}9e~zXb!-o8T>h#9#+dho%+v59&0k^f!^O lWw 100%, 3.0s -> 0%. diff --git a/src/usta/ui/tabs/status_tab.py b/src/usta/ui/tabs/status_tab.py index 84e5789..88ca88e 100644 --- a/src/usta/ui/tabs/status_tab.py +++ b/src/usta/ui/tabs/status_tab.py @@ -1,5 +1,14 @@ -from PySide6.QtGui import QFontDatabase -from PySide6.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget +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 _ @@ -7,40 +16,105 @@ class StatusTab(QWidget): def __init__(self): super().__init__() - tab_status_layout = QVBoxLayout(self) - tab_status_layout.setSpacing(6) - tab_status_layout.setContentsMargins(8, 8, 8, 8) + 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(16, 16) - self.system_indicator.setStyleSheet("background-color: #C62828; border-radius: 8px;") + 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() - tab_status_layout.addLayout(h_system) + self._layout.addLayout(h_system) + self._layout.addStretch() - tab_status_layout.addStretch() - - self.timings_label = QLabel() - self.timings_label.setWordWrap(False) - self.timings_label.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)) - self.timings_label.setToolTip(_("Total includes screen capture and preprocessing.")) + 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() - tab_status_layout.addWidget(self.timings_label) - - self.rect_label_status = QLabel(_("Region: —")) - self.rect_label_status.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)) - tab_status_layout.addWidget(self.rect_label_status) - def _set_timings_text(self, ocr, translation, total): - self.timings_label.setText( - _("OCR: {ocr} s | Translation: {translation} s | Total: {total} s").format( - ocr=ocr, translation=translation, total=total + 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( @@ -50,4 +124,8 @@ def update_timings(self, timings): ) def reset_timings(self): - self._set_timings_text("—", "—", "—") + 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/tests/integration/test_control_panel.py b/tests/integration/test_control_panel.py index 191c4ea..8cd841e 100644 --- a/tests/integration/test_control_panel.py +++ b/tests/integration/test_control_panel.py @@ -273,31 +273,37 @@ def test_running_status_restores_overlay_mode_after_queued_restart(self): self.panel.update_system_status(True) self.assertEqual(mode.call_args_list, [mock.call(False), mock.call(True)]) - def test_region_status_uses_available_system_fixed_font(self): - from PySide6.QtGui import QFontDatabase + 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 - label = self.panel.status_tab.rect_label_status - fixed = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont) - self.assertEqual(label.font().family(), fixed.family()) - self.assertNotIn("monospace", label.styleSheet().lower()) + 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_are_single_line_above_region(self): - from types import SimpleNamespace + 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( - SimpleNamespace(ocr_seconds=0.32, translation_seconds=1.28, total_seconds=1.82) - ) - text = tab.timings_label.text() - for value in ("0.32", "1.28", "1.82"): - self.assertIn(value, text) - self.assertNotIn("\n", text) - self.assertFalse(tab.timings_label.wordWrap()) - self.assertLess( - tab.layout().indexOf(tab.timings_label), tab.layout().indexOf(tab.rect_label_status) - ) + 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) - self.assertNotIn("1.82", tab.timings_label.text()) + 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()) From 00195cbb7b0a05ac919761e941019eb08cc30d2e Mon Sep 17 00:00:00 2001 From: sedataym Date: Tue, 27 Oct 2026 16:39:31 +0300 Subject: [PATCH 22/24] macos system access settings --- .gitignore | 3 + README.md | 4 + docs/macos-dmg.md | 103 ++++++++++++++++ packaging/macos/INSTALL.txt | 23 ++++ packaging/macos/check_bundle.py | 104 ++++++++++++++++ .../macos/hooks/hook-rapidocr_onnxruntime.py | 9 ++ packaging/macos/launcher.py | 30 +++++ packaging/macos/usta.spec | 66 +++++++++++ pyproject.toml | 1 + scripts/build-dmg.sh | 47 ++++++++ src/usta/_version.py | 2 +- src/usta/application/pipeline.py | 5 +- src/usta/application/worker.py | 3 +- src/usta/core/exceptions.py | 8 ++ src/usta/core/screenshot/macos_screenshot.py | 102 +++++++++++++--- src/usta/ui/main_window.py | 2 +- src/usta/ui/overlay_window.py | 13 +- tests/integration/test_control_panel.py | 9 ++ tests/integration/test_worker_signals.py | 36 ++++++ tests/native/test_macos_capture.py | 77 ++++++++++++ tests/packaging/test_macos_bundle.py | 33 ++++++ tests/unit/test_macos_screenshot.py | 111 ++++++++++++++++++ 22 files changed, 767 insertions(+), 24 deletions(-) create mode 100644 docs/macos-dmg.md create mode 100644 packaging/macos/INSTALL.txt create mode 100644 packaging/macos/check_bundle.py create mode 100644 packaging/macos/hooks/hook-rapidocr_onnxruntime.py create mode 100644 packaging/macos/launcher.py create mode 100644 packaging/macos/usta.spec create mode 100755 scripts/build-dmg.sh create mode 100644 tests/native/test_macos_capture.py create mode 100644 tests/packaging/test_macos_bundle.py create mode 100644 tests/unit/test_macos_screenshot.py diff --git a/.gitignore b/.gitignore index a650b2f..a50bccc 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,6 @@ target/ # Local native/runtime scratch /:memory:.ses + +# Reusable macOS build recipe +!packaging/macos/usta.spec diff --git a/README.md b/README.md index eed52a0..8062d6f 100644 --- a/README.md +++ b/README.md @@ -267,3 +267,7 @@ main window with **X** hides it; use **Quit Application** in the tray menu to ex 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. diff --git a/docs/macos-dmg.md b/docs/macos-dmg.md new file mode 100644 index 0000000..6dec79e --- /dev/null +++ b/docs/macos-dmg.md @@ -0,0 +1,103 @@ +# 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. + +## Build + +From an Apple Silicon Mac with the project virtual environment installed: + +```sh +.venv/bin/python -m pip install 'pyinstaller>=6.16,<7' +./scripts/build-dmg.sh +``` + +The script builds and signs `dist/macos/USTA.app`, runs offline diagnostics, +and creates `dist/macos/USTA--macOS-arm64.dmg` and its SHA-256 checksum. +The disk image contains the application, an Applications shortcut and install notes. +Diagnostics verify Qt/SVG resources, translations, dynamic engine imports, +EasyOCR/PyTorch, macOS-specific entries in the actual OCR tab combo boxes, and +actual RapidOCR/Apple Vision recognition of a generated image. +They do not capture the screen, start keyboard listeners or make network calls. +The report is saved to `build/macos/bundle-check.json`. + +PyInstaller's cache and intermediate files stay under `build/macos`. +Set `PYTHON_BIN` to use another compatible project Python environment. + +## Signing and installation + +Without `USTA_CODESIGN_IDENTITY`, the build uses a local ad-hoc signature. This +verifies bundle integrity but is **not an Apple Developer ID signature or Apple +notarization**. macOS may require approval under System Settings → Privacy & +Security → Open Anyway. Drag USTA.app to Applications before granting its permissions. +Set `USTA_CODESIGN_IDENTITY` to a valid Developer ID identity for a signed build; +notarization remains a separate release step requiring an Apple developer account. + +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 a stable Apple Development/Developer ID signing +identity. 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/packaging/macos/INSTALL.txt b/packaging/macos/INSTALL.txt new file mode 100644 index 0000000..9f922a1 --- /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. + +This build is ad-hoc signed, not Apple-notarized. macOS may ask you to +approve the app in System Settings > Privacy & Security > Open Anyway. +Only approve the build if you trust its source. + +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/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/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..0e2cd21 --- /dev/null +++ b/packaging/macos/launcher.py @@ -0,0 +1,30 @@ +"""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])) + + 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/usta.spec b/packaging/macos/usta.spec new file mode 100644 index 0000000..10ee874 --- /dev/null +++ b/packaging/macos/usta.spec @@ -0,0 +1,66 @@ +# -*- mode: python ; coding: utf-8 -*- +import os +import runpy +from pathlib import Path + +from PyInstaller.utils.hooks import collect_data_files, collect_dynamic_libs, collect_submodules + +root = Path(SPECPATH).resolve().parents[1] +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", + # 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=os.environ.get("USTA_CODESIGN_IDENTITY") or None, + entitlements_file=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, + "LSMinimumSystemVersion": "26.0", + "NSPrincipalClass": "NSApplication", + "LSApplicationCategoryType": "public.app-category.utilities", + }, +) diff --git a/pyproject.toml b/pyproject.toml index fe2673d..debeaa1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ dependencies = [ "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] diff --git a/scripts/build-dmg.sh b/scripts/build-dmg.sh new file mode 100755 index 0000000..8d85919 --- /dev/null +++ b/scripts/build-dmg.sh @@ -0,0 +1,47 @@ +#!/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 +} +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" + +codesign --verify --deep --strict "${DIST_DIR}/USTA.app" +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" +DMG_PATH="${DIST_DIR}/USTA-${APP_VERSION}-macOS-arm64.dmg" +hdiutil create -ov -volname "USTA ${APP_VERSION}" -srcfolder "${STAGING_DIR}" \ + -format UDZO -imagekey zlib-level=9 "${DMG_PATH}" +hdiutil verify "${DMG_PATH}" +shasum -a 256 "${DMG_PATH}" > "${DMG_PATH}.sha256" +echo "Built: ${DMG_PATH}" diff --git a/src/usta/_version.py b/src/usta/_version.py index f1380ee..c11f861 100644 --- a/src/usta/_version.py +++ b/src/usta/_version.py @@ -1 +1 @@ -__version__ = "0.1.7" +__version__ = "0.1.9" diff --git a/src/usta/application/pipeline.py b/src/usta/application/pipeline.py index f694c08..936fd5d 100644 --- a/src/usta/application/pipeline.py +++ b/src/usta/application/pipeline.py @@ -9,7 +9,7 @@ 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 +from usta.core.exceptions import PortalCanceledError, ScreenCaptureError from usta.core.ocr.ocr_manager import OCRManager from usta.core.translation.translator_manager import TranslatorManager @@ -195,6 +195,9 @@ def run(self) -> None: ) 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: diff --git a/src/usta/application/worker.py b/src/usta/application/worker.py index ea61241..fd6f441 100644 --- a/src/usta/application/worker.py +++ b/src/usta/application/worker.py @@ -69,7 +69,8 @@ def _deliver_translation(self, payload): @Slot(object) def _deliver_error(self, payload): session, text = payload - if self.pipeline.is_current(session): + # Fatal capture errors belong to the just-stopped session as well. + if session == self.pipeline.session_id: self.error_occurred.emit(text) @property diff --git a/src/usta/core/exceptions.py b/src/usta/core/exceptions.py index cdac5d0..7ef9c86 100644 --- a/src/usta/core/exceptions.py +++ b/src/usta/core/exceptions.py @@ -2,3 +2,11 @@ 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/usta/core/screenshot/macos_screenshot.py b/src/usta/core/screenshot/macos_screenshot.py index bd0b116..8d2c682 100644 --- a/src/usta/core/screenshot/macos_screenshot.py +++ b/src/usta/core/screenshot/macos_screenshot.py @@ -1,37 +1,101 @@ -import logging +"""Capture inside USTA's process so screen access belongs to the application.""" + +import io +import math import sys +import threading -from PIL import ImageGrab +from PIL import Image from PySide6.QtCore import QRect +from usta.core.exceptions import ScreenCaptureError, ScreenCapturePermissionError from usta.core.screenshot.base_screenshot import BaseScreenshot -logger = logging.getLogger(__name__) +_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(self, rect: QRect, output_path: str, dpi_scale: float = 1.0) -> bool: - try: - full_img = ImageGrab.grab(all_screens=True) + def _capture_region(self, rect): + import AppKit + import objc + import Quartz + import ScreenCaptureKit - x1 = max(0, int(rect.x() * dpi_scale)) - y1 = max(0, int(rect.y() * dpi_scale)) - x2 = min(full_img.width, int((rect.x() + rect.width()) * dpi_scale)) - y2 = min(full_img.height, int((rect.y() + rect.height()) * dpi_scale)) + if not Quartz.CGPreflightScreenCaptureAccess(): + if not self._permission_requested: + self._permission_requested = True + Quartz.CGRequestScreenCaptureAccess() + if not Quartz.CGPreflightScreenCaptureAccess(): + raise ScreenCapturePermissionError(_PERMISSION_MESSAGE) - if x2 <= x1 or y2 <= y1: - return False + done = threading.Event() + result = {} - crop = full_img.crop((x1, y1, x2, y2)) - if crop.mode != "RGB": - crop = crop.convert("RGB") + 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() - crop.save(output_path) - return True - except Exception as e: - logger.info(f"macOS ekran görüntüsü alınamadı: {e}") + 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/usta/ui/main_window.py b/src/usta/ui/main_window.py index 878eb1f..5425528 100644 --- a/src/usta/ui/main_window.py +++ b/src/usta/ui/main_window.py @@ -172,7 +172,7 @@ def __init__(self): self.btn_stop.clicked.connect(self.stop) layout.addWidget(self.btn_stop) - self.worker.error_occurred.connect(self.overlay.update_text) + 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) diff --git a/src/usta/ui/overlay_window.py b/src/usta/ui/overlay_window.py index b9636da..93c320c 100644 --- a/src/usta/ui/overlay_window.py +++ b/src/usta/ui/overlay_window.py @@ -121,6 +121,7 @@ def __init__(self): 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.setAttribute(Qt.WA_TransparentForMouseEvents) @@ -238,6 +239,7 @@ def set_bg_opacity(self, opacity): @Slot(str) def update_text(self, text): + self._showing_error = False self.label.setText(text) if self.isVisible(): if self._macos_rules is not None: @@ -246,6 +248,11 @@ def update_text(self, text): self.raise_() self.hide_timer.start(10000) + @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() @@ -275,10 +282,14 @@ def set_mode(self, scan): 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() diff --git a/tests/integration/test_control_panel.py b/tests/integration/test_control_panel.py index 8cd841e..d28bc2b 100644 --- a/tests/integration/test_control_panel.py +++ b/tests/integration/test_control_panel.py @@ -267,6 +267,15 @@ def test_presenter_cannot_erase_valid_presets_when_one_entry_is_bad(self): 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) diff --git a/tests/integration/test_worker_signals.py b/tests/integration/test_worker_signals.py index e56ad06..4d8d955 100644 --- a/tests/integration/test_worker_signals.py +++ b/tests/integration/test_worker_signals.py @@ -46,3 +46,39 @@ def test_queued_timings_are_discarded_after_stop(self): 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/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/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/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() From 5ed79efe3c7950e31e34885dbb3bd2983411c887 Mon Sep 17 00:00:00 2001 From: sedataym Date: Tue, 27 Oct 2026 21:00:52 +0300 Subject: [PATCH 23/24] dmg self signed settings --- README.md | 4 + docs/macos-dmg.md | 131 ++++++++++++-- docs/macos-selfsigned-test.md | 52 ++++++ packaging/macos/INSTALL.txt | 6 +- packaging/macos/certs/usta-self-signed.cer | Bin 0 -> 1099 bytes packaging/macos/check_permissions.py | 31 ++++ packaging/macos/entitlements.plist | 8 + packaging/macos/launcher.py | 5 + packaging/macos/self-signed-identity.json | 6 + packaging/macos/signing_policy.py | 168 ++++++++++++++++++ packaging/macos/usta.spec | 13 +- scripts/build-dmg.sh | 70 +++++++- src/usta/_version.py | 2 +- .../translations/tr/LC_MESSAGES/messages.mo | Bin 5520 -> 5613 bytes .../translations/tr/LC_MESSAGES/messages.po | 3 + .../translations/uk/LC_MESSAGES/messages.mo | Bin 7254 -> 7344 bytes .../translations/uk/LC_MESSAGES/messages.po | 3 + src/usta/ui/main_window.py | 2 +- src/usta/ui/system_tray.py | 66 ++++++- src/usta/ui/tray_hint.py | 101 +++++++++++ tests/integration/test_control_panel.py | 55 ++++++ tests/packaging/test_artifacts.py | 6 +- tests/unit/test_macos_signing.py | 72 ++++++++ 23 files changed, 772 insertions(+), 32 deletions(-) create mode 100644 docs/macos-selfsigned-test.md create mode 100644 packaging/macos/certs/usta-self-signed.cer create mode 100644 packaging/macos/check_permissions.py create mode 100644 packaging/macos/entitlements.plist create mode 100644 packaging/macos/self-signed-identity.json create mode 100644 packaging/macos/signing_policy.py create mode 100644 src/usta/ui/tray_hint.py create mode 100644 tests/unit/test_macos_signing.py diff --git a/README.md b/README.md index 8062d6f..fc9a2a6 100644 --- a/README.md +++ b/README.md @@ -251,6 +251,8 @@ If you do not accept full responsibility for the risks described above, you shou ## 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 @@ -271,3 +273,5 @@ 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/macos-dmg.md b/docs/macos-dmg.md index 6dec79e..ad03dfd 100644 --- a/docs/macos-dmg.md +++ b/docs/macos-dmg.md @@ -5,35 +5,130 @@ 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. -## Build +## 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 builds and signs `dist/macos/USTA.app`, runs offline diagnostics, -and creates `dist/macos/USTA--macOS-arm64.dmg` and its SHA-256 checksum. -The disk image contains the application, an Applications shortcut and install notes. -Diagnostics verify Qt/SVG resources, translations, dynamic engine imports, -EasyOCR/PyTorch, macOS-specific entries in the actual OCR tab combo boxes, and -actual RapidOCR/Apple Vision recognition of a generated image. +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`. -PyInstaller's cache and intermediate files stay under `build/macos`. -Set `PYTHON_BIN` to use another compatible project Python environment. +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. -## Signing and installation +## Permission continuity -Without `USTA_CODESIGN_IDENTITY`, the build uses a local ad-hoc signature. This -verifies bundle integrity but is **not an Apple Developer ID signature or Apple -notarization**. macOS may require approval under System Settings → Privacy & -Security → Open Anyway. Drag USTA.app to Applications before granting its permissions. -Set `USTA_CODESIGN_IDENTITY` to a valid Developer ID identity for a signed build; -notarization remains a separate release step requiring an Apple developer account. +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. @@ -96,8 +191,8 @@ 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 a stable Apple Development/Developer ID signing -identity. Ad-hoc builds use code-hash requirements and should not be described +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/packaging/macos/INSTALL.txt b/packaging/macos/INSTALL.txt index 9f922a1..4a71a74 100644 --- a/packaging/macos/INSTALL.txt +++ b/packaging/macos/INSTALL.txt @@ -3,9 +3,9 @@ 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. -This build is ad-hoc signed, not Apple-notarized. macOS may ask you to -approve the app in System Settings > Privacy & Security > Open Anyway. -Only approve the build if you trust its source. +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. diff --git a/packaging/macos/certs/usta-self-signed.cer b/packaging/macos/certs/usta-self-signed.cer new file mode 100644 index 0000000000000000000000000000000000000000..addee0c7f66c69e656f2bda0f211f6b8ab3a5dd1 GIT binary patch literal 1099 zcmXqLVsSTUVp_j|nTe5!NhHK}weh>~3tq~WPoIgWE6II*qB3En0drQgF_q@eDaeMa}=EOQ&JUzGt=`j^U@9E#CeU(3=NFT4NZ)U zOw6Oid5u9_6DXG`GnyC|AzRJJ%D~*j*w0|l#Ms5u#MsDi<4BrFP4J!U$t^dxO-n~>eOXjj)!CVyulroryt*;1uJO(N zqKT7rU9ZiLn5C(;eY&qXr@fG-Am1U4#XJ2=ZM4otyWB8TeV3kcCCfbd^F%ZAX+fus zZ)5H@x?FF0eYbD3_xh76uP&*~oBM}-Uj6NRA+K(lWpMHzca6!ae!q^Pko^j)#?|^V zgT4N79B)?bHr#v2^?l`)S5g1ieI&W1blv2b44Lc$qFMQh0=`V;b@rI>Hf!eN5@Ca;yT)-j^JW`aI0WpE|0Q%vEmRb&diLQFo0UF!-nEB!K ze|^&SJ$hB)f9CbF$N6Y` zD7#d^`?Rf>^^6RB-)1h@c`(YmoGq#Jo{L{>#RhTHvrB?cR%ZCV-%#ysp30TF0A>1kZ`tgFF3*S|RX5|<^+3>oB_sE{_kJe;Pnh;kUyK2MMHI|Y# z|KE=G}4LB)LnMJNN2{s RimNM>H*83sHfzx`UH}r9(=7l1 literal 0 HcmV?d00001 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/launcher.py b/packaging/macos/launcher.py index 0e2cd21..5782dd2 100644 --- a/packaging/macos/launcher.py +++ b/packaging/macos/launcher.py @@ -13,6 +13,11 @@ def main(): 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( 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 index 10ee874..c08a99a 100644 --- a/packaging/macos/usta.spec +++ b/packaging/macos/usta.spec @@ -1,17 +1,23 @@ # -*- 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_bundle", "check_permissions", # Loaded by name; pkgutil collection skips this namespace-package directory. "usta.core.platform.macos.capabilities", "pynput.keyboard._darwin", @@ -44,8 +50,8 @@ exe = EXE( console=False, argv_emulation=False, target_arch="arm64", - codesign_identity=os.environ.get("USTA_CODESIGN_IDENTITY") or None, - entitlements_file=None, + 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( @@ -59,6 +65,7 @@ app = BUNDLE( "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/scripts/build-dmg.sh b/scripts/build-dmg.sh index 8d85919..9f9e89a 100755 --- a/scripts/build-dmg.sh +++ b/scripts/build-dmg.sh @@ -14,6 +14,16 @@ fi 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__)')" @@ -28,7 +38,13 @@ export PYINSTALLER_CONFIG_DIR="${BUILD_DIR}/pyinstaller-cache" --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 @@ -39,9 +55,57 @@ 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" -DMG_PATH="${DIST_DIR}/USTA-${APP_VERSION}-macOS-arm64.dmg" +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}" -shasum -a 256 "${DMG_PATH}" > "${DMG_PATH}.sha256" -echo "Built: ${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/usta/_version.py b/src/usta/_version.py index c11f861..74acd0e 100644 --- a/src/usta/_version.py +++ b/src/usta/_version.py @@ -1 +1 @@ -__version__ = "0.1.9" +__version__ = "0.1.12" diff --git a/src/usta/resources/translations/tr/LC_MESSAGES/messages.mo b/src/usta/resources/translations/tr/LC_MESSAGES/messages.mo index b614667217d64b1cc4fcb40fe95a6c8395fe1d36..1126e141f6c8f749fd84ce58b3150ce9c80fe6bf 100644 GIT binary patch delta 1379 zcmYMzOGs2v9LMovO+FeQWtwJ+w}~<}U#)b}Yf)N}F0w!=nHd{%!ls&WBqW$cDj^n9 z27y6~Y*SGSEfu+} z!>5*CF2BB2>iz#Lxr|ApyNs!rvc{NF%*Bm(1PjoQ3D}E#%?&PV@iuP2dsvO5n2Eo! z#+aDNjQ{&!6RLoqwFlQ}AZFkQCgW=xpRxVVsKh^Pe96XB2r8b#MdLh}gk`uItI$n; zvzx{q0*7!jUd1gqg!%XqbMO=DMRAG7tjAPTfew<>RA3%fq8m>jlQe#0sA)wO(Of{S zWE2mR-*nl)6sFLh#Vq`SCFr7TE!i$q!WvW|4OopWs6>zL{Sj0_lc>aRYcvMHv@#xbmb|E$w^%PC zi)AjO&PpF@+!L(S|Nj{cozm~92NzL)!vy~HUfha<*oC_9urGyJjz!prO4yAmq#rf& zJIErM2R8oP`pU-NVxl(Xa+&J wfzshC?#`sbf5C~d$i!GE;P|6q-)Y80+kBIEeg0ruFdPYnf}N!|mof(a00^R!SO5S3 delta 1298 zcmYMzPe{{Y9LMo*98*gUl5SQ!gaiit>>fk6u@C1eG4=#V5P8o|H@1;P+k zry>ZAPF<1~k+6$(=+Gst4$;X9!eK!W1&iJvd!VuXUeE9Q{GQ+U`}|osJ{8Y?3|8GX zK7IVc{Bmn`_y4n8WftJJj2p3nyV374+kyu$ifLSrSCOwxaH+wYSdRtl!G{>a6+B{= zHIMh-gU3(>^gBnfP6Kf>&Y&Nkx%f+W{|=RS$;FplyyD_DbZT4>tFal^;V$%%-x4%Z z1Uj$@$1sAouo;Wkh;LCZ`i`6M530br4QAxB2yVw1`tUF^NlPP-+G%9*EQ4Cf^LUi} zHsS&$tmXbSZp9Cnz#ph3YoUB4Y)2K+i#>P}mFS-9pFtJ$1eJKs#ph9lFJctGU{(VI zY*ZVDaJ6#Oo~2PUx`1afhk5*rn!yzwR)IOBMth9B)1ITw%nRqDi+^&iV37V_T7TN+ zXF8cRW(}wc$61DLIEgxJ4^S(xfP3*P^0hEkC}As7lkG#TTqh3VDb$%NVh6rKzE(?h zF$@P-|2U091eEYHD$yjWu=}Wj9$^EPT>L$121}^3@e@@@ko{31Vbu62>aZo<{eIM0 z>&6{8k)^Sd#x(B1SFYnXDsdARZAAig-^E3HdjhpJ1E@q}r~-1RmC2)?yNfDx8qZ+S z_16>BxNL;RY65H{(1m*Ou!~QjW;lZbSVFzHkwJPv1a*cIsF^37DP&Qs7hA9&HSQX= z<8|b4W^Im!9-K%0{T48T-|!9|=g+VHGPYs`l_&bg(pZdnD3j?9TU+vd((f|Me diff --git a/src/usta/resources/translations/tr/LC_MESSAGES/messages.po b/src/usta/resources/translations/tr/LC_MESSAGES/messages.po index 86008f4..a4904d8 100644 --- a/src/usta/resources/translations/tr/LC_MESSAGES/messages.po +++ b/src/usta/resources/translations/tr/LC_MESSAGES/messages.po @@ -308,3 +308,6 @@ 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 index 5e4fe2022eaf646b1fc37b847842937238bf6378..9cc8c18c07c53aa1e5f1e8249621a38eae51df16 100644 GIT binary patch delta 1376 zcmYM!T}V@57{KvoHm%gDS*z4Fk0UkunC3+(Q7RQ$k_CN;2*oA~Bht`TS246N3G<>a zM1-Ka3X0NMR9II9M&%iX6qI)rgT4r)Iu7u1`nbpx?{eNq89WFHStRme}h{1M=ZwQ z7||Q^S*TJh#F@%b*DQ!SqvIIB%h-#*FdNTsu@C!D3w?~#EOF%7@)LFMB#h})t#OaB zD4qW6gGvc-jcmq2hsaLMVVp#tG=T^GSczJBE0*I`I5HV(f?hXydt1_>5muNa258V7VrpH;|J6NQmI<^N*R`6J?b9mL|v)@v~bXj z$5A(39JM1~u@0vroXl)K%Gp7n8+AN^)#zsH>w_C`J+`A3cng`NjA9|aMV-+f6VIj! z?MwkOH}RuRq!LFkh)XeI@gvqXuE0`kKuz3@%kc%S#P6tya_RGJtU?!lLw!&Jb?w~@ zG@%zYUcx~W*Wx-nj9U10JgL9`dz|PEB`mnktO~W&2kc% z9cu|$dw1`twvL2a!&XbEJ>(xcmo}bRWDnTG_DHOE_FFq@KZ$k5x?-p7r}l_FZ0TvN K%YUgcZ~7nl>6}df delta 1298 zcmYM!T}abW7{~Evs-QDdcTu`IC(uI2Svy9&cklPGWx_FyhPaq(BKKa5)Vi;IuDc*e!E`BKk?aTb=~bXIl?e5uU^{yo4o~z=b%3n&>;u!QZF^Lo>}-%gV4AmtqcXK{jb|WT@>zif4OKl{|>s zSl`-QU;y*zCviT0#47xWDp@({Yr!Zgk*!#dJ5dW=bKm!%5_*hUxZlNJq7r|P6*!8X z9t?6&)tHY{nWN4uj@r>7+=uOW0l#7xk1<#Yb|5)g0-4hWPJ2r{@`jXVjH>}O{gO}jHT$g{&m!4OQ0(9 z6u0AR)SWBvNT-oTGb(TcH{b|r;s{rNEv`c)cmmm!b>R}ci43(jF8&!+nQzFR>=$Yy zxn%bYSK$JjKpm|Y;743dBZ6AE86$WFmt#L_p;5e#dDNRzu|Cv9gQ&9~K`oea@ic1T zde(No>JFA3rC`z+ASGy2o%gvMK9Aaw9c}Fm_%8-}`*Pj|J0BIL{{m~>f}H>W diff --git a/src/usta/resources/translations/uk/LC_MESSAGES/messages.po b/src/usta/resources/translations/uk/LC_MESSAGES/messages.po index 6e0d211..a694637 100644 --- a/src/usta/resources/translations/uk/LC_MESSAGES/messages.po +++ b/src/usta/resources/translations/uk/LC_MESSAGES/messages.po @@ -324,3 +324,6 @@ msgstr "Загалом" msgid "Region" msgstr "Область" + +msgid "You can access the USTA menu here." +msgstr "Меню USTA доступне тут." diff --git a/src/usta/ui/main_window.py b/src/usta/ui/main_window.py index 5425528..f20da18 100644 --- a/src/usta/ui/main_window.py +++ b/src/usta/ui/main_window.py @@ -535,7 +535,7 @@ def closeEvent(self, event): self.hotkey_controller.close() self.controller.stop() self.overlay.close() - self.tray.icon.hide() + self.tray.close() self._shutdown_complete = True super().closeEvent(event) if event.isAccepted(): diff --git a/src/usta/ui/system_tray.py b/src/usta/ui/system_tray.py index b1833d6..8cda928 100644 --- a/src/usta/ui/system_tray.py +++ b/src/usta/ui/system_tray.py @@ -2,10 +2,13 @@ import logging -from PySide6.QtCore import QObject +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__) @@ -33,9 +36,70 @@ def __init__(self, panel): 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() 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/integration/test_control_panel.py b/tests/integration/test_control_panel.py index d28bc2b..968d729 100644 --- a/tests/integration/test_control_panel.py +++ b/tests/integration/test_control_panel.py @@ -42,6 +42,61 @@ def setUp(self): 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): diff --git a/tests/packaging/test_artifacts.py b/tests/packaging/test_artifacts.py index 95978ce..eaa36e5 100644 --- a/tests/packaging/test_artifacts.py +++ b/tests/packaging/test_artifacts.py @@ -27,8 +27,10 @@ def test_built_wheel_and_sdist_contain_identical_resources(self): import tarfile import zipfile - wheels = list((ROOT / "dist").glob("*.whl")) - sdists = list((ROOT / "dist").glob("*.tar.gz")) + 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") 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) From 61ec5e7e821d5ecb6ef55ee5ad008d97ed8d1874 Mon Sep 17 00:00:00 2001 From: sedataym Date: Wed, 28 Oct 2026 10:08:53 +0300 Subject: [PATCH 24/24] platform dependency fix --- pyproject.toml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index debeaa1..bad96ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,13 +8,14 @@ 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.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", ] @@ -29,7 +30,7 @@ dependencies = [ "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'",