diff --git a/gotify_tray/database/default_settings.py b/gotify_tray/database/default_settings.py index 0b951cc..b54fdde 100644 --- a/gotify_tray/database/default_settings.py +++ b/gotify_tray/database/default_settings.py @@ -18,6 +18,7 @@ DEFAULT_SETTINGS = { "tray/notifications/click": True, "tray/icon/unread": False, "watchdog/interval/s": 60, + "MessageWidget/height/min": 100, "MessageWidget/image/size": 33, "MessageWidget/content_image/W_percentage": 1.0, "MessageWidget/content_image/H_percentage": 0.5, @@ -29,4 +30,4 @@ DEFAULT_SETTINGS = { "ImagePopup/extensions": [".jpg", ".jpeg", ".png", ".svg"], "ImagePopup/w": 400, "ImagePopup/h": 400, -} +} \ No newline at end of file diff --git a/gotify_tray/gotify/api.py b/gotify_tray/gotify/api.py index 7fedf7d..e21225a 100644 --- a/gotify_tray/gotify/api.py +++ b/gotify_tray/gotify/api.py @@ -1,5 +1,5 @@ import logging -from typing import Callable, List, Optional, Union +from typing import Callable import requests @@ -22,12 +22,12 @@ class GotifySession(object): self.session = requests.Session() self.update_auth(url.rstrip("/"), token) - def update_auth(self, url: str = None, token: str = None): + def update_auth(self, url: str | None = None, token: str | None = None): if url: self.url = url if token: self.token = token - self.session.headers.update({"X-Gotify-Key": token}) + self.session.headers.update({"X-Gotify-Key": token}) def _get(self, endpoint: str = "/", **kwargs) -> requests.Response: return self.session.get(self.url + endpoint, **kwargs) @@ -50,8 +50,8 @@ class GotifyApplication(GotifySession): super(GotifyApplication, self).__init__(url, application_token) def push( - self, title: str = "", message: str = "", priority: int = 0, extras: dict = None - ) -> Union[GotifyMessageModel, GotifyErrorModel]: + self, title: str = "", message: str = "", priority: int = 0, extras: dict | None = None + ) -> GotifyMessageModel | GotifyErrorModel: response = self._post( "/message", json={ @@ -77,7 +77,7 @@ class GotifyClient(GotifySession): Application """ - def get_applications(self) -> Union[List[GotifyApplicationModel], GotifyErrorModel]: + def get_applications(self) -> list[GotifyApplicationModel] | GotifyErrorModel: response = self._get("/application") return ( [GotifyApplicationModel(x) for x in response.json()] @@ -87,7 +87,7 @@ class GotifyClient(GotifySession): def create_application( self, name: str, description: str = "" - ) -> Union[GotifyApplicationModel, GotifyErrorModel]: + ) -> GotifyApplicationModel | GotifyErrorModel: response = self._post( "/application", json={"name": name, "description": description} ) @@ -99,7 +99,7 @@ class GotifyClient(GotifySession): def update_application( self, application_id: int, name: str, description: str = "" - ) -> Union[GotifyApplicationModel, GotifyErrorModel]: + ) -> GotifyApplicationModel | GotifyErrorModel: response = self._put( f"/application/{application_id}", json={"name": name, "description": description}, @@ -115,7 +115,7 @@ class GotifyClient(GotifySession): def upload_application_image( self, application_id: int, img_path: str - ) -> Optional[Union[GotifyApplicationModel, GotifyErrorModel]]: + ) -> GotifyApplicationModel | GotifyErrorModel | None: try: with open(img_path, "rb") as f: response = self._post( @@ -137,8 +137,8 @@ class GotifyClient(GotifySession): """ def get_application_messages( - self, application_id: int, limit: int = 100, since: int = None - ) -> Union[GotifyPagedMessagesModel, GotifyErrorModel]: + self, application_id: int, limit: int = 100, since: int | None = None + ) -> GotifyPagedMessagesModel | GotifyErrorModel: response = self._get( f"/application/{application_id}/message", params={"limit": limit, "since": since}, @@ -155,8 +155,8 @@ class GotifyClient(GotifySession): return self._delete(f"/application/{application_id}/message").ok def get_messages( - self, limit: int = 100, since: int = None - ) -> Union[GotifyPagedMessagesModel, GotifyErrorModel]: + self, limit: int = 100, since: int | None = None + ) -> GotifyPagedMessagesModel | GotifyErrorModel: response = self._get("/message", params={"limit": limit, "since": since}) if not response.ok: return GotifyErrorModel(response) @@ -174,10 +174,10 @@ class GotifyClient(GotifySession): def listen( self, - opened_callback: Callable[[], None] = None, - closed_callback: Callable[[int, str], None] = None, - new_message_callback: Callable[[GotifyMessageModel], None] = None, - error_callback: Callable[[Exception], None] = None, + opened_callback: (Callable[[], None]) | None = None, + closed_callback: Callable[[int, str], None] | None = None, + new_message_callback: Callable[[GotifyMessageModel], None] | None = None, + error_callback: Callable[[Exception], None] | None = None, ): def dummy(*args): ... @@ -189,7 +189,7 @@ class GotifyClient(GotifySession): self.listener.error.connect(error_callback or dummy) self.listener.start() - def opened_callback(self, user_callback: Callable[[], None] = None): + def opened_callback(self, user_callback: Callable[[], None] | None = None): self.reset_wait_time() if user_callback: user_callback() @@ -222,7 +222,7 @@ class GotifyClient(GotifySession): Health """ - def health(self) -> Union[GotifyHealthModel, GotifyErrorModel]: + def health(self) -> GotifyHealthModel | GotifyErrorModel: response = self._get("/health") return ( GotifyHealthModel(response.json()) @@ -234,7 +234,7 @@ class GotifyClient(GotifySession): Version """ - def version(self) -> Union[GotifyVersionModel, GotifyErrorModel]: + def version(self) -> GotifyVersionModel | GotifyErrorModel: response = self._get("/version") return ( GotifyVersionModel(response.json()) diff --git a/gotify_tray/gotify/models.py b/gotify_tray/gotify/models.py index 36064f3..fd413a3 100644 --- a/gotify_tray/gotify/models.py +++ b/gotify_tray/gotify/models.py @@ -1,7 +1,6 @@ import datetime from dateutil.parser import isoparse import logging -from typing import List, Optional import requests @@ -33,7 +32,7 @@ class GotifyApplicationModel(AttributeDict): class GotifyPagingModel(AttributeDict): limit: int - next: Optional[str] = None + next: str | None = None since: int size: int @@ -41,11 +40,11 @@ class GotifyPagingModel(AttributeDict): class GotifyMessageModel(AttributeDict): appid: int date: datetime.datetime - extras: Optional[dict] = None + extras: dict | None = None id: int message: str - priority: Optional[int] = None - title: Optional[str] = None + priority: int | None = None + title: str | None = None def __init__(self, d: dict, *args, **kwargs): d.update( @@ -55,7 +54,7 @@ class GotifyMessageModel(AttributeDict): class GotifyPagedMessagesModel(AttributeDict): - messages: List[GotifyMessageModel] + messages: list[GotifyMessageModel] paging: GotifyPagingModel diff --git a/gotify_tray/gui/MainApplication.py b/gotify_tray/gui/MainApplication.py index c36115d..c839e06 100644 --- a/gotify_tray/gui/MainApplication.py +++ b/gotify_tray/gui/MainApplication.py @@ -4,7 +4,6 @@ import os import platform import sys import tempfile -from typing import List, Union from gotify_tray import gotify from gotify_tray.__version__ import __title__ @@ -18,7 +17,6 @@ from gotify_tray.tasks import ( GetApplicationMessagesTask, GetMessagesTask, ProcessMessageTask, - ProcessMessagesTask, ServerConnectionWatchdogTask, ) from gotify_tray.gui.themes import set_theme @@ -51,9 +49,7 @@ def init_logger(logger: logging.Logger): else: logging.disable() - logdir = QtCore.QStandardPaths.standardLocations( - QtCore.QStandardPaths.StandardLocation.AppDataLocation - )[0] + logdir = QtCore.QStandardPaths.standardLocations(QtCore.QStandardPaths.StandardLocation.AppDataLocation)[0] if not os.path.exists(logdir): os.mkdir(logdir) logging.basicConfig( @@ -88,9 +84,9 @@ class MainApplication(QtWidgets.QApplication): self.first_connect = True self.gotify_client.listen( - new_message_callback=self.new_message_callback, opened_callback=self.listener_opened_callback, closed_callback=self.listener_closed_callback, + new_message_callback=self.new_message_callback, error_callback=self.listener_error_callback, ) @@ -108,29 +104,17 @@ class MainApplication(QtWidgets.QApplication): self.application_model.setItem(0, 0, ApplicationAllMessagesItem()) self.get_applications_task = GetApplicationsTask(self.gotify_client) - self.get_applications_task.success.connect( - self.get_applications_success_callback - ) - self.get_applications_task.started.connect( - self.main_window.disable_applications - ) - self.get_applications_task.finished.connect( - self.main_window.enable_applications - ) + self.get_applications_task.success.connect(self.get_applications_success_callback) + self.get_applications_task.started.connect(self.main_window.disable_applications) + self.get_applications_task.finished.connect(self.main_window.enable_applications) self.get_applications_task.start() def get_applications_success_callback( - self, applications: List[gotify.GotifyApplicationModel], + self, applications: list[gotify.GotifyApplicationModel], ): for i, application in enumerate(applications): - icon = QtGui.QIcon( - self.downloader.get_filename( - f"{self.gotify_client.url}/{application.image}" - ) - ) - self.application_model.setItem( - i + 1, 0, ApplicationModelItem(application, icon), - ) + icon = QtGui.QIcon(self.downloader.get_filename(f"{self.gotify_client.url}/{application.image}")) + self.application_model.setItem(i + 1, 0, ApplicationModelItem(application, icon)) def update_last_id(self, i: int): if i > settings.value("message/last", type=int): @@ -153,9 +137,9 @@ class MainApplication(QtWidgets.QApplication): for message in page.messages: if message.id > last_id: if settings.value("message/check_missed/notify", type=bool): - self.new_message_callback(message) + self.new_message_callback(message, process=False) else: - self.add_message_to_model(message) + self.add_message_to_model(message, process=False) ids.append(message.id) if ids: @@ -169,9 +153,7 @@ class MainApplication(QtWidgets.QApplication): self.main_window.set_connecting() self.tray.set_icon_error() self.gotify_client.increase_wait_time() - QtCore.QTimer.singleShot( - self.gotify_client.get_wait_time() * 1000, self.gotify_client.reconnect - ) + QtCore.QTimer.singleShot(self.gotify_client.get_wait_time() * 1000, self.gotify_client.reconnect) def listener_error_callback(self, exception: Exception): self.main_window.set_connecting() @@ -184,51 +166,8 @@ class MainApplication(QtWidgets.QApplication): else: self.gotify_client.stop(reset_wait=True) - def insert_message( - self, - row: int, - message: gotify.GotifyMessageModel, - application: gotify.GotifyApplicationModel, - ): - """Insert a message gotify message into the messages model. Also add the message widget to the listview - - Args: - row (int): >=0: insert message at specified position, <0: append message to the end of the model - message (gotify.GotifyMessageModel): message - application (gotify.GotifyApplicationModel): application - """ - self.update_last_id(message.id) - message_item = MessagesModelItem(message) - - if row >= 0: - self.messages_model.insertRow(row, message_item) - else: - self.messages_model.appendRow(message_item) - - self.main_window.insert_message_widget( - message_item, - self.downloader.get_filename( - f"{self.gotify_client.url}/{application.image}" - ), - ) - - def get_messages_finished_callback(self, page: gotify.GotifyPagedMessagesModel): - """Process messages before inserting them into the main window - """ - - def insert_helper(message: gotify.GotifyMessageModel): - if item := self.application_model.itemFromId(message.appid): - self.insert_message( - -1, message, item.data(ApplicationItemDataRole.ApplicationRole) - ) - self.processEvents() - - self.process_messages_task = ProcessMessagesTask(page) - self.process_messages_task.message_processed.connect(insert_helper) - self.process_messages_task.start() - def application_selection_changed_callback( - self, item: Union[ApplicationModelItem, ApplicationAllMessagesItem] + self, item: ApplicationModelItem | ApplicationAllMessagesItem ): self.messages_model.clear() @@ -237,62 +176,44 @@ class MainApplication(QtWidgets.QApplication): item.data(ApplicationItemDataRole.ApplicationRole).id, self.gotify_client, ) - self.get_application_messages_task.success.connect( - self.get_messages_finished_callback - ) + self.get_application_messages_task.message.connect(self.messages_model.append_message) self.get_application_messages_task.start() elif isinstance(item, ApplicationAllMessagesItem): self.get_messages_task = GetMessagesTask(self.gotify_client) - self.get_messages_task.success.connect(self.get_messages_finished_callback) + self.get_messages_task.message.connect(self.messages_model.append_message) self.get_messages_task.start() - def add_message_to_model(self, message: gotify.GotifyMessageModel): - if application_item := self.application_model.itemFromId(message.appid): + def add_message_to_model(self, message: gotify.GotifyMessageModel, process: bool = True): + if self.application_model.itemFromId(message.appid): application_index = self.main_window.currentApplicationIndex() - if selected_application_item := self.application_model.itemFromIndex( - application_index - ): + if selected_application_item := self.application_model.itemFromIndex(application_index): def insert_message_helper(): if isinstance(selected_application_item, ApplicationModelItem): # A single application is selected + # -> Only insert the message if the appid matches the selected appid if ( - message.appid - == selected_application_item.data( - ApplicationItemDataRole.ApplicationRole - ).id + message.appid + == selected_application_item.data(ApplicationItemDataRole.ApplicationRole).id ): - self.insert_message( - 0, - message, - application_item.data( - ApplicationItemDataRole.ApplicationRole - ), - ) - elif isinstance( - selected_application_item, ApplicationAllMessagesItem - ): + self.messages_model.insert_message(0, message) + elif isinstance(selected_application_item, ApplicationAllMessagesItem): # "All messages' is selected - self.insert_message( - 0, - message, - application_item.data( - ApplicationItemDataRole.ApplicationRole - ), - ) + self.messages_model.insert_message(0, message) - self.process_message_task = ProcessMessageTask(message) - self.process_message_task.finished.connect(insert_message_helper) - self.process_message_task.start() + if process: + self.process_message_task = ProcessMessageTask(message) + self.process_message_task.finished.connect(insert_message_helper) + self.process_message_task.start() + else: + insert_message_helper() else: - logger.error( - f"App id {message.appid} could not be found. Refreshing applications." - ) + logger.error(f"App id {message.appid} could not be found. Refreshing applications.") self.refresh_applications() - def new_message_callback(self, message: gotify.GotifyMessageModel): - self.add_message_to_model(message) + def new_message_callback(self, message: gotify.GotifyMessageModel, process: bool = True): + self.add_message_to_model(message, process=process) # Change the tray icon to show there are unread notifications if ( @@ -301,23 +222,19 @@ class MainApplication(QtWidgets.QApplication): ): self.tray.set_icon_unread() - # Show a notification + # Don't show a notification if it's low priority or the window is active if ( message.priority < settings.value("tray/notifications/priority", type=int) or self.main_window.isActiveWindow() ): return - if settings.value("tray/notifications/icon/show", type=bool): - if application_item := self.application_model.itemFromId(message.appid): - image_url = f"{self.gotify_client.url}/{application_item.data(ApplicationItemDataRole.ApplicationRole).image}" - icon = QtGui.QIcon(self.downloader.get_filename(image_url)) - else: - logger.error( - f"MainWindow.new_message_callback: App id {message.appid} could not be found. Refreshing applications." - ) - self.refresh_applications() - icon = QtWidgets.QSystemTrayIcon.MessageIcon.Information + # Get the application icon + if ( + settings.value("tray/notifications/icon/show", type=bool) + and (application_item := self.application_model.itemFromId(message.appid)) + ): + icon = application_item.icon() else: icon = QtWidgets.QSystemTrayIcon.MessageIcon.Information @@ -336,7 +253,7 @@ class MainApplication(QtWidgets.QApplication): self.delete_message_task.start() def delete_all_messages_callback( - self, item: Union[ApplicationModelItem, ApplicationAllMessagesItem] + self, item: ApplicationModelItem | ApplicationAllMessagesItem ): if isinstance(item, ApplicationModelItem): self.delete_application_messages_task = DeleteApplicationMessagesTask( @@ -375,17 +292,13 @@ class MainApplication(QtWidgets.QApplication): # Update the message widget icons for r in range(self.messages_model.rowCount()): - message_widget: MessageWidget = self.main_window.listView_messages.indexWidget( - self.messages_model.index(r, 0) - ) + message_widget: MessageWidget = self.main_window.listView_messages.indexWidget(self.messages_model.index(r, 0)) message_widget.set_icons() def settings_callback(self): settings_dialog = SettingsDialog(self) settings_dialog.quit_requested.connect(self.quit) - settings_dialog.theme_change_requested.connect( - self.theme_change_requested_callback - ) + settings_dialog.theme_change_requested.connect(self.theme_change_requested_callback) accepted = settings_dialog.exec() if accepted and settings_dialog.settings_changed: @@ -428,9 +341,7 @@ class MainApplication(QtWidgets.QApplication): self.main_window.refresh.connect(self.refresh_applications) self.main_window.delete_all.connect(self.delete_all_messages_callback) - self.main_window.application_selection_changed.connect( - self.application_selection_changed_callback - ) + self.main_window.application_selection_changed.connect(self.application_selection_changed_callback) self.main_window.delete_message.connect(self.delete_message_callback) self.main_window.image_popup.connect(self.image_popup_callback) self.main_window.hidden.connect(self.main_window_hidden_callback) @@ -438,7 +349,9 @@ class MainApplication(QtWidgets.QApplication): self.styleHints().colorSchemeChanged.connect(lambda _: self.theme_change_requested_callback(settings.value("theme", type=str))) - self.watchdog.closed.connect(lambda: self.listener_closed_callback(None, None)) + self.messages_model.rowsInserted.connect(self.main_window.display_message_widgets) + + self.watchdog.closed.connect(lambda: self.listener_closed_callback(0, 0)) def init_shortcuts(self): self.shortcut_quit = QtGui.QShortcut( @@ -449,9 +362,7 @@ class MainApplication(QtWidgets.QApplication): def acquire_lock(self) -> bool: temp_dir = tempfile.gettempdir() - lock_filename = os.path.join( - temp_dir, __title__ + "-" + getpass.getuser() + ".lock" - ) + lock_filename = os.path.join(temp_dir, __title__ + "-" + getpass.getuser() + ".lock") self.lock_file = QtCore.QLockFile(lock_filename) self.lock_file.setStaleLockTime(0) return self.lock_file.tryLock() diff --git a/gotify_tray/gui/models/ApplicationModel.py b/gotify_tray/gui/models/ApplicationModel.py index 8226004..870094b 100644 --- a/gotify_tray/gui/models/ApplicationModel.py +++ b/gotify_tray/gui/models/ApplicationModel.py @@ -1,6 +1,5 @@ import enum -from typing import Optional, Union from PyQt6 import QtCore, QtGui from gotify_tray import gotify from gotify_tray.database import Settings @@ -18,7 +17,7 @@ class ApplicationModelItem(QtGui.QStandardItem): def __init__( self, application: gotify.GotifyApplicationModel, - icon: Optional[QtGui.QIcon] = None, + icon: QtGui.QIcon | None = None, *args, **kwargs, ): @@ -55,24 +54,22 @@ class ApplicationAllMessagesItem(QtGui.QStandardItem): class ApplicationModel(QtGui.QStandardItemModel): def __init__(self): super(ApplicationModel, self).__init__() - self.setItemPrototype( - ApplicationModelItem(gotify.GotifyApplicationModel({"name": ""}), None) - ) + self.setItemPrototype(ApplicationModelItem(gotify.GotifyApplicationModel({"name": ""}), None)) def setItem( self, row: int, column: int, - item: Union[ApplicationModelItem, ApplicationAllMessagesItem], + item: ApplicationModelItem | ApplicationAllMessagesItem, ) -> None: super(ApplicationModel, self).setItem(row, column, item) def itemFromIndex( self, index: QtCore.QModelIndex - ) -> Union[ApplicationModelItem, ApplicationAllMessagesItem]: + ) -> ApplicationModelItem | ApplicationAllMessagesItem: return super(ApplicationModel, self).itemFromIndex(index) - def itemFromId(self, appid: int) -> Optional[ApplicationModelItem]: + def itemFromId(self, appid: int) -> ApplicationModelItem | None: for row in range(self.rowCount()): item = self.item(row, 0) if not isinstance(item, ApplicationModelItem): diff --git a/gotify_tray/gui/models/MessagesModel.py b/gotify_tray/gui/models/MessagesModel.py index b74d895..16de087 100644 --- a/gotify_tray/gui/models/MessagesModel.py +++ b/gotify_tray/gui/models/MessagesModel.py @@ -3,6 +3,10 @@ import enum from typing import cast from PyQt6 import QtCore, QtGui from gotify_tray import gotify +from gotify_tray.database import Settings + + +settings = Settings("gotify-tray") class MessageItemDataRole(enum.IntEnum): @@ -16,6 +20,20 @@ class MessagesModelItem(QtGui.QStandardItem): class MessagesModel(QtGui.QStandardItemModel): + def update_last_id(self, i: int): + if i > settings.value("message/last", type=int): + settings.setValue("message/last", i) + + def insert_message(self, row: int, message: gotify.GotifyMessageModel): + self.update_last_id(message.id) + message_item = MessagesModelItem(message) + self.insertRow(row, message_item) + + def append_message(self, message: gotify.GotifyMessageModel): + self.update_last_id(message.id) + message_item = MessagesModelItem(message) + self.appendRow(message_item) + def setItem(self, row: int, column: int, item: MessagesModelItem) -> None: super(MessagesModel, self).setItem(row, column, item) diff --git a/gotify_tray/gui/themes/__init__.py b/gotify_tray/gui/themes/__init__.py index c4565db..0df134f 100644 --- a/gotify_tray/gui/themes/__init__.py +++ b/gotify_tray/gui/themes/__init__.py @@ -45,7 +45,7 @@ def set_theme(app: QtWidgets.QApplication, theme: str = "automatic"): app.setStyleSheet(stylesheet) -def get_theme_file(app: QtWidgets.QApplication, file: str, theme: str = None) -> str: +def get_theme_file(app: QtWidgets.QApplication, file: str, theme: str | None = None) -> str: theme = settings.value("theme", type=str) if not theme else theme if not is_valid_theme(theme): diff --git a/gotify_tray/gui/themes/dark_purple/style.qss b/gotify_tray/gui/themes/dark_purple/style.qss index d33b80c..11d9b03 100644 --- a/gotify_tray/gui/themes/dark_purple/style.qss +++ b/gotify_tray/gui/themes/dark_purple/style.qss @@ -6,19 +6,27 @@ QPushButton:default:hover, QPushButton:checked:hover { background: #441b85; } -QPushButton[state="success"] { +ServerInfoDialog QPushButton[state="success"] { background-color: #960b7a0b; color: white; } -QPushButton[state="failed"] { +ServerInfoDialog QPushButton[state="success"]:!default:hover { + background: #960b7a0b; +} + +ServerInfoDialog QPushButton[state="failed"] { background-color: #8ebb2929; color: white; } -QLineEdit[state="success"] {} +ServerInfoDialog QPushButton[state="failed"]:!default:hover { + background: #8ebb2929; +} -QLineEdit[state="failed"] { +ServerInfoDialog QLineEdit[state="success"] {} + +ServerInfoDialog QLineEdit[state="failed"] { border: 1px solid red; } diff --git a/gotify_tray/gui/themes/light_purple/style.qss b/gotify_tray/gui/themes/light_purple/style.qss index 5e811b2..4c3d4c5 100644 --- a/gotify_tray/gui/themes/light_purple/style.qss +++ b/gotify_tray/gui/themes/light_purple/style.qss @@ -6,19 +6,27 @@ QPushButton:default:hover, QPushButton:checked:hover { background: #5c24b6; } -QPushButton[state="success"] { +ServerInfoDialog QPushButton[state="success"] { background-color: #6400FF00; color: black; } -QPushButton[state="failed"] { +ServerInfoDialog QPushButton[state="success"]:!default:hover { + background: #6400FF00; +} + +ServerInfoDialog QPushButton[state="failed"] { background-color: #64FF0000; color: black; } -QLineEdit[state="success"] {} +ServerInfoDialog QPushButton[state="failed"]:!default:hover { + background: #64FF0000; +} -QLineEdit[state="failed"] { +ServerInfoDialog QLineEdit[state="success"] {} + +ServerInfoDialog QLineEdit[state="failed"] { border: 1px solid red; } diff --git a/gotify_tray/gui/widgets/ImagePopup.py b/gotify_tray/gui/widgets/ImagePopup.py index 8850c6d..7a11564 100644 --- a/gotify_tray/gui/widgets/ImagePopup.py +++ b/gotify_tray/gui/widgets/ImagePopup.py @@ -8,7 +8,7 @@ settings = Settings("gotify-tray") class ImagePopup(QtWidgets.QLabel): - def __init__(self, filename: str, pos: QtCore.QPoint, link: str = None): + def __init__(self, filename: str, pos: QtCore.QPoint, link: str | None = None): """Create and show a pop-up image under the cursor Args: diff --git a/gotify_tray/gui/widgets/MainWindow.py b/gotify_tray/gui/widgets/MainWindow.py index e502949..629440c 100644 --- a/gotify_tray/gui/widgets/MainWindow.py +++ b/gotify_tray/gui/widgets/MainWindow.py @@ -11,6 +11,8 @@ from . import MessageWidget from gotify_tray.__version__ import __title__ from gotify_tray.database import Settings from gotify_tray.gui.themes import get_theme_file +from gotify_tray.gui.models import MessageItemDataRole +from gotify_tray import gotify settings = Settings("gotify-tray") @@ -104,17 +106,20 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): def set_error(self): self.status_widget.set_error() - def insert_message_widget( - self, message_item: MessagesModelItem, image_path: str = "" - ): - message_widget = MessageWidget( - self.app, self.listView_messages, message_item, image_path=image_path - ) - self.listView_messages.setIndexWidget( - self.messages_model.indexFromItem(message_item), message_widget - ) - message_widget.deletion_requested.connect(self.delete_message.emit) - message_widget.image_popup.connect(self.image_popup.emit) + def display_message_widgets(self, parent: QtCore.QModelIndex, first: int, last: int): + for i in range(first, last+1): + if index := self.messages_model.index(i, 0, parent): + message_item = self.messages_model.itemFromIndex(index) + + message: gotify.GotifyMessageModel = self.messages_model.data(index, MessageItemDataRole.MessageRole) + + application_item = self.application_model.itemFromId(message.appid) + + message_widget = MessageWidget(self.app, self.listView_messages, message_item, icon=application_item.icon()) + message_widget.deletion_requested.connect(self.delete_message.emit) + message_widget.image_popup.connect(self.image_popup.emit) + + self.listView_messages.setIndexWidget(self.messages_model.indexFromItem(message_item), message_widget) def currentApplicationIndex(self) -> QtCore.QModelIndex: return self.listView_applications.selectionModel().currentIndex() diff --git a/gotify_tray/gui/widgets/MessageWidget.py b/gotify_tray/gui/widgets/MessageWidget.py index 0f87eae..9213d11 100644 --- a/gotify_tray/gui/widgets/MessageWidget.py +++ b/gotify_tray/gui/widgets/MessageWidget.py @@ -23,11 +23,11 @@ class MessageWidget(QtWidgets.QWidget, Ui_Form): app: QtWidgets.QApplication, parent: QtWidgets.QWidget, message_item: MessagesModelItem, - image_path: str = "", + icon: QtGui.QIcon | None = None, ): - super(MessageWidget, self).__init__() + super(MessageWidget, self).__init__(parent) self.app = app - self.parent = parent + self._parent = parent self.setupUi(self) self.setAutoFillBackground(True) self.message_item = message_item @@ -43,10 +43,7 @@ class MessageWidget(QtWidgets.QWidget, Ui_Form): self.label_title.setText(message.title) self.label_date.setText(message.date.strftime("%Y-%m-%d, %H:%M")) - if markdown := ( - message.get("extras", {}).get("client::display", {}).get("contentType") - == "text/markdown" - ): + if message.get("extras", {}).get("client::display", {}).get("contentType") == "text/markdown": self.label_message.setTextFormat(QtCore.Qt.TextFormat.MarkdownText) # If the message is only an image URL, then instead of showing the message, @@ -59,15 +56,9 @@ class MessageWidget(QtWidgets.QWidget, Ui_Form): self.label_message.setText(convert_links(message.message)) # Show the application icon - if image_path: + if icon: image_size = settings.value("MessageWidget/image/size", type=int) - self.label_image.setFixedSize(QtCore.QSize(image_size, image_size)) - pixmap = QtGui.QPixmap(image_path).scaled( - image_size, - image_size, - aspectRatioMode=QtCore.Qt.AspectRatioMode.KeepAspectRatioByExpanding, - transformMode=QtCore.Qt.TransformationMode.SmoothTransformation, - ) + pixmap = icon.pixmap(QtCore.QSize(image_size, image_size)) self.label_image.setPixmap(pixmap) else: self.label_image.hide() @@ -77,7 +68,12 @@ class MessageWidget(QtWidgets.QWidget, Ui_Form): self.gridLayout.setContentsMargins(4, 5, 4, 0) self.adjustSize() size_hint = self.message_item.sizeHint() - self.message_item.setSizeHint(QtCore.QSize(size_hint.width(), self.height())) + self.message_item.setSizeHint( + QtCore.QSize( + size_hint.width(), + max(settings.value("MessageWidget/height/min", type=int), self.height()) + ) + ) self.set_icons() @@ -113,13 +109,10 @@ class MessageWidget(QtWidgets.QWidget, Ui_Form): pixmap = QtGui.QPixmap(filename) # Make sure the image fits within the listView - W = settings.value("MessageWidget/content_image/W_percentage", type=float) * ( - self.parent.width() - self.label_image.width() - ) - H = ( - settings.value("MessageWidget/content_image/H_percentage", type=float) - * self.parent.height() - ) + W = settings.value("MessageWidget/content_image/W_percentage", type=float) + H = settings.value("MessageWidget/content_image/H_percentage", type=float) + W *= self._parent.width() - self.label_image.width() + H *= self._parent.height() if pixmap.width() > W or pixmap.height() > H: pixmap = pixmap.scaled( @@ -150,7 +143,5 @@ class MessageWidget(QtWidgets.QWidget, Ui_Form): self.image_popup.emit(link, QtGui.QCursor.pos()) def link_callbacks(self): - self.pb_delete.clicked.connect( - lambda: self.deletion_requested.emit(self.message_item) - ) + self.pb_delete.clicked.connect(lambda: self.deletion_requested.emit(self.message_item)) self.label_message.linkHovered.connect(self.link_hovered_callback) diff --git a/gotify_tray/gui/widgets/ServerInfoDialog.py b/gotify_tray/gui/widgets/ServerInfoDialog.py index aac812a..c8a6171 100644 --- a/gotify_tray/gui/widgets/ServerInfoDialog.py +++ b/gotify_tray/gui/widgets/ServerInfoDialog.py @@ -19,9 +19,7 @@ class ServerInfoDialog(QtWidgets.QDialog, Ui_Dialog): self.line_url.setPlaceholderText("https://gotify.example.com") self.line_url.setText(url) self.line_token.setText(token) - self.buttonBox.button(QtWidgets.QDialogButtonBox.StandardButton.Ok).setDisabled( - True - ) + self.buttonBox.button(QtWidgets.QDialogButtonBox.StandardButton.Ok).setDisabled(True) self.pb_import.setVisible(enable_import) self.link_callbacks() @@ -37,9 +35,7 @@ class ServerInfoDialog(QtWidgets.QDialog, Ui_Dialog): return self.pb_test.setDisabled(True) - self.buttonBox.button(QtWidgets.QDialogButtonBox.StandardButton.Ok).setDisabled( - True - ) + self.buttonBox.button(QtWidgets.QDialogButtonBox.StandardButton.Ok).setDisabled(True) self.task = VerifyServerInfoTask(url, client_token) self.task.success.connect(self.server_info_success) @@ -59,9 +55,7 @@ class ServerInfoDialog(QtWidgets.QDialog, Ui_Dialog): self.update_widget_state(self.pb_test, "success") self.update_widget_state(self.line_token, "success") self.update_widget_state(self.line_url, "success") - self.buttonBox.button(QtWidgets.QDialogButtonBox.StandardButton.Ok).setEnabled( - True - ) + self.buttonBox.button(QtWidgets.QDialogButtonBox.StandardButton.Ok).setEnabled(True) self.buttonBox.button(QtWidgets.QDialogButtonBox.StandardButton.Ok).setFocus() def incorrect_token_callback(self, version: GotifyVersionModel): @@ -80,6 +74,10 @@ class ServerInfoDialog(QtWidgets.QDialog, Ui_Dialog): self.update_widget_state(self.line_url, "failed") self.line_url.setFocus() + def input_changed_callback(self): + self.buttonBox.button(QtWidgets.QDialogButtonBox.StandardButton.Ok).setDisabled(True) + self.update_widget_state(self.pb_test, "") + def import_success_callback(self): self.line_url.setText(settings.value("Server/url", type=str)) self.line_token.setText(settings.value("Server/client_token")) @@ -95,14 +93,6 @@ class ServerInfoDialog(QtWidgets.QDialog, Ui_Dialog): def link_callbacks(self): self.pb_test.clicked.connect(self.test_server_info) - self.line_url.textChanged.connect( - lambda: self.buttonBox.button( - QtWidgets.QDialogButtonBox.StandardButton.Ok - ).setDisabled(True) - ) - self.line_token.textChanged.connect( - lambda: self.buttonBox.button( - QtWidgets.QDialogButtonBox.StandardButton.Ok - ).setDisabled(True) - ) + self.line_url.textChanged.connect(self.input_changed_callback) + self.line_token.textChanged.connect(self.input_changed_callback) self.pb_import.clicked.connect(self.import_callback) diff --git a/gotify_tray/gui/widgets/SettingsDialog.py b/gotify_tray/gui/widgets/SettingsDialog.py index 10c47db..0a42e30 100644 --- a/gotify_tray/gui/widgets/SettingsDialog.py +++ b/gotify_tray/gui/widgets/SettingsDialog.py @@ -43,42 +43,28 @@ class SettingsDialog(QtWidgets.QDialog, Ui_Dialog): self.link_callbacks() def initUI(self): - self.buttonBox.button( - QtWidgets.QDialogButtonBox.StandardButton.Apply - ).setEnabled(False) + self.buttonBox.button(QtWidgets.QDialogButtonBox.StandardButton.Apply).setEnabled(False) # Notifications - self.spin_priority.setValue( - settings.value("tray/notifications/priority", type=int) - ) + self.spin_priority.setValue(settings.value("tray/notifications/priority", type=int)) - self.spin_duration.setValue( - settings.value("tray/notifications/duration_ms", type=int) - ) + self.spin_duration.setValue(settings.value("tray/notifications/duration_ms", type=int)) if platform.system() == "Windows": # The notification duration setting is ignored by windows self.label_notification_duration.hide() self.spin_duration.hide() self.label_notification_duration_ms.hide() - self.cb_notify.setChecked( - settings.value("message/check_missed/notify", type=bool) - ) + self.cb_notify.setChecked(settings.value("message/check_missed/notify", type=bool)) - self.cb_notification_click.setChecked( - settings.value("tray/notifications/click", type=bool) - ) + self.cb_notification_click.setChecked(settings.value("tray/notifications/click", type=bool)) - self.cb_tray_icon_unread.setChecked( - settings.value("tray/icon/unread", type=bool) - ) + self.cb_tray_icon_unread.setChecked(settings.value("tray/icon/unread", type=bool)) # Interface self.combo_theme.addItems(get_themes()) self.combo_theme.setCurrentText(settings.value("theme", type=str)) - self.cb_priority_colors.setChecked( - settings.value("MessageWidget/priority_color", type=bool) - ) + self.cb_priority_colors.setChecked(settings.value("MessageWidget/priority_color", type=bool)) # Logging self.combo_logging.addItems( @@ -96,9 +82,7 @@ class SettingsDialog(QtWidgets.QDialog, Ui_Dialog): self.add_message_widget() # Advanced - self.groupbox_image_popup.setChecked( - settings.value("ImagePopup/enabled", type=bool) - ) + self.groupbox_image_popup.setChecked(settings.value("ImagePopup/enabled", type=bool)) self.spin_popup_w.setValue(settings.value("ImagePopup/w", type=int)) self.spin_popup_h.setValue(settings.value("ImagePopup/h", type=int)) self.label_cache.setText("0 MB") @@ -118,7 +102,7 @@ class SettingsDialog(QtWidgets.QDialog, Ui_Dialog): } ) ), - get_icon("gotify-small"), + QtGui.QIcon(get_icon("gotify-small")), ) self.layout_fonts_message.addWidget(self.message_widget) @@ -132,16 +116,12 @@ class SettingsDialog(QtWidgets.QDialog, Ui_Dialog): def settings_changed_callback(self, *args, **kwargs): self.settings_changed = True - self.buttonBox.button( - QtWidgets.QDialogButtonBox.StandardButton.Apply - ).setEnabled(True) + self.buttonBox.button(QtWidgets.QDialogButtonBox.StandardButton.Apply).setEnabled(True) def change_font_callback(self, name: str): label: QtWidgets.QLabel = getattr(self.message_widget, "label_" + name) - font, accepted = QtWidgets.QFontDialog.getFont( - label.font(), self, f"Select a {name} font" - ) + font, accepted = QtWidgets.QFontDialog.getFont(label.font(), self, f"Select a {name} font") if accepted: self.settings_changed_callback() @@ -205,9 +185,7 @@ class SettingsDialog(QtWidgets.QDialog, Ui_Dialog): self.label_cache.setText("0 MB") def link_callbacks(self): - self.buttonBox.button( - QtWidgets.QDialogButtonBox.StandardButton.Apply - ).clicked.connect(self.apply_settings) + self.buttonBox.button(QtWidgets.QDialogButtonBox.StandardButton.Apply).clicked.connect(self.apply_settings) # Notifications self.spin_priority.valueChanged.connect(self.settings_changed_callback) @@ -225,22 +203,14 @@ class SettingsDialog(QtWidgets.QDialog, Ui_Dialog): # Logging self.combo_logging.currentTextChanged.connect(self.settings_changed_callback) - self.pb_open_log.clicked.connect( - lambda: open_file(logger.root.handlers[0].baseFilename) - ) + self.pb_open_log.clicked.connect(lambda: open_file(logger.root.handlers[0].baseFilename)) # Fonts self.pb_reset_fonts.clicked.connect(self.reset_fonts_callback) - self.pb_font_message_title.clicked.connect( - lambda: self.change_font_callback("title") - ) - self.pb_font_message_date.clicked.connect( - lambda: self.change_font_callback("date") - ) - self.pb_font_message_content.clicked.connect( - lambda: self.change_font_callback("message") - ) + self.pb_font_message_title.clicked.connect(lambda: self.change_font_callback("title")) + self.pb_font_message_date.clicked.connect(lambda: self.change_font_callback("date")) + self.pb_font_message_content.clicked.connect(lambda: self.change_font_callback("message")) # Advanced self.pb_export.clicked.connect(self.export_callback) @@ -257,9 +227,7 @@ class SettingsDialog(QtWidgets.QDialog, Ui_Dialog): settings.setValue("tray/notifications/priority", self.spin_priority.value()) settings.setValue("tray/notifications/duration_ms", self.spin_duration.value()) settings.setValue("message/check_missed/notify", self.cb_notify.isChecked()) - settings.setValue( - "tray/notifications/click", self.cb_notification_click.isChecked() - ) + settings.setValue("tray/notifications/click", self.cb_notification_click.isChecked()) settings.setValue("tray/icon/unread", self.cb_tray_icon_unread.isChecked()) # Interface @@ -269,9 +237,7 @@ class SettingsDialog(QtWidgets.QDialog, Ui_Dialog): settings.setValue("theme", selected_theme) self.theme_change_requested.emit(selected_theme) - settings.setValue( - "MessageWidget/priority_color", self.cb_priority_colors.isChecked() - ) + settings.setValue("MessageWidget/priority_color", self.cb_priority_colors.isChecked()) # Logging selected_level = self.combo_logging.currentText() @@ -283,17 +249,9 @@ class SettingsDialog(QtWidgets.QDialog, Ui_Dialog): logger.setLevel(selected_level) # Fonts - settings.setValue( - "MessageWidget/font/title", - self.message_widget.label_title.font().toString(), - ) - settings.setValue( - "MessageWidget/font/date", self.message_widget.label_date.font().toString() - ) - settings.setValue( - "MessageWidget/font/message", - self.message_widget.label_message.font().toString(), - ) + settings.setValue("MessageWidget/font/title", self.message_widget.label_title.font().toString()) + settings.setValue("MessageWidget/font/date", self.message_widget.label_date.font().toString()) + settings.setValue("MessageWidget/font/message", self.message_widget.label_message.font().toString()) # Advanced settings.setValue("ImagePopup/enabled", self.groupbox_image_popup.isChecked()) diff --git a/gotify_tray/tasks.py b/gotify_tray/tasks.py index 29f23a8..108673c 100644 --- a/gotify_tray/tasks.py +++ b/gotify_tray/tasks.py @@ -8,10 +8,10 @@ from functools import reduce from PyQt6 import QtCore from PyQt6.QtCore import pyqtSignal -from gotify_tray.database import Cache, Downloader, Settings +from gotify_tray.database import Cache, Settings from gotify_tray.gotify.api import GotifyClient from gotify_tray.gotify.models import GotifyVersionModel -from gotify_tray.utils import get_image +from gotify_tray.utils import process_messages from . import gotify @@ -97,7 +97,7 @@ class GetApplicationsTask(BaseTask): class GetApplicationMessagesTask(BaseTask): - success = pyqtSignal(gotify.GotifyPagedMessagesModel) + message = pyqtSignal(gotify.GotifyMessageModel) error = pyqtSignal(gotify.GotifyErrorModel) def __init__(self, appid: int, gotify_client: gotify.GotifyClient): @@ -110,10 +110,16 @@ class GetApplicationMessagesTask(BaseTask): if isinstance(result, gotify.GotifyErrorModel): self.error.emit(result) else: - self.success.emit(result) + for message in process_messages(result.messages): + self.message.emit(message) + + # Prevent locking up the UI when there are a lot of messages ready at the same time + # -- side effect: switching application while the previous messages are still being inserted causes mixing of messages + time.sleep(0.001) class GetMessagesTask(BaseTask): + message = pyqtSignal(gotify.GotifyMessageModel) success = pyqtSignal(gotify.GotifyPagedMessagesModel) error = pyqtSignal(gotify.GotifyErrorModel) @@ -126,9 +132,22 @@ class GetMessagesTask(BaseTask): if isinstance(result, gotify.GotifyErrorModel): self.error.emit(result) else: + for message in process_messages(result.messages): + self.message.emit(message) + time.sleep(0.001) self.success.emit(result) +class ProcessMessageTask(BaseTask): + def __init__(self, message: gotify.GotifyMessageModel): + super(ProcessMessageTask, self).__init__() + self.message = message + + def task(self): + for _ in process_messages([self.message]): + pass + + class VerifyServerInfoTask(BaseTask): success = pyqtSignal(GotifyVersionModel) incorrect_token = pyqtSignal(GotifyVersionModel) @@ -176,9 +195,7 @@ class ServerConnectionWatchdogTask(BaseTask): time.sleep(settings.value("watchdog/interval/s", type=int)) if not self.gotify_client.is_listening(): self.closed.emit() - logger.debug( - "ServerConnectionWatchdogTask: gotify_client is not listening" - ) + logger.debug("ServerConnectionWatchdogTask: gotify_client is not listening") class ExportSettingsTask(BaseTask): @@ -205,35 +222,6 @@ class ImportSettingsTask(BaseTask): self.success.emit() -class ProcessMessageTask(BaseTask): - def __init__(self, message: gotify.GotifyMessageModel): - super(ProcessMessageTask, self).__init__() - self.message = message - - def task(self): - if image_url := get_image(self.message.message): - downloader = Downloader() - downloader.get_filename(image_url) - - -class ProcessMessagesTask(BaseTask): - message_processed = pyqtSignal(gotify.GotifyMessageModel) - - def __init__(self, page: gotify.GotifyPagedMessagesModel): - super(ProcessMessagesTask, self).__init__() - self.page = page - - def task(self): - downloader = Downloader() - for message in self.page.messages: - if image_url := get_image(message.message): - downloader.get_filename(image_url) - self.message_processed.emit(message) - - # Prevent locking up the UI when there are a lot of messages with images ready at the same time - time.sleep(0.001) - - class CacheSizeTask(BaseTask): size = pyqtSignal(int) diff --git a/gotify_tray/utils.py b/gotify_tray/utils.py index b087872..94ab5fb 100644 --- a/gotify_tray/utils.py +++ b/gotify_tray/utils.py @@ -4,7 +4,10 @@ import re import subprocess from pathlib import Path -from typing import Optional +from typing import Iterator + +from gotify_tray import gotify +from gotify_tray.database import Downloader def verify_server(force_new: bool = False, enable_import: bool = True) -> bool: @@ -28,6 +31,14 @@ def verify_server(force_new: bool = False, enable_import: bool = True) -> bool: return True +def process_messages(messages: list[gotify.GotifyMessageModel]) -> Iterator[gotify.GotifyMessageModel]: + downloader = Downloader() + for message in messages: + if image_url := get_image(message.message): + downloader.get_filename(image_url) + yield message + + def convert_links(text): _link = re.compile( r'(?:(https://|http://)|(www\.))(\S+\b/?)([!"#$%&\'()*+,\-./:;<=>?@[\\\]^_`{|}~]*)(\s|$)', @@ -45,7 +56,7 @@ def convert_links(text): return _link.sub(replace, text) -def get_image(s: str) -> Optional[str]: +def get_image(s: str) -> str | None: """If `s` contains only an image URL, this function returns that URL. This function also extracts a URL in the `![]()` markdown image format. """