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}}",