feat: add apex077/eyecare eye-care reminders plugin (#41)
This commit is contained in:
@@ -0,0 +1,56 @@
|
|||||||
|
# Eye-Care Reminders
|
||||||
|
|
||||||
|
Periodically reminds you to take breaks using the 20-20-20 rule to reduce eye strain.
|
||||||
|
|
||||||
|
## Plugin
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| ID | `apex077/eyecare` |
|
||||||
|
| Entries | Bar widget: `eyecare-widget`; service: `eyecare-service` |
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- `dbus-monitor` on `PATH` (optional, for automatic system idle and screensaver lock detection).
|
||||||
|
- A system audio player on `PATH` (e.g., `canberra-gtk-play`, `paplay`, `pw-play`, or `aplay`) to hear sound cues.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
The **Eye-Care Reminders** plugin provides a status bar widget that displays either the active time countdown or the break duration.
|
||||||
|
|
||||||
|
- **Active State**: Shows the remaining active screen time (e.g., `20:00`). Clicking the widget manually starts a break.
|
||||||
|
- **Break State**: Shows the remaining break duration (e.g., `Break: 20s`). Look 20 feet away at an object during this time. Clicking the widget aborts the break early.
|
||||||
|
- **Idle State**: Automatically pauses active timer accumulation and resets it if the user remains idle for the duration of a break.
|
||||||
|
- **Right-Click**: Resets the active/break timer back to its initial state.
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
| Setting | Type | Default | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `active_duration_minutes` | `int` | `20` | Active screen time before triggering a break (minutes) |
|
||||||
|
| `break_duration_seconds` | `int` | `20` | Required duration for eye-care breaks (seconds) |
|
||||||
|
| `enable_sound` | `bool` | `true` | Play notification sound when breaks start or finish |
|
||||||
|
| `enable_notifications` | `bool` | `true` | Show system-level notifications for reminders |
|
||||||
|
|
||||||
|
## IPC
|
||||||
|
|
||||||
|
The service listens for compositor idle events. You can configure compositor hooks to notify the service when the system goes idle or resumes:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
noctalia msg plugin apex077/eyecare:eyecare-service all idled
|
||||||
|
noctalia msg plugin apex077/eyecare:eyecare-service all active
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also send custom trigger/reset events to the service:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
noctalia msg plugin apex077/eyecare:eyecare-service all trigger-break
|
||||||
|
noctalia msg plugin apex077/eyecare:eyecare-service all finish-break
|
||||||
|
noctalia msg plugin apex077/eyecare:eyecare-service all reset
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- **Zero-Config Idle Detection**: If `dbus-monitor` is installed, the service automatically monitors screensaver and login lock session state without manual compositor configuration.
|
||||||
|
- **Sound Players**: Sound notifications automatically try `canberra-gtk-play`, `paplay`, `pw-play`, and `aplay` to play system sounds.
|
||||||
|
- **Grace Period**: When starting a break, a grace period (default 10 seconds or the break duration, whichever is smaller) protects against accidental inputs aborting the break immediately.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
id = "apex077/eyecare"
|
||||||
|
name = "Eye-Care Reminders"
|
||||||
|
version = "1.0.0"
|
||||||
|
plugin_api = 3
|
||||||
|
author = "Apex077"
|
||||||
|
license = "MIT"
|
||||||
|
dependencies = ["dbus-monitor", "canberra-gtk-play", "paplay", "pw-play", "aplay"]
|
||||||
|
icon = "eye"
|
||||||
|
description = "Periodically reminds you to take breaks using the 20-20-20 rule."
|
||||||
|
tags = ["service", "bar", "utility", "productivity"]
|
||||||
|
|
||||||
|
[[service]]
|
||||||
|
id = "eyecare-service"
|
||||||
|
entry = "service.luau"
|
||||||
|
|
||||||
|
[[widget]]
|
||||||
|
id = "eyecare-widget"
|
||||||
|
entry = "widget.luau"
|
||||||
|
|
||||||
|
# Global settings shared by service and widget
|
||||||
|
[[setting]]
|
||||||
|
key = "active_duration_minutes"
|
||||||
|
type = "int"
|
||||||
|
label_key = "settings.eyecare-active-duration.label"
|
||||||
|
description_key = "settings.eyecare-active-duration.description"
|
||||||
|
default = 20
|
||||||
|
min = 1
|
||||||
|
max = 180
|
||||||
|
|
||||||
|
[[setting]]
|
||||||
|
key = "break_duration_seconds"
|
||||||
|
type = "int"
|
||||||
|
label_key = "settings.eyecare-break-duration.label"
|
||||||
|
description_key = "settings.eyecare-break-duration.description"
|
||||||
|
default = 20
|
||||||
|
min = 10
|
||||||
|
max = 300
|
||||||
|
|
||||||
|
[[setting]]
|
||||||
|
key = "enable_sound"
|
||||||
|
type = "bool"
|
||||||
|
label_key = "settings.eyecare-sound.label"
|
||||||
|
description_key = "settings.eyecare-sound.description"
|
||||||
|
default = true
|
||||||
|
|
||||||
|
[[setting]]
|
||||||
|
key = "enable_notifications"
|
||||||
|
type = "bool"
|
||||||
|
label_key = "settings.eyecare-notifications.label"
|
||||||
|
description_key = "settings.eyecare-notifications.description"
|
||||||
|
default = true
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
--!nonstrict
|
||||||
|
|
||||||
|
local isSystemIdle = false
|
||||||
|
local activeTime = 0
|
||||||
|
local idleTime = 0
|
||||||
|
local breakTimer = 0
|
||||||
|
local breakElapsed = 0
|
||||||
|
local inBreak = false
|
||||||
|
local lastLineWasActiveChanged = false
|
||||||
|
|
||||||
|
-- Get setting values
|
||||||
|
local function getSettings()
|
||||||
|
return {
|
||||||
|
active_duration_minutes = tonumber(noctalia.getConfig("active_duration_minutes")) or 20,
|
||||||
|
break_duration_seconds = tonumber(noctalia.getConfig("break_duration_seconds")) or 20,
|
||||||
|
enable_sound = noctalia.getConfig("enable_sound") ~= false,
|
||||||
|
enable_notifications = noctalia.getConfig("enable_notifications") ~= false
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local settings = getSettings()
|
||||||
|
|
||||||
|
-- Support onConfigChanged to update settings in-place
|
||||||
|
function onConfigChanged()
|
||||||
|
settings = getSettings()
|
||||||
|
noctalia.log("eyecare: settings updated")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Play a notification sound using system players
|
||||||
|
local function playSound(soundName)
|
||||||
|
local path = "/usr/share/sounds/freedesktop/stereo/" .. soundName .. ".oga"
|
||||||
|
noctalia.runAsync("canberra-gtk-play -i " .. soundName .. " || paplay " .. path .. " || pw-play " .. path .. " || aplay " .. path .. " || true")
|
||||||
|
end
|
||||||
|
|
||||||
|
local lastLoggedIdle = nil
|
||||||
|
|
||||||
|
-- Update in-memory state shared with widget
|
||||||
|
local function updateState()
|
||||||
|
if isSystemIdle ~= lastLoggedIdle then
|
||||||
|
lastLoggedIdle = isSystemIdle
|
||||||
|
noctalia.log("eyecare: system idle state changed to " .. tostring(isSystemIdle))
|
||||||
|
end
|
||||||
|
|
||||||
|
noctalia.state.set("eyecare-in-break", inBreak)
|
||||||
|
noctalia.state.set("eyecare-active-time", activeTime)
|
||||||
|
noctalia.state.set("eyecare-break-timer", breakTimer)
|
||||||
|
noctalia.state.set("eyecare-break-elapsed", breakElapsed)
|
||||||
|
noctalia.state.set("eyecare-idle-time", idleTime)
|
||||||
|
noctalia.state.set("eyecare-is-system-idle", isSystemIdle)
|
||||||
|
noctalia.state.set("eyecare-active-duration-minutes", settings.active_duration_minutes)
|
||||||
|
noctalia.state.set("eyecare-break-duration-seconds", settings.break_duration_seconds)
|
||||||
|
end
|
||||||
|
|
||||||
|
local wasSystemIdle = false
|
||||||
|
|
||||||
|
-- Tick cycle actions (every 1 second)
|
||||||
|
function update()
|
||||||
|
noctalia.setUpdateInterval(1000)
|
||||||
|
|
||||||
|
-- Detect transition from idle to active (unlock/wake up)
|
||||||
|
local transitionedToActive = (wasSystemIdle and not isSystemIdle)
|
||||||
|
wasSystemIdle = isSystemIdle
|
||||||
|
|
||||||
|
if inBreak then
|
||||||
|
breakTimer = breakTimer + 1
|
||||||
|
breakElapsed = breakTimer -- Keep breakElapsed in sync for backward compatibility
|
||||||
|
|
||||||
|
if transitionedToActive then
|
||||||
|
local grace_threshold = math.min(10, settings.break_duration_seconds)
|
||||||
|
if breakTimer >= grace_threshold then
|
||||||
|
-- User was away for at least the grace threshold and now returned,
|
||||||
|
-- so consider the break completed/accepted.
|
||||||
|
inBreak = false
|
||||||
|
breakTimer = 0
|
||||||
|
breakElapsed = 0
|
||||||
|
activeTime = 0
|
||||||
|
else
|
||||||
|
-- User returned almost immediately, abort/cancel the break
|
||||||
|
inBreak = false
|
||||||
|
breakTimer = 0
|
||||||
|
breakElapsed = 0
|
||||||
|
activeTime = 0
|
||||||
|
end
|
||||||
|
elseif breakTimer >= settings.break_duration_seconds then
|
||||||
|
-- Break completed successfully
|
||||||
|
inBreak = false
|
||||||
|
breakTimer = 0
|
||||||
|
breakElapsed = 0
|
||||||
|
activeTime = 0
|
||||||
|
|
||||||
|
if settings.enable_notifications then
|
||||||
|
noctalia.notify(
|
||||||
|
noctalia.tr("notifications.eyecare-break-finished-title"),
|
||||||
|
noctalia.tr("notifications.eyecare-break-finished-body")
|
||||||
|
)
|
||||||
|
end
|
||||||
|
if settings.enable_sound then
|
||||||
|
playSound("complete")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
-- Not in break
|
||||||
|
if isSystemIdle then
|
||||||
|
idleTime = idleTime + 1
|
||||||
|
-- Natural break detection (user rested without prompting)
|
||||||
|
if idleTime >= settings.break_duration_seconds then
|
||||||
|
activeTime = 0
|
||||||
|
end
|
||||||
|
else
|
||||||
|
idleTime = 0
|
||||||
|
activeTime = activeTime + 1
|
||||||
|
if activeTime >= settings.active_duration_minutes * 60 then
|
||||||
|
-- Trigger Break Reminder
|
||||||
|
inBreak = true
|
||||||
|
breakTimer = 0
|
||||||
|
breakElapsed = 0
|
||||||
|
activeTime = 0
|
||||||
|
|
||||||
|
if settings.enable_notifications then
|
||||||
|
noctalia.notify(
|
||||||
|
noctalia.tr("notifications.eyecare-break-title"),
|
||||||
|
noctalia.tr("notifications.eyecare-break-body")
|
||||||
|
)
|
||||||
|
end
|
||||||
|
if settings.enable_sound then
|
||||||
|
playSound("message")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
updateState()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Helper functions for timer state transitions
|
||||||
|
local function triggerBreak()
|
||||||
|
inBreak = true
|
||||||
|
breakTimer = 0
|
||||||
|
breakElapsed = 0
|
||||||
|
activeTime = 0
|
||||||
|
|
||||||
|
if settings.enable_notifications then
|
||||||
|
noctalia.notify(
|
||||||
|
noctalia.tr("notifications.eyecare-break-title"),
|
||||||
|
noctalia.tr("notifications.eyecare-break-body")
|
||||||
|
)
|
||||||
|
end
|
||||||
|
if settings.enable_sound then
|
||||||
|
playSound("message")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function abortBreak()
|
||||||
|
inBreak = false
|
||||||
|
breakTimer = 0
|
||||||
|
breakElapsed = 0
|
||||||
|
activeTime = 0
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resetTimer()
|
||||||
|
inBreak = false
|
||||||
|
breakTimer = 0
|
||||||
|
breakElapsed = 0
|
||||||
|
activeTime = 0
|
||||||
|
idleTime = 0
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Listen to compositor IPC commands
|
||||||
|
function onIpc(event, payload)
|
||||||
|
if event == "idled" then
|
||||||
|
isSystemIdle = true
|
||||||
|
elseif event == "active" then
|
||||||
|
isSystemIdle = false
|
||||||
|
elseif event == "trigger-break" then
|
||||||
|
triggerBreak()
|
||||||
|
elseif event == "finish-break" or event == "abort-break" then
|
||||||
|
abortBreak()
|
||||||
|
elseif event == "reset" then
|
||||||
|
resetTimer()
|
||||||
|
end
|
||||||
|
updateState()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Watch widget-triggered requests in-process
|
||||||
|
noctalia.state.watch("eyecare-request", function(req)
|
||||||
|
if req == "trigger-break" then
|
||||||
|
triggerBreak()
|
||||||
|
noctalia.state.set("eyecare-request", nil)
|
||||||
|
elseif req == "abort-break" then
|
||||||
|
abortBreak()
|
||||||
|
noctalia.state.set("eyecare-request", nil)
|
||||||
|
elseif req == "reset" then
|
||||||
|
resetTimer()
|
||||||
|
noctalia.state.set("eyecare-request", nil)
|
||||||
|
end
|
||||||
|
updateState()
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- Initialize automatic DBus monitor stream hooks (zero-config fallback)
|
||||||
|
if noctalia.commandExists("dbus-monitor") then
|
||||||
|
noctalia.log("eyecare: dbus-monitor found, starting automatic screensaver & lock streams")
|
||||||
|
|
||||||
|
local sessionLoop = 'P=$PPID; while kill -0 "$P" 2>/dev/null; do dbus-monitor --session "interface=\'org.freedesktop.ScreenSaver\'" 2>/dev/null; sleep 3; done'
|
||||||
|
noctalia.runStream(sessionLoop, function(line)
|
||||||
|
if string.find(line, "ActiveChanged") then
|
||||||
|
lastLineWasActiveChanged = true
|
||||||
|
elseif lastLineWasActiveChanged then
|
||||||
|
lastLineWasActiveChanged = false
|
||||||
|
if string.find(line, "true") then
|
||||||
|
isSystemIdle = true
|
||||||
|
updateState()
|
||||||
|
elseif string.find(line, "false") then
|
||||||
|
isSystemIdle = false
|
||||||
|
updateState()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
local systemLoop = 'P=$PPID; while kill -0 "$P" 2>/dev/null; do dbus-monitor --system "interface=\'org.freedesktop.login1.Session\'" 2>/dev/null; sleep 3; done'
|
||||||
|
noctalia.runStream(systemLoop, function(line)
|
||||||
|
if string.find(line, "member=Lock") then
|
||||||
|
isSystemIdle = true
|
||||||
|
updateState()
|
||||||
|
elseif string.find(line, "member=Unlock") then
|
||||||
|
isSystemIdle = false
|
||||||
|
updateState()
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
else
|
||||||
|
noctalia.log("eyecare: dbus-monitor not found on system path")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Initialize the shared state
|
||||||
|
updateState()
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"settings.eyecare-active-duration.label": "Active Duration",
|
||||||
|
"settings.eyecare-active-duration.description": "Active screen time before triggering a break (minutes)",
|
||||||
|
"settings.eyecare-break-duration.label": "Break Duration",
|
||||||
|
"settings.eyecare-break-duration.description": "Required duration for eye-care breaks (seconds)",
|
||||||
|
"settings.eyecare-sound.label": "Enable Audio Feedback",
|
||||||
|
"settings.eyecare-sound.description": "Play notification sound when breaks start or finish",
|
||||||
|
"settings.eyecare-notifications.label": "Enable Notifications",
|
||||||
|
"settings.eyecare-notifications.description": "Show system-level notifications for reminders",
|
||||||
|
|
||||||
|
"notifications.eyecare-break-title": "Time for a break",
|
||||||
|
"notifications.eyecare-break-body": "Focus on an object 20 feet away for 20 seconds.",
|
||||||
|
"notifications.eyecare-break-finished-title": "Break finished",
|
||||||
|
"notifications.eyecare-break-finished-body": "Your eyes are rested. You can return to your screen."
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
--!nonstrict
|
||||||
|
|
||||||
|
local inBreak = false
|
||||||
|
local activeTime = 0
|
||||||
|
local breakTimer = 0
|
||||||
|
local breakElapsed = 0
|
||||||
|
local idleTime = 0
|
||||||
|
local isSystemIdle = false
|
||||||
|
local activeDurationMinutes = 20
|
||||||
|
local breakDurationSeconds = 20
|
||||||
|
|
||||||
|
local function formatTime(seconds)
|
||||||
|
local mins = math.floor(seconds / 60)
|
||||||
|
local secs = seconds % 60
|
||||||
|
return string.format("%02d:%02d", mins, secs)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function render()
|
||||||
|
if isSystemIdle then
|
||||||
|
barWidget.setGlyph("coffee")
|
||||||
|
barWidget.setText(string.format("Idle: %ds", idleTime))
|
||||||
|
barWidget.setTooltip("System is idle. Resting eyes...")
|
||||||
|
barWidget.setGlyphColor("#26a69a") -- Teal
|
||||||
|
barWidget.setColor("#ffffff")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if inBreak then
|
||||||
|
barWidget.setGlyph("hourglass")
|
||||||
|
local remaining = math.max(0, breakDurationSeconds - breakTimer)
|
||||||
|
barWidget.setText(string.format("Break: %ds", remaining))
|
||||||
|
barWidget.setTooltip(string.format("Break in progress! Look 20ft away.\nTime remaining: %ds\nClick to abort early\nRight-click to reset", remaining))
|
||||||
|
barWidget.setGlyphColor("#ff5252") -- Soft Red
|
||||||
|
barWidget.setColor("#ff5252")
|
||||||
|
else
|
||||||
|
barWidget.setGlyph("eye")
|
||||||
|
local activeLimit = activeDurationMinutes * 60
|
||||||
|
local remaining = math.max(0, activeLimit - activeTime)
|
||||||
|
barWidget.setText(formatTime(remaining))
|
||||||
|
barWidget.setTooltip(string.format("Eye-Care timer active.\nTime remaining: %s\nClick to start break manually\nRight-click to reset", formatTime(remaining)))
|
||||||
|
barWidget.setGlyphColor("#00e676") -- Vibrant Green
|
||||||
|
barWidget.setColor("#ffffff")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Watch state updates
|
||||||
|
noctalia.state.watch("eyecare-in-break", function(val)
|
||||||
|
inBreak = val == true
|
||||||
|
render()
|
||||||
|
end)
|
||||||
|
|
||||||
|
noctalia.state.watch("eyecare-active-time", function(val)
|
||||||
|
activeTime = tonumber(val) or 0
|
||||||
|
render()
|
||||||
|
end)
|
||||||
|
|
||||||
|
noctalia.state.watch("eyecare-break-timer", function(val)
|
||||||
|
breakTimer = tonumber(val) or 0
|
||||||
|
render()
|
||||||
|
end)
|
||||||
|
|
||||||
|
noctalia.state.watch("eyecare-break-elapsed", function(val)
|
||||||
|
breakElapsed = tonumber(val) or 0
|
||||||
|
render()
|
||||||
|
end)
|
||||||
|
|
||||||
|
noctalia.state.watch("eyecare-idle-time", function(val)
|
||||||
|
idleTime = tonumber(val) or 0
|
||||||
|
render()
|
||||||
|
end)
|
||||||
|
|
||||||
|
noctalia.state.watch("eyecare-is-system-idle", function(val)
|
||||||
|
isSystemIdle = val == true
|
||||||
|
render()
|
||||||
|
end)
|
||||||
|
|
||||||
|
noctalia.state.watch("eyecare-active-duration-minutes", function(val)
|
||||||
|
activeDurationMinutes = tonumber(val) or 20
|
||||||
|
render()
|
||||||
|
end)
|
||||||
|
|
||||||
|
noctalia.state.watch("eyecare-break-duration-seconds", function(val)
|
||||||
|
breakDurationSeconds = tonumber(val) or 20
|
||||||
|
render()
|
||||||
|
end)
|
||||||
|
|
||||||
|
function onClick()
|
||||||
|
if inBreak then
|
||||||
|
noctalia.state.set("eyecare-request", "abort-break")
|
||||||
|
else
|
||||||
|
noctalia.state.set("eyecare-request", "trigger-break")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function onRightClick()
|
||||||
|
noctalia.state.set("eyecare-request", "reset")
|
||||||
|
noctalia.notify("Eye-Care", "Timer has been reset.")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Initial render
|
||||||
|
render()
|
||||||
Reference in New Issue
Block a user