1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-01-24 13:32:50 -05:00

Compare commits

...

8 Commits

Author SHA1 Message Date
purian23
15dc91f779 dock: Fix dock launcher button persistence 2026-01-22 18:15:00 -05:00
bbedward
dd3d2908a2 prek format 2026-01-22 17:57:12 -05:00
bbedward
0857023dba core: ipc fill in help, remove management tui 2026-01-22 17:51:38 -05:00
purian23
1edc8f468e feat: Pinnable DMS coreApps w/Color options 2026-01-22 17:45:38 -05:00
purian23
2681fe87bb feat: Implement Dank Launcher button on the Dock
- Configurable with custom icons/logos
- Respects light/dark theme
- Drag & Drop in place
2026-01-22 16:52:38 -05:00
bbedward
3f0d0f4d95 launcher v2: remove dupe launch on dGPU 2026-01-22 14:52:36 -05:00
bbedward
f24ecf1b99 weather: m/s wind units and feels like
fixes #1463
fixes #1456
2026-01-22 14:44:40 -05:00
bbedward
acdd1d2ec4 settings: fix theme flavor buttons 2026-01-22 13:58:44 -05:00
31 changed files with 980 additions and 2959 deletions

View File

@@ -64,9 +64,8 @@ var killCmd = &cobra.Command{
}
var ipcCmd = &cobra.Command{
Use: "ipc",
Use: "ipc [target] [function] [args...]",
Short: "Send IPC commands to running DMS shell",
Long: "Send IPC commands to running DMS shell (qs -c dms ipc <args>)",
PreRunE: findConfig,
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
_ = findConfig(cmd, args)
@@ -77,6 +76,13 @@ var ipcCmd = &cobra.Command{
},
}
func init() {
ipcCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
_ = findConfig(cmd, args)
printIPCHelp()
})
}
var debugSrvCmd = &cobra.Command{
Use: "debug-srv",
Short: "Start the debug server",

View File

@@ -7,9 +7,7 @@ import (
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/dms"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
tea "github.com/charmbracelet/bubbletea"
"github.com/spf13/cobra"
)
@@ -20,11 +18,9 @@ var rootCmd = &cobra.Command{
Use: "dms",
Short: "dms CLI",
Long: "dms is the DankMaterialShell management CLI and backend server.",
Run: runInteractiveMode,
}
func init() {
// Add the -c flag
rootCmd.PersistentFlags().StringVarP(&customConfigPath, "config", "c", "", "Specify a custom path to the DMS config directory")
}
@@ -38,7 +34,7 @@ func findConfig(cmd *cobra.Command, args []string) error {
if statErr == nil && !info.IsDir() {
configPath = customConfigPath
log.Debug("Using config from: %s", configPath)
return nil // <-- Guard statement
return nil
}
if statErr != nil {
@@ -76,18 +72,3 @@ func findConfig(cmd *cobra.Command, args []string) error {
log.Debug("Using config from: %s", configPath)
return nil
}
func runInteractiveMode(cmd *cobra.Command, args []string) {
detector, _ := dms.NewDetector()
if !detector.IsDMSInstalled() {
log.Error("DankMaterialShell (DMS) is not detected as installed on this system.")
log.Info("Please install DMS using dankinstall before using this management interface.")
os.Exit(1)
}
model := dms.NewModel(Version)
p := tea.NewProgram(model, tea.WithAltScreen())
if _, err := p.Run(); err != nil {
log.Fatalf("Error running program: %v", err)
}
}

View File

@@ -618,9 +618,8 @@ func getShellIPCCompletions(args []string, _ string) []string {
func runShellIPCCommand(args []string) {
if len(args) == 0 {
log.Error("IPC command requires arguments")
log.Info("Usage: dms ipc <command> [args...]")
os.Exit(1)
printIPCHelp()
return
}
if args[0] != "call" {
@@ -642,3 +641,45 @@ func runShellIPCCommand(args []string) {
log.Fatalf("Error running IPC command: %v", err)
}
}
func printIPCHelp() {
fmt.Println("Usage: dms ipc <target> <function> [args...]")
fmt.Println()
cmdArgs := []string{"ipc"}
if qsHasAnyDisplay() {
cmdArgs = append(cmdArgs, "--any-display")
}
cmdArgs = append(cmdArgs, "-p", configPath, "show")
cmd := exec.Command("qs", cmdArgs...)
output, err := cmd.Output()
if err != nil {
fmt.Println("Could not retrieve available IPC targets (is DMS running?)")
return
}
targets := parseTargetsFromIPCShowOutput(string(output))
if len(targets) == 0 {
fmt.Println("No IPC targets available")
return
}
fmt.Println("Targets:")
targetNames := make([]string, 0, len(targets))
for name := range targets {
targetNames = append(targetNames, name)
}
slices.Sort(targetNames)
for _, targetName := range targetNames {
funcs := targets[targetName]
funcNames := make([]string, 0, len(funcs))
for fn := range funcs {
funcNames = append(funcNames, fn)
}
slices.Sort(funcNames)
fmt.Printf(" %-16s %s\n", targetName, strings.Join(funcNames, ", "))
}
}

View File

@@ -1,450 +0,0 @@
//go:build !distro_binary
package dms
import (
"os/exec"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
tea "github.com/charmbracelet/bubbletea"
)
type AppState int
const (
StateMainMenu AppState = iota
StateUpdate
StateUpdatePassword
StateUpdateProgress
StateShell
StatePluginsMenu
StatePluginsBrowse
StatePluginDetail
StatePluginSearch
StatePluginsInstalled
StatePluginInstalledDetail
StateGreeterMenu
StateGreeterCompositorSelect
StateGreeterPassword
StateGreeterInstalling
StateAbout
)
type Model struct {
version string
detector *Detector
dependencies []DependencyInfo
state AppState
selectedItem int
width int
height int
// Menu items
menuItems []MenuItem
updateDeps []DependencyInfo
selectedUpdateDep int
updateToggles map[string]bool
updateProgressChan chan updateProgressMsg
updateProgress updateProgressMsg
updateLogs []string
sudoPassword string
passwordInput string
passwordError string
// Window manager states
hyprlandInstalled bool
niriInstalled bool
selectedGreeterItem int
greeterInstallChan chan greeterProgressMsg
greeterProgress greeterProgressMsg
greeterLogs []string
greeterPasswordInput string
greeterPasswordError string
greeterSudoPassword string
greeterCompositors []string
greeterSelectedComp int
greeterChosenCompositor string
pluginsMenuItems []MenuItem
selectedPluginsMenuItem int
pluginsList []pluginInfo
filteredPluginsList []pluginInfo
selectedPluginIndex int
pluginsLoading bool
pluginsError string
pluginSearchQuery string
installedPluginsList []pluginInfo
selectedInstalledIndex int
installedPluginsLoading bool
installedPluginsError string
pluginInstallStatus map[string]bool
}
type pluginInfo struct {
ID string
Name string
Category string
Author string
Description string
Repo string
Path string
Capabilities []string
Compositors []string
Dependencies []string
FirstParty bool
}
type MenuItem struct {
Label string
Action AppState
}
func NewModel(version string) Model {
detector, _ := NewDetector()
var dependencies []DependencyInfo
var hyprlandInstalled, niriInstalled bool
var err error
if detector != nil {
dependencies = detector.GetInstalledComponents()
// Use the proper detection method for both window managers
hyprlandInstalled, niriInstalled, err = detector.GetWindowManagerStatus()
if err != nil {
// Fallback to false if detection fails
hyprlandInstalled = false
niriInstalled = false
}
}
updateToggles := make(map[string]bool)
for _, dep := range dependencies {
if dep.Name == "dms (DankMaterialShell)" && dep.Status == deps.StatusNeedsUpdate {
updateToggles[dep.Name] = true
break
}
}
m := Model{
version: version,
detector: detector,
dependencies: dependencies,
state: StateMainMenu,
selectedItem: 0,
updateToggles: updateToggles,
updateDeps: dependencies,
updateProgressChan: make(chan updateProgressMsg, 100),
hyprlandInstalled: hyprlandInstalled,
niriInstalled: niriInstalled,
greeterInstallChan: make(chan greeterProgressMsg, 100),
pluginInstallStatus: make(map[string]bool),
}
m.menuItems = m.buildMenuItems()
return m
}
func (m *Model) buildMenuItems() []MenuItem {
items := []MenuItem{
{Label: "Update", Action: StateUpdate},
}
// Shell management
if m.isShellRunning() {
items = append(items, MenuItem{Label: "Terminate Shell", Action: StateShell})
} else {
items = append(items, MenuItem{Label: "Start Shell (Daemon)", Action: StateShell})
}
// Plugins management
items = append(items, MenuItem{Label: "Plugins", Action: StatePluginsMenu})
// Greeter management
items = append(items, MenuItem{Label: "Greeter", Action: StateGreeterMenu})
items = append(items, MenuItem{Label: "About", Action: StateAbout})
return items
}
func (m *Model) buildPluginsMenuItems() []MenuItem {
return []MenuItem{
{Label: "Browse Plugins", Action: StatePluginsBrowse},
{Label: "View Installed", Action: StatePluginsInstalled},
}
}
func (m *Model) isShellRunning() bool {
// Check for both -c and -p flag patterns since quickshell can be started either way
// -c dms: config name mode
// -p <path>/dms: path mode (used when installed via system packages)
cmd := exec.Command("pgrep", "-f", "qs.*dms")
err := cmd.Run()
return err == nil
}
func (m Model) Init() tea.Cmd {
return nil
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
case shellStartedMsg:
m.menuItems = m.buildMenuItems()
if m.selectedItem >= len(m.menuItems) {
m.selectedItem = len(m.menuItems) - 1
}
return m, nil
case updateProgressMsg:
m.updateProgress = msg
if msg.logOutput != "" {
m.updateLogs = append(m.updateLogs, msg.logOutput)
}
return m, m.waitForProgress()
case updateCompleteMsg:
m.updateProgress.complete = true
m.updateProgress.err = msg.err
m.dependencies = m.detector.GetInstalledComponents()
m.updateDeps = m.dependencies
m.menuItems = m.buildMenuItems()
// Restart shell if update was successful and shell is running
if msg.err == nil && m.isShellRunning() {
restartShell()
}
return m, nil
case greeterProgressMsg:
m.greeterProgress = msg
if msg.logOutput != "" {
m.greeterLogs = append(m.greeterLogs, msg.logOutput)
}
return m, m.waitForGreeterProgress()
case pluginsLoadedMsg:
m.pluginsLoading = false
if msg.err != nil {
m.pluginsError = msg.err.Error()
} else {
m.pluginsList = make([]pluginInfo, len(msg.plugins))
for i, p := range msg.plugins {
m.pluginsList[i] = pluginInfo{
ID: p.ID,
Name: p.Name,
Category: p.Category,
Author: p.Author,
Description: p.Description,
Repo: p.Repo,
Path: p.Path,
Capabilities: p.Capabilities,
Compositors: p.Compositors,
Dependencies: p.Dependencies,
FirstParty: strings.HasPrefix(p.Repo, "https://github.com/AvengeMedia"),
}
}
m.filteredPluginsList = m.pluginsList
m.selectedPluginIndex = 0
m.updatePluginInstallStatus()
}
return m, nil
case installedPluginsLoadedMsg:
m.installedPluginsLoading = false
if msg.err != nil {
m.installedPluginsError = msg.err.Error()
} else {
m.installedPluginsList = make([]pluginInfo, len(msg.plugins))
for i, p := range msg.plugins {
m.installedPluginsList[i] = pluginInfo{
ID: p.ID,
Name: p.Name,
Category: p.Category,
Author: p.Author,
Description: p.Description,
Repo: p.Repo,
Path: p.Path,
Capabilities: p.Capabilities,
Compositors: p.Compositors,
Dependencies: p.Dependencies,
FirstParty: strings.HasPrefix(p.Repo, "https://github.com/AvengeMedia"),
}
}
m.selectedInstalledIndex = 0
}
return m, nil
case pluginUninstalledMsg:
if msg.err != nil {
m.installedPluginsError = msg.err.Error()
m.state = StatePluginInstalledDetail
} else {
m.state = StatePluginsInstalled
m.installedPluginsLoading = true
m.installedPluginsError = ""
return m, loadInstalledPlugins
}
return m, nil
case pluginUpdatedMsg:
if msg.err != nil {
m.installedPluginsError = msg.err.Error()
} else {
m.installedPluginsError = ""
}
return m, nil
case pluginInstalledMsg:
if msg.err != nil {
m.pluginsError = msg.err.Error()
} else {
m.pluginInstallStatus[msg.pluginName] = true
m.pluginsError = ""
}
return m, nil
case greeterPasswordValidMsg:
if msg.valid {
m.greeterSudoPassword = msg.password
m.greeterPasswordInput = ""
m.greeterPasswordError = ""
m.state = StateGreeterInstalling
m.greeterProgress = greeterProgressMsg{step: "Starting greeter installation..."}
m.greeterLogs = []string{}
return m, tea.Batch(m.performGreeterInstall(), m.waitForGreeterProgress())
} else {
m.greeterPasswordError = "Incorrect password. Please try again."
m.greeterPasswordInput = ""
}
return m, nil
case passwordValidMsg:
if msg.valid {
m.sudoPassword = msg.password
m.passwordInput = ""
m.passwordError = ""
m.state = StateUpdateProgress
m.updateProgress = updateProgressMsg{progress: 0.0, step: "Starting update..."}
m.updateLogs = []string{}
return m, tea.Batch(m.performUpdate(), m.waitForProgress())
} else {
m.passwordError = "Incorrect password. Please try again."
m.passwordInput = ""
}
return m, nil
case tea.KeyMsg:
switch m.state {
case StateMainMenu:
return m.updateMainMenu(msg)
case StateUpdate:
return m.updateUpdateView(msg)
case StateUpdatePassword:
return m.updatePasswordView(msg)
case StateUpdateProgress:
return m.updateProgressView(msg)
case StateShell:
return m.updateShellView(msg)
case StatePluginsMenu:
return m.updatePluginsMenu(msg)
case StatePluginsBrowse:
return m.updatePluginsBrowse(msg)
case StatePluginDetail:
return m.updatePluginDetail(msg)
case StatePluginSearch:
return m.updatePluginSearch(msg)
case StatePluginsInstalled:
return m.updatePluginsInstalled(msg)
case StatePluginInstalledDetail:
return m.updatePluginInstalledDetail(msg)
case StateGreeterMenu:
return m.updateGreeterMenu(msg)
case StateGreeterCompositorSelect:
return m.updateGreeterCompositorSelect(msg)
case StateGreeterPassword:
return m.updateGreeterPasswordView(msg)
case StateGreeterInstalling:
return m.updateGreeterInstalling(msg)
case StateAbout:
return m.updateAboutView(msg)
}
}
return m, nil
}
type updateProgressMsg struct {
progress float64
step string
complete bool
err error
logOutput string
}
type updateCompleteMsg struct {
err error
}
type passwordValidMsg struct {
password string
valid bool
}
type greeterProgressMsg struct {
step string
complete bool
err error
logOutput string
}
type greeterPasswordValidMsg struct {
password string
valid bool
}
func (m Model) waitForProgress() tea.Cmd {
return func() tea.Msg {
return <-m.updateProgressChan
}
}
func (m Model) waitForGreeterProgress() tea.Cmd {
return func() tea.Msg {
return <-m.greeterInstallChan
}
}
func (m Model) View() string {
switch m.state {
case StateMainMenu:
return m.renderMainMenu()
case StateUpdate:
return m.renderUpdateView()
case StateUpdatePassword:
return m.renderPasswordView()
case StateUpdateProgress:
return m.renderProgressView()
case StateShell:
return m.renderShellView()
case StatePluginsMenu:
return m.renderPluginsMenu()
case StatePluginsBrowse:
return m.renderPluginsBrowse()
case StatePluginDetail:
return m.renderPluginDetail()
case StatePluginSearch:
return m.renderPluginSearch()
case StatePluginsInstalled:
return m.renderPluginsInstalled()
case StatePluginInstalledDetail:
return m.renderPluginInstalledDetail()
case StateGreeterMenu:
return m.renderGreeterMenu()
case StateGreeterCompositorSelect:
return m.renderGreeterCompositorSelect()
case StateGreeterPassword:
return m.renderGreeterPasswordView()
case StateGreeterInstalling:
return m.renderGreeterInstalling()
case StateAbout:
return m.renderAboutView()
default:
return m.renderMainMenu()
}
}

View File

@@ -1,267 +0,0 @@
//go:build distro_binary
package dms
import (
"os/exec"
"strings"
tea "github.com/charmbracelet/bubbletea"
)
type AppState int
const (
StateMainMenu AppState = iota
StateShell
StatePluginsMenu
StatePluginsBrowse
StatePluginDetail
StatePluginSearch
StatePluginsInstalled
StatePluginInstalledDetail
StateAbout
)
type Model struct {
version string
detector *Detector
dependencies []DependencyInfo
state AppState
selectedItem int
width int
height int
// Menu items
menuItems []MenuItem
// Window manager states
hyprlandInstalled bool
niriInstalled bool
pluginsMenuItems []MenuItem
selectedPluginsMenuItem int
pluginsList []pluginInfo
filteredPluginsList []pluginInfo
selectedPluginIndex int
pluginsLoading bool
pluginsError string
pluginSearchQuery string
installedPluginsList []pluginInfo
selectedInstalledIndex int
installedPluginsLoading bool
installedPluginsError string
pluginInstallStatus map[string]bool
}
type pluginInfo struct {
ID string
Name string
Category string
Author string
Description string
Repo string
Path string
Capabilities []string
Compositors []string
Dependencies []string
FirstParty bool
}
type MenuItem struct {
Label string
Action AppState
}
func NewModel(version string) Model {
detector, _ := NewDetector()
var dependencies []DependencyInfo
var hyprlandInstalled, niriInstalled bool
if detector != nil {
dependencies = detector.GetInstalledComponents()
hyprlandInstalled, niriInstalled, _ = detector.GetWindowManagerStatus()
}
m := Model{
version: version,
detector: detector,
dependencies: dependencies,
state: StateMainMenu,
selectedItem: 0,
hyprlandInstalled: hyprlandInstalled,
niriInstalled: niriInstalled,
pluginInstallStatus: make(map[string]bool),
}
m.menuItems = m.buildMenuItems()
return m
}
func (m *Model) buildMenuItems() []MenuItem {
items := []MenuItem{}
// Shell management
if m.isShellRunning() {
items = append(items, MenuItem{Label: "Terminate Shell", Action: StateShell})
} else {
items = append(items, MenuItem{Label: "Start Shell (Daemon)", Action: StateShell})
}
// Plugins management
items = append(items, MenuItem{Label: "Plugins", Action: StatePluginsMenu})
items = append(items, MenuItem{Label: "About", Action: StateAbout})
return items
}
func (m *Model) buildPluginsMenuItems() []MenuItem {
return []MenuItem{
{Label: "Browse Plugins", Action: StatePluginsBrowse},
{Label: "View Installed", Action: StatePluginsInstalled},
}
}
func (m *Model) isShellRunning() bool {
cmd := exec.Command("pgrep", "-f", "qs -c dms")
err := cmd.Run()
return err == nil
}
func (m Model) Init() tea.Cmd {
return nil
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
case pluginsLoadedMsg:
m.pluginsLoading = false
if msg.err != nil {
m.pluginsError = msg.err.Error()
} else {
m.pluginsList = make([]pluginInfo, len(msg.plugins))
for i, p := range msg.plugins {
m.pluginsList[i] = pluginInfo{
ID: p.ID,
Name: p.Name,
Category: p.Category,
Author: p.Author,
Description: p.Description,
Repo: p.Repo,
Path: p.Path,
Capabilities: p.Capabilities,
Compositors: p.Compositors,
Dependencies: p.Dependencies,
FirstParty: strings.HasPrefix(p.Repo, "https://github.com/AvengeMedia"),
}
}
m.filteredPluginsList = m.pluginsList
m.selectedPluginIndex = 0
m.updatePluginInstallStatus()
}
return m, nil
case installedPluginsLoadedMsg:
m.installedPluginsLoading = false
if msg.err != nil {
m.installedPluginsError = msg.err.Error()
} else {
m.installedPluginsList = make([]pluginInfo, len(msg.plugins))
for i, p := range msg.plugins {
m.installedPluginsList[i] = pluginInfo{
ID: p.ID,
Name: p.Name,
Category: p.Category,
Author: p.Author,
Description: p.Description,
Repo: p.Repo,
Path: p.Path,
Capabilities: p.Capabilities,
Compositors: p.Compositors,
Dependencies: p.Dependencies,
FirstParty: strings.HasPrefix(p.Repo, "https://github.com/AvengeMedia"),
}
}
m.selectedInstalledIndex = 0
}
return m, nil
case pluginUninstalledMsg:
if msg.err != nil {
m.installedPluginsError = msg.err.Error()
m.state = StatePluginInstalledDetail
} else {
m.state = StatePluginsInstalled
m.installedPluginsLoading = true
m.installedPluginsError = ""
return m, loadInstalledPlugins
}
return m, nil
case pluginUpdatedMsg:
if msg.err != nil {
m.installedPluginsError = msg.err.Error()
} else {
m.installedPluginsError = ""
}
return m, nil
case pluginInstalledMsg:
if msg.err != nil {
m.pluginsError = msg.err.Error()
} else {
m.pluginInstallStatus[msg.pluginName] = true
m.pluginsError = ""
}
return m, nil
case tea.KeyMsg:
switch m.state {
case StateMainMenu:
return m.updateMainMenu(msg)
case StateShell:
return m.updateShellView(msg)
case StatePluginsMenu:
return m.updatePluginsMenu(msg)
case StatePluginsBrowse:
return m.updatePluginsBrowse(msg)
case StatePluginDetail:
return m.updatePluginDetail(msg)
case StatePluginSearch:
return m.updatePluginSearch(msg)
case StatePluginsInstalled:
return m.updatePluginsInstalled(msg)
case StatePluginInstalledDetail:
return m.updatePluginInstalledDetail(msg)
case StateAbout:
return m.updateAboutView(msg)
}
}
return m, nil
}
func (m Model) View() string {
switch m.state {
case StateMainMenu:
return m.renderMainMenu()
case StateShell:
return m.renderShellView()
case StatePluginsMenu:
return m.renderPluginsMenu()
case StatePluginsBrowse:
return m.renderPluginsBrowse()
case StatePluginDetail:
return m.renderPluginDetail()
case StatePluginSearch:
return m.renderPluginSearch()
case StatePluginsInstalled:
return m.renderPluginsInstalled()
case StatePluginInstalledDetail:
return m.renderPluginInstalledDetail()
case StateAbout:
return m.renderAboutView()
default:
return m.renderMainMenu()
}
}

View File

@@ -1,143 +0,0 @@
package dms
import (
"context"
"os"
"os/exec"
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
)
type Detector struct {
homeDir string
distribution distros.Distribution
}
func (d *Detector) GetDistribution() distros.Distribution {
return d.distribution
}
func NewDetector() (*Detector, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, err
}
logChan := make(chan string, 100)
go func() {
for range logChan {
}
}()
osInfo, err := distros.GetOSInfo()
if err != nil {
return nil, err
}
dist, err := distros.NewDistribution(osInfo.Distribution.ID, logChan)
if err != nil {
return nil, err
}
return &Detector{
homeDir: homeDir,
distribution: dist,
}, nil
}
func (d *Detector) IsDMSInstalled() bool {
_, err := config.LocateDMSConfig()
return err == nil
}
func (d *Detector) GetDependencyStatus() ([]deps.Dependency, error) {
hyprlandDeps, err := d.distribution.DetectDependencies(context.Background(), deps.WindowManagerHyprland)
if err != nil {
return nil, err
}
niriDeps, err := d.distribution.DetectDependencies(context.Background(), deps.WindowManagerNiri)
if err != nil {
return nil, err
}
// Combine dependencies and deduplicate
depMap := make(map[string]deps.Dependency)
for _, dep := range hyprlandDeps {
depMap[dep.Name] = dep
}
for _, dep := range niriDeps {
// If dependency already exists, keep the one that's installed or needs update
if existing, exists := depMap[dep.Name]; exists {
if dep.Status > existing.Status {
depMap[dep.Name] = dep
}
} else {
depMap[dep.Name] = dep
}
}
// Convert map back to slice
var allDeps []deps.Dependency
for _, dep := range depMap {
allDeps = append(allDeps, dep)
}
return allDeps, nil
}
func (d *Detector) GetWindowManagerStatus() (bool, bool, error) {
// Reuse the existing command detection logic from BaseDistribution
// Since all distros embed BaseDistribution, we can access it via interface
type CommandChecker interface {
CommandExists(string) bool
}
checker, ok := d.distribution.(CommandChecker)
if !ok {
// Fallback to direct command check if interface not available
hyprlandInstalled := d.commandExists("hyprland") || d.commandExists("Hyprland")
niriInstalled := d.commandExists("niri")
return hyprlandInstalled, niriInstalled, nil
}
hyprlandInstalled := checker.CommandExists("hyprland") || checker.CommandExists("Hyprland")
niriInstalled := checker.CommandExists("niri")
return hyprlandInstalled, niriInstalled, nil
}
func (d *Detector) commandExists(cmd string) bool {
_, err := exec.LookPath(cmd)
return err == nil
}
func (d *Detector) GetInstalledComponents() []DependencyInfo {
dependencies, err := d.GetDependencyStatus()
if err != nil {
return []DependencyInfo{}
}
var components []DependencyInfo
for _, dep := range dependencies {
components = append(components, DependencyInfo{
Name: dep.Name,
Status: dep.Status,
Description: dep.Description,
Required: dep.Required,
})
}
return components
}
type DependencyInfo struct {
Name string
Status deps.DependencyStatus
Description string
Required bool
}

View File

@@ -1,54 +0,0 @@
package dms
import (
"os/exec"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
tea "github.com/charmbracelet/bubbletea"
)
func (m Model) updateShellView(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
m.state = StateMainMenu
default:
return m, tea.Quit
}
return m, nil
}
func (m Model) updateAboutView(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q", "esc":
if msg.String() == "esc" {
m.state = StateMainMenu
} else {
return m, tea.Quit
}
}
return m, nil
}
func terminateShell() {
patterns := []string{"dms run", "qs -c dms"}
for _, pattern := range patterns {
cmd := exec.Command("pkill", "-f", pattern)
cmd.Run()
}
}
func startShellDaemon() {
cmd := exec.Command("dms", "run", "-d")
if err := cmd.Start(); err != nil {
log.Errorf("Error starting daemon: %v", err)
}
}
func restartShell() {
terminateShell()
time.Sleep(500 * time.Millisecond)
startShellDaemon()
}

View File

@@ -1,392 +0,0 @@
//go:build !distro_binary
package dms
import (
"context"
"fmt"
"os/exec"
"strings"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
tea "github.com/charmbracelet/bubbletea"
)
func (m Model) updateUpdateView(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
filteredDeps := m.getFilteredDeps()
maxIndex := len(filteredDeps) - 1
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
m.state = StateMainMenu
case "up", "k":
if m.selectedUpdateDep > 0 {
m.selectedUpdateDep--
}
case "down", "j":
if m.selectedUpdateDep < maxIndex {
m.selectedUpdateDep++
}
case " ":
if dep := m.getDepAtVisualIndex(m.selectedUpdateDep); dep != nil {
m.updateToggles[dep.Name] = !m.updateToggles[dep.Name]
}
case "enter":
hasSelected := false
for _, toggle := range m.updateToggles {
if toggle {
hasSelected = true
break
}
}
if !hasSelected {
m.state = StateMainMenu
return m, nil
}
m.state = StateUpdatePassword
m.passwordInput = ""
m.passwordError = ""
return m, nil
}
return m, nil
}
func (m Model) updatePasswordView(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c":
return m, tea.Quit
case "esc":
m.state = StateUpdate
m.passwordInput = ""
m.passwordError = ""
return m, nil
case "enter":
if m.passwordInput == "" {
return m, nil
}
return m, m.validatePassword(m.passwordInput)
case "backspace":
if len(m.passwordInput) > 0 {
m.passwordInput = m.passwordInput[:len(m.passwordInput)-1]
}
default:
if len(msg.String()) == 1 && msg.String()[0] >= 32 && msg.String()[0] <= 126 {
m.passwordInput += msg.String()
}
}
return m, nil
}
func (m Model) updateProgressView(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
if m.updateProgress.complete {
m.state = StateMainMenu
m.updateProgress = updateProgressMsg{}
m.updateLogs = []string{}
}
}
return m, nil
}
func (m Model) validatePassword(password string) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "sudo", "-S", "-v")
stdin, err := cmd.StdinPipe()
if err != nil {
return passwordValidMsg{password: "", valid: false}
}
go func() {
defer stdin.Close()
fmt.Fprintf(stdin, "%s\n", password)
}()
output, err := cmd.CombinedOutput()
outputStr := string(output)
if err != nil {
if strings.Contains(outputStr, "Sorry, try again") ||
strings.Contains(outputStr, "incorrect password") ||
strings.Contains(outputStr, "authentication failure") {
return passwordValidMsg{password: "", valid: false}
}
return passwordValidMsg{password: "", valid: false}
}
return passwordValidMsg{password: password, valid: true}
}
}
func (m Model) performUpdate() tea.Cmd {
var depsToUpdate []deps.Dependency
for _, depInfo := range m.updateDeps {
if m.updateToggles[depInfo.Name] {
depsToUpdate = append(depsToUpdate, deps.Dependency{
Name: depInfo.Name,
Status: depInfo.Status,
Description: depInfo.Description,
Required: depInfo.Required,
})
}
}
if len(depsToUpdate) == 0 {
return func() tea.Msg {
return updateCompleteMsg{err: nil}
}
}
wm := deps.WindowManagerHyprland
if m.niriInstalled {
wm = deps.WindowManagerNiri
}
sudoPassword := m.sudoPassword
reinstallFlags := make(map[string]bool)
for name, toggled := range m.updateToggles {
if toggled {
reinstallFlags[name] = true
}
}
distribution := m.detector.GetDistribution()
progressChan := m.updateProgressChan
return func() tea.Msg {
installerChan := make(chan distros.InstallProgressMsg, 100)
go func() {
ctx := context.Background()
disabledFlags := make(map[string]bool)
err := distribution.InstallPackages(ctx, depsToUpdate, wm, sudoPassword, reinstallFlags, disabledFlags, false, installerChan)
close(installerChan)
if err != nil {
progressChan <- updateProgressMsg{complete: true, err: err}
} else {
progressChan <- updateProgressMsg{complete: true}
}
}()
go func() {
for msg := range installerChan {
progressChan <- updateProgressMsg{
progress: msg.Progress,
step: msg.Step,
complete: msg.IsComplete,
err: msg.Error,
logOutput: msg.LogOutput,
}
}
}()
return nil
}
}
func (m Model) updateGreeterMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
greeterMenuItems := []string{"Install Greeter"}
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
m.state = StateMainMenu
case "up", "k":
if m.selectedGreeterItem > 0 {
m.selectedGreeterItem--
}
case "down", "j":
if m.selectedGreeterItem < len(greeterMenuItems)-1 {
m.selectedGreeterItem++
}
case "enter", " ":
if m.selectedGreeterItem == 0 {
compositors := greeter.DetectCompositors()
if len(compositors) == 0 {
return m, nil
}
m.greeterCompositors = compositors
if len(compositors) > 1 {
m.state = StateGreeterCompositorSelect
m.greeterSelectedComp = 0
return m, nil
} else {
m.greeterChosenCompositor = compositors[0]
m.state = StateGreeterPassword
m.greeterPasswordInput = ""
m.greeterPasswordError = ""
return m, nil
}
}
}
return m, nil
}
func (m Model) updateGreeterCompositorSelect(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
m.state = StateGreeterMenu
return m, nil
case "up", "k":
if m.greeterSelectedComp > 0 {
m.greeterSelectedComp--
}
case "down", "j":
if m.greeterSelectedComp < len(m.greeterCompositors)-1 {
m.greeterSelectedComp++
}
case "enter", " ":
m.greeterChosenCompositor = m.greeterCompositors[m.greeterSelectedComp]
m.state = StateGreeterPassword
m.greeterPasswordInput = ""
m.greeterPasswordError = ""
return m, nil
}
return m, nil
}
func (m Model) updateGreeterPasswordView(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c":
return m, tea.Quit
case "esc":
m.state = StateGreeterMenu
m.greeterPasswordInput = ""
m.greeterPasswordError = ""
return m, nil
case "enter":
if m.greeterPasswordInput == "" {
return m, nil
}
return m, m.validateGreeterPassword(m.greeterPasswordInput)
case "backspace":
if len(m.greeterPasswordInput) > 0 {
m.greeterPasswordInput = m.greeterPasswordInput[:len(m.greeterPasswordInput)-1]
}
default:
if len(msg.String()) == 1 && msg.String()[0] >= 32 && msg.String()[0] <= 126 {
m.greeterPasswordInput += msg.String()
}
}
return m, nil
}
func (m Model) updateGreeterInstalling(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
if m.greeterProgress.complete {
m.state = StateMainMenu
m.greeterProgress = greeterProgressMsg{}
m.greeterLogs = []string{}
}
}
return m, nil
}
func (m Model) performGreeterInstall() tea.Cmd {
progressChan := m.greeterInstallChan
sudoPassword := m.greeterSudoPassword
compositor := m.greeterChosenCompositor
return func() tea.Msg {
go func() {
logFunc := func(msg string) {
progressChan <- greeterProgressMsg{step: msg, logOutput: msg}
}
progressChan <- greeterProgressMsg{step: "Checking greetd installation..."}
if err := performGreeterInstallSteps(progressChan, logFunc, sudoPassword, compositor); err != nil {
progressChan <- greeterProgressMsg{step: "Installation failed", complete: true, err: err}
return
}
progressChan <- greeterProgressMsg{step: "Installation complete", complete: true}
}()
return nil
}
}
func (m Model) validateGreeterPassword(password string) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "sudo", "-S", "-v")
stdin, err := cmd.StdinPipe()
if err != nil {
return greeterPasswordValidMsg{password: "", valid: false}
}
go func() {
defer stdin.Close()
fmt.Fprintf(stdin, "%s\n", password)
}()
output, err := cmd.CombinedOutput()
outputStr := string(output)
if err != nil {
if strings.Contains(outputStr, "Sorry, try again") ||
strings.Contains(outputStr, "incorrect password") ||
strings.Contains(outputStr, "authentication failure") {
return greeterPasswordValidMsg{password: "", valid: false}
}
return greeterPasswordValidMsg{password: "", valid: false}
}
return greeterPasswordValidMsg{password: password, valid: true}
}
}
func performGreeterInstallSteps(progressChan chan greeterProgressMsg, logFunc func(string), sudoPassword string, compositor string) error {
if err := greeter.EnsureGreetdInstalled(logFunc, sudoPassword); err != nil {
return err
}
progressChan <- greeterProgressMsg{step: "Detecting DMS installation..."}
dmsPath, err := greeter.DetectDMSPath()
if err != nil {
return err
}
logFunc(fmt.Sprintf("✓ Found DMS at: %s", dmsPath))
logFunc(fmt.Sprintf("✓ Selected compositor: %s", compositor))
progressChan <- greeterProgressMsg{step: "Copying greeter files..."}
if err := greeter.CopyGreeterFiles(dmsPath, compositor, logFunc, sudoPassword); err != nil {
return err
}
progressChan <- greeterProgressMsg{step: "Configuring greetd..."}
if err := greeter.ConfigureGreetd(dmsPath, compositor, logFunc, sudoPassword); err != nil {
return err
}
progressChan <- greeterProgressMsg{step: "Synchronizing DMS configurations..."}
if err := greeter.SyncDMSConfigs(dmsPath, logFunc, sudoPassword); err != nil {
return err
}
return nil
}

View File

@@ -1,61 +0,0 @@
//go:build !distro_binary
package dms
import (
"time"
tea "github.com/charmbracelet/bubbletea"
)
type shellStartedMsg struct{}
func (m Model) updateMainMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q", "esc":
return m, tea.Quit
case "up", "k":
if m.selectedItem > 0 {
m.selectedItem--
}
case "down", "j":
if m.selectedItem < len(m.menuItems)-1 {
m.selectedItem++
}
case "enter", " ":
if m.selectedItem < len(m.menuItems) {
selectedAction := m.menuItems[m.selectedItem].Action
selectedLabel := m.menuItems[m.selectedItem].Label
switch selectedAction {
case StateUpdate:
m.state = StateUpdate
m.selectedUpdateDep = 0
case StateShell:
if selectedLabel == "Terminate Shell" {
terminateShell()
m.menuItems = m.buildMenuItems()
if m.selectedItem >= len(m.menuItems) {
m.selectedItem = len(m.menuItems) - 1
}
} else {
startShellDaemon()
// Wait a moment for the daemon to actually start before checking status
return m, tea.Tick(300*time.Millisecond, func(t time.Time) tea.Msg {
return shellStartedMsg{}
})
}
case StatePluginsMenu:
m.state = StatePluginsMenu
m.selectedPluginsMenuItem = 0
m.pluginsMenuItems = m.buildPluginsMenuItems()
case StateGreeterMenu:
m.state = StateGreeterMenu
m.selectedGreeterItem = 0
case StateAbout:
m.state = StateAbout
}
}
}
return m, nil
}

View File

@@ -1,55 +0,0 @@
//go:build distro_binary
package dms
import (
"time"
tea "github.com/charmbracelet/bubbletea"
)
type shellStartedMsg struct{}
func (m Model) updateMainMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q", "esc":
return m, tea.Quit
case "up", "k":
if m.selectedItem > 0 {
m.selectedItem--
}
case "down", "j":
if m.selectedItem < len(m.menuItems)-1 {
m.selectedItem++
}
case "enter", " ":
if m.selectedItem < len(m.menuItems) {
selectedAction := m.menuItems[m.selectedItem].Action
selectedLabel := m.menuItems[m.selectedItem].Label
switch selectedAction {
case StateShell:
if selectedLabel == "Terminate Shell" {
terminateShell()
m.menuItems = m.buildMenuItems()
if m.selectedItem >= len(m.menuItems) {
m.selectedItem = len(m.menuItems) - 1
}
} else {
startShellDaemon()
// Wait a moment for the daemon to actually start before checking status
return m, tea.Tick(300*time.Millisecond, func(t time.Time) tea.Msg {
return shellStartedMsg{}
})
}
case StatePluginsMenu:
m.state = StatePluginsMenu
m.selectedPluginsMenuItem = 0
m.pluginsMenuItems = m.buildPluginsMenuItems()
case StateAbout:
m.state = StateAbout
}
}
}
return m, nil
}

View File

@@ -1,377 +0,0 @@
package dms
import (
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/plugins"
tea "github.com/charmbracelet/bubbletea"
)
func (m Model) updatePluginsMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
m.state = StateMainMenu
case "up", "k":
if m.selectedPluginsMenuItem > 0 {
m.selectedPluginsMenuItem--
}
case "down", "j":
if m.selectedPluginsMenuItem < len(m.pluginsMenuItems)-1 {
m.selectedPluginsMenuItem++
}
case "enter", " ":
if m.selectedPluginsMenuItem < len(m.pluginsMenuItems) {
selectedAction := m.pluginsMenuItems[m.selectedPluginsMenuItem].Action
switch selectedAction {
case StatePluginsBrowse:
m.state = StatePluginsBrowse
m.pluginsLoading = true
m.pluginsError = ""
m.pluginsList = nil
return m, loadPlugins
case StatePluginsInstalled:
m.state = StatePluginsInstalled
m.installedPluginsLoading = true
m.installedPluginsError = ""
m.installedPluginsList = nil
return m, loadInstalledPlugins
}
}
}
return m, nil
}
func (m Model) updatePluginsBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
m.state = StatePluginsMenu
m.pluginSearchQuery = ""
m.filteredPluginsList = m.pluginsList
m.selectedPluginIndex = 0
case "up", "k":
if m.selectedPluginIndex > 0 {
m.selectedPluginIndex--
}
case "down", "j":
if m.selectedPluginIndex < len(m.filteredPluginsList)-1 {
m.selectedPluginIndex++
}
case "enter", " ":
if m.selectedPluginIndex < len(m.filteredPluginsList) {
m.state = StatePluginDetail
}
case "/":
m.state = StatePluginSearch
m.pluginSearchQuery = ""
}
return m, nil
}
func (m Model) updatePluginDetail(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
m.state = StatePluginsBrowse
case "i":
if m.selectedPluginIndex < len(m.filteredPluginsList) {
plugin := m.filteredPluginsList[m.selectedPluginIndex]
installed := m.pluginInstallStatus[plugin.Name]
if !installed {
return m, installPlugin(plugin)
}
}
}
return m, nil
}
func (m Model) updatePluginSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c":
return m, tea.Quit
case "esc":
m.state = StatePluginsBrowse
m.pluginSearchQuery = ""
m.filteredPluginsList = m.pluginsList
m.selectedPluginIndex = 0
case "enter":
m.state = StatePluginsBrowse
m.filterPlugins()
case "backspace":
if len(m.pluginSearchQuery) > 0 {
m.pluginSearchQuery = m.pluginSearchQuery[:len(m.pluginSearchQuery)-1]
}
default:
if len(msg.String()) == 1 {
m.pluginSearchQuery += msg.String()
}
}
return m, nil
}
func (m *Model) filterPlugins() {
if m.pluginSearchQuery == "" {
m.filteredPluginsList = m.pluginsList
m.selectedPluginIndex = 0
return
}
rawPlugins := make([]plugins.Plugin, len(m.pluginsList))
for i, p := range m.pluginsList {
rawPlugins[i] = plugins.Plugin{
ID: p.ID,
Name: p.Name,
Category: p.Category,
Author: p.Author,
Description: p.Description,
Repo: p.Repo,
Path: p.Path,
Capabilities: p.Capabilities,
Compositors: p.Compositors,
Dependencies: p.Dependencies,
}
}
searchResults := plugins.FuzzySearch(m.pluginSearchQuery, rawPlugins)
searchResults = plugins.SortByFirstParty(searchResults)
filtered := make([]pluginInfo, len(searchResults))
for i, p := range searchResults {
filtered[i] = pluginInfo{
ID: p.ID,
Name: p.Name,
Category: p.Category,
Author: p.Author,
Description: p.Description,
Repo: p.Repo,
Path: p.Path,
Capabilities: p.Capabilities,
Compositors: p.Compositors,
Dependencies: p.Dependencies,
FirstParty: strings.HasPrefix(p.Repo, "https://github.com/AvengeMedia"),
}
}
m.filteredPluginsList = filtered
m.selectedPluginIndex = 0
}
type pluginsLoadedMsg struct {
plugins []plugins.Plugin
err error
}
func loadPlugins() tea.Msg {
registry, err := plugins.NewRegistry()
if err != nil {
return pluginsLoadedMsg{err: err}
}
pluginList, err := registry.List()
if err != nil {
return pluginsLoadedMsg{err: err}
}
return pluginsLoadedMsg{plugins: pluginList}
}
func (m *Model) updatePluginInstallStatus() {
manager, err := plugins.NewManager()
if err != nil {
return
}
for _, plugin := range m.pluginsList {
p := plugins.Plugin{ID: plugin.ID}
installed, err := manager.IsInstalled(p)
if err == nil {
m.pluginInstallStatus[plugin.Name] = installed
}
}
}
func (m Model) updatePluginsInstalled(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
m.state = StatePluginsMenu
case "up", "k":
if m.selectedInstalledIndex > 0 {
m.selectedInstalledIndex--
}
case "down", "j":
if m.selectedInstalledIndex < len(m.installedPluginsList)-1 {
m.selectedInstalledIndex++
}
case "enter", " ":
if m.selectedInstalledIndex < len(m.installedPluginsList) {
m.state = StatePluginInstalledDetail
}
}
return m, nil
}
func (m Model) updatePluginInstalledDetail(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
m.state = StatePluginsInstalled
case "u":
if m.selectedInstalledIndex < len(m.installedPluginsList) {
plugin := m.installedPluginsList[m.selectedInstalledIndex]
return m, uninstallPlugin(plugin)
}
case "p":
if m.selectedInstalledIndex < len(m.installedPluginsList) {
plugin := m.installedPluginsList[m.selectedInstalledIndex]
return m, updatePlugin(plugin)
}
}
return m, nil
}
type installedPluginsLoadedMsg struct {
plugins []plugins.Plugin
err error
}
type pluginUninstalledMsg struct {
pluginName string
err error
}
type pluginInstalledMsg struct {
pluginName string
err error
}
type pluginUpdatedMsg struct {
pluginName string
err error
}
func loadInstalledPlugins() tea.Msg {
manager, err := plugins.NewManager()
if err != nil {
return installedPluginsLoadedMsg{err: err}
}
registry, err := plugins.NewRegistry()
if err != nil {
return installedPluginsLoadedMsg{err: err}
}
installedNames, err := manager.ListInstalled()
if err != nil {
return installedPluginsLoadedMsg{err: err}
}
allPlugins, err := registry.List()
if err != nil {
return installedPluginsLoadedMsg{err: err}
}
var installed []plugins.Plugin
for _, id := range installedNames {
for _, p := range allPlugins {
if p.ID == id {
installed = append(installed, p)
break
}
}
}
installed = plugins.SortByFirstParty(installed)
return installedPluginsLoadedMsg{plugins: installed}
}
func installPlugin(plugin pluginInfo) tea.Cmd {
return func() tea.Msg {
manager, err := plugins.NewManager()
if err != nil {
return pluginInstalledMsg{pluginName: plugin.Name, err: err}
}
p := plugins.Plugin{
ID: plugin.ID,
Name: plugin.Name,
Category: plugin.Category,
Author: plugin.Author,
Description: plugin.Description,
Repo: plugin.Repo,
Path: plugin.Path,
Capabilities: plugin.Capabilities,
Compositors: plugin.Compositors,
Dependencies: plugin.Dependencies,
}
if err := manager.Install(p); err != nil {
return pluginInstalledMsg{pluginName: plugin.Name, err: err}
}
return pluginInstalledMsg{pluginName: plugin.Name}
}
}
func uninstallPlugin(plugin pluginInfo) tea.Cmd {
return func() tea.Msg {
manager, err := plugins.NewManager()
if err != nil {
return pluginUninstalledMsg{pluginName: plugin.Name, err: err}
}
p := plugins.Plugin{
ID: plugin.ID,
Name: plugin.Name,
Category: plugin.Category,
Author: plugin.Author,
Description: plugin.Description,
Repo: plugin.Repo,
Path: plugin.Path,
Capabilities: plugin.Capabilities,
Compositors: plugin.Compositors,
Dependencies: plugin.Dependencies,
}
if err := manager.Uninstall(p); err != nil {
return pluginUninstalledMsg{pluginName: plugin.Name, err: err}
}
return pluginUninstalledMsg{pluginName: plugin.Name}
}
}
func updatePlugin(plugin pluginInfo) tea.Cmd {
return func() tea.Msg {
manager, err := plugins.NewManager()
if err != nil {
return pluginUpdatedMsg{pluginName: plugin.Name, err: err}
}
p := plugins.Plugin{
ID: plugin.ID,
Name: plugin.Name,
Category: plugin.Category,
Author: plugin.Author,
Description: plugin.Description,
Repo: plugin.Repo,
Path: plugin.Path,
Capabilities: plugin.Capabilities,
Compositors: plugin.Compositors,
Dependencies: plugin.Dependencies,
}
if err := manager.Update(p); err != nil {
return pluginUpdatedMsg{pluginName: plugin.Name, err: err}
}
return pluginUpdatedMsg{pluginName: plugin.Name}
}
}

View File

@@ -1,367 +0,0 @@
package dms
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
func (m Model) renderPluginsMenu() string {
var b strings.Builder
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#00D4AA"))
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
selectedStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA")).
Bold(true)
b.WriteString(titleStyle.Render("Plugins"))
b.WriteString("\n\n")
for i, item := range m.pluginsMenuItems {
if i == m.selectedPluginsMenuItem {
b.WriteString(selectedStyle.Render(fmt.Sprintf("→ %s", item.Label)))
} else {
b.WriteString(normalStyle.Render(fmt.Sprintf(" %s", item.Label)))
}
b.WriteString("\n")
}
b.WriteString("\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
b.WriteString(instructionStyle.Render("↑/↓: Navigate | Enter: Select | Esc: Back | q: Quit"))
return b.String()
}
func (m Model) renderPluginsBrowse() string {
var b strings.Builder
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#00D4AA"))
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
errorStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF0000"))
selectedStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA")).
Bold(true)
b.WriteString(titleStyle.Render("Browse Plugins"))
b.WriteString("\n\n")
if m.pluginsLoading {
b.WriteString(normalStyle.Render("Fetching plugins from registry..."))
} else if m.pluginsError != "" {
b.WriteString(errorStyle.Render(fmt.Sprintf("Error: %s", m.pluginsError)))
} else if len(m.filteredPluginsList) == 0 {
if m.pluginSearchQuery != "" {
b.WriteString(normalStyle.Render(fmt.Sprintf("No plugins match '%s'", m.pluginSearchQuery)))
} else {
b.WriteString(normalStyle.Render("No plugins found in registry."))
}
} else {
installedStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
for i, plugin := range m.filteredPluginsList {
installed := m.pluginInstallStatus[plugin.Name]
installMarker := ""
if installed {
installMarker = " [Installed]"
}
if i == m.selectedPluginIndex {
b.WriteString(selectedStyle.Render(fmt.Sprintf("→ %s", plugin.Name)))
if installed {
b.WriteString(installedStyle.Render(installMarker))
}
} else {
b.WriteString(normalStyle.Render(fmt.Sprintf(" %s", plugin.Name)))
if installed {
b.WriteString(installedStyle.Render(installMarker))
}
}
b.WriteString("\n")
}
}
b.WriteString("\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
if m.pluginsLoading || m.pluginsError != "" {
b.WriteString(instructionStyle.Render("Esc: Back | q: Quit"))
} else {
b.WriteString(instructionStyle.Render("↑/↓: Navigate | Enter: View/Install | /: Search | Esc: Back | q: Quit"))
}
return b.String()
}
func (m Model) renderPluginDetail() string {
var b strings.Builder
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#00D4AA"))
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
labelStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
if m.selectedPluginIndex >= len(m.filteredPluginsList) {
return "No plugin selected"
}
plugin := m.filteredPluginsList[m.selectedPluginIndex]
b.WriteString(titleStyle.Render(plugin.Name))
b.WriteString("\n\n")
b.WriteString(labelStyle.Render("ID: "))
b.WriteString(normalStyle.Render(plugin.ID))
b.WriteString("\n\n")
b.WriteString(labelStyle.Render("Category: "))
b.WriteString(normalStyle.Render(plugin.Category))
b.WriteString("\n\n")
b.WriteString(labelStyle.Render("Author: "))
b.WriteString(normalStyle.Render(plugin.Author))
b.WriteString("\n\n")
b.WriteString(labelStyle.Render("Description:"))
b.WriteString("\n")
wrapped := wrapText(plugin.Description, 60)
b.WriteString(normalStyle.Render(wrapped))
b.WriteString("\n\n")
b.WriteString(labelStyle.Render("Repository: "))
b.WriteString(normalStyle.Render(plugin.Repo))
b.WriteString("\n\n")
if len(plugin.Capabilities) > 0 {
b.WriteString(labelStyle.Render("Capabilities: "))
b.WriteString(normalStyle.Render(strings.Join(plugin.Capabilities, ", ")))
b.WriteString("\n\n")
}
if len(plugin.Compositors) > 0 {
b.WriteString(labelStyle.Render("Compositors: "))
b.WriteString(normalStyle.Render(strings.Join(plugin.Compositors, ", ")))
b.WriteString("\n\n")
}
if len(plugin.Dependencies) > 0 {
b.WriteString(labelStyle.Render("Dependencies: "))
b.WriteString(normalStyle.Render(strings.Join(plugin.Dependencies, ", ")))
b.WriteString("\n\n")
}
installed := m.pluginInstallStatus[plugin.Name]
if installed {
b.WriteString(labelStyle.Render("Status: "))
installedStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA"))
b.WriteString(installedStyle.Render("Installed"))
b.WriteString("\n\n")
}
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
if installed {
b.WriteString(instructionStyle.Render("Esc: Back | q: Quit"))
} else {
b.WriteString(instructionStyle.Render("i: Install | Esc: Back | q: Quit"))
}
return b.String()
}
func (m Model) renderPluginSearch() string {
var b strings.Builder
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#00D4AA"))
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
b.WriteString(titleStyle.Render("Search Plugins"))
b.WriteString("\n\n")
b.WriteString(normalStyle.Render("Query: "))
b.WriteString(titleStyle.Render(m.pluginSearchQuery + "▌"))
b.WriteString("\n\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
b.WriteString(instructionStyle.Render("Enter: Search | Esc: Cancel"))
return b.String()
}
func (m Model) renderPluginsInstalled() string {
var b strings.Builder
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#00D4AA"))
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
errorStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF0000"))
selectedStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA")).
Bold(true)
b.WriteString(titleStyle.Render("Installed Plugins"))
b.WriteString("\n\n")
if m.installedPluginsLoading {
b.WriteString(normalStyle.Render("Loading installed plugins..."))
} else if m.installedPluginsError != "" {
b.WriteString(errorStyle.Render(fmt.Sprintf("Error: %s", m.installedPluginsError)))
} else if len(m.installedPluginsList) == 0 {
b.WriteString(normalStyle.Render("No plugins installed."))
} else {
for i, plugin := range m.installedPluginsList {
if i == m.selectedInstalledIndex {
b.WriteString(selectedStyle.Render(fmt.Sprintf("→ %s", plugin.Name)))
} else {
b.WriteString(normalStyle.Render(fmt.Sprintf(" %s", plugin.Name)))
}
b.WriteString("\n")
}
}
b.WriteString("\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
if m.installedPluginsLoading || m.installedPluginsError != "" {
b.WriteString(instructionStyle.Render("Esc: Back | q: Quit"))
} else {
b.WriteString(instructionStyle.Render("↑/↓: Navigate | Enter: Details | Esc: Back | q: Quit"))
}
return b.String()
}
func (m Model) renderPluginInstalledDetail() string {
var b strings.Builder
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#00D4AA"))
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
labelStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
errorStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF0000"))
if m.selectedInstalledIndex >= len(m.installedPluginsList) {
return "No plugin selected"
}
plugin := m.installedPluginsList[m.selectedInstalledIndex]
b.WriteString(titleStyle.Render(plugin.Name))
b.WriteString("\n\n")
b.WriteString(labelStyle.Render("ID: "))
b.WriteString(normalStyle.Render(plugin.ID))
b.WriteString("\n\n")
b.WriteString(labelStyle.Render("Category: "))
b.WriteString(normalStyle.Render(plugin.Category))
b.WriteString("\n\n")
b.WriteString(labelStyle.Render("Author: "))
b.WriteString(normalStyle.Render(plugin.Author))
b.WriteString("\n\n")
b.WriteString(labelStyle.Render("Description:"))
b.WriteString("\n")
wrapped := wrapText(plugin.Description, 60)
b.WriteString(normalStyle.Render(wrapped))
b.WriteString("\n\n")
b.WriteString(labelStyle.Render("Repository: "))
b.WriteString(normalStyle.Render(plugin.Repo))
b.WriteString("\n\n")
if len(plugin.Capabilities) > 0 {
b.WriteString(labelStyle.Render("Capabilities: "))
b.WriteString(normalStyle.Render(strings.Join(plugin.Capabilities, ", ")))
b.WriteString("\n\n")
}
if len(plugin.Compositors) > 0 {
b.WriteString(labelStyle.Render("Compositors: "))
b.WriteString(normalStyle.Render(strings.Join(plugin.Compositors, ", ")))
b.WriteString("\n\n")
}
if len(plugin.Dependencies) > 0 {
b.WriteString(labelStyle.Render("Dependencies: "))
b.WriteString(normalStyle.Render(strings.Join(plugin.Dependencies, ", ")))
b.WriteString("\n\n")
}
if m.installedPluginsError != "" {
b.WriteString(errorStyle.Render(fmt.Sprintf("Error: %s", m.installedPluginsError)))
b.WriteString("\n\n")
}
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
b.WriteString(instructionStyle.Render("u: Uninstall | Esc: Back | q: Quit"))
return b.String()
}
func wrapText(text string, width int) string {
words := strings.Fields(text)
if len(words) == 0 {
return text
}
var lines []string
currentLine := words[0]
for _, word := range words[1:] {
if len(currentLine)+1+len(word) <= width {
currentLine += " " + word
} else {
lines = append(lines, currentLine)
currentLine = word
}
}
lines = append(lines, currentLine)
return strings.Join(lines, "\n")
}

View File

@@ -1,152 +0,0 @@
package dms
import (
"fmt"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/tui"
"github.com/charmbracelet/lipgloss"
)
func (m Model) renderMainMenu() string {
var b strings.Builder
b.WriteString(m.renderBanner())
b.WriteString("\n")
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
MarginBottom(1)
b.WriteString(headerStyle.Render("dms"))
b.WriteString("\n")
selectedStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA")).
Bold(true)
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
for i, item := range m.menuItems {
if i == m.selectedItem {
b.WriteString(selectedStyle.Render(fmt.Sprintf("▶ %s", item.Label)))
} else {
b.WriteString(normalStyle.Render(fmt.Sprintf(" %s", item.Label)))
}
b.WriteString("\n")
}
b.WriteString("\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888")).
MarginTop(1)
instructions := "↑/↓: Navigate, Enter: Select, q/Esc: Exit"
b.WriteString(instructionStyle.Render(instructions))
return b.String()
}
func (m Model) renderShellView() string {
var b strings.Builder
b.WriteString(m.renderBanner())
b.WriteString("\n")
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
MarginBottom(1)
b.WriteString(headerStyle.Render("Shell"))
b.WriteString("\n\n")
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
b.WriteString(normalStyle.Render("Opening interactive shell..."))
b.WriteString("\n")
b.WriteString(normalStyle.Render("This will launch a shell with DMS environment loaded."))
b.WriteString("\n\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888")).
MarginTop(1)
instructions := "Press any key to launch shell, Esc: Back"
b.WriteString(instructionStyle.Render(instructions))
return b.String()
}
func (m Model) renderAboutView() string {
var b strings.Builder
b.WriteString(m.renderBanner())
b.WriteString("\n")
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
MarginBottom(1)
b.WriteString(headerStyle.Render("About DankMaterialShell"))
b.WriteString("\n\n")
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
b.WriteString(normalStyle.Render(fmt.Sprintf("DMS Management Interface %s", m.version)))
b.WriteString("\n\n")
b.WriteString(normalStyle.Render("DankMaterialShell is a comprehensive desktop environment"))
b.WriteString("\n")
b.WriteString(normalStyle.Render("built around Quickshell, providing a modern Material Design"))
b.WriteString("\n")
b.WriteString(normalStyle.Render("experience for Wayland compositors."))
b.WriteString("\n\n")
b.WriteString(normalStyle.Render("Components:"))
b.WriteString("\n")
if len(m.dependencies) == 0 {
b.WriteString(normalStyle.Render("\n Component detection not supported on this platform."))
}
for _, dep := range m.dependencies {
status := "✗"
if dep.Status == 1 {
status = "✓"
}
b.WriteString(normalStyle.Render(fmt.Sprintf(" %s %s", status, dep.Name)))
b.WriteString("\n")
}
b.WriteString("\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888")).
MarginTop(1)
instructions := "Esc: Back to main menu"
b.WriteString(instructionStyle.Render(instructions))
return b.String()
}
func (m Model) renderBanner() string {
theme := tui.TerminalTheme()
logo := `
██████╗ █████╗ ███╗ ██╗██╗ ██╗
██╔══██╗██╔══██╗████╗ ██║██║ ██╔╝
██║ ██║███████║██╔██╗ ██║█████╔╝
██║ ██║██╔══██║██║╚██╗██║██╔═██╗
██████╔╝██║ ██║██║ ╚████║██║ ██╗
╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝`
titleStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color(theme.Primary)).
Bold(true).
MarginBottom(1)
return titleStyle.Render(logo)
}

View File

@@ -1,529 +0,0 @@
//go:build !distro_binary
package dms
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
func (m Model) renderUpdateView() string {
var b strings.Builder
b.WriteString(m.renderBanner())
b.WriteString("\n")
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
MarginBottom(1)
b.WriteString(headerStyle.Render("Update Dependencies"))
b.WriteString("\n")
if len(m.updateDeps) == 0 {
b.WriteString("Loading dependencies...\n")
return b.String()
}
categories := m.categorizeDependencies()
currentIndex := 0
for _, category := range []string{"Shell", "Shared Components", "Hyprland Components", "Niri Components"} {
deps, exists := categories[category]
if !exists || len(deps) == 0 {
continue
}
categoryStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#7060ac")).
Bold(true).
MarginTop(1)
b.WriteString(categoryStyle.Render(category + ":"))
b.WriteString("\n")
for _, dep := range deps {
var statusText, icon, reinstallMarker string
var style lipgloss.Style
if m.updateToggles[dep.Name] {
reinstallMarker = "🔄 "
if dep.Status == 0 {
statusText = "Will be installed"
} else {
statusText = "Will be upgraded"
}
style = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFA500"))
} else {
switch dep.Status {
case 1:
icon = "✓"
statusText = "Installed"
style = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF"))
case 0:
icon = "○"
statusText = "Not installed"
style = lipgloss.NewStyle().Foreground(lipgloss.Color("#888888"))
case 2:
icon = "△"
statusText = "Needs update"
style = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFA500"))
case 3:
icon = "!"
statusText = "Needs reinstall"
style = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFA500"))
}
}
line := fmt.Sprintf("%s%s%-25s %s", reinstallMarker, icon, dep.Name, statusText)
if currentIndex == m.selectedUpdateDep {
line = "▶ " + line
selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#7060ac")).Bold(true)
b.WriteString(selectedStyle.Render(line))
} else {
line = " " + line
b.WriteString(style.Render(line))
}
b.WriteString("\n")
currentIndex++
}
}
b.WriteString("\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888")).
MarginTop(1)
instructions := "↑/↓: Navigate, Space: Toggle, Enter: Update Selected, Esc: Back"
b.WriteString(instructionStyle.Render(instructions))
return b.String()
}
func (m Model) renderPasswordView() string {
var b strings.Builder
b.WriteString(m.renderBanner())
b.WriteString("\n")
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
MarginBottom(1)
b.WriteString(headerStyle.Render("Sudo Authentication"))
b.WriteString("\n\n")
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
b.WriteString(normalStyle.Render("Package installation requires sudo privileges."))
b.WriteString("\n")
b.WriteString(normalStyle.Render("Please enter your password to continue:"))
b.WriteString("\n\n")
inputStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA"))
maskedPassword := strings.Repeat("*", len(m.passwordInput))
b.WriteString(inputStyle.Render("Password: " + maskedPassword))
b.WriteString("\n")
if m.passwordError != "" {
errorStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF0000"))
b.WriteString(errorStyle.Render("✗ " + m.passwordError))
b.WriteString("\n")
}
b.WriteString("\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888")).
MarginTop(1)
instructions := "Enter: Continue, Esc: Back, Ctrl+C: Cancel"
b.WriteString(instructionStyle.Render(instructions))
return b.String()
}
func (m Model) renderProgressView() string {
var b strings.Builder
b.WriteString(m.renderBanner())
b.WriteString("\n")
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
MarginBottom(1)
b.WriteString(headerStyle.Render("Updating Packages"))
b.WriteString("\n\n")
if !m.updateProgress.complete {
progressStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA"))
b.WriteString(progressStyle.Render(m.updateProgress.step))
b.WriteString("\n\n")
progressBar := fmt.Sprintf("[%s%s] %.0f%%",
strings.Repeat("█", int(m.updateProgress.progress*30)),
strings.Repeat("░", 30-int(m.updateProgress.progress*30)),
m.updateProgress.progress*100)
b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Render(progressBar))
b.WriteString("\n")
if len(m.updateLogs) > 0 {
b.WriteString("\n")
logHeader := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).Render("Live Output:")
b.WriteString(logHeader)
b.WriteString("\n")
maxLines := 8
startIdx := 0
if len(m.updateLogs) > maxLines {
startIdx = len(m.updateLogs) - maxLines
}
logStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888"))
for i := startIdx; i < len(m.updateLogs); i++ {
if m.updateLogs[i] != "" {
b.WriteString(logStyle.Render(" " + m.updateLogs[i]))
b.WriteString("\n")
}
}
}
}
if m.updateProgress.err != nil {
errorStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF0000"))
b.WriteString("\n")
b.WriteString(errorStyle.Render(fmt.Sprintf("✗ Update failed: %v", m.updateProgress.err)))
b.WriteString("\n")
if len(m.updateLogs) > 0 {
b.WriteString("\n")
logHeader := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).Render("Error Logs:")
b.WriteString(logHeader)
b.WriteString("\n")
maxLines := 15
startIdx := 0
if len(m.updateLogs) > maxLines {
startIdx = len(m.updateLogs) - maxLines
}
logStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888"))
for i := startIdx; i < len(m.updateLogs); i++ {
if m.updateLogs[i] != "" {
b.WriteString(logStyle.Render(" " + m.updateLogs[i]))
b.WriteString("\n")
}
}
}
b.WriteString("\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
b.WriteString(instructionStyle.Render("Press Esc to go back"))
} else if m.updateProgress.complete {
successStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA"))
b.WriteString("\n")
b.WriteString(successStyle.Render("✓ Update complete!"))
b.WriteString("\n\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
b.WriteString(instructionStyle.Render("Press Esc to return to main menu"))
}
return b.String()
}
func (m Model) getFilteredDeps() []DependencyInfo {
categories := m.categorizeDependencies()
var filtered []DependencyInfo
for _, category := range []string{"Shell", "Shared Components", "Hyprland Components", "Niri Components"} {
deps, exists := categories[category]
if exists {
filtered = append(filtered, deps...)
}
}
return filtered
}
func (m Model) getDepAtVisualIndex(index int) *DependencyInfo {
filtered := m.getFilteredDeps()
if index >= 0 && index < len(filtered) {
return &filtered[index]
}
return nil
}
func (m Model) renderGreeterPasswordView() string {
var b strings.Builder
b.WriteString(m.renderBanner())
b.WriteString("\n")
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
MarginBottom(1)
b.WriteString(headerStyle.Render("Sudo Authentication"))
b.WriteString("\n\n")
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
b.WriteString(normalStyle.Render("Greeter installation requires sudo privileges."))
b.WriteString("\n")
b.WriteString(normalStyle.Render("Please enter your password to continue:"))
b.WriteString("\n\n")
inputStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA"))
maskedPassword := strings.Repeat("*", len(m.greeterPasswordInput))
b.WriteString(inputStyle.Render("Password: " + maskedPassword))
b.WriteString("\n")
if m.greeterPasswordError != "" {
errorStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF0000"))
b.WriteString(errorStyle.Render("✗ " + m.greeterPasswordError))
b.WriteString("\n")
}
b.WriteString("\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888")).
MarginTop(1)
instructions := "Enter: Continue, Esc: Back, Ctrl+C: Cancel"
b.WriteString(instructionStyle.Render(instructions))
return b.String()
}
func (m Model) renderGreeterCompositorSelect() string {
var b strings.Builder
b.WriteString(m.renderBanner())
b.WriteString("\n")
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
MarginBottom(1)
b.WriteString(headerStyle.Render("Select Compositor"))
b.WriteString("\n\n")
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
b.WriteString(normalStyle.Render("Multiple compositors detected. Choose which one to use for the greeter:"))
b.WriteString("\n\n")
selectedStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA")).
Bold(true)
for i, comp := range m.greeterCompositors {
if i == m.greeterSelectedComp {
b.WriteString(selectedStyle.Render(fmt.Sprintf("▶ %s", comp)))
} else {
b.WriteString(normalStyle.Render(fmt.Sprintf(" %s", comp)))
}
b.WriteString("\n")
}
b.WriteString("\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888")).
MarginTop(1)
instructions := "↑/↓: Navigate, Enter: Select, Esc: Back"
b.WriteString(instructionStyle.Render(instructions))
return b.String()
}
func (m Model) renderGreeterMenu() string {
var b strings.Builder
b.WriteString(m.renderBanner())
b.WriteString("\n")
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
MarginBottom(1)
b.WriteString(headerStyle.Render("Greeter Management"))
b.WriteString("\n")
greeterMenuItems := []string{"Install Greeter"}
selectedStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA")).
Bold(true)
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
for i, item := range greeterMenuItems {
if i == m.selectedGreeterItem {
b.WriteString(selectedStyle.Render(fmt.Sprintf("▶ %s", item)))
} else {
b.WriteString(normalStyle.Render(fmt.Sprintf(" %s", item)))
}
b.WriteString("\n")
}
b.WriteString("\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888")).
MarginTop(1)
instructions := "↑/↓: Navigate, Enter: Select, Esc: Back"
b.WriteString(instructionStyle.Render(instructions))
return b.String()
}
func (m Model) renderGreeterInstalling() string {
var b strings.Builder
b.WriteString(m.renderBanner())
b.WriteString("\n")
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
MarginBottom(1)
b.WriteString(headerStyle.Render("Installing Greeter"))
b.WriteString("\n\n")
if !m.greeterProgress.complete {
progressStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA"))
b.WriteString(progressStyle.Render(m.greeterProgress.step))
b.WriteString("\n\n")
if len(m.greeterLogs) > 0 {
b.WriteString("\n")
logHeader := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).Render("Output:")
b.WriteString(logHeader)
b.WriteString("\n")
maxLines := 10
startIdx := 0
if len(m.greeterLogs) > maxLines {
startIdx = len(m.greeterLogs) - maxLines
}
logStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888"))
for i := startIdx; i < len(m.greeterLogs); i++ {
if m.greeterLogs[i] != "" {
b.WriteString(logStyle.Render(" " + m.greeterLogs[i]))
b.WriteString("\n")
}
}
}
}
if m.greeterProgress.err != nil {
errorStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF0000"))
b.WriteString("\n")
b.WriteString(errorStyle.Render(fmt.Sprintf("✗ Installation failed: %v", m.greeterProgress.err)))
b.WriteString("\n\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
b.WriteString(instructionStyle.Render("Press Esc to go back"))
} else if m.greeterProgress.complete {
successStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D4AA"))
b.WriteString("\n")
b.WriteString(successStyle.Render("✓ Greeter installation complete!"))
b.WriteString("\n\n")
normalStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
b.WriteString(normalStyle.Render("To test the greeter, run:"))
b.WriteString("\n")
b.WriteString(normalStyle.Render(" sudo systemctl start greetd"))
b.WriteString("\n\n")
b.WriteString(normalStyle.Render("To enable on boot, run:"))
b.WriteString("\n")
b.WriteString(normalStyle.Render(" sudo systemctl enable --now greetd"))
b.WriteString("\n\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
b.WriteString(instructionStyle.Render("Press Esc to return to main menu"))
}
return b.String()
}
func (m Model) categorizeDependencies() map[string][]DependencyInfo {
categories := map[string][]DependencyInfo{
"Shell": {},
"Shared Components": {},
"Hyprland Components": {},
"Niri Components": {},
}
excludeList := map[string]bool{
"git": true,
"polkit-agent": true,
"jq": true,
"xdg-desktop-portal": true,
"xdg-desktop-portal-wlr": true,
"xdg-desktop-portal-hyprland": true,
"xdg-desktop-portal-gtk": true,
}
for _, dep := range m.updateDeps {
if excludeList[dep.Name] {
continue
}
switch dep.Name {
case "dms (DankMaterialShell)", "quickshell":
categories["Shell"] = append(categories["Shell"], dep)
case "hyprland", "hyprctl":
categories["Hyprland Components"] = append(categories["Hyprland Components"], dep)
case "niri":
categories["Niri Components"] = append(categories["Niri Components"], dep)
case "kitty", "alacritty", "ghostty":
categories["Shared Components"] = append(categories["Shared Components"], dep)
default:
categories["Shared Components"] = append(categories["Shared Components"], dep)
}
}
return categories
}

View File

@@ -83,6 +83,7 @@ Singleton {
property string nightModeLocationProvider: ""
property var pinnedApps: []
property int dockLauncherPosition: 0
property var hiddenTrayIds: []
property var recentColors: []
property bool showThirdPartyPlugins: false
@@ -757,6 +758,11 @@ Singleton {
saveSettings();
}
function setDockLauncherPosition(position) {
dockLauncherPosition = position;
saveSettings();
}
function addPinnedApp(appId) {
if (!appId)
return;

View File

@@ -147,6 +147,7 @@ Singleton {
property bool use24HourClock: true
property bool showSeconds: false
property bool useFahrenheit: false
property string windSpeedUnit: "kmh"
property bool nightModeEnabled: false
property int animationSpeed: SettingsData.AnimationSpeed.Short
property int customAnimationDuration: 500
@@ -430,6 +431,13 @@ Singleton {
property real dockBorderOpacity: 1.0
property int dockBorderThickness: 1
property bool dockIsolateDisplays: false
property bool dockLauncherEnabled: false
property string dockLauncherLogoMode: "apps"
property string dockLauncherLogoCustomPath: ""
property string dockLauncherLogoColorOverride: ""
property int dockLauncherLogoSizeOffset: 0
property real dockLauncherLogoBrightness: 0.5
property real dockLauncherLogoContrast: 1
property bool notificationOverlayEnabled: false
property int overviewRows: 2

View File

@@ -39,6 +39,7 @@ var SPEC = {
weatherCoordinates: { def: "40.7128,-74.0060" },
pinnedApps: { def: [] },
dockLauncherPosition: { def: 0 },
hiddenTrayIds: { def: [] },
recentColors: { def: [] },
showThirdPartyPlugins: { def: false },

View File

@@ -33,6 +33,7 @@ var SPEC = {
use24HourClock: { def: true },
showSeconds: { def: false },
useFahrenheit: { def: false },
windSpeedUnit: { def: "kmh" },
nightModeEnabled: { def: false },
animationSpeed: { def: 1 },
customAnimationDuration: { def: 500 },
@@ -254,6 +255,13 @@ var SPEC = {
dockBorderOpacity: { def: 1.0, coerce: percentToUnit },
dockBorderThickness: { def: 1 },
dockIsolateDisplays: { def: false },
dockLauncherEnabled: { def: false },
dockLauncherLogoMode: { def: "apps" },
dockLauncherLogoCustomPath: { def: "" },
dockLauncherLogoColorOverride: { def: "" },
dockLauncherLogoSizeOffset: { def: 0 },
dockLauncherLogoBrightness: { def: 0.5, coerce: percentToUnit },
dockLauncherLogoContrast: { def: 1, coerce: percentToUnit },
notificationOverlayEnabled: { def: false },
overviewRows: { def: 2, persist: false },

View File

@@ -76,8 +76,6 @@ FocusScope {
function showContextMenu(item, x, y, fromKeyboard) {
if (!item)
return;
if (item.isCore)
return;
if (!contextMenu.hasContextMenuActions(item))
return;
contextMenu.show(x, y, item, fromKeyboard);

View File

@@ -20,7 +20,7 @@ Popup {
function hasContextMenuActions(spotlightItem) {
if (!spotlightItem)
return false;
if (spotlightItem.type === "app" && !spotlightItem.isCore)
if (spotlightItem.type === "app")
return true;
if (spotlightItem.type === "plugin" && spotlightItem.pluginId) {
var instance = PluginService.pluginInstances[spotlightItem.pluginId];
@@ -34,9 +34,16 @@ Popup {
return false;
}
readonly property var desktopEntry: item?.data ?? null
readonly property string appId: desktopEntry?.id || desktopEntry?.execString || ""
readonly property bool isPinned: SessionData.isPinnedApp(appId)
readonly property bool isCoreApp: item?.type === "app" && item?.isCore
readonly property var coreAppData: isCoreApp ? item?.data ?? null : null
readonly property var desktopEntry: !isCoreApp ? (item?.data ?? null) : null
readonly property string appId: {
if (isCoreApp) {
return item?.id || coreAppData?.builtInPluginId || "";
}
return desktopEntry?.id || desktopEntry?.execString || "";
}
readonly property bool isPinned: appId ? SessionData.isPinnedApp(appId) : false
readonly property bool isRegularApp: item?.type === "app" && !item.isCore && desktopEntry
readonly property bool isPluginItem: item?.type === "plugin"
@@ -82,15 +89,14 @@ Popup {
return items;
}
if (!desktopEntry)
return items;
items.push({
type: "item",
icon: isPinned ? "keep_off" : "push_pin",
text: isPinned ? I18n.tr("Unpin from Dock") : I18n.tr("Pin to Dock"),
action: togglePin
});
if (item?.type === "app") {
items.push({
type: "item",
icon: isPinned ? "keep_off" : "push_pin",
text: isPinned ? I18n.tr("Unpin from Dock") : I18n.tr("Pin to Dock"),
action: togglePin
});
}
if (isRegularApp) {
items.push({
@@ -132,18 +138,6 @@ Popup {
action: launchApp
});
if (SessionService.nvidiaCommand) {
items.push({
type: "separator"
});
items.push({
type: "item",
icon: "memory",
text: I18n.tr("Launch on dGPU"),
action: launchWithNvidia
});
}
return items;
}
@@ -212,6 +206,14 @@ Popup {
}
function launchApp() {
if (isCoreApp) {
if (!coreAppData)
return;
AppSearchService.executeCoreApp(coreAppData);
controller?.itemExecuted();
hide();
return;
}
if (!desktopEntry)
return;
SessionService.launchDesktopEntry(desktopEntry);

View File

@@ -47,10 +47,17 @@ Rectangle {
readonly property var humidity: WeatherService.formatPercent(root.forecastData?.humidity)
readonly property string humidityText: humidity ?? "--"
readonly property var wind: WeatherService.formatSpeed(root.forecastData?.wind)
readonly property var wind: {
SettingsData.windSpeedUnit;
SettingsData.useFahrenheit;
return WeatherService.formatSpeed(root.forecastData?.wind);
}
readonly property string windText: wind ?? "--"
readonly property var pressure: WeatherService.formatPressure(root.forecastData?.pressure)
readonly property var pressure: {
SettingsData.useFahrenheit;
return WeatherService.formatPressure(root.forecastData?.pressure);
}
readonly property string pressureText: pressure ?? "--"
readonly property var precipitation: root.forecastData?.precipitationProbability

View File

@@ -229,10 +229,17 @@ Item {
}
}
StyledText {
property var feelsLike: SettingsData.useFahrenheit ? (WeatherService.weather.feelsLikeF || WeatherService.weather.tempF) : (WeatherService.weather.feelsLike || WeatherService.weather.temp)
text: I18n.tr("Feels Like %1°", "weather feels like temperature").arg(feelsLike)
font.pixelSize: Theme.fontSizeSmall
color: Theme.withAlpha(Theme.surfaceText, 0.7)
}
StyledText {
text: WeatherService.weather.city || ""
font.pixelSize: Theme.fontSizeMedium
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
color: Theme.withAlpha(Theme.surfaceText, 0.7)
visible: text.length > 0
}
}
@@ -253,7 +260,7 @@ Item {
id: sunriseIcon
name: "wb_twilight"
size: Theme.iconSize - 6
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.6)
color: Theme.withAlpha(Theme.surfaceText, 0.6)
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
}
@@ -272,7 +279,7 @@ Item {
}
}
font.pixelSize: Theme.fontSizeSmall
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.6)
color: Theme.withAlpha(Theme.surfaceText, 0.6)
anchors.left: sunriseIcon.right
anchors.leftMargin: Theme.spacingXS
anchors.verticalCenter: parent.verticalCenter

View File

@@ -29,12 +29,29 @@ Item {
property bool showTooltip: mouseArea.containsMouse && !dragging
property var cachedDesktopEntry: null
property real actualIconSize: 40
readonly property string coreIconColorOverride: SettingsData.dockLauncherLogoColorOverride
readonly property bool coreIconHasCustomColor: coreIconColorOverride !== "" && coreIconColorOverride !== "primary" && coreIconColorOverride !== "surface"
readonly property color effectiveCoreIconColor: {
if (coreIconColorOverride === "primary")
return Theme.primary;
if (coreIconColorOverride === "surface")
return Theme.surfaceText;
if (coreIconColorOverride !== "")
return coreIconColorOverride;
return Theme.surfaceText;
}
readonly property real effectiveCoreIconBrightness: coreIconHasCustomColor ? SettingsData.dockLauncherLogoBrightness : 0.0
readonly property real effectiveCoreIconContrast: coreIconHasCustomColor ? SettingsData.dockLauncherLogoContrast : 0.0
function updateDesktopEntry() {
if (!appData || appData.appId === "__SEPARATOR__") {
cachedDesktopEntry = null;
return;
}
if (appData.isCoreApp) {
cachedDesktopEntry = null;
return;
}
const moddedId = Paths.moddedAppId(appData.appId);
cachedDesktopEntry = DesktopEntries.heuristicLookup(moddedId);
}
@@ -85,7 +102,12 @@ Item {
return "";
}
const appName = Paths.getAppName(appData.appId, cachedDesktopEntry);
let appName;
if (appData.isCoreApp && appData.coreAppData) {
appName = appData.coreAppData.name || appData.appId;
} else {
appName = Paths.getAppName(appData.appId, cachedDesktopEntry);
}
if ((appData.type === "window" && showWindowTitle) || (appData.type === "grouped" && appData.windowTitle)) {
const title = appData.type === "window" ? windowTitle : appData.windowTitle;
@@ -208,7 +230,7 @@ Item {
targetIndex = -1;
originalIndex = -1;
if (dockApps && !didReorder) {
if (dockApps) {
dockApps.draggedIndex = -1;
dockApps.dropTargetIndex = -1;
}
@@ -227,6 +249,10 @@ Item {
case "pinned":
if (!appData.appId)
return;
if (appData.isCoreApp && appData.coreAppData) {
AppSearchService.executeCoreApp(appData.coreAppData);
return;
}
const pinnedEntry = cachedDesktopEntry;
if (pinnedEntry) {
AppUsageHistoryData.addAppUsage({
@@ -248,6 +274,10 @@ Item {
if (appData.windowCount === 0) {
if (!appData.appId)
return;
if (appData.isCoreApp && appData.coreAppData) {
AppSearchService.executeCoreApp(appData.coreAppData);
return;
}
const groupedEntry = cachedDesktopEntry;
if (groupedEntry) {
AppUsageHistoryData.addAppUsage({
@@ -374,6 +404,19 @@ Item {
z: -1
}
AppIconRenderer {
id: coreIcon
anchors.centerIn: parent
iconSize: actualIconSize
iconValue: appData && appData.isCoreApp && appData.coreAppData ? (appData.coreAppData.icon || "") : ""
colorOverride: effectiveCoreIconColor
brightnessOverride: effectiveCoreIconBrightness
contrastOverride: effectiveCoreIconContrast
fallbackText: "?"
visible: iconValue !== ""
}
IconImage {
id: iconImg
@@ -383,12 +426,15 @@ Item {
if (!appData || appData.appId === "__SEPARATOR__") {
return "";
}
if (appData.isCoreApp && appData.coreAppData) {
return "";
}
return Paths.getAppIcon(appData.appId, cachedDesktopEntry);
}
mipmap: true
smooth: true
asynchronous: true
visible: status === Image.Ready
visible: status === Image.Ready && !coreIcon.visible
layer.enabled: appData && appData.appId === "org.quickshell"
layer.smooth: true
layer.mipmap: true
@@ -403,7 +449,7 @@ Item {
width: actualIconSize
height: actualIconSize
anchors.centerIn: parent
visible: iconImg.status !== Image.Ready && appData && appData.appId && !Paths.isSteamApp(appData.appId)
visible: !coreIcon.visible && iconImg.status !== Image.Ready && appData && appData.appId && !Paths.isSteamApp(appData.appId)
color: Theme.surfaceLight
radius: Theme.cornerRadius
border.width: 1
@@ -416,7 +462,12 @@ Item {
return "?";
}
const appName = Paths.getAppName(appData.appId, cachedDesktopEntry);
let appName;
if (appData.isCoreApp && appData.coreAppData) {
appName = appData.coreAppData.name || appData.appId;
} else {
appName = Paths.getAppName(appData.appId, cachedDesktopEntry);
}
return appName.charAt(0).toUpperCase();
}
font.pixelSize: Math.max(8, parent.width * 0.35)
@@ -430,7 +481,7 @@ Item {
size: actualIconSize
name: "sports_esports"
color: Theme.surfaceText
visible: iconImg.status !== Image.Ready && appData && appData.appId && Paths.isSteamApp(appData.appId)
visible: !coreIcon.visible && iconImg.status !== Image.Ready && appData && appData.appId && Paths.isSteamApp(appData.appId)
}
Loader {

View File

@@ -22,18 +22,34 @@ Item {
implicitWidth: isVertical ? appLayout.height : appLayout.width
implicitHeight: isVertical ? appLayout.width : appLayout.height
function movePinnedApp(fromIndex, toIndex) {
if (fromIndex === toIndex) {
function dockIndexToPinnedIndex(dockIndex) {
if (!SettingsData.dockLauncherEnabled) {
return dockIndex;
}
const launcherPos = SessionData.dockLauncherPosition;
if (dockIndex < launcherPos) {
return dockIndex;
} else {
return dockIndex - 1;
}
}
function movePinnedApp(fromDockIndex, toDockIndex) {
const fromPinnedIndex = dockIndexToPinnedIndex(fromDockIndex);
const toPinnedIndex = dockIndexToPinnedIndex(toDockIndex);
if (fromPinnedIndex === toPinnedIndex) {
return;
}
const currentPinned = [...(SessionData.pinnedApps || [])];
if (fromIndex < 0 || fromIndex >= currentPinned.length || toIndex < 0 || toIndex >= currentPinned.length) {
if (fromPinnedIndex < 0 || fromPinnedIndex >= currentPinned.length || toPinnedIndex < 0 || toPinnedIndex >= currentPinned.length) {
return;
}
const movedApp = currentPinned.splice(fromIndex, 1)[0];
currentPinned.splice(toIndex, 0, movedApp);
const movedApp = currentPinned.splice(fromPinnedIndex, 1)[0];
currentPinned.splice(toPinnedIndex, 0, movedApp);
SessionData.setPinnedApps(currentPinned);
}
@@ -75,6 +91,51 @@ Item {
return false;
}
function getCoreAppData(appId) {
if (typeof AppSearchService === "undefined")
return null;
const coreApps = AppSearchService.coreApps || [];
for (let i = 0; i < coreApps.length; i++) {
const app = coreApps[i];
if (app.builtInPluginId === appId) {
return app;
}
}
return null;
}
function getCoreAppDataByTitle(windowTitle) {
if (typeof AppSearchService === "undefined" || !windowTitle)
return null;
const coreApps = AppSearchService.coreApps || [];
for (let i = 0; i < coreApps.length; i++) {
const app = coreApps[i];
if (app.name === windowTitle) {
return app;
}
}
return null;
}
function insertLauncher(targetArray) {
if (!SettingsData.dockLauncherEnabled)
return;
const launcherItem = {
uniqueKey: "launcher_button",
type: "launcher",
appId: "__LAUNCHER__",
toplevel: null,
isPinned: true,
isRunning: false
};
const pos = Math.max(0, Math.min(SessionData.dockLauncherPosition, targetArray.length));
targetArray.splice(pos, 0, launcherItem);
}
function updateModel() {
const items = [];
const pinnedApps = [...(SessionData.pinnedApps || [])];
@@ -86,21 +147,35 @@ Item {
pinnedApps.forEach(rawAppId => {
const appId = Paths.moddedAppId(rawAppId);
const coreAppData = getCoreAppData(appId);
appGroups.set(appId, {
appId: appId,
isPinned: true,
windows: []
windows: [],
isCoreApp: coreAppData !== null,
coreAppData: coreAppData
});
});
sortedToplevels.forEach((toplevel, index) => {
const rawAppId = toplevel.appId || "unknown";
const appId = Paths.moddedAppId(rawAppId);
let appId = Paths.moddedAppId(rawAppId);
let coreAppData = null;
if (rawAppId === "org.quickshell") {
coreAppData = getCoreAppDataByTitle(toplevel.title);
if (coreAppData) {
appId = coreAppData.builtInPluginId;
}
}
if (!appGroups.has(appId)) {
appGroups.set(appId, {
appId: appId,
isPinned: false,
windows: []
windows: [],
isCoreApp: coreAppData !== null,
coreAppData: coreAppData
});
}
@@ -124,7 +199,9 @@ Item {
isPinned: group.isPinned,
isRunning: group.windows.length > 0,
windowCount: group.windows.length,
allWindows: group.windows
allWindows: group.windows,
isCoreApp: group.isCoreApp || false,
coreAppData: group.coreAppData || null
};
if (group.isPinned) {
@@ -136,6 +213,8 @@ Item {
pinnedGroups.forEach(item => items.push(item));
insertLauncher(items);
if (pinnedGroups.length > 0 && unpinnedGroups.length > 0) {
items.push({
uniqueKey: "separator_grouped",
@@ -148,21 +227,26 @@ Item {
}
unpinnedGroups.forEach(item => items.push(item));
root.pinnedAppCount = pinnedGroups.length;
root.pinnedAppCount = pinnedGroups.length + (SettingsData.dockLauncherEnabled ? 1 : 0);
} else {
pinnedApps.forEach(rawAppId => {
const appId = Paths.moddedAppId(rawAppId);
const coreAppData = getCoreAppData(appId);
items.push({
uniqueKey: "pinned_" + appId,
type: "pinned",
appId: appId,
toplevel: null,
isPinned: true,
isRunning: false
isRunning: false,
isCoreApp: coreAppData !== null,
coreAppData: coreAppData
});
});
root.pinnedAppCount = pinnedApps.length;
root.pinnedAppCount = pinnedApps.length + (SettingsData.dockLauncherEnabled ? 1 : 0);
insertLauncher(items);
if (pinnedApps.length > 0 && sortedToplevels.length > 0) {
items.push({
@@ -187,13 +271,31 @@ Item {
}
}
const rawAppId = toplevel.appId || "unknown";
const moddedAppId = Paths.moddedAppId(rawAppId);
// Check if this is a core app window (e.g., Settings modal with appId "org.quickshell")
let coreAppData = null;
let isCoreApp = false;
if (rawAppId === "org.quickshell") {
coreAppData = getCoreAppDataByTitle(toplevel.title);
if (coreAppData) {
isCoreApp = true;
}
}
const finalAppId = isCoreApp ? coreAppData.builtInPluginId : moddedAppId;
const isPinned = pinnedApps.indexOf(finalAppId) !== -1;
items.push({
uniqueKey: uniqueKey,
type: "window",
appId: Paths.moddedAppId(toplevel.appId),
appId: finalAppId,
toplevel: toplevel,
isPinned: false,
isRunning: true
isPinned: isPinned,
isRunning: true,
isCoreApp: isCoreApp,
coreAppData: coreAppData
});
});
}
@@ -203,10 +305,10 @@ Item {
delegate: Item {
id: delegateItem
property alias dockButton: button
property var dockButton: itemData.type === "launcher" ? launcherButton : button
property var itemData: modelData
clip: false
z: button.dragging ? 100 : 0
z: (itemData.type === "launcher" ? launcherButton.dragging : button.dragging) ? 100 : 0
width: itemData.type === "separator" ? (root.isVertical ? root.iconSize : 8) : (root.isVertical ? root.iconSize : root.iconSize * 1.2)
height: itemData.type === "separator" ? (root.isVertical ? 8 : root.iconSize) : (root.isVertical ? root.iconSize * 1.2 : root.iconSize)
@@ -261,9 +363,22 @@ Item {
anchors.centerIn: parent
}
DockLauncherButton {
id: launcherButton
visible: itemData.type === "launcher"
anchors.centerIn: parent
width: delegateItem.width
height: delegateItem.height
actualIconSize: root.iconSize
dockApps: root
index: model.index
}
DockAppButton {
id: button
visible: itemData.type !== "separator"
visible: itemData.type !== "separator" && itemData.type !== "launcher"
anchors.centerIn: parent
width: delegateItem.width
@@ -314,5 +429,27 @@ Item {
function onDockIsolateDisplaysChanged() {
repeater.updateModel();
}
function onDockLauncherEnabledChanged() {
root.suppressShiftAnimation = true;
root.draggedIndex = -1;
root.dropTargetIndex = -1;
repeater.updateModel();
Qt.callLater(() => {
root.suppressShiftAnimation = false;
});
}
}
Connections {
target: SessionData
function onDockLauncherPositionChanged() {
root.suppressShiftAnimation = true;
root.draggedIndex = -1;
root.dropTargetIndex = -1;
repeater.updateModel();
Qt.callLater(() => {
root.suppressShiftAnimation = false;
});
}
}
}

View File

@@ -0,0 +1,294 @@
import QtQuick
import QtQuick.Effects
import Quickshell.Widgets
import qs.Common
import qs.Services
import qs.Widgets
Item {
id: root
clip: false
property var dockApps: null
property int index: -1
property bool longPressing: false
property bool dragging: false
property point dragStartPos: Qt.point(0, 0)
property real dragAxisOffset: 0
property int targetIndex: -1
property int originalIndex: -1
property bool isVertical: SettingsData.dockPosition === SettingsData.Position.Left || SettingsData.dockPosition === SettingsData.Position.Right
property bool isHovered: mouseArea.containsMouse && !dragging
property bool showTooltip: mouseArea.containsMouse && !dragging
property real actualIconSize: 40
readonly property string tooltipText: I18n.tr("Applications")
readonly property color effectiveLogoColor: {
const override = SettingsData.dockLauncherLogoColorOverride;
if (override === "primary")
return Theme.primary;
if (override === "surface")
return Theme.surfaceText;
if (override !== "")
return override;
return Theme.surfaceText;
}
onIsHoveredChanged: {
if (mouseArea.pressed || dragging)
return;
if (isHovered) {
exitAnimation.stop();
if (!bounceAnimation.running) {
bounceAnimation.restart();
}
} else {
bounceAnimation.stop();
exitAnimation.restart();
}
}
readonly property bool animateX: SettingsData.dockPosition === SettingsData.Position.Left || SettingsData.dockPosition === SettingsData.Position.Right
readonly property real animationDistance: actualIconSize
readonly property real animationDirection: {
if (SettingsData.dockPosition === SettingsData.Position.Bottom)
return -1;
if (SettingsData.dockPosition === SettingsData.Position.Top)
return 1;
if (SettingsData.dockPosition === SettingsData.Position.Right)
return -1;
if (SettingsData.dockPosition === SettingsData.Position.Left)
return 1;
return -1;
}
SequentialAnimation {
id: bounceAnimation
running: false
NumberAnimation {
target: root
property: "hoverAnimOffset"
to: animationDirection * animationDistance * 0.25
duration: Anims.durShort
easing.type: Easing.BezierSpline
easing.bezierCurve: Anims.emphasizedAccel
}
NumberAnimation {
target: root
property: "hoverAnimOffset"
to: animationDirection * animationDistance * 0.2
duration: Anims.durShort
easing.type: Easing.BezierSpline
easing.bezierCurve: Anims.emphasizedDecel
}
}
NumberAnimation {
id: exitAnimation
running: false
target: root
property: "hoverAnimOffset"
to: 0
duration: Anims.durShort
easing.type: Easing.BezierSpline
easing.bezierCurve: Anims.emphasizedDecel
}
Timer {
id: longPressTimer
interval: 500
repeat: false
onTriggered: {
longPressing = true;
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
enabled: true
preventStealing: true
cursorShape: longPressing ? Qt.DragMoveCursor : Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton
onPressed: mouse => {
if (mouse.button === Qt.LeftButton) {
dragStartPos = Qt.point(mouse.x, mouse.y);
longPressTimer.start();
}
}
onReleased: mouse => {
longPressTimer.stop();
const wasDragging = dragging;
const didReorder = wasDragging && targetIndex >= 0 && dockApps;
if (didReorder) {
SessionData.setDockLauncherPosition(targetIndex);
}
longPressing = false;
dragging = false;
dragAxisOffset = 0;
targetIndex = -1;
originalIndex = -1;
if (dockApps) {
dockApps.draggedIndex = -1;
dockApps.dropTargetIndex = -1;
}
if (wasDragging || mouse.button !== Qt.LeftButton)
return;
PopoutService.toggleDankLauncherV2();
}
onPositionChanged: mouse => {
if (longPressing && !dragging) {
const distance = Math.sqrt(Math.pow(mouse.x - dragStartPos.x, 2) + Math.pow(mouse.y - dragStartPos.y, 2));
if (distance > 5) {
dragging = true;
targetIndex = index;
originalIndex = index;
if (dockApps) {
dockApps.draggedIndex = index;
dockApps.dropTargetIndex = index;
}
}
}
if (!dragging || !dockApps)
return;
const axisOffset = isVertical ? (mouse.y - dragStartPos.y) : (mouse.x - dragStartPos.x);
dragAxisOffset = axisOffset;
const spacing = Math.min(8, Math.max(4, actualIconSize * 0.08));
const itemSize = actualIconSize * 1.2 + spacing;
const slotOffset = Math.round(axisOffset / itemSize);
const newTargetIndex = Math.max(0, Math.min(dockApps.pinnedAppCount, originalIndex + slotOffset));
if (newTargetIndex !== targetIndex) {
targetIndex = newTargetIndex;
dockApps.dropTargetIndex = newTargetIndex;
}
}
}
property real hoverAnimOffset: 0
Item {
id: visualContent
anchors.fill: parent
transform: Translate {
x: dragging && !isVertical ? dragAxisOffset : (!dragging && isVertical ? hoverAnimOffset : 0)
y: dragging && isVertical ? dragAxisOffset : (!dragging && !isVertical ? hoverAnimOffset : 0)
}
Item {
anchors.centerIn: parent
width: actualIconSize
height: actualIconSize
DankIcon {
visible: SettingsData.dockLauncherLogoMode === "apps"
anchors.centerIn: parent
name: "apps"
size: actualIconSize - 4
color: Theme.widgetIconColor
}
SystemLogo {
visible: SettingsData.dockLauncherLogoMode === "os"
anchors.centerIn: parent
width: actualIconSize + SettingsData.dockLauncherLogoSizeOffset
height: actualIconSize + SettingsData.dockLauncherLogoSizeOffset
colorOverride: effectiveLogoColor
brightnessOverride: SettingsData.dockLauncherLogoBrightness
contrastOverride: SettingsData.dockLauncherLogoContrast
}
IconImage {
visible: SettingsData.dockLauncherLogoMode === "dank"
anchors.centerIn: parent
width: actualIconSize + SettingsData.dockLauncherLogoSizeOffset
height: actualIconSize + SettingsData.dockLauncherLogoSizeOffset
smooth: true
mipmap: true
asynchronous: true
source: "file://" + Theme.shellDir + "/assets/danklogo.svg"
layer.enabled: effectiveLogoColor !== ""
layer.smooth: true
layer.mipmap: true
layer.effect: MultiEffect {
saturation: 0
colorization: 1
colorizationColor: effectiveLogoColor
}
}
IconImage {
visible: SettingsData.dockLauncherLogoMode === "compositor" && (CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isDwl || CompositorService.isSway || CompositorService.isScroll || CompositorService.isLabwc)
anchors.centerIn: parent
width: actualIconSize + SettingsData.dockLauncherLogoSizeOffset
height: actualIconSize + SettingsData.dockLauncherLogoSizeOffset
smooth: true
asynchronous: true
source: {
if (CompositorService.isNiri) {
return "file://" + Theme.shellDir + "/assets/niri.svg";
} else if (CompositorService.isHyprland) {
return "file://" + Theme.shellDir + "/assets/hyprland.svg";
} else if (CompositorService.isDwl) {
return "file://" + Theme.shellDir + "/assets/mango.png";
} else if (CompositorService.isSway) {
return "file://" + Theme.shellDir + "/assets/sway.svg";
} else if (CompositorService.isScroll) {
return "file://" + Theme.shellDir + "/assets/sway.svg";
} else if (CompositorService.isLabwc) {
return "file://" + Theme.shellDir + "/assets/labwc.png";
}
return "";
}
layer.enabled: effectiveLogoColor !== ""
layer.effect: MultiEffect {
saturation: 0
colorization: 1
colorizationColor: effectiveLogoColor
brightness: {
SettingsData.dockLauncherLogoBrightness;
}
contrast: {
SettingsData.dockLauncherLogoContrast;
}
}
}
IconImage {
visible: SettingsData.dockLauncherLogoMode === "custom" && SettingsData.dockLauncherLogoCustomPath !== ""
anchors.centerIn: parent
width: actualIconSize + SettingsData.dockLauncherLogoSizeOffset
height: actualIconSize + SettingsData.dockLauncherLogoSizeOffset
smooth: true
asynchronous: true
source: SettingsData.dockLauncherLogoCustomPath ? "file://" + SettingsData.dockLauncherLogoCustomPath.replace("file://", "") : ""
layer.enabled: effectiveLogoColor !== ""
layer.effect: MultiEffect {
saturation: 0
colorization: 1
colorizationColor: effectiveLogoColor
brightness: SettingsData.dockLauncherLogoBrightness
contrast: SettingsData.dockLauncherLogoContrast
}
}
}
}
}

View File

@@ -164,6 +164,265 @@ Item {
}
}
SettingsCard {
width: parent.width
iconName: "apps"
title: I18n.tr("Launcher Button")
settingKey: "dockLauncher"
SettingsToggleRow {
settingKey: "dockLauncherEnabled"
tags: ["dock", "launcher", "button", "apps"]
text: I18n.tr("Show Launcher Button")
description: I18n.tr("Add a draggable launcher button to the dock")
checked: SettingsData.dockLauncherEnabled
onToggled: checked => SettingsData.set("dockLauncherEnabled", checked)
}
Column {
width: parent.width
spacing: Theme.spacingL
visible: SettingsData.dockLauncherEnabled
StyledText {
width: parent.width
text: I18n.tr("Long press and drag the launcher button to reposition it in the dock")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
}
Column {
width: parent.width
spacing: Theme.spacingM
StyledText {
text: I18n.tr("Launcher Icon")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
font.weight: Font.Medium
anchors.horizontalCenter: parent.horizontalCenter
}
Item {
width: parent.width
height: logoModeGroup.implicitHeight
clip: true
DankButtonGroup {
id: logoModeGroup
anchors.horizontalCenter: parent.horizontalCenter
buttonPadding: parent.width < 480 ? Theme.spacingS : Theme.spacingL
minButtonWidth: parent.width < 480 ? 44 : 64
textSize: parent.width < 480 ? Theme.fontSizeSmall : Theme.fontSizeMedium
model: {
const modes = [I18n.tr("Apps Icon"), I18n.tr("OS Logo"), I18n.tr("Dank")];
if (CompositorService.isNiri) {
modes.push("niri");
} else if (CompositorService.isHyprland) {
modes.push("Hyprland");
} else if (CompositorService.isDwl) {
modes.push("mango");
} else if (CompositorService.isSway) {
modes.push("Sway");
} else if (CompositorService.isScroll) {
modes.push("Scroll");
} else {
modes.push(I18n.tr("Compositor"));
}
modes.push(I18n.tr("Custom"));
return modes;
}
currentIndex: {
if (SettingsData.dockLauncherLogoMode === "apps")
return 0;
if (SettingsData.dockLauncherLogoMode === "os")
return 1;
if (SettingsData.dockLauncherLogoMode === "dank")
return 2;
if (SettingsData.dockLauncherLogoMode === "compositor")
return 3;
if (SettingsData.dockLauncherLogoMode === "custom")
return 4;
return 0;
}
onSelectionChanged: (index, selected) => {
if (!selected)
return;
switch (index) {
case 0:
SettingsData.set("dockLauncherLogoMode", "apps");
break;
case 1:
SettingsData.set("dockLauncherLogoMode", "os");
break;
case 2:
SettingsData.set("dockLauncherLogoMode", "dank");
break;
case 3:
SettingsData.set("dockLauncherLogoMode", "compositor");
break;
case 4:
SettingsData.set("dockLauncherLogoMode", "custom");
break;
}
}
}
}
}
Column {
width: parent.width
spacing: Theme.spacingL
visible: SettingsData.dockLauncherLogoMode !== "apps"
Column {
width: parent.width
spacing: Theme.spacingM
StyledText {
text: I18n.tr("Color Override")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
font.weight: Font.Medium
anchors.horizontalCenter: parent.horizontalCenter
}
Item {
width: parent.width
height: colorOverrideRow.implicitHeight
clip: true
Row {
id: colorOverrideRow
anchors.horizontalCenter: parent.horizontalCenter
spacing: Theme.spacingM
DankButtonGroup {
id: colorModeGroup
buttonPadding: parent.parent.width < 480 ? Theme.spacingS : Theme.spacingL
minButtonWidth: parent.parent.width < 480 ? 44 : 64
textSize: parent.parent.width < 480 ? Theme.fontSizeSmall : Theme.fontSizeMedium
model: [I18n.tr("Default"), I18n.tr("Primary"), I18n.tr("Surface"), I18n.tr("Custom")]
currentIndex: {
const override = SettingsData.dockLauncherLogoColorOverride;
if (override === "")
return 0;
if (override === "primary")
return 1;
if (override === "surface")
return 2;
return 3;
}
onSelectionChanged: (index, selected) => {
if (!selected)
return;
switch (index) {
case 0:
SettingsData.set("dockLauncherLogoColorOverride", "");
break;
case 1:
SettingsData.set("dockLauncherLogoColorOverride", "primary");
break;
case 2:
SettingsData.set("dockLauncherLogoColorOverride", "surface");
break;
case 3:
const currentOverride = SettingsData.dockLauncherLogoColorOverride;
const isPreset = currentOverride === "" || currentOverride === "primary" || currentOverride === "surface";
if (isPreset) {
SettingsData.set("dockLauncherLogoColorOverride", "#ffffff");
}
break;
}
}
}
Rectangle {
id: colorPickerCircle
visible: {
const override = SettingsData.dockLauncherLogoColorOverride;
return override !== "" && override !== "primary" && override !== "surface";
}
width: 36
height: 36
radius: 18
color: {
const override = SettingsData.dockLauncherLogoColorOverride;
if (override !== "" && override !== "primary" && override !== "surface")
return override;
return "#ffffff";
}
border.color: Theme.outline
border.width: 1
anchors.verticalCenter: parent.verticalCenter
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
if (!PopoutService.colorPickerModal)
return;
PopoutService.colorPickerModal.selectedColor = SettingsData.dockLauncherLogoColorOverride;
PopoutService.colorPickerModal.pickerTitle = I18n.tr("Choose Dock Launcher Logo Color");
PopoutService.colorPickerModal.onColorSelectedCallback = function (selectedColor) {
SettingsData.set("dockLauncherLogoColorOverride", selectedColor);
};
PopoutService.colorPickerModal.show();
}
}
}
}
}
}
SettingsSliderRow {
settingKey: "dockLauncherLogoSizeOffset"
tags: ["dock", "launcher", "logo", "size", "offset", "scale"]
text: I18n.tr("Size Offset")
minimum: -12
maximum: 12
value: SettingsData.dockLauncherLogoSizeOffset
defaultValue: 0
onSliderValueChanged: newValue => SettingsData.set("dockLauncherLogoSizeOffset", newValue)
}
Column {
width: parent.width
spacing: Theme.spacingM
visible: {
const override = SettingsData.dockLauncherLogoColorOverride;
return override !== "" && override !== "primary" && override !== "surface";
}
SettingsSliderRow {
settingKey: "dockLauncherLogoBrightness"
tags: ["dock", "launcher", "logo", "brightness", "color"]
text: I18n.tr("Brightness")
minimum: 0
maximum: 100
value: Math.round(SettingsData.dockLauncherLogoBrightness * 100)
unit: "%"
defaultValue: 50
onSliderValueChanged: newValue => SettingsData.set("dockLauncherLogoBrightness", newValue / 100)
}
SettingsSliderRow {
settingKey: "dockLauncherLogoContrast"
tags: ["dock", "launcher", "logo", "contrast", "color"]
text: I18n.tr("Contrast")
minimum: 0
maximum: 200
value: Math.round(SettingsData.dockLauncherLogoContrast * 100)
unit: "%"
defaultValue: 100
onSliderValueChanged: newValue => SettingsData.set("dockLauncherLogoContrast", newValue / 100)
}
}
}
}
}
SettingsCard {
width: parent.width
iconName: "photo_size_select_large"

View File

@@ -259,7 +259,8 @@ Item {
minButtonWidth: parent.width < 420 ? 44 : 64
textSize: parent.width < 420 ? Theme.fontSizeSmall : Theme.fontSizeMedium
property bool isRegistryTheme: Theme.currentThemeCategory === "registry"
property int currentThemeIndex: {
property int pendingIndex: -1
property int computedIndex: {
if (isRegistryTheme)
return 3;
if (Theme.currentTheme === Theme.dynamic)
@@ -268,20 +269,21 @@ Item {
return 2;
return 0;
}
property int pendingThemeIndex: -1
model: DMSService.dmsAvailable ? ["Generic", "Auto", "Custom", "Browse"] : ["Generic", "Auto", "Custom"]
currentIndex: currentThemeIndex
currentIndex: pendingIndex >= 0 ? pendingIndex : computedIndex
selectionMode: "single"
onSelectionChanged: (index, selected) => {
if (!selected)
return;
pendingThemeIndex = index;
pendingIndex = index;
}
onAnimationCompleted: {
if (pendingThemeIndex === -1)
if (pendingIndex < 0)
return;
switch (pendingThemeIndex) {
const idx = pendingIndex;
pendingIndex = -1;
switch (idx) {
case 0:
Theme.switchThemeCategory("generic", "blue");
break;
@@ -300,7 +302,6 @@ Item {
Theme.switchThemeCategory("registry", "");
break;
}
pendingThemeIndex = -1;
}
}
}
@@ -809,13 +810,21 @@ Item {
buttonPadding: parent.width < 400 ? Theme.spacingS : Theme.spacingL
minButtonWidth: parent.width < 400 ? 44 : 64
textSize: parent.width < 400 ? Theme.fontSizeSmall : Theme.fontSizeMedium
property int pendingIndex: -1
model: variantSelector.flavorNames
currentIndex: variantSelector.flavorIndex
currentIndex: pendingIndex >= 0 ? pendingIndex : variantSelector.flavorIndex
selectionMode: "single"
onAnimationCompleted: {
if (currentIndex < 0 || currentIndex >= variantSelector.flavorOptions.length)
onSelectionChanged: (index, selected) => {
if (!selected)
return;
const flavorId = variantSelector.flavorOptions[currentIndex]?.id;
pendingIndex = index;
}
onAnimationCompleted: {
if (pendingIndex < 0 || pendingIndex >= variantSelector.flavorOptions.length)
return;
const flavorId = variantSelector.flavorOptions[pendingIndex]?.id;
const idx = pendingIndex;
pendingIndex = -1;
if (!flavorId || flavorId === variantSelector.selectedFlavor)
return;
Theme.screenTransition();
@@ -909,13 +918,21 @@ Item {
buttonPadding: parent.width < 400 ? Theme.spacingS : Theme.spacingL
minButtonWidth: parent.width < 400 ? 44 : 64
textSize: parent.width < 400 ? Theme.fontSizeSmall : Theme.fontSizeMedium
property int pendingIndex: -1
model: variantSelector.variantNames
currentIndex: variantSelector.selectedIndex
currentIndex: pendingIndex >= 0 ? pendingIndex : variantSelector.selectedIndex
selectionMode: "single"
onAnimationCompleted: {
if (currentIndex < 0 || !variantSelector.activeThemeVariants?.options)
onSelectionChanged: (index, selected) => {
if (!selected)
return;
const variantId = variantSelector.activeThemeVariants.options[currentIndex]?.id;
pendingIndex = index;
}
onAnimationCompleted: {
if (pendingIndex < 0 || !variantSelector.activeThemeVariants?.options)
return;
const variantId = variantSelector.activeThemeVariants.options[pendingIndex]?.id;
const idx = pendingIndex;
pendingIndex = -1;
if (!variantId || variantId === variantSelector.selectedVariant)
return;
Theme.screenTransition();

View File

@@ -363,6 +363,25 @@ Item {
onToggled: checked => SettingsData.set("useFahrenheit", checked)
}
Rectangle {
width: parent.width
height: 1
color: Theme.outline
opacity: 0.15
visible: !SettingsData.useFahrenheit
}
SettingsToggleRow {
tab: "time"
tags: ["weather", "wind", "speed", "units", "metric"]
settingKey: "windSpeedUnit"
text: I18n.tr("Wind Speed in m/s")
description: I18n.tr("Use meters per second instead of km/h for wind speed")
checked: SettingsData.windSpeedUnit === "ms"
onToggled: checked => SettingsData.set("windSpeedUnit", checked ? "ms" : "kmh")
visible: !SettingsData.useFahrenheit
}
Rectangle {
width: parent.width
height: 1
@@ -689,6 +708,13 @@ Item {
}
}
StyledText {
property var feelsLike: SettingsData.useFahrenheit ? (WeatherService.weather.feelsLikeF || WeatherService.weather.tempF) : (WeatherService.weather.feelsLike || WeatherService.weather.temp)
text: I18n.tr("Feels Like %1°").arg(feelsLike)
font.pixelSize: Theme.fontSizeSmall
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.5)
}
StyledText {
text: WeatherService.weather.city || ""
font.pixelSize: Theme.fontSizeMedium
@@ -891,20 +917,24 @@ Item {
anchors.horizontalCenter: parent.horizontalCenter
}
StyledText {
id: windText
text: {
if (!WeatherService.weather.wind)
return "--";
const windKmh = parseFloat(WeatherService.weather.wind);
if (isNaN(windKmh))
return WeatherService.weather.wind;
if (SettingsData.useFahrenheit)
return Math.round(windKmh * 0.621371) + " mph";
return WeatherService.weather.wind;
SettingsData.windSpeedUnit;
SettingsData.useFahrenheit;
return WeatherService.formatSpeed(WeatherService.weather.wind) || "--";
}
font.pixelSize: Theme.fontSizeSmall + 1
color: Theme.surfaceText
font.weight: Font.Medium
anchors.horizontalCenter: parent.horizontalCenter
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: SettingsData.useFahrenheit ? Qt.ArrowCursor : Qt.PointingHandCursor
enabled: !SettingsData.useFahrenheit
onClicked: SettingsData.set("windSpeedUnit", SettingsData.windSpeedUnit === "kmh" ? "ms" : "kmh")
}
}
}
}

View File

@@ -318,10 +318,10 @@ Singleton {
PopoutService.focusOrToggleSettings();
return true;
case "notepad":
PopoutService.openNotepad();
PopoutService.toggleNotepad();
return true;
case "processlist":
PopoutService.showProcessListModal();
PopoutService.toggleProcessList();
return true;
}
return false;

View File

@@ -354,9 +354,15 @@ Singleton {
if (kmh == null) {
return null;
}
const value = SettingsData.useFahrenheit ? Math.round(kmh * 0.621371) : kmh;
const unit = SettingsData.useFahrenheit ? "mph" : "km/h";
return includeUnits ? value + " " + unit : value;
if (SettingsData.useFahrenheit) {
const value = Math.round(kmh * 0.621371);
return includeUnits ? value + " mph" : value;
}
if (SettingsData.windSpeedUnit === "ms") {
const value = (kmh / 3.6).toFixed(1);
return includeUnits ? value + " m/s" : value;
}
return includeUnits ? kmh + " km/h" : kmh;
}
function formatPressure(hpa, includeUnits = true) {
@@ -805,7 +811,7 @@ Singleton {
"country": root.location?.country || "Unknown",
"wCode": current.weather_code || 0,
"humidity": Math.round(current.relative_humidity_2m || 0),
"wind": Math.round(current.wind_speed_10m || 0) + " " + (currentUnits.wind_speed_10m || 'm/s'),
"wind": Math.round(current.wind_speed_10m || 0),
"sunrise": formatTime(daily.sunrise?.[0]) || "06:00",
"sunset": formatTime(daily.sunset?.[0]) || "18:00",
"rawSunrise": daily.sunrise?.[0] || "",

View File

@@ -11,6 +11,10 @@ Item {
required property int iconSize
property string fallbackText: "A"
property color iconColor: Theme.surfaceText
property color colorOverride: "transparent"
property real brightnessOverride: 0.0
property real contrastOverride: 0.0
property real saturationOverride: 0.0
property color fallbackBackgroundColor: Theme.surfaceLight
property color fallbackTextColor: Theme.primary
property real materialIconSizeAdjustment: Theme.spacingM
@@ -27,6 +31,7 @@ Item {
readonly property bool isSvgCorner: iconValue.startsWith("svg+corner:")
readonly property bool isSvg: !isSvgCorner && iconValue.startsWith("svg:")
readonly property bool isImage: iconValue.startsWith("image:")
readonly property bool hasColorOverride: colorOverride.a > 0
readonly property string materialName: isMaterial ? iconValue.substring(9) : ""
readonly property string unicodeChar: isUnicode ? iconValue.substring(8) : ""
readonly property string imagePath: isImage ? iconValue.substring(6) : ""
@@ -48,7 +53,7 @@ Item {
anchors.centerIn: parent
name: root.materialName
size: root.iconSize - root.materialIconSizeAdjustment
color: root.iconColor
color: root.hasColorOverride ? root.colorOverride : root.iconColor
visible: root.isMaterial
}
@@ -56,7 +61,7 @@ Item {
anchors.centerIn: parent
text: root.unicodeChar
font.pixelSize: root.iconSize * root.unicodeIconScale
color: root.iconColor
color: root.hasColorOverride ? root.colorOverride : root.iconColor
visible: root.isUnicode
}
@@ -65,6 +70,10 @@ Item {
source: root.svgSource
size: root.iconSize
cornerIcon: root.svgCornerIcon
colorOverride: root.colorOverride
brightnessOverride: root.brightnessOverride
contrastOverride: root.contrastOverride
saturationOverride: root.saturationOverride
visible: root.isSvg || root.isSvgCorner
}