mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-30 15:07:49 -04:00
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:
1 parent
9abaf4051e
commit
a56360d8ed
11 files changed
+725
-22
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user