mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-20 02:04:01 -04:00
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:
co-authored by
claude[bot]
bbedward
parent
4bdfcfc0cf
commit
f22ad2c333
@@ -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, ", ") + "}"
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user