diff --git a/build.py b/build.py index 9b173a913..32ae2c90f 100644 --- a/build.py +++ b/build.py @@ -247,7 +247,7 @@ class Libs(str, Enum): # libc = "/usr/lib/libc++.1.dylib" tesseract = "/usr/local/bin/tesseract" - libtess = "/usr/local/Cellar/tesseract/5.0.1/lib/libtesseract.5.dylib" + libtess = "/usr/local/Cellar/tesseract/5.1.0/lib/libtesseract.5.dylib" liblept = "/usr/local/opt/leptonica/lib/liblept.5.dylib" libarchive = "/usr/local/opt/libarchive/lib/libarchive.13.dylib" libpng = "/usr/local/opt/libpng/lib/libpng16.16.dylib" diff --git a/pyproject.toml b/pyproject.toml index 9bf4adc3a..549971347 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -114,9 +114,8 @@ disable = [ convention = "google" add-ignore = "D107,D104,D103,D100,D105" - [tool.pytest.ini_options] -addopts = "--cov normcap --cov-report xml:cov.xml --cov-report html" +#addopts = "--cov normcap --cov-report xml:cov.xml --cov-report html" testpaths = ["src/tests"] qt_api = "pyside6" markers = ["skip_on_gh: do not run during CI/CD on github"] diff --git a/src/normcap/app.py b/src/normcap/app.py index 9793f3be3..51a7ae672 100644 --- a/src/normcap/app.py +++ b/src/normcap/app.py @@ -8,7 +8,6 @@ from importlib import metadata, resources # TODO: Manual test multi screen -# TODO: Slim down packages # Workaround for older tesseract version 4.0.0 on e.g. Debian Buster locale.setlocale(locale.LC_ALL, "C") @@ -52,7 +51,7 @@ def _set_environ_for_briefcase(): from normcap import __version__ from normcap.args import create_argparser from normcap.gui import system_info, utils -from normcap.gui.main_window import MainWindow +from normcap.gui.tray import SystemTray logging.basicConfig( format="%(asctime)s - %(levelname)-7s - %(name)s:%(lineno)d - %(message)s", @@ -91,9 +90,11 @@ def main(): utils.copy_tessdata_files_to_config_dir() app = QtWidgets.QApplication(sys.argv) - app.setQuitOnLastWindowClosed(True) + app.setQuitOnLastWindowClosed(False) logger.debug("System info:\n%s", system_info.to_dict()) - MainWindow(vars(args)).show() + tray = SystemTray(app, vars(args)) + tray.setVisible(True) + sys.exit(app.exec_()) diff --git a/src/normcap/gui/notifier.py b/src/normcap/gui/notifier.py index eebf4a081..93939c1b7 100644 --- a/src/normcap/gui/notifier.py +++ b/src/normcap/gui/notifier.py @@ -32,8 +32,8 @@ def send_notification(self, capture: Capture): notification_icon = get_icon(icon_file, "tool-magic-symbolic") title, message = self.compose_notification(capture) - self.parent().tray.show() - self.parent().tray.showMessage(title, message, notification_icon) + self.parent().show() + self.parent().showMessage(title, message, notification_icon) # Delay quit or hide to get notification enough time to show up. delay = 5000 if sys.platform == "win32" else 500 diff --git a/src/normcap/gui/settings.py b/src/normcap/gui/settings.py index 456598642..6ca99f1b3 100644 --- a/src/normcap/gui/settings.py +++ b/src/normcap/gui/settings.py @@ -1,5 +1,4 @@ import logging -import sys from PySide6 import QtCore @@ -8,50 +7,41 @@ logger = logging.getLogger(__name__) -def init_settings(*args, initial: dict, reset=False) -> QtCore.QSettings: - """Prepare QT settings. - - Apply defaults to missing setting and overwrite with initially - provided settings (e.g. from CLI args). - """ - settings = QtCore.QSettings(*args) - settings.setFallbacksEnabled(False) - - if reset: - settings = _remove_all_keys(settings) - settings = _set_missing_to_default(settings, DEFAULT_SETTINGS) - settings = _update_from_dict(settings, initial) - if sys.platform == "darwin": - # TODO: Remove after adding working tray support to MacOS - settings.setValue("tray", "false") - settings.sync() - - return settings - - -def _update_from_dict(settings, update_dict): - for key, value in update_dict.items(): - if settings.contains(key): - if value is not None: - settings.setValue(key, value) - elif key in ["reset", "verbose", "very_verbose"]: - continue - else: - logger.debug("Skip update of non existing setting (%s: %s)", key, value) - return settings - - -def _remove_all_keys(settings): - logger.info("Remove existing settings") - for key in settings.allKeys(): - settings.remove(key) - return settings - - -def _set_missing_to_default(settings, defaults): - for d in defaults: - key, value = d.key, d.value - if key not in settings.allKeys() or (settings.value(key) is None): - logger.debug("Reset settings to (%s: %s)", key, value) - settings.setValue(key, value) - return settings +class Settings(QtCore.QSettings): + """Customized settings.""" + + def __init__(self, *args, initial: dict, reset=False): + super().__init__(*args) + self.setFallbacksEnabled(False) + + # Do nicer? + if reset: + self.reset() + + self._set_missing_to_default(DEFAULT_SETTINGS) + self._update_from_dict(initial) + + self.sync() + + def reset(self): + """Remove all existing settings and values.""" + logger.info("Remove existing settings") + for key in self.allKeys(): + self.remove(key) + + def _set_missing_to_default(self, defaults): + for d in defaults: + key, value = d.key, d.value + if key not in self.allKeys() or (self.value(key) is None): + logger.debug("Reset settings to (%s: %s)", key, value) + self.setValue(key, value) + + def _update_from_dict(self, settings_dict): + for key, value in settings_dict.items(): + if self.contains(key): + if value is not None: + self.setValue(key, value) + elif key in ["reset", "verbose", "very_verbose"]: + continue + else: + logger.debug("Skip update of non existing setting (%s: %s)", key, value) diff --git a/src/normcap/gui/settings_menu.py b/src/normcap/gui/settings_menu.py index bf9e17140..ed3f62361 100644 --- a/src/normcap/gui/settings_menu.py +++ b/src/normcap/gui/settings_menu.py @@ -57,7 +57,6 @@ class Communicate(QtCore.QObject): """SettingsMenu' communication bus.""" - on_setting_changed = QtCore.Signal(tuple) on_open_url = QtCore.Signal(str) on_quit_or_hide = QtCore.Signal(str) @@ -65,10 +64,10 @@ class Communicate(QtCore.QObject): class SettingsMenu(QtWidgets.QToolButton): """Button to adjust setting on main window top right.""" - def __init__(self, window_main: QtWidgets.QMainWindow): - super().__init__(window_main) + def __init__(self, parent: QtWidgets.QMainWindow, settings: QtCore.QSettings): + super().__init__(parent) self.setObjectName("settings_icon") - self.settings = window_main.settings + self.settings = settings self.setCursor(QtCore.Qt.ArrowCursor) self.setFixedSize(38, 38) @@ -150,7 +149,7 @@ def _on_item_click(self, action: QtGui.QAction): value = languages if None not in [setting, value]: - self.com.on_setting_changed.emit((setting, value)) + self.settings.setValue(str(setting), value) def _add_settings_section(self, menu): settings_group = QtGui.QActionGroup(menu) diff --git a/src/normcap/gui/system_tray.py b/src/normcap/gui/system_tray.py deleted file mode 100644 index 08d7ec906..000000000 --- a/src/normcap/gui/system_tray.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Create system tray and its menu.""" -import logging - -from PySide6 import QtCore, QtGui, QtWidgets - -from normcap import __version__ -from normcap.gui.utils import get_icon - -logger = logging.getLogger(__name__) - - -class Communicate(QtCore.QObject): - """TrayMenus' communication bus.""" - - on_capture = QtCore.Signal() - on_quit = QtCore.Signal() - - -class SystemTray(QtWidgets.QSystemTrayIcon): - """System tray icon with menu.""" - - def __init__(self, parent: QtCore.QObject): - logger.debug("Set up tray icon") - super().__init__(parent) - self.com = Communicate() - self.setIcon(get_icon("tray.png", "tool-magic-symbolic")) - self._add_tray_menu() - - def _add_tray_menu(self): - """Create menu for system tray.""" - menu = QtWidgets.QMenu() - - action = QtGui.QAction("Capture", menu) - action.triggered.connect(self.com.on_capture.emit) # pylint: disable=no-member - menu.addAction(action) - - action = QtGui.QAction("Exit", menu) - action.triggered.connect(self.com.on_quit.emit) # pylint: disable=no-member - menu.addAction(action) - - self.setContextMenu(menu) diff --git a/src/normcap/gui/main_window.py b/src/normcap/gui/tray.py similarity index 56% rename from src/normcap/gui/main_window.py rename to src/normcap/gui/tray.py index c93a6ce99..ba54dbe44 100644 --- a/src/normcap/gui/main_window.py +++ b/src/normcap/gui/tray.py @@ -1,115 +1,93 @@ -"""Normcap main window.""" - +"""Create system tray and its menu.""" import io import logging import os -import sys import tempfile import time from PIL import Image from PySide6 import QtCore, QtGui, QtWidgets -from normcap import ocr +from normcap import __version__, ocr from normcap.gui import system_info, utils -from normcap.gui.base_window import BaseWindow from normcap.gui.models import Capture, CaptureMode, DesktopEnvironment, Rect, Screen from normcap.gui.notifier import Notifier -from normcap.gui.settings import init_settings -from normcap.gui.settings_menu import SettingsMenu -from normcap.gui.system_tray import SystemTray +from normcap.gui.settings import Settings from normcap.gui.update_check import UpdateChecker +from normcap.gui.window import Window from normcap.screengrab import grab_screens logger = logging.getLogger(__name__) class Communicate(QtCore.QObject): - """Applications' communication bus.""" + """TrayMenus' communication bus.""" - on_region_selected = QtCore.Signal(Rect) - on_image_cropped = QtCore.Signal() + on_capture = QtCore.Signal() + on_quit = QtCore.Signal() + on_update_available = QtCore.Signal(str) on_ocr_performed = QtCore.Signal() on_copied_to_clipboard = QtCore.Signal() on_send_notification = QtCore.Signal(Capture) on_window_positioned = QtCore.Signal() - on_minimize_windows = QtCore.Signal() - on_set_cursor_wait = QtCore.Signal() - on_quit_or_hide = QtCore.Signal(str) - on_update_available = QtCore.Signal(str) on_open_url_and_hide = QtCore.Signal(str) + on_set_cursor_wait = QtCore.Signal() + on_close_or_exit = QtCore.Signal(str) + on_region_selected = QtCore.Signal(Rect) + on_image_cropped = QtCore.Signal() + on_minimize_windows = QtCore.Signal() -class MainWindow(BaseWindow): - """Main (parent) window.""" +class SystemTray(QtWidgets.QSystemTrayIcon): + """System tray icon with menu.""" - tray: SystemTray - settings_menu: SettingsMenu - update_checker: UpdateChecker - com = Communicate() capture = Capture() + windows: dict[int, Window] = {} + + def __init__(self, parent, args): + logger.debug("Set up tray icon") + super().__init__(parent) - def __init__(self, args): - """Initialize main application window.""" - self.settings = init_settings( + self.com = Communicate() + self.settings = Settings( "normcap", "settings", initial=args, reset=args.get("reset", False), ) + self.capture.mode = ( CaptureMode.PARSE if self.settings.value("mode") == "parse" else CaptureMode.RAW ) + self.clipboard = QtWidgets.QApplication.clipboard() self.screens: dict[int, Screen] = system_info.screens() - try: - self._update_screenshots() - except AssertionError: - logger.warning("Screenshots not available. Exiting NormCap.") - sys.exit(1) - - super().__init__( - screen_idx=0, - parent=None, - color=str(self.settings.value("color")), - ) - self.clipboard = QtWidgets.QApplication.clipboard() + self._update_screenshots() - self._set_signals() - self._add_tray() - self._add_settings_menu() + self.setIcon(utils.get_icon("tray.png", "tool-magic-symbolic")) + self._add_tray_menu() self._add_update_checker() self._add_notifier() + self._set_signals() + self._show_windows() - self.all_windows: dict[int, BaseWindow] = {0: self} - if len(self.screens) > 1: - self._init_child_windows() - - def _update_screenshots(self): - for idx, screenshot in enumerate(grab_screens()): - utils.save_image_in_tempfolder(screenshot, postfix=f"_raw_screen{idx}") - self.screens[idx].screenshot = screenshot + def _set_signals(self): + """Set up signals to trigger program logic.""" + self.com.on_capture.connect(self._show_windows) + self.com.on_quit.connect(lambda: self._exit_application("clicked exit in tray")) + self.com.on_region_selected.connect(self._crop_image) + self.com.on_image_cropped.connect(self._capture_to_ocr) + self.com.on_ocr_performed.connect(self._copy_to_clipboard) + self.com.on_copied_to_clipboard.connect(self._notify_or_close) - def _add_settings_menu(self): - self.settings_menu = SettingsMenu(self) - self.settings_menu.com.on_setting_changed.connect(self._update_setting) - self.settings_menu.com.on_open_url.connect(self.com.on_open_url_and_hide) - self.settings_menu.com.on_quit_or_hide.connect( - lambda: self.com.on_quit_or_hide.emit("clicked close in menu") - ) - self.settings_menu.move(self.width() - self.settings_menu.width() - 26, 26) - self.settings_menu.show() - - def _add_tray(self): - self.tray = SystemTray(self) - self.tray.com.on_capture.connect(self._show_windows) - self.tray.com.on_quit.connect( - lambda: self._quit_application("clicked exit in tray") + self.com.on_minimize_windows.connect(self._close_windows) + self.com.on_set_cursor_wait.connect( + lambda: utils.set_cursor(QtCore.Qt.WaitCursor) ) - if self.settings.value("tray", type=bool): - self.tray.show() + self.com.on_close_or_exit.connect(self._close_or_exit) + self.com.on_open_url_and_hide.connect(self._open_url_and_hide) def _add_update_checker(self): if self.settings.value("update", type=bool): @@ -122,153 +100,58 @@ def _add_notifier(self): self.notifier = Notifier(self) self.com.on_send_notification.connect(self.notifier.send_notification) self.notifier.com.on_notification_sent.connect( - lambda: self.com.on_quit_or_hide.emit("notification sent") + lambda: self.com.on_close_or_exit.emit("notification sent") ) - def _set_signals(self): - """Set up signals to trigger program logic.""" - self.com.on_region_selected.connect(self._crop_image) - self.com.on_image_cropped.connect(self._capture_to_ocr) - self.com.on_ocr_performed.connect(self._copy_to_clipboard) - self.com.on_copied_to_clipboard.connect(self._notify_or_close) + def _update_screenshots(self): + screens = grab_screens() + for idx, screenshot in enumerate(screens): + utils.save_image_in_tempfolder(screenshot, postfix=f"_raw_screen{idx}") + self.screens[idx].screenshot = screenshot - self.com.on_minimize_windows.connect(self._hide_windows) - self.com.on_set_cursor_wait.connect( - lambda: utils.set_cursor(QtCore.Qt.WaitCursor) - ) - self.com.on_quit_or_hide.connect(self._quit_or_hide) - self.com.on_open_url_and_hide.connect(self._open_url_and_hide) + def _add_tray_menu(self): + """Create menu for system tray.""" + menu = QtWidgets.QMenu() + + action = QtGui.QAction("Capture", menu) + action.triggered.connect(self.com.on_capture.emit) # pylint: disable=no-member + menu.addAction(action) + + action = QtGui.QAction("Exit", menu) + action.triggered.connect(self.com.on_quit.emit) # pylint: disable=no-member + menu.addAction(action) - ################### - # UI Manipulation # - ################### + self.setContextMenu(menu) - def _init_child_windows(self): + def _show_windows(self): """Initialize child windows with method depending on system.""" if not system_info.display_manager_is_wayland(): - self._create_all_child_windows() + for index in system_info.screens(): + self._create_window(index) + elif system_info.desktop_environment() in [ DesktopEnvironment.GNOME, DesktopEnvironment.KDE, ]: - self.com.on_window_positioned.connect(self._create_next_child_window) + self._create_next_window() - def _create_next_child_window(self): + def _create_next_window(self): """Open child window only for next display.""" - if len(system_info.screens()) > len(self.all_windows): - index = max(self.all_windows.keys()) + 1 - self._create_child_window(index) - - def _create_all_child_windows(self): - """Open all child windows at once.""" - for index in system_info.screens(): - if index == self.screen_idx: - continue - self._create_child_window(index) - - def _create_child_window(self, index: int): + if len(system_info.screens()) > len(self.windows): + index = len(self.windows.keys()) + self._create_window(index) + + def _create_window(self, index: int): """Open a child window for the specified screen.""" - self.all_windows[index] = BaseWindow( + new_window = Window( screen_idx=index, parent=self, color=str(self.settings.value("color")), ) - # self.all_windows[index].show() - - def _hide_windows(self): - """Hide all windows of normcap.""" - logger.debug("Hide %s window(s)", len(self.all_windows)) - utils.set_cursor(None) - for window in self.all_windows.values(): - window.hide() - - def _show_windows(self): - """Make hidden windows visible again.""" - try: - # Give the menu some time to close before taking screenshot - time.sleep(0.05) - self._update_screenshots() - except AssertionError: - logger.debug("Abort showing windows.") - return - - for window in self.all_windows.values(): - window.showFullScreen() - - def _quit_or_hide(self, reason: str): - if self.settings.value("tray", type=bool): - self._hide_windows() - else: - self._quit_application(reason) - - def _quit_application(self, reason: str): - self.main_window.tray.hide() - QtWidgets.QApplication.processEvents() - time.sleep(0.05) - logger.debug("Path to debug images: %s%snormcap", tempfile.gettempdir(), os.sep) - logger.info("Exit normcap (reason: %s)", reason) - QtWidgets.QApplication.quit() - - def _show_or_hide_tray_icon(self): - if self.settings.value("tray", type=bool): - logger.debug("Show tray icon") - self.main_window.tray.show() - else: - logger.debug("Hide tray icon") - self.main_window.tray.hide() - - def _notify_or_close(self): - if self.settings.value("notification", type=bool): - self.com.on_send_notification.emit(self.capture) - else: - self.com.on_quit_or_hide.emit("detection completed") - - def resizeEvent(self, event: QtGui.QResizeEvent) -> None: - """Reposition settings menu on resize.""" - if hasattr(self, "settings_menu"): - self.settings_menu.move(self.width() - self.settings_menu.width() - 26, 26) - return super().resizeEvent(event) - - ######################### - # Helper # - ######################### + if index == 0: + new_window.add_settings_menu(self) - def _open_url_and_hide(self, url): - """Open url in default browser, then hide to tray or exit.""" - logger.debug("Open %s", url) - QtGui.QDesktopServices.openUrl(url) - self.com.on_quit_or_hide.emit("opened web browser") - - def _copy_to_clipboard(self): - """Copy results to clipboard.""" - # Signal is only temporarily connected to avoid being triggered - # on arbitrary clipboard changes - self.clipboard.dataChanged.connect(self.com.on_copied_to_clipboard.emit) - self.clipboard.dataChanged.connect(self.clipboard.dataChanged.disconnect) - - copy = utils.copy_to_clipboard() - copy(self.capture.ocr_text) - QtWidgets.QApplication.processEvents() - - ######################### - # Settings # - ######################### - - def _update_setting(self, data): - name, value = data - self.settings.setValue(name, value) - - if name == "tray": - self._show_or_hide_tray_icon() - elif name == "mode": - self.capture.mode = ( - CaptureMode.PARSE if value == "parse" else CaptureMode.RAW - ) - - logger.debug( - "Settings:\n%s", - [(k, self.settings.value(k)) for k in self.settings.allKeys()], - ) + self.windows[index] = new_window ##################### # OCR Functionality # @@ -283,6 +166,7 @@ def _crop_image(self, grab_info: tuple[Rect, int]): if not screenshot: raise TypeError("Screenshot is None!") + self.capture.mode = CaptureMode[self.settings.value("mode").upper()] self.capture.rect = rect self.capture.screen = self.screens[screen_idx] self.capture.image = screenshot.copy(QtCore.QRect(*rect.geometry)) @@ -304,7 +188,7 @@ def _capture_to_ocr(self): """Perform content recognition on grabed image.""" if self.capture.image_area < 25: logger.warning("Area of %s too small. Skip OCR", self.capture.image_area) - self.com.on_quit_or_hide.emit("selection too small") + self.com.on_close_or_exit.emit("selection too small") return logger.debug("Start OCR") @@ -323,3 +207,52 @@ def _capture_to_ocr(self): logger.info("Text from OCR:\n%s", self.capture.ocr_text) self.com.on_ocr_performed.emit() + + ######################### + # Helper # + ######################### + + def _open_url_and_hide(self, url): + """Open url in default browser, then hide to tray or exit.""" + logger.debug("Open %s", url) + QtGui.QDesktopServices.openUrl(url) + self.com.on_close_or_exit.emit("opened web browser") + + def _copy_to_clipboard(self): + """Copy results to clipboard.""" + # Signal is only temporarily connected to avoid being triggered + # on arbitrary clipboard changes + self.clipboard.dataChanged.connect(self.com.on_copied_to_clipboard.emit) + self.clipboard.dataChanged.connect(self.clipboard.dataChanged.disconnect) + + copy = utils.copy_to_clipboard() + copy(self.capture.ocr_text) + QtWidgets.QApplication.processEvents() + + def _notify_or_close(self): + if self.settings.value("notification", type=bool): + self.com.on_send_notification.emit(self.capture) + else: + self.com.on_close_or_exit.emit("detection completed") + + def _close_windows(self): + """Hide all windows of normcap.""" + logger.debug("Hide %s window(s)", len(self.windows)) + utils.set_cursor(None) + for window in self.windows.values(): + window.close() + self.windows = {} + + def _close_or_exit(self, reason: str): + if self.settings.value("tray", type=bool): + self._close_windows() + else: + self._exit_application(reason) + + def _exit_application(self, reason: str): + self.hide() + QtWidgets.QApplication.processEvents() + time.sleep(0.05) + logger.debug("Path to debug images: %s%snormcap", tempfile.gettempdir(), os.sep) + logger.info("Exit normcap (reason: %s)", reason) + QtWidgets.QApplication.quit() diff --git a/src/normcap/gui/update_check.py b/src/normcap/gui/update_check.py index 5276db65b..90333e134 100644 --- a/src/normcap/gui/update_check.py +++ b/src/normcap/gui/update_check.py @@ -9,21 +9,11 @@ from normcap import __version__ from normcap.gui.constants import URLS +from normcap.gui.downloader_qtnetwork import Downloader from normcap.gui.utils import get_icon, set_cursor logger = logging.getLogger(__name__) -try: - from normcap.gui.downloader_qtnetwork import Downloader - - logger.debug("Using QtNetwork Downloader.") -except ImportError as e: - # TODO: Is this still an issue? - logger.warning("Couldn't load QtNetwork Downloader: %s.", e) - from normcap.gui.downloader_requests import Downloader - - logger.debug("Using Requests Downloader.") - class Communicate(QtCore.QObject): """TrayMenus' communication bus.""" diff --git a/src/normcap/gui/utils.py b/src/normcap/gui/utils.py index ded4eb12a..e95a6ad4d 100644 --- a/src/normcap/gui/utils.py +++ b/src/normcap/gui/utils.py @@ -26,12 +26,18 @@ from normcap.gui import system_info from normcap.gui.constants import URLS -from normcap.gui.models import Capture, DesktopEnvironment +from normcap.gui.models import Capture, CaptureMode, DesktopEnvironment from normcap.ocr.models import OcrResult logger = logging.getLogger(__name__) +def get_capture_mode(mode_text: str) -> CaptureMode: + """""" + + return CaptureMode.PARSE if mode_text.lower() == "parse" else CaptureMode.RAW + + def save_image_in_tempfolder( image: QtGui.QImage, postfix: str = "", log_level=logging.DEBUG ): diff --git a/src/normcap/gui/base_window.py b/src/normcap/gui/window.py similarity index 83% rename from src/normcap/gui/base_window.py rename to src/normcap/gui/window.py index 64ae8b91c..88eac00b1 100644 --- a/src/normcap/gui/base_window.py +++ b/src/normcap/gui/window.py @@ -6,25 +6,29 @@ import logging import sys +from typing import Optional from PySide6 import QtCore, QtGui, QtWidgets from normcap.gui import system_info from normcap.gui.models import CaptureMode, Selection +from normcap.gui.settings_menu import SettingsMenu from normcap.gui.utils import get_icon, move_active_window_to_position logger = logging.getLogger(__name__) -class BaseWindow(QtWidgets.QMainWindow): +class Window(QtWidgets.QMainWindow): """Used for child windows and as base class for MainWindow.""" + settings_menu: Optional[QtWidgets.QToolButton] = None + def __init__(self, screen_idx: int, color: str, parent=None): """Initialize window.""" super().__init__() self.screen_idx: int = screen_idx self.color: QtGui.QColor = QtGui.QColor(color) - self.main_window: QtWidgets.QMainWindow = parent or self + self.tray: QtWidgets.QMainWindow = parent or self # Window properties self.setObjectName(f"window-{self.screen_idx}") @@ -66,11 +70,21 @@ def _add_ui_layer(self): def draw_background_image(self): """Draw screenshot as background image.""" - screen = self.main_window.screens[self.screen_idx] + screen = self.tray.screens[self.screen_idx] pixmap = QtGui.QPixmap() pixmap.convertFromImage(screen.screenshot) self.image_layer.setPixmap(pixmap) + def add_settings_menu(self, tray): + """Add settings menu to current window.""" + self.settings_menu = SettingsMenu(self, tray.settings) + self.settings_menu.com.on_open_url.connect(tray.com.on_open_url_and_hide) + self.settings_menu.com.on_quit_or_hide.connect( + lambda: self.com.on_quit_or_hide.emit("clicked close in menu") + ) + self.settings_menu.move(self.width() - self.settings_menu.width() - 26, 26) + self.settings_menu.show() + ################## # Events ################## @@ -81,7 +95,7 @@ def _ui_layer_paintEvent(self, _): if logger.getEffectiveLevel() == logging.DEBUG: # Draw debug information on screen - screen = self.main_window.screens[self.screen_idx] + screen = self.tray.screens[self.screen_idx] x = y = 25 painter.setPen(QtGui.QPen(self.color)) painter.drawText(x, y * 1, f"QScreen: {screen.geometry}") @@ -100,7 +114,7 @@ def _ui_layer_paintEvent(self, _): painter.drawRect(*rect.geometry) # Draw Mode indicator - if self.main_window.capture.mode is CaptureMode.PARSE: + if CaptureMode[self.tray.settings.value("mode").upper()] is CaptureMode.PARSE: mode_indicator = get_icon("parse.svg") else: mode_indicator = get_icon("raw.svg") @@ -116,12 +130,12 @@ def keyPressEvent(self, event): self.is_selecting = False self.update() else: - self.main_window.com.on_quit_or_hide.emit("esc button pressed") + self.tray.com.on_quit_or_hide.emit("esc button pressed") def mousePressEvent(self, event): """Handle left mouse button clicked.""" if event.button() == QtCore.Qt.LeftButton: - screen = self.main_window.screens[self.screen_idx] + screen = self.tray.screens[self.screen_idx] self.selection.scale_factor = screen.screenshot.width() / screen.width self.selection.start_y = self.selection.end_y = event.position().y() self.selection.start_x = self.selection.end_x = event.position().x() @@ -139,9 +153,9 @@ def mouseReleaseEvent(self, event): if (event.button() == QtCore.Qt.LeftButton) and self.is_selecting: self.selection.end_y = event.position().y() self.selection.end_x = event.position().x() - self.main_window.com.on_set_cursor_wait.emit() - self.main_window.com.on_minimize_windows.emit() - self.main_window.com.on_region_selected.emit( + self.tray.com.on_set_cursor_wait.emit() + self.tray.com.on_minimize_windows.emit() + self.tray.com.on_region_selected.emit( (self.selection.scaled_rect, self.screen_idx) ) self.selection = Selection() @@ -157,13 +171,17 @@ def changeEvent(self, event) -> None: and not self.is_positioned ): self._position_windows_on_wayland() - self.main_window.com.on_window_positioned.emit() + self.tray.com.on_window_positioned.emit() return super().changeEvent(event) def resizeEvent(self, event: QtGui.QResizeEvent) -> None: """Adjust child widget on resize.""" self.ui_layer.resize(self.size()) + if self.settings_menu: + # Reposition settings menu + self.settings_menu.move(self.width() - self.settings_menu.width() - 26, 26) + return super().resizeEvent(event) def showEvent(self, event: QtGui.QShowEvent) -> None: @@ -210,7 +228,7 @@ def _set_fullscreen_linux(self): self.setFocusPolicy(QtCore.Qt.StrongFocus) - screen_geometry = self.main_window.screens[self.screen_idx].geometry + screen_geometry = self.tray.screens[self.screen_idx].geometry self.move(screen_geometry.left, screen_geometry.top) self.setMinimumSize(QtCore.QSize(screen_geometry.width, screen_geometry.height)) self.setMaximumSize(QtCore.QSize(screen_geometry.width, screen_geometry.height)) @@ -225,7 +243,7 @@ def _set_fullscreen_macos(self): ) self.setFocusPolicy(QtCore.Qt.StrongFocus) - screen_geometry = self.main_window.screens[self.screen_idx].geometry + screen_geometry = self.tray.screens[self.screen_idx].geometry self.setGeometry( screen_geometry.left, screen_geometry.top, @@ -240,13 +258,13 @@ def _set_fullscreen_windows(self): | QtCore.Qt.CustomizeWindowHint | QtCore.Qt.WindowStaysOnTopHint ) - screen_geometry = self.main_window.screens[self.screen_idx].geometry + screen_geometry = self.tray.screens[self.screen_idx].geometry self.move(screen_geometry.left, screen_geometry.top) self.showFullScreen() def _position_windows_on_wayland(self): self.setFocus() - screen_geometry = self.main_window.screens[self.screen_idx].geometry + screen_geometry = self.tray.screens[self.screen_idx].geometry logger.debug("Move window %s to position %s", self.screen_idx, screen_geometry) move_active_window_to_position(screen_geometry) self.is_positioned = True diff --git a/src/tests/conftest.py b/src/tests/conftest.py new file mode 100644 index 000000000..90da91e1f --- /dev/null +++ b/src/tests/conftest.py @@ -0,0 +1,35 @@ +from typing import Generator + +import pytest # type: ignore +from PySide6 import QtGui + +from normcap.gui.downloader_qtnetwork import Downloader as QtNetworkDownloader +from normcap.gui.downloader_requests import Downloader as RequestsDownloader +from normcap.gui.models import Capture, CaptureMode, Rect + + +@pytest.fixture(scope="session") +def capture() -> Generator[Capture, None, None]: + """Create argparser and provide its default values.""" + image = QtGui.QImage(200, 300, QtGui.QImage.Format.Format_RGB32) + image.fill(QtGui.QColor("#ff0000")) + + yield Capture( + mode=CaptureMode.PARSE, + rect=Rect(20, 30, 220, 330), + ocr_text="one two three", + ocr_applied_magic="", + image=image, + ) + + +@pytest.fixture(scope="session") +def qt_network_downloader(): + """Create QtNetworkDownloader.""" + yield QtNetworkDownloader() + + +@pytest.fixture(scope="session") +def requests_downloader(): + """Create RequestsDownloader.""" + yield RequestsDownloader() diff --git a/src/tests/fixtures.py b/src/tests/fixtures.py deleted file mode 100644 index 6910cf88e..000000000 --- a/src/tests/fixtures.py +++ /dev/null @@ -1,19 +0,0 @@ -import pytest # type: ignore -from PySide6 import QtGui - -from normcap.gui.models import Capture, CaptureMode, Rect - - -@pytest.fixture(scope="session") -def capture() -> Capture: - """Create argparser and provide its default values.""" - image = QtGui.QImage(200, 300, QtGui.QImage.Format.Format_RGB32) - image.fill(QtGui.QColor("#ff0000")) - - return Capture( - mode=CaptureMode.PARSE, - rect=Rect(20, 30, 220, 330), - ocr_text="one two three", - ocr_applied_magic="", - image=image, - ) diff --git a/src/tests/integration/test_normcap.py b/src/tests/integration/test_normcap.py index 0eccb5107..5e258afdf 100644 --- a/src/tests/integration/test_normcap.py +++ b/src/tests/integration/test_normcap.py @@ -3,11 +3,11 @@ import Levenshtein import pytest -from PySide6 import QtCore, QtGui, QtWidgets +from PySide6 import QtCore, QtGui import normcap from normcap.args import create_argparser -from normcap.gui.main_window import MainWindow +from normcap.gui.tray import SystemTray from .testcases.data import TESTCASES @@ -21,48 +21,33 @@ "data", TESTCASES, ) -def test_app(monkeypatch, qtbot, xvfb, data): +def test_app(monkeypatch, qtbot, qapp, xvfb, data): """Tests complete OCR workflow.""" - logger.setLevel("DEBUG") - args = create_argparser().parse_args([f"--language={data['language']}"]) - test_file = Path(__file__).parent / "testcases" / data["image"] - test_image = QtGui.QImage(test_file.absolute()) - - app = QtWidgets.QApplication.instance() - screen_rect = app.primaryScreen().size() - + screen_rect = qapp.primaryScreen().size() if screen_rect.width() != 1920 or screen_rect.height() != 1080: pytest.xfail("Skipped due to wrong screen resolution.") - # Avoid flickering on wayland during test - monkeypatch.setattr( - normcap.gui.main_window.BaseWindow, - "_position_windows_on_wayland", - lambda _: True, - ) - monkeypatch.setattr( - normcap.gui.main_window, - "grab_screens", - lambda: [test_image], - ) + test_file = Path(__file__).parent / "testcases" / data["image"] + test_image = QtGui.QImage(test_file.absolute()) + + monkeypatch.setattr(normcap.gui.tray, "grab_screens", lambda: [test_image]) + args = create_argparser().parse_args(["--language=eng", "--mode=parse"]) + tray = SystemTray(None, vars(args)) + tray.show() - window = MainWindow(vars(args)) - qtbot.addWidget(window) - window.show() + window = tray.windows[0] + qtbot.mousePress(window, QtCore.Qt.LeftButton, pos=QtCore.QPoint(*data["tl"])) + qtbot.mouseMove(window, pos=QtCore.QPoint(*data["br"])) + qtbot.mouseRelease(window, QtCore.Qt.LeftButton, pos=QtCore.QPoint(*data["br"])) - with qtbot.waitSignal(window.main_window.com.on_copied_to_clipboard): - qtbot.mousePress(window, QtCore.Qt.LeftButton, pos=QtCore.QPoint(*data["tl"])) - qtbot.mouseMove(window, pos=QtCore.QPoint(*data["br"])) - qtbot.mouseRelease(window, QtCore.Qt.LeftButton, pos=QtCore.QPoint(*data["br"])) + def check_ocr_result(): + assert tray.capture.ocr_text - capture = window.main_window.capture - print(capture.rect) - print(data["br"]) + qtbot.waitUntil(check_ocr_result) + capture = tray.capture # Text output is not 100% predictable across different machines: similarity = Levenshtein.ratio(capture.ocr_text, data["transformed"]) - assert ( - capture.ocr_applied_magic == data["ocr_applied_magic"] - ), f"{capture.ocr_text=}" + assert capture.ocr_applied_magic == data["ocr_applied_magic"], capture assert similarity >= 0.98, f"{capture.ocr_text=}" diff --git a/src/tests/integration/testcases/data.py b/src/tests/integration/testcases/data.py index 68ac2526e..2ba58085e 100644 --- a/src/tests/integration/testcases/data.py +++ b/src/tests/integration/testcases/data.py @@ -1,6 +1,5 @@ TESTCASES = [ { - "language": "eng", "image": "ocr_test_1.png", "tl": (46, 745), "br": (500, 950), @@ -16,7 +15,6 @@ "ocr_applied_magic": "UrlMagic", }, { - "language": "eng", "image": "ocr_test_1.png", "tl": (312, 550), "br": (470, 572), diff --git a/src/tests/test_args.py b/src/tests/test_args.py index 797a6b645..16e92f75d 100644 --- a/src/tests/test_args.py +++ b/src/tests/test_args.py @@ -1,7 +1,7 @@ import pytest # type: ignore from normcap.args import create_argparser -from normcap.gui.settings import init_settings +from normcap.gui.settings import Settings # Specific settings for pytest # pylint: disable=redefined-outer-name,protected-access,unused-argument @@ -40,7 +40,7 @@ def test_argparser_help_is_complete(): def test_all_argparser_attributes_in_settings(argparser_defaults): - settings = init_settings("normcap", "settings", initial={}, reset=False) + settings = Settings("normcap", "settings", initial={}, reset=False) for arg in argparser_defaults: if arg in ["verbose", "very_verbose", "reset"]: diff --git a/src/tests/tests_gui/test_downloader.py b/src/tests/tests_gui/test_downloader.py index 6c7ec36c0..3bc2ff0f7 100644 --- a/src/tests/tests_gui/test_downloader.py +++ b/src/tests/tests_gui/test_downloader.py @@ -1,15 +1,14 @@ import pytest -from normcap.gui.downloader_qtnetwork import Downloader as QtNetworkDownloader -from normcap.gui.downloader_requests import Downloader as RequestsDownloader - # Specific settings for pytest # pylint: disable=redefined-outer-name,protected-access,unused-argument @pytest.mark.skip_on_gh -@pytest.mark.parametrize("downloader", [QtNetworkDownloader(), RequestsDownloader()]) -def test_downloader_retrieves_website(qtbot, downloader): +@pytest.mark.parametrize("downloader", ["qt_network_downloader", "requests_downloader"]) +def test_downloader_retrieves_website(qtbot, downloader, request): + downloader = request.getfixturevalue(downloader) + with qtbot.waitSignal(downloader.com.on_download_finished) as result: downloader.get("https://www.google.com") @@ -18,8 +17,10 @@ def test_downloader_retrieves_website(qtbot, downloader): assert "" in raw.lower() -@pytest.mark.parametrize("downloader", [RequestsDownloader(), QtNetworkDownloader()]) -def test_downloader_handles_not_existing_url(caplog, qtbot, downloader): +@pytest.mark.parametrize("downloader", ["qt_network_downloader", "requests_downloader"]) +def test_downloader_handles_not_existing_url(caplog, qtbot, downloader, request): + downloader = request.getfixturevalue(downloader) + # Do not trigger download finished signal on error with qtbot.waitSignal( downloader.com.on_download_finished, raising=False, timeout=4000 diff --git a/src/tests/tests_gui/test_models.py b/src/tests/tests_gui/test_models.py index 058216e9d..7c2f22c6c 100644 --- a/src/tests/tests_gui/test_models.py +++ b/src/tests/tests_gui/test_models.py @@ -2,8 +2,6 @@ from normcap.gui.models import Capture, Rect, Screen, Selection -from ..fixtures import capture # pylint: disable=unused-import - # Specific settings for pytest # pylint: disable=redefined-outer-name,protected-access,unused-argument