From a56360d8ed1d322174b2dad3d3224e0147cc31a0 Mon Sep 17 00:00:00 2001 From: Marlon Kranz Date: Mon, 17 Aug 2026 19:23:38 +0200 Subject: [PATCH] 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 --- core/cmd/dms/commands_doctor.go | 141 +++++++- core/cmd/dms/commands_doctor_test.go | 63 ++++ core/cmd/dms/commands_matugen.go | 16 + core/internal/matugen/matugen.go | 16 +- core/internal/matugen/matugen_test.go | 318 ++++++++++++++++++ core/internal/matugen/qtengine.go | 110 ++++++ quickshell/Common/SettingsData.qml | 6 + quickshell/Common/Theme.qml | 38 ++- quickshell/Common/settings/SettingsSpec.js | 1 + .../Modules/Settings/ThemeColorsTab.qml | 20 +- .../translations/settings_search_index.json | 18 + 11 files changed, 725 insertions(+), 22 deletions(-) create mode 100644 core/cmd/dms/commands_doctor_test.go create mode 100644 core/internal/matugen/qtengine.go 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",