This commit is contained in:
2024-07-08 22:46:35 +02:00
parent 02f44c49d2
commit 27254d817a
56249 changed files with 808097 additions and 1 deletions

View File

@@ -0,0 +1,12 @@
// SPDX-FileCopyrightText: Night Theme Switcher Contributors
// SPDX-License-Identifier: GPL-3.0-or-later
/**
* Print a message in debug builds.
*
* @param {string} msg Message to print.
*/
export function message(msg) {
if (NTS.metadata['build-type'] === 'debug')
console.log(`[${NTS.metadata.name}] ${msg}`);
}

View File

@@ -0,0 +1,14 @@
// SPDX-FileCopyrightText: Night Theme Switcher Contributors
// SPDX-License-Identifier: GPL-3.0-or-later
/**
* Color Schemes.
*
* @readonly
* @enum {string}
*/
export const ColorScheme = {
DEFAULT: 'default',
PREFER_DARK: 'prefer-dark',
PREFER_LIGHT: 'prefer-night',
};

View File

@@ -0,0 +1,14 @@
// SPDX-FileCopyrightText: Night Theme Switcher Contributors
// SPDX-License-Identifier: GPL-3.0-or-later
/**
* Times.
*
* @readonly
* @enum {string}
*/
export const Time = {
UNKNOWN: 'unknown',
DAY: 'day',
NIGHT: 'night',
};

View File

@@ -0,0 +1,49 @@
// SPDX-FileCopyrightText: Night Theme Switcher Contributors
// SPDX-License-Identifier: GPL-3.0-or-later
'use strict';
import { Extension } from 'resource:///org/gnome/shell/extensions/extension.js';
import * as debug from './debug.js';
import { ColorSchemeSwitcher } from './modules/ColorSchemeSwitcher.js';
import { CommandsSwitcher } from './modules/CommandsSwitcher.js';
import { Timer } from './modules/Timer.js';
export default class NightThemeSwitcher extends Extension {
#modules = [];
enable() {
globalThis.NTS = this;
debug.message('Enabling extension...');
const timer = new Timer();
[
timer,
new ColorSchemeSwitcher({ timer }),
new CommandsSwitcher({ timer }),
].forEach(module => this.#modules.push(module));
this.#modules.forEach(module => module.enable());
debug.message('Extension enabled.');
}
disable() {
// Extension won't be disabled in `unlock-dialog` session mode. This is
// to enable the color scheme switch while the lock screen is displayed,
// as the background image and the shell theme are visible in this mode.
debug.message('Disabling extension...');
this.#modules.forEach(module => module.disable());
this.#modules = [];
debug.message('Extension disabled.');
delete globalThis.NTS;
}
}

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
SPDX-FileCopyrightText: Night Theme Switcher Contributors
SPDX-License-Identifier: CC-BY-SA-4.0
-->
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32">
<path d="M4 0C1.784 0 0 1.784 0 4v28h32V4c0-2.216-1.784-4-4-4zm22.951 3A2 2 0 0127 3a2 2 0 012 2 2 2 0 01-2 2 2 2 0 01-2-2 2 2 0 011.951-2zM3 10h26v19H3z"/>
<path d="M13.643 13.734c-2.433 1.306-3.81 4.261-3.244 6.964.488 2.74 2.906 4.98 5.676 5.257 2.09.255 4.271-.581 5.652-2.17-2.73.504-5.667-.963-6.904-3.448-1.242-2.328-.863-5.383.91-7.337a6.547 6.547 0 00-2.09.734z"/>
</svg>

After

Width:  |  Height:  |  Size: 593 B

View File

@@ -0,0 +1,18 @@
{
"_generated": "Generated by SweetTooth, do not edit",
"build-type": "release",
"description": "Automatically toggle your desktop\u2019s color scheme between light and dark, switch backgrounds and run custom commands at sunset and sunrise.",
"gettext-domain": "nightthemeswitcher@romainvigier.fr",
"name": "Night Theme Switcher",
"session-modes": [
"unlock-dialog",
"user"
],
"settings-schema": "org.gnome.shell.extensions.nightthemeswitcher",
"shell-version": [
"46"
],
"url": "https://nightthemeswitcher.romainvigier.fr",
"uuid": "nightthemeswitcher@romainvigier.fr",
"version": 77
}

View File

@@ -0,0 +1,88 @@
// SPDX-FileCopyrightText: Night Theme Switcher Contributors
// SPDX-License-Identifier: GPL-3.0-or-later
import Gio from 'gi://Gio';
import * as debug from '../debug.js';
import { Time } from '../enums/Time.js';
import { Switcher } from './Switcher.js';
/**
* The Color Scheme Switcher changes the system color scheme according to the time.
*/
export class ColorSchemeSwitcher extends Switcher {
#settings;
#interfaceSettings;
#timer;
#settingsConnections = [];
/**
* @param {object} params Params object.
* @param {Timer} params.timer Timer to listen to.
*/
constructor({ timer }) {
const settings = NTS.getSettings(`${NTS.metadata['settings-schema']}.color-scheme`);
super({
name: 'Color Scheme',
timer,
settings,
callback: time => this.#onTimeChanged(time),
});
this.#timer = timer;
this.#settings = settings;
this.#interfaceSettings = new Gio.Settings({ schema: 'org.gnome.desktop.interface' });
}
enable() {
super.enable();
this.#connectSettings();
}
disable() {
super.disable();
this.#disconnectSettings();
}
#connectSettings() {
debug.message('Connecting Color Scheme Switcher to settings...');
this.#settingsConnections.push({
settings: this.#settings,
id: this.#settings.connect('changed::day', this.#onColorSchemeChanged.bind(this)),
});
this.#settingsConnections.push({
settings: this.#settings,
id: this.#settings.connect('changed::night', this.#onColorSchemeChanged.bind(this)),
});
this.#settingsConnections.push({
settings: this.#interfaceSettings,
id: this.#interfaceSettings.connect('changed::color-scheme', this.#onSystemColorSchemeChanged.bind(this)),
});
}
#disconnectSettings() {
this.#settingsConnections.forEach(({ settings, id }) => settings.disconnect(id));
this.#settingsConnections = [];
debug.message('Disconnected Color Scheme Switcher from settings.');
}
#onTimeChanged(time) {
const colorScheme = time === Time.NIGHT ? this.#settings.get_string('night') : this.#settings.get_string('day');
this.#interfaceSettings.set_string('color-scheme', colorScheme);
}
#onColorSchemeChanged(_settings, time) {
const colorScheme = this.#settings.get_string(time);
debug.message(`${time} color scheme changed to ${colorScheme}.`);
if (time === this.#timer.time)
this.#interfaceSettings.set_string('color-scheme', colorScheme);
}
#onSystemColorSchemeChanged() {
const colorScheme = this.#interfaceSettings.get_string('color-scheme');
debug.message(`System color scheme changed to ${colorScheme}.`);
this.#timer.syncTimeToColorScheme(colorScheme);
}
}

View File

@@ -0,0 +1,44 @@
// SPDX-FileCopyrightText: Night Theme Switcher Contributors
// SPDX-License-Identifier: GPL-3.0-or-later
import GLib from 'gi://GLib';
import * as debug from '../debug.js';
import { Time } from '../enums/Time.js';
import { Switcher } from './Switcher.js';
/**
* The Commands Switcher spawns commands according to the time.
*/
export class CommandsSwitcher extends Switcher {
#settings;
/**
* @param {object} params Params object.
* @param {Timer} params.timer Timer to listen to.
*/
constructor({ timer }) {
const settings = NTS.getSettings(`${NTS.metadata['settings-schema']}.commands`);
super({
name: 'Command',
timer,
settings,
callback: time => this.#onTimeChanged(time),
disableable: true,
});
this.#settings = settings;
}
#onTimeChanged(time) {
if (time === Time.UNKNOWN)
return;
const command = this.#settings.get_string(time === Time.DAY ? 'sunrise' : 'sunset');
if (!command)
return;
GLib.spawn_async(null, ['sh', '-c', command], null, GLib.SpawnFlags.SEARCH_PATH, null);
debug.message(`Spawned ${time} command.`);
}
}

View File

@@ -0,0 +1,101 @@
// SPDX-FileCopyrightText: Night Theme Switcher Contributors
// SPDX-License-Identifier: GPL-3.0-or-later
import * as debug from '../debug.js';
/**
* Function called when the time changes.
*
* @callback TimeChangedCallback
* @param {Time} time New time.
*/
/**
* The Switcher runs a callback function when the time changes.
*
*/
export class Switcher {
#name;
#timer;
#settings;
#callback;
#disableable;
#statusConnection = null;
#timerConnection = null;
/**
* @param {object} params Params object.
* @param {string} params.name Name of the switcher.
* @param {Timer} params.timer Timer to listen to.
* @param {Gio.Settings} params.settings Settings.
* @param {TimeChangedCallback} params.callback Callback function.
* @param {boolean} params.disableable If the switcher can be disabled using an `enabled` key in the settings.
*/
constructor({ name, timer, settings, callback, disableable = false }) {
this.#name = name;
this.#timer = timer;
this.#settings = settings;
this.#callback = callback;
this.#disableable = disableable;
}
enable() {
debug.message(`Enabling ${this.#name} switcher...`);
if (this.#disableable)
this.#watchStatus();
if (!this.#disableable || this.#settings.get_boolean('enabled')) {
this.#connectTimer();
this.#onTimeChanged();
}
debug.message(`${this.#name} switcher enabled.`);
}
disable() {
debug.message(`Disabling ${this.#name} switcher...`);
this.#disconnectTimer();
if (this.#disableable)
this.#unwatchStatus();
debug.message(`${this.#name} switcher disabled.`);
}
#watchStatus() {
debug.message(`Watching ${this.#name} switching status...`);
this.#statusConnection = this.#settings.connect('changed::enabled', this.#onStatusChanged.bind(this));
}
#unwatchStatus() {
if (this.#statusConnection) {
this.#settings.disconnect(this.#statusConnection);
this.#statusConnection = null;
}
debug.message(`Stopped watching ${this.#name} switching status.`);
}
#connectTimer() {
debug.message(`Connecting ${this.#name} switcher to Timer...`);
this.#timerConnection = this.#timer.connect('notify::time', this.#onTimeChanged.bind(this));
}
#disconnectTimer() {
if (this.#timerConnection) {
this.#timer.disconnect(this.#timerConnection);
this.#timerConnection = null;
}
debug.message(`Disconnected ${this.#name} switcher from Timer.`);
}
#onStatusChanged() {
debug.message(`${this.#name} switching has been ${this.#settings.get_boolean('enabled') ? 'enabled' : 'disabled'}.`);
this.disable();
this.enable();
}
#onTimeChanged() {
this.#callback(this.#timer.time);
}
}

View File

@@ -0,0 +1,377 @@
// SPDX-FileCopyrightText: Night Theme Switcher Contributors
// SPDX-License-Identifier: GPL-3.0-or-later
import Geoclue from 'gi://Geoclue';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Meta from 'gi://Meta';
import Shell from 'gi://Shell';
import { gettext as _ } from 'resource:///org/gnome/shell/extensions/extension.js';
import { layoutManager, messageTray, wm } from 'resource:///org/gnome/shell/ui/main.js';
import * as MessageTray from 'resource:///org/gnome/shell/ui/messageTray.js';
import * as debug from '../debug.js';
import { ColorScheme } from '../enums/ColorScheme.js'; // eslint-disable-line no-unused-vars
import { Time } from '../enums/Time.js';
/**
* The Timer is responsible for signaling any time change to the other modules.
*
* They can connect to its 'time' property and query it for the current time.
*
* It will try to use the current location as a time source but will fall back
* to a manual schedule if the location services are disabled or if the user
* forced the manual schedule in the preferences.
*/
export class Timer extends GObject.Object {
#settings;
#colorSchemeSettings;
#interfaceSettings;
#locationSettings;
#time;
#cancellable = null;
#previousKeybinding = null;
#timeTimeoutId = null;
#geoclue = null;
#geoclueLocationConnectionId = null;
#suntimesTimeoutId = null;
#manuallySetTime = false;
#settingsConnections = [];
static {
GObject.registerClass({
Properties: {
time: GObject.ParamSpec.string('time', 'Time', 'Time', GObject.ParamFlags.READWRITE, Time.UNKNOWN),
},
}, this);
}
constructor() {
super();
this.#settings = NTS.getSettings(`${NTS.metadata['settings-schema']}.time`);
this.#colorSchemeSettings = NTS.getSettings(`${NTS.metadata['settings-schema']}.color-scheme`);
this.#interfaceSettings = new Gio.Settings({ schema: 'org.gnome.desktop.interface' });
this.#locationSettings = new Gio.Settings({ schema: 'org.gnome.system.location' });
}
enable() {
debug.message('Enabling Timer...');
this.#cancellable = new Gio.Cancellable();
this.#connectSettings();
this.#trackTime();
if (this.#settings.get_boolean('manual-schedule')) {
debug.message('Using the manual schedule.');
} else {
debug.message('Using location.');
this.#trackLocation();
this.#trackSuntimes();
}
this.#addKeybinding();
this.#changeTime(this.#computeTime());
debug.message('Timer enabled.');
}
disable() {
debug.message('Disabling Timer...');
this.#removeKeybinding();
this.#untrackSuntimes();
this.#untrackLocation();
this.#untrackTime();
this.#disconnectSettings();
this.#cancellable.cancel();
debug.message('Timer disabled.');
}
/**
* @param {ColorScheme} colorScheme Color scheme to sync the time to.
*/
syncTimeToColorScheme(colorScheme) {
this.#changeTime(this.#colorSchemeToTime(colorScheme), true);
}
get time() {
return this.#time || Time.UNKNOWN;
}
#changeTime(time, manual = false) {
if (time === this.#time) {
if (!manual && this.#manuallySetTime)
this.#manuallySetTime = false;
return;
}
if (!manual && time !== this.#time && this.#manuallySetTime)
return;
this.#time = time;
this.#manuallySetTime = manual;
debug.message(manual ? `Time manually set to ${time}.` : `Time changed to ${time}.`);
const isMonitorFullscreen = layoutManager.monitors.some(monitor => monitor.inFullscreen);
if (this.#settings.get_boolean('fullscreen-transition') || !isMonitorFullscreen)
layoutManager.screenTransition.run();
this.notify('time');
}
#connectSettings() {
debug.message('Connecting Timer to settings...');
this.#settingsConnections.push({
settings: this.#locationSettings,
id: this.#locationSettings.connect('changed::enabled', this.#onLocationStateChanged.bind(this)),
});
this.#settingsConnections.push({
settings: this.#settings,
id: this.#settings.connect('changed::manual-schedule', this.#onManualScheduleStateChanged.bind(this)),
});
this.#settingsConnections.push({
settings: this.#settings,
id: this.#settings.connect('changed::nightthemeswitcher-ondemand-keybinding', this.#onOndemandKeybindingChanged.bind(this)),
});
// Only listen to the offset setting when not using a manual schedule
if (!this.#settings.get_boolean('manual-schedule')) {
this.#settingsConnections.push({
settings: this.#settings,
id: this.#settings.connect('changed::offset', this.#onOffsetChanged.bind(this)),
});
}
}
#disconnectSettings() {
this.#settingsConnections.forEach(({ settings, id }) => settings.disconnect(id));
this.#settingsConnections = [];
debug.message('Disconnected Timer from settings.');
}
#trackTime() {
debug.message('Watching for time change...');
this.#timeTimeoutId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 1, () => {
this.#changeTime(this.#computeTime());
return GLib.SOURCE_CONTINUE;
});
}
#untrackTime() {
if (this.#timeTimeoutId) {
GLib.Source.remove(this.#timeTimeoutId);
this.#timeTimeoutId = null;
}
debug.message('Stopped watching for time change.');
}
#trackLocation() {
debug.message('Connecting to GeoClue...');
Geoclue.Simple.new(
'org.gnome.Shell',
Geoclue.AccuracyLevel.CITY,
this.#cancellable,
this.#onGeoclueReady.bind(this)
);
}
#untrackLocation() {
debug.message('Disconnecting from GeoClue...');
if (this.#geoclue && this.#geoclueLocationConnectionId) {
this.#geoclue.disconnect(this.#geoclueLocationConnectionId);
this.#geoclueLocationConnectionId = null;
this.#geoclue = null;
}
debug.message('Disconnected from GeoClue.');
}
#trackSuntimes() {
debug.message('Regularly updating sun times...');
this.#suntimesTimeoutId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 3600, () => {
this.#updateSuntimes();
return GLib.SOURCE_CONTINUE;
});
}
#untrackSuntimes() {
if (this.#suntimesTimeoutId) {
GLib.Source.remove(this.#suntimesTimeoutId);
this.#suntimesTimeoutId = null;
}
debug.message('Stopped regularly updating sun times.');
}
#addKeybinding() {
this.#previousKeybinding = this.#settings.get_strv('nightthemeswitcher-ondemand-keybinding')[0];
if (!this.#settings.get_strv('nightthemeswitcher-ondemand-keybinding')[0])
return;
debug.message('Adding keybinding...');
wm.addKeybinding(
'nightthemeswitcher-ondemand-keybinding',
this.#settings,
Meta.KeyBindingFlags.IGNORE_AUTOREPEAT,
Shell.ActionMode.NORMAL | Shell.ActionMode.OVERVIEW,
() => {
const time = this.time === Time.NIGHT ? Time.DAY : Time.NIGHT;
this.#changeTime(time, true);
}
);
debug.message('Added keybinding.');
}
#removeKeybinding() {
if (this.#previousKeybinding) {
debug.message('Removing keybinding...');
wm.removeKeybinding('nightthemeswitcher-ondemand-keybinding');
debug.message('Removed keybinding.');
}
}
#colorSchemeToTime(colorScheme) {
return colorScheme === this.#colorSchemeSettings.get_string('night') ? Time.NIGHT : Time.DAY;
}
#onLocationStateChanged() {
this.disable();
this.enable();
}
#onManualScheduleStateChanged() {
this.disable();
this.enable();
}
#onOffsetChanged() {
this.#updateSuntimes();
}
#onOndemandKeybindingChanged() {
this.#removeKeybinding();
this.#addKeybinding();
}
#onGeoclueReady(_geoclue, result) {
try {
this.#geoclue = Geoclue.Simple.new_finish(result);
this.#geoclueLocationConnectionId = this.#geoclue.connect('notify::location', this.#onLocationChanged.bind(this));
debug.message('Connected to GeoClue.');
this.#onLocationChanged();
} catch (e) {
const [latitude, longitude] = this.#settings.get_value('location').deepUnpack();
if (latitude >= -90 && latitude <= 90 && longitude >= -180 && longitude <= 180) {
console.error(`[${NTS.metadata.name}] Unable to retrieve the location, using the last known location instead.\n${e}`);
this.#updateSuntimes();
} else {
console.error(`[${NTS.metadata.name}] Unable to retrieve the location, using the manual schedule times instead.\n${e}`);
const source = new MessageTray.Source({
title: NTS.metadata.name,
icon: Gio.icon_new_for_string(GLib.build_filenamev([NTS.metadata.path, 'icons', 'nightthemeswitcher-symbolic.svg'])),
});
messageTray.add(source);
const notification = new MessageTray.Notification(
{
source,
title: _('Unknown Location'),
body: _('A manual schedule will be used to switch the dark mode.'),
'icon-name': 'location-services-disabled-symbolic',
}
);
notification.addAction(_('Edit Manual Schedule'), () => NTS.openPreferences());
notification.connect('activated', () => NTS.openPreferences());
source.addNotification(notification);
this.#settings.set_boolean('manual-schedule', true);
}
}
}
#onLocationChanged(_geoclue, _location) {
debug.message('Location has changed.');
const { latitude, longitude } = this.#geoclue.get_location();
this.#settings.set_value('location', new GLib.Variant('(dd)', [latitude, longitude]));
debug.message(`Current location: (${latitude};${longitude})`);
this.#updateSuntimes();
}
#computeTime() {
const sunrise = this.#settings.get_double('sunrise');
const sunset = this.#settings.get_double('sunset');
const datetime = GLib.DateTime.new_now_local();
const hour = datetime.get_hour() + datetime.get_minute() / 60 + datetime.get_second() / 3600;
// Regular schedule
if (sunrise < sunset)
return hour >= sunrise && hour < sunset ? Time.DAY : Time.NIGHT;
// Sunset happens on the day after
else if (sunrise > sunset)
return hour >= sunrise || hour < sunset ? Time.DAY : Time.NIGHT;
// Sunset and Sunrise times are identical; preserve current theme
else
return this.#time || this.#colorSchemeToTime(this.#interfaceSettings.get_string('color-scheme'));
}
#updateSuntimes() {
const [latitude, longitude] = this.#settings.get_value('location').deepUnpack();
if (latitude < -90 && latitude > 90 && longitude < -180 && longitude > 180)
return;
debug.message('Updating sun times...');
const rad = degrees => degrees * Math.PI / 180;
const deg = radians => radians * 180 / Math.PI;
// Calculations from https://www.esrl.noaa.gov/gmd/grad/solcalc/calcdetails.html
const dtNow = GLib.DateTime.new_now_local();
const dtZero = GLib.DateTime.new_utc(1900, 1, 1, 0, 0, 0);
const timeSpan = dtNow.difference(dtZero);
const date = timeSpan / 1000 / 1000 / 60 / 60 / 24 + 2;
const tzOffset = dtNow.get_utc_offset() / 1000 / 1000 / 60 / 60;
const julianDay = date + 2415018.5 - tzOffset / 24;
const julianCentury = (julianDay - 2451545) / 36525;
const geomMeanLongSun = (280.46646 + julianCentury * (36000.76983 + julianCentury * 0.0003032)) % 360;
const geomMeanAnomSun = 357.52911 + julianCentury * (35999.05029 - 0.0001537 * julianCentury);
const eccentEarthOrbit = 0.016708634 - julianCentury * (0.000042037 + 0.0000001267 * julianCentury);
const sunEqOfCtr = Math.sin(rad(geomMeanAnomSun)) * (1.914602 - julianCentury * (0.004817 + 0.000014 * julianCentury)) + Math.sin(rad(2 * geomMeanAnomSun)) * (0.019993 - 0.000101 * julianCentury) + Math.sin(rad(3 * geomMeanAnomSun)) * 0.000289;
const sunTrueLong = geomMeanLongSun + sunEqOfCtr;
const sunAppLong = sunTrueLong - 0.00569 - 0.00478 * Math.sin(rad(125.04 - 1934.136 * julianCentury));
const meanObliqEcliptic = 23 + (26 + ((21.448 - julianCentury * (46.815 + julianCentury * (0.00059 - julianCentury * 0.001813)))) / 60) / 60;
const obliqCorr = meanObliqEcliptic + 0.00256 * Math.cos(rad(125.04 - 1934.136 * julianCentury));
const sunDeclin = deg(Math.asin(Math.sin(rad(obliqCorr)) * Math.sin(rad(sunAppLong))));
const varY = Math.tan(rad(obliqCorr / 2)) * Math.tan(rad(obliqCorr / 2));
const eqOfTime = 4 * deg(varY * Math.sin(2 * rad(geomMeanLongSun)) - 2 * eccentEarthOrbit * Math.sin(rad(geomMeanAnomSun)) + 4 * eccentEarthOrbit * varY * Math.sin(rad(geomMeanAnomSun)) * Math.cos(2 * rad(geomMeanLongSun)) - 0.5 * varY * varY * Math.sin(4 * rad(geomMeanLongSun)) - 1.25 * eccentEarthOrbit * eccentEarthOrbit * Math.sin(2 * rad(geomMeanAnomSun)));
const haSunrise = deg(Math.acos(Math.cos(rad(90.833)) / (Math.cos(rad(latitude)) * Math.cos(rad(sunDeclin))) - Math.tan(rad(latitude)) * Math.tan(rad(sunDeclin))));
const solarNoon = (720 - 4 * longitude - eqOfTime + tzOffset * 60) / 1440;
const timeSunrise = solarNoon - haSunrise * 4 / 1440;
const timeSunset = solarNoon + haSunrise * 4 / 1440;
const modulo = (n, m) => ((n % m) + m) % m;
const offset = this.#settings.get_double('offset');
const sunrise = modulo(timeSunrise * 24 + offset, 24);
const sunset = modulo(timeSunset * 24 - offset, 24);
this.#settings.set_double('sunrise', sunrise);
this.#settings.set_double('sunset', sunset);
debug.message(`New sun times: (sunrise: ${sunrise}; sunset: ${sunset})`);
}
}

View File

@@ -0,0 +1,183 @@
// SPDX-FileCopyrightText: Night Theme Switcher Contributors
// SPDX-License-Identifier: GPL-3.0-or-later
import Adw from 'gi://Adw';
import Gdk from 'gi://Gdk';
import GdkPixbuf from 'gi://GdkPixbuf';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Gtk from 'gi://Gtk';
import { gettext as _ } from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js';
export class BackgroundButton extends Gtk.Button {
#uri;
static {
GObject.registerClass({
GTypeName: 'BackgroundButton',
Template: 'resource:///org/gnome/Shell/Extensions/nightthemeswitcher/preferences/ui/BackgroundButton.ui',
InternalChildren: ['filechooser', 'thumbnail'],
Properties: {
uri: GObject.ParamSpec.string(
'uri',
'URI',
'URI to the background file',
GObject.ParamFlags.READWRITE,
null
),
thumbWidth: GObject.ParamSpec.int(
'thumb-width',
'Thumbnail width',
'Width of the displayed thumbnail',
GObject.ParamFlags.READWRITE,
0, 600,
180
),
thumbHeight: GObject.ParamSpec.int(
'thumb-height',
'Thumbnail height',
'Height of the displayed thumbnail',
GObject.ParamFlags.READWRITE,
0, 600,
180
),
},
}, this);
}
constructor({ ...params } = {}) {
super(params);
this.#setupSize();
this.#setupDropTarget();
this.#setupFileChooserFilter();
}
get uri() {
return this.#uri || null;
}
set uri(uri) {
if (uri === this.#uri)
return;
this.#uri = uri;
this.notify('uri');
GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {
this.#updateThumbnail();
return GLib.SOURCE_REMOVE;
});
}
#setupSize() {
const display = Gdk.Display.get_default();
const monitor = display.get_monitors().get_item(0);
if (monitor.width_mm === 0 || monitor.height_mm === 0)
return;
if (monitor.width_mm > monitor.height_mm)
this.thumbHeight *= monitor.height_mm / monitor.width_mm;
else
this.thumbWidth *= monitor.width_mm / monitor.height_mm;
}
#setupDropTarget() {
const dropTarget = Gtk.DropTarget.new(Gio.File.$gtype, Gdk.DragAction.COPY);
dropTarget.connect('drop', (_target, file, _x, _y) => {
const contentType = Gio.content_type_guess(file.get_basename(), null)[0];
if (this.#isContentTypeSupported(contentType)) {
this.uri = file.get_uri();
return true;
} else {
if (this.root instanceof Adw.PreferencesWindow) {
this.root.add_toast(new Adw.Toast({
title: _('This image format is not supported.'),
timeout: 10,
}));
}
return false;
}
});
this.add_controller(dropTarget);
}
#setupFileChooserFilter() {
this._filechooser.filter = new Gtk.FileFilter();
this._filechooser.filter.add_pixbuf_formats();
this._filechooser.filter.add_mime_type('application/xml');
}
#getSupportedContentTypes() {
return GdkPixbuf.Pixbuf.get_formats().flatMap(format => format.get_mime_types()).concat('application/xml');
}
#isContentTypeSupported(contentType) {
for (const supportedContentType of this.#getSupportedContentTypes()) {
if (Gio.content_type_equals(contentType, supportedContentType))
return true;
}
return false;
}
vfunc_mnemonic_activate() {
this.openFileChooser();
}
openFileChooser() {
this._filechooser.transient_for = this.get_root();
this._filechooser.show();
}
onFileChooserResponse(fileChooser, responseId) {
if (responseId !== Gtk.ResponseType.ACCEPT)
return;
this.uri = fileChooser.get_file().get_uri();
}
onClicked(_button) {
this.openFileChooser();
}
#updateThumbnail() {
this._thumbnail.paintable = null;
if (!this.uri)
return;
const file = Gio.File.new_for_uri(this.uri);
const contentType = Gio.content_type_guess(file.get_basename(), null)[0];
if (!this.#isContentTypeSupported(contentType))
return;
let path;
if (Gio.content_type_equals(contentType, 'application/xml')) {
const decoder = new TextDecoder('utf-8');
const contents = decoder.decode(file.load_contents(null)[1]);
try {
path = contents.match(/<file>(.+)<\/file>/m)[1];
if (!this.#isContentTypeSupported(Gio.content_type_guess(path, null)[0]))
throw new Error();
} catch (e) {
console.error(`No suitable background file found in ${file.get_path()}.\n${e}`);
return;
}
} else {
path = file.get_path();
}
const pixbuf = GdkPixbuf.Pixbuf.new_from_file(path);
const scale = pixbuf.width / pixbuf.height > this.thumbWidth / this.thumbHeight ? this.thumbHeight / pixbuf.height : this.thumbWidth / pixbuf.width;
const thumbPixbuf = GdkPixbuf.Pixbuf.new(pixbuf.colorspace, pixbuf.has_alpha, pixbuf.bits_per_sample, this.thumbWidth, this.thumbHeight);
pixbuf.scale(
thumbPixbuf,
0, 0,
this.thumbWidth, this.thumbHeight,
-(pixbuf.width * scale - this.thumbWidth) / 2, -(pixbuf.height * scale - this.thumbHeight) / 2,
scale, scale,
GdkPixbuf.InterpType.TILES
);
this._thumbnail.paintable = Gdk.Texture.new_for_pixbuf(thumbPixbuf);
}
}

View File

@@ -0,0 +1,28 @@
// SPDX-FileCopyrightText: Night Theme Switcher Contributors
// SPDX-License-Identifier: GPL-3.0-or-later
import Adw from 'gi://Adw';
import Gio from 'gi://Gio';
import GObject from 'gi://GObject';
export class BackgroundsPage extends Adw.PreferencesPage {
static {
GObject.registerClass({
GTypeName: 'BackgroundsPage',
Template: 'resource:///org/gnome/Shell/Extensions/nightthemeswitcher/preferences/ui/BackgroundsPage.ui',
InternalChildren: [
'day_button',
'night_button',
],
}, this);
}
constructor({ ...params } = {}) {
super(params);
const settings = new Gio.Settings({ schema: 'org.gnome.desktop.background' });
settings.bind('picture-uri', this._day_button, 'uri', Gio.SettingsBindFlags.DEFAULT);
settings.bind('picture-uri-dark', this._night_button, 'uri', Gio.SettingsBindFlags.DEFAULT);
}
}

View File

@@ -0,0 +1,29 @@
// SPDX-FileCopyrightText: Night Theme Switcher Contributors
// SPDX-License-Identifier: GPL-3.0-or-later
import Adw from 'gi://Adw';
import Gio from 'gi://Gio';
import GObject from 'gi://GObject';
export class CommandsPage extends Adw.PreferencesPage {
static {
GObject.registerClass({
GTypeName: 'CommandsPage',
Template: 'resource:///org/gnome/Shell/Extensions/nightthemeswitcher/preferences/ui/CommandsPage.ui',
InternalChildren: [
'enabled_switch',
'sunrise_entry',
'sunset_entry',
],
}, this);
}
constructor({ settings, ...params } = {}) {
super(params);
settings.bind('enabled', this._enabled_switch, 'active', Gio.SettingsBindFlags.DEFAULT);
settings.bind('sunrise', this._sunrise_entry, 'text', Gio.SettingsBindFlags.DEFAULT);
settings.bind('sunset', this._sunset_entry, 'text', Gio.SettingsBindFlags.DEFAULT);
}
}

View File

@@ -0,0 +1,15 @@
// SPDX-FileCopyrightText: Night Theme Switcher Contributors
// SPDX-License-Identifier: GPL-3.0-or-later
import Adw from 'gi://Adw';
import GObject from 'gi://GObject';
export class ContributePage extends Adw.PreferencesPage {
static {
GObject.registerClass({
GTypeName: 'ContributePage',
Template: 'resource:///org/gnome/Shell/Extensions/nightthemeswitcher/preferences/ui/ContributePage.ui',
}, this);
}
}

View File

@@ -0,0 +1,42 @@
// SPDX-FileCopyrightText: Night Theme Switcher Contributors
// SPDX-License-Identifier: GPL-3.0-or-later
import Adw from 'gi://Adw';
import Gio from 'gi://Gio';
import GObject from 'gi://GObject';
export class SchedulePage extends Adw.PreferencesPage {
static {
GObject.registerClass({
GTypeName: 'SchedulePage',
Template: 'resource:///org/gnome/Shell/Extensions/nightthemeswitcher/preferences/ui/SchedulePage.ui',
InternalChildren: [
'manual_schedule_switch',
'keyboard_shortcut_button',
'schedule_sunrise_time_chooser',
'schedule_sunset_time_chooser',
'fullscreen_transition_switch',
],
}, this);
}
constructor({ settings, ...params } = {}) {
super(params);
settings.bind('manual-schedule', this._manual_schedule_switch, 'active', Gio.SettingsBindFlags.DEFAULT);
settings.bind('sunrise', this._schedule_sunrise_time_chooser, 'time', Gio.SettingsBindFlags.DEFAULT);
settings.bind('sunset', this._schedule_sunset_time_chooser, 'time', Gio.SettingsBindFlags.DEFAULT);
settings.bind('fullscreen-transition', this._fullscreen_transition_switch, 'active', Gio.SettingsBindFlags.DEFAULT);
settings.connect('changed::nightthemeswitcher-ondemand-keybinding', () => {
this._keyboard_shortcut_button.keybinding = settings.get_strv('nightthemeswitcher-ondemand-keybinding')[0];
});
this._keyboard_shortcut_button.connect('notify::keybinding', () => {
settings.set_strv('nightthemeswitcher-ondemand-keybinding', [this._keyboard_shortcut_button.keybinding]);
});
this._keyboard_shortcut_button.keybinding = settings.get_strv('nightthemeswitcher-ondemand-keybinding')[0];
}
}

View File

@@ -0,0 +1,147 @@
// SPDX-FileCopyrightText: Night Theme Switcher Contributors
// SPDX-License-Identifier: GPL-3.0-or-later
import Gdk from 'gi://Gdk';
import GObject from 'gi://GObject';
import Gtk from 'gi://Gtk';
export class ShortcutButton extends Gtk.Stack {
static {
GObject.registerClass({
GTypeName: 'ShortcutButton',
Template: 'resource:///org/gnome/Shell/Extensions/nightthemeswitcher/preferences/ui/ShortcutButton.ui',
InternalChildren: ['choose_button', 'change_button', 'clear_button', 'dialog'],
Properties: {
keybinding: GObject.ParamSpec.string(
'keybinding',
'Keybinding',
'Key sequence',
GObject.ParamFlags.READWRITE,
null
),
},
}, this);
}
vfunc_mnemonic_activate() {
this.activate();
}
activate() {
if (this.keybinding)
return this._change_button.activate();
else
return this._choose_button.activate();
}
openDialog() {
this._dialog.transient_for = this.get_root();
this._dialog.present();
}
onKeybindingChanged(button) {
button.visible_child_name = button.keybinding ? 'edit' : 'choose';
}
onChooseButtonClicked(_button) {
this.openDialog();
}
onChangeButtonClicked(_button) {
this.openDialog();
}
onClearButtonClicked(_button) {
this.keybinding = '';
}
onKeyPressed(_widget, keyval, keycode, state) {
let mask = state & Gtk.accelerator_get_default_mod_mask();
mask &= ~Gdk.ModifierType.LOCK_MASK;
if (mask === 0 && keyval === Gdk.KEY_Escape) {
this._dialog.close();
return Gdk.EVENT_STOP;
}
if (
!isBindingValid({ mask, keycode, keyval }) ||
!isAccelValid({ mask, keyval })
)
return Gdk.EVENT_STOP;
this.keybinding = Gtk.accelerator_name_with_keycode(
null,
keyval,
keycode,
mask
);
this._dialog.close();
return Gdk.EVENT_STOP;
}
}
/**
* Check if the given keyval is forbidden.
*
* @param {number} keyval The keyval number.
* @returns {boolean} `true` if the keyval is forbidden.
*/
function isKeyvalForbidden(keyval) {
const forbiddenKeyvals = [
Gdk.KEY_Home,
Gdk.KEY_Left,
Gdk.KEY_Up,
Gdk.KEY_Right,
Gdk.KEY_Down,
Gdk.KEY_Page_Up,
Gdk.KEY_Page_Down,
Gdk.KEY_End,
Gdk.KEY_Tab,
Gdk.KEY_KP_Enter,
Gdk.KEY_Return,
Gdk.KEY_Mode_switch,
];
return forbiddenKeyvals.includes(keyval);
}
/**
* Check if the given key combo is a valid binding
*
* @param {{mask: number, keycode: number, keyval:number}} combo An object
* representing the key combo.
* @returns {boolean} `true` if the key combo is a valid binding.
*/
function isBindingValid({ mask, keycode, keyval }) {
if ((mask === 0 || mask === Gdk.SHIFT_MASK) && keycode !== 0) {
if (
(keyval >= Gdk.KEY_a && keyval <= Gdk.KEY_z) ||
(keyval >= Gdk.KEY_A && keyval <= Gdk.KEY_Z) ||
(keyval >= Gdk.KEY_0 && keyval <= Gdk.KEY_9) ||
(keyval >= Gdk.KEY_kana_fullstop && keyval <= Gdk.KEY_semivoicedsound) ||
(keyval >= Gdk.KEY_Arabic_comma && keyval <= Gdk.KEY_Arabic_sukun) ||
(keyval >= Gdk.KEY_Serbian_dje && keyval <= Gdk.KEY_Cyrillic_HARDSIGN) ||
(keyval >= Gdk.KEY_Greek_ALPHAaccent && keyval <= Gdk.KEY_Greek_omega) ||
(keyval >= Gdk.KEY_hebrew_doublelowline && keyval <= Gdk.KEY_hebrew_taf) ||
(keyval >= Gdk.KEY_Thai_kokai && keyval <= Gdk.KEY_Thai_lekkao) ||
(keyval >= Gdk.KEY_Hangul_Kiyeog && keyval <= Gdk.KEY_Hangul_J_YeorinHieuh) ||
(keyval === Gdk.KEY_space && mask === 0) ||
isKeyvalForbidden(keyval)
)
return false;
}
return true;
}
/**
* Check if the given key combo is a valid accelerator.
*
* @param {{mask: number, keyval:number}} combo An object representing the key
* combo.
* @returns {boolean} `true` if the key combo is a valid accelerator.
*/
function isAccelValid({ mask, keyval }) {
return Gtk.accelerator_valid(keyval, mask) || (keyval === Gdk.KEY_Tab && mask !== 0);
}

View File

@@ -0,0 +1,97 @@
// SPDX-FileCopyrightText: Night Theme Switcher Contributors
// SPDX-License-Identifier: GPL-3.0-or-later
import GDesktopEnums from 'gi://GDesktopEnums';
import Gio from 'gi://Gio';
import GObject from 'gi://GObject';
import Gtk from 'gi://Gtk';
export class TimeChooser extends Gtk.Widget {
#clockFormat;
#interfaceSettings;
static {
GObject.registerClass({
GTypeName: 'TimeChooser',
Template: 'resource:///org/gnome/Shell/Extensions/nightthemeswitcher/preferences/ui/TimeChooser.ui',
InternalChildren: ['clock_format_stack', 'hours_12', 'minutes_12', 'hours_24', 'minutes_24', 'am_toggle_button', 'pm_toggle_button'],
Properties: {
time: GObject.ParamSpec.double(
'time',
'Time',
'The time of the chooser',
GObject.ParamFlags.READWRITE,
0,
24,
0
),
},
}, this);
}
constructor({ ...params } = {}) {
super(params);
this.#interfaceSettings = new Gio.Settings({ schema: 'org.gnome.desktop.interface' });
this.#interfaceSettings.connect('changed::clock-format', this.#onClockFormatChanged.bind(this));
this.#onClockFormatChanged();
}
#onClockFormatChanged(_settings) {
this.#clockFormat = this.#interfaceSettings.get_enum('clock-format');
this.#syncClockFormatStack();
}
#syncClockFormatStack() {
this._clock_format_stack.set_visible_child_name(this.#clockFormat === GDesktopEnums.ClockFormat['12H'] ? '12h' : '24h');
}
#convertTimeTo24hFormat(time) {
const hours = Math.trunc(time);
const minutes = Math.round((time - hours) * 60);
return { hours, minutes };
}
#convertTimeTo12hFormat(time) {
const { hours: hours24, minutes } = this.#convertTimeTo24hFormat(time);
const hours = hours24 % 12;
const isPm = hours24 > 12;
return { hours, minutes, isPm };
}
#convert24hFormatToTime({ hours, minutes }) {
return hours + minutes / 60;
}
#convert12hFormatToTime({ hours, minutes, isPm }) {
return this.#convert24hFormatToTime({
hours: hours + Number(isPm) * 12,
minutes,
});
}
onTimeChanged(_chooser) {
const time = this.time;
const clock12 = this.#convertTimeTo12hFormat(time);
this._hours_12.value = clock12.hours;
this._minutes_12.value = clock12.minutes;
this._am_toggle_button.active = !clock12.isPm;
this._pm_toggle_button.active = clock12.isPm;
const clock24 = this.#convertTimeTo24hFormat(time);
this._hours_24.value = clock24.hours;
this._minutes_24.value = clock24.minutes;
}
onValueChanged(_widget) {
this.time = this.#clockFormat === GDesktopEnums.ClockFormat['12H']
? this.#convert12hFormatToTime({ hours: this._hours_12.value, minutes: this._minutes_12.value, isPm: this._pm_toggle_button.active })
: this.#convert24hFormatToTime({ hours: this._hours_24.value, minutes: this._minutes_24.value });
}
onOutputChanged(spin) {
spin.text = spin.value.toString().padStart(2, '0');
return true;
}
}

View File

@@ -0,0 +1,68 @@
// SPDX-FileCopyrightText: Night Theme Switcher Contributors
// SPDX-License-Identifier: GPL-3.0-or-later
'use strict';
import Adw from 'gi://Adw';
import Gdk from 'gi://Gdk';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Gtk from 'gi://Gtk';
import { ExtensionPreferences } from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js';
export default class NightThemeSwitcherPreferences extends ExtensionPreferences {
/**
* Fill the PreferencesWindow.
*
* @param {Adw.PreferencesWindow} window The PreferencesWindow to fill.
*/
async fillPreferencesWindow(window) {
// Load resources
const resource = Gio.Resource.load(GLib.build_filenamev([this.path, 'resources', 'preferences.gresource']));
Gio.resources_register(resource);
// Load icons
const iconTheme = Gtk.IconTheme.get_for_display(Gdk.Display.get_default());
iconTheme.add_resource_path('/org/gnome/Shell/Extensions/nightthemeswitcher/preferences/icons');
// Set window properties
window.search_enabled = true;
window.set_default_size(500, 630);
// Add a dummy page until the dynamics imports are done
const dummyPage = new Adw.PreferencesPage();
window.add(dummyPage);
// Dynamically import all classes
const { BackgroundButton } = await import('./preferences/BackgroundButton.js');
const { BackgroundsPage } = await import('./preferences/BackgroundsPage.js');
const { CommandsPage } = await import('./preferences/CommandsPage.js');
const { ContributePage } = await import('./preferences/ContributePage.js');
const { SchedulePage } = await import('./preferences/SchedulePage.js');
const { ShortcutButton } = await import('./preferences/ShortcutButton.js');
const { TimeChooser } = await import('./preferences/TimeChooser.js');
// Make sure all GObjects are registered
GObject.type_ensure(BackgroundButton);
GObject.type_ensure(BackgroundsPage);
GObject.type_ensure(CommandsPage);
GObject.type_ensure(ContributePage);
GObject.type_ensure(SchedulePage);
GObject.type_ensure(ShortcutButton);
GObject.type_ensure(TimeChooser);
// Remove the dummy page
window.remove(dummyPage);
// Add all pages
[
new SchedulePage({ settings: this.getSettings(`${this.metadata['settings-schema']}.time`) }),
new BackgroundsPage(),
new CommandsPage({ settings: this.getSettings(`${this.metadata['settings-schema']}.commands`) }),
new ContributePage(),
].forEach(page => window.add(page));
}
}

View File

@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
SPDX-FileCopyrightText: Night Theme Switcher Contributors
SPDX-License-Identifier: GPL-3.0-or-later
-->
<schemalist gettext-domain="nightthemeswitcher@romainvigier.fr">
<enum id="org.gnome.shell.extensions.nightthemeswitcher.color-scheme-enum">
<value nick="default" value="0"/>
<value nick="prefer-dark" value="1"/>
<value nick="prefer-light" value="2"/>
</enum>
<schema id="org.gnome.shell.extensions.nightthemeswitcher" path="/org/gnome/shell/extensions/nightthemeswitcher/">
<key name="settings-version" type="i">
<default>0</default>
</key>
</schema>
<schema id="org.gnome.shell.extensions.nightthemeswitcher.commands" path="/org/gnome/shell/extensions/nightthemeswitcher/commands/">
<key name="enabled" type="b">
<default>false</default>
</key>
<key name="sunrise" type="s">
<default>""</default>
</key>
<key name="sunset" type="s">
<default>""</default>
</key>
</schema>
<schema id="org.gnome.shell.extensions.nightthemeswitcher.time" path="/org/gnome/shell/extensions/nightthemeswitcher/time/">
<key name="nightthemeswitcher-ondemand-keybinding" type="as">
<default><![CDATA[['<Shift><Super>t']]]></default>
</key>
<key name="fullscreen-transition" type="b">
<default>true</default>
</key>
<key name="manual-schedule" type="b">
<default>false</default>
</key>
<key name="sunrise" type="d">
<range min="0" max="24"/>
<default>6</default>
</key>
<key name="sunset" type="d">
<range min="0" max="24"/>
<default>20</default>
</key>
<key name="location" type="(dd)">
<default>(91,181)</default>
</key>
<key name="offset" type="d">
<range min="0" max="24"/>
<default>0.4</default>
</key>
</schema>
<schema id="org.gnome.shell.extensions.nightthemeswitcher.color-scheme" path="/org/gnome/shell/extensions/nightthemeswitcher/color-scheme/">
<key name="day" enum="org.gnome.shell.extensions.nightthemeswitcher.color-scheme-enum">
<default>"default"</default>
</key>
<key name="night" enum="org.gnome.shell.extensions.nightthemeswitcher.color-scheme-enum">
<default>"prefer-dark"</default>
</key>
</schema>
</schemalist>