diff --git a/core/internal/matugen/matugen.go b/core/internal/matugen/matugen.go index 1bed3a2cb..7c6a3876a 100644 --- a/core/internal/matugen/matugen.go +++ b/core/internal/matugen/matugen.go @@ -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, ", ") + "}" } diff --git a/core/internal/matugen/matugen_test.go b/core/internal/matugen/matugen_test.go index 51436e40a..da9f1824c 100644 --- a/core/internal/matugen/matugen_test.go +++ b/core/internal/matugen/matugen_test.go @@ -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. diff --git a/core/internal/matugen/paletteinject.go b/core/internal/matugen/paletteinject.go new file mode 100644 index 000000000..b310b4b7b --- /dev/null +++ b/core/internal/matugen/paletteinject.go @@ -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 +} diff --git a/core/internal/matugen/paletteinject_test.go b/core/internal/matugen/paletteinject_test.go new file mode 100644 index 000000000..37e889851 --- /dev/null +++ b/core/internal/matugen/paletteinject_test.go @@ -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") +} diff --git a/quickshell/Common/SettingsTabs.qml b/quickshell/Common/SettingsTabs.qml index a380c5caa..d0e41a4d9 100644 --- a/quickshell/Common/SettingsTabs.qml +++ b/quickshell/Common/SettingsTabs.qml @@ -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") } ] }, diff --git a/quickshell/Modals/Settings/SettingsContent.qml b/quickshell/Modals/Settings/SettingsContent.qml index cadb96734..a2c22ae0f 100644 --- a/quickshell/Modals/Settings/SettingsContent.qml +++ b/quickshell/Modals/Settings/SettingsContent.qml @@ -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", diff --git a/quickshell/Modules/Settings/PaletteInjectTab.qml b/quickshell/Modules/Settings/PaletteInjectTab.qml new file mode 100644 index 000000000..2a683cd96 --- /dev/null +++ b/quickshell/Modules/Settings/PaletteInjectTab.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) + } + } + } + } + } + } + } + } +} diff --git a/quickshell/Services/PaletteInjectService.qml b/quickshell/Services/PaletteInjectService.qml new file mode 100644 index 000000000..99a9a69ed --- /dev/null +++ b/quickshell/Services/PaletteInjectService.qml @@ -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; + } + } +} diff --git a/quickshell/translations/extract_settings_index.py b/quickshell/translations/extract_settings_index.py index fa92b8cfc..9cdc57b55 100644 --- a/quickshell/translations/extract_settings_index.py +++ b/quickshell/translations/extract_settings_index.py @@ -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, diff --git a/quickshell/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json index 37f41c3c3..aeb1e16f0 100644 --- a/quickshell/translations/settings_search_index.json +++ b/quickshell/translations/settings_search_index.json @@ -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",