feat(theme): inject external color palettes into matugen (#3509)

* matugen: support multiple injected palettes with settings ui

Run user-configured external commands on each theme build and merge their
JSON output into matugen's import data under a namespace, so templates can
reference e.g. {{mypalette.color0}} alongside dank16. Multiple palettes are
supported, each under its own namespace. Command output may be nested JSON
(pywal/wallust shape). A settings page manages the list; a legacy
single-object config file is still read.

* settings: map PaletteInjectTab in search index

* matugen: bound palette-inject command with timeout and WaitDelay

* fix palette column spacing (review suggested

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>

* remove wallpaper kind guard (review suggestion)

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>

* fixup! remove wallpaper kind guard (review suggestion)

---------

Co-authored-by: bbedward <[email protected]>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
This commit is contained in:
koopa
2026-09-23 10:26:47 -04:00
committed by GitHub
co-authored by claude[bot] bbedward
parent 4bdfcfc0cf
commit f22ad2c333
10 changed files with 709 additions and 5 deletions
+15 -2
View File
@@ -493,8 +493,10 @@ func buildOnce(opts *Options) (bool, error) {
primaryLight = primaryDark
}
injections := InjectedPalettes(opts.ConfigDir, sourceImage, opts.Mode)
dank16JSON = generateDank16Variants(primaryDark, primaryLight, surfaceDark, surfaceLight, containerDark, containerLight, opts.Mode)
importArgs = []string{"--import-json-string", buildImportData(dank16JSON, sourceImage, specColors)}
importArgs = []string{"--import-json-string", buildImportData(dank16JSON, sourceImage, specColors, injections)}
log.Infof("Running matugen %s with dank16 injection", opts.Kind)
switch opts.Kind {
@@ -613,7 +615,9 @@ func appendContrastArg(args []string, contrast float64) []string {
// set only when the source was rewritten from a wallpaper to a hex color, where
// matugen leaves {{image}} unset and templates using it would render "Null".
// colors, when set, is a full role map that replaces matugen's own palette.
func buildImportData(dank16JSON, image, colors string) string {
// Each injection is exposed under its own namespace (e.g. {{mypalette.color0}}),
// skipping any name already emitted so the object stays valid.
func buildImportData(dank16JSON, image, colors string, injections []paletteInjection) string {
fields := []string{fmt.Sprintf(`"dank16": %s`, dank16JSON)}
if image != "" {
path, _ := json.Marshal(image)
@@ -622,6 +626,15 @@ func buildImportData(dank16JSON, image, colors string) string {
if colors != "" {
fields = append(fields, fmt.Sprintf(`"colors": %s`, colors))
}
seen := map[string]bool{"dank16": true, "image": true, "colors": true}
for _, inj := range injections {
if inj.Namespace == "" || inj.JSON == "" || seen[inj.Namespace] {
continue
}
seen[inj.Namespace] = true
nsKey, _ := json.Marshal(inj.Namespace)
fields = append(fields, fmt.Sprintf(`%s: %s`, nsKey, inj.JSON))
}
return "{" + strings.Join(fields, ", ") + "}"
}
+3 -3
View File
@@ -416,12 +416,12 @@ func TestBuildMatugenArgsDefaultPreservesExistingBehavior(t *testing.T) {
func TestBuildImportData(t *testing.T) {
const dank16 = `{"color0":"#000000"}`
assert.Equal(t, `{"dank16": {"color0":"#000000"}}`, buildImportData(dank16, "", ""),
assert.Equal(t, `{"dank16": {"color0":"#000000"}}`, buildImportData(dank16, "", "", nil),
"no image must produce byte-identical import data to pre-feature behavior")
assert.Equal(t, `{"dank16": {"color0":"#000000"}, "image": "/home/u/My Wallpaper.png"}`,
buildImportData(dank16, "/home/u/My Wallpaper.png", ""))
buildImportData(dank16, "/home/u/My Wallpaper.png", "", nil))
assert.Equal(t, `{"dank16": {"color0":"#000000"}, "image": "/home/u/a\"b\\c.png"}`,
buildImportData(dank16, `/home/u/a"b\c.png`, ""), "paths must be escaped, not interpolated raw")
buildImportData(dank16, `/home/u/a"b\c.png`, "", nil), "paths must be escaped, not interpolated raw")
}
// writeTestPNG encodes img as a PNG at path, failing the test on any error.
+214
View File
@@ -0,0 +1,214 @@
package matugen
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
// paletteEntry runs an external command that emits a JSON object of colors and
// merges it into matugen's render data under Namespace, so user templates can
// reference e.g. {{mypalette.color0}}. The palette logic stays entirely in the
// command; DMS only runs it and forwards the result.
type paletteEntry struct {
Enabled bool `json:"enabled"`
Command string `json:"command"`
// Args are passed verbatim; {image} and {mode} are substituted, and the same
// values are exported as $DMS_WALLPAPER and $DMS_MODE.
Args []string `json:"args"`
// OutputFile is where the command writes its JSON (also exported as
// $DMS_PALETTE_OUT); when empty DMS reads the command's stdout instead.
// {image}/{mode} are substituted here too.
OutputFile string `json:"output_file"`
// Namespace is the palette's key in matugen's import data. When empty it
// falls back to a positional name (palette1, palette2, ...).
Namespace string `json:"namespace"`
}
// paletteInjectConfig accepts both a {"palettes": [...]} list and a bare single
// palette object, so a file written before multi-palette support still loads.
type paletteInjectConfig struct {
Palettes []paletteEntry `json:"palettes"`
paletteEntry
}
// paletteInjection is a resolved palette: its namespace and the compact JSON its
// command produced.
type paletteInjection struct {
Namespace string
JSON string
}
// loadPaletteInjectConfig returns the enabled palettes, or nil when the config
// is absent. A bare single-object file is normalized into a one-element list.
func loadPaletteInjectConfig(configDir string) ([]paletteEntry, error) {
if configDir == "" {
return nil, nil
}
path := filepath.Join(configDir, "matugen", "palette-inject.json")
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var cfg paletteInjectConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
entries := cfg.Palettes
if len(entries) == 0 && cfg.Command != "" {
entries = []paletteEntry{cfg.paletteEntry}
}
out := make([]paletteEntry, 0, len(entries))
for i, e := range entries {
if !e.Enabled || e.Command == "" {
continue
}
if e.Namespace == "" {
e.Namespace = fmt.Sprintf("palette%d", i+1)
}
out = append(out, e)
}
return out, nil
}
func substituteTokens(s, image, mode string) string {
s = strings.ReplaceAll(s, "{image}", image)
s = strings.ReplaceAll(s, "{mode}", mode)
return s
}
// lookPathIn resolves command against the PATH in env rather than the process
// environment, so binaries in user bin dirs (e.g. ~/.local/bin) are found.
// exec.Command resolves cmd.Path from the current process PATH at construction
// time, so setting cmd.Env alone is not enough.
func lookPathIn(command string, env []string) (string, error) {
isExecutable := func(p string) bool {
info, err := os.Stat(p)
return err == nil && !info.IsDir() && info.Mode()&0o111 != 0
}
if strings.ContainsRune(command, os.PathSeparator) {
if isExecutable(command) {
return command, nil
}
return "", fmt.Errorf("not executable: %s", command)
}
var pathVal string
for _, e := range env {
if strings.HasPrefix(e, "PATH=") {
pathVal = strings.TrimPrefix(e, "PATH=")
}
}
for _, dir := range filepath.SplitList(pathVal) {
if dir == "" {
continue
}
candidate := filepath.Join(dir, command)
if isExecutable(candidate) {
return candidate, nil
}
}
return "", fmt.Errorf("%q not found in PATH", command)
}
// runPaletteEntry runs one palette command and returns its compact JSON, or ""
// on any failure: a bad command must never break a theme build.
func runPaletteEntry(e paletteEntry, imagePath string, mode ColorMode) string {
env := append(utils.EnvWithUserBinPath(nil),
"DMS_WALLPAPER="+imagePath,
"DMS_MODE="+string(mode),
)
resolved, err := lookPathIn(e.Command, env)
if err != nil {
log.Warnf("palette-inject: command %q not found: %v", e.Command, err)
return ""
}
modeStr := string(mode)
outputFile := substituteTokens(e.OutputFile, imagePath, modeStr)
args := make([]string, 0, len(e.Args))
for _, a := range e.Args {
args = append(args, substituteTokens(a, imagePath, modeStr))
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, resolved, args...)
cmd.WaitDelay = time.Second
cmd.Env = append(env, "DMS_PALETTE_OUT="+outputFile)
var raw []byte
if outputFile != "" {
if out, err := cmd.CombinedOutput(); err != nil {
log.Warnf("palette-inject: command failed: %v: %s", err, strings.TrimSpace(string(out)))
return ""
}
raw, err = os.ReadFile(outputFile)
if err != nil {
log.Warnf("palette-inject: reading output_file %s: %v", outputFile, err)
return ""
}
} else {
out, err := cmd.Output()
if err != nil {
log.Warnf("palette-inject: command failed: %v", err)
return ""
}
raw = out
}
// The command must emit a JSON object; values may be nested (e.g. pywal's
// {"colors": {...}, "special": {...}}), which matugen resolves through the
// import as {{name.colors.color0}}.
var palette map[string]json.RawMessage
if err := json.Unmarshal(raw, &palette); err != nil {
log.Warnf("palette-inject: palette must be a JSON object: %v", err)
return ""
}
if len(palette) == 0 {
log.Warn("palette-inject: command produced an empty palette")
return ""
}
compact, err := json.Marshal(palette)
if err != nil {
log.Warnf("palette-inject: re-marshal failed: %v", err)
return ""
}
log.Infof("palette-inject: %s produced %d keys under %q", e.Command, len(palette), e.Namespace)
return string(compact)
}
// InjectedPalettes runs every enabled palette command for imagePath and returns
// the resolved injections in config order, skipping any that fail. It returns
// nil when the feature is off. mode is "dark"/"light".
func InjectedPalettes(configDir, imagePath string, mode ColorMode) []paletteInjection {
if imagePath == "" {
return nil
}
entries, err := loadPaletteInjectConfig(configDir)
if err != nil {
log.Warnf("palette-inject: %v", err)
return nil
}
injections := make([]paletteInjection, 0, len(entries))
for _, e := range entries {
if palJSON := runPaletteEntry(e, imagePath, mode); palJSON != "" {
injections = append(injections, paletteInjection{Namespace: e.Namespace, JSON: palJSON})
}
}
return injections
}
+109
View File
@@ -0,0 +1,109 @@
package matugen
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBuildImportDataInjections(t *testing.T) {
const dank16 = `{"color0":"#000000"}`
const pal = `{"color0":"#111111"}`
const pal2 = `{"color0":"#222222"}`
assert.Equal(t,
`{"dank16": {"color0":"#000000"}, "wallust": {"color0":"#111111"}}`,
buildImportData(dank16, "", "", []paletteInjection{{Namespace: "wallust", JSON: pal}}),
"palette lands under its namespace")
assert.Equal(t,
`{"dank16": {"color0":"#000000"}, "image": "/w.png", "mypalette": {"color0":"#111111"}}`,
buildImportData(dank16, "/w.png", "", []paletteInjection{{Namespace: "mypalette", JSON: pal}}),
"custom namespace coexists with image")
assert.Equal(t,
`{"dank16": {"color0":"#000000"}, "wallust": {"color0":"#111111"}, "mytool": {"color0":"#222222"}}`,
buildImportData(dank16, "", "", []paletteInjection{{Namespace: "wallust", JSON: pal}, {Namespace: "mytool", JSON: pal2}}),
"each palette lands under its own namespace, in order")
dup := []paletteInjection{{Namespace: "wallust", JSON: pal}, {Namespace: "wallust", JSON: pal2}, {Namespace: "dank16", JSON: pal2}}
assert.Equal(t,
`{"dank16": {"color0":"#000000"}, "wallust": {"color0":"#111111"}}`,
buildImportData(dank16, "", "", dup),
"duplicate and reserved namespaces are dropped")
empty := []paletteInjection{{Namespace: "wallust", JSON: ""}, {Namespace: "", JSON: pal}}
assert.Equal(t,
`{"dank16": {"color0":"#000000"}}`,
buildImportData(dank16, "", "", empty),
"injections missing a namespace or JSON are skipped")
}
func TestLoadPaletteInjectConfig(t *testing.T) {
write := func(t *testing.T, body string) string {
t.Helper()
dir := t.TempDir()
mdir := filepath.Join(dir, "matugen")
require.NoError(t, os.MkdirAll(mdir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(mdir, "palette-inject.json"), []byte(body), 0o644))
return dir
}
got, err := loadPaletteInjectConfig(t.TempDir())
require.NoError(t, err)
assert.Nil(t, got, "absent config yields no entries")
got, err = loadPaletteInjectConfig(write(t, `{"enabled":true,"command":"mytool","args":["{image}"]}`))
require.NoError(t, err)
require.Len(t, got, 1, "bare single-object config is normalized to one entry")
assert.Equal(t, "mytool", got[0].Command)
assert.Equal(t, "palette1", got[0].Namespace, "missing namespace defaults to a positional name")
got, err = loadPaletteInjectConfig(write(t, `{"palettes":[
{"enabled":true,"command":"a","namespace":"one"},
{"enabled":false,"command":"b","namespace":"two"},
{"enabled":true,"command":"","namespace":"three"},
{"enabled":true,"command":"c"}
]}`))
require.NoError(t, err)
require.Len(t, got, 2, "disabled entries and empty commands are filtered out")
assert.Equal(t, "one", got[0].Namespace)
assert.Equal(t, "c", got[1].Command)
assert.Equal(t, "palette4", got[1].Namespace, "positional default uses the original config index")
}
func TestRunPaletteEntry(t *testing.T) {
sh := func(script string) paletteEntry {
return paletteEntry{Enabled: true, Command: "sh", Args: []string{"-c", script}, Namespace: "p"}
}
// Nested JSON (pywal/wallust shape) is accepted and forwarded verbatim.
nested := `{"colors":{"color0":"#111111"},"special":{"background":"#000000"}}`
got := runPaletteEntry(sh("printf '%s' '"+nested+"'"), "/img.png", ColorModeDark)
assert.JSONEq(t, nested, got, "nested palette objects are accepted")
// A JSON array or scalar is not an object and is rejected.
assert.Empty(t, runPaletteEntry(sh(`printf '%s' '["#111"]'`), "/img.png", ColorModeDark),
"a non-object payload is rejected")
assert.Empty(t, runPaletteEntry(sh(`printf '%s' '{}'`), "/img.png", ColorModeDark),
"an empty object is rejected")
// A failing command yields no palette rather than breaking the build.
assert.Empty(t, runPaletteEntry(sh("exit 1"), "/img.png", ColorModeDark),
"a failing command is skipped")
// {image}/{mode} tokens reach the command.
echoed := runPaletteEntry(
paletteEntry{Enabled: true, Command: "sh", Args: []string{"-c", `printf '{"m":"%s","i":"%s"}' "$1" "$2"`, "sh", "{mode}", "{image}"}, Namespace: "p"},
"/wall.png", ColorModeLight)
assert.JSONEq(t, `{"m":"light","i":"/wall.png"}`, echoed, "tokens are substituted in args")
// output_file is read back instead of stdout when set.
out := filepath.Join(t.TempDir(), "pal.json")
fileEntry := paletteEntry{Enabled: true, Command: "sh", Args: []string{"-c", `printf '%s' '{"color0":"#abcabc"}' > "$DMS_PALETTE_OUT"`}, OutputFile: out, Namespace: "p"}
assert.JSONEq(t, `{"color0":"#abcabc"}`, runPaletteEntry(fileEntry, "/img.png", ColorModeDark),
"palette is read from output_file")
}
+8
View File
@@ -50,6 +50,14 @@ Singleton {
"icon": "apps",
"tabIndex": 50,
"hint": I18n.tr("GTK, Qt, portal, matugen templates")
},
{
"id": "palette_inject",
"advanced": true,
"text": I18n.tr("Injected palettes"),
"icon": "colorize",
"tabIndex": 64,
"hint": I18n.tr("External palette commands, namespaces")
}
]
},
@@ -209,6 +209,7 @@ FocusScope {
"launcher": "LauncherTab.qml",
"theme": "ThemeColorsTab.qml",
"theme_apps": "ThemeAppsTab.qml",
"palette_inject": "PaletteInjectTab.qml",
"lock_screen": "LockScreenTab.qml",
"greeter": "GreeterTab.qml",
"about": "AboutTab.qml",
@@ -0,0 +1,186 @@
import QtQuick
import qs.Common
import qs.Services
import qs.Widgets
import qs.Modules.Settings.Widgets
Item {
id: root
Component.onCompleted: {
if (!PaletteInjectService.loaded)
PaletteInjectService.load();
}
SettingsPage {
id: mainColumn
SettingsCard {
width: parent.width
iconName: "colorize"
title: I18n.tr("Injected palettes")
settingKey: "paletteInject"
tags: ["palette", "matugen", "inject", "namespace", "command", "template", "color"]
visible: Theme.matugenAvailable
headerActions: [
DankActionButton {
buttonSize: 36
iconName: "add"
iconSize: 20
Accessible.name: I18n.tr("Add")
iconColor: Theme.primary
onClicked: PaletteInjectService.addPalette()
}
]
SettingsRow {
body: Column {
width: parent.width
spacing: Theme.spacingS
StyledText {
text: I18n.tr("Run an external command on each wallpaper change and merge its JSON palette into matugen under a namespace, so templates can use e.g. {{name.color0}}. The command receives the wallpaper as {image} / $DMS_WALLPAPER and the mode as {mode} / $DMS_MODE.")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
width: parent.width
bottomPadding: Theme.spacingS
}
StyledText {
visible: PaletteInjectService.palettes.length === 0
text: I18n.tr("No palettes configured. Use + to add one.")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
width: parent.width
}
Repeater {
model: PaletteInjectService.palettes
delegate: Rectangle {
id: paletteItem
required property var modelData
required property int index
width: parent.width
height: paletteColumn.implicitHeight + Theme.spacingM * 2
radius: Theme.cornerRadius
color: Theme.floatingWindowFieldColor
Column {
id: paletteColumn
anchors.fill: parent
anchors.margins: Theme.spacingM
spacing: Theme.spacingM
Row {
width: parent.width
spacing: Theme.spacingS
StyledText {
id: paletteLabel
text: paletteItem.modelData.namespace ? paletteItem.modelData.namespace : I18n.tr("Palette %1", "palette heading, %1 is the palette number").arg(paletteItem.index + 1)
font.pixelSize: Theme.fontSizeMedium
font.weight: Theme.fontWeightMedium
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
Item {
width: Math.max(0, parent.width - paletteLabel.implicitWidth - enableToggle.width - deleteBtn.width - Theme.spacingS * 2)
height: 1
}
DankToggle {
id: enableToggle
width: 40
height: 24
hideText: true
checked: paletteItem.modelData.enabled !== false
onToggled: checked => PaletteInjectService.updatePalette(paletteItem.index, "enabled", checked)
}
Item {
id: deleteBtn
Accessible.role: Accessible.Button
Accessible.name: I18n.tr("Remove")
width: 28
height: 28
anchors.verticalCenter: parent.verticalCenter
Rectangle {
anchors.fill: parent
radius: Theme.cornerRadius
color: deleteArea.containsMouse ? Theme.withAlpha(Theme.error, 0.2) : Theme.withAlpha(Theme.error, 0)
}
DankIcon {
anchors.centerIn: parent
name: "delete"
size: 18
color: deleteArea.containsMouse ? Theme.error : Theme.surfaceVariantText
}
MouseArea {
id: deleteArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: PaletteInjectService.removePalette(paletteItem.index)
}
}
}
DankTextField {
outlined: true
leftIconName: "label"
labelText: I18n.tr("Name")
placeholderText: I18n.tr("Palette name")
width: parent.width
text: paletteItem.modelData.namespace || ""
font.pixelSize: Theme.fontSizeSmall
onEditingFinished: PaletteInjectService.updatePalette(paletteItem.index, "namespace", text)
}
DankTextField {
outlined: true
leftIconName: "terminal"
labelText: I18n.tr("Command")
width: parent.width
text: paletteItem.modelData.command || ""
font.pixelSize: Theme.fontSizeSmall
onEditingFinished: PaletteInjectService.updatePalette(paletteItem.index, "command", text)
}
DankTextField {
outlined: true
leftIconName: "data_array"
labelText: I18n.tr("Arguments")
supportingText: I18n.tr("Space-separated; {image} and {mode} are substituted")
width: parent.width
text: PaletteInjectService.argsText(paletteItem.modelData)
font.pixelSize: Theme.fontSizeSmall
onEditingFinished: PaletteInjectService.updatePalette(paletteItem.index, "args", PaletteInjectService.argsFromText(text))
}
DankTextField {
outlined: true
leftIconName: "description"
labelText: I18n.tr("JSON output path")
supportingText: I18n.tr("File the command writes; leave empty to read stdout")
width: parent.width
text: paletteItem.modelData.output_file || ""
font.pixelSize: Theme.fontSizeSmall
onEditingFinished: PaletteInjectService.updatePalette(paletteItem.index, "output_file", text)
}
}
}
}
}
}
}
}
}
@@ -0,0 +1,139 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import QtCore
import Quickshell
import Quickshell.Io
import qs.Common
// PaletteInjectService owns ~/.config/matugen/palette-inject.json, the file the
// DMS core reads on each theme build to run external palette commands. The UI
// edits this list; the core consumes it. The file uses the {"palettes": [...]}
// shape; a legacy single-object file is read transparently and rewritten as a
// list on the next save.
Singleton {
id: root
readonly property var log: Log.scoped("PaletteInjectService")
readonly property string configRoot: Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation))
readonly property string dir: configRoot + "/matugen"
readonly property string filePath: dir + "/palette-inject.json"
property var palettes: []
property bool loaded: false
function load() {
configFile.path = "";
configFile.path = root.filePath;
}
function newPalette(namespace) {
return {
"enabled": true,
"namespace": namespace,
"command": "",
"args": [],
"output_file": ""
};
}
function nextPaletteName() {
var used = {};
for (var i = 0; i < root.palettes.length; i++)
used[root.palettes[i].namespace] = true;
var n = 1;
while (used["palette" + n])
n++;
return "palette" + n;
}
function addPalette() {
var next = root.palettes.slice();
next.push(root.newPalette(root.nextPaletteName()));
root.palettes = next;
root.save();
}
function removePalette(index) {
if (index < 0 || index >= root.palettes.length)
return;
var next = root.palettes.slice();
next.splice(index, 1);
root.palettes = next;
root.save();
}
function updatePalette(index, field, value) {
if (index < 0 || index >= root.palettes.length)
return;
var next = root.palettes.slice();
var entry = Object.assign({}, next[index]);
entry[field] = value;
next[index] = entry;
root.palettes = next;
root.save();
}
// argsText/argsFromText bridge the array core schema and a single-line field:
// arguments are shown space-joined and split back on whitespace, so simple
// commands (e.g. run {image} --format json) round-trip cleanly.
function argsText(entry) {
var a = entry && entry.args ? entry.args : [];
return a.join(" ");
}
function argsFromText(text) {
var trimmed = (text || "").trim();
if (trimmed.length === 0)
return [];
return trimmed.split(/\s+/);
}
function save() {
var payload = {
"palettes": root.palettes
};
Proc.runCommand("", ["mkdir", "-p", root.dir], () => {
configFile.setText(JSON.stringify(payload, null, 2));
});
}
function normalize(data) {
var list = [];
var raw = (data && data.palettes && data.palettes.length !== undefined) ? data.palettes : (data && data.command !== undefined ? [data] : []);
for (var i = 0; i < raw.length; i++) {
var e = raw[i] || {};
list.push({
"enabled": e.enabled === true,
"namespace": e.namespace || "",
"command": e.command || "",
"args": (e.args && e.args.length !== undefined) ? e.args : [],
"output_file": e.output_file || ""
});
}
return list;
}
FileView {
id: configFile
blockWrites: true
atomicWrites: true
onLoaded: {
try {
root.palettes = root.normalize(JSON.parse(text()));
} catch (e) {
root.log.warn("Failed to parse palette-inject.json:", e);
root.palettes = [];
}
root.loaded = true;
}
onLoadFailed: {
root.palettes = [];
root.loaded = true;
}
}
}
@@ -146,6 +146,7 @@ TAB_INDEX_MAP = {
"UserAccountsTab.qml": 60,
"CreateUserTab.qml": 61,
"GreeterAuthTab.qml": 62,
"PaletteInjectTab.qml": 64,
"AutoStartTab.qml": 36,
"BatteryTab.qml": 42,
"MouseTouchpadTab.qml": 44,
@@ -2440,10 +2440,12 @@
"desktop",
"hue",
"image",
"injected",
"look",
"mode",
"night",
"palette",
"palettes",
"picture",
"style",
"theme",
@@ -15666,6 +15668,37 @@
"parentLabel": "Desktop widgets",
"description": "Options of one widget instance"
},
{
"section": "paletteInject",
"label": "Injected palettes",
"tabIndex": 64,
"category": "Injected palettes",
"keywords": [
"background",
"bg",
"color",
"colors",
"colour",
"colours",
"command",
"desktop",
"hue",
"image",
"inject",
"injected",
"matugen",
"namespace",
"palette",
"palettes",
"picture",
"template",
"tint",
"wallpaper"
],
"parentLabel": "Wallpaper & colors",
"icon": "colorize",
"conditionKey": "matugenAvailable"
},
{
"section": "dotSurfaceColor",
"label": "Background",