diff --git a/data/io.elementary.settings-daemon.gschema.xml b/data/io.elementary.settings-daemon.gschema.xml
index cd0b1a6c..8700afc0 100644
--- a/data/io.elementary.settings-daemon.gschema.xml
+++ b/data/io.elementary.settings-daemon.gschema.xml
@@ -97,6 +97,14 @@
+
+
+ []
+ The current focus modes.
+ a(a{sv}a{sv})
+
+
+
false
diff --git a/src/Application.vala b/src/Application.vala
index 3e9de932..036b2d7f 100644
--- a/src/Application.vala
+++ b/src/Application.vala
@@ -22,6 +22,8 @@ public sealed class SettingsDaemon.Application : Gtk.Application {
private Backends.PowerProfilesSync power_profiles_sync;
private Backends.ApplicationShortcuts application_shortcuts;
+ private Backends.FocusModes.Manager focus_modes_manager;
+
private const string FDO_ACCOUNTS_NAME = "org.freedesktop.Accounts";
private const string FDO_ACCOUNTS_PATH = "/org/freedesktop/Accounts";
@@ -40,6 +42,8 @@ public sealed class SettingsDaemon.Application : Gtk.Application {
GLib.Intl.textdomain (Build.GETTEXT_PACKAGE);
add_main_option ("version", 'v', NONE, NONE, "Display the version", null);
+
+ focus_modes_manager = new Backends.FocusModes.Manager ();
}
protected override int handle_local_options (VariantDict options) {
@@ -80,6 +84,7 @@ public sealed class SettingsDaemon.Application : Gtk.Application {
protected override bool dbus_register (DBusConnection connection, string object_path) throws Error {
base.dbus_register (connection, object_path);
+ connection.register_object (object_path, focus_modes_manager);
connection.register_object (object_path, new Backends.SystemUpdate ());
#if UBUNTU_DRIVERS
diff --git a/src/Backends/FocusModes/GLibSetting.vala b/src/Backends/FocusModes/GLibSetting.vala
new file mode 100644
index 00000000..81fe65de
--- /dev/null
+++ b/src/Backends/FocusModes/GLibSetting.vala
@@ -0,0 +1,37 @@
+/*
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2025 elementary, Inc. (https://elementary.io)
+ * Authored by: Leonhard Kargl
+ */
+
+public class SettingsDaemon.Backends.FocusModes.GLibSetting : Object, Setting {
+ public Settings settings { get; construct; }
+ public string key { get; construct; }
+ public Variant active_value { get; construct; }
+
+ private int applied = 0;
+ private Variant? last_value = null;
+
+ public GLibSetting (string schema_id, string key, Variant active_value) {
+ Object (settings: new Settings (schema_id), key: key, active_value: active_value);
+ }
+
+ public void apply (Variant value) {
+ applied++;
+
+ if (applied == 1) {
+ last_value = settings.get_value (key);
+ settings.set_value (key, active_value);
+ }
+ }
+
+ public void unapply () {
+ applied--;
+
+ assert (applied >= 0);
+
+ if (applied == 0 && settings.get_value (key).equal (active_value) && last_value != null) {
+ settings.set_value (key, last_value);
+ }
+ }
+}
diff --git a/src/Backends/FocusModes/Manager.vala b/src/Backends/FocusModes/Manager.vala
new file mode 100644
index 00000000..e156364e
--- /dev/null
+++ b/src/Backends/FocusModes/Manager.vala
@@ -0,0 +1,87 @@
+/*
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2025 elementary, Inc. (https://elementary.io)
+ * Authored by: Leonhard Kargl
+ */
+
+[DBus (name="io.elementary.settings_daemon.ModeManager")]
+public class SettingsDaemon.Backends.FocusModes.Manager : GLib.Object {
+ private static Settings settings = new Settings ("io.elementary.settings-daemon.focus-modes");
+
+ public signal void items_changed (uint pos, uint removed, uint added);
+ public signal void properties_changed (uint pos);
+
+ private HashTable modes = new HashTable (str_hash, str_equal);
+ private ListStore modes_list;
+
+ construct {
+ modes_list = new ListStore (typeof (Mode));
+ modes_list.items_changed.connect ((pos, rem, add) => items_changed (pos, rem, add));
+
+ foreach (var parsed_mode in (Mode.Parsed[]) settings.get_value ("focus-modes")) {
+ add_mode (parsed_mode);
+ }
+ }
+
+ public uint get_n_modes () throws DBusError, IOError {
+ return modes_list.n_items;
+ }
+
+ public Mode.Parsed get_mode (uint pos) throws DBusError, IOError {
+ return ((Mode) modes_list.get_item (pos)).parsed;
+ }
+
+ public void update_mode (Mode.Parsed parsed) throws DBusError, IOError {
+ if (parsed.id in modes) {
+ modes[parsed.id].parsed = parsed;
+ } else {
+ add_mode (parsed);
+ }
+
+ save_modes ();
+ }
+
+ private void add_mode (Mode.Parsed parsed) {
+ var mode = new Mode (parsed);
+
+ mode.notify.connect (on_mode_notify);
+
+ modes[mode.id] = mode;
+ modes_list.append (mode);
+ }
+
+ private void on_mode_notify (Object obj, ParamSpec pspec) requires (obj is Mode) {
+ var mode = (Mode) obj;
+
+ uint pos;
+ if (modes_list.find (mode, out pos)) {
+ properties_changed (pos);
+ } else {
+ warning ("Unknown mode notified");
+ }
+ }
+
+ public void delete_mode (string id) throws DBusError, IOError {
+ if (!(id in modes)) {
+ throw new IOError.NOT_FOUND ("Mode with the same name not found");
+ }
+
+ uint pos;
+ if (modes_list.find (modes[id], out pos)) {
+ modes_list.remove (pos);
+ }
+
+ modes.remove (id);
+
+ save_modes ();
+ }
+
+ private void save_modes () {
+ Mode.Parsed[] parsed_modes = {};
+ foreach (var mode in modes.get_values ()) {
+ parsed_modes += mode.parsed;
+ }
+
+ settings.set_value ("focus-modes", parsed_modes);
+ }
+}
diff --git a/src/Backends/FocusModes/Mode.vala b/src/Backends/FocusModes/Mode.vala
new file mode 100644
index 00000000..962c39a0
--- /dev/null
+++ b/src/Backends/FocusModes/Mode.vala
@@ -0,0 +1,141 @@
+/*
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2025 elementary, Inc. (https://elementary.io)
+ * Authored by: Leonhard Kargl
+ */
+
+public class SettingsDaemon.Backends.FocusModes.Mode : Object {
+ public enum Type {
+ MANUAL,
+ DAYLIGHT
+ }
+
+ public struct Parsed {
+ string id;
+ string name;
+ bool enabled;
+ bool active;
+ HashTable schedule;
+ HashTable settings;
+ }
+
+ private const string DARK_MODE = "dark-mode";
+ private const string DND = "dnd";
+ private const string MONOCHROME = "monochrome";
+
+ private static HashTable setting_handlers;
+
+ static construct {
+ setting_handlers = new HashTable (str_hash, str_equal);
+
+ setting_handlers[DARK_MODE] = new GLibSetting ("io.elementary.settings-daemon.prefers-color-scheme", "color-scheme", "prefer-dark");
+ setting_handlers[DND] = new GLibSetting ("io.elementary.notifications", "do-not-disturb", true);
+ setting_handlers[MONOCHROME] = new GLibSetting ("io.elementary.desktop.wm.accessibility", "enable-monochrome-filter", true);
+ }
+
+ private Parsed _parsed;
+ public Parsed parsed {
+ get { return _parsed; }
+ set {
+ if (is_active) {
+ // If we're currently active unapply the old settings and reapply the new ones
+ unapply_settings ();
+ _parsed = value;
+ apply_settings ();
+ } else {
+ _parsed = value;
+ }
+
+ check_triggers ();
+ }
+ }
+
+ public string id { get { return parsed.id; } }
+ public string name { get { return parsed.name; } }
+ public bool enabled { get { return parsed.enabled; } }
+ public bool active { get { return parsed.active; } set { _parsed.active = value; } }
+ public HashTable schedule { get { return parsed.schedule; } }
+ public HashTable settings { get { return parsed.settings; } }
+
+ private TimeTracker time_tracker;
+ private bool is_active = false;
+ private bool user_override = false;
+
+ public Mode (Parsed parsed) {
+ Object (parsed: parsed);
+ }
+
+ construct {
+ time_tracker = new TimeTracker ();
+ Timeout.add_seconds (1, () => {
+ check_triggers ();
+ return Source.CONTINUE;
+ });
+ }
+
+ private void check_triggers () {
+ var is_in = false;
+
+ if ("daylight" in schedule) {
+ is_in = time_tracker.is_in_time_window_daylight ();
+ } else if ("manual" in schedule && schedule["manual"].n_children () == 2) {
+ var from_time = schedule["manual"].get_child_value (0).get_double ();
+ var to_time = schedule["manual"].get_child_value (1).get_double ();
+ is_in = time_tracker.is_in_time_window_manual (from_time, to_time);
+ }
+
+ if (active != is_active) {
+ // The user toggled the mode manually
+ user_override = true;
+ }
+
+ if (user_override && is_in == active) {
+ // The schedule agrees with the user again so we disable the override and
+ // use the schedule as the source of truth again
+ user_override = false;
+ }
+
+ var should_activate = user_override ? active : is_in;
+
+ if (should_activate && !is_active) {
+ if (!active) {
+ active = true;
+ }
+
+ apply_settings ();
+ } else if (!should_activate && is_active) {
+ if (active) {
+ active = false;
+ }
+
+ unapply_settings ();
+ }
+
+ assert (user_override ^ active == is_in);
+ assert (active == is_active);
+ }
+
+ private void apply_settings () requires (!is_active) {
+ is_active = true;
+
+ foreach (var key in settings.get_keys ()) {
+ if (key in setting_handlers) {
+ setting_handlers[key].apply (settings[key]);
+ } else {
+ warning ("Tried to apply unknown setting: %s", key);
+ }
+ }
+ }
+
+ private void unapply_settings () requires (is_active) {
+ is_active = false;
+
+ foreach (var key in settings.get_keys ()) {
+ if (key in setting_handlers) {
+ setting_handlers[key].unapply ();
+ } else {
+ warning ("Tried to unapply unknown setting: %s", key);
+ }
+ }
+ }
+}
diff --git a/src/Backends/FocusModes/Setting.vala b/src/Backends/FocusModes/Setting.vala
new file mode 100644
index 00000000..66a284d9
--- /dev/null
+++ b/src/Backends/FocusModes/Setting.vala
@@ -0,0 +1,10 @@
+/*
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2025 elementary, Inc. (https://elementary.io)
+ * Authored by: Leonhard Kargl
+ */
+
+public interface SettingsDaemon.Backends.FocusModes.Setting : Object {
+ public abstract void apply (Variant value);
+ public abstract void unapply ();
+}
diff --git a/src/Backends/FocusModes/TimeTracker.vala b/src/Backends/FocusModes/TimeTracker.vala
new file mode 100644
index 00000000..3dd53b95
--- /dev/null
+++ b/src/Backends/FocusModes/TimeTracker.vala
@@ -0,0 +1,71 @@
+/*
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2025 elementary, Inc. (https://elementary.io)
+ * Authored by: Leonhard Kargl
+ */
+
+[SingleInstance]
+public class SettingsDaemon.Backends.FocusModes.TimeTracker : Object {
+ private GClue.Simple simple;
+
+ private double sunrise = 6.0;
+ private double sunset = 20.0;
+
+ construct {
+ get_location.begin ();
+ }
+
+ private async void get_location () {
+ try {
+ simple = yield new GClue.Simple (Build.PROJECT_NAME, GClue.AccuracyLevel.CITY, null);
+
+ simple.notify["location"].connect (() => {
+ on_location_updated (simple.location.latitude, simple.location.longitude);
+ });
+
+ on_location_updated (simple.location.latitude, simple.location.longitude);
+ } catch (Error e) {
+ warning ("Failed to connect to GeoClue2 service: %s", e.message);
+ return;
+ }
+ }
+
+ private void on_location_updated (double latitude, double longitude) {
+ var now = new DateTime.now_local ();
+ double _sunrise, _sunset;
+ if (SettingsDaemon.Utils.SunriseSunsetCalculator.get_sunrise_and_sunset (now, latitude, longitude, out _sunrise, out _sunset)) {
+ sunrise = _sunrise;
+ sunset = _sunset;
+ }
+ }
+
+ public bool is_in_time_window_daylight () {
+ var date_time = new DateTime.now_local ();
+ double time_double = 0;
+ time_double += date_time.get_hour ();
+ time_double += (double) date_time.get_minute () / 60;
+
+ // PM to AM
+ if (sunset > sunrise) {
+ return time_double < sunrise ? time_double <= sunset : time_double >= sunset;
+ }
+
+ // AM to AM, PM to PM, AM to PM
+ return (time_double >= sunset && time_double <= sunrise);
+ }
+
+ public bool is_in_time_window_manual (double from, double to) {
+ var date_time = new DateTime.now_local ();
+ double time_double = 0;
+ time_double += date_time.get_hour ();
+ time_double += (double) date_time.get_minute () / 60;
+
+ // PM to AM
+ if (from > to) {
+ return time_double < to ? time_double <= from : time_double >= from;
+ }
+
+ // AM to AM, PM to PM, AM to PM
+ return (time_double >= from && time_double <= to);
+ }
+}
diff --git a/src/Backends/PrefersColorSchemeSettings.vala b/src/Backends/PrefersColorSchemeSettings.vala
index cd6097c7..e13b861d 100644
--- a/src/Backends/PrefersColorSchemeSettings.vala
+++ b/src/Backends/PrefersColorSchemeSettings.vala
@@ -23,14 +23,8 @@ public class SettingsDaemon.Backends.PrefersColorSchemeSettings : Object {
public unowned Pantheon.AccountsService accounts_service { get; construct; }
private const string COLOR_SCHEME = "color-scheme";
- private const string DARK_SCHEDULE = "prefer-dark-schedule";
- private const string DARK_SCHEDULE_SNOOZED = "prefer-dark-schedule-snoozed";
private Settings color_settings;
- private double sunrise = -1.0;
- private double sunset = -1.0;
-
- private uint time_id = 0;
public PrefersColorSchemeSettings (Pantheon.AccountsService accounts_service) {
Object (accounts_service: accounts_service);
@@ -38,143 +32,15 @@ public class SettingsDaemon.Backends.PrefersColorSchemeSettings : Object {
construct {
color_settings = new Settings ("io.elementary.settings-daemon.prefers-color-scheme");
-
- var schedule = color_settings.get_string (DARK_SCHEDULE);
- if (schedule == "sunset-to-sunrise") {
- var variant = color_settings.get_value ("last-coordinates");
- on_location_updated (variant.get_child_value (0).get_double (), variant.get_child_value (1).get_double ());
- }
-
- color_settings.changed[DARK_SCHEDULE].connect (update_timer);
color_settings.changed[COLOR_SCHEME].connect (update_color_scheme);
-
- update_timer ();
- }
-
- private void update_timer () {
- var schedule = color_settings.get_string (DARK_SCHEDULE);
-
- if (schedule == "sunset-to-sunrise") {
- get_location.begin ();
-
- start_timer ();
- } else if (schedule == "manual") {
- start_timer ();
- } else {
- color_settings.set_boolean (DARK_SCHEDULE_SNOOZED, false);
- stop_timer ();
- }
- }
-
- private void start_timer () {
- if (time_id == 0) {
- var time = new TimeoutSource (1000);
- time.set_callback (time_callback);
- time_id = time.attach (null);
- }
- }
-
- private void stop_timer () {
- if (time_id != 0) {
- Source.remove (time_id);
- time_id = 0;
- }
- }
-
- private async void get_location () {
- try {
- var simple = yield new GClue.Simple (Build.PROJECT_NAME, GClue.AccuracyLevel.CITY, null);
-
- simple.notify["location"].connect (() => {
- on_location_updated (simple.location.latitude, simple.location.longitude);
- });
-
- on_location_updated (simple.location.latitude, simple.location.longitude);
- } catch (Error e) {
- warning ("Failed to connect to GeoClue2 service: %s", e.message);
- return;
- }
- }
-
- private bool time_callback () {
- var new_color_scheme = Granite.Settings.ColorScheme.NO_PREFERENCE;
- if (is_in_schedule ()) {
- new_color_scheme = Granite.Settings.ColorScheme.DARK;
- }
-
- if (new_color_scheme == color_settings.get_enum (COLOR_SCHEME)) {
- color_settings.set_boolean (DARK_SCHEDULE_SNOOZED, false);
- return true;
- }
-
- if (!color_settings.get_boolean (DARK_SCHEDULE_SNOOZED)) {
- color_settings.set_enum (COLOR_SCHEME, new_color_scheme);
- return true;
- };
-
- return GLib.Source.CONTINUE;
- }
-
- private void on_location_updated (double latitude, double longitude) {
- color_settings.set_value ("last-coordinates", new Variant.tuple ({latitude, longitude}));
-
- var now = new DateTime.now_local ();
- double _sunrise, _sunset;
- if (SettingsDaemon.Utils.SunriseSunsetCalculator.get_sunrise_and_sunset (now, latitude, longitude, out _sunrise, out _sunset)) {
- sunrise = _sunrise;
- sunset = _sunset;
- }
}
private void update_color_scheme () {
var color_scheme = color_settings.get_enum (COLOR_SCHEME);
- if (
- color_scheme == Granite.Settings.ColorScheme.DARK && !is_in_schedule () ||
- color_scheme != Granite.Settings.ColorScheme.DARK && is_in_schedule ()
- ) {
- color_settings.set_boolean (DARK_SCHEDULE_SNOOZED, true);
- }
accounts_service.prefers_color_scheme = color_scheme;
var mutter_settings = new GLib.Settings ("org.gnome.desktop.interface");
mutter_settings.set_enum ("color-scheme", color_scheme);
}
-
- private bool is_in_schedule () {
- var schedule = color_settings.get_string (DARK_SCHEDULE);
-
- // fallback times (6AM and 8PM) for when an invalid result was returned
- // from the calculation (i.e. probably wasn't able to get a location)
- double from = 20.0;
- double to = 6.0;
- if (schedule == "sunset-to-sunrise" && sunrise >= 0 && sunset >= 0) {
- from = sunset;
- to = sunrise;
- } else if (schedule == "manual") {
- from = color_settings.get_double ("prefer-dark-schedule-from");
- to = color_settings.get_double ("prefer-dark-schedule-to");
- }
-
- var now = new DateTime.now_local ();
- return is_in_time_window (date_time_double (now), from, to);
- }
-
- public static bool is_in_time_window (double time_double, double from, double to) {
- // PM to AM
- if (from > to) {
- return time_double < to ? time_double <= from : time_double >= from;
- }
-
- // AM to AM, PM to PM, AM to PM
- return (time_double >= from && time_double <= to);
- }
-
- public static double date_time_double (DateTime date_time) {
- double time_double = 0;
- time_double += date_time.get_hour ();
- time_double += (double) date_time.get_minute () / 60;
-
- return time_double;
- }
}
diff --git a/src/meson.build b/src/meson.build
index e5cb5503..851819c4 100644
--- a/src/meson.build
+++ b/src/meson.build
@@ -10,6 +10,11 @@ sources = files(
'Backends/NightLightSettings.vala',
'Backends/PowerProfilesSync.vala',
'Backends/PrefersColorSchemeSettings.vala',
+ 'Backends' / 'FocusModes' / 'GLibSetting.vala',
+ 'Backends' / 'FocusModes' / 'Manager.vala',
+ 'Backends' / 'FocusModes' / 'Mode.vala',
+ 'Backends' / 'FocusModes' / 'Setting.vala',
+ 'Backends' / 'FocusModes' / 'TimeTracker.vala',
'Backends/SystemUpdate.vala',
'DBus/DesktopIntegration.vala',
'DBus/ShellKeyGrabber.vala',