diff --git a/core/cmd/dms/commands_doctor.go b/core/cmd/dms/commands_doctor.go
index 51dc31f0f..89010f8ca 100644
--- a/core/cmd/dms/commands_doctor.go
+++ b/core/cmd/dms/commands_doctor.go
@@ -3,6 +3,7 @@ package main
import (
"encoding/json"
"fmt"
+ "maps"
"os"
"os/exec"
"path/filepath"
@@ -15,6 +16,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/matugen"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
"github.com/AvengeMedia/DankMaterialShell/core/internal/tui"
@@ -321,6 +323,9 @@ func checkEnvironmentVars() []checkResult {
results = append(results, checkEnvVar("QT_QPA_PLATFORMTHEME")...)
results = append(results, checkEnvVar("QS_ICON_THEME")...)
results = append(results, checkXDGMenuPrefix()...)
+ if matugen.QtengineActive() {
+ results = append(results, checkQtenginePlugin()...)
+ }
return results
}
@@ -352,6 +357,128 @@ func checkXDGMenuPrefix() []checkResult {
return nil
}
+// qtQueryBinaries are the Qt build tools that can be asked where Qt looks for
+// plugins, in probe order. A binary's name does not reliably say which Qt it
+// belongs to — plain "qmake" is Qt5 on Arch and Qt6 elsewhere — so each one is
+// asked for QT_VERSION and the major is taken from the answer.
+var qtQueryBinaries = []struct{ bin, flag string }{
+ {"qmake6", "-query"},
+ {"qmake-qt6", "-query"},
+ {"qtpaths6", "--query"},
+ {"qtpaths-qt6", "--query"},
+ {"qmake-qt5", "-query"},
+ {"qtpaths-qt5", "--query"},
+ {"qmake", "-query"},
+ {"qtpaths", "--query"},
+}
+
+// checkQtenginePlugin verifies the qtengine platform theme plugin is installed
+// where Qt actually searches, for every Qt major that can be interrogated.
+//
+// Qt is asked where it looks rather than the filesystem searched, because a
+// package can install the Qt5 plugin under a prefix Qt5 does not search, after
+// which Qt5 apps silently fall back while Qt6 apps look correct. A search would
+// find the plugin and report OK on exactly that bug.
+func checkQtenginePlugin() []checkResult {
+ url := doctorDocsURL + "#environment-variables"
+
+ pluginRoots := make(map[string]string)
+ for _, q := range qtQueryBinaries {
+ if _, err := exec.LookPath(q.bin); err != nil {
+ continue
+ }
+ major, _, _ := strings.Cut(qtQuery(q.bin, q.flag, "QT_VERSION"), ".")
+ if major == "" || pluginRoots[major] != "" {
+ continue
+ }
+ if root := qtQuery(q.bin, q.flag, "QT_INSTALL_PLUGINS"); root != "" {
+ pluginRoots[major] = root
+ }
+ }
+
+ // qmake/qtpaths ship in dev packages most users lack — never warn on a Qt
+ // that could not be interrogated, and never claim it is fine either.
+ if len(pluginRoots) == 0 {
+ return []checkResult{{
+ catEnvironment, "qtengine plugin", statusInfo, "Cannot verify (no qmake/qtpaths)",
+ "Install a Qt development package to let dms doctor check the plugin against the path Qt actually searches.",
+ url,
+ }}
+ }
+
+ // Qt also searches QT_PLUGIN_PATH, so a plugin found there is loadable.
+ envRoots := filepath.SplitList(os.Getenv("QT_PLUGIN_PATH"))
+
+ var results []checkResult
+ for _, major := range slices.Sorted(maps.Keys(pluginRoots)) {
+ // qtengine only ships Qt5 and Qt6 plugins.
+ if major != "5" && major != "6" {
+ continue
+ }
+
+ root := pluginRoots[major]
+ name := fmt.Sprintf("qtengine plugin (Qt%s)", major)
+
+ plugin := qtenginePluginPath(append([]string{root}, envRoots...), major)
+ if plugin == "" {
+ expected := qtenginePluginFile(root, major)
+ results = append(results, checkResult{
+ catEnvironment, name, statusWarn, fmt.Sprintf("Not found in Qt%s plugin path", major),
+ fmt.Sprintf("Expected %s — Qt%s apps will silently fall back to an unthemed palette. The plugin is likely installed under a prefix Qt%s does not search.", expected, major, major),
+ url,
+ })
+ continue
+ }
+ results = append(results, checkResult{catEnvironment, name, statusOK, "Installed", plugin, url})
+ }
+
+ return results
+}
+
+// qtenginePluginPath returns the first of roots that holds the qtengine platform
+// theme plugin for the given Qt major, or "" if none does.
+func qtenginePluginPath(roots []string, major string) string {
+ for _, root := range roots {
+ if root == "" {
+ continue
+ }
+ path := qtenginePluginFile(root, major)
+ if _, err := os.Stat(path); err == nil {
+ return path
+ }
+ }
+ return ""
+}
+
+func qtenginePluginFile(root, major string) string {
+ return filepath.Join(root, "platformthemes", fmt.Sprintf("libqt%sengine-plugin.so", major))
+}
+
+// qtQuery asks a Qt build tool for a single QLibraryInfo property.
+func qtQuery(bin, flag, key string) string {
+ output, err := exec.Command(bin, flag, key).Output()
+ if err != nil {
+ return ""
+ }
+ return qtQueryValue(string(output), key)
+}
+
+// qtQueryValue extracts one property from a Qt query. Most tools print the bare
+// value, but some ignore the requested property and dump every one as
+// "KEY:value" lines; taking the whole output there would yield a bogus path.
+func qtQueryValue(output, key string) string {
+ lines := strings.Split(strings.TrimSpace(output), "\n")
+ for _, line := range lines {
+ if value, found := strings.CutPrefix(strings.TrimSpace(line), key+":"); found {
+ return strings.TrimSpace(value)
+ }
+ }
+ if len(lines) == 1 {
+ return strings.TrimSpace(lines[0])
+ }
+ return ""
+}
+
func checkXDGMenuFile(prefix string) bool {
menuPath := fmt.Sprintf("/etc/xdg/menus/%sapplications.menu", prefix)
_, err := os.Stat(menuPath)
@@ -789,17 +916,15 @@ func findQtPluginDirs() []string {
}
// Check all paths in QT_PLUGIN_PATH env var (used by NixOS and custom setups)
- if envPath := os.Getenv("QT_PLUGIN_PATH"); envPath != "" {
- for dir := range strings.SplitSeq(envPath, ":") {
- addDir(dir)
- }
+ for _, dir := range filepath.SplitList(os.Getenv("QT_PLUGIN_PATH")) {
+ addDir(dir)
}
- // Try qtpaths
- for _, cmd := range []string{"qtpaths6", "qtpaths"} {
- if output, err := exec.Command(cmd, "-query", "QT_INSTALL_PLUGINS").Output(); err == nil {
- addDir(strings.TrimSpace(string(output)))
+ for _, q := range qtQueryBinaries {
+ if _, err := exec.LookPath(q.bin); err != nil {
+ continue
}
+ addDir(qtQuery(q.bin, q.flag, "QT_INSTALL_PLUGINS"))
}
// Fallback: common distro paths
diff --git a/core/cmd/dms/commands_doctor_test.go b/core/cmd/dms/commands_doctor_test.go
new file mode 100644
index 000000000..5202de356
--- /dev/null
+++ b/core/cmd/dms/commands_doctor_test.go
@@ -0,0 +1,63 @@
+package main
+
+import "testing"
+
+// Parsing a full property dump as a bare value would yield a bogus plugin root,
+// making the qtengine check warn at a user whose install is fine.
+func TestQtQueryValue(t *testing.T) {
+ const dumpAll = `QT_INSTALL_PREFIX:/usr
+QT_INSTALL_PLUGINS:/usr/lib/qt/plugins
+QT_VERSION:5.15.19`
+
+ tests := []struct {
+ name string
+ output string
+ key string
+ want string
+ }{
+ {
+ name: "bare value as qmake prints it",
+ output: "/usr/lib/qt6/plugins\n",
+ key: "QT_INSTALL_PLUGINS",
+ want: "/usr/lib/qt6/plugins",
+ },
+ {
+ name: "key:value line among a full property dump",
+ output: dumpAll,
+ key: "QT_INSTALL_PLUGINS",
+ want: "/usr/lib/qt/plugins",
+ },
+ {
+ name: "a different key from the same dump",
+ output: dumpAll,
+ key: "QT_VERSION",
+ want: "5.15.19",
+ },
+ {
+ name: "key absent from a multi line dump yields nothing",
+ output: dumpAll,
+ key: "QT_INSTALL_LIBEXECS",
+ want: "",
+ },
+ {
+ name: "empty output yields nothing",
+ output: "",
+ key: "QT_INSTALL_PLUGINS",
+ want: "",
+ },
+ {
+ name: "surrounding whitespace is trimmed",
+ output: " QT_INSTALL_PLUGINS: /usr/lib/qt/plugins \n",
+ key: "QT_INSTALL_PLUGINS",
+ want: "/usr/lib/qt/plugins",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := qtQueryValue(tc.output, tc.key); got != tc.want {
+ t.Fatalf("qtQueryValue(%q, %q) = %q, want %q", tc.output, tc.key, got, tc.want)
+ }
+ })
+ }
+}
diff --git a/core/cmd/dms/commands_matugen.go b/core/cmd/dms/commands_matugen.go
index 31a9cb327..8aeaef520 100644
--- a/core/cmd/dms/commands_matugen.go
+++ b/core/cmd/dms/commands_matugen.go
@@ -44,11 +44,18 @@ var matugenPreviewCmd = &cobra.Command{
Run: runMatugenPreview,
}
+var matugenQtengineCmd = &cobra.Command{
+ Use: "qtengine",
+ Short: "Sync qtengine's config with the current DMS theme",
+ Run: runMatugenQtengine,
+}
+
func init() {
matugenCmd.AddCommand(matugenGenerateCmd)
matugenCmd.AddCommand(matugenQueueCmd)
matugenCmd.AddCommand(matugenCheckCmd)
matugenCmd.AddCommand(matugenPreviewCmd)
+ matugenCmd.AddCommand(matugenQtengineCmd)
for _, cmd := range []*cobra.Command{matugenGenerateCmd, matugenQueueCmd} {
cmd.Flags().String("state-dir", "", "State directory for cache files")
@@ -71,6 +78,7 @@ func init() {
matugenQueueCmd.Flags().Duration("timeout", 90*time.Second, "Timeout for waiting")
matugenPreviewCmd.Flags().String("source-color", "", "Source color used to generate previews")
matugenPreviewCmd.Flags().Float64("contrast", 0, "Contrast value from -1 to 1 (0 = standard)")
+ matugenQtengineCmd.Flags().String("icon-theme", "", "Icon theme name")
}
func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
@@ -223,3 +231,11 @@ func runMatugenPreview(cmd *cobra.Command, args []string) {
}
fmt.Println(string(data))
}
+
+func runMatugenQtengine(cmd *cobra.Command, args []string) {
+ iconTheme, _ := cmd.Flags().GetString("icon-theme")
+
+ if err := matugen.SyncQtengineConfig(iconTheme); err != nil {
+ log.Fatalf("Failed to sync qtengine config: %v", err)
+ }
+}
diff --git a/core/internal/matugen/matugen.go b/core/internal/matugen/matugen.go
index 1b3ea75f9..637a34540 100644
--- a/core/internal/matugen/matugen.go
+++ b/core/internal/matugen/matugen.go
@@ -401,6 +401,15 @@ func buildOnce(opts *Options) (bool, error) {
refreshQt6ct()
}
+ // kcolorscheme writes the .colors file qtengine is pointed at, so with that
+ // template off there is nothing to point to and the config would name a
+ // scheme DMS no longer generates.
+ if !opts.ShouldSkipTemplate("qtengine") && !opts.ShouldSkipTemplate("kcolorscheme") && QtengineActive() {
+ if err := SyncQtengineConfig(opts.IconTheme); err != nil {
+ log.Warnf("Failed to sync qtengine config: %v", err)
+ }
+ }
+
signalTerminals(opts)
if !opts.ShouldSkipTemplate("fcitx5") && appExists(opts.AppChecker, []string{"fcitx5"}, nil) {
refreshFcitx5()
@@ -1216,7 +1225,7 @@ func CheckTemplates(checker utils.AppChecker) []TemplateCheck {
}
homeDir, _ := os.UserHomeDir()
- checks := make([]TemplateCheck, 0, len(templateRegistry))
+ checks := make([]TemplateCheck, 0, len(templateRegistry)+1)
for _, tmpl := range templateRegistry {
detected := false
@@ -1235,6 +1244,11 @@ func CheckTemplates(checker utils.AppChecker) []TemplateCheck {
checks = append(checks, TemplateCheck{ID: tmpl.ID, Detected: detected})
}
+ // qtengine is not a matugen template and ships no binary, so it has no
+ // templateRegistry entry to detect; the settings row still needs an honest
+ // indicator, which is the platform theme env var.
+ checks = append(checks, TemplateCheck{ID: "qtengine", Detected: QtengineActive()})
+
return checks
}
diff --git a/core/internal/matugen/matugen_test.go b/core/internal/matugen/matugen_test.go
index 80796f51d..7e4dc24cc 100644
--- a/core/internal/matugen/matugen_test.go
+++ b/core/internal/matugen/matugen_test.go
@@ -1,10 +1,12 @@
package matugen
import (
+ "encoding/json"
"os"
"path/filepath"
"strings"
"testing"
+ "time"
mocks_utils "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/utils"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
@@ -612,3 +614,319 @@ func TestAppendFlatpakConfigRewritesOutputAndTemplateName(t *testing.T) {
assert.Contains(t, string(output), "[templates.dmsvesktop-flatpak]")
assert.Contains(t, string(output), "'/home/test/.var/app/dev.vencord.Vesktop/config/vesktop/themes/dank-discord.css'")
}
+
+func TestQtengineActive(t *testing.T) {
+ tests := []struct {
+ name string
+ env map[string]string
+ want bool
+ }{
+ {name: "both unset", want: false},
+ {name: "qtengine on QT_QPA_PLATFORMTHEME", env: map[string]string{"QT_QPA_PLATFORMTHEME": "qtengine"}, want: true},
+ {name: "qtengine on QT_QPA_PLATFORMTHEME_QT6", env: map[string]string{"QT_QPA_PLATFORMTHEME_QT6": "qtengine"}, want: true},
+ {name: "gtk3", env: map[string]string{"QT_QPA_PLATFORMTHEME": "gtk3"}, want: false},
+ {name: "qt6ct on both", env: map[string]string{"QT_QPA_PLATFORMTHEME": "qt6ct", "QT_QPA_PLATFORMTHEME_QT6": "qt6ct"}, want: false},
+ {name: "both empty", env: map[string]string{"QT_QPA_PLATFORMTHEME": "", "QT_QPA_PLATFORMTHEME_QT6": ""}, want: false},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ for _, key := range []string{"QT_QPA_PLATFORMTHEME", "QT_QPA_PLATFORMTHEME_QT6"} {
+ t.Setenv(key, "")
+ os.Unsetenv(key)
+ }
+ for key, value := range tc.env {
+ t.Setenv(key, value)
+ }
+
+ assert.Equal(t, tc.want, QtengineActive())
+ })
+ }
+}
+
+func TestSyncQtengineConfig(t *testing.T) {
+ const (
+ uiFont = "Inter,11,-1,5,400,0,0,0,0,0,0,0,0,0,0,1"
+ fixedFont = "JetBrainsMono Nerd Font,11,-1,5,400,0,0,0,0,0,0,0,0,0,0,1"
+ )
+
+ existingConfig := `{
+ "theme": {
+ "style": "Fusion",
+ "font": "` + uiFont + `",
+ "fontFixed": "` + fixedFont + `",
+ "colorScheme": "/stale/path/Old.colors",
+ "iconTheme": "breeze"
+ },
+ "misc": {
+ "wheelScrollLines": 3,
+ "dialogButtonsHaveIcons": true
+ },
+ "somethingQtengineAddsLater": ["keep", "me"]
+}
+`
+
+ tests := []struct {
+ name string
+ existing string
+ writeEmpty bool
+ noColorScheme bool
+ iconTheme string
+ wantErr bool
+ check func(t *testing.T, cfg, theme map[string]any)
+ }{
+ {
+ name: "creates config when none exists",
+ iconTheme: "Papirus-Dark",
+ check: func(t *testing.T, cfg, theme map[string]any) {
+ assert.Equal(t, "Papirus-Dark", theme["iconTheme"])
+ },
+ },
+ {
+ name: "preserves user keys at every level",
+ existing: existingConfig,
+ iconTheme: "Papirus-Dark",
+ check: func(t *testing.T, cfg, theme map[string]any) {
+ assert.Equal(t, "Fusion", theme["style"])
+ assert.Equal(t, uiFont, theme["font"])
+ assert.Equal(t, fixedFont, theme["fontFixed"])
+ assert.Equal(t, "Papirus-Dark", theme["iconTheme"])
+ assert.Equal(t, map[string]any{"wheelScrollLines": float64(3), "dialogButtonsHaveIcons": true}, cfg["misc"])
+ assert.Equal(t, []any{"keep", "me"}, cfg["somethingQtengineAddsLater"])
+ },
+ },
+ {
+ name: "System Default leaves the existing icon theme alone",
+ existing: existingConfig,
+ iconTheme: "System Default",
+ check: func(t *testing.T, cfg, theme map[string]any) {
+ assert.Equal(t, "breeze", theme["iconTheme"])
+ },
+ },
+ {
+ name: "empty icon theme writes no iconTheme key",
+ iconTheme: "",
+ check: func(t *testing.T, cfg, theme map[string]any) {
+ _, ok := theme["iconTheme"]
+ assert.False(t, ok, "iconTheme must stay absent when none was requested")
+ },
+ },
+ {
+ name: "a top level null is a fresh start, not a panic",
+ existing: "null",
+ iconTheme: "Papirus-Dark",
+ check: func(t *testing.T, cfg, theme map[string]any) {
+ assert.Equal(t, "Papirus-Dark", theme["iconTheme"])
+ },
+ },
+ {
+ name: "a top level null with a trailing newline is a fresh start",
+ existing: "null\n",
+ iconTheme: "Papirus-Dark",
+ check: func(t *testing.T, cfg, theme map[string]any) {
+ assert.Equal(t, "Papirus-Dark", theme["iconTheme"])
+ },
+ },
+ {
+ name: "an empty file is a fresh start",
+ writeEmpty: true,
+ iconTheme: "Papirus-Dark",
+ check: func(t *testing.T, cfg, theme map[string]any) {
+ assert.Equal(t, "Papirus-Dark", theme["iconTheme"])
+ },
+ },
+ {
+ name: "a whitespace only file is a fresh start",
+ existing: "\n\t \n",
+ iconTheme: "Papirus-Dark",
+ check: func(t *testing.T, cfg, theme map[string]any) {
+ assert.Equal(t, "Papirus-Dark", theme["iconTheme"])
+ },
+ },
+ {
+ name: "a null theme is treated as absent",
+ existing: "{\n \"theme\": null,\n \"misc\": {\"wheelScrollLines\": 3}\n}\n",
+ iconTheme: "Papirus-Dark",
+ check: func(t *testing.T, cfg, theme map[string]any) {
+ assert.Equal(t, "Papirus-Dark", theme["iconTheme"])
+ assert.Equal(t, map[string]any{"wheelScrollLines": float64(3)}, cfg["misc"])
+ },
+ },
+ {
+ name: "leaves a hand set colorScheme alone when DMS generated none",
+ existing: existingConfig,
+ noColorScheme: true,
+ iconTheme: "Papirus-Dark",
+ check: func(t *testing.T, cfg, theme map[string]any) {
+ assert.Equal(t, "/stale/path/Old.colors", theme["colorScheme"], "pointing qtengine at a scheme that does not exist resets the user's palette")
+ assert.Equal(t, "Papirus-Dark", theme["iconTheme"])
+ assert.Equal(t, "Fusion", theme["style"])
+ },
+ },
+ {
+ name: "writes no colorScheme at all when there is none to point at",
+ noColorScheme: true,
+ iconTheme: "Papirus-Dark",
+ check: func(t *testing.T, cfg, theme map[string]any) {
+ _, ok := theme["colorScheme"]
+ assert.False(t, ok, "colorScheme must stay absent rather than name a missing file")
+ assert.Equal(t, "Papirus-Dark", theme["iconTheme"])
+ },
+ },
+ {
+ name: "refuses to clobber malformed json",
+ existing: "{ this is not json",
+ iconTheme: "Papirus-Dark",
+ wantErr: true,
+ },
+ {
+ name: "refuses to clobber a non-object theme",
+ existing: "{\n \"theme\": \"Fusion\"\n}\n",
+ iconTheme: "Papirus-Dark",
+ wantErr: true,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ tempDir := t.TempDir()
+ configDir := filepath.Join(tempDir, "config")
+ dataHome := filepath.Join(tempDir, "data")
+ t.Setenv("XDG_CONFIG_HOME", configDir)
+ t.Setenv("XDG_DATA_HOME", dataHome)
+
+ path := QtengineConfigPath()
+ assert.Equal(t, filepath.Join(configDir, "qtengine", "config.json"), path)
+
+ scheme := filepath.Join(dataHome, "color-schemes", "DankMatugen.colors")
+ if !tc.noColorScheme {
+ if err := os.MkdirAll(filepath.Dir(scheme), 0o755); err != nil {
+ t.Fatalf("failed to create color-schemes dir: %v", err)
+ }
+ if err := os.WriteFile(scheme, []byte("[Colors:Window]\n"), 0o644); err != nil {
+ t.Fatalf("failed to write color scheme: %v", err)
+ }
+ }
+
+ if tc.existing != "" || tc.writeEmpty {
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatalf("failed to create qtengine config dir: %v", err)
+ }
+ if err := os.WriteFile(path, []byte(tc.existing), 0o644); err != nil {
+ t.Fatalf("failed to write existing config: %v", err)
+ }
+ }
+
+ err := SyncQtengineConfig(tc.iconTheme)
+
+ if tc.wantErr {
+ assert.Error(t, err)
+ after, readErr := os.ReadFile(path)
+ assert.NoError(t, readErr)
+ assert.Equal(t, tc.existing, string(after), "a rejected merge must leave the file byte-identical")
+ return
+ }
+
+ assert.NoError(t, err)
+
+ info, err := os.Stat(path)
+ if err != nil {
+ t.Fatalf("failed to stat written config: %v", err)
+ }
+ assert.Equal(t, os.FileMode(0o644), info.Mode().Perm())
+
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("failed to read written config: %v", err)
+ }
+ assert.True(t, strings.HasSuffix(string(data), "}\n"), "config should be indented JSON with a trailing newline, got %q", string(data))
+
+ var cfg map[string]any
+ if err := json.Unmarshal(data, &cfg); err != nil {
+ t.Fatalf("written config is not valid JSON: %v", err)
+ }
+ theme, ok := cfg["theme"].(map[string]any)
+ if !ok {
+ t.Fatalf("written config has no theme object: %#v", cfg["theme"])
+ }
+ if !tc.noColorScheme {
+ assert.Equal(t, scheme, theme["colorScheme"])
+ }
+
+ if tc.check != nil {
+ tc.check(t, cfg, theme)
+ }
+ })
+ }
+}
+
+// The appended entry is the only thing driving the settings row's indicator.
+func TestCheckTemplatesIncludesQtengine(t *testing.T) {
+ tests := []struct {
+ name string
+ platformTheme string
+ wantDetected bool
+ }{
+ {name: "detected when qtengine is the platform theme", platformTheme: "qtengine", wantDetected: true},
+ {name: "not detected under another platform theme", platformTheme: "gtk3", wantDetected: false},
+ {name: "not detected when unset", platformTheme: "", wantDetected: false},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Setenv("QT_QPA_PLATFORMTHEME", tc.platformTheme)
+ t.Setenv("QT_QPA_PLATFORMTHEME_QT6", "")
+
+ // nil uses the default checker; the qtengine entry never consults it.
+ var found *TemplateCheck
+ for _, check := range CheckTemplates(nil) {
+ if check.ID == "qtengine" {
+ found = &check
+ break
+ }
+ }
+
+ if found == nil {
+ t.Fatal("CheckTemplates must report qtengine; the settings row indicator depends on it")
+ }
+ assert.Equal(t, tc.wantDetected, found.Detected)
+ })
+ }
+}
+
+func TestSyncQtengineConfigBumpsMtime(t *testing.T) {
+ tempDir := t.TempDir()
+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(tempDir, "config"))
+ t.Setenv("XDG_DATA_HOME", filepath.Join(tempDir, "data"))
+
+ if err := SyncQtengineConfig("Papirus-Dark"); err != nil {
+ t.Fatalf("first sync failed: %v", err)
+ }
+
+ path := QtengineConfigPath()
+ before, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("failed to read written config: %v", err)
+ }
+
+ past := time.Now().Add(-time.Hour)
+ if err := os.Chtimes(path, past, past); err != nil {
+ t.Fatalf("failed to backdate config: %v", err)
+ }
+
+ if err := SyncQtengineConfig("Papirus-Dark"); err != nil {
+ t.Fatalf("second sync failed: %v", err)
+ }
+
+ info, err := os.Stat(path)
+ if err != nil {
+ t.Fatalf("failed to stat config: %v", err)
+ }
+ assert.True(t, info.ModTime().After(past), "an unchanged write must still bump mtime; that is what repaints running apps")
+
+ after, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("failed to re-read config: %v", err)
+ }
+ assert.Equal(t, string(before), string(after))
+}
diff --git a/core/internal/matugen/qtengine.go b/core/internal/matugen/qtengine.go
new file mode 100644
index 000000000..4729437ef
--- /dev/null
+++ b/core/internal/matugen/qtengine.go
@@ -0,0 +1,110 @@
+package matugen
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
+)
+
+// Options.IconTheme defaults to this UI label rather than a real icon theme name.
+const qtenginePlaceholderIconTheme = "System Default"
+
+// QtengineActive reports whether qtengine is the selected Qt platform theme. Qt
+// never auto-loads a platform theme plugin, so the env var is necessary for it
+// to be in use. Mirrors the QML guard in ThemeColorsTab.qml.
+func QtengineActive() bool {
+ return os.Getenv("QT_QPA_PLATFORMTHEME") == "qtengine" ||
+ os.Getenv("QT_QPA_PLATFORMTHEME_QT6") == "qtengine"
+}
+
+// QtengineConfigPath returns the config qtengine serves both Qt5 and Qt6 from.
+func QtengineConfigPath() string {
+ return filepath.Join(utils.XDGConfigHome(), "qtengine", "config.json")
+}
+
+// SyncQtengineConfig merges the DMS colour scheme path and icon theme into
+// qtengine's config, preserving every other key, and writes it atomically. The
+// write's mtime bump is what trips qtengine's watcher and repaints running apps.
+// Map round trip rather than a typed struct so keys from qtengine versions we
+// have not seen survive.
+func SyncQtengineConfig(iconTheme string) error {
+ path := QtengineConfigPath()
+
+ // Empty file and JSON null are both "not set" to qtengine; a top-level null
+ // unmarshals to a nil map, which would panic on write.
+ cfg := map[string]any{}
+ data, err := os.ReadFile(path)
+ switch {
+ case err == nil:
+ if len(bytes.TrimSpace(data)) > 0 {
+ if err := json.Unmarshal(data, &cfg); err != nil {
+ return fmt.Errorf("refusing to overwrite unparseable %s: %w", path, err)
+ }
+ }
+ case !os.IsNotExist(err):
+ return fmt.Errorf("failed to read %s: %w", path, err)
+ }
+ if cfg == nil {
+ cfg = map[string]any{}
+ }
+
+ theme := map[string]any{}
+ if existing := cfg["theme"]; existing != nil {
+ obj, isObject := existing.(map[string]any)
+ if !isObject {
+ return fmt.Errorf("refusing to overwrite %s: %q is %T, not an object", path, "theme", existing)
+ }
+ theme = obj
+ }
+
+ // Only written if the file exists: qtengine resolves a missing path to an
+ // empty config and resets the palette to its built-in default.
+ scheme := filepath.Join(utils.XDGDataHome(), "color-schemes", "DankMatugen.colors")
+ if _, err := os.Stat(scheme); err == nil {
+ theme["colorScheme"] = scheme
+ }
+ if iconTheme != "" && iconTheme != qtenginePlaceholderIconTheme {
+ theme["iconTheme"] = iconTheme
+ }
+ cfg["theme"] = theme
+
+ out, err := json.MarshalIndent(cfg, "", " ")
+ if err != nil {
+ return fmt.Errorf("failed to encode %s: %w", path, err)
+ }
+ out = append(out, '\n')
+
+ dir := filepath.Dir(path)
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return fmt.Errorf("failed to create %s: %w", dir, err)
+ }
+
+ // Same directory so the rename is atomic; an in-place truncate risks a torn
+ // read by qtengine's watcher.
+ tmp, err := os.CreateTemp(dir, "config-*.json.tmp")
+ if err != nil {
+ return fmt.Errorf("failed to create temp config in %s: %w", dir, err)
+ }
+ tmpPath := tmp.Name()
+ defer os.Remove(tmpPath)
+
+ if _, err := tmp.Write(out); err != nil {
+ tmp.Close()
+ return fmt.Errorf("failed to write %s: %w", tmpPath, err)
+ }
+ if err := tmp.Close(); err != nil {
+ return fmt.Errorf("failed to close %s: %w", tmpPath, err)
+ }
+ if err := os.Chmod(tmpPath, 0o644); err != nil {
+ return fmt.Errorf("failed to set mode on %s: %w", tmpPath, err)
+ }
+ if err := os.Rename(tmpPath, path); err != nil {
+ return fmt.Errorf("failed to replace %s: %w", path, err)
+ }
+
+ return nil
+}
diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml
index 264af868f..61d52833e 100644
--- a/quickshell/Common/SettingsData.qml
+++ b/quickshell/Common/SettingsData.qml
@@ -77,6 +77,7 @@ Singleton {
readonly property string _configUrl: StandardPaths.writableLocation(StandardPaths.ConfigLocation)
readonly property string _configDir: Paths.strip(_configUrl)
readonly property string pluginSettingsPath: _configDir + "/DankMaterialShell/plugin_settings.json"
+ readonly property bool qtengineActive: Quickshell.env("QT_QPA_PLATFORMTHEME") === "qtengine" || Quickshell.env("QT_QPA_PLATFORMTHEME_QT6") === "qtengine"
property bool _loading: false
property bool _pluginSettingsLoading: false
@@ -828,6 +829,7 @@ Singleton {
property bool matugenTemplateQt5ct: true
property bool matugenTemplateQt6ct: true
property bool matugenTemplateFcitx5: true
+ property bool matugenTemplateQtengine: true
property bool matugenTemplateFirefox: true
property bool matugenTemplatePywalfox: true
property bool matugenTemplateZenBrowser: true
@@ -1522,6 +1524,10 @@ Singleton {
update_qt_icon_theme ${_configDir}/qt6ct/qt6ct.conf '${qtThemeNameEscaped}'`;
Quickshell.execDetached(["sh", "-lc", script]);
+
+ if (!qtengineActive || !runDmsMatugenTemplates || !matugenTemplateQtengine)
+ return;
+ Proc.runCommand("updateQtengineIconTheme", [Proc.dmsBin, "matugen", "qtengine", "--icon-theme", qtThemeName], () => {});
}
function scheduleAuthApply() {
diff --git a/quickshell/Common/Theme.qml b/quickshell/Common/Theme.qml
index 76fc2cb6c..c7c513d77 100644
--- a/quickshell/Common/Theme.qml
+++ b/quickshell/Common/Theme.qml
@@ -1525,7 +1525,7 @@ Singleton {
if (typeof SettingsData !== "undefined") {
const skipTemplates = [];
if (!SettingsData.runDmsMatugenTemplates) {
- skipTemplates.push("gtk", "nvim", "niri", "qt5ct", "qt6ct", "fcitx5", "firefox", "pywalfox", "zenbrowser", "vesktop", "vencord", "equibop", "ghostty", "kitty", "foot", "alacritty", "wezterm", "dgop", "kcolorscheme", "vscode", "emacs", "zed");
+ skipTemplates.push("gtk", "nvim", "niri", "qt5ct", "qt6ct", "qtengine", "fcitx5", "firefox", "pywalfox", "zenbrowser", "vesktop", "vencord", "equibop", "ghostty", "kitty", "foot", "alacritty", "wezterm", "dgop", "kcolorscheme", "vscode", "emacs", "zed");
} else {
if (!SettingsData.matugenTemplateGtk)
skipTemplates.push("gtk");
@@ -1539,6 +1539,8 @@ Singleton {
skipTemplates.push("qt5ct");
if (!SettingsData.matugenTemplateQt6ct)
skipTemplates.push("qt6ct");
+ if (!SettingsData.matugenTemplateQtengine)
+ skipTemplates.push("qtengine");
if (!SettingsData.matugenTemplateFcitx5)
skipTemplates.push("fcitx5");
if (!SettingsData.matugenTemplateFirefox)
@@ -1823,17 +1825,35 @@ Singleton {
return;
}
- Proc.runCommand("qtApplier", ["bash", shellDir + "/scripts/qt.sh", configDir], (output, exitCode) => {
- if (exitCode === 0) {
- if (typeof ToastService !== "undefined") {
- ToastService.showInfo(I18n.tr("Qt colors applied successfully"));
- }
+ const isQtengineActive = SettingsData.qtengineActive;
+ let pendingAppliers = isQtengineActive ? 2 : 1;
+ let anyApplierSucceeded = false;
+ let qtengineFailed = false;
+
+ const finishApplyQtColors = succeeded => {
+ anyApplierSucceeded = anyApplierSucceeded || succeeded;
+ pendingAppliers -= 1;
+ if (pendingAppliers !== 0)
+ return;
+ if (typeof ToastService === "undefined")
+ return;
+ if (anyApplierSucceeded && !qtengineFailed) {
+ ToastService.showInfo(I18n.tr("Qt colors applied successfully"));
} else {
- if (typeof ToastService !== "undefined") {
- ToastService.showError(I18n.tr("Failed to apply %1 colors").arg("Qt"));
- }
+ ToastService.showError(I18n.tr("Failed to apply %1 colors").arg("Qt"));
}
+ };
+
+ Proc.runCommand("qtApplier", ["bash", shellDir + "/scripts/qt.sh", configDir], (output, exitCode) => {
+ finishApplyQtColors(exitCode === 0);
});
+
+ if (isQtengineActive) {
+ Proc.runCommand("qtengineApplier", [Proc.dmsBin, "matugen", "qtengine", "--config-dir", configDir], (output, exitCode) => {
+ qtengineFailed = exitCode !== 0;
+ finishApplyQtColors(exitCode === 0);
+ });
+ }
}
function withAlpha(c, a) {
diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js
index 90b68aaca..4157b54b4 100644
--- a/quickshell/Common/settings/SettingsSpec.js
+++ b/quickshell/Common/settings/SettingsSpec.js
@@ -401,6 +401,7 @@ var SPEC = {
matugenTemplateQt5ct: { def: true },
matugenTemplateQt6ct: { def: true },
matugenTemplateFcitx5: { def: true },
+ matugenTemplateQtengine: { def: true },
matugenTemplateFirefox: { def: true },
matugenTemplatePywalfox: { def: true },
matugenTemplateZenBrowser: { def: true },
diff --git a/quickshell/Modules/Settings/ThemeColorsTab.qml b/quickshell/Modules/Settings/ThemeColorsTab.qml
index 632750b98..c57cfb956 100644
--- a/quickshell/Modules/Settings/ThemeColorsTab.qml
+++ b/quickshell/Modules/Settings/ThemeColorsTab.qml
@@ -274,9 +274,9 @@ Item {
}
function warnIfMissingQtTheme() {
- if (Quickshell.env("QT_QPA_PLATFORMTHEME") === "gtk3" || Quickshell.env("QT_QPA_PLATFORMTHEME") === "qt6ct" || Quickshell.env("QT_QPA_PLATFORMTHEME_QT6") === "qt6ct" || Quickshell.env("QT_QPA_PLATFORMTHEME") === "kde")
+ if (Quickshell.env("QT_QPA_PLATFORMTHEME") === "gtk3" || Quickshell.env("QT_QPA_PLATFORMTHEME") === "qt6ct" || Quickshell.env("QT_QPA_PLATFORMTHEME_QT6") === "qt6ct" || SettingsData.qtengineActive || Quickshell.env("QT_QPA_PLATFORMTHEME") === "kde")
return;
- ToastService.showError(I18n.tr("Missing Environment Variables", "qt theme env error title"), I18n.tr("You need to set either:\nQT_QPA_PLATFORMTHEME=gtk3 OR\nQT_QPA_PLATFORMTHEME=qt6ct\nas environment variables, and then restart the shell.\n\nqt6ct requires qt6ct-kde to be installed.", "qt theme env error body"));
+ ToastService.showError(I18n.tr("Missing Environment Variables", "qt theme env error title"), I18n.tr("You need to set one of:\nQT_QPA_PLATFORMTHEME=gtk3 OR\nQT_QPA_PLATFORMTHEME=qt6ct OR\nQT_QPA_PLATFORMTHEME=qtengine\nas environment variables, and then restart the shell.\n\nOnly qt6ct requires qt6ct-kde to be installed.", "qt theme env error body"));
}
function refreshMatugenSchemePreviews() {
@@ -2642,6 +2642,18 @@ Item {
onToggled: checked => SettingsData.set("matugenTemplateQt6ct", checked)
}
+ SettingsToggleRow {
+ tab: "theme"
+ tags: ["matugen", "qtengine", "template", "qt"]
+ settingKey: "matugenTemplateQtengine"
+ text: "qtengine"
+ description: getTemplateDescription("qtengine", "")
+ descriptionColor: getTemplateDescriptionColor("qtengine")
+ visible: SettingsData.runDmsMatugenTemplates
+ checked: SettingsData.matugenTemplateQtengine
+ onToggled: checked => SettingsData.set("matugenTemplateQtengine", checked)
+ }
+
SettingsToggleRow {
tab: "theme"
tags: ["matugen", "fcitx5", "input", "template"]
@@ -2966,7 +2978,7 @@ Item {
StyledText {
id: warningText
font.pixelSize: Theme.fontSizeSmall
- text: I18n.tr("The below settings will modify your GTK and Qt settings. If you wish to preserve your current configurations, please back them up (qt5ct.conf|qt6ct.conf and ~/.config/gtk-3.0|gtk-4.0).")
+ text: I18n.tr("The below settings will modify your GTK and Qt settings. If you wish to preserve your current configurations, please back them up (qt5ct.conf|qt6ct.conf|qtengine/config.json and ~/.config/gtk-3.0|gtk-4.0).")
wrapMode: Text.WordWrap
width: parent.width - Theme.iconSizeSmall - Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
@@ -3056,7 +3068,7 @@ Item {
}
StyledText {
- text: I18n.tr('Generate baseline GTK3/4 or QT5/QT6 (requires qt6ct-kde) configurations to follow DMS colors. Only needed once.
It is recommended to configure %1 prior to applying GTK themes.').arg(`adw-gtk3`)
+ text: I18n.tr('Generate baseline GTK3/4, QT5/QT6, or qtengine configurations to follow DMS colors (only qt6ct requires qt6ct-kde). Only needed once.
It is recommended to configure %1 prior to applying GTK themes.').arg(`adw-gtk3`)
textFormat: Text.RichText
linkColor: Theme.primary
onLinkActivated: url => Qt.openUrlExternally(url)
diff --git a/quickshell/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json
index 8d14b6292..ffe1ec558 100644
--- a/quickshell/translations/settings_search_index.json
+++ b/quickshell/translations/settings_search_index.json
@@ -5270,6 +5270,24 @@
"theme"
]
},
+ {
+ "section": "matugenTemplateQtengine",
+ "label": "qtengine",
+ "tabIndex": 10,
+ "category": "Theme & Colors",
+ "keywords": [
+ "appearance",
+ "colors",
+ "look",
+ "matugen",
+ "qt",
+ "qtengine",
+ "scheme",
+ "style",
+ "template",
+ "theme"
+ ]
+ },
{
"section": "matugenTemplateVencord",
"label": "vencord",