feat(theme): support qtengine as a Qt platform theme (#3019)

* feat(theme): support qtengine as a Qt platform theme

qtengine is a single platform theme plugin serving both Qt5 and Qt6 from one JSON config, so it needs neither the qt5ct/qt6ct split nor qt6ct-kde.
It reads a KDE .colors scheme by path, which the kcolorscheme template already generates, so no new matugen template is required.

DMS now merges theme.colorScheme and theme.iconTheme into $XDG_CONFIG_HOME/qtengine/config.json after each generate, on icon theme changes, and from Apply Qt Colors.
The merge preserves every other key,refuses to touch a config it cannot parse, and only names a color scheme that exists.
The write is atomic, and its mtime bump is what trips qtengine's watcher and repaints running apps.

QT_QPA_PLATFORMTHEME=qtengine is accepted without the missing-env-var toast, Apply Qt Colors no longer fails on a machine without qt5ct or qt6ct, and dms doctor reports whether the plugin is installed where Qt actually searches, per Qt major.

* fix(theme): resolve qtengine config via XDG, tighten doctor probing

---------

Co-authored-by: bbedward <[email protected]>
This commit is contained in:
Marlon Kranzandbbedward authored and GitHub committed 2026-08-17 13:23:38 -04:00
1 parent 9abaf4051e
commit a56360d8ed
11 files changed
+725 -22

No files matched your search

+133 -8
View File
@@ -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
+63
View File
@@ -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)
}
})
}
}
+16
View File
@@ -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)
}
}
+15 -1
View File
@@ -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
}
+318
View File
@@ -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))
}
+110
View File
@@ -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
}
+6
View File
@@ -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() {
+29 -9
View File
@@ -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) {
@@ -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 },
+16 -4
View File
@@ -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.<br /><br />It is recommended to configure %1 prior to applying GTK themes.').arg(`<a href="https://github.com/AvengeMedia/DankMaterialShell/blob/master/README.md#Theming" style="text-decoration:none; color:${Theme.primary};">adw-gtk3</a>`)
text: I18n.tr('Generate baseline GTK3/4, QT5/QT6, or qtengine configurations to follow DMS colors (only qt6ct requires qt6ct-kde). Only needed once.<br /><br />It is recommended to configure %1 prior to applying GTK themes.').arg(`<a href="https://github.com/AvengeMedia/DankMaterialShell/blob/master/README.md#Theming" style="text-decoration:none; color:${Theme.primary};">adw-gtk3</a>`)
textFormat: Text.RichText
linkColor: Theme.primary
onLinkActivated: url => Qt.openUrlExternally(url)
@@ -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",