Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
3ed6324
settings: dont duplicate the active plugin config per setting
deltragon Aug 7, 2024
946aef4
settings: dialogs: set transient_for instead of application
deltragon Aug 21, 2025
4e5622b
Merge pull request #744 from deltragon/ui-misc-fixes
deltragon Aug 22, 2025
8cbcc7b
refactor: settings: split plugin item into separate class
deltragon Aug 7, 2024
51a1e00
refactor: settings: split break item into separate class
deltragon Aug 7, 2024
b267c26
settings: plugin settings items: split out common code
deltragon Aug 21, 2025
1699f3b
settings: refactor plugin settings items into classes
deltragon Aug 7, 2024
f5e76f3
break screen: split window instances into separate class
deltragon Aug 7, 2024
dd473be
Merge pull request #745 from deltragon/ui-split-classes
deltragon Aug 22, 2025
74db826
about dialog: migrate from Gtk.Builder to Gtk.Template
deltragon Jul 15, 2024
aab4f57
gtk template: required plugin dialog
deltragon Aug 4, 2024
a26fedf
gtk template: settings dialog (main window)
deltragon Aug 4, 2024
73eb8b0
gtk template: settings dialog (subdialogs)
deltragon Aug 7, 2024
b424091
gtk template: settings - plugin and break items
deltragon Aug 7, 2024
aa50b70
gtk template: settings - plugin settings items
deltragon Aug 7, 2024
ef748f4
gtk template: break screen
deltragon Aug 7, 2024
8deda22
about dialog: add types
deltragon Apr 24, 2025
ec87407
required plugin dialog: add types
deltragon Aug 18, 2025
feaa1ee
settings: add types
deltragon Aug 18, 2025
8bc91d0
break screen: add types
deltragon Aug 20, 2025
b885db0
remove unused create_gtk_builder helper
deltragon Aug 8, 2024
e1ddb74
Merge pull request #746 from deltragon/gtk4-template
deltragon Aug 22, 2025
513cc59
typing: add Context to gradually type
deltragon Jun 4, 2025
d375a4c
add context to core and model
deltragon Jun 4, 2025
b7adbf0
typing: context: add to break screen
deltragon Aug 21, 2025
d545223
typing: add Context to smartpause plugin
deltragon Jun 4, 2025
738b536
Merge pull request #747 from deltragon/typing-context
deltragon Aug 22, 2025
6fb6d61
trayicon: remove unused context arg
deltragon Aug 22, 2025
e9521b2
settings: xdg activation
deltragon Aug 22, 2025
051670e
about dialog: xdg activation
deltragon Aug 22, 2025
14c5601
add types, remove unused global
deltragon Aug 22, 2025
785db28
Merge pull request #748 from deltragon/xdg-activation
deltragon Aug 22, 2025
d702cb6
break screen: x11 keyboard locking: move threading
deltragon Aug 22, 2025
8ee1667
move another main thread call to the context/api
deltragon Aug 22, 2025
1a08010
break screen: remove now unneeded idle calls
deltragon Aug 22, 2025
3a626cb
trayicon: switch from thread to timer
deltragon Aug 22, 2025
6833bd5
Merge pull request #749 from deltragon/thread-fixes
deltragon Aug 22, 2025
c397e7c
update version to 3.0.0b4
deltragon Aug 22, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions debian/changelog
Original file line number Diff line number Diff line change
@@ -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 <[email protected]> Fri, 22 Aug 2025 11:30:00 +0000

safeeyes (3.0.0b3) noble; urgency=medium

* Re-release due to broken github action
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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"
Expand Down
137 changes: 137 additions & 0 deletions safeeyes/context.py
Original file line number Diff line number Diff line change
@@ -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 <[email protected]>

# 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 <http://www.gnu.org/licenses/>.

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)
51 changes: 25 additions & 26 deletions safeeyes/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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

Expand All @@ -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 <time-to-prepare> for a break
self.on_pre_break = EventHook()
Expand All @@ -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."""
Expand Down Expand Up @@ -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."""
Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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:
Expand Down
8 changes: 5 additions & 3 deletions safeeyes/glade/about_dialog.glade
Original file line number Diff line number Diff line change
Expand Up @@ -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 &lt;https://www.gnu.org/licenses/&gt;.</property>
</object>
<object class="GtkWindow" id="window_about">
<template parent="GtkApplicationWindow" class="AboutDialog">
<property name="title">Safe Eyes</property>
<property name="resizable">0</property>
<property name="icon-name">io.github.slgobinath.SafeEyes</property>
<signal name="close-request" handler="on_window_delete" swapped="no" />
<child>
<object class="GtkBox" id="layout_box">
<property name="visible">1</property>
Expand All @@ -63,7 +64,7 @@ along with this program. If not, see &lt;https://www.gnu.org/licenses/&gt;.</pr
<property name="valign">center</property>
<property name="margin-top">10</property>
<property name="margin-bottom">10</property>
<property name="label">Safe Eyes 3.0.0b3</property>
<property name="label">Safe Eyes 3.0.0b4</property>
<property name="justify">center</property>
<property name="hexpand">1</property>
<property name="vexpand">1</property>
Expand Down Expand Up @@ -144,6 +145,7 @@ along with this program. If not, see &lt;https://www.gnu.org/licenses/&gt;.</pr
</child>
<child>
<object class="GtkButton" id="btn_close">
<signal name="clicked" handler="on_close_clicked" swapped="no" />
<property name="label" translatable="yes">Close</property>
<property name="visible">1</property>
<property name="can-focus">1</property>
Expand All @@ -168,5 +170,5 @@ along with this program. If not, see &lt;https://www.gnu.org/licenses/&gt;.</pr
</child>
</object>
</child>
</object>
</template>
</interface>
Loading