diff --git a/debian/changelog b/debian/changelog index d7e96cc8..ebfa6309 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,24 @@ +safeeyes (3.0.0b3) noble; urgency=medium + + * Wayland support: break screen shortcuts, window activation, donotdisturb + detection + + * Feature: Add option to postpone breaks by seconds rather than minutes + + * Feature: screensaver: add tray action to lock screen now + + * smartpause: Performance/Battery life improvements + + * replace RPC server with native GTK commandline integration + + * Internal refactoring to improve thread safety + + * Internal: automated tests using pytest + + * Internal: typechecking improvement + + -- Mel Dafert Fri, 22 Aug 2025 11:30:00 +0000 + safeeyes (3.0.0b3) noble; urgency=medium * Re-release due to broken github action diff --git a/pyproject.toml b/pyproject.toml index 822c0ca2..e21ab0e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "safeeyes" -version = "3.0.0b3" +version = "3.0.0b4" description = "Protect your eyes from eye strain using this continuous breaks reminder." keywords = ["linux utility health eye-strain safe-eyes"] readme = "README.md" @@ -31,7 +31,7 @@ requires-python = ">=3.10" [project.urls] Homepage = "https://github.com/slgobinath/SafeEyes" -Downloads = "https://github.com/slgobinath/SafeEyes/archive/v3.0.0b3.tar.gz" +Downloads = "https://github.com/slgobinath/SafeEyes/archive/v3.0.0b4.tar.gz" [project.scripts] safeeyes = "safeeyes.__main__:main" diff --git a/safeeyes/context.py b/safeeyes/context.py new file mode 100644 index 00000000..e92d5128 --- /dev/null +++ b/safeeyes/context.py @@ -0,0 +1,137 @@ +# Safe Eyes is a utility to remind you to take break frequently +# to protect your eyes from eye strain. + +# Copyright (C) 2025 Mel Dafert + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from collections.abc import MutableMapping +import datetime +import typing + +from safeeyes import utility +from safeeyes.model import BreakType, State + +if typing.TYPE_CHECKING: + from safeeyes.safeeyes import SafeEyes + + +class API: + _application: "SafeEyes" + + def __init__( + self, + application: "SafeEyes", + ) -> None: + self._application = application + + def __getitem__(self, key: str) -> typing.Callable: + """This is soft-deprecated - it is preferred to access the property.""" + return getattr(self, key) + + def show_settings(self, activation_token: typing.Optional[str] = None) -> None: + utility.execute_main_thread(self._application.show_settings, activation_token) + + def show_about(self, activation_token: typing.Optional[str] = None) -> None: + utility.execute_main_thread(self._application.show_about, activation_token) + + def enable_safeeyes(self, next_break_time=-1) -> None: + utility.execute_main_thread(self._application.enable_safeeyes, next_break_time) + + def disable_safeeyes(self, status=None, is_resting=False) -> None: + utility.execute_main_thread( + self._application.disable_safeeyes, status, is_resting + ) + + def status(self) -> str: + return self._application.status() + + def quit(self) -> None: + utility.execute_main_thread(self._application.quit) + + def take_break(self, break_type: typing.Optional[BreakType] = None) -> None: + utility.execute_main_thread(self._application.take_break, break_type) + + def has_breaks(self, break_type=None) -> bool: + return self._application.safe_eyes_core.has_breaks(break_type) + + def postpone(self, duration=-1) -> None: + self._application.safe_eyes_core.postpone(duration) + + def get_break_time(self, break_type=None) -> typing.Optional[datetime.datetime]: + return self._application.safe_eyes_core.get_break_time(break_type) + + +class Context(MutableMapping): + version: str + api: API + desktop: str + is_wayland: bool + locale: str + session: dict[str, typing.Any] + state: State + + skipped: bool = False + postponed: bool = False + skip_button_disabled: bool = False + postpone_button_disabled: bool = False + + ext: dict + + def __init__( + self, + api: API, + locale: str, + version: str, + session: dict[str, typing.Any], + ) -> None: + self.version = version + self.desktop = utility.desktop_environment() + self.is_wayland = utility.is_wayland() + self.locale = locale + self.session = session + self.state = State.START + self.api = api + + self.ext = {} + + def __setitem__(self, key: str, value: typing.Any) -> None: + """This is soft-deprecated - it is preferred to access the property.""" + if hasattr(self, key): + setattr(self, key, value) + return + + self.ext[key] = value + + def __getitem__(self, key: str) -> typing.Any: + """This is soft-deprecated - it is preferred to access the property.""" + if hasattr(self, key): + return getattr(self, key) + + return self.ext[key] + + def __delitem__(self, key: str) -> None: + """This is soft-deprecated - it is preferred to access the property.""" + if hasattr(self, key): + raise Exception("cannot delete property") + + del self.ext[key] + + def __len__(self) -> int: + """This is soft-deprecated.""" + return len(self.ext) + + def __iter__(self) -> typing.Iterator[typing.Any]: + """This is soft-deprecated.""" + return iter(self.ext) diff --git a/safeeyes/core.py b/safeeyes/core.py index 5e6ae0f1..ab9eb9c4 100644 --- a/safeeyes/core.py +++ b/safeeyes/core.py @@ -29,6 +29,8 @@ from safeeyes.model import State from safeeyes.model import Config +from safeeyes.context import Context + import gi gi.require_version("GLib", "2.0") @@ -45,6 +47,7 @@ class SafeEyesCore: postpone_duration: int = 0 default_postpone_duration: int = 0 pre_break_warning_time: int = 0 + context: Context _break_queue: typing.Optional[BreakQueue] = None @@ -62,7 +65,7 @@ class SafeEyesCore: # set to true when a break was requested _take_break_now: bool = False - def __init__(self, context) -> None: + def __init__(self, context: Context) -> None: """Create an instance of SafeEyesCore and initialize the variables.""" # This event is fired before for a break self.on_pre_break = EventHook() @@ -77,11 +80,7 @@ def __init__(self, context) -> None: # This event is fired when deciding the next break time self.on_update_next_break = EventHook() self.context = context - self.context["skipped"] = False - self.context["postponed"] = False - self.context["skip_button_disabled"] = False - self.context["postpone_button_disabled"] = False - self.context["state"] = State.WAITING + self.context.state = State.WAITING def initialize(self, config: Config): """Initialize the internal properties from configuration.""" @@ -116,14 +115,14 @@ def stop(self, is_resting=False) -> None: self.paused_time = datetime.datetime.now().timestamp() # Stop the break thread self.running = False - if self.context["state"] != State.QUIT: - self.context["state"] = State.RESTING if (is_resting) else State.STOPPED + if self.context.state != State.QUIT: + self.context.state = State.RESTING if (is_resting) else State.STOPPED self.__wakeup_scheduler() def skip(self) -> None: """User skipped the break using Skip button.""" - self.context["skipped"] = True + self.context.skipped = True def postpone(self, duration=-1) -> None: """User postponed the break using Postpone button.""" @@ -132,7 +131,7 @@ def postpone(self, duration=-1) -> None: else: self.postpone_duration = self.default_postpone_duration logging.debug("Postpone the break for %d seconds", self.postpone_duration) - self.context["postponed"] = True + self.context.postponed = True def get_break_time( self, break_type: typing.Optional[BreakType] = None @@ -154,7 +153,7 @@ def take_break(self, break_type: typing.Optional[BreakType] = None) -> None: """ if self._break_queue is None: return - if not self.context["state"] == State.WAITING: + if not self.context.state == State.WAITING: return if break_type is not None and self._break_queue.get_break().type != break_type: @@ -189,7 +188,7 @@ def __scheduler_job(self) -> None: current_time = datetime.datetime.now() current_timestamp = current_time.timestamp() - if self.context["state"] == State.RESTING and self.paused_time > -1: + if self.context.state == State.RESTING and self.paused_time > -1: # Safe Eyes was resting paused_duration = int(current_timestamp - self.paused_time) self.paused_time = -1 @@ -203,11 +202,11 @@ def __scheduler_job(self) -> None: # Skip the next long break self._break_queue.skip_long_break() - if self.context["postponed"]: + if self.context.postponed: # Previous break was postponed logging.info("Prepare for postponed break") time_to_wait = self.postpone_duration - self.context["postponed"] = False + self.context.postponed = False elif current_timestamp < self.scheduled_next_break_timestamp: # Non-standard break was set. time_to_wait = round( @@ -221,7 +220,7 @@ def __scheduler_job(self) -> None: self.scheduled_next_break_time = current_time + datetime.timedelta( seconds=time_to_wait ) - self.context["state"] = State.WAITING + self.context.state = State.WAITING self.__fire_on_update_next_break(self.scheduled_next_break_time) # Wait for the pre break warning period @@ -262,7 +261,7 @@ def __fire_pre_break(self) -> None: if self._break_queue is None: # This will only be called by methods which check this return - self.context["state"] = State.PRE_BREAK + self.context.state = State.PRE_BREAK proceed = self.__fire_hook(self.on_pre_break, self._break_queue.get_break()) if not proceed: # Plugins wanted to ignore this break @@ -298,9 +297,9 @@ def __do_start_break(self) -> None: # Plugins want to ignore this break self.__start_next_break() return - if self.context["postponed"]: + if self.context.postponed: # Plugins want to postpone this break - self.context["postponed"] = False + self.context.postponed = False if self.scheduled_next_break_time is None: raise Exception("this should never happen") @@ -322,7 +321,7 @@ def __start_break(self) -> None: if self._break_queue is None: # This will only be called by methods which check this return - self.context["state"] = State.BREAK + self.context.state = State.BREAK break_obj = self._break_queue.get_break() self._taking_break = break_obj self._countdown = break_obj.duration @@ -340,8 +339,8 @@ def __cycle_break_countdown(self) -> None: if ( self._countdown > 0 and self.running - and not self.context["skipped"] - and not self.context["postponed"] + and not self.context.skipped + and not self.context.postponed ): countdown = self._countdown self._countdown -= 1 @@ -359,14 +358,14 @@ def __cycle_break_countdown(self) -> None: def __fire_stop_break(self) -> None: # Loop terminated because of timeout (not skipped) -> Close the break alert - if not self.context["skipped"] and not self.context["postponed"]: + if not self.context.skipped and not self.context.postponed: logging.info("Break is terminated automatically") self.__fire_hook(self.on_stop_break) # Reset the skipped flag - self.context["skipped"] = False - self.context["skip_button_disabled"] = False - self.context["postpone_button_disabled"] = False + self.context.skipped = False + self.context.skip_button_disabled = False + self.context.postpone_button_disabled = False self.__start_next_break() def __wait_for( @@ -440,7 +439,7 @@ def __start_next_break(self) -> None: if self._break_queue is None: # This will only be called by methods which check this return - if not self.context["postponed"]: + if not self.context.postponed: self._break_queue.next() if self.running: diff --git a/safeeyes/glade/about_dialog.glade b/safeeyes/glade/about_dialog.glade index 27570bf3..0dbb0d40 100644 --- a/safeeyes/glade/about_dialog.glade +++ b/safeeyes/glade/about_dialog.glade @@ -35,10 +35,11 @@ GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>. - + diff --git a/safeeyes/glade/break_screen.glade b/safeeyes/glade/break_screen.glade index 1ccd3106..c3214d9a 100644 --- a/safeeyes/glade/break_screen.glade +++ b/safeeyes/glade/break_screen.glade @@ -18,12 +18,13 @@ ~ You should have received a copy of the GNU General Public License ~ along with this program. If not, see . --> - + - + diff --git a/safeeyes/glade/item_bool.glade b/safeeyes/glade/item_bool.glade index 799ed20f..c036fdd2 100644 --- a/safeeyes/glade/item_bool.glade +++ b/safeeyes/glade/item_bool.glade @@ -19,9 +19,9 @@ ~ You should have received a copy of the GNU General Public License ~ along with this program. If not, see . --> - + - + diff --git a/safeeyes/glade/item_break.glade b/safeeyes/glade/item_break.glade index 17d01a0d..39ff4e89 100644 --- a/safeeyes/glade/item_break.glade +++ b/safeeyes/glade/item_break.glade @@ -19,9 +19,9 @@ ~ You should have received a copy of the GNU General Public License ~ along with this program. If not, see . --> - + - + diff --git a/safeeyes/glade/item_int.glade b/safeeyes/glade/item_int.glade index 31cd8581..83c40cd8 100644 --- a/safeeyes/glade/item_int.glade +++ b/safeeyes/glade/item_int.glade @@ -19,14 +19,14 @@ ~ You should have received a copy of the GNU General Public License ~ along with this program. If not, see . --> - + 100 1 10 - + diff --git a/safeeyes/glade/item_plugin.glade b/safeeyes/glade/item_plugin.glade index 6edf781c..90060608 100644 --- a/safeeyes/glade/item_plugin.glade +++ b/safeeyes/glade/item_plugin.glade @@ -19,9 +19,9 @@ ~ You should have received a copy of the GNU General Public License ~ along with this program. If not, see . --> - + - + diff --git a/safeeyes/glade/item_text.glade b/safeeyes/glade/item_text.glade index aad81a14..44bca9b7 100644 --- a/safeeyes/glade/item_text.glade +++ b/safeeyes/glade/item_text.glade @@ -19,9 +19,9 @@ ~ You should have received a copy of the GNU General Public License ~ along with this program. If not, see . --> - + - + diff --git a/safeeyes/glade/new_break.glade b/safeeyes/glade/new_break.glade index 88bff455..935a616a 100644 --- a/safeeyes/glade/new_break.glade +++ b/safeeyes/glade/new_break.glade @@ -19,7 +19,7 @@ ~ You should have received a copy of the GNU General Public License ~ along with this program. If not, see . --> - + 100 @@ -40,7 +40,7 @@ - + diff --git a/safeeyes/glade/required_plugin_dialog.glade b/safeeyes/glade/required_plugin_dialog.glade index d9c05954..c97f3cb6 100644 --- a/safeeyes/glade/required_plugin_dialog.glade +++ b/safeeyes/glade/required_plugin_dialog.glade @@ -19,12 +19,13 @@ ~ You should have received a copy of the GNU General Public License ~ along with this program. If not, see . --> - + - + diff --git a/safeeyes/glade/settings_break.glade b/safeeyes/glade/settings_break.glade index 77830a36..179ac6a9 100644 --- a/safeeyes/glade/settings_break.glade +++ b/safeeyes/glade/settings_break.glade @@ -19,7 +19,7 @@ ~ You should have received a copy of the GNU General Public License ~ along with this program. If not, see . --> - + 1 @@ -47,7 +47,7 @@ - + diff --git a/safeeyes/glade/settings_dialog.glade b/safeeyes/glade/settings_dialog.glade index 03844853..116e3122 100644 --- a/safeeyes/glade/settings_dialog.glade +++ b/safeeyes/glade/settings_dialog.glade @@ -19,7 +19,7 @@ ~ You should have received a copy of the GNU General Public License ~ along with this program. If not, see . --> - + 15 @@ -77,6 +77,7 @@ 1 1 1 + @@ -85,11 +86,12 @@ - + diff --git a/safeeyes/glade/settings_plugin.glade b/safeeyes/glade/settings_plugin.glade index 1a6c8fb1..071393e2 100644 --- a/safeeyes/glade/settings_plugin.glade +++ b/safeeyes/glade/settings_plugin.glade @@ -19,9 +19,9 @@ ~ You should have received a copy of the GNU General Public License ~ along with this program. If not, see . --> - + - + diff --git a/safeeyes/model.py b/safeeyes/model.py index 5d5bbe49..aabfbe24 100644 --- a/safeeyes/model.py +++ b/safeeyes/model.py @@ -38,6 +38,9 @@ from safeeyes import utility from safeeyes.translations import translate as _ +if typing.TYPE_CHECKING: + from safeeyes.context import Context + class BreakType(Enum): """Type of Safe Eyes breaks.""" @@ -105,9 +108,12 @@ class BreakQueue: __is_random_order: bool __long_queue: typing.Optional[list[Break]] __short_queue: typing.Optional[list[Break]] + context: "Context" @classmethod - def create(cls, config: "Config", context) -> typing.Optional["BreakQueue"]: + def create( + cls, config: "Config", context: "Context" + ) -> typing.Optional["BreakQueue"]: short_break_time = config.get("short_break_interval") long_break_time = config.get("long_break_interval") is_random_order = config.get("random_order") @@ -142,7 +148,7 @@ def create(cls, config: "Config", context) -> typing.Optional["BreakQueue"]: def __init__( self, - context, + context: "Context", short_break_time: int, long_break_time: int, is_random_order: bool, @@ -166,7 +172,7 @@ def __init__( self.__set_next_break() # Restore the last break from session - last_break = context["session"].get("break") + last_break = context.session.get("break") if last_break is not None: current_break = self.get_break() if last_break != current_break.name: @@ -247,7 +253,7 @@ def __set_next_break(self, break_type: typing.Optional[BreakType] = None) -> Non break_obj = self.__next_short() self.__current_break = break_obj - self.context["session"]["break"] = self.__current_break.name + self.context.session["break"] = self.__current_break.name def skip_long_break(self) -> None: if not (self.__short_queue and self.__long_queue): @@ -265,7 +271,7 @@ def skip_long_break(self) -> None: # we could decrement the __current_long counter, but then we'd need to # handle wraparound and possibly randomizing, which seems complicated self.__current_break = self.__next_short() - self.context["session"]["break"] = self.__current_break.name + self.context.session["break"] = self.__current_break.name def is_empty(self, break_type: BreakType) -> bool: """Check if the given break type is empty or not.""" @@ -283,7 +289,7 @@ def __next_short(self) -> Break: raise Exception("this may only be called when there are short breaks") break_obj = shorts[self.__current_short] - self.context["break_type"] = "short" + self.context.ext["break_type"] = "short" # Update the index to next self.__current_short = (self.__current_short + 1) % len(shorts) @@ -297,7 +303,7 @@ def __next_long(self) -> Break: raise Exception("this may only be called when there are long breaks") break_obj = longs[self.__current_long] - self.context["break_type"] = "long" + self.context.ext["break_type"] = "long" # Update the index to next self.__current_long = (self.__current_long + 1) % len(longs) diff --git a/safeeyes/platform/io.github.slgobinath.SafeEyes.metainfo.xml b/safeeyes/platform/io.github.slgobinath.SafeEyes.metainfo.xml index 45d2dab2..ad759fc7 100644 --- a/safeeyes/platform/io.github.slgobinath.SafeEyes.metainfo.xml +++ b/safeeyes/platform/io.github.slgobinath.SafeEyes.metainfo.xml @@ -53,6 +53,7 @@ https://slgobinath.github.io/SafeEyes/ + diff --git a/safeeyes/plugins/smartpause/plugin.py b/safeeyes/plugins/smartpause/plugin.py index 55c308fd..682a08ac 100644 --- a/safeeyes/plugins/smartpause/plugin.py +++ b/safeeyes/plugins/smartpause/plugin.py @@ -21,6 +21,7 @@ import typing from safeeyes.model import State +from safeeyes.context import Context from .interface import IdleMonitorInterface from .gnome_dbus import IdleMonitorGnomeDBus @@ -31,7 +32,7 @@ Safe Eyes smart pause plugin """ -context = None +context: Context idle_time: float = 0 enable_safeeyes = None disable_safeeyes = None @@ -60,7 +61,7 @@ def _on_idle() -> None: global smart_pause_activated global idle_start_time - if context["state"] == State.WAITING: # type: ignore[index] + if context["state"] == State.WAITING: smart_pause_activated = True idle_start_time = datetime.datetime.now() - datetime.timedelta( seconds=idle_time @@ -73,15 +74,12 @@ def _on_resumed() -> None: global smart_pause_activated global idle_start_time - if ( - context["state"] == State.RESTING # type: ignore[index] - and idle_start_time is not None - ): + if context["state"] == State.RESTING and idle_start_time is not None: logging.info("Resume Safe Eyes due to user activity") smart_pause_activated = False idle_period = datetime.datetime.now() - idle_start_time idle_seconds = idle_period.total_seconds() - context["idle_period"] = idle_seconds # type: ignore[index] + context["idle_period"] = idle_seconds if idle_seconds < short_break_interval: # Credit back the idle time if next_break_time is not None: @@ -281,7 +279,7 @@ def disable() -> None: global idle_monitor # Remove the idle_period - context.pop("idle_period", None) # type: ignore[union-attr] + context.pop("idle_period", None) if idle_monitor is not None: idle_monitor.stop() diff --git a/safeeyes/plugins/trayicon/plugin.py b/safeeyes/plugins/trayicon/plugin.py index af9dfa0f..d0251e9b 100644 --- a/safeeyes/plugins/trayicon/plugin.py +++ b/safeeyes/plugins/trayicon/plugin.py @@ -24,15 +24,14 @@ from gi.repository import Gio, GLib import logging from safeeyes import utility +from safeeyes.context import Context from safeeyes.translations import translate as _ -import threading import typing """ Safe Eyes tray icon plugin """ -context = None tray_icon = None safeeyes_config = None @@ -53,6 +52,10 @@ + + + + @@ -192,7 +195,7 @@ class DBusMenuService(DBusService): # TODO: replace dict here with more exact typing for item idToItems: dict[str, dict] = {} - def __init__(self, session_bus, context, items): + def __init__(self, session_bus, items): super().__init__( interface_info=MENU_NODE_INFO, object_path=self.DBUS_SERVICE_PATH, @@ -374,7 +377,9 @@ class StatusNotifierItemService(DBusService): ItemIsMenu = True Menu = None - def __init__(self, session_bus, context, menu_items): + last_activation_token: typing.Optional[str] = None + + def __init__(self, session_bus, menu_items): super().__init__( interface_info=SNI_NODE_INFO, object_path=self.DBUS_SERVICE_PATH, @@ -383,7 +388,7 @@ def __init__(self, session_bus, context, menu_items): self.bus = session_bus - self._menu = DBusMenuService(session_bus, context, menu_items) + self._menu = DBusMenuService(session_bus, menu_items) self.Menu = self._menu.DBUS_SERVICE_PATH def register(self): @@ -424,6 +429,9 @@ def set_xayatanalabel(self, label): self.emit_signal("XAyatanaNewLabel", (label, "")) + def ProvideXdgActivationToken(self, token: str) -> None: + self.last_activation_token = token + class TrayIcon: """Create and show the tray icon along with the tray menu.""" @@ -431,29 +439,29 @@ class TrayIcon: _animation_timeout_id: typing.Optional[int] = None _animation_icon_enabled: bool = False - def __init__(self, context, plugin_config): + _resume_timeout_id: typing.Optional[int] = None + + def __init__(self, context: Context, plugin_config): self.context = context - self.on_show_settings = context["api"]["show_settings"] - self.on_show_about = context["api"]["show_about"] - self.quit = context["api"]["quit"] - self.enable_safeeyes = context["api"]["enable_safeeyes"] - self.disable_safeeyes = context["api"]["disable_safeeyes"] - self.take_break = context["api"]["take_break"] - self.has_breaks = context["api"]["has_breaks"] - self.get_break_time = context["api"]["get_break_time"] + self.on_show_settings = context.api.show_settings + self.on_show_about = context.api.show_about + self.quit = context.api.quit + self.enable_safeeyes = context.api.enable_safeeyes + self.disable_safeeyes = context.api.disable_safeeyes + self.take_break = context.api.take_break + self.has_breaks = context.api.has_breaks + self.get_break_time = context.api.get_break_time self.plugin_config = plugin_config self.date_time = None self.active = True self.wakeup_time = None - self.idle_condition = threading.Condition() - self.lock = threading.Lock() self.allow_disabling = plugin_config["allow_disabling"] self.menu_locked = False session_bus = Gio.bus_get_sync(Gio.BusType.SESSION) self.sni_service = StatusNotifierItemService( - session_bus, context, menu_items=self.get_items() + session_bus, menu_items=self.get_items() ) self.sni_service.register() @@ -654,27 +662,24 @@ def quit_safe_eyes(self): This action terminates the application. """ - with self.lock: - self.active = True - # Notify all schedulers - self.idle_condition.acquire() - self.idle_condition.notify_all() - self.idle_condition.release() + self.active = True + self.__clear_resume_timer() + self.quit() - def show_settings(self): + def show_settings(self) -> None: """Handle Settings menu action. This action shows the Settings dialog. """ - self.on_show_settings() + self.on_show_settings(self.sni_service.last_activation_token) - def show_about(self): + def show_about(self) -> None: """Handle About menu action. This action shows the About dialog. """ - self.on_show_about() + self.on_show_about(self.sni_service.last_activation_token) def next_break_time(self, dateTime): """Update the next break time to be displayed in the menu and @@ -711,13 +716,9 @@ def on_enable_clicked(self): This action enables the application if it is currently disabled. """ if not self.active: - with self.lock: - self.enable_ui() - self.enable_safeeyes() - # Notify all schedulers - self.idle_condition.acquire() - self.idle_condition.notify_all() - self.idle_condition.release() + self.enable_ui() + self.enable_safeeyes() + self.__clear_resume_timer() def on_disable_clicked(self, time_to_wait): """Handle the menu actions of all the sub menus of 'Disable Safe Eyes'. @@ -737,7 +738,9 @@ def on_disable_clicked(self, time_to_wait): ) info = _("Disabled until %s") % utility.format_time(self.wakeup_time) self.disable_safeeyes(info) - utility.start_thread(self.__schedule_resume, time_minutes=time_to_wait) + self._resume_timeout_id = GLib.timeout_add_seconds( + time_to_wait * 60, self.__resume + ) self.update_menu() def lock_menu(self): @@ -774,17 +777,19 @@ def enable_ui(self): self.sni_service.set_icon("io.github.slgobinath.SafeEyes-enabled") self.update_menu() - def __schedule_resume(self, time_minutes): - """Schedule a local timer to enable Safe Eyes after the given - timeout. - """ - self.idle_condition.acquire() - self.idle_condition.wait(time_minutes * 60) # Convert to seconds - self.idle_condition.release() + def __resume(self): + """Reenable Safe Eyes after the given timeout.""" + if not self.active: + self.on_enable_clicked() + + self._resume_timeout_id = None + + return GLib.SOURCE_REMOVE - with self.lock: - if not self.active: - utility.execute_main_thread(self.on_enable_clicked) + def __clear_resume_timer(self): + if self._resume_timeout_id is not None: + GLib.source_remove(self._resume_timeout_id) + self._resume_timeout_id = None def start_animation(self) -> None: if self._animation_timeout_id is not None: @@ -821,14 +826,12 @@ def stop_animation(self) -> None: def init(ctx, safeeyes_cfg, plugin_config): """Initialize the tray icon.""" - global context global tray_icon global safeeyes_config logging.debug("Initialize Tray Icon plugin") - context = ctx safeeyes_config = safeeyes_cfg if not tray_icon: - tray_icon = TrayIcon(context, plugin_config) + tray_icon = TrayIcon(ctx, plugin_config) else: tray_icon.initialize(plugin_config) diff --git a/safeeyes/safeeyes.py b/safeeyes/safeeyes.py index b1c67655..c3cca8da 100644 --- a/safeeyes/safeeyes.py +++ b/safeeyes/safeeyes.py @@ -22,15 +22,15 @@ import atexit import logging -import typing from importlib import metadata +import typing import gi -from safeeyes import utility +from safeeyes import context, utility from safeeyes.ui.about_dialog import AboutDialog from safeeyes.ui.break_screen import BreakScreen from safeeyes.ui.required_plugin_dialog import RequiredPluginDialog -from safeeyes.model import State, RequiredPluginException +from safeeyes.model import BreakType, State, RequiredPluginException from safeeyes.translations import translate as _ from safeeyes.plugin_manager import PluginManager from safeeyes.core import SafeEyesCore @@ -47,20 +47,22 @@ class SafeEyes(Gtk.Application): required_plugin_dialog_active = False retry_errored_plugins_count = 0 + context: context.Context + break_screen: BreakScreen + safe_eyes_core: SafeEyesCore + plugins_manager: PluginManager + system_locale: str - def __init__(self, system_locale, config) -> None: + _settings_dialog: typing.Optional[SettingsDialog] = None + + def __init__(self, system_locale: str, config) -> None: super().__init__( application_id="io.github.slgobinath.SafeEyes", flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE, ) self.active = False - self.break_screen = None - self.safe_eyes_core = None self.config = config - self.context: typing.Any = {} - self.plugins_manager = None - self.settings_dialog_active = False self._status = "" self.system_locale = system_locale @@ -219,39 +221,23 @@ def do_command_line(self, command_line): return 0 - def do_startup(self): + def do_startup(self) -> None: Gtk.Application.do_startup(self) logging.info("Starting up Application") # Initialize the Safe Eyes Context - self.context["version"] = SAFE_EYES_VERSION - self.context["desktop"] = utility.desktop_environment() - self.context["is_wayland"] = utility.is_wayland() - self.context["locale"] = self.system_locale - self.context["api"] = {} - self.context["api"]["show_settings"] = lambda: utility.execute_main_thread( - self.show_settings - ) - self.context["api"]["show_about"] = lambda: utility.execute_main_thread( - self.show_about - ) - self.context["api"]["enable_safeeyes"] = ( - lambda next_break_time=-1: utility.execute_main_thread( - self.enable_safeeyes, next_break_time - ) - ) - self.context["api"]["disable_safeeyes"] = ( - lambda status=None, is_resting=False: utility.execute_main_thread( - self.disable_safeeyes, status, is_resting - ) - ) - self.context["api"]["status"] = self.status - self.context["api"]["quit"] = lambda: utility.execute_main_thread(self.quit) if self.config.get("persist_state"): - self.context["session"] = utility.open_session() + session = utility.open_session() else: - self.context["session"] = {"plugin": {}} + session = {"plugin": {}} + + self.context = context.Context( + api=context.API(self), + locale=self.system_locale, + version=SAFE_EYES_VERSION, + session=session, + ) # Initialize the theme self._initialize_styles() @@ -269,10 +255,6 @@ def do_startup(self): self.safe_eyes_core.on_stop_break += self.stop_break self.safe_eyes_core.on_update_next_break += self.update_next_break self.safe_eyes_core.initialize(self.config) - self.context["api"]["take_break"] = self.take_break - self.context["api"]["has_breaks"] = self.safe_eyes_core.has_breaks - self.context["api"]["postpone"] = self.safe_eyes_core.postpone - self.context["api"]["get_break_time"] = self.safe_eyes_core.get_break_time try: self.plugins_manager.init(self.context, self.config) @@ -289,7 +271,7 @@ def do_startup(self): and self.safe_eyes_core.has_breaks() ): self.active = True - self.context["state"] = State.START + self.context.state = State.START self.plugins_manager.start() # Call the start method of all plugins self.safe_eyes_core.start() self.handle_system_suspend() @@ -334,30 +316,33 @@ def _retry_errored_plugins(self): GLib.timeout_add_seconds(timeout, self._retry_errored_plugins) - def show_settings(self): + def show_settings(self, activation_token: typing.Optional[str] = None) -> None: """Listen to tray icon Settings action and send the signal to Settings dialog. """ - if not self.settings_dialog_active: + if self._settings_dialog is None: logging.info("Show Settings dialog") - self.settings_dialog_active = True - settings_dialog = SettingsDialog( + self._settings_dialog = SettingsDialog( self, self.config.clone(), self.save_settings ) - settings_dialog.show() - def show_required_plugin_dialog(self, error: RequiredPluginException): + if activation_token is not None: + self._settings_dialog.set_startup_id(activation_token) + + self._settings_dialog.show() + + def show_required_plugin_dialog(self, error: RequiredPluginException) -> None: self.required_plugin_dialog_active = True logging.info("Show RequiredPlugin dialog") plugin_id = error.get_plugin_id() dialog = RequiredPluginDialog( - error.get_plugin_id(), error.get_plugin_name(), error.get_message(), self.quit, lambda: self.disable_plugin(plugin_id), + application=self, ) dialog.show() @@ -373,12 +358,16 @@ def disable_plugin(self, plugin_id): self.restart(config, set_active=True) - def show_about(self): + def show_about(self, activation_token: typing.Optional[str] = None): """Listen to tray icon About action and send the signal to About dialog. """ logging.info("Show About dialog") about_dialog = AboutDialog(self, SAFE_EYES_VERSION) + + if activation_token is not None: + about_dialog.set_startup_id(activation_token) + about_dialog.show() def quit(self): @@ -455,7 +444,7 @@ def save_settings(self, config): """Listen to Settings dialog Save action and write to the config file. """ - self.settings_dialog_active = False + self._settings_dialog = None if self.config == config: # Config is not modified @@ -547,9 +536,9 @@ def stop_break(self): self.plugins_manager.stop_break() return True - def take_break(self, break_type=None): + def take_break(self, break_type: typing.Optional[BreakType] = None) -> None: """Take a break now.""" - utility.execute_main_thread(self.safe_eyes_core.take_break, break_type) + self.safe_eyes_core.take_break(break_type) def status(self): """Return the status of Safe Eyes.""" diff --git a/safeeyes/tests/test_core.py b/safeeyes/tests/test_core.py index abea61af..6efed3d3 100644 --- a/safeeyes/tests/test_core.py +++ b/safeeyes/tests/test_core.py @@ -20,6 +20,7 @@ import pytest import typing +from safeeyes import context from safeeyes import core from safeeyes import model @@ -134,7 +135,7 @@ def run_next_break( sequential_threading_handle: SafeEyesCoreHandle, time_machine: TimeMachineFixture, safe_eyes_core: core.SafeEyesCore, - context, + ctx: context.Context, break_duration: int, break_name_translated: str, initial: bool = False, @@ -154,11 +155,11 @@ def run_next_break( if initial: safe_eyes_core.start() else: - assert context["state"] == model.State.BREAK + assert ctx["state"] == model.State.BREAK sequential_threading_handle.next() - assert context["state"] == model.State.WAITING + assert ctx["state"] == model.State.WAITING on_update_next_break.assert_called_once() assert isinstance(on_update_next_break.call_args[0][0], model.Break) @@ -168,7 +169,7 @@ def run_next_break( self.run_next_break_from_waiting_state( sequential_threading_handle, safe_eyes_core, - context, + ctx, break_duration, break_name_translated, ) @@ -177,7 +178,7 @@ def run_next_break_from_waiting_state( self, sequential_threading_handle: SafeEyesCoreHandle, safe_eyes_core: core.SafeEyesCore, - context, + ctx: context.Context, break_duration: int, break_name_translated: str, ) -> None: @@ -193,13 +194,13 @@ def run_next_break_from_waiting_state( safe_eyes_core.on_count_down += on_count_down safe_eyes_core.on_stop_break += on_stop_break - assert context["state"] == model.State.WAITING + assert ctx["state"] == model.State.WAITING # continue after condvar sequential_threading_handle.next() # end of __scheduler_job - assert context["state"] == model.State.PRE_BREAK + assert ctx["state"] == model.State.PRE_BREAK on_pre_break.assert_called_once() assert isinstance(on_pre_break.call_args[0][0], model.Break) @@ -210,7 +211,7 @@ def run_next_break_from_waiting_state( # first sleep in __start_break sequential_threading_handle.next() - assert context["state"] == model.State.BREAK + assert ctx["state"] == model.State.BREAK on_start_break.assert_called_once() assert isinstance(on_start_break.call_args[0][0], model.Break) @@ -222,13 +223,13 @@ def run_next_break_from_waiting_state( assert start_break.call_args[0][0].name == break_name_translated start_break.reset_mock() - assert context["state"] == model.State.BREAK + assert ctx["state"] == model.State.BREAK # continue sleep in __start_break for i in range(break_duration - 1): sequential_threading_handle.next() - assert context["state"] == model.State.BREAK + assert ctx["state"] == model.State.BREAK sequential_threading_handle.next() # end of __start_break @@ -241,7 +242,7 @@ def run_next_break_from_waiting_state( assert on_stop_break.call_count == 1 on_stop_break.reset_mock() - assert context["state"] == model.State.BREAK + assert ctx["state"] == model.State.BREAK def assert_datetime(self, string: str): if not string.endswith("+00:00"): @@ -251,7 +252,9 @@ def assert_datetime(self, string: str): ) == datetime.datetime.fromisoformat(string) def test_start_empty(self, sequential_threading: SequentialThreadingFixture): - context: dict[str, typing.Any] = {} + ctx = context.Context( + api=mock.Mock(spec=context.API), locale="en_US", version="0.0.0", session={} + ) config = model.Config( user_config={ "short_breaks": [], @@ -266,7 +269,7 @@ def test_start_empty(self, sequential_threading: SequentialThreadingFixture): system_config={}, ) on_update_next_break = mock.Mock() - safe_eyes_core = core.SafeEyesCore(context) + safe_eyes_core = core.SafeEyesCore(ctx) safe_eyes_core.on_update_next_break += mock safe_eyes_core.initialize(config) @@ -277,9 +280,9 @@ def test_start_empty(self, sequential_threading: SequentialThreadingFixture): on_update_next_break.assert_not_called() def test_start(self, sequential_threading: SequentialThreadingFixture): - context: dict[str, typing.Any] = { - "session": {}, - } + ctx = context.Context( + api=mock.Mock(spec=context.API), locale="en_US", version="0.0.0", session={} + ) config = model.Config( user_config={ "short_breaks": [ @@ -304,7 +307,7 @@ def test_start(self, sequential_threading: SequentialThreadingFixture): system_config={}, ) on_update_next_break = mock.Mock() - safe_eyes_core = core.SafeEyesCore(context) + safe_eyes_core = core.SafeEyesCore(ctx) safe_eyes_core.on_update_next_break += on_update_next_break safe_eyes_core.initialize(config) @@ -313,7 +316,7 @@ def test_start(self, sequential_threading: SequentialThreadingFixture): safe_eyes_core.start() - assert context["state"] == model.State.WAITING + assert ctx["state"] == model.State.WAITING on_update_next_break.assert_called_once() assert isinstance(on_update_next_break.call_args[0][0], model.Break) @@ -325,16 +328,16 @@ def test_start(self, sequential_threading: SequentialThreadingFixture): sequential_threading_handle.next() safe_eyes_core.stop() - assert context["state"] == model.State.STOPPED + assert ctx["state"] == model.State.STOPPED def test_full_run_with_defaults( self, sequential_threading: SequentialThreadingFixture, time_machine: TimeMachineFixture, ): - context: dict[str, typing.Any] = { - "session": {}, - } + ctx = context.Context( + api=mock.Mock(spec=context.API), locale="en_US", version="0.0.0", session={} + ) short_break_duration = 15 # seconds short_break_interval = 15 # minutes pre_break_warning_time = 10 # seconds @@ -366,7 +369,7 @@ def test_full_run_with_defaults( self.assert_datetime("2024-08-25T13:00:00") - safe_eyes_core = core.SafeEyesCore(context) + safe_eyes_core = core.SafeEyesCore(ctx) sequential_threading_handle = sequential_threading(safe_eyes_core) @@ -376,7 +379,7 @@ def test_full_run_with_defaults( sequential_threading_handle, time_machine, safe_eyes_core, - context, + ctx, short_break_duration, "translated!: break 1", initial=True, @@ -391,7 +394,7 @@ def test_full_run_with_defaults( sequential_threading_handle, time_machine, safe_eyes_core, - context, + ctx, short_break_duration, "translated!: break 2", ) @@ -402,7 +405,7 @@ def test_full_run_with_defaults( sequential_threading_handle, time_machine, safe_eyes_core, - context, + ctx, short_break_duration, "translated!: break 3", ) @@ -413,7 +416,7 @@ def test_full_run_with_defaults( sequential_threading_handle, time_machine, safe_eyes_core, - context, + ctx, short_break_duration, "translated!: break 4", ) @@ -424,7 +427,7 @@ def test_full_run_with_defaults( sequential_threading_handle, time_machine, safe_eyes_core, - context, + ctx, long_break_duration, "translated!: long break 1", ) @@ -439,7 +442,7 @@ def test_full_run_with_defaults( sequential_threading_handle, time_machine, safe_eyes_core, - context, + ctx, short_break_duration, "translated!: break 1", ) @@ -451,7 +454,7 @@ def test_full_run_with_defaults( safe_eyes_core.stop() - assert context["state"] == model.State.STOPPED + assert ctx["state"] == model.State.STOPPED def test_long_duration_is_bigger_than_short_interval( self, @@ -459,9 +462,9 @@ def test_long_duration_is_bigger_than_short_interval( time_machine: TimeMachineFixture, ): """Example taken from https://github.com/slgobinath/SafeEyes/issues/640.""" - context: dict[str, typing.Any] = { - "session": {}, - } + ctx = context.Context( + api=mock.Mock(spec=context.API), locale="en_US", version="0.0.0", session={} + ) short_break_duration = 300 # seconds = 5min short_break_interval = 25 # minutes pre_break_warning_time = 10 # seconds @@ -493,7 +496,7 @@ def test_long_duration_is_bigger_than_short_interval( self.assert_datetime("2024-08-25T13:00:00") - safe_eyes_core = core.SafeEyesCore(context) + safe_eyes_core = core.SafeEyesCore(ctx) sequential_threading_handle = sequential_threading(safe_eyes_core) @@ -503,7 +506,7 @@ def test_long_duration_is_bigger_than_short_interval( sequential_threading_handle, time_machine, safe_eyes_core, - context, + ctx, short_break_duration, "translated!: break 1", initial=True, @@ -518,7 +521,7 @@ def test_long_duration_is_bigger_than_short_interval( sequential_threading_handle, time_machine, safe_eyes_core, - context, + ctx, short_break_duration, "translated!: break 2", ) @@ -529,7 +532,7 @@ def test_long_duration_is_bigger_than_short_interval( sequential_threading_handle, time_machine, safe_eyes_core, - context, + ctx, short_break_duration, "translated!: break 3", ) @@ -540,7 +543,7 @@ def test_long_duration_is_bigger_than_short_interval( sequential_threading_handle, time_machine, safe_eyes_core, - context, + ctx, long_break_duration, "translated!: long break 1", ) @@ -555,7 +558,7 @@ def test_long_duration_is_bigger_than_short_interval( sequential_threading_handle, time_machine, safe_eyes_core, - context, + ctx, short_break_duration, "translated!: break 4", ) @@ -567,7 +570,7 @@ def test_long_duration_is_bigger_than_short_interval( safe_eyes_core.stop() - assert context["state"] == model.State.STOPPED + assert ctx["state"] == model.State.STOPPED def test_idle( self, @@ -575,9 +578,9 @@ def test_idle( time_machine: TimeMachineFixture, ): """Test idling for short amount of time.""" - context: dict[str, typing.Any] = { - "session": {}, - } + ctx = context.Context( + api=mock.Mock(spec=context.API), locale="en_US", version="0.0.0", session={} + ) short_break_duration = 15 # seconds short_break_interval = 15 # minutes pre_break_warning_time = 10 # seconds @@ -609,7 +612,7 @@ def test_idle( self.assert_datetime("2024-08-25T13:00:00") - safe_eyes_core = core.SafeEyesCore(context) + safe_eyes_core = core.SafeEyesCore(ctx) sequential_threading_handle = sequential_threading(safe_eyes_core) @@ -619,7 +622,7 @@ def test_idle( sequential_threading_handle, time_machine, safe_eyes_core, - context, + ctx, short_break_duration, "translated!: break 1", initial=True, @@ -636,7 +639,7 @@ def test_idle( safe_eyes_core.stop(is_resting=True) - assert context["state"] == model.State.RESTING + assert ctx["state"] == model.State.RESTING time_machine.shift(delta=idle_period) @@ -650,7 +653,7 @@ def test_idle( self.run_next_break_from_waiting_state( sequential_threading_handle, safe_eyes_core, - context, + ctx, short_break_duration, "translated!: break 2", ) @@ -661,7 +664,7 @@ def test_idle( sequential_threading_handle, time_machine, safe_eyes_core, - context, + ctx, short_break_duration, "translated!: break 3", ) @@ -672,7 +675,7 @@ def test_idle( sequential_threading_handle, time_machine, safe_eyes_core, - context, + ctx, short_break_duration, "translated!: break 4", ) @@ -683,7 +686,7 @@ def test_idle( sequential_threading_handle, time_machine, safe_eyes_core, - context, + ctx, long_break_duration, "translated!: long break 1", ) @@ -698,7 +701,7 @@ def test_idle( sequential_threading_handle, time_machine, safe_eyes_core, - context, + ctx, short_break_duration, "translated!: break 1", ) @@ -710,7 +713,7 @@ def test_idle( safe_eyes_core.stop() - assert context["state"] == model.State.STOPPED + assert ctx["state"] == model.State.STOPPED def test_idle_skip_long( self, @@ -718,9 +721,9 @@ def test_idle_skip_long( time_machine: TimeMachineFixture, ): """Test idling for longer than long break time.""" - context: dict[str, typing.Any] = { - "session": {}, - } + ctx = context.Context( + api=mock.Mock(spec=context.API), locale="en_US", version="0.0.0", session={} + ) short_break_duration = 15 # seconds short_break_interval = 15 # minutes pre_break_warning_time = 10 # seconds @@ -752,7 +755,7 @@ def test_idle_skip_long( self.assert_datetime("2024-08-25T13:00:00") - safe_eyes_core = core.SafeEyesCore(context) + safe_eyes_core = core.SafeEyesCore(ctx) sequential_threading_handle = sequential_threading(safe_eyes_core) @@ -762,7 +765,7 @@ def test_idle_skip_long( sequential_threading_handle, time_machine, safe_eyes_core, - context, + ctx, short_break_duration, "translated!: break 1", initial=True, @@ -779,7 +782,7 @@ def test_idle_skip_long( safe_eyes_core.stop(is_resting=True) - assert context["state"] == model.State.RESTING + assert ctx["state"] == model.State.RESTING time_machine.shift(delta=idle_period) @@ -793,7 +796,7 @@ def test_idle_skip_long( self.run_next_break_from_waiting_state( sequential_threading_handle, safe_eyes_core, - context, + ctx, short_break_duration, "translated!: break 2", ) @@ -804,7 +807,7 @@ def test_idle_skip_long( sequential_threading_handle, time_machine, safe_eyes_core, - context, + ctx, short_break_duration, "translated!: break 3", ) @@ -815,7 +818,7 @@ def test_idle_skip_long( sequential_threading_handle, time_machine, safe_eyes_core, - context, + ctx, short_break_duration, "translated!: break 4", ) @@ -826,7 +829,7 @@ def test_idle_skip_long( sequential_threading_handle, time_machine, safe_eyes_core, - context, + ctx, short_break_duration, "translated!: break 1", ) @@ -837,7 +840,7 @@ def test_idle_skip_long( sequential_threading_handle, time_machine, safe_eyes_core, - context, + ctx, long_break_duration, "translated!: long break 1", ) @@ -852,7 +855,7 @@ def test_idle_skip_long( sequential_threading_handle, time_machine, safe_eyes_core, - context, + ctx, short_break_duration, "translated!: break 2", ) @@ -864,7 +867,7 @@ def test_idle_skip_long( safe_eyes_core.stop() - assert context["state"] == model.State.STOPPED + assert ctx["state"] == model.State.STOPPED def test_idle_skip_long_before_long( self, @@ -876,9 +879,9 @@ def test_idle_skip_long_before_long( This used to skip all the short breaks too. """ - context: dict[str, typing.Any] = { - "session": {}, - } + ctx = context.Context( + api=mock.Mock(spec=context.API), locale="en_US", version="0.0.0", session={} + ) short_break_duration = 15 # seconds short_break_interval = 15 # minutes pre_break_warning_time = 10 # seconds @@ -910,7 +913,7 @@ def test_idle_skip_long_before_long( self.assert_datetime("2024-08-25T13:00:00") - safe_eyes_core = core.SafeEyesCore(context) + safe_eyes_core = core.SafeEyesCore(ctx) sequential_threading_handle = sequential_threading(safe_eyes_core) @@ -920,7 +923,7 @@ def test_idle_skip_long_before_long( sequential_threading_handle, time_machine, safe_eyes_core, - context, + ctx, short_break_duration, "translated!: break 1", initial=True, @@ -935,7 +938,7 @@ def test_idle_skip_long_before_long( sequential_threading_handle, time_machine, safe_eyes_core, - context, + ctx, short_break_duration, "translated!: break 2", ) @@ -946,7 +949,7 @@ def test_idle_skip_long_before_long( sequential_threading_handle, time_machine, safe_eyes_core, - context, + ctx, short_break_duration, "translated!: break 3", ) @@ -957,7 +960,7 @@ def test_idle_skip_long_before_long( sequential_threading_handle, time_machine, safe_eyes_core, - context, + ctx, short_break_duration, "translated!: break 4", ) @@ -970,7 +973,7 @@ def test_idle_skip_long_before_long( safe_eyes_core.stop(is_resting=True) - assert context["state"] == model.State.RESTING + assert ctx["state"] == model.State.RESTING time_machine.shift(delta=idle_period) @@ -984,7 +987,7 @@ def test_idle_skip_long_before_long( self.run_next_break_from_waiting_state( sequential_threading_handle, safe_eyes_core, - context, + ctx, short_break_duration, "translated!: break 1", ) @@ -995,7 +998,7 @@ def test_idle_skip_long_before_long( sequential_threading_handle, time_machine, safe_eyes_core, - context, + ctx, short_break_duration, "translated!: break 2", ) @@ -1006,7 +1009,7 @@ def test_idle_skip_long_before_long( sequential_threading_handle, time_machine, safe_eyes_core, - context, + ctx, short_break_duration, "translated!: break 3", ) @@ -1017,7 +1020,7 @@ def test_idle_skip_long_before_long( sequential_threading_handle, time_machine, safe_eyes_core, - context, + ctx, short_break_duration, "translated!: break 4", ) @@ -1031,7 +1034,7 @@ def test_idle_skip_long_before_long( sequential_threading_handle, time_machine, safe_eyes_core, - context, + ctx, long_break_duration, "translated!: long break 2", ) @@ -1040,4 +1043,4 @@ def test_idle_skip_long_before_long( safe_eyes_core.stop() - assert context["state"] == model.State.STOPPED + assert ctx["state"] == model.State.STOPPED diff --git a/safeeyes/tests/test_model.py b/safeeyes/tests/test_model.py index f0d219db..0be8c516 100644 --- a/safeeyes/tests/test_model.py +++ b/safeeyes/tests/test_model.py @@ -19,7 +19,8 @@ import pytest import random import typing -from safeeyes import model +from unittest import mock +from safeeyes import context, model class TestBreak: @@ -65,9 +66,11 @@ def test_create_empty(self) -> None: system_config={}, ) - context: dict[str, typing.Any] = {} + ctx = context.Context( + api=mock.Mock(spec=context.API), locale="en_US", version="0.0.0", session={} + ) - bq = model.BreakQueue.create(config, context) + bq = model.BreakQueue.create(config, ctx) assert bq is None @@ -98,11 +101,11 @@ def get_bq_only_short( system_config={}, ) - context: dict[str, typing.Any] = { - "session": {}, - } + ctx = context.Context( + api=mock.Mock(spec=context.API), locale="en_US", version="0.0.0", session={} + ) - bq = model.BreakQueue.create(config, context) + bq = model.BreakQueue.create(config, ctx) assert bq is not None @@ -135,11 +138,11 @@ def get_bq_only_long( system_config={}, ) - context: dict[str, typing.Any] = { - "session": {}, - } + ctx = context.Context( + api=mock.Mock(spec=context.API), locale="en_US", version="0.0.0", session={} + ) - bq = model.BreakQueue.create(config, context) + bq = model.BreakQueue.create(config, ctx) assert bq is not None @@ -177,11 +180,11 @@ def get_bq_full( system_config={}, ) - context: dict[str, typing.Any] = { - "session": {}, - } + ctx = context.Context( + api=mock.Mock(spec=context.API), locale="en_US", version="0.0.0", session={} + ) - bq = model.BreakQueue.create(config, context) + bq = model.BreakQueue.create(config, ctx) assert bq is not None diff --git a/safeeyes/ui/about_dialog.py b/safeeyes/ui/about_dialog.py index 5914d44a..d4d230bc 100644 --- a/safeeyes/ui/about_dialog.py +++ b/safeeyes/ui/about_dialog.py @@ -19,6 +19,10 @@ """This module creates the AboutDialog which shows the version and license.""" import os +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk from safeeyes import utility from safeeyes.translations import translate as _ @@ -26,7 +30,8 @@ ABOUT_DIALOG_GLADE = os.path.join(utility.BIN_DIRECTORY, "glade/about_dialog.glade") -class AboutDialog: +@Gtk.Template(filename=ABOUT_DIALOG_GLADE) +class AboutDialog(Gtk.ApplicationWindow): """AboutDialog reads the about_dialog.glade and build the user interface using that file. @@ -34,33 +39,36 @@ class AboutDialog: license and the GitHub url. """ - def __init__(self, application, version): - builder = utility.create_gtk_builder(ABOUT_DIALOG_GLADE) - self.window = builder.get_object("window_about") - self.window.set_application(application) + __gtype_name__ = "AboutDialog" + + lbl_decription: Gtk.Label = Gtk.Template.Child() + lbl_license: Gtk.Label = Gtk.Template.Child() + lbl_app_name: Gtk.Label = Gtk.Template.Child() - self.window.connect("close-request", self.on_window_delete) - builder.get_object("btn_close").connect("clicked", self.on_close_clicked) + def __init__(self, application: Gtk.Application, version: str): + super().__init__(application=application) - builder.get_object("lbl_decription").set_label( + self.lbl_decription.set_label( _( "Safe Eyes protects your eyes from eye strain (asthenopia) by reminding" " you to take breaks while you're working long hours at the computer" ) ) - builder.get_object("lbl_license").set_label(_("License") + ":") + self.lbl_license.set_label(_("License") + ":") # Set the version at the runtime - builder.get_object("lbl_app_name").set_label("Safe Eyes " + version) + self.lbl_app_name.set_label("Safe Eyes " + version) - def show(self): + def show(self) -> None: """Show the About dialog.""" - self.window.present() + self.present() - def on_window_delete(self, *args): + @Gtk.Template.Callback() + def on_window_delete(self, *args) -> None: """Window close event handler.""" - self.window.destroy() + self.destroy() - def on_close_clicked(self, *args): + @Gtk.Template.Callback() + def on_close_clicked(self, *args) -> None: """Close button click event handler.""" - self.window.destroy() + self.destroy() diff --git a/safeeyes/ui/break_screen.py b/safeeyes/ui/break_screen.py index 158adc42..a7c49009 100644 --- a/safeeyes/ui/break_screen.py +++ b/safeeyes/ui/break_screen.py @@ -20,10 +20,12 @@ import logging import os import time +import typing import gi from safeeyes import utility -from safeeyes.model import TrayAction +from safeeyes.context import Context +from safeeyes.model import Break, Config, TrayAction from safeeyes.translations import translate as _ import Xlib from Xlib.display import Display @@ -31,7 +33,6 @@ gi.require_version("Gtk", "4.0") from gi.repository import Gdk -from gi.repository import GLib from gi.repository import Gtk from gi.repository import GdkX11 @@ -39,16 +40,22 @@ class BreakScreen: - """The fullscreen window which prevents users from using the computer. + """The fullscreen windows which prevent users from using the computer. - This class reads the break_screen.glade and build the user - interface. + This class creates and manages the fullscreen windows for every monitor. """ - def __init__(self, application, context, on_skipped, on_postponed): + windows: list["BreakScreenWindow"] + + def __init__( + self, + application: Gtk.Application, + context: Context, + on_skipped: typing.Callable[[], None], + on_postponed: typing.Callable[[], None], + ): self.application = application self.context = context - self.count_labels = [] self.x11_display = None self.enable_postpone = False self.enable_shortcut = False @@ -63,17 +70,17 @@ def __init__(self, application, context, on_skipped, on_postponed): self.show_skip_button = False self.show_postpone_button = False - if not self.context["is_wayland"]: + if not self.context.is_wayland: self.x11_display = Display() - def initialize(self, config): + def initialize(self, config: Config) -> None: """Initialize the internal properties from configuration.""" logging.info("Initialize the break screen") self.enable_postpone = config.get("allow_postpone", False) self.keycode_shortcut_postpone = config.get("shortcut_postpone", 65) self.keycode_shortcut_skip = config.get("shortcut_skip", 9) - if self.context["is_wayland"] and ( + if self.context.is_wayland and ( self.keycode_shortcut_postpone != 65 or self.keycode_shortcut_skip != 9 ): logging.warning( @@ -86,7 +93,7 @@ def initialize(self, config): self.shortcut_disable_time = config.get("shortcut_disable_time", 2) self.strict_break = config.get("strict_break", False) - def skip_break(self): + def skip_break(self) -> None: """Skip the break from the break screen.""" logging.info("User skipped the break") # Must call on_skipped before close to lock screen before closing the break @@ -94,70 +101,65 @@ def skip_break(self): self.on_skipped() self.close() - def postpone_break(self): + def postpone_break(self) -> None: """Postpone the break from the break screen.""" logging.info("User postponed the break") self.on_postponed() self.close() - def on_window_delete(self, *args): - """Window close event handler.""" - logging.info("Closing the break screen") - self.close() - - def on_skip_clicked(self, button): + def on_skip_clicked(self, button) -> None: """Skip button press event handler.""" self.skip_break() - def on_postpone_clicked(self, button): + def on_postpone_clicked(self, button) -> None: """Postpone button press event handler.""" self.postpone_break() - def show_count_down(self, countdown, seconds): + def show_count_down(self, countdown: int, seconds: int) -> None: """Show/update the count down on all screens.""" self.enable_shortcut = self.shortcut_disable_time <= seconds mins, secs = divmod(countdown, 60) timeformat = "{:02d}:{:02d}".format(mins, secs) - GLib.idle_add(lambda: self.__update_count_down(timeformat)) + self.__update_count_down(timeformat) - def show_message(self, break_obj, widget, tray_actions=[]): + def show_message( + self, break_obj: Break, widget: str, tray_actions: list[TrayAction] = [] + ) -> None: """Show the break screen with the given message on all displays.""" message = break_obj.name image_path = break_obj.image self.enable_shortcut = self.shortcut_disable_time <= 0 - GLib.idle_add( - lambda: self.__show_break_screen(message, image_path, widget, tray_actions) - ) + self.__show_break_screen(message, image_path, widget, tray_actions) - def close(self): + def close(self) -> None: """Hide the break screen from active window and destroy all other windows. """ logging.info("Close the break screen(s)") - if not self.context["is_wayland"]: + if not self.context.is_wayland: self.__release_keyboard_x11() # Destroy other windows if exists - GLib.idle_add(lambda: self.__destroy_all_screens()) - - def __tray_action(self, button, tray_action: TrayAction): - """Tray action handler. - - Hides all toolbar buttons for this action, if it is single use, - and call the action provided by the plugin. - """ - if tray_action.single_use: - tray_action.reset() - tray_action.action() - - def __show_break_screen(self, message, image_path, widget, tray_actions): + self.__destroy_all_screens() + + def __show_break_screen( + self, + message: str, + image_path: typing.Optional[str], + widget: str, + tray_actions: list[TrayAction], + ) -> None: """Show an empty break screen on all screens.""" # Lock the keyboard - if not self.context["is_wayland"]: + if not self.context.is_wayland: utility.start_thread(self.__lock_keyboard_x11) display = Gdk.Display.get_default() - monitors = display.get_monitors() + + if display is None: + raise Exception("display not found") + + monitors = typing.cast(typing.Sequence[Gdk.Monitor], display.get_monitors()) logging.info("Show break screens in %d display(s)", len(monitors)) skip_button_disabled = self.context.get("skip_button_disabled", False) @@ -171,14 +173,20 @@ def __show_break_screen(self, message, image_path, widget, tray_actions): i = 0 for monitor in monitors: - builder = Gtk.Builder() - builder.add_from_file(BREAK_SCREEN_GLADE) - - window = builder.get_object("window_main") - window.set_application(self.application) - window.connect("close-request", self.on_window_delete) + window = BreakScreenWindow( + self.application, + message, + image_path, + widget, + tray_actions, + lambda: self.close(), + self.show_postpone_button, + self.on_postpone_clicked, + self.show_skip_button, + self.on_skip_clicked, + ) - if self.context["is_wayland"]: + if self.context.is_wayland: # Note: in theory, this could also be used on X11 # however, that already has its own implementation below controller = Gtk.EventControllerKey() @@ -187,56 +195,10 @@ def __show_break_screen(self, message, image_path, widget, tray_actions): window.add_controller(controller) window.set_title("SafeEyes-" + str(i)) - lbl_message = builder.get_object("lbl_message") - lbl_count = builder.get_object("lbl_count") - lbl_widget = builder.get_object("lbl_widget") - img_break = builder.get_object("img_break") - box_buttons = builder.get_object("box_buttons") - toolbar = builder.get_object("toolbar") - - for tray_action in tray_actions: - # TODO: apparently, this would be better served with an icon theme - # + Gtk.button.new_from_icon_name - icon = tray_action.get_icon() - toolbar_button = Gtk.Button() - toolbar_button.set_child(icon) - tray_action.add_toolbar_button(toolbar_button) - toolbar_button.connect( - "clicked", - lambda button, action: self.__tray_action(button, action), - tray_action, - ) - toolbar_button.set_tooltip_text(_(tray_action.name)) - toolbar.append(toolbar_button) - toolbar_button.show() - - # Add the buttons - if self.show_postpone_button: - # Add postpone button - btn_postpone = Gtk.Button.new_with_label(_("Postpone")) - btn_postpone.get_style_context().add_class("btn_postpone") - btn_postpone.connect("clicked", self.on_postpone_clicked) - btn_postpone.set_visible(True) - box_buttons.append(btn_postpone) - - if self.show_skip_button: - # Add the skip button - btn_skip = Gtk.Button.new_with_label(_("Skip")) - btn_skip.get_style_context().add_class("btn_skip") - btn_skip.connect("clicked", self.on_skip_clicked) - btn_skip.set_visible(True) - box_buttons.append(btn_skip) - - # Set values - if image_path: - img_break.set_from_file(image_path) - lbl_message.set_label(message) - lbl_widget.set_markup(widget) self.windows.append(window) - self.count_labels.append(lbl_count) - if self.context["desktop"] == "kde": + if self.context.desktop == "kde": # Fix flickering screen in KDE by setting opacity to 1 window.set_opacity(0.9) @@ -248,22 +210,27 @@ def __show_break_screen(self, message, image_path, widget, tray_actions): # shortcut window.set_focus(None) - if not self.context["is_wayland"]: + if not self.context.is_wayland: self.__window_set_keep_above_x11(window) - if self.context["is_wayland"]: + if self.context.is_wayland: # this may or may not be granted by the window system - window.get_surface().inhibit_system_shortcuts(None) + surface = window.get_surface() + if surface is not None: + typing.cast(Gdk.Toplevel, surface).inhibit_system_shortcuts(None) i = i + 1 - def __update_count_down(self, count): + def __update_count_down(self, count: str) -> None: """Update the countdown on all break screens.""" - for label in self.count_labels: - label.set_text(count) + for window in self.windows: + window.set_count_down(count) - def __window_set_keep_above_x11(self, window): + def __window_set_keep_above_x11(self, window: "BreakScreenWindow") -> None: """Use EWMH hints to keep window above and on all desktops.""" + if self.x11_display is None: + return + NET_WM_STATE = self.x11_display.intern_atom("_NET_WM_STATE") NET_WM_STATE_ABOVE = self.x11_display.intern_atom("_NET_WM_STATE_ABOVE") NET_WM_STATE_STICKY = self.x11_display.intern_atom("_NET_WM_STATE_STICKY") @@ -273,7 +240,12 @@ def __window_set_keep_above_x11(self, window): # See https://specifications.freedesktop.org/wm-spec/1.3/ar01s05.html#id-1.6.8 root_window = self.x11_display.screen().root - xid = GdkX11.X11Surface.get_xid(window.get_surface()) + surface = window.get_surface() + + if surface is None or not isinstance(surface, GdkX11.X11Surface): + return + + xid = GdkX11.X11Surface.get_xid(surface) root_window.send_event( Xlib.protocol.event.ClientMessage( @@ -297,11 +269,14 @@ def __window_set_keep_above_x11(self, window): self.x11_display.sync() - def __lock_keyboard_x11(self): + def __lock_keyboard_x11(self) -> None: """Lock the keyboard to prevent the user from using keyboard shortcuts. (X11 only) """ + if self.x11_display is None: + return + logging.info("Lock the keyboard") self.lock_keyboard = True @@ -320,19 +295,24 @@ def __lock_keyboard_x11(self): event.detail == self.keycode_shortcut_skip and self.show_skip_button ): - self.skip_break() + utility.execute_main_thread(lambda: self.skip_break()) break elif ( event.detail == self.keycode_shortcut_postpone and self.show_postpone_button ): - self.postpone_break() + utility.execute_main_thread(lambda: self.postpone_break()) break else: # Reduce the CPU usage by sleeping for a second time.sleep(1) - def on_key_pressed_wayland(self, event_controller_key, keyval, keycode, state): + self.x11_display.ungrab_keyboard(X.CurrentTime) + self.x11_display.flush() + + def on_key_pressed_wayland( + self, event_controller_key, keyval, keycode, state + ) -> bool: if self.enable_shortcut: if keyval == Gdk.KEY_space and self.show_postpone_button: self.postpone_break() @@ -343,16 +323,105 @@ def on_key_pressed_wayland(self, event_controller_key, keyval, keycode, state): return False - def __release_keyboard_x11(self): + def __release_keyboard_x11(self) -> None: """Release the locked keyboard.""" logging.info("Unlock the keyboard") self.lock_keyboard = False - self.x11_display.ungrab_keyboard(X.CurrentTime) - self.x11_display.flush() - def __destroy_all_screens(self): + def __destroy_all_screens(self) -> None: """Close all the break screens.""" for win in self.windows: win.destroy() del self.windows[:] - del self.count_labels[:] + + +@Gtk.Template(filename=BREAK_SCREEN_GLADE) +class BreakScreenWindow(Gtk.Window): + """This class manages the UI for the break screen window. + + Each instance is a single window, covering a single monitor. + """ + + __gtype_name__ = "BreakScreenWindow" + + lbl_message: Gtk.Label = Gtk.Template.Child() + lbl_count: Gtk.Label = Gtk.Template.Child() + lbl_widget: Gtk.Label = Gtk.Template.Child() + img_break: Gtk.Image = Gtk.Template.Child() + box_buttons: Gtk.Box = Gtk.Template.Child() + toolbar: Gtk.Box = Gtk.Template.Child() + + def __init__( + self, + application: Gtk.Application, + message: str, + image_path: typing.Optional[str], + widget: str, + tray_actions: list[TrayAction], + on_close: typing.Callable[[], None], + show_postpone: bool, + on_postpone: typing.Callable[[Gtk.Button], None], + show_skip: bool, + on_skip: typing.Callable[[Gtk.Button], None], + ): + super().__init__(application=application) + + self.on_close = on_close + + for tray_action in tray_actions: + # TODO: apparently, this would be better served with an icon theme + # + Gtk.button.new_from_icon_name + icon = tray_action.get_icon() + toolbar_button = Gtk.Button() + toolbar_button.set_child(icon) + tray_action.add_toolbar_button(toolbar_button) + toolbar_button.connect( + "clicked", + lambda button, action: self.__tray_action(button, action), + tray_action, + ) + toolbar_button.set_tooltip_text(_(tray_action.name)) + self.toolbar.append(toolbar_button) + toolbar_button.show() + + # Add the buttons + if show_postpone: + # Add postpone button + btn_postpone = Gtk.Button.new_with_label(_("Postpone")) + btn_postpone.get_style_context().add_class("btn_postpone") + btn_postpone.connect("clicked", on_postpone) + btn_postpone.set_visible(True) + self.box_buttons.append(btn_postpone) + + if show_skip: + # Add the skip button + btn_skip = Gtk.Button.new_with_label(_("Skip")) + btn_skip.get_style_context().add_class("btn_skip") + btn_skip.connect("clicked", on_skip) + btn_skip.set_visible(True) + self.box_buttons.append(btn_skip) + + # Set values + if image_path: + self.img_break.set_from_file(image_path) + self.lbl_message.set_label(message) + self.lbl_widget.set_markup(widget) + + def set_count_down(self, count: str) -> None: + self.lbl_count.set_text(count) + + def __tray_action(self, button, tray_action: TrayAction) -> None: + """Tray action handler. + + Hides all toolbar buttons for this action and call the action + provided by the plugin. + """ + if tray_action.single_use: + tray_action.reset() + tray_action.action() + + @Gtk.Template.Callback() + def on_window_delete(self, *args) -> None: + """Window close event handler.""" + logging.info("Closing the break screen") + self.on_close() diff --git a/safeeyes/ui/required_plugin_dialog.py b/safeeyes/ui/required_plugin_dialog.py index ebfbd5d7..33df3e5a 100644 --- a/safeeyes/ui/required_plugin_dialog.py +++ b/safeeyes/ui/required_plugin_dialog.py @@ -21,6 +21,11 @@ """ import os +import gi +import typing + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk from safeeyes import utility from safeeyes.model import PluginDependency @@ -31,62 +36,59 @@ ) -class RequiredPluginDialog: +@Gtk.Template(filename=REQUIRED_PLUGIN_DIALOG_GLADE) +class RequiredPluginDialog(Gtk.ApplicationWindow): """RequiredPluginDialog shows an error when a plugin has required dependencies. """ - def __init__(self, plugin_id, plugin_name, message, on_quit, on_disable_plugin): - self.on_quit = on_quit - self.on_disable_plugin = on_disable_plugin + __gtype_name__ = "RequiredPluginDialog" - builder = utility.create_gtk_builder(REQUIRED_PLUGIN_DIALOG_GLADE) - self.window = builder.get_object("window_required_plugin") + lbl_header: Gtk.Label = Gtk.Template.Child() + lbl_message: Gtk.Label = Gtk.Template.Child() + btn_extra_link: Gtk.LinkButton = Gtk.Template.Child() - self.window.connect("close-request", self.on_window_delete) - builder.get_object("btn_close").connect("clicked", self.on_close_clicked) - builder.get_object("btn_disable_plugin").connect( - "clicked", self.on_disable_plugin_clicked - ) + def __init__( + self, + plugin_name: str, + message: typing.Union[str, PluginDependency], + on_quit: typing.Callable[[], None], + on_disable_plugin: typing.Callable[[], None], + application: Gtk.Application, + ): + super().__init__(application=application) - builder.get_object("lbl_header").set_label( - _("The required plugin '%s' is missing dependencies!") % _(plugin_name) - ) - - builder.get_object("lbl_main").set_label( - _( - "Please install the dependencies or disable the plugin. To hide this" - " message, you can also deactivate the plugin in the settings." - ) - ) + self.on_quit = on_quit + self.on_disable_plugin = on_disable_plugin - builder.get_object("btn_close").set_label(_("Quit")) - builder.get_object("btn_disable_plugin").set_label( - _("Disable plugin temporarily") + self.lbl_header.set_label( + _("The required plugin '%s' is missing dependencies!") % _(plugin_name) ) if isinstance(message, PluginDependency): - builder.get_object("lbl_message").set_label(_(message.message)) - btn_extra_link = builder.get_object("btn_extra_link") - btn_extra_link.set_label(_("Click here for more information")) - btn_extra_link.set_uri(message.link) - btn_extra_link.set_visible(True) + self.lbl_message.set_label(_(message.message)) + if message.link is not None: + self.btn_extra_link.set_uri(message.link) + self.btn_extra_link.set_visible(True) else: - builder.get_object("lbl_message").set_label(_(message)) + self.lbl_message.set_label(_(message)) - def show(self): + def show(self) -> None: """Show the dialog.""" - self.window.present() + self.present() - def on_window_delete(self, *args): + @Gtk.Template.Callback() + def on_window_delete(self, *args) -> None: """Window close event handler.""" - self.window.destroy() + self.destroy() self.on_quit() - def on_close_clicked(self, *args): - self.window.destroy() + @Gtk.Template.Callback() + def on_close_clicked(self, *args) -> None: + self.destroy() self.on_quit() - def on_disable_plugin_clicked(self, *args): - self.window.destroy() + @Gtk.Template.Callback() + def on_disable_plugin_clicked(self, *args) -> None: + self.destroy() self.on_disable_plugin() diff --git a/safeeyes/ui/settings_dialog.py b/safeeyes/ui/settings_dialog.py index 4f6e9cac..4db846ca 100644 --- a/safeeyes/ui/settings_dialog.py +++ b/safeeyes/ui/settings_dialog.py @@ -18,6 +18,7 @@ import math import os +import typing import gi from safeeyes import utility @@ -52,69 +53,59 @@ SETTINGS_ITEM_BOOL_GLADE = os.path.join(utility.BIN_DIRECTORY, "glade/item_bool.glade") -class SettingsDialog: +@Gtk.Template(filename=SETTINGS_DIALOG_GLADE) +class SettingsDialog(Gtk.ApplicationWindow): """Create and initialize SettingsDialog instance.""" - def __init__(self, application, config, on_save_settings): - self.application = application + __gtype_name__ = "SettingsDialog" + + box_short_breaks: Gtk.Box = Gtk.Template.Child() + box_long_breaks: Gtk.Box = Gtk.Template.Child() + box_plugins: Gtk.Box = Gtk.Template.Child() + popover: Gtk.MenuButton = Gtk.Template.Child() + + spin_short_break_duration: Gtk.SpinButton = Gtk.Template.Child() + spin_long_break_duration: Gtk.SpinButton = Gtk.Template.Child() + spin_short_break_interval: Gtk.SpinButton = Gtk.Template.Child() + spin_long_break_interval: Gtk.SpinButton = Gtk.Template.Child() + spin_time_to_prepare: Gtk.SpinButton = Gtk.Template.Child() + spin_postpone_duration: Gtk.SpinButton = Gtk.Template.Child() + dropdown_postpone_unit: Gtk.DropDown = Gtk.Template.Child() + spin_disable_keyboard_shortcut: Gtk.SpinButton = Gtk.Template.Child() + switch_strict_break: Gtk.Switch = Gtk.Template.Child() + switch_random_order: Gtk.Switch = Gtk.Template.Child() + switch_postpone: Gtk.Switch = Gtk.Template.Child() + switch_persist: Gtk.Switch = Gtk.Template.Child() + info_bar_long_break: Gtk.InfoBar = Gtk.Template.Child() + + plugin_items: dict[str, "PluginItem"] + plugin_map: dict[str, str] + config: Config + + def __init__( + self, + application: Gtk.Application, + config: Config, + on_save_settings: typing.Callable[[Config], None], + ): + super().__init__(application=application) + self.config = config self.on_save_settings = on_save_settings - self.plugin_switches = {} + self.plugin_items = {} self.plugin_map = {} self.last_short_break_interval = config.get("short_break_interval") self.initializing = True self.infobar_long_break_shown = False - builder = utility.create_gtk_builder(SETTINGS_DIALOG_GLADE) - - self.window = builder.get_object("window_settings") - self.window.set_application(application) - self.box_short_breaks = builder.get_object("box_short_breaks") - self.box_long_breaks = builder.get_object("box_long_breaks") - self.box_plugins = builder.get_object("box_plugins") - self.popover = builder.get_object("popover") - - self.spin_short_break_duration = builder.get_object("spin_short_break_duration") - self.spin_long_break_duration = builder.get_object("spin_long_break_duration") - self.spin_short_break_interval = builder.get_object("spin_short_break_interval") - self.spin_long_break_interval = builder.get_object("spin_long_break_interval") - self.spin_time_to_prepare = builder.get_object("spin_time_to_prepare") - self.spin_postpone_duration = builder.get_object("spin_postpone_duration") - self.dropdown_postpone_unit = builder.get_object("dropdown_postpone_unit") - self.spin_disable_keyboard_shortcut = builder.get_object( - "spin_disable_keyboard_shortcut" - ) - self.switch_strict_break = builder.get_object("switch_strict_break") - self.switch_random_order = builder.get_object("switch_random_order") - self.switch_postpone = builder.get_object("switch_postpone") - self.switch_persist = builder.get_object("switch_persist") - self.info_bar_long_break = builder.get_object("info_bar_long_break") self.info_bar_long_break.hide() - self.window.connect("close-request", self.on_window_delete) - builder.get_object("reset_menu").connect("clicked", self.on_reset_menu_clicked) - self.spin_short_break_interval.connect( - "value-changed", self.on_spin_short_break_interval_change - ) - self.info_bar_long_break.connect("close", self.on_info_bar_long_break_close) - self.info_bar_long_break.connect("response", self.on_info_bar_long_break_close) - self.spin_long_break_interval.connect( - "value-changed", self.on_spin_long_break_interval_change - ) - builder.get_object("btn_add_break").connect("clicked", self.add_break) - # Set the current values of input fields self.__initialize(config) - # Add event listener to postpone switch - self.switch_postpone.connect("state-set", self.on_switch_postpone_activate) - self.on_switch_postpone_activate( - self.switch_postpone, self.switch_postpone.get_active() - ) - self.initializing = False - def __initialize(self, config): + def __initialize(self, config: Config) -> None: # Don't show infobar for changes made internally self.infobar_long_break_shown = True for short_break in config.get("short_breaks"): @@ -145,43 +136,36 @@ def __initialize(self, config): self.switch_persist.set_active(config.get("persist_state")) self.infobar_long_break_shown = False - def __create_break_item(self, break_config, is_short): + def __create_break_item(self, break_config: dict, is_short: bool) -> None: """Create an entry for break to be listed in the break tab.""" parent_box = self.box_long_breaks if is_short: parent_box = self.box_short_breaks - builder = utility.create_gtk_builder(SETTINGS_BREAK_ITEM_GLADE) - box = builder.get_object("box") - lbl_name = builder.get_object("lbl_name") - lbl_name.set_label(_(break_config["name"])) - btn_properties = builder.get_object("btn_properties") - btn_properties.connect( - "clicked", - lambda button: self.__show_break_properties_dialog( + + box: "BreakItem" = BreakItem( + break_name=break_config["name"], + on_properties=lambda: self.__show_break_properties_dialog( break_config, is_short, self.config, - lambda cfg: lbl_name.set_label(_(cfg["name"])), - lambda is_short, break_config: self.__create_break_item( + on_close=lambda cfg: box.set_break_name(cfg["name"]), + on_add=lambda is_short, break_config: self.__create_break_item( break_config, is_short ), - lambda: parent_box.remove(box), + on_remove=lambda: parent_box.remove(box), ), - ) - btn_delete = builder.get_object("btn_delete") - btn_delete.connect( - "clicked", - lambda button: self.__delete_break( + on_delete=lambda: self.__delete_break( break_config, is_short, lambda: parent_box.remove(box), ), ) + box.set_visible(True) parent_box.append(box) - return box - def on_reset_menu_clicked(self, button): + @Gtk.Template.Callback() + def on_reset_menu_clicked(self, button: Gtk.Button) -> None: self.popover.hide() def __confirmation_dialog_response(dialog, result) -> None: @@ -207,16 +191,18 @@ def __confirmation_dialog_response(dialog, result) -> None: messagedialog.set_cancel_button(0) messagedialog.set_default_button(0) - messagedialog.choose(self.window, None, __confirmation_dialog_response) + messagedialog.choose(self, None, __confirmation_dialog_response) - def __clear_children(self, widget): - while widget.get_last_child() is not None: - widget.remove(widget.get_last_child()) + def __clear_children(self, widget: Gtk.Box) -> None: + while (child := widget.get_last_child()) is not None: + widget.remove(child) - def __delete_break(self, break_config, is_short, on_remove): + def __delete_break( + self, break_config: dict, is_short: bool, on_remove: typing.Callable[[], None] + ) -> None: """Remove the break after a confirmation.""" - def __confirmation_dialog_response(dialog, result): + def __confirmation_dialog_response(dialog, result) -> None: response_id = dialog.choose_finish(result) if response_id == 1: if is_short: @@ -234,74 +220,40 @@ def __confirmation_dialog_response(dialog, result): messagedialog.set_cancel_button(0) messagedialog.set_default_button(0) - messagedialog.choose(self.window, None, __confirmation_dialog_response) + messagedialog.choose(self, None, __confirmation_dialog_response) - def __create_plugin_item(self, plugin_config): + def __create_plugin_item(self, plugin_config: dict) -> "PluginItem": """Create an entry for plugin to be listed in the plugin tab.""" - builder = utility.create_gtk_builder(SETTINGS_PLUGIN_ITEM_GLADE) - lbl_plugin_name = builder.get_object("lbl_plugin_name") - lbl_plugin_description = builder.get_object("lbl_plugin_description") - switch_enable = builder.get_object("switch_enable") - btn_properties = builder.get_object("btn_properties") - lbl_plugin_name.set_label(_(plugin_config["meta"]["name"])) - switch_enable.set_active(plugin_config["enabled"]) - if plugin_config["error"]: - message = plugin_config["meta"]["dependency_description"] - if isinstance(message, PluginDependency): - lbl_plugin_description.set_label(_(message.message)) - btn_plugin_extra_link = builder.get_object("btn_plugin_extra_link") - btn_plugin_extra_link.set_label(_("Click here for more information")) - btn_plugin_extra_link.set_uri(message.link) - btn_plugin_extra_link.set_visible(True) - else: - lbl_plugin_description.set_label(_(message)) - lbl_plugin_name.set_sensitive(False) - lbl_plugin_description.set_sensitive(False) - switch_enable.set_sensitive(False) - btn_properties.set_sensitive(False) - if plugin_config["enabled"]: - btn_disable_errored = builder.get_object("btn_disable_errored") - btn_disable_errored.set_visible(True) - btn_disable_errored.connect( - "clicked", - lambda button: self.__disable_errored_plugin(button, plugin_config), - ) + box = PluginItem( + plugin_config, + on_properties=lambda: self.__show_plugins_properties_dialog(plugin_config), + ) + + self.plugin_items[plugin_config["id"]] = box - else: - lbl_plugin_description.set_label(_(plugin_config["meta"]["description"])) - if plugin_config["settings"]: - btn_properties.set_sensitive(True) - btn_properties.connect( - "clicked", - lambda button: self.__show_plugins_properties_dialog(plugin_config), - ) - else: - btn_properties.set_sensitive(False) - self.plugin_switches[plugin_config["id"]] = switch_enable if plugin_config.get("break_override_allowed", False): self.plugin_map[plugin_config["id"]] = plugin_config["meta"]["name"] - if plugin_config["icon"]: - builder.get_object("img_plugin_icon").set_from_file(plugin_config["icon"]) - box = builder.get_object("box") + box.set_visible(True) return box - def __show_plugins_properties_dialog(self, plugin_config): + def __show_plugins_properties_dialog(self, plugin_config: dict) -> None: """Show the PluginProperties dialog.""" - dialog = PluginSettingsDialog(self.application, plugin_config) + dialog = PluginSettingsDialog(self, plugin_config) dialog.show() - def __disable_errored_plugin(self, button, plugin_config): - """Permanently disable errored plugin.""" - button.set_sensitive(False) - self.plugin_switches[plugin_config["id"]].set_active(False) - def __show_break_properties_dialog( - self, break_config, is_short, parent, on_close, on_add, on_remove - ): + self, + break_config: dict, + is_short: bool, + parent: Config, + on_close: typing.Callable[[dict], None], + on_add: typing.Callable[[bool, dict], None], + on_remove: typing.Callable[[], None], + ) -> None: """Show the BreakProperties dialog.""" dialog = BreakSettingsDialog( - self.application, + self, break_config, is_short, parent, @@ -312,11 +264,12 @@ def __show_break_properties_dialog( ) dialog.show() - def show(self): + def show(self) -> None: """Show the SettingsDialog.""" - self.window.present() + self.present() - def on_switch_postpone_activate(self, switch, state): + @Gtk.Template.Callback() + def on_switch_postpone_activate(self, switch, state) -> None: """Event handler to the state change of the postpone switch. Enable or disable the self.spin_postpone_duration based on the @@ -325,7 +278,8 @@ def on_switch_postpone_activate(self, switch, state): self.spin_postpone_duration.set_sensitive(self.switch_postpone.get_active()) self.dropdown_postpone_unit.set_sensitive(self.switch_postpone.get_active()) - def on_spin_short_break_interval_change(self, spin_button, *value): + @Gtk.Template.Callback() + def on_spin_short_break_interval_change(self, spin_button, *value) -> None: """Event handler for value change of short break interval.""" short_break_interval = self.spin_short_break_interval.get_value_as_int() long_break_interval = self.spin_long_break_interval.get_value_as_int() @@ -342,20 +296,23 @@ def on_spin_short_break_interval_change(self, spin_button, *value): self.infobar_long_break_shown = True self.info_bar_long_break.show() - def on_spin_long_break_interval_change(self, spin_button, *value): + @Gtk.Template.Callback() + def on_spin_long_break_interval_change(self, spin_button, *value) -> None: """Event handler for value change of long break interval.""" if not self.initializing and not self.infobar_long_break_shown: self.infobar_long_break_shown = True self.info_bar_long_break.show() - def on_info_bar_long_break_close(self, infobar, *user_data): + @Gtk.Template.Callback() + def on_info_bar_long_break_close(self, infobar, *user_data) -> None: """Event handler for info bar close action.""" self.info_bar_long_break.hide() + @Gtk.Template.Callback() def add_break(self, button) -> None: """Event handler for add break button.""" dialog = NewBreakDialog( - self.application, + self, self.config, lambda is_short, break_config: self.__create_break_item( break_config, is_short @@ -363,7 +320,8 @@ def add_break(self, button) -> None: ) dialog.show() - def on_window_delete(self, *args): + @Gtk.Template.Callback() + def on_window_delete(self, *args) -> None: """Event handler for Settings dialog close action.""" self.config.set( "short_break_duration", self.spin_short_break_duration.get_value_as_int() @@ -385,7 +343,11 @@ def on_window_delete(self, *args): ) self.config.set( "postpone_unit", - self.dropdown_postpone_unit.get_selected_item().get_string(), + # the model is a GtkStringList - so get_selected_item will return a + # StringObject + typing.cast( + Gtk.StringObject, self.dropdown_postpone_unit.get_selected_item() + ).get_string(), ) self.config.set( "shortcut_disable_time", @@ -396,118 +358,240 @@ def on_window_delete(self, *args): self.config.set("allow_postpone", self.switch_postpone.get_active()) self.config.set("persist_state", self.switch_persist.get_active()) for plugin in self.config.get("plugins"): - if plugin["id"] in self.plugin_switches: - plugin["enabled"] = self.plugin_switches[plugin["id"]].get_active() + if plugin["id"] in self.plugin_items: + plugin["enabled"] = self.plugin_items[plugin["id"]].is_enabled() self.on_save_settings(self.config) # Call the provided save method - self.window.destroy() + self.destroy() + + +@Gtk.Template(filename=SETTINGS_BREAK_ITEM_GLADE) +class BreakItem(Gtk.Box): + __gtype_name__ = "BreakItem" + + lbl_name: Gtk.Label = Gtk.Template.Child() + + def __init__( + self, + break_name: str, + on_properties: typing.Callable[[], None], + on_delete: typing.Callable[[], None], + ): + super().__init__() + + self.on_properties = on_properties + self.on_delete = on_delete + + self.lbl_name.set_label(_(break_name)) + + def set_break_name(self, break_name: str) -> None: + self.lbl_name.set_label(_(break_name)) + + @Gtk.Template.Callback() + def on_properties_clicked(self, button) -> None: + self.on_properties() + @Gtk.Template.Callback() + def on_delete_clicked(self, button) -> None: + self.on_delete() -class PluginSettingsDialog: + +@Gtk.Template(filename=SETTINGS_PLUGIN_ITEM_GLADE) +class PluginItem(Gtk.Box): + __gtype_name__ = "PluginItem" + + lbl_plugin_name: Gtk.Label = Gtk.Template.Child() + lbl_plugin_description: Gtk.Label = Gtk.Template.Child() + switch_enable: Gtk.Switch = Gtk.Template.Child() + btn_properties: Gtk.Button = Gtk.Template.Child() + btn_disable_errored: Gtk.Button = Gtk.Template.Child() + btn_plugin_extra_link: Gtk.LinkButton = Gtk.Template.Child() + img_plugin_icon: Gtk.Image = Gtk.Template.Child() + + def __init__(self, plugin_config: dict, on_properties: typing.Callable[[], None]): + super().__init__() + + self.on_properties = on_properties + self.plugin_config = plugin_config + + self.lbl_plugin_name.set_label(_(plugin_config["meta"]["name"])) + self.switch_enable.set_active(plugin_config["enabled"]) + + if plugin_config["error"]: + message = plugin_config["meta"]["dependency_description"] + if isinstance(message, PluginDependency): + self.lbl_plugin_description.set_label(_(message.message)) + if message.link is not None: + self.btn_plugin_extra_link.set_uri(message.link) + self.btn_plugin_extra_link.set_visible(True) + else: + self.lbl_plugin_description.set_label(_(message)) + self.lbl_plugin_name.set_sensitive(False) + self.lbl_plugin_description.set_sensitive(False) + self.switch_enable.set_sensitive(False) + self.btn_properties.set_sensitive(False) + if plugin_config["enabled"]: + self.btn_disable_errored.set_visible(True) + else: + self.lbl_plugin_description.set_label( + _(plugin_config["meta"]["description"]) + ) + if plugin_config["settings"]: + self.btn_properties.set_sensitive(True) + else: + self.btn_properties.set_sensitive(False) + + if plugin_config["icon"]: + self.img_plugin_icon.set_from_file(plugin_config["icon"]) + + def is_enabled(self) -> bool: + return self.switch_enable.get_active() + + @Gtk.Template.Callback() + def on_disable_errored(self, button) -> None: + """Permanently disable errored plugin.""" + self.btn_disable_errored.set_sensitive(False) + self.switch_enable.set_active(False) + + @Gtk.Template.Callback() + def on_properties_clicked(self, button) -> None: + if not self.plugin_config["error"] and self.plugin_config["settings"]: + self.on_properties() + + +@Gtk.Template(filename=SETTINGS_ITEM_INT_GLADE) +class IntItem(Gtk.Box): + __gtype_name__ = "IntItem" + + lbl_name: Gtk.Label = Gtk.Template.Child() + spin_value: Gtk.SpinButton = Gtk.Template.Child() + + def __init__(self, name: str, value: float, min_value: float, max_value: float): + super().__init__() + + self.lbl_name.set_label(_(name)) + self.spin_value.set_range(min_value, max_value) + self.spin_value.set_value(value) + + def get_value(self) -> float: + return self.spin_value.get_value() + + +@Gtk.Template(filename=SETTINGS_ITEM_TEXT_GLADE) +class TextItem(Gtk.Box): + __gtype_name__ = "TextItem" + + lbl_name: Gtk.Label = Gtk.Template.Child() + txt_value: Gtk.Entry = Gtk.Template.Child() + + def __init__(self, name: str, value: str): + super().__init__() + + self.lbl_name.set_label(_(name)) + self.txt_value.set_text(value) + + def get_value(self) -> str: + return self.txt_value.get_text() + + +@Gtk.Template(filename=SETTINGS_ITEM_BOOL_GLADE) +class BoolItem(Gtk.Box): + __gtype_name__ = "BoolItem" + + lbl_name: Gtk.Label = Gtk.Template.Child() + switch_value: Gtk.Switch = Gtk.Template.Child() + + def __init__(self, name: str, value: bool): + super().__init__() + + self.lbl_name.set_label(_(name)) + self.switch_value.set_active(value) + + def get_value(self) -> bool: + return self.switch_value.get_active() + + +@Gtk.Template(filename=SETTINGS_DIALOG_PLUGIN_GLADE) +class PluginSettingsDialog(Gtk.Window): """Builds a settings dialog based on the configuration of a plugin.""" - def __init__(self, application, config): + __gtype_name__ = "PluginSettingsDialog" + + box_settings: Gtk.Box = Gtk.Template.Child() + + def __init__(self, parent: Gtk.Window, config: typing.Any): + super().__init__(transient_for=parent) + self.config = config self.property_controls = [] - builder = utility.create_gtk_builder(SETTINGS_DIALOG_PLUGIN_GLADE) - self.window = builder.get_object("dialog_settings_plugin") - self.window.set_application(application) - box_settings = builder.get_object("box_settings") - self.window.set_title(_("Plugin Settings")) for setting in config.get("settings"): + box: typing.Union[IntItem, BoolItem, TextItem] if setting["type"].upper() == "INT": - box_settings.append( - self.__load_int_item( - setting["label"], - setting["id"], - setting["safeeyes_config"], - setting.get("min", 0), - setting.get("max", 120), - ) + box = IntItem( + setting["label"], + config["active_plugin_config"][setting["id"]], + setting.get("min", 0), + setting.get("max", 120), ) elif setting["type"].upper() == "TEXT": - box_settings.append( - self.__load_text_item( - setting["label"], setting["id"], setting["safeeyes_config"] - ) + box = TextItem( + setting["label"], config["active_plugin_config"][setting["id"]] ) elif setting["type"].upper() == "BOOL": - box_settings.append( - self.__load_bool_item( - setting["label"], setting["id"], setting["safeeyes_config"] - ) + box = BoolItem( + setting["label"], config["active_plugin_config"][setting["id"]] ) + else: + continue - self.window.connect("close-request", self.on_window_delete) - - def __load_int_item(self, name, key, settings, min_value, max_value): - """Load the UI control for int property.""" - builder = utility.create_gtk_builder(SETTINGS_ITEM_INT_GLADE) - builder.get_object("lbl_name").set_label(_(name)) - spin_value = builder.get_object("spin_value") - spin_value.set_range(min_value, max_value) - spin_value.set_value(settings[key]) - box = builder.get_object("box") - box.set_visible(True) - self.property_controls.append( - {"key": key, "settings": settings, "value": spin_value.get_value} - ) - return box - - def __load_text_item(self, name, key, settings): - """Load the UI control for text property.""" - builder = utility.create_gtk_builder(SETTINGS_ITEM_TEXT_GLADE) - builder.get_object("lbl_name").set_label(_(name)) - txt_value = builder.get_object("txt_value") - txt_value.set_text(settings[key]) - box = builder.get_object("box") - box.set_visible(True) - self.property_controls.append( - {"key": key, "settings": settings, "value": txt_value.get_text} - ) - return box - - def __load_bool_item(self, name, key, settings): - """Load the UI control for boolean property.""" - builder = utility.create_gtk_builder(SETTINGS_ITEM_BOOL_GLADE) - builder.get_object("lbl_name").set_label(_(name)) - switch_value = builder.get_object("switch_value") - switch_value.set_active(settings[key]) - box = builder.get_object("box") - box.set_visible(True) - self.property_controls.append( - {"key": key, "settings": settings, "value": switch_value.get_active} - ) - return box + self.property_controls.append({"key": setting["id"], "box": box}) + self.box_settings.append(box) - def on_window_delete(self, *args): + @Gtk.Template.Callback() + def on_window_delete(self, *args) -> None: """Event handler for Properties dialog close action.""" for property_control in self.property_controls: - property_control["settings"][property_control["key"]] = property_control[ - "value" - ]() - self.window.destroy() + self.config["active_plugin_config"][property_control["key"]] = ( + property_control["box"].get_value() + ) + self.destroy() - def show(self): + def show(self) -> None: """Show the Properties dialog.""" - self.window.present() + self.present() -class BreakSettingsDialog: +@Gtk.Template(filename=SETTINGS_DIALOG_BREAK_GLADE) +class BreakSettingsDialog(Gtk.Window): """Builds a settings dialog based on the configuration of a plugin.""" + __gtype_name__ = "BreakSettingsDialog" + + txt_break: Gtk.Entry = Gtk.Template.Child() + switch_override_interval: Gtk.Switch = Gtk.Template.Child() + switch_override_duration: Gtk.Switch = Gtk.Template.Child() + switch_override_plugins: Gtk.Switch = Gtk.Template.Child() + spin_interval: Gtk.SpinButton = Gtk.Template.Child() + spin_duration: Gtk.SpinButton = Gtk.Template.Child() + btn_image: Gtk.Button = Gtk.Template.Child() + cmb_type: Gtk.ComboBox = Gtk.Template.Child() + grid_plugins: Gtk.Grid = Gtk.Template.Child() + lst_break_types: Gtk.ComboBox = Gtk.Template.Child() + def __init__( self, - application, - break_config, - is_short, - parent_config, - plugin_map, - on_close, - on_add, - on_remove, + parent: Gtk.Window, + break_config: dict, + is_short: bool, + parent_config: Config, + plugin_map: dict[str, str], + on_close: typing.Callable[[dict], None], + on_add: typing.Callable[[bool, dict], None], + on_remove: typing.Callable[[], None], ): + super().__init__(transient_for=parent) + self.break_config = break_config self.parent_config = parent_config self.plugin_check_buttons = {} @@ -516,34 +600,16 @@ def __init__( self.on_add = on_add self.on_remove = on_remove - builder = utility.create_gtk_builder(SETTINGS_DIALOG_BREAK_GLADE) - self.window = builder.get_object("dialog_settings_break") - self.window.set_application(application) - self.txt_break = builder.get_object("txt_break") - self.switch_override_interval = builder.get_object("switch_override_interval") - self.switch_override_duration = builder.get_object("switch_override_duration") - self.switch_override_plugins = builder.get_object("switch_override_plugins") - self.spin_interval = builder.get_object("spin_interval") - self.spin_duration = builder.get_object("spin_duration") - self.btn_image = builder.get_object("btn_image") - self.cmb_type = builder.get_object("cmb_type") - - grid_plugins = builder.get_object("grid_plugins") - list_types = builder.get_object("lst_break_types") - interval_overriden = break_config.get("interval", None) is not None duration_overriden = break_config.get("duration", None) is not None plugins_overriden = break_config.get("plugins", None) is not None # Set the values - self.window.set_title(_("Break Settings")) self.txt_break.set_text(_(break_config["name"])) self.switch_override_interval.set_active(interval_overriden) self.switch_override_duration.set_active(duration_overriden) self.switch_override_plugins.set_active(plugins_overriden) self.cmb_type.set_active(0 if is_short else 1) - list_types[0][0] = _(list_types[0][0]) - list_types[1][0] = _(list_types[1][0]) if interval_overriden: self.spin_interval.set_value(break_config["interval"]) @@ -565,7 +631,7 @@ def __init__( for plugin_id in plugin_map.keys(): chk_button = Gtk.CheckButton.new_with_label(_(plugin_map[plugin_id])) self.plugin_check_buttons[plugin_id] = chk_button - grid_plugins.attach(chk_button, row, col, 1, 1) + self.grid_plugins.attach(chk_button, row, col, 1, 1) if plugins_overriden: chk_button.set_active(plugin_id in break_config["plugins"]) else: @@ -582,18 +648,6 @@ def __init__( image = Gtk.Image.new_from_pixbuf(pixbuf) self.btn_image.set_child(image) - self.window.connect("close-request", self.on_window_delete) - self.btn_image.connect("clicked", self.select_image) - - self.switch_override_interval.connect( - "state-set", self.on_switch_override_interval_activate - ) - self.switch_override_duration.connect( - "state-set", self.on_switch_override_duration_activate - ) - self.switch_override_plugins.connect( - "state-set", self.on_switch_override_plugins_activate - ) self.on_switch_override_interval_activate( self.switch_override_interval, self.switch_override_interval.get_active() ) @@ -604,20 +658,24 @@ def __init__( self.switch_override_plugins, self.switch_override_plugins.get_active() ) - def on_switch_override_interval_activate(self, switch_button, state): + @Gtk.Template.Callback() + def on_switch_override_interval_activate(self, switch_button, state) -> None: """switch_override_interval state change event handler.""" self.spin_interval.set_sensitive(state) - def on_switch_override_duration_activate(self, switch_button, state): + @Gtk.Template.Callback() + def on_switch_override_duration_activate(self, switch_button, state) -> None: """switch_override_duration state change event handler.""" self.spin_duration.set_sensitive(state) - def on_switch_override_plugins_activate(self, switch_button, state): + @Gtk.Template.Callback() + def on_switch_override_plugins_activate(self, switch_button, state) -> None: """switch_override_plugins state change event handler.""" for chk_box in self.plugin_check_buttons.values(): chk_box.set_sensitive(state) - def select_image(self, button): + @Gtk.Template.Callback() + def select_image(self, button) -> None: """Show a file chooser dialog and let the user to select an image.""" dialog = Gtk.FileDialog() dialog.set_title(_("Please select an image")) @@ -630,9 +688,11 @@ def select_image(self, button): filters.append(png_filter) dialog.set_filters(filters) - dialog.open(self.window, None, self.select_image_callback) + dialog.open(self, None, self.select_image_callback) - def select_image_callback(self, dialog, result): + def select_image_callback( + self, dialog: Gtk.FileDialog, result: Gio.AsyncResult + ) -> None: response = None try: @@ -652,7 +712,8 @@ def select_image_callback(self, dialog, result): self.break_config.pop("image", None) self.btn_image.set_icon_name("gtk-missing-image") - def on_window_delete(self, *args): + @Gtk.Template.Callback() + def on_window_delete(self, *args) -> None: """Event handler for Properties dialog close action.""" break_name = self.txt_break.get_text().strip() if break_name: @@ -688,42 +749,40 @@ def on_window_delete(self, *args): self.on_add(not self.is_short, self.break_config) else: self.on_close(self.break_config) - self.window.destroy() + self.destroy() - def show(self): + def show(self) -> None: """Show the Properties dialog.""" - self.window.present() + self.present() -class NewBreakDialog: +@Gtk.Template(filename=SETTINGS_DIALOG_NEW_BREAK_GLADE) +class NewBreakDialog(Gtk.Window): """Builds a new break dialog.""" - def __init__(self, application, parent_config, on_add): - self.parent_config = parent_config - self.on_add = on_add + __gtype_name__ = "NewBreakDialog" - builder = utility.create_gtk_builder(SETTINGS_DIALOG_NEW_BREAK_GLADE) - self.window = builder.get_object("dialog_new_break") - self.window.set_application(application) - self.txt_break = builder.get_object("txt_break") - self.cmb_type = builder.get_object("cmb_type") - list_types = builder.get_object("lst_break_types") + txt_break: Gtk.Entry = Gtk.Template.Child() + cmb_type: Gtk.ComboBox = Gtk.Template.Child() - list_types[0][0] = _(list_types[0][0]) - list_types[1][0] = _(list_types[1][0]) - - self.window.connect("close-request", self.on_window_delete) - builder.get_object("btn_discard").connect("clicked", self.discard) - builder.get_object("btn_save").connect("clicked", self.save) + def __init__( + self, + parent: Gtk.Window, + parent_config: Config, + on_add: typing.Callable[[bool, dict], None], + ): + super().__init__(transient_for=parent) - # Set the values - self.window.set_title(_("New Break")) + self.parent_config = parent_config + self.on_add = on_add - def discard(self, button): + @Gtk.Template.Callback() + def discard(self, button) -> None: """Close the dialog.""" - self.window.destroy() + self.destroy() - def save(self, button): + @Gtk.Template.Callback() + def save(self, button) -> None: """Event handler for Properties dialog close action.""" break_config = {"name": self.txt_break.get_text().strip()} @@ -733,12 +792,13 @@ def save(self, button): else: self.parent_config.get("long_breaks").append(break_config) self.on_add(False, break_config) - self.window.destroy() + self.destroy() - def on_window_delete(self, *args): + @Gtk.Template.Callback() + def on_window_delete(self, *args) -> None: """Event handler for dialog close action.""" - self.window.destroy() + self.destroy() - def show(self): + def show(self) -> None: """Show the Properties dialog.""" - self.window.present() + self.present() diff --git a/safeeyes/utility.py b/safeeyes/utility.py index c8908438..32b6a18f 100644 --- a/safeeyes/utility.py +++ b/safeeyes/utility.py @@ -259,8 +259,8 @@ def load_plugins_config(safeeyes_config): config["id"] = plugin["id"] config["icon"] = icon config["enabled"] = plugin["enabled"] - for setting in config["settings"]: - setting["safeeyes_config"] = plugin["settings"] + config["active_plugin_config"] = plugin.get("settings") + configs.append(config) return configs @@ -713,26 +713,6 @@ def open_session(): return session -def create_gtk_builder(glade_file): - """Create a Gtk builder and load the glade file.""" - from safeeyes.translations import translate as _ - - builder = Gtk.Builder() - builder.set_translation_domain("safeeyes") - builder.add_from_file(glade_file) - # Tranlslate all sub components - for obj in builder.get_objects(): - if hasattr(obj, "get_label"): - label = obj.get_label() - if label is not None: - obj.set_label(_(label)) - elif hasattr(obj, "get_title"): - title = obj.get_title() - if title is not None: - obj.set_title(_(title)) - return builder - - def load_and_scale_image( path: str, width: int, height: int ) -> typing.Optional[Gtk.Image]: