diff --git a/assets/dms-open.desktop b/assets/dms-open.desktop
index b9480a2a..25a29cd8 100644
--- a/assets/dms-open.desktop
+++ b/assets/dms-open.desktop
@@ -1,10 +1,10 @@
[Desktop Entry]
Type=Application
-Name=DMS Application Picker
+Name=DMS
Comment=Select an application to open links and files
Exec=dms open %u
Icon=danklogo
Terminal=false
NoDisplay=true
-MimeType=x-scheme-handler/http;x-scheme-handler/https;text/html;application/xhtml+xml;
+MimeType=x-scheme-handler/http;x-scheme-handler/https;x-scheme-handler/dms;text/html;application/xhtml+xml;
Categories=Utility;
diff --git a/core/cmd/dms/commands_open.go b/core/cmd/dms/commands_open.go
index 5f162a92..8cc16cfd 100644
--- a/core/cmd/dms/commands_open.go
+++ b/core/cmd/dms/commands_open.go
@@ -131,6 +131,12 @@ func runOpen(target string) {
detectedRequestType = "url"
}
log.Infof("Detected HTTP(S) URL")
+ } else if strings.HasPrefix(target, "dms://") {
+ // Handle DMS internal URLs (theme/plugin install, etc.)
+ if detectedRequestType == "" {
+ detectedRequestType = "url"
+ }
+ log.Infof("Detected DMS internal URL")
} else if _, err := os.Stat(target); err == nil {
// Handle local file paths directly (not file:// URIs)
// Convert to absolute path
@@ -177,7 +183,7 @@ func runOpen(target string) {
}
method := "apppicker.open"
- if detectedMimeType == "" && len(detectedCategories) == 0 && (strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://")) {
+ if detectedMimeType == "" && len(detectedCategories) == 0 && (strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") || strings.HasPrefix(target, "dms://")) {
method = "browser.open"
params["url"] = target
}
diff --git a/core/internal/server/router.go b/core/internal/server/router.go
index 44828b55..061b5ea3 100644
--- a/core/internal/server/router.go
+++ b/core/internal/server/router.go
@@ -18,6 +18,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
serverPlugins "github.com/AvengeMedia/DankMaterialShell/core/internal/server/plugins"
+ serverThemes "github.com/AvengeMedia/DankMaterialShell/core/internal/server/themes"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput"
)
@@ -37,6 +38,11 @@ func RouteRequest(conn net.Conn, req models.Request) {
return
}
+ if strings.HasPrefix(req.Method, "themes.") {
+ serverThemes.HandleRequest(conn, req)
+ return
+ }
+
if strings.HasPrefix(req.Method, "loginctl.") {
if loginctlManager == nil {
models.RespondError(conn, req.ID, "loginctl manager not initialized")
diff --git a/core/internal/server/themes/handlers.go b/core/internal/server/themes/handlers.go
new file mode 100644
index 00000000..b3b308a8
--- /dev/null
+++ b/core/internal/server/themes/handlers.go
@@ -0,0 +1,27 @@
+package themes
+
+import (
+ "fmt"
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+)
+
+func HandleRequest(conn net.Conn, req models.Request) {
+ switch req.Method {
+ case "themes.list":
+ HandleList(conn, req)
+ case "themes.listInstalled":
+ HandleListInstalled(conn, req)
+ case "themes.install":
+ HandleInstall(conn, req)
+ case "themes.uninstall":
+ HandleUninstall(conn, req)
+ case "themes.update":
+ HandleUpdate(conn, req)
+ case "themes.search":
+ HandleSearch(conn, req)
+ default:
+ models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
+ }
+}
diff --git a/core/internal/server/themes/install.go b/core/internal/server/themes/install.go
new file mode 100644
index 00000000..955dfff8
--- /dev/null
+++ b/core/internal/server/themes/install.go
@@ -0,0 +1,52 @@
+package themes
+
+import (
+ "fmt"
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/themes"
+)
+
+func HandleInstall(conn net.Conn, req models.Request) {
+ idOrName, ok := req.Params["name"].(string)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'name' parameter")
+ return
+ }
+
+ registry, err := themes.NewRegistry()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to create registry: %v", err))
+ return
+ }
+
+ themeList, err := registry.List()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to list themes: %v", err))
+ return
+ }
+
+ theme := themes.FindByIDOrName(idOrName, themeList)
+ if theme == nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("theme not found: %s", idOrName))
+ return
+ }
+
+ manager, err := themes.NewManager()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to create manager: %v", err))
+ return
+ }
+
+ registryThemeDir := registry.GetThemeDir(theme.SourceDir)
+ if err := manager.Install(*theme, registryThemeDir); err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to install theme: %v", err))
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{
+ Success: true,
+ Message: fmt.Sprintf("theme installed: %s", theme.Name),
+ })
+}
diff --git a/core/internal/server/themes/list.go b/core/internal/server/themes/list.go
new file mode 100644
index 00000000..66f9ce2a
--- /dev/null
+++ b/core/internal/server/themes/list.go
@@ -0,0 +1,52 @@
+package themes
+
+import (
+ "fmt"
+ "net"
+ "strings"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/themes"
+)
+
+func HandleList(conn net.Conn, req models.Request) {
+ registry, err := themes.NewRegistry()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to create registry: %v", err))
+ return
+ }
+
+ themeList, err := registry.List()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to list themes: %v", err))
+ return
+ }
+
+ manager, err := themes.NewManager()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to create manager: %v", err))
+ return
+ }
+
+ result := make([]ThemeInfo, len(themeList))
+ for i, t := range themeList {
+ installed, _ := manager.IsInstalled(t)
+ result[i] = ThemeInfo{
+ ID: t.ID,
+ Name: t.Name,
+ Version: t.Version,
+ Author: t.Author,
+ Description: t.Description,
+ PreviewPath: t.PreviewPath,
+ SourceDir: t.SourceDir,
+ Installed: installed,
+ FirstParty: isFirstParty(t.Author),
+ }
+ }
+
+ models.Respond(conn, req.ID, result)
+}
+
+func isFirstParty(author string) bool {
+ return strings.EqualFold(author, "Avenge Media") || strings.EqualFold(author, "AvengeMedia")
+}
diff --git a/core/internal/server/themes/list_installed.go b/core/internal/server/themes/list_installed.go
new file mode 100644
index 00000000..e9ccc882
--- /dev/null
+++ b/core/internal/server/themes/list_installed.go
@@ -0,0 +1,82 @@
+package themes
+
+import (
+ "fmt"
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/themes"
+)
+
+func HandleListInstalled(conn net.Conn, req models.Request) {
+ manager, err := themes.NewManager()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to create manager: %v", err))
+ return
+ }
+
+ installedIDs, err := manager.ListInstalled()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to list installed themes: %v", err))
+ return
+ }
+
+ registry, err := themes.NewRegistry()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to create registry: %v", err))
+ return
+ }
+
+ allThemes, err := registry.List()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to list themes: %v", err))
+ return
+ }
+
+ themeMap := make(map[string]themes.Theme)
+ for _, t := range allThemes {
+ themeMap[t.ID] = t
+ }
+
+ result := make([]ThemeInfo, 0, len(installedIDs))
+ for _, id := range installedIDs {
+ if theme, ok := themeMap[id]; ok {
+ hasUpdate := false
+ if hasUpdates, err := manager.HasUpdates(id, theme); err == nil {
+ hasUpdate = hasUpdates
+ }
+
+ result = append(result, ThemeInfo{
+ ID: theme.ID,
+ Name: theme.Name,
+ Version: theme.Version,
+ Author: theme.Author,
+ Description: theme.Description,
+ SourceDir: id,
+ FirstParty: isFirstParty(theme.Author),
+ HasUpdate: hasUpdate,
+ })
+ } else {
+ installed, err := manager.GetInstalledTheme(id)
+ if err != nil {
+ result = append(result, ThemeInfo{
+ ID: id,
+ Name: id,
+ SourceDir: id,
+ })
+ continue
+ }
+ result = append(result, ThemeInfo{
+ ID: installed.ID,
+ Name: installed.Name,
+ Version: installed.Version,
+ Author: installed.Author,
+ Description: installed.Description,
+ SourceDir: id,
+ FirstParty: isFirstParty(installed.Author),
+ })
+ }
+ }
+
+ models.Respond(conn, req.ID, result)
+}
diff --git a/core/internal/server/themes/search.go b/core/internal/server/themes/search.go
new file mode 100644
index 00000000..97e75a95
--- /dev/null
+++ b/core/internal/server/themes/search.go
@@ -0,0 +1,53 @@
+package themes
+
+import (
+ "fmt"
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/themes"
+)
+
+func HandleSearch(conn net.Conn, req models.Request) {
+ query, ok := req.Params["query"].(string)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'query' parameter")
+ return
+ }
+
+ registry, err := themes.NewRegistry()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to create registry: %v", err))
+ return
+ }
+
+ themeList, err := registry.List()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to list themes: %v", err))
+ return
+ }
+
+ searchResults := themes.FuzzySearch(query, themeList)
+
+ manager, err := themes.NewManager()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to create manager: %v", err))
+ return
+ }
+
+ result := make([]ThemeInfo, len(searchResults))
+ for i, t := range searchResults {
+ installed, _ := manager.IsInstalled(t)
+ result[i] = ThemeInfo{
+ ID: t.ID,
+ Name: t.Name,
+ Version: t.Version,
+ Author: t.Author,
+ Description: t.Description,
+ Installed: installed,
+ FirstParty: isFirstParty(t.Author),
+ }
+ }
+
+ models.Respond(conn, req.ID, result)
+}
diff --git a/core/internal/server/themes/types.go b/core/internal/server/themes/types.go
new file mode 100644
index 00000000..a2e1dd80
--- /dev/null
+++ b/core/internal/server/themes/types.go
@@ -0,0 +1,14 @@
+package themes
+
+type ThemeInfo struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Version string `json:"version"`
+ Author string `json:"author,omitempty"`
+ Description string `json:"description,omitempty"`
+ PreviewPath string `json:"previewPath,omitempty"`
+ SourceDir string `json:"sourceDir,omitempty"`
+ Installed bool `json:"installed,omitempty"`
+ FirstParty bool `json:"firstParty,omitempty"`
+ HasUpdate bool `json:"hasUpdate,omitempty"`
+}
diff --git a/core/internal/server/themes/uninstall.go b/core/internal/server/themes/uninstall.go
new file mode 100644
index 00000000..ed5edf6a
--- /dev/null
+++ b/core/internal/server/themes/uninstall.go
@@ -0,0 +1,63 @@
+package themes
+
+import (
+ "fmt"
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/themes"
+)
+
+func HandleUninstall(conn net.Conn, req models.Request) {
+ idOrName, ok := req.Params["name"].(string)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'name' parameter")
+ return
+ }
+
+ manager, err := themes.NewManager()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to create manager: %v", err))
+ return
+ }
+
+ registry, err := themes.NewRegistry()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to create registry: %v", err))
+ return
+ }
+
+ themeList, _ := registry.List()
+ theme := themes.FindByIDOrName(idOrName, themeList)
+
+ if theme != nil {
+ installed, err := manager.IsInstalled(*theme)
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to check if theme is installed: %v", err))
+ return
+ }
+ if !installed {
+ models.RespondError(conn, req.ID, fmt.Sprintf("theme not installed: %s", idOrName))
+ return
+ }
+ if err := manager.Uninstall(*theme); err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to uninstall theme: %v", err))
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{
+ Success: true,
+ Message: fmt.Sprintf("theme uninstalled: %s", theme.Name),
+ })
+ return
+ }
+
+ if err := manager.UninstallByID(idOrName); err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("theme not found: %s", idOrName))
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{
+ Success: true,
+ Message: fmt.Sprintf("theme uninstalled: %s", idOrName),
+ })
+}
diff --git a/core/internal/server/themes/update.go b/core/internal/server/themes/update.go
new file mode 100644
index 00000000..f351b100
--- /dev/null
+++ b/core/internal/server/themes/update.go
@@ -0,0 +1,57 @@
+package themes
+
+import (
+ "fmt"
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/themes"
+)
+
+func HandleUpdate(conn net.Conn, req models.Request) {
+ idOrName, ok := req.Params["name"].(string)
+ if !ok {
+ models.RespondError(conn, req.ID, "missing or invalid 'name' parameter")
+ return
+ }
+
+ manager, err := themes.NewManager()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to create manager: %v", err))
+ return
+ }
+
+ registry, err := themes.NewRegistry()
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to create registry: %v", err))
+ return
+ }
+
+ themeList, _ := registry.List()
+ theme := themes.FindByIDOrName(idOrName, themeList)
+
+ if theme == nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("theme not found in registry: %s", idOrName))
+ return
+ }
+
+ installed, err := manager.IsInstalled(*theme)
+ if err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to check if theme is installed: %v", err))
+ return
+ }
+ if !installed {
+ models.RespondError(conn, req.ID, fmt.Sprintf("theme not installed: %s", idOrName))
+ return
+ }
+
+ if err := manager.Update(*theme); err != nil {
+ models.RespondError(conn, req.ID, fmt.Sprintf("failed to update theme: %v", err))
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{
+ Success: true,
+ Message: fmt.Sprintf("theme updated: %s", theme.Name),
+ })
+}
diff --git a/core/internal/themes/manager.go b/core/internal/themes/manager.go
new file mode 100644
index 00000000..3cabc43e
--- /dev/null
+++ b/core/internal/themes/manager.go
@@ -0,0 +1,244 @@
+package themes
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ "github.com/spf13/afero"
+)
+
+type Manager struct {
+ fs afero.Fs
+ themesDir string
+}
+
+func NewManager() (*Manager, error) {
+ return NewManagerWithFs(afero.NewOsFs())
+}
+
+func NewManagerWithFs(fs afero.Fs) (*Manager, error) {
+ themesDir := getThemesDir()
+ return &Manager{
+ fs: fs,
+ themesDir: themesDir,
+ }, nil
+}
+
+func getThemesDir() string {
+ configDir, err := os.UserConfigDir()
+ if err != nil {
+ log.Error("failed to get user config dir", "err", err)
+ return ""
+ }
+ return filepath.Join(configDir, "DankMaterialShell", "themes")
+}
+
+func (m *Manager) IsInstalled(theme Theme) (bool, error) {
+ path := m.getInstalledPath(theme.ID)
+ exists, err := afero.Exists(m.fs, path)
+ if err != nil {
+ return false, err
+ }
+ return exists, nil
+}
+
+func (m *Manager) getInstalledDir(themeID string) string {
+ return filepath.Join(m.themesDir, themeID)
+}
+
+func (m *Manager) getInstalledPath(themeID string) string {
+ return filepath.Join(m.getInstalledDir(themeID), "theme.json")
+}
+
+func (m *Manager) Install(theme Theme, registryThemeDir string) error {
+ themeDir := m.getInstalledDir(theme.ID)
+
+ exists, err := afero.DirExists(m.fs, themeDir)
+ if err != nil {
+ return fmt.Errorf("failed to check if theme exists: %w", err)
+ }
+
+ if exists {
+ return fmt.Errorf("theme already installed: %s", theme.Name)
+ }
+
+ if err := m.fs.MkdirAll(themeDir, 0755); err != nil {
+ return fmt.Errorf("failed to create theme directory: %w", err)
+ }
+
+ data, err := json.MarshalIndent(theme, "", " ")
+ if err != nil {
+ return fmt.Errorf("failed to marshal theme: %w", err)
+ }
+
+ themePath := filepath.Join(themeDir, "theme.json")
+ if err := afero.WriteFile(m.fs, themePath, data, 0644); err != nil {
+ return fmt.Errorf("failed to write theme file: %w", err)
+ }
+
+ for _, preview := range []string{"preview-dark.svg", "preview-light.svg"} {
+ srcPath := filepath.Join(registryThemeDir, preview)
+ exists, _ := afero.Exists(m.fs, srcPath)
+ if !exists {
+ continue
+ }
+ data, err := afero.ReadFile(m.fs, srcPath)
+ if err != nil {
+ continue
+ }
+ dstPath := filepath.Join(themeDir, preview)
+ _ = afero.WriteFile(m.fs, dstPath, data, 0644)
+ }
+
+ return nil
+}
+
+func (m *Manager) InstallFromRegistry(registry *Registry, themeID string) error {
+ theme, err := registry.Get(themeID)
+ if err != nil {
+ return err
+ }
+
+ registryThemeDir := registry.GetThemeDir(theme.SourceDir)
+ return m.Install(*theme, registryThemeDir)
+}
+
+func (m *Manager) Update(theme Theme) error {
+ themePath := m.getInstalledPath(theme.ID)
+
+ exists, err := afero.Exists(m.fs, themePath)
+ if err != nil {
+ return fmt.Errorf("failed to check if theme exists: %w", err)
+ }
+
+ if !exists {
+ return fmt.Errorf("theme not installed: %s", theme.Name)
+ }
+
+ data, err := json.MarshalIndent(theme, "", " ")
+ if err != nil {
+ return fmt.Errorf("failed to marshal theme: %w", err)
+ }
+
+ if err := afero.WriteFile(m.fs, themePath, data, 0644); err != nil {
+ return fmt.Errorf("failed to write theme file: %w", err)
+ }
+
+ return nil
+}
+
+func (m *Manager) Uninstall(theme Theme) error {
+ return m.UninstallByID(theme.ID)
+}
+
+func (m *Manager) UninstallByID(themeID string) error {
+ themeDir := m.getInstalledDir(themeID)
+
+ exists, err := afero.DirExists(m.fs, themeDir)
+ if err != nil {
+ return fmt.Errorf("failed to check if theme exists: %w", err)
+ }
+
+ if !exists {
+ return fmt.Errorf("theme not installed: %s", themeID)
+ }
+
+ if err := m.fs.RemoveAll(themeDir); err != nil {
+ return fmt.Errorf("failed to remove theme: %w", err)
+ }
+
+ return nil
+}
+
+func (m *Manager) ListInstalled() ([]string, error) {
+ exists, err := afero.DirExists(m.fs, m.themesDir)
+ if err != nil {
+ return nil, err
+ }
+
+ if !exists {
+ return []string{}, nil
+ }
+
+ entries, err := afero.ReadDir(m.fs, m.themesDir)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read themes directory: %w", err)
+ }
+
+ var installed []string
+ for _, entry := range entries {
+ if !entry.IsDir() {
+ continue
+ }
+
+ themeID := entry.Name()
+ themePath := filepath.Join(m.themesDir, themeID, "theme.json")
+ if exists, _ := afero.Exists(m.fs, themePath); exists {
+ installed = append(installed, themeID)
+ }
+ }
+
+ return installed, nil
+}
+
+func (m *Manager) GetInstalledTheme(themeID string) (*Theme, error) {
+ themePath := m.getInstalledPath(themeID)
+
+ data, err := afero.ReadFile(m.fs, themePath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read theme file: %w", err)
+ }
+
+ var theme Theme
+ if err := json.Unmarshal(data, &theme); err != nil {
+ return nil, fmt.Errorf("failed to parse theme file: %w", err)
+ }
+
+ return &theme, nil
+}
+
+func (m *Manager) HasUpdates(themeID string, registryTheme Theme) (bool, error) {
+ installed, err := m.GetInstalledTheme(themeID)
+ if err != nil {
+ return false, err
+ }
+
+ return compareVersions(installed.Version, registryTheme.Version) < 0, nil
+}
+
+func compareVersions(installed, registry string) int {
+ installedParts := strings.Split(installed, ".")
+ registryParts := strings.Split(registry, ".")
+
+ maxLen := len(installedParts)
+ if len(registryParts) > maxLen {
+ maxLen = len(registryParts)
+ }
+
+ for i := 0; i < maxLen; i++ {
+ var installedNum, registryNum int
+ if i < len(installedParts) {
+ fmt.Sscanf(installedParts[i], "%d", &installedNum)
+ }
+ if i < len(registryParts) {
+ fmt.Sscanf(registryParts[i], "%d", ®istryNum)
+ }
+
+ if installedNum < registryNum {
+ return -1
+ }
+ if installedNum > registryNum {
+ return 1
+ }
+ }
+
+ return 0
+}
+
+func (m *Manager) GetThemesDir() string {
+ return m.themesDir
+}
diff --git a/core/internal/themes/registry.go b/core/internal/themes/registry.go
new file mode 100644
index 00000000..9e0f1b15
--- /dev/null
+++ b/core/internal/themes/registry.go
@@ -0,0 +1,236 @@
+package themes
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "github.com/go-git/go-git/v6"
+ "github.com/spf13/afero"
+)
+
+const registryRepo = "https://github.com/AvengeMedia/dms-plugin-registry.git"
+
+type ColorScheme struct {
+ Primary string `json:"primary"`
+ PrimaryText string `json:"primaryText"`
+ PrimaryContainer string `json:"primaryContainer"`
+ Secondary string `json:"secondary"`
+ Surface string `json:"surface"`
+ SurfaceText string `json:"surfaceText"`
+ SurfaceVariant string `json:"surfaceVariant"`
+ SurfaceVariantText string `json:"surfaceVariantText"`
+ SurfaceTint string `json:"surfaceTint"`
+ Background string `json:"background"`
+ BackgroundText string `json:"backgroundText"`
+ Outline string `json:"outline"`
+ SurfaceContainer string `json:"surfaceContainer"`
+ SurfaceContainerHigh string `json:"surfaceContainerHigh"`
+ Error string `json:"error"`
+ Warning string `json:"warning"`
+ Info string `json:"info"`
+}
+
+type Theme struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Version string `json:"version"`
+ Author string `json:"author"`
+ Description string `json:"description"`
+ Dark ColorScheme `json:"dark"`
+ Light ColorScheme `json:"light"`
+ PreviewPath string `json:"-"`
+ SourceDir string `json:"sourceDir,omitempty"`
+}
+
+type GitClient interface {
+ PlainClone(path string, url string) error
+ Pull(path string) error
+}
+
+type realGitClient struct{}
+
+func (g *realGitClient) PlainClone(path string, url string) error {
+ _, err := git.PlainClone(path, &git.CloneOptions{
+ URL: url,
+ Progress: os.Stdout,
+ })
+ return err
+}
+
+func (g *realGitClient) Pull(path string) error {
+ repo, err := git.PlainOpen(path)
+ if err != nil {
+ return err
+ }
+
+ worktree, err := repo.Worktree()
+ if err != nil {
+ return err
+ }
+
+ err = worktree.Pull(&git.PullOptions{})
+ if err != nil && err.Error() != "already up-to-date" {
+ return err
+ }
+
+ return nil
+}
+
+type Registry struct {
+ fs afero.Fs
+ cacheDir string
+ themes []Theme
+ git GitClient
+}
+
+func NewRegistry() (*Registry, error) {
+ return NewRegistryWithFs(afero.NewOsFs())
+}
+
+func NewRegistryWithFs(fs afero.Fs) (*Registry, error) {
+ cacheDir := getCacheDir()
+ return &Registry{
+ fs: fs,
+ cacheDir: cacheDir,
+ git: &realGitClient{},
+ }, nil
+}
+
+func getCacheDir() string {
+ return filepath.Join(os.TempDir(), "dankdots-plugin-registry")
+}
+
+func (r *Registry) Update() error {
+ exists, err := afero.DirExists(r.fs, r.cacheDir)
+ if err != nil {
+ return fmt.Errorf("failed to check cache directory: %w", err)
+ }
+
+ if !exists {
+ if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0755); err != nil {
+ return fmt.Errorf("failed to create cache directory: %w", err)
+ }
+
+ if err := r.git.PlainClone(r.cacheDir, registryRepo); err != nil {
+ return fmt.Errorf("failed to clone registry: %w", err)
+ }
+ } else {
+ if err := r.git.Pull(r.cacheDir); err != nil {
+ if err := r.fs.RemoveAll(r.cacheDir); err != nil {
+ return fmt.Errorf("failed to remove corrupted registry: %w", err)
+ }
+
+ if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0755); err != nil {
+ return fmt.Errorf("failed to create cache directory: %w", err)
+ }
+
+ if err := r.git.PlainClone(r.cacheDir, registryRepo); err != nil {
+ return fmt.Errorf("failed to re-clone registry: %w", err)
+ }
+ }
+ }
+
+ return r.loadThemes()
+}
+
+func (r *Registry) loadThemes() error {
+ themesDir := filepath.Join(r.cacheDir, "themes")
+
+ entries, err := afero.ReadDir(r.fs, themesDir)
+ if err != nil {
+ return fmt.Errorf("failed to read themes directory: %w", err)
+ }
+
+ r.themes = []Theme{}
+
+ for _, entry := range entries {
+ if !entry.IsDir() {
+ continue
+ }
+
+ themeDir := filepath.Join(themesDir, entry.Name())
+ themeFile := filepath.Join(themeDir, "theme.json")
+
+ data, err := afero.ReadFile(r.fs, themeFile)
+ if err != nil {
+ continue
+ }
+
+ var theme Theme
+ if err := json.Unmarshal(data, &theme); err != nil {
+ continue
+ }
+
+ if theme.ID == "" {
+ theme.ID = entry.Name()
+ }
+ theme.SourceDir = entry.Name()
+
+ previewPath := filepath.Join(themeDir, "preview.svg")
+ if exists, _ := afero.Exists(r.fs, previewPath); exists {
+ theme.PreviewPath = previewPath
+ }
+
+ r.themes = append(r.themes, theme)
+ }
+
+ return nil
+}
+
+func (r *Registry) List() ([]Theme, error) {
+ if len(r.themes) == 0 {
+ if err := r.Update(); err != nil {
+ return nil, err
+ }
+ }
+
+ return SortByFirstParty(r.themes), nil
+}
+
+func (r *Registry) Search(query string) ([]Theme, error) {
+ allThemes, err := r.List()
+ if err != nil {
+ return nil, err
+ }
+
+ if query == "" {
+ return allThemes, nil
+ }
+
+ return SortByFirstParty(FuzzySearch(query, allThemes)), nil
+}
+
+func (r *Registry) Get(idOrName string) (*Theme, error) {
+ themes, err := r.List()
+ if err != nil {
+ return nil, err
+ }
+
+ for _, t := range themes {
+ if t.ID == idOrName {
+ return &t, nil
+ }
+ }
+
+ for _, t := range themes {
+ if t.Name == idOrName {
+ return &t, nil
+ }
+ }
+
+ return nil, fmt.Errorf("theme not found: %s", idOrName)
+}
+
+func (r *Registry) GetThemeSourcePath(themeID string) string {
+ return filepath.Join(r.cacheDir, "themes", themeID, "theme.json")
+}
+
+func (r *Registry) GetThemeDir(themeID string) string {
+ return filepath.Join(r.cacheDir, "themes", themeID)
+}
+
+func SortByFirstParty(themes []Theme) []Theme {
+ return themes
+}
diff --git a/core/internal/themes/search.go b/core/internal/themes/search.go
new file mode 100644
index 00000000..347f791e
--- /dev/null
+++ b/core/internal/themes/search.go
@@ -0,0 +1,40 @@
+package themes
+
+import (
+ "strings"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
+)
+
+func FuzzySearch(query string, themes []Theme) []Theme {
+ if query == "" {
+ return themes
+ }
+
+ queryLower := strings.ToLower(query)
+ return utils.Filter(themes, func(t Theme) bool {
+ return fuzzyMatch(queryLower, strings.ToLower(t.Name)) ||
+ fuzzyMatch(queryLower, strings.ToLower(t.Description)) ||
+ fuzzyMatch(queryLower, strings.ToLower(t.Author))
+ })
+}
+
+func fuzzyMatch(query, text string) bool {
+ queryIdx := 0
+ for _, char := range text {
+ if queryIdx < len(query) && char == rune(query[queryIdx]) {
+ queryIdx++
+ }
+ }
+ return queryIdx == len(query)
+}
+
+func FindByIDOrName(idOrName string, themes []Theme) *Theme {
+ if t, found := utils.Find(themes, func(t Theme) bool { return t.ID == idOrName }); found {
+ return &t
+ }
+ if t, found := utils.Find(themes, func(t Theme) bool { return t.Name == idOrName }); found {
+ return &t
+ }
+ return nil
+}
diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml
index 438d1ccd..e3c6f3fe 100644
--- a/quickshell/Common/SettingsData.qml
+++ b/quickshell/Common/SettingsData.qml
@@ -63,6 +63,7 @@ Singleton {
property alias dankBarRightWidgetsModel: rightWidgetsModel
property string currentThemeName: "blue"
+ property string currentThemeCategory: "generic"
property string customThemeFile: ""
property string matugenScheme: "scheme-tonal-spot"
property bool runUserMatugenTemplates: true
@@ -562,10 +563,12 @@ Singleton {
function applyStoredTheme() {
if (typeof Theme !== "undefined") {
+ Theme.currentThemeCategory = currentThemeCategory;
Theme.switchTheme(currentThemeName, false, false);
} else {
Qt.callLater(function () {
if (typeof Theme !== "undefined") {
+ Theme.currentThemeCategory = currentThemeCategory;
Theme.switchTheme(currentThemeName, false, false);
}
});
diff --git a/quickshell/Common/Theme.qml b/quickshell/Common/Theme.qml
index 87ffdc54..f96ce337 100644
--- a/quickshell/Common/Theme.qml
+++ b/quickshell/Common/Theme.qml
@@ -474,24 +474,32 @@ Singleton {
if (themeName === dynamic) {
currentTheme = dynamic;
- currentThemeCategory = dynamic;
+ if (currentThemeCategory !== "registry")
+ currentThemeCategory = dynamic;
} else if (themeName === custom) {
currentTheme = custom;
- currentThemeCategory = custom;
+ if (currentThemeCategory !== "registry")
+ currentThemeCategory = custom;
if (typeof SettingsData !== "undefined" && SettingsData.customThemeFile) {
loadCustomThemeFromFile(SettingsData.customThemeFile);
}
+ } else if (themeName === "" && currentThemeCategory === "registry") {
+ // Registry category selected but no theme chosen yet
} else {
currentTheme = themeName;
- if (StockThemes.isCatppuccinVariant(themeName)) {
- currentThemeCategory = "catppuccin";
- } else {
- currentThemeCategory = "generic";
+ if (currentThemeCategory !== "registry") {
+ if (StockThemes.isCatppuccinVariant(themeName)) {
+ currentThemeCategory = "catppuccin";
+ } else {
+ currentThemeCategory = "generic";
+ }
}
}
const isGreeterMode = (typeof SessionData !== "undefined" && SessionData.isGreeterMode);
- if (savePrefs && typeof SettingsData !== "undefined" && !isGreeterMode)
+ if (savePrefs && typeof SettingsData !== "undefined" && !isGreeterMode) {
+ SettingsData.set("currentThemeCategory", currentThemeCategory);
SettingsData.set("currentThemeName", currentTheme);
+ }
if (!isGreeterMode) {
generateSystemThemesFromCurrentTheme();
diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js
index e73039d9..61a5f03e 100644
--- a/quickshell/Common/settings/SettingsSpec.js
+++ b/quickshell/Common/settings/SettingsSpec.js
@@ -7,6 +7,7 @@ function percentToUnit(v) {
var SPEC = {
currentThemeName: { def: "blue", onChange: "applyStoredTheme" },
+ currentThemeCategory: { def: "generic" },
customThemeFile: { def: "" },
matugenScheme: { def: "scheme-tonal-spot", onChange: "regenSystemThemes" },
runUserMatugenTemplates: { def: true, onChange: "regenSystemThemes" },
diff --git a/quickshell/DMSShell.qml b/quickshell/DMSShell.qml
index 272589a3..c40c1fee 100644
--- a/quickshell/DMSShell.qml
+++ b/quickshell/DMSShell.qml
@@ -510,6 +510,22 @@ Item {
Connections {
target: DMSService
function onOpenUrlRequested(url) {
+ if (url.startsWith("dms://theme/install/")) {
+ var themeId = url.replace("dms://theme/install/", "").split(/[?#]/)[0];
+ if (themeId) {
+ PopoutService.pendingThemeInstall = themeId;
+ PopoutService.openSettingsWithTab("theme");
+ }
+ return;
+ }
+ if (url.startsWith("dms://plugin/install/")) {
+ var pluginId = url.replace("dms://plugin/install/", "").split(/[?#]/)[0];
+ if (pluginId) {
+ PopoutService.pendingPluginInstall = pluginId;
+ PopoutService.openSettingsWithTab("plugins");
+ }
+ return;
+ }
browserPickerModal.url = url;
browserPickerModal.open();
}
diff --git a/quickshell/Modules/Settings/PluginBrowser.qml b/quickshell/Modules/Settings/PluginBrowser.qml
index 36f560c0..af558d44 100644
--- a/quickshell/Modules/Settings/PluginBrowser.qml
+++ b/quickshell/Modules/Settings/PluginBrowser.qml
@@ -2,6 +2,7 @@ import QtQuick
import QtQuick.Controls
import Quickshell
import qs.Common
+import qs.Modals.Common
import qs.Services
import qs.Widgets
@@ -15,6 +16,7 @@ FloatingWindow {
property bool keyboardNavigationActive: false
property bool isLoading: false
property var parentModal: null
+ property bool pendingInstallHandled: false
function updateFilteredPlugins() {
var filtered = [];
@@ -61,16 +63,22 @@ FloatingWindow {
keyboardNavigationActive = false;
}
- function installPlugin(pluginName) {
- ToastService.showInfo("Installing plugin: " + pluginName);
+ function installPlugin(pluginName, enableAfterInstall) {
+ ToastService.showInfo(I18n.tr("Installing: %1", "installation progress").arg(pluginName));
DMSService.install(pluginName, response => {
if (response.error) {
- ToastService.showError("Install failed: " + response.error);
+ ToastService.showError(I18n.tr("Install failed: %1", "installation error").arg(response.error));
return;
}
- ToastService.showInfo("Plugin installed: " + pluginName);
+ ToastService.showInfo(I18n.tr("Installed: %1", "installation success").arg(pluginName));
PluginService.scanPlugins();
refreshPlugins();
+ if (enableAfterInstall) {
+ Qt.callLater(() => {
+ PluginService.enablePlugin(pluginName);
+ hide();
+ });
+ }
});
}
@@ -81,13 +89,27 @@ FloatingWindow {
DMSService.listInstalled();
}
+ function checkPendingInstall() {
+ if (!PopoutService.pendingPluginInstall || pendingInstallHandled)
+ return;
+ pendingInstallHandled = true;
+ var pluginId = PopoutService.pendingPluginInstall;
+ PopoutService.pendingPluginInstall = "";
+ urlInstallConfirm.showWithOptions({
+ title: I18n.tr("Install Plugin", "plugin installation dialog title"),
+ message: I18n.tr("Install plugin '%1' from the DMS registry?", "plugin installation confirmation").arg(pluginId),
+ confirmText: I18n.tr("Install", "install action button"),
+ cancelText: I18n.tr("Cancel"),
+ onConfirm: () => installPlugin(pluginId, true),
+ onCancel: () => hide()
+ });
+ }
+
function show() {
if (parentModal)
parentModal.shouldHaveFocus = false;
visible = true;
- Qt.callLater(() => {
- browserSearchField.forceActiveFocus();
- });
+ Qt.callLater(() => browserSearchField.forceActiveFocus());
}
function hide() {
@@ -102,7 +124,7 @@ FloatingWindow {
}
objectName: "pluginBrowser"
- title: I18n.tr("Browse Plugins")
+ title: I18n.tr("Browse Plugins", "plugin browser window title")
minimumSize: Qt.size(450, 400)
implicitWidth: 600
implicitHeight: 650
@@ -111,9 +133,11 @@ FloatingWindow {
onVisibleChanged: {
if (visible) {
+ pendingInstallHandled = false;
refreshPlugins();
Qt.callLater(() => {
browserSearchField.forceActiveFocus();
+ checkPendingInstall();
});
return;
}
@@ -125,6 +149,10 @@ FloatingWindow {
isLoading = false;
}
+ ConfirmModal {
+ id: urlInstallConfirm
+ }
+
FocusScope {
id: browserKeyHandler
@@ -171,7 +199,7 @@ FloatingWindow {
StyledText {
id: headerText
- text: I18n.tr("Browse Plugins")
+ text: I18n.tr("Browse Plugins", "plugin browser header")
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.Medium
color: Theme.surfaceText
@@ -225,7 +253,7 @@ FloatingWindow {
anchors.right: parent.right
anchors.top: headerArea.bottom
anchors.topMargin: Theme.spacingM
- text: I18n.tr("Install plugins from the DMS plugin registry")
+ text: I18n.tr("Install plugins from the DMS plugin registry", "plugin browser description")
font.pixelSize: Theme.fontSizeSmall
color: Theme.outline
wrapMode: Text.WordWrap
@@ -249,7 +277,7 @@ FloatingWindow {
showClearButton: true
textColor: Theme.surfaceText
font.pixelSize: Theme.fontSizeMedium
- placeholderText: I18n.tr("Search plugins...")
+ placeholderText: I18n.tr("Search plugins...", "plugin search placeholder")
text: root.searchQuery
focus: true
ignoreLeftRightKeys: true
@@ -293,7 +321,7 @@ FloatingWindow {
}
StyledText {
- text: I18n.tr("Loading plugins...")
+ text: I18n.tr("Loading...", "loading indicator")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceVariantText
anchors.horizontalCenter: parent.horizontalCenter
@@ -407,8 +435,8 @@ FloatingWindow {
StyledText {
text: {
- const author = "by " + (modelData.author || "Unknown");
- const source = modelData.repo ? ` • source` : "";
+ const author = I18n.tr("by %1", "author attribution").arg(modelData.author || I18n.tr("Unknown", "unknown author"));
+ const source = modelData.repo ? ` • ${I18n.tr("source", "source code link")}` : "";
return author + source;
}
font.pixelSize: Theme.fontSizeSmall
@@ -458,7 +486,7 @@ FloatingWindow {
}
StyledText {
- text: isInstalled ? "Installed" : "Install"
+ text: isInstalled ? I18n.tr("Installed", "installed status") : I18n.tr("Install", "install action button")
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
color: isInstalled ? Theme.surfaceText : Theme.surface
@@ -474,7 +502,7 @@ FloatingWindow {
enabled: !isInstalled
onClicked: {
if (!isInstalled)
- root.installPlugin(modelData.name);
+ root.installPlugin(modelData.name, false);
}
}
}
@@ -521,7 +549,7 @@ FloatingWindow {
StyledText {
anchors.centerIn: listArea
- text: I18n.tr("No plugins found")
+ text: I18n.tr("No plugins found", "empty plugin list")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceVariantText
visible: !root.isLoading && root.filteredPlugins.length === 0
diff --git a/quickshell/Modules/Settings/PluginsTab.qml b/quickshell/Modules/Settings/PluginsTab.qml
index aa857d2f..81bffad6 100644
--- a/quickshell/Modules/Settings/PluginsTab.qml
+++ b/quickshell/Modules/Settings/PluginsTab.qml
@@ -387,6 +387,16 @@ FocusScope {
pluginBrowser.parentModal = pluginsTab.parentModal;
if (DMSService.dmsAvailable && DMSService.apiVersion >= 8)
DMSService.listInstalled();
+ if (PopoutService.pendingPluginInstall)
+ Qt.callLater(() => pluginBrowser.show());
+ }
+
+ Connections {
+ target: PopoutService
+ function onPendingPluginInstallChanged() {
+ if (PopoutService.pendingPluginInstall)
+ pluginBrowser.show();
+ }
}
PluginBrowser {
diff --git a/quickshell/Modules/Settings/ThemeBrowser.qml b/quickshell/Modules/Settings/ThemeBrowser.qml
new file mode 100644
index 00000000..eebac12b
--- /dev/null
+++ b/quickshell/Modules/Settings/ThemeBrowser.qml
@@ -0,0 +1,572 @@
+import QtQuick
+import QtQuick.Controls
+import Quickshell
+import qs.Common
+import qs.Modals.Common
+import qs.Services
+import qs.Widgets
+
+FloatingWindow {
+ id: root
+
+ property var allThemes: []
+ property string searchQuery: ""
+ property var filteredThemes: []
+ property int selectedIndex: -1
+ property bool keyboardNavigationActive: false
+ property bool isLoading: false
+ property var parentModal: null
+ property bool pendingInstallHandled: false
+ property string pendingApplyThemeId: ""
+
+ function updateFilteredThemes() {
+ var filtered = [];
+ var query = searchQuery ? searchQuery.toLowerCase() : "";
+
+ for (var i = 0; i < allThemes.length; i++) {
+ var theme = allThemes[i];
+
+ if (query.length === 0) {
+ filtered.push(theme);
+ continue;
+ }
+
+ var name = theme.name ? theme.name.toLowerCase() : "";
+ var description = theme.description ? theme.description.toLowerCase() : "";
+ var author = theme.author ? theme.author.toLowerCase() : "";
+
+ if (name.indexOf(query) !== -1 || description.indexOf(query) !== -1 || author.indexOf(query) !== -1)
+ filtered.push(theme);
+ }
+
+ filteredThemes = filtered;
+ selectedIndex = -1;
+ keyboardNavigationActive = false;
+ }
+
+ function selectNext() {
+ if (filteredThemes.length === 0)
+ return;
+ keyboardNavigationActive = true;
+ selectedIndex = Math.min(selectedIndex + 1, filteredThemes.length - 1);
+ }
+
+ function selectPrevious() {
+ if (filteredThemes.length === 0)
+ return;
+ keyboardNavigationActive = true;
+ selectedIndex = Math.max(selectedIndex - 1, -1);
+ if (selectedIndex === -1)
+ keyboardNavigationActive = false;
+ }
+
+ function installTheme(themeId, themeName, applyAfterInstall) {
+ ToastService.showInfo(I18n.tr("Installing: %1", "installation progress").arg(themeName));
+ DMSService.installTheme(themeId, response => {
+ if (response.error) {
+ ToastService.showError(I18n.tr("Install failed: %1", "installation error").arg(response.error));
+ return;
+ }
+ ToastService.showInfo(I18n.tr("Installed: %1", "installation success").arg(themeName));
+ if (applyAfterInstall)
+ pendingApplyThemeId = themeId;
+ refreshThemes();
+ });
+ }
+
+ function applyInstalledTheme(themeId, installedThemes) {
+ for (var i = 0; i < installedThemes.length; i++) {
+ var theme = installedThemes[i];
+ if (theme.id === themeId) {
+ var sourceDir = theme.sourceDir || theme.id;
+ var themePath = Quickshell.env("HOME") + "/.config/DankMaterialShell/themes/" + sourceDir + "/theme.json";
+ SettingsData.set("customThemeFile", themePath);
+ Theme.switchThemeCategory("registry", "custom");
+ Theme.switchTheme("custom", true, true);
+ hide();
+ return;
+ }
+ }
+ }
+
+ function uninstallTheme(themeId, themeName) {
+ ToastService.showInfo(I18n.tr("Uninstalling: %1", "uninstallation progress").arg(themeName));
+ DMSService.uninstallTheme(themeId, response => {
+ if (response.error) {
+ ToastService.showError(I18n.tr("Uninstall failed: %1", "uninstallation error").arg(response.error));
+ return;
+ }
+ ToastService.showInfo(I18n.tr("Uninstalled: %1", "uninstallation success").arg(themeName));
+ refreshThemes();
+ });
+ }
+
+ function refreshThemes() {
+ isLoading = true;
+ DMSService.listThemes();
+ DMSService.listInstalledThemes();
+ }
+
+ function checkPendingInstall() {
+ if (!PopoutService.pendingThemeInstall || pendingInstallHandled)
+ return;
+ pendingInstallHandled = true;
+ var themeId = PopoutService.pendingThemeInstall;
+ PopoutService.pendingThemeInstall = "";
+ urlInstallConfirm.showWithOptions({
+ title: I18n.tr("Install Theme", "theme installation dialog title"),
+ message: I18n.tr("Install theme '%1' from the DMS registry?", "theme installation confirmation").arg(themeId),
+ confirmText: I18n.tr("Install", "install action button"),
+ cancelText: I18n.tr("Cancel"),
+ onConfirm: () => installTheme(themeId, themeId, true),
+ onCancel: () => hide()
+ });
+ }
+
+ function show() {
+ if (parentModal)
+ parentModal.shouldHaveFocus = false;
+ visible = true;
+ Qt.callLater(() => browserSearchField.forceActiveFocus());
+ }
+
+ function hide() {
+ visible = false;
+ if (!parentModal)
+ return;
+ parentModal.shouldHaveFocus = Qt.binding(() => parentModal.shouldBeVisible);
+ Qt.callLater(() => {
+ if (parentModal.modalFocusScope)
+ parentModal.modalFocusScope.forceActiveFocus();
+ });
+ }
+
+ objectName: "themeBrowser"
+ title: I18n.tr("Browse Themes", "theme browser window title")
+ minimumSize: Qt.size(550, 450)
+ implicitWidth: 700
+ implicitHeight: 700
+ color: Theme.surfaceContainer
+ visible: false
+
+ onVisibleChanged: {
+ if (visible) {
+ pendingInstallHandled = false;
+ refreshThemes();
+ Qt.callLater(() => {
+ browserSearchField.forceActiveFocus();
+ checkPendingInstall();
+ });
+ return;
+ }
+ allThemes = [];
+ searchQuery = "";
+ filteredThemes = [];
+ selectedIndex = -1;
+ keyboardNavigationActive = false;
+ isLoading = false;
+ }
+
+ ConfirmModal {
+ id: urlInstallConfirm
+ }
+
+ Connections {
+ target: DMSService
+ function onThemesListReceived(themes) {
+ isLoading = false;
+ allThemes = themes;
+ updateFilteredThemes();
+ }
+ function onInstalledThemesReceived(themes) {
+ if (!pendingApplyThemeId)
+ return;
+ var themeId = pendingApplyThemeId;
+ pendingApplyThemeId = "";
+ applyInstalledTheme(themeId, themes);
+ }
+ }
+
+ FocusScope {
+ id: browserKeyHandler
+
+ anchors.fill: parent
+ focus: true
+
+ Keys.onPressed: event => {
+ switch (event.key) {
+ case Qt.Key_Escape:
+ root.hide();
+ event.accepted = true;
+ return;
+ case Qt.Key_Down:
+ root.selectNext();
+ event.accepted = true;
+ return;
+ case Qt.Key_Up:
+ root.selectPrevious();
+ event.accepted = true;
+ return;
+ }
+ }
+
+ Item {
+ id: browserContent
+ anchors.fill: parent
+ anchors.margins: Theme.spacingL
+
+ Item {
+ id: headerArea
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ height: Math.max(headerIcon.height, headerText.height, refreshButton.height, closeButton.height)
+
+ DankIcon {
+ id: headerIcon
+ name: "palette"
+ size: Theme.iconSize
+ color: Theme.primary
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ id: headerText
+ text: I18n.tr("Browse Themes", "theme browser header")
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ anchors.left: headerIcon.right
+ anchors.leftMargin: Theme.spacingM
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Row {
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingXS
+
+ DankActionButton {
+ id: refreshButton
+ iconName: "refresh"
+ iconSize: 18
+ iconColor: Theme.primary
+ visible: !root.isLoading
+ onClicked: root.refreshThemes()
+ }
+
+ DankActionButton {
+ id: closeButton
+ iconName: "close"
+ iconSize: Theme.iconSize - 2
+ iconColor: Theme.outline
+ onClicked: root.hide()
+ }
+ }
+ }
+
+ StyledText {
+ id: descriptionText
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: headerArea.bottom
+ anchors.topMargin: Theme.spacingM
+ text: I18n.tr("Install color themes from the DMS theme registry", "theme browser description")
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.outline
+ wrapMode: Text.WordWrap
+ }
+
+ DankTextField {
+ id: browserSearchField
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: descriptionText.bottom
+ anchors.topMargin: Theme.spacingM
+ height: 48
+ cornerRadius: Theme.cornerRadius
+ backgroundColor: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
+ normalBorderColor: Theme.outlineMedium
+ focusedBorderColor: Theme.primary
+ leftIconName: "search"
+ leftIconSize: Theme.iconSize
+ leftIconColor: Theme.surfaceVariantText
+ leftIconFocusedColor: Theme.primary
+ showClearButton: true
+ textColor: Theme.surfaceText
+ font.pixelSize: Theme.fontSizeMedium
+ placeholderText: I18n.tr("Search themes...", "theme search placeholder")
+ text: root.searchQuery
+ focus: true
+ ignoreLeftRightKeys: true
+ keyForwardTargets: [browserKeyHandler]
+ onTextEdited: {
+ root.searchQuery = text;
+ root.updateFilteredThemes();
+ }
+ }
+
+ Item {
+ id: listArea
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: browserSearchField.bottom
+ anchors.topMargin: Theme.spacingM
+ anchors.bottom: parent.bottom
+ anchors.bottomMargin: Theme.spacingM
+
+ Item {
+ anchors.fill: parent
+ visible: root.isLoading
+
+ Column {
+ anchors.centerIn: parent
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: "sync"
+ size: 48
+ color: Theme.primary
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ RotationAnimator on rotation {
+ from: 0
+ to: 360
+ duration: 1000
+ loops: Animation.Infinite
+ running: root.isLoading
+ }
+ }
+
+ StyledText {
+ text: I18n.tr("Loading...", "loading indicator")
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceVariantText
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+ }
+ }
+
+ DankListView {
+ id: themeBrowserList
+
+ anchors.fill: parent
+ anchors.leftMargin: Theme.spacingM
+ anchors.rightMargin: Theme.spacingM
+ anchors.topMargin: Theme.spacingS
+ anchors.bottomMargin: Theme.spacingS
+ spacing: Theme.spacingS
+ model: ScriptModel {
+ values: root.filteredThemes
+ }
+ clip: true
+ visible: !root.isLoading
+
+ ScrollBar.vertical: DankScrollbar {
+ id: browserScrollbar
+ }
+
+ delegate: Rectangle {
+ width: themeBrowserList.width
+ height: hasPreview ? 140 : themeDelegateContent.implicitHeight + Theme.spacingM * 2
+ radius: Theme.cornerRadius
+ property bool isSelected: root.keyboardNavigationActive && index === root.selectedIndex
+ property bool isInstalled: modelData.installed || false
+ property bool isFirstParty: modelData.firstParty || false
+ property string previewPath: "/tmp/dankdots-plugin-registry/themes/" + (modelData.sourceDir || modelData.id) + "/preview-" + (Theme.isLightMode ? "light" : "dark") + ".svg"
+ property bool hasPreview: previewImage.status === Image.Ready
+ color: isSelected ? Theme.primarySelected : Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.3)
+ border.color: isSelected ? Theme.primary : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
+ border.width: isSelected ? 2 : 1
+
+ Row {
+ id: themeDelegateContent
+ anchors.fill: parent
+ anchors.margins: Theme.spacingM
+ spacing: Theme.spacingM
+
+ Rectangle {
+ width: hasPreview ? 180 : 0
+ height: parent.height
+ radius: Theme.cornerRadius - 2
+ color: Theme.surfaceContainerHigh
+ visible: hasPreview
+
+ Image {
+ id: previewImage
+ anchors.fill: parent
+ anchors.margins: 2
+ source: "file://" + previewPath
+ fillMode: Image.PreserveAspectFit
+ smooth: true
+ mipmap: true
+ }
+ }
+
+ DankIcon {
+ name: "palette"
+ size: 48
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+ visible: !hasPreview
+ }
+
+ Column {
+ width: parent.width - (hasPreview ? 180 : 48) - Theme.spacingM - installButton.width - Theme.spacingM
+ spacing: 6
+ anchors.verticalCenter: parent.verticalCenter
+
+ Row {
+ spacing: Theme.spacingXS
+ width: parent.width
+
+ StyledText {
+ text: modelData.name
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ elide: Text.ElideRight
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Rectangle {
+ height: 18
+ width: versionText.implicitWidth + Theme.spacingS
+ radius: 9
+ color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.15)
+ anchors.verticalCenter: parent.verticalCenter
+
+ StyledText {
+ id: versionText
+ anchors.centerIn: parent
+ text: modelData.version || "1.0.0"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.outline
+ }
+ }
+
+ Rectangle {
+ height: 18
+ width: firstPartyText.implicitWidth + Theme.spacingS
+ radius: 9
+ color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.15)
+ border.color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.4)
+ border.width: 1
+ visible: isFirstParty
+ anchors.verticalCenter: parent.verticalCenter
+
+ StyledText {
+ id: firstPartyText
+ anchors.centerIn: parent
+ text: I18n.tr("official")
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.primary
+ font.weight: Font.Medium
+ }
+ }
+ }
+
+ StyledText {
+ text: I18n.tr("by %1", "author attribution").arg(modelData.author || I18n.tr("Unknown", "unknown author"))
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.outline
+ elide: Text.ElideRight
+ width: parent.width
+ }
+
+ StyledText {
+ text: modelData.description || ""
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ width: parent.width
+ wrapMode: Text.WordWrap
+ maximumLineCount: 3
+ elide: Text.ElideRight
+ visible: modelData.description && modelData.description.length > 0
+ }
+ }
+
+ Rectangle {
+ id: installButton
+ width: 90
+ height: 36
+ radius: Theme.cornerRadius
+ anchors.verticalCenter: parent.verticalCenter
+ color: isInstalled ? (uninstallMouseArea.containsMouse ? Theme.error : Theme.surfaceVariant) : Theme.primary
+ opacity: installMouseArea.containsMouse || uninstallMouseArea.containsMouse ? 0.9 : 1
+ border.width: isInstalled ? 1 : 0
+ border.color: isInstalled ? (uninstallMouseArea.containsMouse ? Theme.error : Theme.outline) : "transparent"
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ }
+ }
+
+ Row {
+ anchors.centerIn: parent
+ spacing: Theme.spacingXS
+
+ DankIcon {
+ name: isInstalled ? (uninstallMouseArea.containsMouse ? "delete" : "check") : "download"
+ size: 16
+ color: isInstalled ? (uninstallMouseArea.containsMouse ? "white" : Theme.surfaceText) : Theme.surface
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: {
+ if (!isInstalled)
+ return I18n.tr("Install", "install action button");
+ if (uninstallMouseArea.containsMouse)
+ return I18n.tr("Uninstall", "uninstall action button");
+ return I18n.tr("Installed", "installed status");
+ }
+ font.pixelSize: Theme.fontSizeMedium
+ font.weight: Font.Medium
+ color: isInstalled ? (uninstallMouseArea.containsMouse ? "white" : Theme.surfaceText) : Theme.surface
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ MouseArea {
+ id: installMouseArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ visible: !isInstalled
+ onClicked: root.installTheme(modelData.id, modelData.name, false)
+ }
+
+ MouseArea {
+ id: uninstallMouseArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ visible: isInstalled
+ onClicked: root.uninstallTheme(modelData.id, modelData.name)
+ }
+ }
+ }
+ }
+ }
+
+ StyledText {
+ anchors.centerIn: listArea
+ text: I18n.tr("No themes found", "empty theme list")
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceVariantText
+ visible: !root.isLoading && root.filteredThemes.length === 0
+ }
+ }
+ }
+ }
+}
diff --git a/quickshell/Modules/Settings/ThemeColorsTab.qml b/quickshell/Modules/Settings/ThemeColorsTab.qml
index 9a0495a1..af420fa2 100644
--- a/quickshell/Modules/Settings/ThemeColorsTab.qml
+++ b/quickshell/Modules/Settings/ThemeColorsTab.qml
@@ -12,9 +12,29 @@ Item {
property var cachedIconThemes: SettingsData.availableIconThemes
property var cachedMatugenSchemes: Theme.availableMatugenSchemes.map(option => option.label)
+ property var installedRegistryThemes: []
Component.onCompleted: {
SettingsData.detectAvailableIconThemes();
+ if (DMSService.dmsAvailable)
+ DMSService.listInstalledThemes();
+ if (PopoutService.pendingThemeInstall)
+ Qt.callLater(() => themeBrowser.show());
+ }
+
+ Connections {
+ target: DMSService
+ function onInstalledThemesReceived(themes) {
+ themeColorsTab.installedRegistryThemes = themes;
+ }
+ }
+
+ Connections {
+ target: PopoutService
+ function onPendingThemeInstallChanged() {
+ if (PopoutService.pendingThemeInstall)
+ themeBrowser.show();
+ }
}
DankFlickable {
@@ -41,12 +61,24 @@ Item {
spacing: Theme.spacingS
StyledText {
+ property string registryThemeName: {
+ if (Theme.currentThemeCategory !== "registry")
+ return "";
+ for (var i = 0; i < themeColorsTab.installedRegistryThemes.length; i++) {
+ var t = themeColorsTab.installedRegistryThemes[i];
+ if (SettingsData.customThemeFile && SettingsData.customThemeFile.endsWith((t.sourceDir || t.id) + "/theme.json"))
+ return t.name;
+ }
+ return "";
+ }
text: {
if (Theme.currentTheme === Theme.dynamic)
- return "Current Theme: Dynamic";
+ return I18n.tr("Current Theme: %1", "current theme label").arg(I18n.tr("Dynamic", "dynamic theme name"));
+ if (Theme.currentThemeCategory === "registry" && registryThemeName)
+ return I18n.tr("Current Theme: %1", "current theme label").arg(registryThemeName);
if (Theme.currentThemeCategory === "catppuccin")
- return "Current Theme: Catppuccin " + Theme.getThemeColors(Theme.currentThemeName).name;
- return "Current Theme: " + Theme.getThemeColors(Theme.currentThemeName).name;
+ return I18n.tr("Current Theme: %1", "current theme label").arg("Catppuccin " + Theme.getThemeColors(Theme.currentThemeName).name);
+ return I18n.tr("Current Theme: %1", "current theme label").arg(Theme.getThemeColors(Theme.currentThemeName).name);
}
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
@@ -57,12 +89,14 @@ Item {
StyledText {
text: {
if (Theme.currentTheme === Theme.dynamic)
- return "Material colors generated from wallpaper";
+ return I18n.tr("Material colors generated from wallpaper", "dynamic theme description");
+ if (Theme.currentThemeCategory === "registry")
+ return I18n.tr("Color theme from DMS registry", "registry theme description");
if (Theme.currentThemeCategory === "catppuccin")
- return "Soothing pastel theme based on Catppuccin";
+ return I18n.tr("Soothing pastel theme based on Catppuccin", "catppuccin theme description");
if (Theme.currentTheme === Theme.custom)
- return "Custom theme loaded from JSON file";
- return "Material Design inspired color themes";
+ return I18n.tr("Custom theme loaded from JSON file", "custom theme description");
+ return I18n.tr("Material Design inspired color themes", "generic theme description");
}
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
@@ -78,7 +112,11 @@ Item {
anchors.horizontalCenter: parent.horizontalCenter
DankButtonGroup {
+ id: themeCategoryGroup
+ property bool isRegistryTheme: Theme.currentThemeCategory === "registry"
property int currentThemeIndex: {
+ if (isRegistryTheme)
+ return 4;
if (Theme.currentTheme === Theme.dynamic)
return 2;
if (Theme.currentThemeName === "custom")
@@ -89,7 +127,7 @@ Item {
}
property int pendingThemeIndex: -1
- model: ["Generic", "Catppuccin", "Auto", "Custom"]
+ model: DMSService.dmsAvailable ? ["Generic", "Catppuccin", "Auto", "Custom", "Registry"] : ["Generic", "Catppuccin", "Auto", "Custom"]
currentIndex: currentThemeIndex
selectionMode: "single"
anchors.horizontalCenter: parent.horizontalCenter
@@ -110,15 +148,17 @@ Item {
break;
case 2:
if (ToastService.wallpaperErrorStatus === "matugen_missing")
- ToastService.showError("matugen not found - install matugen package for dynamic theming");
+ ToastService.showError(I18n.tr("matugen not found - install matugen package for dynamic theming", "matugen error"));
else if (ToastService.wallpaperErrorStatus === "error")
- ToastService.showError("Wallpaper processing failed - check wallpaper path");
+ ToastService.showError(I18n.tr("Wallpaper processing failed - check wallpaper path", "wallpaper error"));
else
- Theme.switchTheme(Theme.dynamic, true, true);
+ Theme.switchThemeCategory("dynamic", Theme.dynamic);
break;
case 3:
- if (Theme.currentThemeName !== "custom")
- Theme.switchTheme("custom", true, true);
+ Theme.switchThemeCategory("custom", "custom");
+ break;
+ case 4:
+ Theme.switchThemeCategory("registry", "");
break;
}
pendingThemeIndex = -1;
@@ -388,7 +428,7 @@ Item {
Column {
width: parent.width
spacing: Theme.spacingM
- visible: Theme.currentTheme === Theme.dynamic
+ visible: Theme.currentTheme === Theme.dynamic && Theme.currentThemeCategory !== "registry"
Row {
width: parent.width
@@ -450,12 +490,12 @@ Item {
StyledText {
text: {
if (ToastService.wallpaperErrorStatus === "error")
- return "Wallpaper Error";
+ return I18n.tr("Wallpaper Error", "wallpaper error status");
if (ToastService.wallpaperErrorStatus === "matugen_missing")
- return "Matugen Missing";
+ return I18n.tr("Matugen Missing", "matugen not found status");
if (Theme.wallpaperPath)
return Theme.wallpaperPath.split('/').pop();
- return "No wallpaper selected";
+ return I18n.tr("No wallpaper selected", "no wallpaper status");
}
font.pixelSize: Theme.fontSizeLarge
color: Theme.surfaceText
@@ -467,12 +507,12 @@ Item {
StyledText {
text: {
if (ToastService.wallpaperErrorStatus === "error")
- return "Wallpaper processing failed";
+ return I18n.tr("Wallpaper processing failed", "wallpaper processing error");
if (ToastService.wallpaperErrorStatus === "matugen_missing")
- return "Install matugen package for dynamic theming";
+ return I18n.tr("Install matugen package for dynamic theming", "matugen installation hint");
if (Theme.wallpaperPath)
return Theme.wallpaperPath;
- return "Dynamic colors from wallpaper";
+ return I18n.tr("Dynamic colors from wallpaper", "dynamic colors description");
}
font.pixelSize: Theme.fontSizeSmall
color: (ToastService.wallpaperErrorStatus === "error" || ToastService.wallpaperErrorStatus === "matugen_missing") ? Theme.error : Theme.surfaceVariantText
@@ -520,7 +560,7 @@ Item {
Column {
width: parent.width
spacing: Theme.spacingM
- visible: Theme.currentThemeName === "custom"
+ visible: Theme.currentThemeName === "custom" && Theme.currentThemeCategory !== "registry"
Row {
width: parent.width
@@ -541,7 +581,7 @@ Item {
anchors.verticalCenter: parent.verticalCenter
StyledText {
- text: SettingsData.customThemeFile ? SettingsData.customThemeFile.split('/').pop() : "No custom theme file"
+ text: SettingsData.customThemeFile ? SettingsData.customThemeFile.split('/').pop() : I18n.tr("No custom theme file", "no custom theme file status")
font.pixelSize: Theme.fontSizeLarge
color: Theme.surfaceText
elide: Text.ElideMiddle
@@ -550,7 +590,7 @@ Item {
}
StyledText {
- text: SettingsData.customThemeFile || "Click to select a custom theme JSON file"
+ text: SettingsData.customThemeFile || I18n.tr("Click to select a custom theme JSON file", "custom theme file hint")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
elide: Text.ElideMiddle
@@ -560,6 +600,174 @@ Item {
}
}
}
+
+ Column {
+ id: registrySection
+ width: parent.width
+ spacing: Theme.spacingM
+ visible: Theme.currentThemeCategory === "registry"
+
+ Grid {
+ columns: 3
+ spacing: Theme.spacingS
+ anchors.horizontalCenter: parent.horizontalCenter
+ visible: themeColorsTab.installedRegistryThemes.length > 0
+
+ Repeater {
+ model: themeColorsTab.installedRegistryThemes
+
+ Rectangle {
+ id: themeCard
+ property bool isActive: Theme.currentThemeCategory === "registry" && Theme.currentThemeName === "custom" && SettingsData.customThemeFile && SettingsData.customThemeFile.endsWith((modelData.sourceDir || modelData.id) + "/theme.json")
+ property string previewPath: Quickshell.env("HOME") + "/.config/DankMaterialShell/themes/" + (modelData.sourceDir || modelData.id) + "/preview-" + (Theme.isLightMode ? "light" : "dark") + ".svg"
+ width: 140
+ height: 100
+ radius: Theme.cornerRadius
+ color: Theme.surfaceVariant
+ border.color: isActive ? Theme.primary : Theme.outline
+ border.width: isActive ? 2 : 1
+ scale: isActive ? 1.03 : 1
+
+ Behavior on scale {
+ NumberAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.emphasizedEasing
+ }
+ }
+
+ Image {
+ id: previewImage
+ anchors.fill: parent
+ anchors.margins: 2
+ source: "file://" + themeCard.previewPath
+ fillMode: Image.PreserveAspectFit
+ smooth: true
+ mipmap: true
+ }
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "palette"
+ size: 32
+ color: Theme.primary
+ visible: previewImage.status === Image.Error || previewImage.status === Image.Null
+ }
+
+ Rectangle {
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ height: 24
+ radius: Theme.cornerRadius
+ color: Qt.rgba(0, 0, 0, 0.6)
+
+ StyledText {
+ anchors.centerIn: parent
+ text: modelData.name
+ font.pixelSize: Theme.fontSizeSmall
+ color: "white"
+ font.weight: Font.Medium
+ elide: Text.ElideRight
+ width: parent.width - Theme.spacingS * 2
+ horizontalAlignment: Text.AlignHCenter
+ }
+ }
+
+ Rectangle {
+ anchors.top: parent.top
+ anchors.right: parent.right
+ anchors.margins: 4
+ width: 20
+ height: 20
+ radius: 10
+ color: Theme.primary
+ visible: themeCard.isActive
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "check"
+ size: 14
+ color: Theme.surface
+ }
+ }
+
+ MouseArea {
+ id: cardMouseArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ const themesDir = Quickshell.env("HOME") + "/.config/DankMaterialShell/themes";
+ const themePath = themesDir + "/" + (modelData.sourceDir || modelData.id) + "/theme.json";
+ SettingsData.set("customThemeFile", themePath);
+ Theme.switchTheme("custom", true, true);
+ }
+ }
+
+ Rectangle {
+ id: deleteButton
+ anchors.top: parent.top
+ anchors.left: parent.left
+ anchors.margins: 4
+ width: 24
+ height: 24
+ radius: 12
+ color: deleteMouseArea.containsMouse ? Theme.error : Qt.rgba(0, 0, 0, 0.6)
+ opacity: cardMouseArea.containsMouse || deleteMouseArea.containsMouse ? 1 : 0
+ visible: opacity > 0
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Theme.shortDuration
+ }
+ }
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "close"
+ size: 14
+ color: "white"
+ }
+
+ MouseArea {
+ id: deleteMouseArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ ToastService.showInfo(I18n.tr("Uninstalling: %1", "uninstallation progress").arg(modelData.name));
+ DMSService.uninstallTheme(modelData.id, response => {
+ if (response.error) {
+ ToastService.showError(I18n.tr("Uninstall failed: %1", "uninstallation error").arg(response.error));
+ return;
+ }
+ ToastService.showInfo(I18n.tr("Uninstalled: %1", "uninstallation success").arg(modelData.name));
+ DMSService.listInstalledThemes();
+ });
+ }
+ }
+ }
+ }
+ }
+ }
+
+ StyledText {
+ text: I18n.tr("No themes installed. Browse themes to install from the registry.", "no registry themes installed hint")
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ wrapMode: Text.WordWrap
+ width: parent.width
+ visible: themeColorsTab.installedRegistryThemes.length === 0
+ horizontalAlignment: Text.AlignHCenter
+ }
+
+ DankButton {
+ text: I18n.tr("Browse Themes", "browse themes button")
+ iconName: "store"
+ anchors.horizontalCenter: parent.horizontalCenter
+ onClicked: themeBrowser.show()
+ }
+ }
}
}
@@ -1086,4 +1294,8 @@ Item {
}
}
}
+
+ ThemeBrowser {
+ id: themeBrowser
+ }
}
diff --git a/quickshell/Services/DMSService.qml b/quickshell/Services/DMSService.qml
index a4d026e5..7635ae20 100644
--- a/quickshell/Services/DMSService.qml
+++ b/quickshell/Services/DMSService.qml
@@ -16,6 +16,8 @@ Singleton {
readonly property int expectedApiVersion: 1
property var availablePlugins: []
property var installedPlugins: []
+ property var availableThemes: []
+ property var installedThemes: []
property bool isConnected: false
property bool isConnecting: false
property bool subscribeConnected: false
@@ -33,6 +35,9 @@ Singleton {
signal pluginsListReceived(var plugins)
signal installedPluginsReceived(var plugins)
signal searchResultsReceived(var plugins)
+ signal themesListReceived(var themes)
+ signal installedThemesReceived(var themes)
+ signal themeSearchResultsReceived(var themes)
signal operationSuccess(string message)
signal operationError(string error)
signal connectionStateChanged
@@ -514,6 +519,82 @@ Singleton {
});
}
+ function listThemes(callback) {
+ sendRequest("themes.list", null, response => {
+ if (response.result) {
+ availableThemes = response.result;
+ themesListReceived(response.result);
+ }
+ if (callback) {
+ callback(response);
+ }
+ });
+ }
+
+ function listInstalledThemes(callback) {
+ sendRequest("themes.listInstalled", null, response => {
+ if (response.result) {
+ installedThemes = response.result;
+ installedThemesReceived(response.result);
+ }
+ if (callback) {
+ callback(response);
+ }
+ });
+ }
+
+ function searchThemes(query, callback) {
+ sendRequest("themes.search", {
+ "query": query
+ }, response => {
+ if (response.result) {
+ themeSearchResultsReceived(response.result);
+ }
+ if (callback) {
+ callback(response);
+ }
+ });
+ }
+
+ function installTheme(themeName, callback) {
+ sendRequest("themes.install", {
+ "name": themeName
+ }, response => {
+ if (callback) {
+ callback(response);
+ }
+ if (!response.error) {
+ listInstalledThemes();
+ }
+ });
+ }
+
+ function uninstallTheme(themeName, callback) {
+ sendRequest("themes.uninstall", {
+ "name": themeName
+ }, response => {
+ if (callback) {
+ callback(response);
+ }
+ if (!response.error) {
+ listInstalledThemes();
+ }
+ });
+ }
+
+ function updateTheme(themeName, callback) {
+ sendRequest("themes.update", {
+ "name": themeName
+ }, response => {
+ if (callback) {
+ callback(response);
+ }
+ if (!response.error) {
+ listInstalledThemes();
+ }
+ });
+ }
+
function lockSession(callback) {
sendRequest("loginctl.lock", null, callback);
}
diff --git a/quickshell/Services/PopoutService.qml b/quickshell/Services/PopoutService.qml
index 07fe233b..9df3d2da 100644
--- a/quickshell/Services/PopoutService.qml
+++ b/quickshell/Services/PopoutService.qml
@@ -32,6 +32,9 @@ Singleton {
property var notepadSlideouts: []
+ property string pendingThemeInstall: ""
+ property string pendingPluginInstall: ""
+
function setPosition(popout, x, y, width, section, screen) {
if (popout && popout.setTriggerPosition && arguments.length >= 6) {
popout.setTriggerPosition(x, y, width, section, screen);
diff --git a/quickshell/matugen/templates/kitty-tabs.conf b/quickshell/matugen/templates/kitty-tabs.conf
index fee54b5a..78b708a8 100644
--- a/quickshell/matugen/templates/kitty-tabs.conf
+++ b/quickshell/matugen/templates/kitty-tabs.conf
@@ -9,8 +9,8 @@ tab_bar_margin_color {{colors.background.default.hex}}
tab_bar_background {{colors.background.default.hex}}
-active_tab_foreground {{colors.on_primary_container.default.hex}}
-active_tab_background {{colors.primary_container.default.hex}}
+active_tab_foreground {{colors.on_primary.default.hex}}
+active_tab_background {{colors.primary.default.hex}}
active_tab_font_style bold
inactive_tab_foreground {{colors.on_surface_variant.default.hex}}
diff --git a/quickshell/matugen/templates/vscode-color-theme-dark.json b/quickshell/matugen/templates/vscode-color-theme-dark.json
index 3ec3031f..2706bb14 100644
--- a/quickshell/matugen/templates/vscode-color-theme-dark.json
+++ b/quickshell/matugen/templates/vscode-color-theme-dark.json
@@ -58,8 +58,8 @@
//
// Lists (files, search results, etc.)
//
- "list.activeSelectionBackground": "{{colors.primary_container.dark.hex}}",
- "list.activeSelectionForeground": "{{colors.on_primary_container.dark.hex}}",
+ "list.activeSelectionBackground": "{{colors.primary.dark.hex}}",
+ "list.activeSelectionForeground": "{{colors.on_primary.dark.hex}}",
"list.inactiveSelectionBackground": "{{colors.surface_container.dark.hex}}",
"list.hoverBackground": "{{colors.surface_container.dark.hex}}",
"list.hoverForeground": "{{colors.on_surface.dark.hex}}",
diff --git a/quickshell/matugen/templates/vscode-color-theme-default.json b/quickshell/matugen/templates/vscode-color-theme-default.json
index 11105559..75bcb5ad 100644
--- a/quickshell/matugen/templates/vscode-color-theme-default.json
+++ b/quickshell/matugen/templates/vscode-color-theme-default.json
@@ -40,8 +40,8 @@
"tab.activeForeground": "{{colors.on_surface.default.hex}}",
"tab.inactiveForeground": "{{colors.outline.default.hex}}",
"tab.activeBorderTop": "{{colors.primary.default.hex}}",
- "list.activeSelectionBackground": "{{colors.primary_container.default.hex}}",
- "list.activeSelectionForeground": "{{colors.on_primary_container.default.hex}}",
+ "list.activeSelectionBackground": "{{colors.primary.default.hex}}",
+ "list.activeSelectionForeground": "{{colors.on_primary.default.hex}}",
"list.inactiveSelectionBackground": "{{colors.surface_container.default.hex}}",
"list.hoverBackground": "{{colors.surface_container.default.hex}}",
"list.hoverForeground": "{{colors.on_surface.default.hex}}",
diff --git a/quickshell/matugen/templates/vscode-color-theme-light.json b/quickshell/matugen/templates/vscode-color-theme-light.json
index 483b468a..7166cda2 100644
--- a/quickshell/matugen/templates/vscode-color-theme-light.json
+++ b/quickshell/matugen/templates/vscode-color-theme-light.json
@@ -69,8 +69,8 @@
"sideBarTitle.foreground": "{{colors.on_surface.light.hex}}",
"sideBarSectionHeader.background": "{{colors.surface_container_low.light.hex}}",
"sideBarSectionHeader.foreground": "{{colors.on_surface.light.hex}}",
- "list.activeSelectionBackground": "{{colors.primary_container.light.hex}}",
- "list.activeSelectionForeground": "{{colors.on_primary_container.light.hex}}",
+ "list.activeSelectionBackground": "{{colors.primary.light.hex}}",
+ "list.activeSelectionForeground": "{{colors.on_primary.light.hex}}",
"list.inactiveSelectionBackground": "{{colors.surface_container.light.hex}}",
"list.inactiveSelectionForeground": "{{colors.on_surface.light.hex}}",
"list.hoverBackground": "{{colors.surface_container.light.hex}}",