Preferences: Improve the appearances tab with live preview, color scheme and accent color settings

This commit is contained in:
Romain Vigier
2026-08-02 00:25:06 +02:00
parent 955fdef40d
commit 39faff700a
19 changed files with 878 additions and 135 deletions
+40
View File
@@ -0,0 +1,40 @@
// SPDX-FileCopyrightText: Night Theme Switcher Contributors
// SPDX-License-Identifier: GPL-3.0-or-later
import GObject from "gi://GObject";
import Gtk from "gi://Gtk";
export class AppearanceChooser extends Gtk.Widget {
static {
GObject.registerClass(
{
GTypeName: "AppearanceChooser",
Template: "resource:///org/gnome/Shell/Extensions/nightthemeswitcher/preferences/ui/AppearanceChooser.ui",
Properties: {
"accent-color": GObject.ParamSpec.string(
"accent-color",
"Accent color",
"Accent color",
GObject.ParamFlags.READWRITE,
null,
),
"background-uri": GObject.ParamSpec.string(
"background-uri",
"Background URI",
"URI to the background file",
GObject.ParamFlags.READWRITE,
null,
),
"color-scheme": GObject.ParamSpec.string(
"color-scheme",
"Color scheme",
"Color scheme",
GObject.ParamFlags.READWRITE,
null,
),
},
},
this,
);
}
}
+27 -5
View File
@@ -11,17 +11,39 @@ export class AppearancePage extends Adw.PreferencesPage {
{
GTypeName: "AppearancePage",
Template: "resource:///org/gnome/Shell/Extensions/nightthemeswitcher/preferences/ui/AppearancePage.ui",
InternalChildren: ["day_button", "night_button"],
InternalChildren: ["day_appearance_chooser", "night_appearance_chooser"],
},
this,
);
}
constructor({ ...params } = {}) {
constructor({ accentColorSettings, colorSchemeSettings, ...params } = {}) {
super(params);
const settings = new Gio.Settings({ schema: "org.gnome.desktop.background" });
const backgroundSettings = new Gio.Settings({ schema: "org.gnome.desktop.background" });
const interfaceSettings = new Gio.Settings({ schema: "org.gnome.desktop.interface" });
settings.bind("picture-uri", this._day_button, "uri", Gio.SettingsBindFlags.DEFAULT);
settings.bind("picture-uri-dark", this._night_button, "uri", Gio.SettingsBindFlags.DEFAULT);
if (accentColorSettings.get_string("day") === "unset")
accentColorSettings.set_string("day", interfaceSettings.get_string("accent-color"));
if (accentColorSettings.get_string("night") === "unset")
accentColorSettings.set_string("night", interfaceSettings.get_string("accent-color"));
accentColorSettings.bind("day", this._day_appearance_chooser, "accent-color", Gio.SettingsBindFlags.DEFAULT);
accentColorSettings.bind("night", this._night_appearance_chooser, "accent-color", Gio.SettingsBindFlags.DEFAULT);
colorSchemeSettings.bind("day", this._day_appearance_chooser, "color-scheme", Gio.SettingsBindFlags.DEFAULT);
colorSchemeSettings.bind("night", this._night_appearance_chooser, "color-scheme", Gio.SettingsBindFlags.DEFAULT);
backgroundSettings.bind(
"picture-uri",
this._day_appearance_chooser,
"background-uri",
Gio.SettingsBindFlags.DEFAULT,
);
backgroundSettings.bind(
"picture-uri-dark",
this._night_appearance_chooser,
"background-uri",
Gio.SettingsBindFlags.DEFAULT,
);
}
}
+333
View File
@@ -0,0 +1,333 @@
// SPDX-FileCopyrightText: Night Theme Switcher Contributors
// SPDX-License-Identifier: GPL-3.0-or-later
import Gdk from "gi://Gdk";
import Gio from "gi://Gio";
import Gly from "gi://Gly";
import GlyGtk4 from "gi://GlyGtk4";
import GObject from "gi://GObject";
import Graphene from "gi://Graphene";
import Gsk from "gi://Gsk";
import Gtk from "gi://Gtk";
import { AccentColor } from "../enums/AccentColor.js";
import { ColorScheme } from "../enums/ColorScheme.js";
Gio._promisify(Gly.Loader.prototype, "load_async", "load_finish");
const PREVIEW_HEIGHT = 200;
const PANEL_HEIGHT = 15;
const WINDOW_WIDTH = 160;
const WINDOW_HEIGHT = 120;
const BUTTON_WIDTH = 60;
const BUTTON_HEIGHT = 20;
export class AppearancePreview extends Gtk.Widget {
/** @type {?Gdk.Texture} */
#backgroundTexture = null;
/** @type {Gdk.Texture} */
#windowDarkTexture;
/** @type {Gdk.Texture} */
#windowLightTexture;
/** @type {?AccentColor} */
#accentColor = null;
/** @type {?string} */
#backgroundUri = null;
/** @type {?ColorScheme} */
#colorScheme = null;
static {
GObject.registerClass(
{
GTypeName: "AppearancePreview",
CssName: "appearance-preview",
Properties: {
"accent-color": GObject.ParamSpec.string(
"accent-color",
"Accent color",
"Accent color",
GObject.ParamFlags.READWRITE,
null,
),
"background-uri": GObject.ParamSpec.string(
"background-uri",
"Background URI",
"URI to the background file",
GObject.ParamFlags.READWRITE,
null,
),
"color-scheme": GObject.ParamSpec.string(
"color-scheme",
"Color scheme",
"Color scheme",
GObject.ParamFlags.READWRITE,
null,
),
},
},
this,
);
}
constructor({ ...params } = {}) {
super(params);
this.#windowDarkTexture = Gdk.Texture.new_from_resource(
"/org/gnome/Shell/Extensions/nightthemeswitcher/preferences/assets/preview-window-dark.svg",
);
this.#windowLightTexture = Gdk.Texture.new_from_resource(
"/org/gnome/Shell/Extensions/nightthemeswitcher/preferences/assets/preview-window-light.svg",
);
}
get accentColor() {
return this.#accentColor;
}
set accentColor(color) {
if (!color || color === this.#accentColor || !Object.values(AccentColor).includes(color)) return;
this.#accentColor = color;
this.notify("accent-color");
this.queue_draw();
}
get backgroundUri() {
return this.#backgroundUri;
}
set backgroundUri(uri) {
if (!uri || uri === this.#backgroundUri) return;
this.#backgroundUri = uri;
this.notify("background-uri");
this.#updateBackgroundTexture();
}
get colorScheme() {
return this.#colorScheme;
}
set colorScheme(scheme) {
if (!scheme || scheme === this.#colorScheme || !Object.values(ColorScheme).includes(scheme)) return;
this.#colorScheme = scheme;
this.notify("color-scheme");
this.queue_draw();
}
/**
* @param {Gtk.Orientation} _orientation The orientation to measure.
* @param {number} _for_size Size for the opposite of orientation.
* @returns {[number, number, number, number]} Minimum size, natural size, minimum baseline, natural baseline.
*/
vfunc_measure(_orientation, _for_size) {
return [PREVIEW_HEIGHT, PREVIEW_HEIGHT, -1, -1];
}
/**
* @param {Gtk.Snapshot} snapshot The snapshot to populate.
*/
vfunc_snapshot(snapshot) {
const previewWidth = this.get_width();
const previewHeight = this.get_height();
snapshot.push_fill(buildGskRoundedRectanglePath(previewWidth, previewHeight, 12), Gsk.FillRule.WINDING);
this.#snapshotBackground(snapshot, previewWidth, previewHeight);
this.#snapshotWindows(snapshot, previewWidth, previewHeight, this.colorScheme, this.accentColor);
this.#snapshotPanel(snapshot, previewWidth, this.colorScheme);
snapshot.pop();
}
async #updateBackgroundTexture() {
try {
const loader = new Gly.Loader({ file: Gio.File.new_for_uri(this.backgroundUri) });
const image = await loader.load_async(null);
this.#backgroundTexture = GlyGtk4.frame_get_texture(image.next_frame());
} catch (e) {
console.error(e);
}
this.queue_draw();
}
/**
* @param {Gtk.Snapshot} snapshot The snapshot to populate.
* @param {number} width The width to populate.
* @param {number} height The height to populate.
*/
#snapshotBackground(snapshot, width, height) {
const ratio = width / height;
if (!this.#backgroundTexture) {
snapshot.append_color(
new Gdk.RGBA({ red: 0.08, green: 0.235, blue: 0.533, alpha: 1 }),
new Graphene.Rect({ origin: new Graphene.Point({ x: 0, y: 0 }), size: { width, height } }),
);
return;
}
let backgroundSnapshotWidth, backgroundSnapshotHeight;
const backgroundRatio = this.#backgroundTexture.get_intrinsic_aspect_ratio();
if (backgroundRatio > ratio) {
backgroundSnapshotWidth = height;
backgroundSnapshotHeight = height / backgroundRatio;
} else {
backgroundSnapshotWidth = width * backgroundRatio;
backgroundSnapshotHeight = width;
}
const x = (width - backgroundSnapshotWidth) / 2;
const y = (height - backgroundSnapshotHeight) / 2;
snapshot.save();
snapshot.translate(new Graphene.Point({ x, y }));
this.#backgroundTexture.snapshot(snapshot, backgroundSnapshotWidth, backgroundSnapshotHeight);
snapshot.restore();
}
/**
* @param {Gtk.Snapshot} snapshot The snapshot to populate.
* @param {number} width The width to populate.
* @param {number} height The height to populate.
* @param {ColorScheme} colorScheme The color scheme to render.
* @param {AccentColor} accentColor The accent color to render.
*/
#snapshotWindows(snapshot, width, height, colorScheme, accentColor) {
const usableHeight = height - PANEL_HEIGHT;
const backgroundWindowX = Math.ceil(width / 2 - WINDOW_WIDTH + WINDOW_WIDTH / 2 - width / 16);
const backgroundWindowY = Math.ceil(
usableHeight / 2 - WINDOW_HEIGHT + WINDOW_HEIGHT / 2 - usableHeight / 16 + PANEL_HEIGHT,
);
const foregroundWindowX = Math.ceil(width / 2 - WINDOW_WIDTH / 2 + width / 16);
const foregroundWindowY = Math.ceil(usableHeight / 2 - WINDOW_HEIGHT / 2 + usableHeight / 16 + PANEL_HEIGHT);
switch (colorScheme) {
case ColorScheme.DEFAULT:
this.#snapshotWindow(snapshot, backgroundWindowX, backgroundWindowY, ColorScheme.PREFER_DARK);
this.#snapshotWindow(snapshot, foregroundWindowX, foregroundWindowY, ColorScheme.PREFER_LIGHT);
break;
case ColorScheme.PREFER_DARK:
this.#snapshotWindow(snapshot, backgroundWindowX, backgroundWindowY, ColorScheme.PREFER_DARK);
this.#snapshotWindow(snapshot, foregroundWindowX, foregroundWindowY, ColorScheme.PREFER_DARK);
break;
case ColorScheme.PREFER_LIGHT:
this.#snapshotWindow(snapshot, backgroundWindowX, backgroundWindowY, ColorScheme.PREFER_LIGHT);
this.#snapshotWindow(snapshot, foregroundWindowX, foregroundWindowY, ColorScheme.PREFER_LIGHT);
break;
}
const buttonX = foregroundWindowX + WINDOW_WIDTH - BUTTON_WIDTH - 20;
const buttonY = foregroundWindowY + WINDOW_HEIGHT - BUTTON_HEIGHT - 20;
this.#snapshotButton(snapshot, buttonX, buttonY, accentColor);
}
/**
* @param {Gtk.Snapshot} snapshot The snapshot to populate.
* @param {number} x The X position at which to populate.
* @param {number} y The Y position at which to populate.
* @param {ColorScheme} colorScheme The color scheme to render.
*/
#snapshotWindow(snapshot, x, y, colorScheme) {
const texture = colorScheme === ColorScheme.PREFER_DARK ? this.#windowDarkTexture : this.#windowLightTexture;
snapshot.save();
snapshot.translate(new Graphene.Point({ x, y }));
texture.snapshot(snapshot, WINDOW_WIDTH, WINDOW_HEIGHT);
snapshot.restore();
}
/**
* @param {Gtk.Snapshot} snapshot The snapshot to populate.
* @param {number} x The X position at which to populate.
* @param {number} y The Y position at which to populate.
* @param {AccentColor} accentColor The accent color to render.
*/
#snapshotButton(snapshot, x, y, accentColor) {
let color;
switch (accentColor) {
case AccentColor.BLUE:
color = new Gdk.RGBA({ red: 0.208, green: 0.518, blue: 0.894, alpha: 1 });
break;
case AccentColor.GREEN:
color = new Gdk.RGBA({ red: 0.227, green: 0.58, blue: 0.29, alpha: 1 });
break;
case AccentColor.ORANGE:
color = new Gdk.RGBA({ red: 0.929, green: 0.357, blue: 0, alpha: 1 });
break;
case AccentColor.PINK:
color = new Gdk.RGBA({ red: 0.835, green: 0.38, blue: 0.6, alpha: 1 });
break;
case AccentColor.PURPLE:
color = new Gdk.RGBA({ red: 0.569, green: 0.255, blue: 0.675, alpha: 1 });
break;
case AccentColor.RED:
color = new Gdk.RGBA({ red: 0.902, green: 0.176, blue: 0.259, alpha: 1 });
break;
case AccentColor.SLATE:
color = new Gdk.RGBA({ red: 0.435, green: 0.514, blue: 0.588, alpha: 1 });
break;
case AccentColor.TEAL:
color = new Gdk.RGBA({ red: 0.129, green: 0.565, blue: 0.643, alpha: 1 });
break;
case AccentColor.YELLOW:
color = new Gdk.RGBA({ red: 0.784, green: 0.533, blue: 0, alpha: 1 });
break;
}
if (!color) return;
snapshot.save();
snapshot.translate(new Graphene.Point({ x, y }));
snapshot.append_fill(buildGskRoundedRectanglePath(BUTTON_WIDTH, BUTTON_HEIGHT, 3), Gsk.FillRule.WINDING, color);
snapshot.restore();
}
/**
* @param {Gtk.Snapshot} snapshot The snapshot to populate.
* @param {number} width The width to populate.
* @param {ColorScheme} colorScheme The color scheme to render.
*/
#snapshotPanel(snapshot, width, colorScheme) {
const backgroundColor =
colorScheme === ColorScheme.PREFER_LIGHT
? new Gdk.RGBA({ red: 0.98, green: 0.98, blue: 0.984, alpha: 1 })
: new Gdk.RGBA({ red: 0, green: 0, blue: 0, alpha: 1 });
snapshot.append_color(
backgroundColor,
new Graphene.Rect({
origin: new Graphene.Point({ x: 0, y: 0 }),
size: new Graphene.Size({ width, height: PANEL_HEIGHT }),
}),
);
}
}
/**
* Build a rounded rectangle path until GJS supports `Gsk.RoundedRect`.
* @param {number} width The width of the rectangle.
* @param {number} height The height of the rectangle.
* @param {number} radius The radius of the corners.
* @returns {Gsk.Path} The path.
*/
function buildGskRoundedRectanglePath(width, height, radius) {
const builder = new Gsk.PathBuilder();
builder.add_circle(new Graphene.Point({ x: radius, y: radius }), radius);
builder.add_circle(new Graphene.Point({ x: width - radius, y: radius }), radius);
builder.add_circle(new Graphene.Point({ x: radius, y: height - radius }), radius);
builder.add_circle(new Graphene.Point({ x: width - radius, y: height - radius }), radius);
builder.add_rect(
new Graphene.Rect({
origin: new Graphene.Point({ x: radius, y: 0 }),
size: new Graphene.Size({ width: width - 2 * radius, height }),
}),
);
builder.add_rect(
new Graphene.Rect({
origin: new Graphene.Point({ x: 0, y: radius }),
size: new Graphene.Size({ width, height: height - 2 * radius }),
}),
);
return builder.to_path();
}
+6 -99
View File
@@ -3,9 +3,9 @@
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 Gly from "gi://Gly";
import GObject from "gi://GObject";
import Gtk from "gi://Gtk";
@@ -14,34 +14,14 @@ import { gettext as _ } from "resource:///org/gnome/Shell/Extensions/js/extensio
Gio._promisify(Gtk.FileDialog.prototype, "open", "open_finish");
export class BackgroundButton extends Gtk.Button {
#uri;
static {
GObject.registerClass(
{
GTypeName: "BackgroundButton",
Template: "resource:///org/gnome/Shell/Extensions/nightthemeswitcher/preferences/ui/BackgroundButton.ui",
InternalChildren: ["file_dialog", "thumbnail"],
InternalChildren: ["file_dialog"],
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,
@@ -50,33 +30,10 @@ export class BackgroundButton extends Gtk.Button {
constructor({ ...params } = {}) {
super(params);
this.#setupSize();
this.#setupDropTarget();
this.#setupFileDialog();
}
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) => {
@@ -108,9 +65,7 @@ export class BackgroundButton extends Gtk.Button {
}
#getSupportedContentTypes() {
return GdkPixbuf.Pixbuf.get_formats()
.flatMap((format) => format.get_mime_types())
.concat("application/xml");
return Gly.Loader.get_mime_types().concat("application/xml");
}
#isContentTypeSupported(contentType) {
@@ -130,61 +85,13 @@ export class BackgroundButton extends Gtk.Button {
async #setURIFromFileDialog() {
try {
this._file_dialog.initial_folder = Gio.File.new_for_uri(this.uri).get_parent();
const file = await this._file_dialog.open(this.get_root(), null);
this.uri = file.get_uri();
} catch {}
}
#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);
_getBackgroundFileName(_widget, uri) {
return uri ? GLib.filename_display_basename(uri) : "";
}
}
+90
View File
@@ -0,0 +1,90 @@
// SPDX-FileCopyrightText: Night Theme Switcher Contributors
// SPDX-License-Identifier: GPL-3.0-or-later
import GObject from "gi://GObject";
import Gtk from "gi://Gtk";
import { gettext as _ } from "resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js";
import { AccentColor } from "../enums/AccentColor.js";
export class ColorChooser extends Gtk.Widget {
/** @type {Gtk.ToggleButton[]} */
#buttons = [];
/** @type {AccentColor} */
#activeColor;
static {
GObject.registerClass(
{
GTypeName: "ColorChooser",
CssName: "color-chooser",
Properties: {
color: GObject.ParamSpec.string(
"active-color",
"Active color",
"Active color",
GObject.ParamFlags.READWRITE,
null,
),
},
},
this,
);
}
constructor({ ...params } = {}) {
super({ layoutManager: new Gtk.BoxLayout({ spacing: 6 }), ...params });
const colors = [
{ accentColor: AccentColor.BLUE, name: _("Blue") },
{ accentColor: AccentColor.TEAL, name: _("Teal") },
{ accentColor: AccentColor.GREEN, name: _("Green") },
{ accentColor: AccentColor.YELLOW, name: _("Yellow") },
{ accentColor: AccentColor.ORANGE, name: _("Orange") },
{ accentColor: AccentColor.RED, name: _("Red") },
{ accentColor: AccentColor.PINK, name: _("Pink") },
{ accentColor: AccentColor.PURPLE, name: _("Purple") },
{ accentColor: AccentColor.SLATE, name: _("Slate") },
];
let toggleGroup;
for (const color of colors) {
const button = new Gtk.ToggleButton({
tooltipText: color.name,
cssClasses: ["circular", color.accentColor],
active: this.activeColor === color.accentColor,
});
if (!toggleGroup) toggleGroup = button;
else button.group = toggleGroup;
button.connect("toggled", () => {
if (!button.active) return;
this.activeColor = color.accentColor;
});
this.#buttons.push(button);
button.set_parent(this);
}
}
get activeColor() {
return this.#activeColor;
}
set activeColor(color) {
if (!color || color === this.#activeColor || !Object.values(AccentColor).includes(color)) return;
this.#activeColor = color;
this.notify("active-color");
this.#updateButtons();
}
#updateButtons() {
this.#buttons.forEach((button) => {
button.active = button.cssClasses.includes(this.activeColor);
});
}
}