diff --git a/eyecare/README.md b/eyecare/README.md new file mode 100644 index 0000000..e9be2ac --- /dev/null +++ b/eyecare/README.md @@ -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. diff --git a/eyecare/plugin.toml b/eyecare/plugin.toml new file mode 100644 index 0000000..9130670 --- /dev/null +++ b/eyecare/plugin.toml @@ -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 diff --git a/eyecare/service.luau b/eyecare/service.luau new file mode 100644 index 0000000..f5cf4ee --- /dev/null +++ b/eyecare/service.luau @@ -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() diff --git a/eyecare/thumbnail.webp b/eyecare/thumbnail.webp new file mode 100644 index 0000000..d8f0aa3 Binary files /dev/null and b/eyecare/thumbnail.webp differ diff --git a/eyecare/translations/en.json b/eyecare/translations/en.json new file mode 100644 index 0000000..58a3bc4 --- /dev/null +++ b/eyecare/translations/en.json @@ -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." +} diff --git a/eyecare/widget.luau b/eyecare/widget.luau new file mode 100644 index 0000000..c8ab940 --- /dev/null +++ b/eyecare/widget.luau @@ -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()