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',