gnome-shell/js/misc/breakManager.js
Philip Withnall 81e7c944e7 breakManager: Clarify some nextBreakType variables
This doesn’t introduce any functional changes, as
`this._manager.currentBreakType` is set to the value returned by
`this._manager.getNextBreakDue()` when entering `BreakState.BREAK_DUE`.

However, it should make the code a little clearer as now the code refers
to the ’next’ break type rather than the ‘current’ one in the context of
an upcoming break.

Signed-off-by: Philip Withnall <pwithnall@gnome.org>
Part-of: <https://gitlab.gnome.org/GNOME/gnome-shell/-/merge_requests/3687>
2025-04-28 15:52:14 +00:00

1472 lines
55 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 its 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.freeze_notify();
this.notify('next-break-due-time');
if (this._state === BreakState.DISABLED)
this._startStateMachine();
else
this._updateState(currentTime);
this.thaw_notify();
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 havent 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 isnt 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 cant 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 its 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 its 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 its 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 users 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 finishVal = 127 * BRIGHTNESS_FACTOR;
const finishColor = new Cogl.Color({
red: finishVal,
green: finishVal,
blue: finishVal,
alpha: 255,
});
this._brightnessEffect.set_brightness(startVal);
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 cant 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 its 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 shouldnt, thats 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 shouldnt
// notify. This means the notification remains, but is not presented to
// the user — its 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 its 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(nextBreakType);
const [durationText, durationValue, unused] = this._formatTimeSpan(durationSecs);
let titleText, bodyText;
switch (nextBreakType) {
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” */
'Its time to take a break. Get away from the device for %s!',
'Its 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(nextBreakType);
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);
}
if (this._notification) {
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;
}
});