Complete the Panama theme system
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
import qs.modules.settings
|
||||
import qs.services
|
||||
|
||||
ShellRoot {
|
||||
Item {
|
||||
width: 680
|
||||
height: editor.implicitHeight
|
||||
|
||||
AccentEditor {
|
||||
id: editor
|
||||
width: parent.width
|
||||
}
|
||||
|
||||
ThemeProfilePicker {
|
||||
width: parent.width
|
||||
visible: false
|
||||
}
|
||||
|
||||
AccentPicker {
|
||||
width: parent.width
|
||||
visible: false
|
||||
}
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "accent-controls-test"
|
||||
|
||||
function status(): string {
|
||||
return JSON.stringify(ThemeProfiles.activeProfile);
|
||||
}
|
||||
|
||||
function adjust(target: string, channel: string, ratio: real): string {
|
||||
editor.changeChannel(target, channel, ratio);
|
||||
return status();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -975,6 +975,19 @@ Singleton {
|
||||
{ value: "slate", label: "Slate" }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: "themeProfileId", type: "string", def: "moon", group: "appearance",
|
||||
pattern: "^[a-z0-9][a-z0-9-]{0,63}$",
|
||||
label: "Selected theme profile",
|
||||
detail: "The shipped or saved theme currently applied to the desktop",
|
||||
internal: true
|
||||
},
|
||||
{
|
||||
key: "themeProfiles", type: "json", def: [], group: "appearance",
|
||||
label: "Saved theme profiles",
|
||||
detail: "Named custom colour schemes and accent pairs",
|
||||
internal: true
|
||||
},
|
||||
|
||||
// ── Application themes ─────────────────────────────────────────────
|
||||
// ColorScheme owns GTK's light/dark theme. These are the two theme
|
||||
|
||||
@@ -14,6 +14,7 @@ import Quickshell
|
||||
// QtQuick is required even though nothing visual is declared here: `color` is a
|
||||
// QtQuick value type, and Qt.rgba() lives in its JS namespace.
|
||||
import QtQuick
|
||||
import qs.services
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
@@ -60,30 +61,20 @@ Singleton {
|
||||
// Blue is the shipped Prism -- blue leading, orchid following -- and stays
|
||||
// the default.
|
||||
//
|
||||
// `gnome` is the nearest member of GNOME's own accent-color enum, which is
|
||||
// a fixed list of nine we do not get to extend. It is what libadwaita
|
||||
// The table itself lives in services/ThemeProfileModel.js so a curated
|
||||
// accent and a custom profile are the same kind of record. Its `gnome`
|
||||
// member is the nearest name in GNOME's own accent-color enum, which is a
|
||||
// fixed list of nine we do not get to extend; it is what libadwaita
|
||||
// applications -- Files, Papers, Loupe -- are told to use, so choosing an
|
||||
// accent here recolors them too instead of leaving them in GNOME blue.
|
||||
// Nearest by hue, not by name: "rose" maps to red rather than pink because
|
||||
// it is the red role in this palette.
|
||||
readonly property var accents: ({
|
||||
"blue": { dark: "#82aaff", darkSecondary: "#b172b0", light: "#2e7de9", lightSecondary: "#9854f1", label: "Prism blue", gnome: "blue" },
|
||||
"orchid": { dark: "#c099ff", darkSecondary: "#fca7ea", light: "#7847bd", lightSecondary: "#9854f1", label: "Orchid", gnome: "purple" },
|
||||
"teal": { dark: "#86e1fc", darkSecondary: "#82aaff", light: "#007197", lightSecondary: "#2e7de9", label: "Teal", gnome: "teal" },
|
||||
"green": { dark: "#c3e88d", darkSecondary: "#86e1fc", light: "#587539", lightSecondary: "#007197", label: "Green", gnome: "green" },
|
||||
"amber": { dark: "#ffc777", darkSecondary: "#ff966c", light: "#8c6c3e", lightSecondary: "#b15c00", label: "Amber", gnome: "yellow" },
|
||||
"orange": { dark: "#ff966c", darkSecondary: "#ff757f", light: "#b15c00", lightSecondary: "#c64343", label: "Orange", gnome: "orange" },
|
||||
"rose": { dark: "#ff757f", darkSecondary: "#c099ff", light: "#f52a65", lightSecondary: "#9854f1", label: "Rose", gnome: "red" },
|
||||
"slate": { dark: "#828bb8", darkSecondary: "#82aaff", light: "#6172b0", lightSecondary: "#2e7de9", label: "Slate", gnome: "slate" }
|
||||
})
|
||||
readonly property var accents: ThemeProfiles.curatedAccents
|
||||
readonly property var activeProfile: ThemeProfiles.activeProfile
|
||||
|
||||
// Falls back to blue for an unknown name, so a settings file written by a
|
||||
// newer Panama -- or edited by hand -- degrades to the shipped identity
|
||||
// rather than to an undefined color.
|
||||
readonly property var accentPair: root.accents[DesktopPreferences.get("accentName")] ?? root.accents["blue"]
|
||||
|
||||
readonly property color accent: root.dark ? root.accentPair.dark : root.accentPair.light
|
||||
readonly property color accentSecondary: root.dark ? root.accentPair.darkSecondary : root.accentPair.lightSecondary
|
||||
// ThemeProfiles validates every persisted record before it can become
|
||||
// active, so these bindings are both reactive and safe to expose as the
|
||||
// shell-wide colour roles.
|
||||
readonly property color accent: root.activeProfile.accent
|
||||
readonly property color accentSecondary: root.activeProfile.secondary
|
||||
readonly property color accentAlt: root.dark ? "#65bcff" : "#007197" // blue1, a lighter blue
|
||||
readonly property color cyan: root.dark ? "#86e1fc" : "#007197"
|
||||
readonly property color teal: root.dark ? "#4fd6be" : "#118c74"
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
// Advanced accent editing remains a labelled, keyboard-operable extension of
|
||||
// the named fast path. Hue is always accompanied by saturation, value, a
|
||||
// numeric readout, and the two-colour preview supplied by Appearance.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
import "../../services/ThemeProfileModel.js" as ThemeProfileModel
|
||||
|
||||
Column {
|
||||
id: editor
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
spacing: 0
|
||||
|
||||
property string pickerTarget: "primary"
|
||||
property string lastError: ""
|
||||
|
||||
readonly property var primaryHsv: ThemeProfileModel.hexToHsv(
|
||||
ThemeProfiles.activeProfile.accent) || ({ h: 0, s: 0, v: 0 })
|
||||
readonly property var secondaryHsv: ThemeProfileModel.hexToHsv(
|
||||
ThemeProfiles.activeProfile.secondary) || ({ h: 0, s: 0, v: 0 })
|
||||
|
||||
function changeChannel(target: string, channel: string, ratio: real): void {
|
||||
const source = target === "primary" ? editor.primaryHsv : editor.secondaryHsv;
|
||||
const next = { h: source.h, s: source.s, v: source.v };
|
||||
next[channel] = Math.round(Math.max(0, Math.min(1, ratio))
|
||||
* (channel === "h" ? 360 : 100));
|
||||
const changed = ThemeProfileModel.hsvToHex(next.h, next.s, next.v);
|
||||
const primary = target === "primary" ? changed : ThemeProfiles.activeProfile.accent;
|
||||
const secondary = target === "secondary" ? changed : ThemeProfiles.activeProfile.secondary;
|
||||
ThemeProfiles.setAccentPair(primary, secondary);
|
||||
}
|
||||
|
||||
function pick(target: string): void {
|
||||
if (screenPicker.running)
|
||||
return;
|
||||
editor.pickerTarget = target;
|
||||
editor.lastError = "";
|
||||
screenPicker.exec(["hyprpicker", "--format=hex", "--lowercase-hex", "--quiet", "--no-fancy"]);
|
||||
}
|
||||
|
||||
function acceptPicked(value: string): void {
|
||||
const picked = String(value).trim().toLowerCase();
|
||||
if (ThemeProfileModel.hexToHsv(picked) === null) {
|
||||
editor.lastError = "The sampled colour was not valid.";
|
||||
return;
|
||||
}
|
||||
const primary = editor.pickerTarget === "primary"
|
||||
? picked : ThemeProfiles.activeProfile.accent;
|
||||
const secondary = editor.pickerTarget === "secondary"
|
||||
? picked : ThemeProfiles.activeProfile.secondary;
|
||||
ThemeProfiles.setAccentPair(primary, secondary);
|
||||
}
|
||||
|
||||
component HsvRow: SettingRow {
|
||||
id: root
|
||||
|
||||
required property string target
|
||||
required property string channel
|
||||
required property real channelValue
|
||||
required property real channelMaximum
|
||||
property string suffix: "%"
|
||||
|
||||
controlWidth: 280
|
||||
|
||||
Item {
|
||||
id: keyboardSlider
|
||||
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 270
|
||||
height: 32
|
||||
activeFocusOnTab: true
|
||||
|
||||
Accessible.role: Accessible.Slider
|
||||
Accessible.name: root.label
|
||||
Accessible.description: Math.round(root.channelValue) + root.suffix
|
||||
+ ", range 0 to " + root.channelMaximum
|
||||
Accessible.focusable: true
|
||||
Accessible.focused: activeFocus
|
||||
Accessible.onIncreaseAction: keyboardSlider.step(1)
|
||||
Accessible.onDecreaseAction: keyboardSlider.step(-1)
|
||||
|
||||
function step(direction: int): void {
|
||||
const increment = root.channel === "h" ? 1 : 1;
|
||||
const value = Math.max(0, Math.min(root.channelMaximum,
|
||||
root.channelValue + direction * increment));
|
||||
editor.changeChannel(root.target, root.channel, value / root.channelMaximum);
|
||||
}
|
||||
|
||||
Keys.onPressed: event => {
|
||||
if (event.key === Qt.Key_Left || event.key === Qt.Key_Down) {
|
||||
keyboardSlider.step(-1);
|
||||
event.accepted = true;
|
||||
} else if (event.key === Qt.Key_Right || event.key === Qt.Key_Up) {
|
||||
keyboardSlider.step(1);
|
||||
event.accepted = true;
|
||||
} else if (event.key === Qt.Key_Home) {
|
||||
editor.changeChannel(root.target, root.channel, 0);
|
||||
event.accepted = true;
|
||||
} else if (event.key === Qt.Key_End) {
|
||||
editor.changeChannel(root.target, root.channel, 1);
|
||||
event.accepted = true;
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.right: readout.left
|
||||
anchors.rightMargin: 10
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
height: 24
|
||||
radius: 9
|
||||
color: "transparent"
|
||||
border.width: keyboardSlider.activeFocus ? 2 : 1
|
||||
border.color: keyboardSlider.activeFocus ? Theme.accentSecondary : Theme.alpha(Theme.fg, 0.08)
|
||||
|
||||
ValueSlider {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 4
|
||||
value: root.channelMaximum > 0 ? root.channelValue / root.channelMaximum : 0
|
||||
onMoved: ratio => editor.changeChannel(root.target, root.channel, ratio)
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: readout
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 56
|
||||
horizontalAlignment: Text.AlignRight
|
||||
text: Math.round(root.channelValue) + root.suffix
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HsvRow {
|
||||
target: "primary"; channel: "h"; channelValue: editor.primaryHsv.h; channelMaximum: 360
|
||||
suffix: "°"; label: "Primary hue"; detail: "Colour family, measured from 0 to 360 degrees"
|
||||
}
|
||||
HsvRow {
|
||||
target: "primary"; channel: "s"; channelValue: editor.primaryHsv.s; channelMaximum: 100
|
||||
label: "Primary saturation"; detail: "Colour intensity from grey to vivid"
|
||||
}
|
||||
HsvRow {
|
||||
target: "primary"; channel: "v"; channelValue: editor.primaryHsv.v; channelMaximum: 100
|
||||
label: "Primary value"; detail: "Brightness from black to full colour"
|
||||
}
|
||||
HsvRow {
|
||||
target: "secondary"; channel: "h"; channelValue: editor.secondaryHsv.h; channelMaximum: 360
|
||||
suffix: "°"; label: "Secondary hue"; detail: "Colour family at the far end of the Prism gradient"
|
||||
}
|
||||
HsvRow {
|
||||
target: "secondary"; channel: "s"; channelValue: editor.secondaryHsv.s; channelMaximum: 100
|
||||
label: "Secondary saturation"; detail: "Colour intensity from grey to vivid"
|
||||
}
|
||||
HsvRow {
|
||||
target: "secondary"; channel: "v"; channelValue: editor.secondaryHsv.v; channelMaximum: 100
|
||||
label: "Secondary value"; detail: "Brightness from black to full colour"
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
label: "Pick colour from screen"
|
||||
detail: editor.lastError !== ""
|
||||
? editor.lastError
|
||||
: "Sample either end of the accent gradient with hyprpicker"
|
||||
divider: false
|
||||
controlWidth: 330
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 7
|
||||
|
||||
SettingsButton {
|
||||
text: "Pick primary from screen"
|
||||
enabled: !screenPicker.running
|
||||
activeFocusOnTab: enabled
|
||||
border.width: activeFocus ? 2 : 1
|
||||
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
|
||||
onClicked: editor.pick("primary")
|
||||
Keys.onReturnPressed: if (enabled) editor.pick("primary")
|
||||
Keys.onSpacePressed: if (enabled) editor.pick("primary")
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
text: "Pick secondary from screen"
|
||||
enabled: !screenPicker.running
|
||||
activeFocusOnTab: enabled
|
||||
border.width: activeFocus ? 2 : 1
|
||||
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
|
||||
onClicked: editor.pick("secondary")
|
||||
Keys.onReturnPressed: if (enabled) editor.pick("secondary")
|
||||
Keys.onSpacePressed: if (enabled) editor.pick("secondary")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: screenPicker
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: editor.acceptPicked(this.text)
|
||||
}
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (exitCode !== 0)
|
||||
editor.lastError = "Screen colour picking was cancelled or unavailable.";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ Flow {
|
||||
|
||||
spacing: 10
|
||||
|
||||
readonly property string current: DesktopPreferences.get("accentName") || "blue"
|
||||
readonly property string current: ThemeProfiles.activeAccentName
|
||||
|
||||
// The schema's own option list, not Theme.accents directly -- two lists
|
||||
// hand-kept in sync is how they drift. This is the same pattern
|
||||
@@ -60,6 +60,8 @@ Flow {
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: swatch
|
||||
|
||||
width: 46
|
||||
height: 46
|
||||
radius: 23
|
||||
@@ -67,8 +69,25 @@ Flow {
|
||||
color: "transparent"
|
||||
// The ring sits outside the gradient rather than over it, so a
|
||||
// selected swatch still shows its true colors.
|
||||
border.width: entry.selected ? 2 : 1
|
||||
border.color: entry.selected ? Theme.fg : Theme.alpha(Theme.fg, 0.14)
|
||||
border.width: swatch.activeFocus || entry.selected ? 2 : 1
|
||||
border.color: swatch.activeFocus
|
||||
? Theme.accentSecondary
|
||||
: (entry.selected ? Theme.fg : Theme.alpha(Theme.fg, 0.14))
|
||||
activeFocusOnTab: true
|
||||
|
||||
Accessible.role: Accessible.Button
|
||||
Accessible.name: entry.pair.label + " accent"
|
||||
Accessible.description: String(entry.start) + " to " + String(entry.end)
|
||||
Accessible.focusable: true
|
||||
Accessible.focused: activeFocus
|
||||
|
||||
function choose(): void {
|
||||
if (!ThemeProfiles.useCuratedAccent(entry.modelData))
|
||||
console.warn("AccentPicker: accent was rejected", entry.modelData);
|
||||
}
|
||||
|
||||
Keys.onReturnPressed: swatch.choose()
|
||||
Keys.onSpacePressed: swatch.choose()
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
@@ -81,6 +100,9 @@ Flow {
|
||||
GradientStop { position: 1.0; color: entry.end }
|
||||
}
|
||||
}
|
||||
|
||||
HoverHandler { cursorShape: Qt.PointingHandCursor }
|
||||
TapHandler { onTapped: swatch.choose() }
|
||||
}
|
||||
|
||||
// Always shown, not a tooltip. Telling swatches apart by color is
|
||||
|
||||
@@ -129,18 +129,26 @@ SettingsPage {
|
||||
|
||||
SettingsCard {
|
||||
visible: root.tab === "theme"
|
||||
title: "Color scheme"
|
||||
// Not "Color scheme" any more: the card holds the saved themes as well
|
||||
// as the scheme, and a theme carries both ends of the accent with it.
|
||||
title: "Theme"
|
||||
subtitle: ColorScheme.lastError !== ""
|
||||
? ColorScheme.lastError
|
||||
: "Light is Tokyo Night Day, the official light variant — the same hues at a different lightness, so the blue-into-orchid signature survives the switch. Applications and window borders follow."
|
||||
: "Start with Moon, Moon Rose, or Day; saved themes capture the scheme and both ends of the Prism accent."
|
||||
|
||||
ChoiceRow { setting: "colorScheme" }
|
||||
ThemeProfilePicker {
|
||||
width: parent.width
|
||||
}
|
||||
|
||||
// Drawn as the gradient each accent produces rather than a flat dot,
|
||||
// because the gradient is what is being chosen.
|
||||
AccentPicker {
|
||||
width: parent.width
|
||||
}
|
||||
|
||||
AccentEditor {
|
||||
width: parent.width
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
// Named themes are the first layer of Appearance: shipped profiles stay
|
||||
// immutable, while saved profiles can be selected or removed in place.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
spacing: 0
|
||||
|
||||
Repeater {
|
||||
model: ThemeProfiles.profiles
|
||||
|
||||
SettingRow {
|
||||
id: profileRow
|
||||
|
||||
required property var modelData
|
||||
|
||||
label: profileRow.modelData.name
|
||||
detail: (profileRow.modelData.scheme === "light" ? "Light" : "Dark")
|
||||
+ " · " + profileRow.modelData.accent + " → " + profileRow.modelData.secondary
|
||||
controlWidth: profileRow.modelData.shipped ? 88 : 170
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 6
|
||||
|
||||
SettingsButton {
|
||||
id: useButton
|
||||
text: profileRow.modelData.id === ThemeProfiles.activeProfile.id ? "Selected" : "Use"
|
||||
enabled: profileRow.modelData.id !== ThemeProfiles.activeProfile.id
|
||||
activeFocusOnTab: enabled
|
||||
border.width: activeFocus ? 2 : (tone === "accent" ? 0 : 1)
|
||||
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
|
||||
onClicked: ThemeProfiles.selectProfile(profileRow.modelData.id)
|
||||
Keys.onReturnPressed: if (enabled) ThemeProfiles.selectProfile(profileRow.modelData.id)
|
||||
Keys.onSpacePressed: if (enabled) ThemeProfiles.selectProfile(profileRow.modelData.id)
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
id: deleteButton
|
||||
visible: !profileRow.modelData.shipped
|
||||
text: "Delete"
|
||||
activeFocusOnTab: visible
|
||||
border.width: activeFocus ? 2 : 1
|
||||
border.color: activeFocus ? Theme.danger : Theme.alpha(Theme.fg, 0.08)
|
||||
onClicked: ThemeProfiles.deleteProfile(profileRow.modelData.id)
|
||||
Keys.onReturnPressed: ThemeProfiles.deleteProfile(profileRow.modelData.id)
|
||||
Keys.onSpacePressed: ThemeProfiles.deleteProfile(profileRow.modelData.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
label: "Save current theme"
|
||||
detail: "Stores this scheme and accent pair under a unique name"
|
||||
divider: false
|
||||
controlWidth: 292
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 7
|
||||
|
||||
Rectangle {
|
||||
width: 196
|
||||
height: 31
|
||||
radius: 8
|
||||
color: Theme.alpha(Theme.fg, 0.05)
|
||||
border.width: nameInput.activeFocus ? 2 : 1
|
||||
border.color: nameInput.activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.12)
|
||||
|
||||
TextInput {
|
||||
id: nameInput
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 10
|
||||
anchors.rightMargin: 10
|
||||
activeFocusOnTab: true
|
||||
verticalAlignment: TextInput.AlignVCenter
|
||||
maximumLength: 40
|
||||
color: Theme.fg
|
||||
selectionColor: Theme.alpha(Theme.accent, 0.35)
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
Accessible.name: "Theme profile name"
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: nameInput.text === "" && !nameInput.activeFocus
|
||||
text: "Theme name"
|
||||
color: Theme.fgMuted
|
||||
font: nameInput.font
|
||||
}
|
||||
|
||||
onAccepted: saveButton.save()
|
||||
}
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
id: saveButton
|
||||
text: "Save"
|
||||
tone: "accent"
|
||||
activeFocusOnTab: true
|
||||
border.width: activeFocus ? 2 : 0
|
||||
border.color: activeFocus ? Theme.fg : "transparent"
|
||||
|
||||
function save(): void {
|
||||
if (ThemeProfiles.saveProfile(nameInput.text))
|
||||
nameInput.text = "";
|
||||
}
|
||||
|
||||
onClicked: save()
|
||||
Keys.onReturnPressed: save()
|
||||
Keys.onSpacePressed: save()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,3 +80,5 @@ SearchPicker 1.0 SearchPicker.qml
|
||||
OnlineAccountsPage 1.0 OnlineAccountsPage.qml
|
||||
AccentPicker 1.0 AccentPicker.qml
|
||||
TextFieldRow 1.0 TextFieldRow.qml
|
||||
AccentEditor 1.0 AccentEditor.qml
|
||||
ThemeProfilePicker 1.0 ThemeProfilePicker.qml
|
||||
|
||||
@@ -93,6 +93,14 @@ Singleton {
|
||||
function onRevisionChanged(): void { coalesce.restart(); }
|
||||
}
|
||||
|
||||
// Profile selection and custom edits both update preferences, but binding
|
||||
// directly to the effective profile also documents the in-shell ownership
|
||||
// boundary and keeps this propagation reactive if that storage changes.
|
||||
Connections {
|
||||
target: ThemeProfiles
|
||||
function onActiveProfileChanged(): void { coalesce.restart(); }
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: coalesce
|
||||
interval: 250
|
||||
|
||||
@@ -136,7 +136,10 @@ Singleton {
|
||||
{ label: "Per-display wallpaper", detail: "Assign a different image to each connected display", page: "appearance" },
|
||||
{ label: "Arrange displays", detail: "Drag connected displays into their physical positions", page: "displays" },
|
||||
{ label: "Monitor position", detail: "Set where each display sits in the desktop", page: "displays" },
|
||||
{ label: "Primary display", detail: "Choose the display that anchors the desktop", page: "displays" }
|
||||
{ label: "Primary display", detail: "Choose the display that anchors the desktop", page: "displays" },
|
||||
{ label: "Theme profiles", detail: "Switch between Moon, Moon Rose, Day, and saved themes", page: "appearance" },
|
||||
{ label: "Advanced accent", detail: "Adjust primary and secondary hue, saturation, and value", page: "appearance" },
|
||||
{ label: "Pick colour from screen", detail: "Sample an accent colour with hyprpicker", page: "appearance" }
|
||||
]
|
||||
|
||||
function pageFor(group: string): string {
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
var MAX_NAME_LENGTH = 40;
|
||||
|
||||
var SHIPPED = [
|
||||
{
|
||||
id: "moon",
|
||||
name: "Moon",
|
||||
scheme: "dark",
|
||||
accent: "#82aaff",
|
||||
secondary: "#b172b0",
|
||||
shipped: true
|
||||
},
|
||||
{
|
||||
id: "moon-rose",
|
||||
name: "Moon Rose",
|
||||
scheme: "dark",
|
||||
accent: "#ff757f",
|
||||
secondary: "#c099ff",
|
||||
shipped: true
|
||||
},
|
||||
{
|
||||
id: "day",
|
||||
name: "Day",
|
||||
scheme: "light",
|
||||
accent: "#2e7de9",
|
||||
secondary: "#9854f1",
|
||||
shipped: true
|
||||
}
|
||||
];
|
||||
|
||||
// The curated accents. `gnome` is the nearest member of GNOME's own
|
||||
// accent-color enum, which is a fixed list of nine we do not get to extend. It
|
||||
// is what libadwaita applications -- Files, Papers, Loupe -- are told to use, so
|
||||
// choosing an accent here recolors them too instead of leaving them in GNOME
|
||||
// blue. Nearest by hue, not by name: "rose" maps to red rather than pink
|
||||
// because it is the red role in this palette. adwaita-accent-contract reads
|
||||
// this table and fails when a member is missing or is not in GNOME's enum.
|
||||
var CURATED = {
|
||||
blue: {
|
||||
dark: "#82aaff", darkSecondary: "#b172b0",
|
||||
light: "#2e7de9", lightSecondary: "#9854f1",
|
||||
label: "Prism blue",
|
||||
gnome: "blue"
|
||||
},
|
||||
orchid: {
|
||||
dark: "#c099ff", darkSecondary: "#fca7ea",
|
||||
light: "#7847bd", lightSecondary: "#9854f1",
|
||||
label: "Orchid",
|
||||
gnome: "purple"
|
||||
},
|
||||
teal: {
|
||||
dark: "#86e1fc", darkSecondary: "#82aaff",
|
||||
light: "#007197", lightSecondary: "#2e7de9",
|
||||
label: "Teal",
|
||||
gnome: "teal"
|
||||
},
|
||||
green: {
|
||||
dark: "#c3e88d", darkSecondary: "#86e1fc",
|
||||
light: "#587539", lightSecondary: "#007197",
|
||||
label: "Green",
|
||||
gnome: "green"
|
||||
},
|
||||
amber: {
|
||||
dark: "#ffc777", darkSecondary: "#ff966c",
|
||||
light: "#8c6c3e", lightSecondary: "#b15c00",
|
||||
label: "Amber",
|
||||
gnome: "yellow"
|
||||
},
|
||||
orange: {
|
||||
dark: "#ff966c", darkSecondary: "#ff757f",
|
||||
light: "#b15c00", lightSecondary: "#c64343",
|
||||
label: "Orange",
|
||||
gnome: "orange"
|
||||
},
|
||||
rose: {
|
||||
dark: "#ff757f", darkSecondary: "#c099ff",
|
||||
light: "#f52a65", lightSecondary: "#9854f1",
|
||||
label: "Rose",
|
||||
gnome: "red"
|
||||
},
|
||||
slate: {
|
||||
dark: "#828bb8", darkSecondary: "#82aaff",
|
||||
light: "#6172b0", lightSecondary: "#2e7de9",
|
||||
label: "Slate",
|
||||
gnome: "slate"
|
||||
}
|
||||
};
|
||||
|
||||
function copyProfile(profile) {
|
||||
return {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
scheme: profile.scheme,
|
||||
accent: profile.accent,
|
||||
secondary: profile.secondary,
|
||||
shipped: profile.shipped === true
|
||||
};
|
||||
}
|
||||
|
||||
function shippedProfiles() {
|
||||
return SHIPPED.map(copyProfile);
|
||||
}
|
||||
|
||||
function curatedAccents() {
|
||||
var result = {};
|
||||
Object.keys(CURATED).forEach(function(name) {
|
||||
result[name] = Object.assign({}, CURATED[name]);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function isScheme(value) {
|
||||
return value === "dark" || value === "light";
|
||||
}
|
||||
|
||||
function normalizedColor(value) {
|
||||
var color = String(value || "").trim().toLowerCase();
|
||||
return /^#[0-9a-f]{6}$/.test(color) ? color : null;
|
||||
}
|
||||
|
||||
function normalizeStoredProfile(value) {
|
||||
if (!value || typeof value !== "object" || value.shipped === true)
|
||||
return null;
|
||||
|
||||
var id = String(value.id || "").trim();
|
||||
var name = String(value.name || "").trim();
|
||||
var accent = normalizedColor(value.accent);
|
||||
var secondary = normalizedColor(value.secondary);
|
||||
if (!/^custom-[a-z0-9][a-z0-9-]{0,56}$/.test(id)
|
||||
|| name.length === 0 || name.length > MAX_NAME_LENGTH
|
||||
|| !isScheme(value.scheme) || !accent || !secondary)
|
||||
return null;
|
||||
|
||||
return {
|
||||
id: id,
|
||||
name: name,
|
||||
scheme: value.scheme,
|
||||
accent: accent,
|
||||
secondary: secondary,
|
||||
shipped: false
|
||||
};
|
||||
}
|
||||
|
||||
function validCustomProfiles(values) {
|
||||
if (!Array.isArray(values))
|
||||
return [];
|
||||
|
||||
var ids = {};
|
||||
var names = {};
|
||||
SHIPPED.forEach(function(profile) {
|
||||
ids[profile.id] = true;
|
||||
names[profile.name.toLowerCase()] = true;
|
||||
});
|
||||
|
||||
var result = [];
|
||||
values.forEach(function(value) {
|
||||
var profile = normalizeStoredProfile(value);
|
||||
if (!profile)
|
||||
return;
|
||||
var foldedName = profile.name.toLowerCase();
|
||||
if (ids[profile.id] || names[foldedName])
|
||||
return;
|
||||
ids[profile.id] = true;
|
||||
names[foldedName] = true;
|
||||
result.push(profile);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function profileCatalog(values) {
|
||||
return shippedProfiles().concat(validCustomProfiles(values));
|
||||
}
|
||||
|
||||
function boundedName(value) {
|
||||
var name = String(value || "").trim();
|
||||
if (!name)
|
||||
name = "Custom theme";
|
||||
return name.slice(0, MAX_NAME_LENGTH);
|
||||
}
|
||||
|
||||
function uniqueName(value, profiles) {
|
||||
var requested = boundedName(value);
|
||||
var names = {};
|
||||
profiles.forEach(function(profile) {
|
||||
names[profile.name.toLowerCase()] = true;
|
||||
});
|
||||
if (!names[requested.toLowerCase()])
|
||||
return requested;
|
||||
|
||||
for (var suffix = 2; suffix < 10000; suffix++) {
|
||||
var ending = " " + suffix;
|
||||
var candidate = requested.slice(0, MAX_NAME_LENGTH - ending.length) + ending;
|
||||
if (!names[candidate.toLowerCase()])
|
||||
return candidate;
|
||||
}
|
||||
return requested.slice(0, MAX_NAME_LENGTH - 6) + " 10000";
|
||||
}
|
||||
|
||||
function slug(value) {
|
||||
var result = String(value || "").toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 48)
|
||||
.replace(/-+$/g, "");
|
||||
return result || "theme";
|
||||
}
|
||||
|
||||
function uniqueId(name, profiles) {
|
||||
var base = "custom-" + slug(name);
|
||||
var ids = {};
|
||||
profiles.forEach(function(profile) { ids[profile.id] = true; });
|
||||
if (!ids[base])
|
||||
return base;
|
||||
for (var suffix = 2; suffix < 10000; suffix++) {
|
||||
var candidate = base.slice(0, 57 - String(suffix).length) + "-" + suffix;
|
||||
if (!ids[candidate])
|
||||
return candidate;
|
||||
}
|
||||
return base.slice(0, 52) + "-10000";
|
||||
}
|
||||
|
||||
function createCustomProfile(values, input) {
|
||||
var customs = validCustomProfiles(values);
|
||||
var catalog = shippedProfiles().concat(customs);
|
||||
var scheme = input && input.scheme;
|
||||
var accent = normalizedColor(input && input.accent);
|
||||
var secondary = normalizedColor(input && input.secondary);
|
||||
if (!isScheme(scheme) || !accent || !secondary)
|
||||
return { profiles: customs, profile: null };
|
||||
|
||||
var name = uniqueName(input && input.name, catalog);
|
||||
var profile = {
|
||||
id: uniqueId(name, catalog),
|
||||
name: name,
|
||||
scheme: scheme,
|
||||
accent: accent,
|
||||
secondary: secondary,
|
||||
shipped: false
|
||||
};
|
||||
return { profiles: customs.concat([profile]), profile: profile };
|
||||
}
|
||||
|
||||
function findProfile(values, id) {
|
||||
var catalog = profileCatalog(values);
|
||||
for (var index = 0; index < catalog.length; index++) {
|
||||
if (catalog[index].id === id)
|
||||
return catalog[index];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function editProfile(values, selected, changes) {
|
||||
var customs = validCustomProfiles(values);
|
||||
if (!selected || typeof selected !== "object")
|
||||
return { profiles: customs, profile: null };
|
||||
|
||||
var accent = normalizedColor(changes && changes.accent !== undefined
|
||||
? changes.accent : selected.accent);
|
||||
var secondary = normalizedColor(changes && changes.secondary !== undefined
|
||||
? changes.secondary : selected.secondary);
|
||||
var scheme = changes && changes.scheme !== undefined ? changes.scheme : selected.scheme;
|
||||
if (!isScheme(scheme) || !accent || !secondary)
|
||||
return { profiles: customs, profile: null };
|
||||
|
||||
if (selected.shipped === true) {
|
||||
return createCustomProfile(customs, {
|
||||
name: selected.name + " custom",
|
||||
scheme: scheme,
|
||||
accent: accent,
|
||||
secondary: secondary
|
||||
});
|
||||
}
|
||||
|
||||
var stored = normalizeStoredProfile(selected);
|
||||
if (!stored)
|
||||
return { profiles: customs, profile: null };
|
||||
|
||||
var updated = Object.assign({}, stored, {
|
||||
scheme: scheme,
|
||||
accent: accent,
|
||||
secondary: secondary
|
||||
});
|
||||
var found = false;
|
||||
var next = customs.map(function(profile) {
|
||||
if (profile.id !== updated.id)
|
||||
return profile;
|
||||
found = true;
|
||||
return updated;
|
||||
});
|
||||
if (!found)
|
||||
next.push(updated);
|
||||
return { profiles: next, profile: updated };
|
||||
}
|
||||
|
||||
function deleteProfile(values, id) {
|
||||
var customs = validCustomProfiles(values);
|
||||
for (var shippedIndex = 0; shippedIndex < SHIPPED.length; shippedIndex++) {
|
||||
if (SHIPPED[shippedIndex].id === id)
|
||||
return { profiles: customs, removed: false };
|
||||
}
|
||||
var next = customs.filter(function(profile) { return profile.id !== id; });
|
||||
return { profiles: next, removed: next.length !== customs.length };
|
||||
}
|
||||
|
||||
function curatedPair(name, scheme) {
|
||||
var entry = CURATED[name] || CURATED.blue;
|
||||
if (scheme === "light")
|
||||
return { accent: entry.light, secondary: entry.lightSecondary };
|
||||
return { accent: entry.dark, secondary: entry.darkSecondary };
|
||||
}
|
||||
|
||||
function matchingShippedProfile(scheme, accent, secondary) {
|
||||
var first = normalizedColor(accent);
|
||||
var second = normalizedColor(secondary);
|
||||
for (var index = 0; index < SHIPPED.length; index++) {
|
||||
var profile = SHIPPED[index];
|
||||
if (profile.scheme === scheme && profile.accent === first && profile.secondary === second)
|
||||
return copyProfile(profile);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function curatedNameForProfile(profile) {
|
||||
if (!profile || !isScheme(profile.scheme))
|
||||
return "";
|
||||
var accent = normalizedColor(profile.accent);
|
||||
var secondary = normalizedColor(profile.secondary);
|
||||
var names = Object.keys(CURATED);
|
||||
for (var index = 0; index < names.length; index++) {
|
||||
var name = names[index];
|
||||
var pair = curatedPair(name, profile.scheme);
|
||||
if (pair.accent === accent && pair.secondary === secondary)
|
||||
return name;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function clamp(value, minimum, maximum) {
|
||||
return Math.max(minimum, Math.min(maximum, Number(value)));
|
||||
}
|
||||
|
||||
function channelHex(value) {
|
||||
var text = Math.round(value).toString(16);
|
||||
return text.length < 2 ? "0" + text : text;
|
||||
}
|
||||
|
||||
function hsvToHex(hue, saturation, value) {
|
||||
var h = Number(hue);
|
||||
var s = Number(saturation);
|
||||
var v = Number(value);
|
||||
if (!isFinite(h) || !isFinite(s) || !isFinite(v))
|
||||
return null;
|
||||
h = ((h % 360) + 360) % 360;
|
||||
s = clamp(s, 0, 100) / 100;
|
||||
v = clamp(v, 0, 100) / 100;
|
||||
|
||||
var chroma = v * s;
|
||||
var section = h / 60;
|
||||
var x = chroma * (1 - Math.abs(section % 2 - 1));
|
||||
var red = 0;
|
||||
var green = 0;
|
||||
var blue = 0;
|
||||
if (section < 1) { red = chroma; green = x; }
|
||||
else if (section < 2) { red = x; green = chroma; }
|
||||
else if (section < 3) { green = chroma; blue = x; }
|
||||
else if (section < 4) { green = x; blue = chroma; }
|
||||
else if (section < 5) { red = x; blue = chroma; }
|
||||
else { red = chroma; blue = x; }
|
||||
var match = v - chroma;
|
||||
return "#" + channelHex((red + match) * 255)
|
||||
+ channelHex((green + match) * 255)
|
||||
+ channelHex((blue + match) * 255);
|
||||
}
|
||||
|
||||
function hexToHsv(value) {
|
||||
var color = normalizedColor(value);
|
||||
if (!color)
|
||||
return null;
|
||||
var red = parseInt(color.slice(1, 3), 16) / 255;
|
||||
var green = parseInt(color.slice(3, 5), 16) / 255;
|
||||
var blue = parseInt(color.slice(5, 7), 16) / 255;
|
||||
var maximum = Math.max(red, green, blue);
|
||||
var minimum = Math.min(red, green, blue);
|
||||
var delta = maximum - minimum;
|
||||
var hue = 0;
|
||||
if (delta !== 0) {
|
||||
if (maximum === red)
|
||||
hue = 60 * (((green - blue) / delta) % 6);
|
||||
else if (maximum === green)
|
||||
hue = 60 * ((blue - red) / delta + 2);
|
||||
else
|
||||
hue = 60 * ((red - green) / delta + 4);
|
||||
}
|
||||
if (hue < 0)
|
||||
hue += 360;
|
||||
return {
|
||||
h: Math.round(hue),
|
||||
s: Math.round((maximum === 0 ? 0 : delta / maximum) * 100),
|
||||
v: Math.round(maximum * 100)
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined") {
|
||||
module.exports = {
|
||||
MAX_NAME_LENGTH: MAX_NAME_LENGTH,
|
||||
shippedProfiles: shippedProfiles,
|
||||
curatedAccents: curatedAccents,
|
||||
validCustomProfiles: validCustomProfiles,
|
||||
profileCatalog: profileCatalog,
|
||||
createCustomProfile: createCustomProfile,
|
||||
findProfile: findProfile,
|
||||
editProfile: editProfile,
|
||||
deleteProfile: deleteProfile,
|
||||
curatedPair: curatedPair,
|
||||
matchingShippedProfile: matchingShippedProfile,
|
||||
curatedNameForProfile: curatedNameForProfile,
|
||||
hsvToHex: hsvToHex,
|
||||
hexToHsv: hexToHsv
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
pragma Singleton
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import "ThemeProfileModel.js" as ThemeProfileModel
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property var customProfiles: ThemeProfileModel.validCustomProfiles(
|
||||
DesktopPreferences.get("themeProfiles"))
|
||||
readonly property var curatedAccents: ThemeProfileModel.curatedAccents()
|
||||
readonly property var profiles: ThemeProfileModel.profileCatalog(root.customProfiles)
|
||||
readonly property string activeId: DesktopPreferences.get("themeProfileId") || "moon"
|
||||
readonly property var activeProfile: ThemeProfileModel.findProfile(
|
||||
root.customProfiles, root.activeId) || ThemeProfileModel.shippedProfiles()[0]
|
||||
readonly property string activeAccentName: ThemeProfileModel.curatedNameForProfile(
|
||||
root.activeProfile)
|
||||
|
||||
property bool reconciling: false
|
||||
|
||||
function commitActive(profile: var): bool {
|
||||
if (!profile)
|
||||
return false;
|
||||
|
||||
// Scheme first: if another control changed it, the reconciliation hook
|
||||
// may briefly select Day/Moon before this final stable profile id lands.
|
||||
const schemeAccepted = SystemSettings.commitPreference("colorScheme", profile.scheme);
|
||||
const profileAccepted = SystemSettings.commitPreference("themeProfileId", profile.id);
|
||||
return schemeAccepted && profileAccepted;
|
||||
}
|
||||
|
||||
function selectProfile(id: string): bool {
|
||||
return root.commitActive(ThemeProfileModel.findProfile(root.customProfiles, id));
|
||||
}
|
||||
|
||||
// Editing a shipped profile creates a saved custom copy. Editing a custom
|
||||
// profile updates only that record. ThemeProfileModel enforces both rules,
|
||||
// leaving this singleton responsible only for validated preference routing.
|
||||
function setAccentPair(accent: string, secondary: string): bool {
|
||||
const result = ThemeProfileModel.editProfile(root.customProfiles, root.activeProfile, {
|
||||
accent: accent,
|
||||
secondary: secondary
|
||||
});
|
||||
if (!result.profile)
|
||||
return false;
|
||||
if (!SystemSettings.commitPreference("themeProfiles", result.profiles))
|
||||
return false;
|
||||
return SystemSettings.commitPreference("themeProfileId", result.profile.id);
|
||||
}
|
||||
|
||||
function saveProfile(name: string): bool {
|
||||
const result = ThemeProfileModel.createCustomProfile(root.customProfiles, {
|
||||
name: name,
|
||||
scheme: root.activeProfile.scheme,
|
||||
accent: root.activeProfile.accent,
|
||||
secondary: root.activeProfile.secondary
|
||||
});
|
||||
if (!result.profile)
|
||||
return false;
|
||||
if (!SystemSettings.commitPreference("themeProfiles", result.profiles))
|
||||
return false;
|
||||
return SystemSettings.commitPreference("themeProfileId", result.profile.id);
|
||||
}
|
||||
|
||||
function deleteProfile(id: string): bool {
|
||||
const result = ThemeProfileModel.deleteProfile(root.customProfiles, id);
|
||||
if (!result.removed)
|
||||
return false;
|
||||
if (!SystemSettings.commitPreference("themeProfiles", result.profiles))
|
||||
return false;
|
||||
if (root.activeId === id)
|
||||
return root.selectProfile(DesktopPreferences.get("colorScheme") === "light" ? "day" : "moon");
|
||||
return true;
|
||||
}
|
||||
|
||||
function useCuratedAccent(name: string): bool {
|
||||
const scheme = DesktopPreferences.get("colorScheme") === "light" ? "light" : "dark";
|
||||
const pair = ThemeProfileModel.curatedPair(name, scheme);
|
||||
const shipped = ThemeProfileModel.matchingShippedProfile(
|
||||
scheme, pair.accent, pair.secondary);
|
||||
const accepted = shipped
|
||||
? root.commitActive(shipped)
|
||||
: root.setAccentPair(pair.accent, pair.secondary);
|
||||
if (accepted)
|
||||
SystemSettings.commitPreference("accentName", name);
|
||||
return accepted;
|
||||
}
|
||||
|
||||
function reconcileScheme(): void {
|
||||
if (root.reconciling)
|
||||
return;
|
||||
const scheme = DesktopPreferences.get("colorScheme") === "light" ? "light" : "dark";
|
||||
if (root.activeProfile.scheme === scheme)
|
||||
return;
|
||||
root.reconciling = true;
|
||||
SystemSettings.commitPreference("themeProfileId", scheme === "light" ? "day" : "moon");
|
||||
root.reconciling = false;
|
||||
}
|
||||
|
||||
Component.onCompleted: root.reconcileScheme()
|
||||
|
||||
Connections {
|
||||
target: DesktopPreferences
|
||||
function onRevisionChanged(): void { root.reconcileScheme(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
ShellRoot {
|
||||
IpcHandler {
|
||||
target: "theme-profiles-test"
|
||||
|
||||
function status(): string {
|
||||
return JSON.stringify({
|
||||
active: ThemeProfiles.activeProfile,
|
||||
profiles: ThemeProfiles.profiles,
|
||||
stored: DesktopPreferences.get("themeProfiles")
|
||||
});
|
||||
}
|
||||
|
||||
function select(id: string): bool {
|
||||
return ThemeProfiles.selectProfile(id);
|
||||
}
|
||||
|
||||
function scheme(value: string): bool {
|
||||
return SystemSettings.commitPreference("colorScheme", value);
|
||||
}
|
||||
|
||||
function edit(accent: string, secondary: string): bool {
|
||||
return ThemeProfiles.setAccentPair(accent, secondary);
|
||||
}
|
||||
|
||||
function save(name: string): bool {
|
||||
return ThemeProfiles.saveProfile(name);
|
||||
}
|
||||
|
||||
function remove(id: string): bool {
|
||||
return ThemeProfiles.deleteProfile(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user