// Copyright 2024 GNOME Foundation, Inc. // // This is a GNOME Shell component to support break reminders and screen time // statistics. // // Licensed under the GNU General Public License Version 2 // // 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 2 // 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, write to the Free Software // Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. // // SPDX-License-Identifier: GPL-2.0-or-later import Clutter from 'gi://Clutter'; import Cogl from 'gi://Cogl'; import Gio from 'gi://Gio'; import GLib from 'gi://GLib'; import GObject from 'gi://GObject'; import Shell from 'gi://Shell'; import St from 'gi://St'; import * as Gettext from 'gettext'; import * as Main from '../ui/main.js'; import * as MessageTray from '../ui/messageTray.js'; import * as SystemActions from './systemActions.js'; export const MIN_BREAK_LENGTH_SECONDS = 10; const BREAK_OVERDUE_TIME_SECONDS = 60; // time after a break is due when the user is notified it’s overdue const BREAK_UPCOMING_NOTIFICATION_TIME_SECONDS = [2 * 60]; // notify the user 2min before a break is due; this must be kept in descending order const BREAK_COUNTDOWN_TIME_SECONDS = 60; const BRIGHTNESS_FADE_TIME_SECONDS = 3; const BRIGHTNESS_FACTOR = 0.6; /** @enum {number} */ const IdleState = { /* session is active */ ACTIVE: 1, /* session is idle */ IDLE: 2, }; /** @enum {number} */ /* This enum is exposed over D-Bus (/org/gnome/Shell/ScreenTime) so is * effectively public API */ export const BreakState = { /* break reminders are disabled */ DISABLED: 0, /* break reminders are enabled, user is active, no break is needed yet */ ACTIVE: 1, /* user is idle, but no break is needed */ IDLE: 2, /* a break is needed and the user is taking it */ IN_BREAK: 3, /* a break is needed but the user is still active */ BREAK_DUE: 4, }; /* The code supports whatever break types the user wants. However, they each * need to be backed by a org.gnome.desktop.break-reminders.* child schema, and * those have to be pre-defined because they all have different default values. * So we need to validate the break types to prevent GSettings complaining about * non-installed schemas. * * So this needs to mirror the child schemas defined in * `schemas/org.gnome.desktop.break-reminders.gschema.xml.in` in * gsettings-desktop-schemas. */ const SUPPORTED_BREAK_TYPES = [ 'eyesight', 'movement', ]; /** * Return a string form of `breakState`. * * @param {int} breakState The break state. * @returns {string} A string form of `breakState`. */ export function breakStateToString(breakState) { return Object.keys(BreakState).find(k => BreakState[k] === breakState); } /** * A manager class which tracks total active/inactive time and signals when the * user needs to take a break (according to their break reminder preferences). */ export const BreakManager = GObject.registerClass({ Properties: { 'state': GObject.ParamSpec.int( 'state', null, null, GObject.ParamFlags.READABLE, BreakState.DISABLED, BreakState.BREAK_DUE, BreakState.DISABLED), 'last-break-end-time': GObject.ParamSpec.uint64( 'last-break-end-time', null, null, GObject.ParamFlags.READABLE, 0, GLib.MAX_UINT64, 0), 'next-break-due-time': GObject.ParamSpec.uint64( 'next-break-due-time', null, null, GObject.ParamFlags.READABLE, 0, GLib.MAX_UINT64, 0), }, Signals: { 'break-due': {}, 'break-finished': {}, 'take-break': {}, }, }, class BreakManager extends GObject.Object { constructor(clock, idleMonitor, settingsFactory) { super(); // Allow these two bits of global state to be overridden for unit testing this._idleMonitor = idleMonitor ?? global.backend.get_core_idle_monitor(); this._clock = clock ?? { sourceRemove: GLib.source_remove, getRealTimeSecs: () => { return GLib.get_real_time() / GLib.USEC_PER_SEC; }, timeoutAddSeconds: GLib.timeout_add_seconds, }; this._settingsFactory = settingsFactory ?? { new: Gio.Settings.new, newWithPath: Gio.Settings.new_with_path, }; this._breakSettings = this._settingsFactory.new('org.gnome.desktop.break-reminders'); this._breakSettings.connect('changed', () => this._updateSettings()); this._state = BreakState.DISABLED; this._breakTypeSettings = new Map(); // map of breakType to GSettings this._breakTypeSettingsChangedId = new Map(); this._breakLastEnd = new Map(); // map of breakType to wall clock time (in seconds) this._idleWatchId = 0; this._activeWatchId = 0; this._timerId = 0; // Start tracking timings this._updateSettings(); } _updateSettings() { // Load settings for each enabled type of break const selectedBreaks = this._breakSettings.get_strv('selected-breaks'); if (!selectedBreaks || !selectedBreaks.length) { this._stopStateMachine(); return false; } const currentTime = this.getCurrentTime(); for (const breakType of SUPPORTED_BREAK_TYPES) { if (selectedBreaks.includes(breakType) && !this._breakTypeSettings.has(breakType)) { // Enabling a previously disabled break const breakSettings = this._settingsFactory.newWithPath( `org.gnome.desktop.break-reminders.${breakType}`, `${this._breakSettings.path}${breakType}/`); this._breakTypeSettings.set(breakType, breakSettings); this._breakTypeSettingsChangedId.set(breakType, breakSettings.connect('changed', () => this._updateState(this.getCurrentTime()))); this._breakLastEnd.set(breakType, currentTime); } else if (!selectedBreaks.includes(breakType) && this._breakTypeSettings.has(breakType)) { // Disabling a previously enabled break const breakSettings = this._breakTypeSettings.get(breakType); breakSettings.disconnect(this._breakTypeSettingsChangedId.get(breakType)); this._breakTypeSettings.delete(breakType); this._breakTypeSettingsChangedId.delete(breakType); this._breakLastEnd.delete(breakType); } } this.notify('next-break-due-time'); if (this._state === BreakState.DISABLED) this._startStateMachine(); return true; } _startStateMachine() { this._idleWatchId = this._idleMonitor.add_idle_watch(MIN_BREAK_LENGTH_SECONDS * 1000, this._onIdleWatch.bind(this)); this._state = BreakState.ACTIVE; this._currentBreakType = null; this._currentBreakStartTime = 0; this._idleState = IdleState.ACTIVE; this._idleStartTime = 0; this._updateState(this.getCurrentTime()); } _stopStateMachine() { if (this._idleWatchId !== 0) this._idleMonitor.remove_watch(this._idleWatchId); this._idleWatchId = 0; if (this._activeWatchId !== 0) this._idleMonitor.remove_watch(this._activeWatchId); this._activeWatchId = 0; if (this._timerId !== 0) this._clock.sourceRemove(this._timerId); this._timerId = 0; for (const [breakType, breakSettings] of this._breakTypeSettings) breakSettings.disconnect(this._breakTypeSettingsChangedId.get(breakType)); this._breakTypeSettings = new Map(); this._breakTypeSettingsChangedId = new Map(); this._breakLastEnd = new Map(); this._state = BreakState.DISABLED; this._currentBreakType = null; this._currentBreakStartTime = 0; this._idleState = IdleState.ACTIVE; this._idleStartTime = 0; this.notify('state'); } _onIdleWatch() { console.assert(this._state !== BreakState.DISABLED, 'Idle received when manager is disabled'); const currentTime = this.getCurrentTime(); console.debug(`BreakManager: _onIdleWatch at ${currentTime}s`); // Start watching to see if the user becomes active again. if (this._activeWatchId === 0) this._activeWatchId = this._idleMonitor.add_user_active_watch(this._onUserActiveWatch.bind(this)); this._idleState = IdleState.IDLE; this._idleStartTime = currentTime - MIN_BREAK_LENGTH_SECONDS; /* already waited MIN_BREAK_LENGTH_SECONDS for the idle watch */ this._updateState(currentTime); } _onUserActiveWatch() { console.assert(this._state !== BreakState.DISABLED, 'Active received when manager is disabled'); const currentTime = this.getCurrentTime(); console.debug(`BreakManager: _onUserActiveWatch at ${currentTime}s`); // Tidy up after the active watch, which is one-shot. // (The idle watch stays installed until explicitly removed.) this._idleMonitor.remove_watch(this._activeWatchId); this._activeWatchId = 0; this._idleState = IdleState.ACTIVE; this._updateState(currentTime); } /** * Get the current real time, in seconds since the Unix epoch. * * @returns {number} */ getCurrentTime() { return this._clock.getRealTimeSecs(); } _updateState(currentTime) { const idleTimeSeconds = currentTime - this._idleStartTime; if (this._idleState === IdleState.IDLE) { // What kind of break are we due, if any? const [dueBreakType, nextDueTime] = this.getNextBreakDue(currentTime); let inBreak = false; let dueBreakStartTime = 0; if (dueBreakType != null && nextDueTime <= currentTime) { const dueBreakTypeDuration = this._breakTypeSettings.get(dueBreakType).get_uint('duration-seconds'); // Has the break been finished? if (nextDueTime + dueBreakTypeDuration <= currentTime) { if (this._state === BreakState.IN_BREAK) this.emit('break-finished'); } else { // Start a timer to announce the end of the break. inBreak = true; dueBreakStartTime = nextDueTime; this._scheduleUpdateState(nextDueTime + dueBreakTypeDuration - currentTime); } } else if (nextDueTime !== 0) { // The user is idle but is not due a break. Store up the idle time // and update the break end times when the user next becomes active. // But also schedule a timer to notify the user about the start of // their scheduled break. console.assert(nextDueTime > currentTime, `nextDueTime (${nextDueTime}) should be greater than currentTime (${currentTime})`); this._scheduleUpdateState(nextDueTime - currentTime); } const newState = inBreak ? BreakState.IN_BREAK : BreakState.IDLE; if (this._state !== newState) { console.debug(`BreakManager: Changing state to ${breakStateToString(newState)}`); this._state = newState; this._currentBreakType = dueBreakType; this._currentBreakStartTime = dueBreakStartTime; this.notify('state'); } } else if (this._idleState === IdleState.ACTIVE) { let emitBreakDue = false; this.freeze_notify(); // How long was the user idle before becoming active? Use that to reset the break end times. // Reset every break type which is shorter than the idle time. This allows // the user to start a break a little early, or take an unexpected break, // and avoid their computer pestering them to take a break on schedule // shortly after they get back. console.debug(`BreakManager: idleStartTime: ${this._idleStartTime}s`); if (this._idleStartTime > 0) { for (const [breakType, breakTypeSettings] of this._breakTypeSettings) { const breakTypeDuration = breakTypeSettings.get_uint('duration-seconds'); if (idleTimeSeconds >= breakTypeDuration) this._breakLastEnd.set(breakType, currentTime); } this.notify('next-break-due-time'); this.notify('last-break-end-time'); } // Reset the idle start time state. this._idleStartTime = 0; // Are any breaks due now? const [dueBreakType, nextDueTime] = this.getNextBreakDue(currentTime); const isBreakDue = dueBreakType != null && nextDueTime <= currentTime; console.debug(`BreakManager: nextDueTime: ${nextDueTime}s, currentTime: ${currentTime}s, dueBreakType: ${dueBreakType}`); if (isBreakDue) { // Notify that a break is due if we haven’t done so already. if (this._state === BreakState.ACTIVE) { this._state = BreakState.BREAK_DUE; this._currentBreakType = dueBreakType; this._currentBreakStartTime = nextDueTime; this.notify('state'); emitBreakDue = true; } } else { if (nextDueTime !== 0) { // Set a timer for when the next break is due. console.assert(nextDueTime > currentTime, `nextDueTime (${nextDueTime}) should be greater than currentTime (${currentTime})`); this._scheduleUpdateState(nextDueTime - currentTime); } if (this._state !== BreakState.ACTIVE) { console.debug('BreakManager: Changing state to ACTIVE'); this._state = BreakState.ACTIVE; this._currentBreakType = null; this._currentBreakStartTime = 0; this.notify('state'); } } this.thaw_notify(); if (emitBreakDue) this.emit('break-due'); } } _scheduleUpdateState(timeout) { if (this._timerId !== 0) this._clock.sourceRemove(this._timerId); // Round up to avoid spinning const timeoutSeconds = Math.ceil(timeout); console.debug(`BreakManager: Scheduling state update in ${timeoutSeconds}s`); this._timerId = this._clock.timeoutAddSeconds(GLib.PRIORITY_DEFAULT, timeoutSeconds, () => { this._timerId = 0; console.debug('BreakManager: Scheduled state update'); this._updateState(this.getCurrentTime()); return GLib.SOURCE_REMOVE; }); } /** * Get a tuple of information about the break type which is currently due or * which will be due next, relative to `fromTime`. * 1. The type of break which is currently due or which will be due next, * or `null` if no break is due. * 2. The time when the next break is due; if the first member of the tuple * is non-null, then this is the time when that break was due. This is * zero if no break types are enabled. * * @param {number} fromTime ‘Current’ time to calculate from * @returns {Array} Two-element array of {?string} break type and {number} next due time */ getNextBreakDue(fromTime) { let maxDuration = 0; let maxDurationType = null; let dueBreakTypes = []; let nextDueTime = 0; console.debug(`BreakManager: Current time: ${fromTime}s`); for (const [breakType, breakTypeSettings] of this._breakTypeSettings) { const breakTypeInterval = breakTypeSettings.get_uint('interval-seconds'); const breakTypeDuration = breakTypeSettings.get_uint('duration-seconds'); // Work out which break types are now due. const breakTypeWouldBeDueAt = this._breakLastEnd.get(breakType) + breakTypeInterval; const breakTypeIsDue = breakTypeWouldBeDueAt <= fromTime; console.debug(`BreakManager: Break type ${breakType}: would be due at ${breakTypeWouldBeDueAt}`); if (breakTypeIsDue) { dueBreakTypes.push(breakType); nextDueTime = breakTypeWouldBeDueAt; // Of the break types which are now due, which has the longest // duration? if (maxDuration === 0 || breakTypeDuration > maxDuration) { maxDuration = breakTypeDuration; maxDurationType = breakType; } } else if (nextDueTime === 0 || nextDueTime > breakTypeWouldBeDueAt) { // This break type isn’t due now, but when is it due? nextDueTime = breakTypeWouldBeDueAt; maxDurationType = breakType; } } console.debug(`BreakManager: Due break types: ${dueBreakTypes.length ? dueBreakTypes : '(none)'}, max duration type: ${maxDurationType}, next due time: ${nextDueTime}`); // maxDurationType is null and nextDueTime is zero if no break types are enabled return [maxDurationType, nextDueTime]; } /** * Current state machine state. * * @type {BreakState} */ get state() { return this._state; } /** * String identifier for the break type which is currently in progress or * due. * * Returns `null` if no break is currently due or happening. * * @type {?string} */ get currentBreakType() { return this._currentBreakType; } /** * Start time for the break which is currently in progress , or `0` if no * break is in progress. * * If the user was idle before the break started, the start time will be * during the idle period, not the start of the idle period. * * @type {number} */ get currentBreakStartTime() { if (this.state !== BreakState.IN_BREAK) return 0; return this._currentBreakStartTime; } /** * End time for the most recent break, or `0` if a break is currently in * progress or no breaks have happened yet. * * @type {number} */ get lastBreakEndTime() { if (this.state === BreakState.IN_BREAK) return 0; return Math.max(0, ...this._breakLastEnd.values()); } /** * Get the time when the next break is due. If a break is currently due, * then this is the time when that break was due. This is zero if no break * types are enabled. * * @param {number} fromTime ‘Current’ time to calculate from * @returns {number} */ getNextBreakDueTime(fromTime) { const [, nextDueTime] = this.getNextBreakDue(fromTime); return nextDueTime; } /** * Time when the next break is due. This is the result of calling * `getNextBreakDueTime()` with the current time. * * @type {number} */ get nextBreakDueTime() { const currentTime = this.getCurrentTime(); return this.getNextBreakDueTime(currentTime); } /** * Delays the currently upcoming, due or in-progress break. * * This is a no-op if no break is currently due or in progress. */ delayBreak() { if (this.state === BreakState.DISABLED) return; const currentTime = this.getCurrentTime(); console.debug('BreakManager: Delaying current break'); // Increment all the last break end times for any break types which are // currently due by the delay. We do this over all break types, rather // than just the current break type, because multiple break types might // be due at the same time, and it would be a bit annoying to delay one // only for another break type to immediately become due. for (const [breakType, breakTypeSettings] of this._breakTypeSettings) { const breakTypeInterval = breakTypeSettings.get_uint('interval-seconds'); const delaySecs = breakTypeSettings.get_uint('delay-seconds'); if (this._breakLastEnd.get(breakType) + breakTypeInterval <= currentTime) this._breakLastEnd.set(breakType, this._breakLastEnd.get(breakType) + delaySecs); } this.freeze_notify(); this.notify('next-break-due-time'); this.notify('last-break-end-time'); this._updateState(currentTime); this.thaw_notify(); } /** * Skips the currently upcoming, due or in-progress break. * * This is a no-op if no break is currently due or in progress. */ skipBreak() { if (this.state === BreakState.DISABLED) return; const currentTime = this.getCurrentTime(); console.debug('BreakManager: Skipping current break'); // Set all the last break end times for any break types which are // currently due to now. We do this over all break types, rather than // just the current break type, because multiple break types might be // due at the same time, and it would be a bit annoying to skip one only // for another break type to immediately become due. for (const [breakType, breakTypeSettings] of this._breakTypeSettings) { const breakTypeInterval = breakTypeSettings.get_uint('interval-seconds'); if (this._breakLastEnd.get(breakType) + breakTypeInterval <= currentTime) this._breakLastEnd.set(breakType, currentTime); } this.freeze_notify(); this.notify('next-break-due-time'); this.notify('last-break-end-time'); this._updateState(currentTime); this.thaw_notify(); } /** * Signals that the user explicitly wants to start taking a break now, even * if they are technically still active. */ takeBreak() { // We can’t force the user to be idle, but we can indicate to the break // dispatcher that it should try and make the user be idle. this.emit('take-break'); } /** * Whether the given breakType should emit notification popups to the user. * * @param {string} breakType * @returns {boolean} */ breakTypeShouldNotify(breakType) { if (!this._breakTypeSettings.has(breakType)) return false; return this._breakTypeSettings.get(breakType).get_boolean('notify'); } /** * Whether the given breakType should emit notification popups to the user * when it’s upcoming. * * @param {string} breakType * @returns {boolean} */ breakTypeShouldNotifyUpcoming(breakType) { if (!this._breakTypeSettings.has(breakType)) return false; return this._breakTypeSettings.get(breakType).get_boolean('notify-upcoming'); } /** * Whether the given breakType should emit notification popups to the user * if it’s overdue. * * @param {string} breakType * @returns {boolean} */ breakTypeShouldNotifyOverdue(breakType) { if (!this._breakTypeSettings.has(breakType)) return false; return this._breakTypeSettings.get(breakType).get_boolean('notify-overdue'); } /** * Whether the given breakType should show a prominent countdown for the * last 60s before it’s due. * * @param {string} breakType * @returns {boolean} */ breakTypeShouldCountdown(breakType) { if (!this._breakTypeSettings.has(breakType)) return false; return this._breakTypeSettings.get(breakType).get_boolean('countdown'); } /** * Whether the given breakType should play sounds to notify the user. * * @param {string} breakType * @returns {boolean} */ breakTypeShouldPlaySound(breakType) { if (!this._breakTypeSettings.has(breakType)) return false; return this._breakTypeSettings.get(breakType).get_boolean('play-sound'); } /** * Whether the given breakType should fade the screen during breaks. * * @param {string} breakType * @returns {boolean} */ breakTypeShouldFadeScreen(breakType) { if (!this._breakTypeSettings.has(breakType)) return false; return this._breakTypeSettings.get(breakType).get_boolean('fade-screen'); } /** * Whether the given breakType should lock the screen during breaks. * * @param {string} breakType * @returns {boolean} */ breakTypeShouldLockScreen(breakType) { if (!this._breakTypeSettings.has(breakType)) return false; return this._breakTypeSettings.get(breakType).get_boolean('lock-screen'); } /** * Duration (in seconds) of the given breakType. * * @param {string} breakType * @returns {number} */ getDurationForBreakType(breakType) { if (!this._breakTypeSettings.has(breakType)) return 0; return this._breakTypeSettings.get(breakType).get_uint('duration-seconds'); } }); /** * Glue class which takes the state-based output from BreakManager and converts * it to event-based notifications/sounds/screen fades for the user to tell them * when to take breaks. It factors the user’s UI preferences into account. */ export const BreakDispatcher = GObject.registerClass( class BreakDispatcher extends GObject.Object { constructor(manager) { super(); this._manager = manager; this._previousState = BreakState.DISABLED; this._previousBreakType = null; this._manager.connectObject( 'take-break', this._onTakeBreak.bind(this), 'notify::state', this._onStateChanged.bind(this), 'notify::next-break-due-time', this._onStateChanged.bind(this), this); this._systemActions = SystemActions.getDefault(); this._notificationSource = null; this._brightnessEffect = null; this._countdownOsd = null; this._countdownTimerId = 0; if (this._manager.state === BreakState.DISABLED) this._ensureDisabled(); else this._ensureEnabled(); } destroy() { this._ensureDisabled(); this._manager.disconnectObject(this); this._manager = null; } _ensureEnabled() { if (this._notificationSource === null) this._notificationSource = new BreakNotificationSource(this._manager); if (this._brightnessEffect === null) { this._brightnessEffect = new Clutter.BrightnessContrastEffect({name: 'brightness'}); this._brightnessEffect.set_enabled(false); Main.layoutManager.uiGroup.add_effect(this._brightnessEffect); Main.layoutManager.uiGroup.connect('destroy', () => (this._brightnessEffect = null)); } } _ensureDisabled() { this._notificationSource?.destroy(); this._notificationSource = null; if (this._brightnessEffect !== null) Main.layoutManager.uiGroup.remove_effect(this._brightnessEffect); this._brightnessEffect = null; this._removeCountdown(); this._previousState = BreakState.DISABLED; this._previousBreakType = null; } _maybePlayCompleteSound() { if (this._manager.breakTypeShouldPlaySound(this._previousBreakType)) { const player = global.display.get_sound_player(); player.play_from_theme('complete', _('Break complete sound'), null); } } _removeCountdown() { if (this._countdownTimerId !== 0) GLib.source_remove(this._countdownTimerId); this._countdownTimerId = 0; this._countdownOsd?.destroy(); this._countdownOsd = null; } _onStateChanged() { switch (this._manager.state) { case BreakState.DISABLED: this._ensureDisabled(); break; case BreakState.ACTIVE: { this._ensureEnabled(); if (this._previousState === BreakState.IN_BREAK) this._maybePlayCompleteSound(); this._brightnessEffect.set_enabled(false); // Work out when the next break is due, and schedule a countdown. const currentTime = this._manager.getCurrentTime(); const [nextBreakType, nextBreakDueTime] = this._manager.getNextBreakDue(currentTime); if (this._manager.breakTypeShouldCountdown(nextBreakType)) { const dueInSeconds = nextBreakDueTime - currentTime; const countdownStart = Math.max(dueInSeconds - BREAK_COUNTDOWN_TIME_SECONDS, 0); console.debug(`BreakDispatcher: Scheduling break countdown to start in ${countdownStart}s`); if (this._countdownTimerId !== 0) GLib.source_remove(this._countdownTimerId); this._countdownTimerId = GLib.timeout_add_seconds( GLib.PRIORITY_DEFAULT, countdownStart, () => { if (this._countdownOsd == null) { this._countdownOsd = new OsdBreakCountdownLabel(this._manager); this._countdownOsd.connect('destroy', () => (this._countdownOsd = null)); } this._countdownTimerId = 0; return GLib.SOURCE_REMOVE; }); } break; } case BreakState.IDLE: { this._ensureEnabled(); if (this._previousState === BreakState.IN_BREAK) this._maybePlayCompleteSound(); this._brightnessEffect.set_enabled(false); break; } case BreakState.IN_BREAK: { this._ensureEnabled(); if (this._manager.breakTypeShouldLockScreen(this._manager.currentBreakType) && this._previousState !== BreakState.IN_BREAK) this._systemActions.activateLockScreen(); else if (this._manager.breakTypeShouldFadeScreen(this._manager.currentBreakType)) this._brightnessEffectOn(); this._removeCountdown(); break; } case BreakState.BREAK_DUE: { this._ensureEnabled(); this._removeCountdown(); break; } default: console.assert(false, `Unknown BreakManager state: ${this._manager.state}`); break; } this._previousState = this._manager.state; this._previousBreakType = this._manager.currentBreakType; } _onTakeBreak() { if (this._manager.breakTypeShouldLockScreen(this._manager.currentBreakType)) this._systemActions.activateLockScreen(); else if (this._manager.breakTypeShouldFadeScreen(this._manager.currentBreakType)) this._brightnessEffectOn(); } _brightnessEffectOn() { // the effect value is in the range [0, 255] with 127 as the ‘no change’ mid-point const startVal = 127; const startColor = new Cogl.Color({ red: startVal, green: startVal, blue: startVal, alpha: 255, }); const finishVal = 127 * BRIGHTNESS_FACTOR; const finishColor = new Cogl.Color({ red: finishVal, green: finishVal, blue: finishVal, alpha: 255, }); this._brightnessEffect.brightness = startColor; this._brightnessEffect.set_enabled(true); Main.layoutManager.uiGroup.ease_property( '@effects.brightness.brightness', finishColor, { duration: BRIGHTNESS_FADE_TIME_SECONDS * 1000, mode: Clutter.AnimationMode.EASE_OUT_QUAD, }); } }); /* This can’t directly extend MessageTray.Source (even though it conceptually * does) because the Shell will automatically destroy a MessageTray.Source when * it becomes empty, and that destroys the state which we need for correctly * showing the next notification (e.g. on a timer). * * So instead this is a normal GObject which closely wraps a MessageTray.Source. */ const BreakNotificationSource = GObject.registerClass( class BreakNotificationSource extends GObject.Object { constructor(manager) { super(); this._app = Shell.AppSystem.get_default().lookup_app('gnome-wellbeing-panel.desktop'); this._source = null; this._notification = null; this._timerId = 0; this._manager = manager; this._manager.connectObject( 'notify::state', this._onStateChanged.bind(this), 'notify::next-break-due-time', this._onStateChanged.bind(this), this); this._previousState = BreakState.DISABLED; this._previousBreakType = null; this._updateState(); } destroy() { this._notification?.destroy(); this._notification = null; this._source?.destroy(); this._source = null; if (this._timerId !== 0) GLib.source_remove(this._timerId); this._timerId = 0; this._manager?.disconnectObject(this); this._manager = null; this.run_dispose(); } _ensureNotification(params) { if (!this._source) { this._source = new MessageTray.Source({ title: this._app.get_name(), icon: this._app.get_icon(), policy: MessageTray.NotificationPolicy.newForApp(this._app), }); this._source.connect('destroy', () => (this._source = null)); Main.messageTray.add(this._source); } if (this._notification === null) { this._notification = new BreakNotification(this._source, this._manager, this._app); this._notification.connect('destroy', () => (this._notification = null)); } // Unacknowledge the notification when it’s updated, by default. this._notification.set({acknowledged: false, ...params}); } /** * Formats the given time span as a translated string including units. * Returns a tuple of: * 1. The translated string * 2. The number which determines whether the string is plural, for use * with `ngettext()` calls to embed the returned string into (as they * will need plural handling). * 3. The number of seconds until the string would change if re-generated, * for use in setting a timer to update a notification. * * @param {number} secondsAgo * * @returns {Array} */ _formatTimeSpan(secondsAgo) { const minutesAgo = secondsAgo / 60; const hoursAgo = minutesAgo / 60; const daysAgo = hoursAgo / 24; if (secondsAgo < 60) { return [Gettext.ngettext( '%d second', '%d seconds', secondsAgo ).format(secondsAgo), secondsAgo, 1]; } else if (minutesAgo < 60) { return [Gettext.ngettext( '%d minute', '%d minutes', minutesAgo ).format(minutesAgo), minutesAgo, 60 - (secondsAgo % 60)]; } else if (hoursAgo < 24) { return [Gettext.ngettext( '%d hour', '%d hours', hoursAgo ).format(hoursAgo), hoursAgo, (60 - (minutesAgo % 60)) * 60]; } else { return [Gettext.ngettext( '%d day', '%d days', daysAgo ).format(daysAgo), daysAgo, (24 - (hoursAgo % 24)) * 60 * 60]; } } _onStateChanged() { this._updateState(); this._previousState = this._manager.state; this._previousBreakType = this._manager.currentBreakType; } _urgencyForBreakType(breakType) { console.assert(breakType != null, 'null breakType'); // While the preferences indicate that certain break types should notify // and others shouldn’t, that’s not really possible: we only have one // notification which gets constantly updated with information about // breaks, interleaving messages from multiple breaks over time. Having // it disappear and reappear for different break types would be jarring. // Instead, set the urgency to LOW for break types which shouldn’t // notify. This means the notification remains, but is not presented to // the user — it’s just visible if they expand the message tray. if (this._manager.breakTypeShouldNotify(breakType)) return MessageTray.Urgency.HIGH; else return MessageTray.Urgency.LOW; } _scheduleUpdateState(timeout) { if (this._timerId !== 0) GLib.source_remove(this._timerId); // Round up to avoid spinning const timeoutSeconds = Math.ceil(timeout); console.debug(`BreakNotificationSource: Scheduling notification state update in ${timeoutSeconds}s`); this._timerId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, timeoutSeconds, () => { this._timerId = 0; console.debug('BreakNotificationSource: Scheduled notification state update'); this._updateState(); return GLib.SOURCE_REMOVE; }); } _updateState() { const currentTime = this._manager.getCurrentTime(); console.debug(`BreakNotificationSource: Break notification source state changed from ${breakStateToString(this._previousState)} to ${breakStateToString(this._manager.state)}`); switch (this._manager.state) { case BreakState.DISABLED: this.destroy(); break; case BreakState.ACTIVE: { if (this._previousState === BreakState.IN_BREAK) { // Break is complete. this._notification?.destroy(MessageTray.NotificationDestroyedReason.SOURCE_CLOSED); } else { // Work out when the next break is due, and display some warnings // that it’s impending. const [nextBreakType, nextBreakDueTime] = this._manager.getNextBreakDue(currentTime); const remainingSecs = nextBreakDueTime - currentTime; console.debug(`BreakNotificationSource: ${remainingSecs}s left before next break`); if (this._manager.breakTypeShouldNotifyUpcoming(nextBreakType)) { for (const notificationTime of BREAK_UPCOMING_NOTIFICATION_TIME_SECONDS) { console.debug(`BreakNotificationSource: Considering upcoming notification ${notificationTime}s`); if (remainingSecs > notificationTime) { // Schedule to show the first (longest) notification // which is less than the remaining time before the break. this._scheduleUpdateState(remainingSecs - notificationTime); break; } else if (Math.ceil(remainingSecs) === notificationTime) { // Bang on time to show this notification. const [remainingText, remainingValue, unused] = this._formatTimeSpan(remainingSecs); const bodyText = Gettext.ngettext( /* %s will be replaced with a string that describes a time interval, such as “2 minutes”, “40 seconds” or “1 hour” */ 'It will be time for a break in %s', 'It will be time for a break in %s', remainingValue ).format(remainingText); let titleText; switch (nextBreakType) { case 'movement': titleText = _('Movement Break Soon'); break; case 'eyesight': titleText = _('Eyesight Break Soon'); break; default: titleText = _('Break Soon'); break; } this._ensureNotification({ title: titleText, body: bodyText, sound: null, allowDelay: true, allowSkip: true, allowTake: false, }); this._source.addNotification(this._notification); // Continue to the next iteration, to schedule an update // for the next-biggest notification. continue; } } } } break; } case BreakState.IDLE: { // Do nothing. break; } case BreakState.IN_BREAK: { // Clamp to a minimum of 1s because saying “you have 0 seconds left” // is a bit odd. The state machine may remain in // `BreakState.IN_BREAK` even after the break duration is over, // because the user might remain idle. In that case, display no // further notifications until the user becomes active again — at // that point they get a ‘Break is over’ notification. const breakDurationRemaining = this._manager.getDurationForBreakType(this._manager.currentBreakType) - (currentTime - this._manager.currentBreakStartTime); if (breakDurationRemaining >= 1) { const [remainingText, remainingValue, updateTimeoutSeconds] = this._formatTimeSpan(breakDurationRemaining); let titleText, bodyText; switch (this._manager.currentBreakType) { case 'movement': titleText = _('Movement Break in Progress'); bodyText = Gettext.ngettext( /* %s will be replaced with a string that describes a time interval, such as “2 minutes”, “40 seconds” or “1 hour” */ 'Continue moving around for %s', 'Continue moving around for %s', remainingValue ).format(remainingText); break; case 'eyesight': titleText = _('Eyesight Break in Progress'); bodyText = Gettext.ngettext( /* %s will be replaced with a string that describes a time interval, such as “2 minutes”, “40 seconds” or “1 hour” */ 'Continue looking away for %s', 'Continue looking away for %s', remainingValue ).format(remainingText); break; default: titleText = _('Break in Progress'); bodyText = Gettext.ngettext( /* %s will be replaced with a string that describes a time interval, such as “2 minutes”, “40 seconds” or “1 hour” */ '%s left in your break', '%s left in your break', remainingValue ).format(remainingText); break; } this._ensureNotification({ title: titleText, body: bodyText, sound: null, urgency: this._urgencyForBreakType(this._manager.currentBreakType), allowDelay: false, allowSkip: false, allowTake: false, }); this._source.addNotification(this._notification); this._scheduleUpdateState(updateTimeoutSeconds); } else { this._notification?.destroy(MessageTray.NotificationDestroyedReason.SOURCE_CLOSED); } break; } case BreakState.BREAK_DUE: { const [nextBreakType, nextBreakDueTime] = this._manager.getNextBreakDue(currentTime); const breakDueAgo = currentTime - nextBreakDueTime; console.assert(breakDueAgo >= 0, 'breakDueAgo should be non-negative'); if ((this._previousState === BreakState.ACTIVE || this._previousState === BreakState.DISABLED) && breakDueAgo < BREAK_OVERDUE_TIME_SECONDS) { const durationSecs = this._manager.getDurationForBreakType(this._manager.currentBreakType); const [durationText, durationValue, unused] = this._formatTimeSpan(durationSecs); let titleText, bodyText; switch (this._manager.currentBreakType) { case 'movement': titleText = _('Time for a Movement Break'); bodyText = Gettext.ngettext( /* %s will be replaced with a string that describes a time interval, such as “2 minutes”, “40 seconds” or “1 hour” */ 'Take a break from the device and move around for %s', 'Take a break from the device and move around for %s', durationValue ).format(durationText); break; case 'eyesight': titleText = _('Time for an Eyesight Break'); bodyText = Gettext.ngettext( /* %s will be replaced with a string that describes a time interval, such as “2 minutes”, “40 seconds” or “1 hour” */ 'Take a break from the screen. Look at least 6 meters away for at least %s', 'Take a break from the screen. Look at least 6 meters away for at least %s', durationValue ).format(durationText); break; default: titleText = _('Time for a Break'); bodyText = Gettext.ngettext( /* %s will be replaced with a string that describes a time interval, such as “2 minutes”, “40 seconds” or “1 hour” */ 'It’s time to take a break. Get away from the device for %s!', 'It’s time to take a break. Get away from the device for %s!', durationValue ).format(durationText); break; } this._ensureNotification({ title: titleText, body: bodyText, sound: null, allowDelay: true, allowSkip: false, allowTake: true, }); this._scheduleUpdateState(BREAK_OVERDUE_TIME_SECONDS); } else if (breakDueAgo >= BREAK_OVERDUE_TIME_SECONDS && this._manager.breakTypeShouldNotifyOverdue(nextBreakType)) { const [delayText, delayValue, updateTimeoutSeconds] = this._formatTimeSpan(breakDueAgo); const bodyText = Gettext.ngettext( /* %s will be replaced with a string that describes a time interval, such as “2 minutes”, “40 seconds” or “1 hour” */ 'You were due to take a break %s ago', 'You were due to take a break %s ago', delayValue ).format(delayText); this._ensureNotification({ title: _('Break Overdue'), body: bodyText, sound: null, allowDelay: true, allowSkip: false, allowTake: true, }); this._scheduleUpdateState(updateTimeoutSeconds); } else if (this._previousState === BreakState.IN_BREAK) { const durationSecs = this._manager.getDurationForBreakType(this._manager.currentBreakType); const [countdownText, countdownValue, updateTimeoutSeconds] = this._formatTimeSpan(durationSecs - breakDueAgo); const bodyText = Gettext.ngettext( /* %s will be replaced with a string that describes a time interval, such as “2 minutes”, “40 seconds” or “1 hour” */ 'There is %s remaining in your break', 'There are %s remaining in your break', countdownValue ).format(countdownText); this._ensureNotification({ title: _('Break Interrupted'), body: bodyText, sound: null, allowDelay: false, allowSkip: false, allowTake: false, }); this._scheduleUpdateState(updateTimeoutSeconds); } this._notification.urgency = this._urgencyForBreakType(this._manager.currentBreakType); this._source.addNotification(this._notification); break; } default: console.assert(false, `Unknown BreakManager state: ${this._manager.state}`); break; } } }); const BreakNotification = GObject.registerClass({ Properties: { 'allow-delay': GObject.ParamSpec.boolean( 'allow-delay', null, null, GObject.ParamFlags.READWRITE, false), 'allow-skip': GObject.ParamSpec.boolean( 'allow-skip', null, null, GObject.ParamFlags.READWRITE, false), 'allow-take': GObject.ParamSpec.boolean( 'allow-take', null, null, GObject.ParamFlags.READWRITE, false), }, }, class BreakNotification extends MessageTray.Notification { constructor(source, manager, app) { super({ source, resident: true, }); this._manager = manager; this._app = app; this.connect('destroy', this._onDestroy.bind(this)); this._delayAction = null; this._skipAction = null; this._takeAction = null; } activate() { this._app.activate(); super.activate(); } _onDestroy(_notification, destroyedReason) { // If it was destroyed by the user (by pressing the close button), skip // the current break. if (destroyedReason === MessageTray.NotificationDestroyedReason.DISMISSED) this._manager.skipBreak(); this._manager = null; } get allowDelay() { return this._delayAction !== null; } set allowDelay(allowDelay) { if (allowDelay === this.allowDelay) return; if (allowDelay) { this._delayAction = this.addAction(_('Delay'), this._onDelayAction.bind(this)); } else { this.removeAction(this._delayAction); this._delayAction = null; } } _onDelayAction() { this._manager.delayBreak(); this.destroy(MessageTray.NotificationDestroyedReason.SOURCE_CLOSED); } get allowSkip() { return this._skipAction !== null; } set allowSkip(allowSkip) { if (allowSkip === this.allowSkip) return; if (allowSkip) { this._skipAction = this.addAction(_('Skip'), this._onSkipAction.bind(this)); } else { this.removeAction(this._skipAction); this._skipAction = null; } } _onSkipAction() { this._manager.skipBreak(); this.destroy(MessageTray.NotificationDestroyedReason.SOURCE_CLOSED); } get allowTake() { return this._takeAction !== null; } set allowTake(allowTake) { if (allowTake === this.allowTake) return; if (allowTake) { this._takeAction = this.addAction(_('Take'), this._onTakeAction.bind(this)); } else { this.removeAction(this._takeAction); this._takeAction = null; } } _onTakeAction() { this._manager.takeBreak(); this.destroy(MessageTray.NotificationDestroyedReason.SOURCE_CLOSED); } }); const OsdBreakCountdownLabel = GObject.registerClass( class OsdBreakCountdownLabel extends St.Widget { constructor(manager) { super({x_expand: true, y_expand: true}); this._manager = manager; this._timerId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 1, () => { return this._updateState() ? GLib.SOURCE_CONTINUE : GLib.SOURCE_REMOVE; }); this._box = new St.BoxLayout({ orientation: Clutter.Orientation.VERTICAL, }); this.add_child(this._box); this._label = new St.Label({ style_class: 'osd-break-countdown-label', text: '', }); this._box.add_child(this._label); Main.uiGroup.add_child(this); Main.uiGroup.set_child_above_sibling(this, null); this._updateState(); this._position(); global.compositor.disable_unredirect(); this.connect('destroy', () => { if (this._timerId !== 0) GLib.source_remove(this._timerId); this._timerId = 0; global.compositor.enable_unredirect(); }); } _updateState() { const currentTime = this._manager.getCurrentTime(); const nextDueTime = this._manager.getNextBreakDueTime(currentTime); const breakDueInSecs = nextDueTime - currentTime; console.debug(`OsdBreakCountdownLabel: breakDueInSecs ${breakDueInSecs} nextDueTime ${nextDueTime} currentTime ${currentTime}`); if (breakDueInSecs < 1) { this.destroy(); return false; } this._label.text = Gettext.ngettext( // Translators: This is a notification to warn the user that a // screen time break will start shortly. 'Break in %d second', 'Break in %d seconds', breakDueInSecs ).format(breakDueInSecs); return true; } _position() { let workArea = Main.layoutManager.getWorkAreaForMonitor(Main.layoutManager.primaryIndex); if (Clutter.get_default_text_direction() === Clutter.TextDirection.RTL) this._box.x = workArea.x + (workArea.width - this._box.width); else this._box.x = workArea.x; this._box.y = workArea.y; } });