1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-06-17 08:35:21 -04:00

Compare commits

..

6 Commits

Author SHA1 Message Date
purian23 aed731efb0 fix(clipboard): restore Save button targets in editor 2026-05-25 23:19:42 -04:00
purian23 cf0632c077 feat(Clipboard): Revive ClipboardEditor PR
- Original PR #1916 by @nabaco
2026-05-24 23:28:21 -04:00
Nachum Barcohen e92da4a15f Show full clipboard text in editor 2026-05-24 22:34:24 -04:00
Nachum Barcohen 8abdff3220 Add clipboard editor shortcuts and hints 2026-05-24 22:34:24 -04:00
Nachum Barcohen 584d57a8de Add split save menu for clipboard editor 2026-05-24 22:34:05 -04:00
Nachum Barcohen afb5e59c29 feat(clipboard): Add editing capability to clipboard entries 2026-05-24 22:34:05 -04:00
235 changed files with 8262 additions and 32678 deletions
-31
View File
@@ -1,31 +0,0 @@
## Description
<!-- What does this PR do and why? -->
## Type of change
<!-- Check all that apply. -->
- [ ] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that changes existing behavior)
- [ ] Refactor / internal cleanup
- [ ] Documentation
- [ ] Other
## Related issues
<!-- e.g. "Fixes #123", "Closes #123". Leave blank if none. -->
## Screenshots / video
<!-- Include screenshots or a video for any user-facing or visual change. -->
## Checklist
- [ ] My code follows the conventions in CONTRIBUTING.md
- [ ] I have tested my changes locally
- [ ] New user-facing strings are wrapped in `I18n.tr()` with translator context, reusing existing terms where possible
- [ ] Go changes: ran `make fmt`, added/updated tests, `make test` passes, and `go mod tidy` is clean
- [ ] QML changes: ran `make lint-qml` with no new warnings
- [ ] I have opened a corresponding pull request in dlx-docs to document any new behaviors: https://github.com/AvengeMedia/DankLinux-Docs
+1 -1
View File
@@ -26,4 +26,4 @@ jobs:
go-version-file: core/go.mod
- name: run pre-commit hooks
uses: j178/prek-action@v2
uses: j178/prek-action@v1
+2 -2
View File
@@ -42,7 +42,7 @@ configure passwordless sudo for your user.`,
}
func init() {
rootCmd.Flags().StringVarP(&compositor, "compositor", "c", "", "Compositor/WM to install: niri, hyprland, or mango (enables headless mode)")
rootCmd.Flags().StringVarP(&compositor, "compositor", "c", "", "Compositor/WM to install: niri or hyprland (enables headless mode)")
rootCmd.Flags().StringVarP(&term, "term", "t", "", "Terminal emulator to install: ghostty, kitty, or alacritty (enables headless mode)")
rootCmd.Flags().StringSliceVar(&includeDeps, "include-deps", []string{}, "Optional deps to enable (e.g. dms-greeter)")
rootCmd.Flags().StringSliceVar(&excludeDeps, "exclude-deps", []string{}, "Deps to skip during installation")
@@ -95,7 +95,7 @@ func runDankinstall(cmd *cobra.Command, args []string) error {
func runHeadless() error {
// Validate required flags
if compositor == "" {
return fmt.Errorf("--compositor is required for headless mode (niri, hyprland, or mango)")
return fmt.Errorf("--compositor is required for headless mode (niri or hyprland)")
}
if term == "" {
return fmt.Errorf("--term is required for headless mode (ghostty, kitty, or alacritty)")
+12 -11
View File
@@ -56,8 +56,6 @@ func init() {
type IncludeResult struct {
Exists bool `json:"exists"`
Included bool `json:"included"`
ConfigFormat string `json:"configFormat,omitempty"`
ReadOnly bool `json:"readOnly,omitempty"`
}
func runResolveInclude(cmd *cobra.Command, args []string) {
@@ -87,7 +85,10 @@ func runResolveInclude(cmd *cobra.Command, args []string) {
}
func checkHyprlandInclude(filename string) (IncludeResult, error) {
configDir := filepath.Join(utils.XDGConfigHome(), "hypr")
configDir, err := utils.ExpandPath("$HOME/.config/hypr")
if err != nil {
return IncludeResult{}, err
}
targetPath := filepath.Join(configDir, "dms", filename)
result := IncludeResult{}
@@ -105,8 +106,6 @@ func checkHyprlandInclude(filename string) (IncludeResult, error) {
mainLua := filepath.Join(configDir, "hyprland.lua")
if _, err := os.Stat(mainLua); err == nil {
result.ConfigFormat = "lua"
result.ReadOnly = false
processedLua := make(map[string]bool)
if luaconfig.RequiresTarget(mainLua, targetAbs, processedLua) {
result.Included = true
@@ -116,10 +115,6 @@ func checkHyprlandInclude(filename string) (IncludeResult, error) {
mainConf := filepath.Join(configDir, "hyprland.conf")
if _, err := os.Stat(mainConf); err == nil {
if result.ConfigFormat == "" {
result.ConfigFormat = "hyprlang"
result.ReadOnly = true
}
processed := make(map[string]bool)
if hyprlandFindIncludeHyprlang(mainConf, targetRel, processed) {
result.Included = true
@@ -188,7 +183,10 @@ func hyprlandFindIncludeHyprlang(filePath, target string, processed map[string]b
}
func checkNiriInclude(filename string) (IncludeResult, error) {
configDir := filepath.Join(utils.XDGConfigHome(), "niri")
configDir, err := utils.ExpandPath("$HOME/.config/niri")
if err != nil {
return IncludeResult{}, err
}
targetPath := filepath.Join(configDir, "dms", filename)
result := IncludeResult{}
@@ -264,7 +262,10 @@ func niriFindInclude(filePath, target string, processed map[string]bool) bool {
}
func checkMangoWCInclude(filename string) (IncludeResult, error) {
configDir := filepath.Join(utils.XDGConfigHome(), "mango")
configDir, err := utils.ExpandPath("$HOME/.config/mango")
if err != nil {
return IncludeResult{}, err
}
targetPath := filepath.Join(configDir, "dms", filename)
result := IncludeResult{}
+1 -4
View File
@@ -947,12 +947,9 @@ func checkSystemdServices() []checkResult {
message = fmt.Sprintf("%s, %s", dmsState.enabled, dmsState.active)
}
switch {
case dmsState.active == "failed":
status = statusError
case dmsState.active == "active":
case dmsState.enabled == "disabled":
status, message = statusWarn, "Disabled"
case dmsState.active == "inactive":
case dmsState.active == "failed" || dmsState.active == "inactive":
status = statusError
}
results = append(results, checkResult{catServices, "dms.service", status, message, "", doctorDocsURL + "#services"})
+9 -111
View File
@@ -3,7 +3,6 @@ package main
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
@@ -62,34 +61,20 @@ var greeterInstallCmd = &cobra.Command{
var greeterSyncCmd = &cobra.Command{
Use: "sync",
Short: "Sync DMS theme and settings with greeter",
Long: "Synchronize your current user's DMS theme, settings, and wallpaper configuration with the login greeter screen. Also updates a per-user cache slot at users/<username>/ for multi-account greeter theme preview.\n\nUse --profile on secondary accounts to sync only your own users/<username>/ slot without sudo or greetd changes.",
PreRunE: func(cmd *cobra.Command, args []string) error {
profile, _ := cmd.Flags().GetBool("profile")
if profile {
return nil
}
return preRunPrivileged(cmd, args)
},
Long: "Synchronize your current user's DMS theme, settings, and wallpaper configuration with the login greeter screen",
PreRunE: preRunPrivileged,
Run: func(cmd *cobra.Command, args []string) {
yes, _ := cmd.Flags().GetBool("yes")
auth, _ := cmd.Flags().GetBool("auth")
local, _ := cmd.Flags().GetBool("local")
profile, _ := cmd.Flags().GetBool("profile")
autologinOnly, _ := cmd.Flags().GetBool("autologin-only")
term, _ := cmd.Flags().GetBool("terminal")
if term {
if err := syncInTerminal(yes, auth, local, profile, autologinOnly); err != nil {
if err := syncInTerminal(yes, auth, local); err != nil {
log.Fatalf("Error launching sync in terminal: %v", err)
}
return
}
if autologinOnly {
if err := syncGreeterAutoLoginOnly(yes); err != nil {
log.Fatalf("Error syncing greeter auto-login: %v", err)
}
return
}
if err := syncGreeter(yes, auth, local, profile); err != nil {
if err := syncGreeter(yes, auth, local); err != nil {
log.Fatalf("Error syncing greeter: %v", err)
}
},
@@ -100,8 +85,6 @@ func init() {
greeterSyncCmd.Flags().BoolP("terminal", "t", false, "Run sync in a new terminal (for entering sudo password); terminal auto-closes when done")
greeterSyncCmd.Flags().BoolP("auth", "a", false, "Configure PAM for fingerprint and U2F (adds both if modules exist); overrides UI toggles")
greeterSyncCmd.Flags().BoolP("local", "l", false, "Developer mode: force greetd config to use a local DMS checkout path")
greeterSyncCmd.Flags().BoolP("profile", "p", false, "Sync only your per-user greeter slot (no sudo; for secondary accounts)")
greeterSyncCmd.Flags().Bool("autologin-only", false, "Apply only greeter auto-login on startup settings to greetd (no theme or auth sync)")
}
var greeterEnableCmd = &cobra.Command{
@@ -529,8 +512,8 @@ func runCommandInTerminal(shellCmd string) error {
return fmt.Errorf("no terminal emulator found (tried: gnome-terminal, konsole, xfce4-terminal, ghostty, wezterm, alacritty, kitty, xterm)")
}
func syncInTerminal(nonInteractive bool, forceAuth bool, local bool, profileOnly bool, autologinOnly bool) error {
syncFlags := make([]string, 0, 5)
func syncInTerminal(nonInteractive bool, forceAuth bool, local bool) error {
syncFlags := make([]string, 0, 3)
if nonInteractive {
syncFlags = append(syncFlags, "--yes")
}
@@ -540,22 +523,11 @@ func syncInTerminal(nonInteractive bool, forceAuth bool, local bool, profileOnly
if local {
syncFlags = append(syncFlags, "--local")
}
if profileOnly {
syncFlags = append(syncFlags, "--profile")
}
if autologinOnly {
syncFlags = append(syncFlags, "--autologin-only")
}
shellSyncCmd := "dms greeter sync"
if len(syncFlags) > 0 {
shellSyncCmd += " " + strings.Join(syncFlags, " ")
}
var shellCmd string
if autologinOnly {
shellCmd = shellSyncCmd + `; echo; echo "Auto-login update finished. Closing in 3 seconds..."; sleep 3`
} else {
shellCmd = shellSyncCmd + `; echo; echo "Sync finished. Closing in 3 seconds..."; sleep 3`
}
shellCmd := shellSyncCmd + `; echo; echo "Sync finished. Closing in 3 seconds..."; sleep 3`
return runCommandInTerminal(shellCmd)
}
@@ -569,54 +541,7 @@ func resolveLocalWrapperShell() (string, error) {
return "", fmt.Errorf("could not find bash or sh in PATH for local greeter wrapper")
}
func syncGreeterAutoLoginOnly(nonInteractive bool) error {
logFunc := func(msg string) {
fmt.Println(msg)
}
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err)
}
settingsPath := filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json")
cacheSettingsPath := filepath.Join(greeter.GreeterCacheDir, "settings.json")
enabled := false
for _, path := range []string{cacheSettingsPath, settingsPath} {
data, readErr := os.ReadFile(path)
if readErr != nil {
continue
}
var cfg struct {
GreeterAutoLogin bool `json:"greeterAutoLogin"`
}
if json.Unmarshal(data, &cfg) == nil {
enabled = cfg.GreeterAutoLogin
break
}
}
fmt.Println("=== Greeter Auto-Login ===")
fmt.Println()
if enabled {
fmt.Println("Enabling auto-login on startup in greetd.")
fmt.Println("After your next reboot, DMS will skip the greeter password until you sign out.")
} else {
fmt.Println("Disabling auto-login on startup in greetd.")
fmt.Println("After your next reboot, you will enter your password at the greeter again.")
}
fmt.Println()
fmt.Println("Administrator (sudo) access is required to update /etc/greetd/config.toml.")
fmt.Println()
return greeter.SyncGreeterAutoLoginOnly(logFunc, "")
}
func syncGreeter(nonInteractive bool, forceAuth bool, local bool, profileOnly bool) error {
if profileOnly {
return syncGreeterProfileOnly(nonInteractive)
}
func syncGreeter(nonInteractive bool, forceAuth bool, local bool) error {
if !nonInteractive {
fmt.Println("=== DMS Greeter Sync ===")
fmt.Println()
@@ -827,26 +752,6 @@ func syncGreeter(nonInteractive bool, forceAuth bool, local bool, profileOnly bo
return nil
}
func syncGreeterProfileOnly(nonInteractive bool) error {
logFunc := func(msg string) {
fmt.Println(msg)
}
if !nonInteractive {
fmt.Println("=== DMS Greeter Profile Sync ===")
fmt.Println()
fmt.Println("Syncing your personal greeter theme slot (no system changes)...")
}
if err := greeter.SyncUserProfileCache(logFunc); err != nil {
return err
}
if !nonInteractive {
fmt.Println("\n=== Profile Sync Complete ===")
fmt.Println("\nYour theme, wallpaper, and profile photo have been synced for the login screen.")
fmt.Println("Log out to preview your greeter look when selecting your account.")
}
return nil
}
func hasDmsShellQml(dir string) bool {
info, err := os.Stat(filepath.Join(dir, "shell.qml"))
return err == nil && !info.IsDir()
@@ -932,14 +837,7 @@ func resolveLocalDMSPath() (string, error) {
}
}
configuredCommand := readDefaultSessionCommand("/etc/greetd/config.toml")
if pathOverride := extractGreeterPathOverrideFromCommand(configuredCommand); pathOverride != "" {
if resolved, ok := resolveDMSLocalCandidate(pathOverride); ok {
return resolved, nil
}
}
return "", fmt.Errorf("could not locate a local DMS checkout from %s; run from repo root, set DMS_LOCAL_PATH=/absolute/path/to/repo, or configure greetd with -p /path/to/quickshell", wd)
return "", fmt.Errorf("could not locate a local DMS checkout from %s; run from repo root or set DMS_LOCAL_PATH=/absolute/path/to/repo", wd)
}
func disableDisplayManager(dmName string) (bool, error) {
+2 -34
View File
@@ -4,9 +4,7 @@ import (
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
@@ -181,39 +179,9 @@ func getScreenshotConfig(mode screenshot.Mode) screenshot.Config {
return config
}
// setPopoutScreenshotMode toggles the shell handshake so popouts drop their keyboard grab during region select. Best-effort.
func setPopoutScreenshotMode(begin bool) {
fn := "end"
if begin {
fn = "begin"
}
cmdArgs := []string{"ipc"}
if pid, ok := getFirstDMSPID(); ok {
cmdArgs = append(cmdArgs, "--pid", strconv.Itoa(pid))
} else {
if err := findConfig(nil, nil); err != nil {
return
}
if qsHasAnyDisplay() {
cmdArgs = append(cmdArgs, "--any-display")
}
cmdArgs = append(cmdArgs, "-p", configPath)
}
cmdArgs = append(cmdArgs, "call", "screenshot", fn)
_ = exec.Command("qs", cmdArgs...).Run()
}
func runScreenshot(config screenshot.Config) {
// Region select needs the keyboard; drop popout grabs for its duration.
result, err := func() (*screenshot.CaptureResult, error) {
interactive := config.Mode == screenshot.ModeRegion || config.Mode == screenshot.ModeLastRegion
if interactive {
setPopoutScreenshotMode(true)
defer setPopoutScreenshotMode(false)
}
return screenshot.New(config).Run()
}()
sc := screenshot.New(config)
result, err := sc.Run()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
+5 -34
View File
@@ -102,42 +102,32 @@ var setupWindowrulesCmd = &cobra.Command{
type dmsConfigSpec struct {
niriFile string
hyprFile string
mangoFile string
niriContent func(terminal string) string
hyprContent func(terminal string) string
mangoContent func(terminal string) string
}
var dmsConfigSpecs = map[string]dmsConfigSpec{
"binds": {
niriFile: "binds.kdl",
hyprFile: "binds.lua",
mangoFile: "binds.conf",
niriContent: func(t string) string {
return strings.ReplaceAll(config.NiriBindsConfig, "{{TERMINAL_COMMAND}}", t)
},
hyprContent: func(t string) string {
return strings.ReplaceAll(config.DMSBindsLuaConfig, "{{TERMINAL_COMMAND}}", t)
},
mangoContent: func(t string) string {
return strings.ReplaceAll(config.MangoBindsConfig, "{{TERMINAL_COMMAND}}", t)
},
},
"layout": {
niriFile: "layout.kdl",
hyprFile: "layout.lua",
mangoFile: "layout.conf",
niriContent: func(_ string) string { return config.NiriLayoutConfig },
hyprContent: func(_ string) string { return config.DMSLayoutLuaConfig },
mangoContent: func(_ string) string { return config.MangoLayoutConfig },
},
"colors": {
niriFile: "colors.kdl",
hyprFile: "colors.lua",
mangoFile: "colors.conf",
niriContent: func(_ string) string { return config.NiriColorsConfig },
hyprContent: func(_ string) string { return config.DMSColorsLuaConfig },
mangoContent: func(_ string) string { return config.MangoColorsConfig },
},
"alttab": {
niriFile: "alttab.kdl",
@@ -146,26 +136,20 @@ var dmsConfigSpecs = map[string]dmsConfigSpec{
"outputs": {
niriFile: "outputs.kdl",
hyprFile: "outputs.lua",
mangoFile: "outputs.conf",
niriContent: func(_ string) string { return "" },
hyprContent: func(_ string) string { return config.DMSOutputsLuaConfig },
mangoContent: func(_ string) string { return "" },
},
"cursor": {
niriFile: "cursor.kdl",
hyprFile: "cursor.lua",
mangoFile: "cursor.conf",
niriContent: func(_ string) string { return "" },
hyprContent: func(_ string) string { return config.DMSCursorLuaConfig },
mangoContent: func(_ string) string { return "" },
},
"windowrules": {
niriFile: "windowrules.kdl",
hyprFile: "windowrules.lua",
mangoFile: "windowrules.conf",
niriContent: func(_ string) string { return "" },
hyprContent: func(_ string) string { return config.DMSWindowRulesLuaConfig },
mangoContent: func(_ string) string { return "" },
},
}
@@ -208,7 +192,7 @@ func detectCompositorForSetup() (string, error) {
switch len(compositors) {
case 0:
return "", fmt.Errorf("no supported compositors found (niri, Hyprland, or mango required)")
return "", fmt.Errorf("no supported compositors found (niri or Hyprland required)")
case 1:
return strings.ToLower(compositors[0]), nil
}
@@ -240,9 +224,6 @@ func runSetupDmsConfig(name string) error {
case "hyprland":
filename = spec.hyprFile
contentFn = spec.hyprContent
case "mango", "mangowc":
filename = spec.mangoFile
contentFn = spec.mangoContent
default:
return fmt.Errorf("unsupported compositor: %s", compositor)
}
@@ -254,11 +235,9 @@ func runSetupDmsConfig(name string) error {
var dmsDir string
switch compositor {
case "niri":
dmsDir = filepath.Join(utils.XDGConfigHome(), "niri", "dms")
dmsDir = filepath.Join(os.Getenv("HOME"), ".config", "niri", "dms")
case "hyprland":
dmsDir = filepath.Join(utils.XDGConfigHome(), "hypr", "dms")
case "mango", "mangowc":
dmsDir = filepath.Join(utils.XDGConfigHome(), "mango", "dms")
dmsDir = filepath.Join(os.Getenv("HOME"), ".config", "hypr", "dms")
}
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
@@ -400,11 +379,10 @@ func promptCompositor() (deps.WindowManager, bool) {
fmt.Println("Select compositor:")
fmt.Println("1) Niri")
fmt.Println("2) Hyprland")
fmt.Println("3) Mango")
fmt.Println("4) None")
fmt.Println("3) None")
var response string
fmt.Print("\nChoice (1-4): ")
fmt.Print("\nChoice (1-3): ")
fmt.Scanln(&response)
response = strings.TrimSpace(response)
@@ -413,8 +391,6 @@ func promptCompositor() (deps.WindowManager, bool) {
return deps.WindowManagerNiri, true
case "2":
return deps.WindowManagerHyprland, true
case "3":
return deps.WindowManagerMango, true
default:
return deps.WindowManagerNiri, false
}
@@ -471,11 +447,6 @@ func checkExistingConfigs(wm deps.WindowManager, wmSelected bool, terminal deps.
filepath.Join(homeDir, ".config", "hypr", "hyprland.lua"),
filepath.Join(homeDir, ".config", "hypr", "hyprland.conf"),
}
case deps.WindowManagerMango:
configPaths = []string{
filepath.Join(homeDir, ".config", "mango", "config.conf"),
filepath.Join(homeDir, ".config", "mango", "mango.conf"),
}
}
for _, configPath := range configPaths {
+22 -49
View File
@@ -4,7 +4,6 @@ import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
@@ -27,7 +26,7 @@ var windowrulesListCmd = &cobra.Command{
Args: cobra.MaximumNArgs(1),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 {
return []string{"hyprland", "niri", "mango"}, cobra.ShellCompDirectiveNoFileComp
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
}
return nil, cobra.ShellCompDirectiveNoFileComp
},
@@ -41,7 +40,7 @@ var windowrulesAddCmd = &cobra.Command{
Args: cobra.ExactArgs(2),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 {
return []string{"hyprland", "niri", "mango"}, cobra.ShellCompDirectiveNoFileComp
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
}
return nil, cobra.ShellCompDirectiveNoFileComp
},
@@ -55,7 +54,7 @@ var windowrulesUpdateCmd = &cobra.Command{
Args: cobra.ExactArgs(3),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 {
return []string{"hyprland", "niri", "mango"}, cobra.ShellCompDirectiveNoFileComp
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
}
return nil, cobra.ShellCompDirectiveNoFileComp
},
@@ -69,7 +68,7 @@ var windowrulesRemoveCmd = &cobra.Command{
Args: cobra.ExactArgs(2),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 {
return []string{"hyprland", "niri", "mango"}, cobra.ShellCompDirectiveNoFileComp
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
}
return nil, cobra.ShellCompDirectiveNoFileComp
},
@@ -83,7 +82,7 @@ var windowrulesReorderCmd = &cobra.Command{
Args: cobra.ExactArgs(2),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 {
return []string{"hyprland", "niri", "mango"}, cobra.ShellCompDirectiveNoFileComp
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
}
return nil, cobra.ShellCompDirectiveNoFileComp
},
@@ -121,9 +120,6 @@ func getCompositor(args []string) string {
if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "" {
return "hyprland"
}
if os.Getenv("MANGO_INSTANCE_SIGNATURE") != "" {
return "mango"
}
return ""
}
@@ -143,14 +139,17 @@ func writeRuleSuccess(id, path string) {
func runWindowrulesList(cmd *cobra.Command, args []string) {
compositor := getCompositor(args)
if compositor == "" {
log.Fatalf("Could not detect compositor. Please specify: hyprland, niri, or mango")
log.Fatalf("Could not detect compositor. Please specify: hyprland or niri")
}
var result WindowRulesListResult
switch compositor {
case "niri":
configDir := filepath.Join(utils.XDGConfigHome(), "niri")
configDir, err := utils.ExpandPath("$HOME/.config/niri")
if err != nil {
log.Fatalf("Failed to expand niri config path: %v", err)
}
parseResult, err := providers.ParseNiriWindowRules(configDir)
if err != nil {
@@ -183,7 +182,10 @@ func runWindowrulesList(cmd *cobra.Command, args []string) {
result.DMSStatus = parseResult.DMSStatus
case "hyprland":
configDir := filepath.Join(utils.XDGConfigHome(), "hypr")
configDir, err := utils.ExpandPath("$HOME/.config/hypr")
if err != nil {
log.Fatalf("Failed to expand hyprland config path: %v", err)
}
parseResult, err := providers.ParseHyprlandWindowRules(configDir)
if err != nil {
@@ -215,38 +217,6 @@ func runWindowrulesList(cmd *cobra.Command, args []string) {
result.Rules = allRules
result.DMSStatus = parseResult.DMSStatus
case "mango", "mangowc":
configDir := filepath.Join(utils.XDGConfigHome(), "mango")
parseResult, err := providers.ParseMangoWindowRules(configDir)
if err != nil {
log.Fatalf("Failed to parse mango window rules: %v", err)
}
allRules := providers.ConvertMangoRulesToWindowRules(parseResult.Rules)
provider := providers.NewMangoWritableProvider(configDir)
dmsRules, _ := provider.LoadDMSRules()
dmsRuleMap := make(map[int]windowrules.WindowRule)
for i, dr := range dmsRules {
dmsRuleMap[i] = dr
}
dmsIdx := 0
for i, r := range allRules {
if r.Source == "dms/windowrules.conf" {
if dmr, ok := dmsRuleMap[dmsIdx]; ok {
allRules[i].ID = dmr.ID
allRules[i].Name = dmr.Name
}
dmsIdx++
}
}
result.Rules = allRules
result.DMSStatus = parseResult.DMSStatus
default:
log.Fatalf("Unknown compositor: %s", compositor)
}
@@ -345,14 +315,17 @@ func runWindowrulesReorder(cmd *cobra.Command, args []string) {
func getWindowRulesProvider(compositor string) windowrules.WritableProvider {
switch compositor {
case "niri":
configDir := filepath.Join(utils.XDGConfigHome(), "niri")
configDir, err := utils.ExpandPath("$HOME/.config/niri")
if err != nil {
return nil
}
return providers.NewNiriWritableProvider(configDir)
case "hyprland":
configDir := filepath.Join(utils.XDGConfigHome(), "hypr")
configDir, err := utils.ExpandPath("$HOME/.config/hypr")
if err != nil {
return nil
}
return providers.NewHyprlandWritableProvider(configDir)
case "mango", "mangowc":
configDir := filepath.Join(utils.XDGConfigHome(), "mango")
return providers.NewMangoWritableProvider(configDir)
default:
return nil
}
-6
View File
@@ -12,10 +12,6 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
)
// maxIPCMessageSize allows room for a 50 MB clipboard entry plus JSON/base64
// overhead in the line-delimited IPC response.
const maxIPCMessageSize = 96 * 1024 * 1024
func sendServerRequest(req models.Request) (*models.Response[any], error) {
socketPath := getServerSocketPath()
@@ -26,7 +22,6 @@ func sendServerRequest(req models.Request) (*models.Response[any], error) {
defer conn.Close()
scanner := bufio.NewScanner(conn)
scanner.Buffer(make([]byte, bufio.MaxScanTokenSize), maxIPCMessageSize)
scanner.Scan() // discard initial capabilities message
reqData, err := json.Marshal(req)
@@ -66,7 +61,6 @@ func sendServerRequestFireAndForget(req models.Request) error {
defer conn.Close()
scanner := bufio.NewScanner(conn)
scanner.Buffer(make([]byte, bufio.MaxScanTokenSize), maxIPCMessageSize)
scanner.Scan() // discard initial capabilities message
reqData, err := json.Marshal(req)
-106
View File
@@ -73,10 +73,6 @@ func (cd *ConfigDeployer) deployConfigurationsInternal(ctx context.Context, wm d
filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.lua"),
filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf"),
},
"Mango": {
filepath.Join(os.Getenv("HOME"), ".config", "mango", "config.conf"),
filepath.Join(os.Getenv("HOME"), ".config", "mango", "mango.conf"),
},
"Ghostty": {
filepath.Join(os.Getenv("HOME"), ".config", "ghostty", "config"),
},
@@ -130,14 +126,6 @@ func (cd *ConfigDeployer) deployConfigurationsInternal(ctx context.Context, wm d
return results, fmt.Errorf("failed to deploy Hyprland config: %w", err)
}
}
case deps.WindowManagerMango:
if shouldReplaceConfig("Mango") {
result, err := cd.deployMangoConfig(terminal, useSystemd)
results = append(results, result)
if err != nil {
return results, fmt.Errorf("failed to deploy Mango config: %w", err)
}
}
}
switch terminal {
@@ -281,96 +269,6 @@ func (cd *ConfigDeployer) deployNiriDmsConfigs(dmsDir, terminalCommand string) e
return nil
}
func (cd *ConfigDeployer) deployMangoConfig(terminal deps.Terminal, useSystemd bool) (DeploymentResult, error) {
result := DeploymentResult{
ConfigType: "Mango",
Path: filepath.Join(os.Getenv("HOME"), ".config", "mango", "config.conf"),
}
configDir := filepath.Dir(result.Path)
if err := os.MkdirAll(configDir, 0o755); err != nil {
result.Error = fmt.Errorf("failed to create config directory: %w", err)
return result, result.Error
}
dmsDir := filepath.Join(configDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
result.Error = fmt.Errorf("failed to create dms directory: %w", err)
return result, result.Error
}
var terminalCommand string
switch terminal {
case deps.TerminalGhostty:
terminalCommand = "ghostty"
case deps.TerminalKitty:
terminalCommand = "kitty"
case deps.TerminalAlacritty:
terminalCommand = "alacritty"
default:
terminalCommand = "ghostty"
}
// DMS owns config.conf for mango (like niri/hyprland): back up and replace.
if existingData, err := os.ReadFile(result.Path); err == nil {
cd.log("Found existing Mango configuration")
timestamp := time.Now().Format("2006-01-02_15-04-05")
result.BackupPath = result.Path + ".backup." + timestamp
if err := os.WriteFile(result.BackupPath, existingData, 0o644); err != nil {
result.Error = fmt.Errorf("failed to create backup: %w", err)
return result, result.Error
}
cd.log(fmt.Sprintf("Backed up existing config to %s", result.BackupPath))
}
newConfig := strings.ReplaceAll(MangoConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
if err := os.WriteFile(result.Path, []byte(newConfig), 0o644); err != nil {
result.Error = fmt.Errorf("failed to write config: %w", err)
return result, result.Error
}
if err := cd.deployMangoDmsConfigs(dmsDir, terminalCommand); err != nil {
result.Error = fmt.Errorf("failed to deploy dms configs: %w", err)
return result, result.Error
}
result.Deployed = true
cd.log("Successfully deployed Mango configuration")
return result, nil
}
func (cd *ConfigDeployer) deployMangoDmsConfigs(dmsDir, terminalCommand string) error {
configs := []struct {
name string
content string
overwrite bool
}{
// binds.conf is DMS-owned (overwrite); the rest are runtime/user-managed.
{"binds.conf", strings.ReplaceAll(MangoBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand), true},
{"colors.conf", MangoColorsConfig, false},
{"layout.conf", MangoLayoutConfig, false},
{"outputs.conf", "", false},
{"cursor.conf", "", false},
{"windowrules.conf", "", false},
}
for _, cfg := range configs {
path := filepath.Join(dmsDir, cfg.name)
if !cfg.overwrite {
if info, err := os.Stat(path); err == nil && info.Size() > 0 {
cd.log(fmt.Sprintf("Skipping %s (already exists)", cfg.name))
continue
}
}
if err := os.WriteFile(path, []byte(cfg.content), 0o644); err != nil {
return fmt.Errorf("failed to write %s: %w", cfg.name, err)
}
cd.log(fmt.Sprintf("Deployed %s", cfg.name))
}
return nil
}
func (cd *ConfigDeployer) deployGhosttyConfig() ([]DeploymentResult, error) {
var results []DeploymentResult
@@ -702,10 +600,6 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
return result, result.Error
}
CleanupStrayHyprlandConfFile(func(format string, v ...any) {
cd.log(fmt.Sprintf(format, v...))
})
result.Deployed = true
cd.log("Successfully deployed Hyprland configuration")
return result, nil
+3 -15
View File
@@ -20,17 +20,13 @@ func TestCleanupStrayHyprlandConfFile(t *testing.T) {
td := t.TempDir()
t.Setenv("HOME", td)
configDir := filepath.Join(td, ".config", "hypr")
dmsDir := filepath.Join(configDir, "dms")
require.NoError(t, os.MkdirAll(dmsDir, 0o755))
require.NoError(t, os.MkdirAll(configDir, 0o755))
confPath := filepath.Join(configDir, "hyprland.conf")
dmsConfPath := filepath.Join(dmsDir, "colors.conf")
require.NoError(t, os.WriteFile(confPath, []byte("# legacy user config\n"), 0o644))
require.NoError(t, os.WriteFile(dmsConfPath, []byte("$primary = rgba(d0bcffFF)\n"), 0o644))
CleanupStrayHyprlandConfFile(nil)
assert.FileExists(t, confPath, "must not touch hyprland.conf when user has not migrated")
assert.FileExists(t, dmsConfPath, "must not touch dms/*.conf when user has not migrated")
assert.NoDirExists(t, filepath.Join(configDir, hyprlandBackupDirName))
})
@@ -38,25 +34,20 @@ func TestCleanupStrayHyprlandConfFile(t *testing.T) {
td := t.TempDir()
t.Setenv("HOME", td)
configDir := filepath.Join(td, ".config", "hypr")
dmsDir := filepath.Join(configDir, "dms")
require.NoError(t, os.MkdirAll(dmsDir, 0o755))
require.NoError(t, os.MkdirAll(configDir, 0o755))
luaPath := filepath.Join(configDir, "hyprland.lua")
require.NoError(t, os.WriteFile(luaPath, []byte("-- dms managed\n"), 0o644))
confPath := filepath.Join(configDir, "hyprland.conf")
dmsConfPath := filepath.Join(dmsDir, "colors.conf")
require.NoError(t, os.WriteFile(confPath, []byte("# autogen\n"), 0o644))
require.NoError(t, os.WriteFile(dmsConfPath, []byte("$primary = rgba(d0bcffFF)\n"), 0o644))
CleanupStrayHyprlandConfFile(nil)
assert.NoFileExists(t, confPath)
assert.NoFileExists(t, dmsConfPath)
assert.FileExists(t, luaPath)
entries, err := os.ReadDir(filepath.Join(configDir, hyprlandBackupDirName))
require.NoError(t, err)
require.Len(t, entries, 1)
assert.FileExists(t, filepath.Join(configDir, hyprlandBackupDirName, entries[0].Name(), "hyprland.conf"))
assert.FileExists(t, filepath.Join(configDir, hyprlandBackupDirName, entries[0].Name(), "dms", "colors.conf"))
})
}
@@ -413,7 +404,6 @@ general {
dmsDir := filepath.Join(td, ".config", "hypr", "dms")
require.NoError(t, os.MkdirAll(dmsDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "binds.conf"), []byte("bind = SUPER, T, exec, foot\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "colors.conf"), []byte("$primary = rgba(d0bcffFF)\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "cursor.conf"), []byte("env = XCURSOR_SIZE,24\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(filepath.Dir(hyprPath), "hyprland.conf.backup.old"), []byte("old backup\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "binds.conf.backup.old"), []byte("old dms backup\n"), 0o644))
@@ -433,12 +423,10 @@ general {
assert.Contains(t, result.BackupPath, hyprlandBackupDirName)
assert.NoFileExists(t, hyprPath)
assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "dms", "binds.conf"))
assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "dms", "colors.conf"))
assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "dms", "cursor.conf"))
assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "hyprland.conf.backup.old"))
assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "dms", "binds.conf.backup.old"))
assert.NoFileExists(t, filepath.Join(dmsDir, "binds.conf"))
assert.NoFileExists(t, filepath.Join(dmsDir, "colors.conf"))
assert.NoFileExists(t, filepath.Join(dmsDir, "cursor.conf"))
assert.NoFileExists(t, filepath.Join(filepath.Dir(hyprPath), "hyprland.conf.backup.old"))
assert.NoFileExists(t, filepath.Join(dmsDir, "binds.conf.backup.old"))
@@ -497,7 +485,7 @@ general {
managed, err := os.ReadFile(filepath.Join(dmsDir, "binds.lua"))
require.NoError(t, err)
assert.Contains(t, string(managed), `hl.bind("SUPER + F", hl.dsp.window.fullscreen({ mode = "maximized", action = "toggle" }))`)
assert.Contains(t, string(managed), `hl.bind("SUPER + minus", hl.dsp.window.resize({ x = -100, y = 0, relative = true }), { repeating = true })`)
assert.Contains(t, string(managed), `hl.bind("SUPER + minus", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive -10% 0]]), { repeating = true })`)
user, err := os.ReadFile(filepath.Join(dmsDir, "binds-user.lua"))
require.NoError(t, err)
+6 -10
View File
@@ -11,7 +11,6 @@ hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notifications toggle"))
hl.bind("SUPER + SHIFT + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"))
hl.bind("SUPER + Y", hl.dsp.exec_cmd("dms ipc call dankdash wallpaper"))
hl.bind("SUPER + TAB", hl.dsp.exec_cmd("dms ipc call hypr toggleOverview"))
hl.bind("SUPER + O", hl.dsp.exec_cmd("dms ipc call hypr toggleOverview"))
hl.bind("SUPER + X", hl.dsp.exec_cmd("dms ipc call powermenu toggle"))
-- === Cheat sheet
@@ -39,7 +38,7 @@ hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd([[dms ipc call brightness increme
hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd([[dms ipc call brightness decrement 5 ""]]), { locked = true, repeating = true })
-- === Window Management ===
hl.bind("SUPER + Q", hl.dsp.window.close())
hl.bind("SUPER + Q", hl.dsp.window.kill())
hl.bind("SUPER + F", hl.dsp.window.fullscreen({ mode = "maximized", action = "toggle" }))
hl.bind("SUPER + SHIFT + F", hl.dsp.window.fullscreen({ mode = "fullscreen", action = "toggle" }))
hl.bind("SUPER + SHIFT + T", hl.dsp.window.float({ action = "toggle" }))
@@ -113,9 +112,6 @@ hl.bind("SUPER + mouse_up", hl.dsp.focus({ workspace = "e-1" }))
hl.bind("SUPER + CTRL + mouse_down", hl.dsp.window.move({ workspace = "e+1" }))
hl.bind("SUPER + CTRL + mouse_up", hl.dsp.window.move({ workspace = "e-1" }))
-- === Touchpad Gestures ===
hl.gesture({ fingers = 3, direction = "horizontal", action = "workspace" })
-- === Numbered Workspaces ===
hl.bind("SUPER + 1", hl.dsp.focus({ workspace = "1" }))
hl.bind("SUPER + 2", hl.dsp.focus({ workspace = "2" }))
@@ -144,7 +140,7 @@ hl.bind("SUPER + bracketright", hl.dsp.layout("preselect r"))
-- === Sizing & Layout ===
hl.bind("SUPER + R", hl.dsp.layout("togglesplit"))
hl.bind("SUPER + CTRL + F", hl.dsp.window.fullscreen({ mode = "maximized", action = "set" }))
hl.bind("SUPER + CTRL + F", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive exact 100% 100%]]))
-- === Move/resize windows with mainMod + LMB/RMB and dragging ===
hl.bind("SUPER + mouse:272", hl.dsp.window.drag(), { mouse = true, description = "Move window" })
@@ -154,10 +150,10 @@ hl.bind("SUPER + code:20", hl.dsp.window.resize({ x = -100, y = 0, relative = tr
hl.bind("SUPER + code:21", hl.dsp.window.resize({ x = 100, y = 0, relative = true }), { description = "Shrink window left" })
-- === Manual Sizing ===
hl.bind("SUPER + minus", hl.dsp.window.resize({ x = -100, y = 0, relative = true }), { repeating = true })
hl.bind("SUPER + equal", hl.dsp.window.resize({ x = 100, y = 0, relative = true }), { repeating = true })
hl.bind("SUPER + SHIFT + minus", hl.dsp.window.resize({ x = 0, y = -100, relative = true }), { repeating = true })
hl.bind("SUPER + SHIFT + equal", hl.dsp.window.resize({ x = 0, y = 100, relative = true }), { repeating = true })
hl.bind("SUPER + minus", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive -10% 0]]), { repeating = true })
hl.bind("SUPER + equal", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive 10% 0]]), { repeating = true })
hl.bind("SUPER + SHIFT + minus", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive 0 -10%]]), { repeating = true })
hl.bind("SUPER + SHIFT + equal", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive 0 10%]]), { repeating = true })
-- === Screenshots ===
hl.bind("Print", hl.dsp.exec_cmd("dms screenshot"))
@@ -13,10 +13,6 @@ hl.config({
input = {
kb_layout = "us",
numlock_by_default = true,
touchpad = {
tap_to_click = true,
natural_scroll = true,
},
},
general = {
gaps_in = 5,
@@ -1,182 +0,0 @@
# DMS default keybinds (MangoWM) — managed by DMS, regenerated by `dms setup`.
# Format: bind=MODS,key,action[,args]
# Descriptions go on the line ABOVE each bind (mango does not strip inline
# comments — a trailing `# ...` would be passed to spawn as extra arguments).
# === Application Launchers ===
# Open Terminal
bind=SUPER,t,spawn,{{TERMINAL_COMMAND}}
# Open Terminal
bind=SUPER,Return,spawn,{{TERMINAL_COMMAND}}
# Application Launcher
bind=SUPER,space,spawn,dms ipc call spotlight toggle
# Spotlight Bar
bind=ALT,space,spawn,dms ipc call spotlight-bar toggle
# Clipboard Manager
bind=SUPER,v,spawn,dms ipc call clipboard toggle
# Task Manager
bind=SUPER,m,spawn,dms ipc call processlist focusOrToggle
# Settings
bind=SUPER,comma,spawn,dms ipc call settings focusOrToggle
# Notification Center
bind=SUPER,n,spawn,dms ipc call notifications toggle
# Notepad
bind=SUPER+SHIFT,n,spawn,dms ipc call notepad toggle
# Browse Wallpapers
bind=SUPER,y,spawn,dms ipc call dankdash wallpaper
# Power Menu
bind=SUPER,x,spawn,dms ipc call powermenu toggle
# Cycle Display Profile
bind=SUPER,p,spawn,dms ipc outputs cycleProfile
# === Cheat sheet ===
# Keyboard Shortcuts
bind=SUPER+SHIFT,slash,spawn,dms ipc call keybinds toggle mangowc
# === Security ===
# Lock Screen
bind=SUPER+ALT,l,spawn,dms ipc call lock lock
# Task Manager
bind=CTRL+ALT,Delete,spawn,dms ipc call processlist focusOrToggle
# === Window Rules ===
# Create Window Rule
bind=SUPER+SHIFT,w,spawn,dms ipc call window-rules toggle
# === Screenshots ===
# Screenshot: Interactive
bind=none,Print,spawn,dms screenshot
# Screenshot: Full Screen
bind=CTRL,Print,spawn,dms screenshot full
# Screenshot: Window
bind=ALT,Print,spawn,dms screenshot window
# === Audio Controls ===
# Volume Up
bind=none,XF86AudioRaiseVolume,spawn,dms ipc call audio increment 3
# Volume Down
bind=none,XF86AudioLowerVolume,spawn,dms ipc call audio decrement 3
# Mute Output
bind=none,XF86AudioMute,spawn,dms ipc call audio mute
# Mute Microphone
bind=none,XF86AudioMicMute,spawn,dms ipc call audio micmute
# Play/Pause
bind=none,XF86AudioPlay,spawn,dms ipc call mpris playPause
# Play/Pause
bind=none,XF86AudioPause,spawn,dms ipc call mpris playPause
# Previous Track
bind=none,XF86AudioPrev,spawn,dms ipc call mpris previous
# Next Track
bind=none,XF86AudioNext,spawn,dms ipc call mpris next
# === Brightness Controls ===
# Brightness Up
bind=none,XF86MonBrightnessUp,spawn,dms ipc call brightness increment 5
# Brightness Down
bind=none,XF86MonBrightnessDown,spawn,dms ipc call brightness decrement 5
# === Window Management ===
# Close Window
bind=SUPER,q,killclient,
# Toggle Fullscreen
bind=SUPER,f,togglefullscreen,
# Toggle Maximize
bind=SUPER,a,togglemaximizescreen,
# Toggle Floating
bind=SUPER+SHIFT,space,togglefloating,
# Toggle Overview
bind=SUPER,o,toggleoverview
bind=ALT,Tab,toggleoverview
# Exit Compositor
bind=SUPER+SHIFT,e,quit,
# === Focus Navigation ===
# Focus Next Window
bind=SUPER,Tab,focusstack,next
# Focus Previous Window
bind=SUPER+SHIFT,Tab,focusstack,prev
# Focus Left
bind=SUPER,Left,focusdir,left
# Focus Right
bind=SUPER,Right,focusdir,right
# Focus Up
bind=SUPER,Up,focusdir,up
# Focus Down
bind=SUPER,Down,focusdir,down
# === Window Movement ===
# Move Window Left
bind=SUPER+SHIFT,Left,exchange_client,left
# Move Window Right
bind=SUPER+SHIFT,Right,exchange_client,right
# Move Window Up
bind=SUPER+SHIFT,Up,exchange_client,up
# Move Window Down
bind=SUPER+SHIFT,Down,exchange_client,down
# === Monitor Navigation ===
# Focus Monitor Left
bind=SUPER+ALT,Left,focusmon,left
# Focus Monitor Right
bind=SUPER+ALT,Right,focusmon,right
# Move to Monitor Left
bind=SUPER+ALT+SHIFT,Left,tagmon,left
# Move to Monitor Right
bind=SUPER+ALT+SHIFT,Right,tagmon,right
# === Layout ===
# Cycle Layout
bind=SUPER,j,switch_layout
# Increase Gaps
bind=SUPER+SHIFT,equal,incgaps,1
# Decrease Gaps
bind=SUPER+SHIFT,minus,incgaps,-1
# === Tags (1-9): view tag ===
# View Tag 1
bind=SUPER,1,view,1
# View Tag 2
bind=SUPER,2,view,2
# View Tag 3
bind=SUPER,3,view,3
# View Tag 4
bind=SUPER,4,view,4
# View Tag 5
bind=SUPER,5,view,5
# View Tag 6
bind=SUPER,6,view,6
# View Tag 7
bind=SUPER,7,view,7
# View Tag 8
bind=SUPER,8,view,8
# View Tag 9
bind=SUPER,9,view,9
# === Tags (1-9): move focused window to tag ===
# Move to Tag 1
bind=SUPER+SHIFT,1,tag,1
# Move to Tag 2
bind=SUPER+SHIFT,2,tag,2
# Move to Tag 3
bind=SUPER+SHIFT,3,tag,3
# Move to Tag 4
bind=SUPER+SHIFT,4,tag,4
# Move to Tag 5
bind=SUPER+SHIFT,5,tag,5
# Move to Tag 6
bind=SUPER+SHIFT,6,tag,6
# Move to Tag 7
bind=SUPER+SHIFT,7,tag,7
# Move to Tag 8
bind=SUPER+SHIFT,8,tag,8
# Move to Tag 9
bind=SUPER+SHIFT,9,tag,9
# === Touchpad Gestures ===
# Syntax: gesturebind=MODIFIERS,DIRECTION,FINGERS,COMMAND,PARAMETERS
# 3-finger horizontal swipe: switch between occupied workspaces
gesturebind=none,left,3,viewtoleft_have_client
gesturebind=none,right,3,viewtoright_have_client
# 4-finger vertical swipe: toggle the overview
gesturebind=none,up,4,toggleoverview
gesturebind=none,down,4,toggleoverview
@@ -1,6 +0,0 @@
# Auto-generated by DMS. Overwritten by matugen (dms/colors.conf).
# Remove `source=./dms/colors.conf` from config.conf to override manually.
bordercolor = 0x595959ff
focuscolor = 0x8ab4f8ff
urgentcolor = 0xff5555ff
@@ -1,8 +0,0 @@
# Auto-generated by DMS. Regenerated from DMS settings (dms/layout.conf).
border_radius=12
gappih=5
gappiv=5
gappoh=5
gappov=5
borderpx=2
-18
View File
@@ -1,18 +0,0 @@
# DankMaterialShell — MangoWM configuration (managed by `dms setup`)
# Keybinds, colors, layout, outputs, cursor and window rules are pulled from the
# ./dms fragments below. Add your own binds/rules here; they sit alongside DMS's.
env=XDG_CURRENT_DESKTOP,mango
env=XDG_SESSION_TYPE,wayland
# exec_once runs only at startup. Do NOT use exec= for the shell: mango re-runs
# every exec= on each config reload, and DMS reloads the config, which would
# spawn a new shell on every reload.
exec_once=dms run
source=./dms/colors.conf
source=./dms/layout.conf
source=./dms/cursor.conf
source=./dms/outputs.conf
source=./dms/windowrules.conf
source=./dms/binds.conf
+1 -1
View File
@@ -1,6 +1,6 @@
binds {
// === System & Overview ===
Mod+O repeat=false { toggle-overview; }
Mod+D repeat=false { toggle-overview; }
Mod+Tab repeat=false { toggle-overview; }
Mod+Shift+Slash { show-hotkey-overlay; }
+11 -34
View File
@@ -138,9 +138,11 @@ func readExistingHyprlandConfig(configDir string) (data string, sourcePath strin
return "", "", nil
}
// CleanupStrayHyprlandConfFile moves stray ~/.config/hypr/hyprland.conf and
// top-level ~/.config/hypr/dms/*.conf files into .dms-backups/<timestamp>/ only
// when hyprland.lua also exists as the live config.
// CleanupStrayHyprlandConfFile moves a stray ~/.config/hypr/hyprland.conf
// into .dms-backups/<timestamp>/ only when hyprland.lua also exists, which
// proves Lua is the live config and the .conf is an autogen Hyprland 0.55
// produced when launched without -c. If only hyprland.conf exists, the user
// has not migrated and we must leave their config alone.
func CleanupStrayHyprlandConfFile(logFn func(format string, v ...any)) {
if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") == "" {
return
@@ -154,44 +156,19 @@ func CleanupStrayHyprlandConfFile(logFn func(format string, v ...any)) {
if _, err := os.Stat(luaPath); err != nil {
return
}
var strayPaths []string
confPath := filepath.Join(configDir, "hyprland.conf")
if info, err := os.Lstat(confPath); err == nil && !info.IsDir() {
strayPaths = append(strayPaths, confPath)
}
dmsConfPaths, err := filepath.Glob(filepath.Join(configDir, "dms", "*.conf"))
if err == nil {
for _, p := range dmsConfPaths {
if info, err := os.Lstat(p); err == nil && !info.IsDir() {
strayPaths = append(strayPaths, p)
}
}
}
if len(strayPaths) == 0 {
if _, err := os.Stat(confPath); err != nil {
return
}
ts := time.Now().Format("2006-01-02_15-04-05")
moved := 0
for _, src := range strayPaths {
rel, err := filepath.Rel(configDir, src)
if err != nil {
rel = filepath.Base(src)
}
dst := filepath.Join(configDir, hyprlandBackupDirName, ts, rel)
if err := moveHyprlandConfigFile(src, dst); err != nil {
dst := filepath.Join(configDir, hyprlandBackupDirName, ts, "hyprland.conf")
if err := moveHyprlandConfigFile(confPath, dst); err != nil {
if logFn != nil {
logFn("Could not move stray Hyprland conf file %s: %v", src, err)
logFn("Could not move stray hyprland.conf: %v", err)
}
continue
return
}
moved++
if logFn != nil {
logFn("Moved stray Hyprland conf file to %s", dst)
}
}
if moved > 0 && logFn != nil {
logFn("Moved %d stray Hyprland conf file(s) out of the active Lua config tree", moved)
logFn("Moved stray hyprland.conf to %s", dst)
}
}
-15
View File
@@ -1,15 +0,0 @@
package config
import _ "embed"
//go:embed embedded/mango.conf
var MangoConfig string
//go:embed embedded/mango-colors.conf
var MangoColorsConfig string
//go:embed embedded/mango-layout.conf
var MangoLayoutConfig string
//go:embed embedded/mango-binds.conf
var MangoBindsConfig string
-1
View File
@@ -35,7 +35,6 @@ type WindowManager int
const (
WindowManagerHyprland WindowManager = iota
WindowManagerNiri
WindowManagerMango
)
type Terminal int
+1 -21
View File
@@ -112,11 +112,6 @@ func (a *ArchDistribution) DetectDependenciesWithTerminal(ctx context.Context, w
dependencies = append(dependencies, a.detectXwaylandSatellite())
}
// Mango-specific tools (dwl-based, uses xwayland-satellite like niri)
if wm == deps.WindowManagerMango {
dependencies = append(dependencies, a.detectXwaylandSatellite())
}
dependencies = append(dependencies, a.detectMatugen())
dependencies = append(dependencies, a.detectDgop())
@@ -177,11 +172,6 @@ func (a *ArchDistribution) isInSystemRepo(pkg string) bool {
return exec.Command("pacman", "-Si", pkg).Run() == nil
}
// isSonameProvides reports whether dep is a shared-library soname
func isSonameProvides(dep string) bool {
return strings.HasSuffix(dep, ".so") || strings.Contains(dep, ".so.")
}
func (a *ArchDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
return a.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant))
}
@@ -209,9 +199,6 @@ func (a *ArchDistribution) GetPackageMappingWithVariants(wm deps.WindowManager,
case deps.WindowManagerNiri:
packages["niri"] = a.getNiriMapping(variants["niri"])
packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem}
case deps.WindowManagerMango:
packages["mango"] = a.getMangoMapping(variants["mango"])
packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem}
}
return packages
@@ -235,13 +222,6 @@ func (a *ArchDistribution) getNiriMapping(variant deps.PackageVariant) PackageMa
return PackageMapping{Name: "niri", Repository: RepoTypeSystem}
}
func (a *ArchDistribution) getMangoMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantGit {
return PackageMapping{Name: "mangowm-git", Repository: RepoTypeAUR}
}
return PackageMapping{Name: "mangowm", Repository: RepoTypeAUR}
}
func (a *ArchDistribution) getMatugenMapping(variant deps.PackageVariant) PackageMapping {
if runtime.GOARCH == "arm64" {
return PackageMapping{Name: "matugen-git", Repository: RepoTypeAUR}
@@ -744,7 +724,7 @@ func (a *ArchDistribution) installSingleAURPackageInternal(ctx context.Context,
continue
}
seen[dep] = true
if isSonameProvides(dep) || a.isInSystemRepo(dep) {
if a.isInSystemRepo(dep) {
systemPkgs = append(systemPkgs, dep)
} else {
aurPkgs = append(aurPkgs, dep)
-30
View File
@@ -337,36 +337,6 @@ func (b *BaseDistribution) detectWindowManager(wm deps.WindowManager) deps.Depen
Variant: variant,
CanToggle: true,
}
case deps.WindowManagerMango:
status := deps.StatusMissing
variant := deps.VariantStable
version := ""
if b.commandExists("mango") {
status = deps.StatusInstalled
cmd := exec.Command("mango", "-v")
if output, err := cmd.Output(); err == nil {
outStr := string(output)
if strings.Contains(outStr, "git") || strings.Contains(outStr, "dirty") {
variant = deps.VariantGit
}
if versionRegex := regexp.MustCompile(`(\d+\.\d+\.\d+)`); versionRegex.MatchString(outStr) {
matches := versionRegex.FindStringSubmatch(outStr)
if len(matches) > 1 {
version = matches[1]
}
}
}
}
return deps.Dependency{
Name: "mango",
Status: status,
Version: version,
Description: "dwl-based dynamic tiling Wayland compositor",
Required: true,
Variant: variant,
CanToggle: true,
}
default:
return deps.Dependency{
Name: "unknown-wm",
+2 -55
View File
@@ -77,11 +77,7 @@ func (f *FedoraDistribution) DetectDependenciesWithTerminal(ctx context.Context,
// Common detections using base methods
dependencies = append(dependencies, f.detectGit())
wmDep := f.detectWindowManager(wm)
if wm == deps.WindowManagerMango {
wmDep.Description = "MangoWM (Wayland compositor) — the Terra repo will be enabled automatically to install it"
}
dependencies = append(dependencies, wmDep)
dependencies = append(dependencies, f.detectWindowManager(wm))
dependencies = append(dependencies, f.detectQuickshell())
dependencies = append(dependencies, f.detectDMSGreeter())
dependencies = append(dependencies, f.detectXDGPortal())
@@ -97,11 +93,6 @@ func (f *FedoraDistribution) DetectDependenciesWithTerminal(ctx context.Context,
dependencies = append(dependencies, f.detectXwaylandSatellite())
}
// Mango-specific tools (dwl-based, uses xwayland-satellite like niri)
if wm == deps.WindowManagerMango {
dependencies = append(dependencies, f.detectXwaylandSatellite())
}
dependencies = append(dependencies, f.detectMatugen())
dependencies = append(dependencies, f.detectDgop())
@@ -148,10 +139,6 @@ func (f *FedoraDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
case deps.WindowManagerNiri:
packages["niri"] = f.getNiriMapping(variants["niri"])
packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem}
case deps.WindowManagerMango:
// mangowm resolves via Terra, enabled automatically by enableTerraRepo.
packages["mango"] = PackageMapping{Name: "mangowm", Repository: RepoTypeSystem}
packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem}
}
return packages
@@ -172,7 +159,7 @@ func (f *FedoraDistribution) getDmsMapping(variant deps.PackageVariant) PackageM
}
func (f *FedoraDistribution) getHyprlandMapping(_ deps.PackageVariant) PackageMapping {
return PackageMapping{Name: "hyprland", Repository: RepoTypeCOPR, RepoURL: "lionheartp/Hyprland"}
return PackageMapping{Name: "hyprland", Repository: RepoTypeCOPR, RepoURL: "sdegler/hyprland"}
}
func (f *FedoraDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping {
@@ -310,22 +297,6 @@ func (f *FedoraDistribution) InstallPackages(ctx context.Context, dependencies [
}
}
// Phase 2b: Enable Terra repo for MangoWM (not in Fedora's repos). Must run
// before the DNF phase so `mangowm` resolves.
if wm == deps.WindowManagerMango {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.25,
Step: "Enabling Terra repository for MangoWM...",
IsComplete: false,
NeedsSudo: true,
LogOutput: "Setting up the Terra repo (fyralabs) to provide mango",
}
if err := f.enableTerraRepo(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to enable Terra repository: %w", err)
}
}
// Phase 3: System Packages (DNF)
if len(dnfPkgs) > 0 {
progressChan <- InstallProgressMsg{
@@ -452,30 +423,6 @@ func (f *FedoraDistribution) extractPackageNames(packages []PackageMapping) []st
return names
}
// enableTerraRepo registers the persistent Terra repo (via terra-release) so
// `mangowm` resolves in the DNF phase. $releasever is single-quoted so dnf, not
// the shell, expands it.
func (f *FedoraDistribution) enableTerraRepo(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
// Skip if Terra is already configured
if exec.CommandContext(ctx, "sh", "-c",
"rpm -q terra-release >/dev/null 2>&1 || test -f /etc/yum.repos.d/terra.repo").Run() == nil {
f.log("Terra repository already configured, skipping enable")
return nil
}
f.log("Enabling Terra repository (fyralabs) for mango...")
cmd := privesc.ExecCommand(ctx, sudoPassword,
`dnf install -y --nogpgcheck --repofrompath 'terra,https://repos.fyralabs.com/terra$releasever' terra-release 2>&1`)
output, err := cmd.CombinedOutput()
if err != nil {
f.logError("failed to enable Terra repository", err)
f.log(fmt.Sprintf("Terra enable output: %s", string(output)))
return fmt.Errorf("failed to enable Terra repository: %w", err)
}
f.log(fmt.Sprintf("Terra repository enabled: %s", string(output)))
return nil
}
func (f *FedoraDistribution) enableCOPRRepos(ctx context.Context, coprPkgs []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
enabledRepos := make(map[string]bool)
-13
View File
@@ -106,11 +106,6 @@ func (g *GentooDistribution) DetectDependenciesWithTerminal(ctx context.Context,
dependencies = append(dependencies, g.detectXwaylandSatellite())
}
// Mango-specific tools (dwl-based, uses xwayland-satellite like niri)
if wm == deps.WindowManagerMango {
dependencies = append(dependencies, g.detectXwaylandSatellite())
}
dependencies = append(dependencies, g.detectMatugen())
dependencies = append(dependencies, g.detectDgop())
@@ -181,10 +176,6 @@ func (g *GentooDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
case deps.WindowManagerNiri:
packages["niri"] = g.getNiriMapping(variants["niri"])
packages["xwayland-satellite"] = PackageMapping{Name: "gui-apps/xwayland-satellite", Repository: RepoTypeGURU, AcceptKeywords: archKeyword}
case deps.WindowManagerMango:
packages["mango"] = g.getMangoMapping(variants["mango"])
packages["scenefx"] = PackageMapping{Name: "gui-libs/scenefx", Repository: RepoTypeGURU, AcceptKeywords: archKeyword}
packages["xwayland-satellite"] = PackageMapping{Name: "gui-apps/xwayland-satellite", Repository: RepoTypeGURU, AcceptKeywords: archKeyword}
}
return packages
@@ -206,10 +197,6 @@ func (g *GentooDistribution) getNiriMapping(_ deps.PackageVariant) PackageMappin
return PackageMapping{Name: "gui-wm/niri", Repository: RepoTypeGURU, UseFlags: "dbus screencast", AcceptKeywords: g.getArchKeyword()}
}
func (g *GentooDistribution) getMangoMapping(_ deps.PackageVariant) PackageMapping {
return PackageMapping{Name: "gui-wm/mangowm", Repository: RepoTypeGURU, AcceptKeywords: g.getArchKeyword()}
}
func (g *GentooDistribution) getPrerequisites() []string {
return []string{
"app-eselect/eselect-repository",
+18 -445
View File
@@ -9,7 +9,6 @@ import (
"fmt"
"os"
"os/exec"
"os/user"
"path/filepath"
"strings"
"time"
@@ -192,421 +191,6 @@ func upsertDefaultSession(configContent, greeterUser, command string) string {
return strings.Join(out, "\n")
}
func removeTomlSection(configContent, sectionName string) string {
lines := strings.Split(configContent, "\n")
var out []string
inSection := false
for _, line := range lines {
if section, ok := parseTomlSection(line); ok {
inSection = section == sectionName
if inSection {
continue
}
out = append(out, line)
continue
}
if inSection {
continue
}
out = append(out, line)
}
result := strings.TrimRight(strings.Join(out, "\n"), "\n")
if result != "" {
result += "\n"
}
return result
}
func stripDesktopExecCodes(execLine string) string {
fields := strings.Fields(execLine)
cleaned := make([]string, 0, len(fields))
for _, field := range fields {
if strings.HasPrefix(field, "%") {
continue
}
cleaned = append(cleaned, field)
}
return strings.Join(cleaned, " ")
}
func formatInitialSessionCommand(sessionExec string) string {
execLine := strings.TrimSpace(stripDesktopExecCodes(sessionExec))
if execLine == "" {
return `command = ""`
}
escaped := strings.ReplaceAll(execLine, `'`, `'\''`)
inner := fmt.Sprintf("env XDG_SESSION_TYPE=wayland sh -c 'exec %s'", escaped)
tomlEscaped := strings.ReplaceAll(inner, `\`, `\\`)
tomlEscaped = strings.ReplaceAll(tomlEscaped, `"`, `\"`)
return fmt.Sprintf(`command = "%s"`, tomlEscaped)
}
func upsertInitialSession(configContent, loginUser, sessionExec string, enabled bool) string {
if !enabled {
return removeTomlSection(configContent, "initial_session")
}
commandLine := formatInitialSessionCommand(sessionExec)
lines := strings.Split(configContent, "\n")
var out []string
inInitialSession := false
foundInitialSession := false
initialSessionUserSet := false
initialSessionCommandSet := false
appendInitialSessionFields := func() {
if !initialSessionUserSet {
out = append(out, fmt.Sprintf(`user = "%s"`, loginUser))
}
if !initialSessionCommandSet {
out = append(out, commandLine)
}
}
for _, line := range lines {
if section, ok := parseTomlSection(line); ok {
if inInitialSession {
appendInitialSessionFields()
}
inInitialSession = section == "initial_session"
if inInitialSession {
foundInitialSession = true
initialSessionUserSet = false
initialSessionCommandSet = false
}
out = append(out, line)
continue
}
if inInitialSession {
trimmed := stripTomlComment(line)
if strings.HasPrefix(trimmed, "user =") || strings.HasPrefix(trimmed, "user=") {
out = append(out, fmt.Sprintf(`user = "%s"`, loginUser))
initialSessionUserSet = true
continue
}
if strings.HasPrefix(trimmed, "command =") || strings.HasPrefix(trimmed, "command=") {
if !initialSessionCommandSet {
out = append(out, commandLine)
initialSessionCommandSet = true
}
continue
}
}
out = append(out, line)
}
if inInitialSession {
appendInitialSessionFields()
}
if !foundInitialSession {
if len(out) > 0 && strings.TrimSpace(out[len(out)-1]) != "" {
out = append(out, "")
}
out = append(out, "[initial_session]")
out = append(out, fmt.Sprintf(`user = "%s"`, loginUser))
out = append(out, commandLine)
}
return strings.Join(out, "\n")
}
type greeterAutoLoginConfig struct {
GreeterAutoLogin bool `json:"greeterAutoLogin"`
GreeterRememberLastUser bool `json:"greeterRememberLastUser"`
GreeterRememberLastSession bool `json:"greeterRememberLastSession"`
}
type greeterAutoLoginMemory struct {
LastSuccessfulUser string `json:"lastSuccessfulUser"`
LastSessionID string `json:"lastSessionId"`
LastSessionExec string `json:"lastSessionExec"`
AutoLoginEnabled bool `json:"autoLoginEnabled"`
}
func readGreeterAutoLoginConfig(settingsPath string) (greeterAutoLoginConfig, error) {
cfg := greeterAutoLoginConfig{
GreeterRememberLastUser: true,
GreeterRememberLastSession: true,
}
data, err := os.ReadFile(settingsPath)
if err != nil {
if os.IsNotExist(err) {
return cfg, nil
}
return cfg, err
}
if err := json.Unmarshal(data, &cfg); err != nil {
return cfg, fmt.Errorf("failed to parse settings at %s: %w", settingsPath, err)
}
return cfg, nil
}
func readGreeterAutoLoginMemory(memoryPath string) (greeterAutoLoginMemory, error) {
var mem greeterAutoLoginMemory
data, err := os.ReadFile(memoryPath)
if err != nil {
if os.IsNotExist(err) {
return mem, nil
}
return mem, err
}
if err := json.Unmarshal(data, &mem); err != nil {
return mem, fmt.Errorf("failed to parse greeter memory at %s: %w", memoryPath, err)
}
return mem, nil
}
func execFromDesktopFile(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
for line := range strings.SplitSeq(string(data), "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "Exec=") {
return strings.TrimSpace(trimmed[len("Exec="):]), nil
}
}
return "", fmt.Errorf("no Exec= line found in %s", path)
}
func resolveGreeterAutoLoginState(cacheDir, homeDir string) (enabled bool, loginUser string, sessionExec string, err error) {
settingsPath := filepath.Join(cacheDir, "settings.json")
if _, statErr := os.Stat(settingsPath); statErr != nil {
settingsPath = filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json")
}
cfg, err := readGreeterAutoLoginConfig(settingsPath)
if err != nil {
return false, "", "", err
}
memoryPath := filepath.Join(cacheDir, ".local/state/memory.json")
mem, err := readGreeterAutoLoginMemory(memoryPath)
if err != nil {
return false, "", "", err
}
enabled = cfg.GreeterAutoLogin
if !enabled {
return false, "", "", nil
}
if !cfg.GreeterRememberLastUser || !cfg.GreeterRememberLastSession {
return true, "", "", nil
}
loginUser = mem.LastSuccessfulUser
if loginUser == "" {
current, userErr := user.Current()
if userErr != nil {
return true, "", "", userErr
}
loginUser = current.Username
}
sessionExec = mem.LastSessionExec
if sessionExec == "" && mem.LastSessionID != "" {
sessionExec, err = execFromDesktopFile(mem.LastSessionID)
if err != nil {
sessionExec = ""
}
}
return true, loginUser, sessionExec, nil
}
func writeGreetdConfig(configPath, content string, logFunc func(string), sudoPassword, successMsg string) error {
if err := backupFileIfExists(sudoPassword, configPath, ".backup"); err != nil {
return fmt.Errorf("failed to backup greetd config: %w", err)
}
tmpFile, err := os.CreateTemp("", "greetd-config-*.toml")
if err != nil {
return fmt.Errorf("failed to create temp greetd config: %w", err)
}
defer os.Remove(tmpFile.Name())
if _, err := tmpFile.WriteString(content); err != nil {
_ = tmpFile.Close()
return fmt.Errorf("failed to write temp greetd config: %w", err)
}
if err := tmpFile.Close(); err != nil {
return fmt.Errorf("failed to close temp greetd config: %w", err)
}
if err := privesc.Run(context.Background(), sudoPassword, "mkdir", "-p", "/etc/greetd"); err != nil {
return fmt.Errorf("failed to create /etc/greetd: %w", err)
}
if err := privesc.Run(context.Background(), sudoPassword, "install", "-o", "root", "-g", "root", "-m", "0644", tmpFile.Name(), configPath); err != nil {
return fmt.Errorf("failed to install greetd config: %w", err)
}
if logFunc != nil && successMsg != "" {
logFunc(successMsg)
}
return nil
}
func clearGreeterAutoLoginMemory(memoryPath, sudoPassword string) error {
data, err := readGreeterMemoryFile(memoryPath, sudoPassword)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if len(strings.TrimSpace(string(data))) == 0 {
return nil
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return fmt.Errorf("failed to parse greeter memory at %s: %w", memoryPath, err)
}
if _, ok := raw["autoLoginEnabled"]; !ok {
return nil
}
delete(raw, "autoLoginEnabled")
encoded, err := json.MarshalIndent(raw, "", " ")
if err != nil {
return err
}
if len(encoded) == 0 || string(encoded) == "null" {
encoded = []byte("{}")
}
encoded = append(encoded, '\n')
if err := os.WriteFile(memoryPath, encoded, 0o644); err == nil {
return nil
} else if !os.IsPermission(err) {
return err
}
tmpFile, err := os.CreateTemp("", "greeter-memory-*.json")
if err != nil {
return fmt.Errorf("failed to create temp greeter memory file: %w", err)
}
defer os.Remove(tmpFile.Name())
if _, err := tmpFile.Write(encoded); err != nil {
_ = tmpFile.Close()
return fmt.Errorf("failed to write temp greeter memory file: %w", err)
}
if err := tmpFile.Close(); err != nil {
return fmt.Errorf("failed to close temp greeter memory file: %w", err)
}
greeterUser := DetectGreeterUser()
greeterGroup := DetectGreeterGroup()
owner := greeterUser + ":" + greeterGroup
if err := privesc.Run(context.Background(), sudoPassword, "install", "-o", greeterUser, "-g", greeterGroup, "-m", "0664", tmpFile.Name(), memoryPath); err != nil {
if fallbackErr := privesc.Run(context.Background(), sudoPassword, "install", "-o", "root", "-g", greeterGroup, "-m", "0664", tmpFile.Name(), memoryPath); fallbackErr != nil {
return fmt.Errorf("failed to install greeter memory file (preferred %s: %w; fallback root:%s: %v)", owner, err, greeterGroup, fallbackErr)
}
}
return nil
}
func readGreeterMemoryFile(memoryPath, sudoPassword string) ([]byte, error) {
data, err := os.ReadFile(memoryPath)
if err == nil || !os.IsPermission(err) {
return data, err
}
tmpFile, err := os.CreateTemp("", "greeter-memory-read-*")
if err != nil {
return nil, fmt.Errorf("failed to create temp file for greeter memory read: %w", err)
}
tmpPath := tmpFile.Name()
_ = tmpFile.Close()
defer os.Remove(tmpPath)
if err := privesc.Run(context.Background(), sudoPassword, "cp", "-f", memoryPath, tmpPath); err != nil {
return nil, fmt.Errorf("failed to read greeter memory at %s: %w", memoryPath, err)
}
return os.ReadFile(tmpPath)
}
func SyncGreetdAutoLogin(cacheDir, homeDir string, logFunc func(string), sudoPassword string) error {
enabled, loginUser, sessionExec, err := resolveGreeterAutoLoginState(cacheDir, homeDir)
if err != nil {
return err
}
configPath := "/etc/greetd/config.toml"
configContent := ""
if data, readErr := os.ReadFile(configPath); readErr == nil {
configContent = string(data)
} else if !os.IsNotExist(readErr) {
return fmt.Errorf("failed to read greetd config: %w", readErr)
}
if !enabled {
memoryPath := filepath.Join(cacheDir, ".local/state/memory.json")
if err := clearGreeterAutoLoginMemory(memoryPath, sudoPassword); err != nil && logFunc != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to clear greeter auto-login memory flag: %v", err))
}
newConfig := upsertInitialSession(configContent, "", "", false)
if newConfig == configContent {
if logFunc != nil {
logFunc("✓ Greeter auto-login disabled")
}
return nil
}
return writeGreetdConfig(configPath, newConfig, logFunc, sudoPassword, "✓ Disabled greeter auto-login")
}
if loginUser == "" || sessionExec == "" {
if logFunc != nil {
logFunc("⚠ Greeter auto-login is enabled but user or session is not configured yet. Log in manually once, then run sync.")
}
newConfig := upsertInitialSession(configContent, "", "", false)
if newConfig != configContent {
return writeGreetdConfig(configPath, newConfig, nil, sudoPassword, "")
}
return nil
}
newConfig := upsertInitialSession(configContent, loginUser, sessionExec, true)
if newConfig == configContent {
if logFunc != nil {
logFunc(fmt.Sprintf("✓ Greeter auto-login already configured for %s", loginUser))
}
memoryPath := filepath.Join(cacheDir, ".local/state/memory.json")
_ = clearGreeterAutoLoginMemory(memoryPath, sudoPassword)
return nil
}
if err := writeGreetdConfig(configPath, newConfig, logFunc, sudoPassword, fmt.Sprintf("✓ Configured greeter auto-login for %s", loginUser)); err != nil {
return err
}
memoryPath := filepath.Join(cacheDir, ".local/state/memory.json")
if err := clearGreeterAutoLoginMemory(memoryPath, sudoPassword); err != nil && logFunc != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to clear greeter auto-login memory flag: %v", err))
}
return nil
}
func SyncGreeterAutoLoginOnly(logFunc func(string), sudoPassword string) error {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err)
}
return SyncGreetdAutoLogin(GreeterCacheDir, homeDir, logFunc, sudoPassword)
}
func DetectGreeterUser() string {
passwdData, err := os.ReadFile("/etc/passwd")
if err == nil {
@@ -680,9 +264,6 @@ func DetectCompositors() []string {
if utils.CommandExists("Hyprland") {
compositors = append(compositors, "Hyprland")
}
if utils.CommandExists("mango") {
compositors = append(compositors, "mango")
}
return compositors
}
@@ -991,7 +572,6 @@ func EnsureGreeterCacheDir(logFunc func(string), sudoPassword string) error {
}
runtimeDirs := []string{
filepath.Join(cacheDir, "users"),
filepath.Join(cacheDir, ".local"),
filepath.Join(cacheDir, ".local", "state"),
filepath.Join(cacheDir, ".local", "share"),
@@ -1675,20 +1255,6 @@ func SyncDMSConfigs(dmsPath, compositor string, logFunc func(string), sudoPasswo
return fmt.Errorf("greeter wallpaper override sync failed: %w", err)
}
currentUser, err := user.Current()
if err != nil {
return fmt.Errorf("failed to resolve syncing user for per-user greeter cache: %w", err)
}
if err := syncUserGreeterCacheSlot(homeDir, cacheDir, currentUser.Username, state, logFunc, userSlotSyncOpts{
sudoPassword: sudoPassword,
}); err != nil {
return fmt.Errorf("per-user greeter cache sync failed: %w", err)
}
if err := SyncGreetdAutoLogin(cacheDir, homeDir, logFunc, sudoPassword); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: greeter auto-login sync failed: %v", err))
}
if strings.ToLower(compositor) != "niri" {
return nil
}
@@ -2153,22 +1719,29 @@ vt = 1
commandLine := fmt.Sprintf(`command = "%s"`, commandValue)
newConfig := upsertDefaultSession(configContent, greeterUser, commandLine)
homeDir, homeErr := os.UserHomeDir()
if homeErr == nil {
enabled, loginUser, sessionExec, resolveErr := resolveGreeterAutoLoginState(GreeterCacheDir, homeDir)
if resolveErr != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to resolve greeter auto-login state: %v", resolveErr))
} else if enabled && loginUser != "" && sessionExec != "" {
newConfig = upsertInitialSession(newConfig, loginUser, sessionExec, true)
} else {
newConfig = upsertInitialSession(newConfig, "", "", false)
tmpFile, err := os.CreateTemp("", "greetd-config-*.toml")
if err != nil {
return fmt.Errorf("failed to create temp greetd config: %w", err)
}
defer os.Remove(tmpFile.Name())
if _, err := tmpFile.WriteString(newConfig); err != nil {
_ = tmpFile.Close()
return fmt.Errorf("failed to write temp greetd config: %w", err)
}
if err := tmpFile.Close(); err != nil {
return fmt.Errorf("failed to close temp greetd config: %w", err)
}
if err := writeGreetdConfig(configPath, newConfig, logFunc, sudoPassword, fmt.Sprintf("✓ Updated greetd configuration (user: %s, command: %s)", greeterUser, commandValue)); err != nil {
return err
if err := privesc.Run(context.Background(), sudoPassword, "mkdir", "-p", "/etc/greetd"); err != nil {
return fmt.Errorf("failed to create /etc/greetd: %w", err)
}
if err := privesc.Run(context.Background(), sudoPassword, "install", "-o", "root", "-g", "root", "-m", "0644", tmpFile.Name(), configPath); err != nil {
return fmt.Errorf("failed to install greetd config: %w", err)
}
logFunc(fmt.Sprintf("✓ Updated greetd configuration (user: %s, command: %s)", greeterUser, commandValue))
return nil
}
-145
View File
@@ -3,7 +3,6 @@ package greeter
import (
"os"
"path/filepath"
"strings"
"testing"
)
@@ -97,147 +96,3 @@ func TestResolveGreeterThemeSyncState(t *testing.T) {
})
}
}
func TestUpsertInitialSession(t *testing.T) {
t.Parallel()
baseConfig := `[terminal]
vt = 1
[default_session]
user = "greeter"
command = "/usr/bin/dms-greeter --command niri"
`
t.Run("inserts initial session", func(t *testing.T) {
t.Parallel()
got := upsertInitialSession(baseConfig, "alice", "niri", true)
if !strings.Contains(got, "[initial_session]") {
t.Fatalf("expected [initial_session] section, got:\n%s", got)
}
if !strings.Contains(got, `user = "alice"`) {
t.Fatalf("expected alice user in initial session, got:\n%s", got)
}
if !strings.Contains(got, `env XDG_SESSION_TYPE=wayland sh -c 'exec niri'`) {
t.Fatalf("expected wrapped session command, got:\n%s", got)
}
})
t.Run("updates existing initial session", func(t *testing.T) {
t.Parallel()
existing := baseConfig + `
[initial_session]
user = "bob"
command = "old-command"
`
got := upsertInitialSession(existing, "alice", "Hyprland", true)
if strings.Contains(got, `user = "bob"`) {
t.Fatalf("expected bob to be replaced, got:\n%s", got)
}
if !strings.Contains(got, `exec Hyprland`) {
t.Fatalf("expected Hyprland command, got:\n%s", got)
}
})
t.Run("removes initial session when disabled", func(t *testing.T) {
t.Parallel()
existing := baseConfig + `
[initial_session]
user = "alice"
command = "niri"
`
got := upsertInitialSession(existing, "", "", false)
if strings.Contains(got, "[initial_session]") {
t.Fatalf("expected initial session removed, got:\n%s", got)
}
if !strings.Contains(got, "[default_session]") {
t.Fatalf("expected default session preserved, got:\n%s", got)
}
})
}
func TestStripDesktopExecCodes(t *testing.T) {
t.Parallel()
got := stripDesktopExecCodes("niri --session %f")
want := "niri --session"
if got != want {
t.Fatalf("stripDesktopExecCodes = %q, want %q", got, want)
}
}
func TestResolveGreeterAutoLoginState(t *testing.T) {
t.Parallel()
cacheDir := t.TempDir()
homeDir := t.TempDir()
writeTestFile(t, filepath.Join(cacheDir, "settings.json"), `{
"greeterAutoLogin": true,
"greeterRememberLastUser": true,
"greeterRememberLastSession": true
}`)
writeTestFile(t, filepath.Join(cacheDir, ".local/state/memory.json"), `{
"lastSuccessfulUser": "alice",
"lastSessionExec": "niri"
}`)
enabled, loginUser, sessionExec, err := resolveGreeterAutoLoginState(cacheDir, homeDir)
if err != nil {
t.Fatalf("resolveGreeterAutoLoginState returned error: %v", err)
}
if !enabled || loginUser != "alice" || sessionExec != "niri" {
t.Fatalf("got enabled=%v user=%q exec=%q", enabled, loginUser, sessionExec)
}
}
func TestResolveGreeterAutoLoginStateIgnoresMemoryFlag(t *testing.T) {
t.Parallel()
cacheDir := t.TempDir()
homeDir := t.TempDir()
writeTestFile(t, filepath.Join(cacheDir, "settings.json"), `{
"greeterAutoLogin": false,
"greeterRememberLastUser": true,
"greeterRememberLastSession": true
}`)
writeTestFile(t, filepath.Join(cacheDir, ".local/state/memory.json"), `{
"autoLoginEnabled": true,
"lastSuccessfulUser": "alice",
"lastSessionExec": "niri"
}`)
enabled, loginUser, sessionExec, err := resolveGreeterAutoLoginState(cacheDir, homeDir)
if err != nil {
t.Fatalf("resolveGreeterAutoLoginState returned error: %v", err)
}
if enabled || loginUser != "" || sessionExec != "" {
t.Fatalf("expected disabled with empty user/exec, got enabled=%v user=%q exec=%q", enabled, loginUser, sessionExec)
}
}
func TestClearGreeterAutoLoginMemory(t *testing.T) {
t.Parallel()
memoryPath := filepath.Join(t.TempDir(), "memory.json")
writeTestFile(t, memoryPath, `{
"autoLoginEnabled": true,
"lastSuccessfulUser": "alice"
}`)
if err := clearGreeterAutoLoginMemory(memoryPath, ""); err != nil {
t.Fatalf("clearGreeterAutoLoginMemory returned error: %v", err)
}
data, err := os.ReadFile(memoryPath)
if err != nil {
t.Fatalf("failed to read memory file: %v", err)
}
if strings.Contains(string(data), "autoLoginEnabled") {
t.Fatalf("expected autoLoginEnabled removed, got: %s", string(data))
}
if !strings.Contains(string(data), "lastSuccessfulUser") {
t.Fatalf("expected other memory fields preserved, got: %s", string(data))
}
}
-548
View File
@@ -1,548 +0,0 @@
package greeter
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"os/user"
"path/filepath"
"regexp"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
var monitorWallpaperSanitizer = regexp.MustCompile(`[^a-zA-Z0-9]+`)
func userGreeterCacheDir(cacheDir, username string) string {
return filepath.Join(cacheDir, "users", username)
}
func isUserOwnedGreeterCacheSlot(path, username string) bool {
if strings.TrimSpace(username) == "" {
return false
}
userDir, err := filepath.Abs(userGreeterCacheDir(GreeterCacheDir, username))
if err != nil {
return false
}
abs, err := filepath.Abs(path)
if err != nil {
return false
}
return abs == userDir || strings.HasPrefix(abs, userDir+string(filepath.Separator))
}
func UserIsInGreeterGroup(username string) bool {
group := DetectGreeterGroup()
if !utils.HasGroup(group) {
return false
}
groupsCmd := exec.Command("groups", username)
groupsOutput, err := groupsCmd.Output()
if err != nil {
return false
}
return strings.Contains(string(groupsOutput), group)
}
func CanSyncOwnUserGreeterProfile(username string) bool {
currentUser, err := user.Current()
if err != nil || currentUser.Username != username {
return false
}
if !UserIsInGreeterGroup(username) {
return false
}
usersDir := filepath.Join(GreeterCacheDir, "users")
if st, err := os.Stat(usersDir); err != nil || !st.IsDir() {
return false
}
testFile := filepath.Join(usersDir, ".write-test-"+username)
file, err := os.OpenFile(testFile, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o660)
if err != nil {
return false
}
_ = file.Close()
_ = os.Remove(testFile)
return true
}
func GreeterProfileSyncReady() bool {
if command := readGreeterSessionCommand(); command != "" && strings.Contains(command, "dms-greeter") {
return true
}
usersDir := filepath.Join(GreeterCacheDir, "users")
st, err := os.Stat(usersDir)
return err == nil && st.IsDir()
}
func readGreeterSessionCommand() string {
data, err := os.ReadFile("/etc/greetd/config.toml")
if err != nil {
return ""
}
inDefaultSession := false
for line := range strings.SplitSeq(string(data), "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
inDefaultSession = strings.EqualFold(strings.Trim(trimmed, "[]"), "default_session")
continue
}
if !inDefaultSession {
continue
}
if idx := strings.Index(trimmed, "#"); idx >= 0 {
trimmed = strings.TrimSpace(trimmed[:idx])
}
if !strings.HasPrefix(trimmed, "command") {
continue
}
parts := strings.SplitN(trimmed, "=", 2)
if len(parts) != 2 {
continue
}
command := strings.Trim(strings.TrimSpace(parts[1]), `"`)
if command != "" {
return command
}
}
return ""
}
// SyncUserProfileCache writes the current user's theme slot under users/<username>/
// without modifying greetd or other system configuration. Requires membership in the
// greeter group and a prior full greeter setup by an administrator.
func SyncUserProfileCache(logFunc func(string)) error {
if logFunc == nil {
logFunc = func(string) {}
}
if !GreeterProfileSyncReady() {
return fmt.Errorf("greeter is not set up on this system yet; an administrator must run 'dms greeter install' or 'dms greeter sync' once first")
}
currentUser, err := user.Current()
if err != nil {
return fmt.Errorf("failed to resolve current user: %w", err)
}
if !CanSyncOwnUserGreeterProfile(currentUser.Username) {
group := DetectGreeterGroup()
return fmt.Errorf("cannot sync greeter profile: you must be in the %s group with write access to %s/users\nAsk an administrator to run:\n sudo usermod -aG %s %s\nThen log out and back in before running:\n dms greeter sync --profile",
group, GreeterCacheDir, group, currentUser.Username)
}
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err)
}
state, err := resolveGreeterThemeSyncState(homeDir)
if err != nil {
return fmt.Errorf("failed to resolve greeter color source: %w", err)
}
if err := syncUserGreeterCacheSlot(homeDir, GreeterCacheDir, currentUser.Username, state, logFunc, userSlotSyncOpts{
profileOnly: true,
}); err != nil {
return err
}
logFunc(fmt.Sprintf(" → %s/users/%s/", GreeterCacheDir, currentUser.Username))
return nil
}
func canWriteUserGreeterCacheSlot(dest, username string) bool {
return isUserOwnedGreeterCacheSlot(dest, username) && CanSyncOwnUserGreeterProfile(username)
}
type userSlotSyncOpts struct {
sudoPassword string
profileOnly bool
username string
}
func (o userSlotSyncOpts) useDirectWrite(dest string) bool {
if !o.profileOnly {
return false
}
return canWriteUserGreeterCacheSlot(dest, o.username)
}
func isGreeterCachePath(path string) bool {
abs, err := filepath.Abs(path)
if err != nil {
return true
}
cacheAbs, err := filepath.Abs(GreeterCacheDir)
if err != nil {
return true
}
if abs == cacheAbs {
return true
}
return strings.HasPrefix(abs, cacheAbs+string(filepath.Separator))
}
func greeterCacheOwner() string {
greeterGroup := DetectGreeterGroup()
daemonUser := DetectGreeterUser()
return daemonUser + ":" + greeterGroup
}
func ensureGreeterCacheSubdir(dir string, opts userSlotSyncOpts) error {
if opts.useDirectWrite(dir) {
if err := os.MkdirAll(dir, 0o770); err != nil {
return fmt.Errorf("failed to create cache directory %s: %w", dir, err)
}
return nil
}
if err := privesc.Run(context.Background(), opts.sudoPassword, "mkdir", "-p", dir); err != nil {
return fmt.Errorf("failed to create cache directory %s: %w", dir, err)
}
owner := greeterCacheOwner()
if err := privesc.Run(context.Background(), opts.sudoPassword, "chown", owner, dir); err != nil {
if fallbackErr := privesc.Run(context.Background(), opts.sudoPassword, "chown", "root:"+DetectGreeterGroup(), dir); fallbackErr != nil {
return fmt.Errorf("failed to set ownership on %s: %w", dir, err)
}
}
if err := privesc.Run(context.Background(), opts.sudoPassword, "chmod", "2770", dir); err != nil {
return fmt.Errorf("failed to set permissions on %s: %w", dir, err)
}
return nil
}
func setGreeterCacheFileOwnership(path, sudoPassword string) error {
owner := greeterCacheOwner()
if err := privesc.Run(context.Background(), sudoPassword, "chown", owner, path); err != nil {
if fallbackErr := privesc.Run(context.Background(), sudoPassword, "chown", "root:"+DetectGreeterGroup(), path); fallbackErr != nil {
return fmt.Errorf("failed to set ownership on %s: %w", path, err)
}
}
if err := privesc.Run(context.Background(), sudoPassword, "chmod", "644", path); err != nil {
return fmt.Errorf("failed to set permissions on %s: %w", path, err)
}
return nil
}
func syncUserGreeterCacheSlot(homeDir, cacheDir, username string, state greeterThemeSyncState, logFunc func(string), opts userSlotSyncOpts) error {
if strings.TrimSpace(username) == "" {
return nil
}
opts.username = username
userDir := userGreeterCacheDir(cacheDir, username)
if err := ensureGreeterCacheSubdir(userDir, opts); err != nil {
return err
}
settingsPath := filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json")
settingsBytes, err := os.ReadFile(settingsPath)
if err != nil {
return fmt.Errorf("failed to read settings for user cache slot: %w", err)
}
settingsMap := map[string]any{}
if strings.TrimSpace(string(settingsBytes)) != "" {
if err := json.Unmarshal(settingsBytes, &settingsMap); err != nil {
return fmt.Errorf("failed to parse settings for user cache slot: %w", err)
}
}
if customTheme, ok := settingsMap["customThemeFile"].(string); ok && strings.TrimSpace(customTheme) != "" {
resolvedTheme := customTheme
if !filepath.IsAbs(resolvedTheme) {
resolvedTheme = filepath.Join(homeDir, resolvedTheme)
}
if st, statErr := os.Stat(resolvedTheme); statErr == nil && !st.IsDir() {
destTheme := filepath.Join(userDir, "custom-theme.json")
if err := copyFileWithPrivesc(resolvedTheme, destTheme, opts); err != nil {
return err
}
settingsMap["customThemeFile"] = destTheme
}
}
settingsBytes, err = json.Marshal(settingsMap)
if err != nil {
return fmt.Errorf("failed to marshal settings for user cache slot: %w", err)
}
if err := writeFileWithPrivesc(filepath.Join(userDir, "settings.json"), settingsBytes, opts); err != nil {
return err
}
sessionPath := filepath.Join(homeDir, ".local", "state", "DankMaterialShell", "session.json")
sessionBytes, err := os.ReadFile(sessionPath)
if err != nil {
return fmt.Errorf("failed to read session for user cache slot: %w", err)
}
sessionMap := map[string]any{}
if strings.TrimSpace(string(sessionBytes)) != "" {
if err := json.Unmarshal(sessionBytes, &sessionMap); err != nil {
return fmt.Errorf("failed to parse session for user cache slot: %w", err)
}
}
if err := localizeSessionWallpapers(sessionMap, userDir, opts); err != nil {
return err
}
sessionBytes, err = json.Marshal(sessionMap)
if err != nil {
return fmt.Errorf("failed to marshal session for user cache slot: %w", err)
}
if err := writeFileWithPrivesc(filepath.Join(userDir, "session.json"), sessionBytes, opts); err != nil {
return err
}
colorsSource := state.effectiveColorsSource(homeDir)
if err := copyFileWithPrivesc(colorsSource, filepath.Join(userDir, "colors.json"), opts); err != nil {
return fmt.Errorf("failed to copy colors for user cache slot: %w", err)
}
if err := syncUserProfileImage(homeDir, userDir, opts); err != nil {
return err
}
rootOverride := filepath.Join(cacheDir, "greeter_wallpaper_override.jpg")
userOverride := filepath.Join(userDir, "greeter_wallpaper_override.jpg")
if st, statErr := os.Stat(rootOverride); statErr == nil && !st.IsDir() {
if err := copyFileWithPrivesc(rootOverride, userOverride, opts); err != nil {
return fmt.Errorf("failed to copy greeter wallpaper override for user cache slot: %w", err)
}
} else if opts.useDirectWrite(userOverride) {
_ = os.Remove(userOverride)
} else {
_ = privesc.Run(context.Background(), opts.sudoPassword, "rm", "-f", userOverride)
}
logFunc(fmt.Sprintf("✓ Synced per-user greeter cache for %s", username))
return nil
}
func localizeSessionWallpapers(session map[string]any, userDir string, opts userSlotSyncOpts) error {
stringKeys := []struct {
key string
prefix string
}{
{"wallpaperPath", "wallpaper"},
{"wallpaperPathLight", "wallpaper-light"},
{"wallpaperPathDark", "wallpaper-dark"},
}
for _, item := range stringKeys {
if err := localizeWallpaperStringField(session, item.key, userDir, item.prefix, opts); err != nil {
return err
}
}
mapKeys := []struct {
key string
prefix string
}{
{"monitorWallpapers", "wallpaper-monitor"},
{"monitorWallpapersLight", "wallpaper-monitor-light"},
{"monitorWallpapersDark", "wallpaper-monitor-dark"},
}
for _, item := range mapKeys {
if err := localizeWallpaperMapField(session, item.key, userDir, item.prefix, opts); err != nil {
return err
}
}
return nil
}
func localizeWallpaperStringField(session map[string]any, key, userDir, prefix string, opts userSlotSyncOpts) error {
raw, ok := session[key]
if !ok {
return nil
}
path, ok := raw.(string)
if !ok || strings.TrimSpace(path) == "" {
return nil
}
dest, err := copyWallpaperIntoUserCache(path, userDir, prefix, opts)
if err != nil {
return err
}
if dest != "" {
session[key] = dest
}
return nil
}
func localizeWallpaperMapField(session map[string]any, key, userDir, prefix string, opts userSlotSyncOpts) error {
raw, ok := session[key]
if !ok || raw == nil {
return nil
}
values, ok := raw.(map[string]any)
if !ok {
return nil
}
for monitor, rawPath := range values {
path, ok := rawPath.(string)
if !ok || strings.TrimSpace(path) == "" {
continue
}
safeMonitor := monitorWallpaperSanitizer.ReplaceAllString(monitor, "-")
dest, err := copyWallpaperIntoUserCache(path, userDir, prefix+"-"+safeMonitor, opts)
if err != nil {
return err
}
if dest != "" {
values[monitor] = dest
}
}
return nil
}
func copyWallpaperIntoUserCache(srcPath, userDir, prefix string, opts userSlotSyncOpts) (string, error) {
if strings.TrimSpace(srcPath) == "" {
return "", nil
}
st, err := os.Stat(srcPath)
if err != nil || st.IsDir() {
return "", nil
}
ext := filepath.Ext(srcPath)
if ext == "" {
ext = ".jpg"
}
dest := filepath.Join(userDir, prefix+ext)
if err := copyFileWithPrivesc(srcPath, dest, opts); err != nil {
return "", err
}
return dest, nil
}
func copyFileWithPrivesc(src, dest string, opts userSlotSyncOpts) error {
if opts.useDirectWrite(dest) {
if err := os.MkdirAll(filepath.Dir(dest), 0o770); err != nil {
return fmt.Errorf("failed to create parent dir for %s: %w", dest, err)
}
data, err := os.ReadFile(src)
if err != nil {
return fmt.Errorf("failed to read %s: %w", src, err)
}
if err := os.WriteFile(dest, data, 0o644); err != nil {
return fmt.Errorf("failed to write %s: %w", dest, err)
}
return nil
}
if !isGreeterCachePath(dest) {
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return fmt.Errorf("failed to create parent dir for %s: %w", dest, err)
}
data, err := os.ReadFile(src)
if err != nil {
return fmt.Errorf("failed to read %s: %w", src, err)
}
if err := os.WriteFile(dest, data, 0o644); err != nil {
return fmt.Errorf("failed to write %s: %w", dest, err)
}
return nil
}
_ = privesc.Run(context.Background(), opts.sudoPassword, "rm", "-f", dest)
if err := privesc.Run(context.Background(), opts.sudoPassword, "cp", src, dest); err != nil {
return fmt.Errorf("failed to copy %s to %s: %w", src, dest, err)
}
return setGreeterCacheFileOwnership(dest, opts.sudoPassword)
}
func writeFileWithPrivesc(path string, data []byte, opts userSlotSyncOpts) error {
if opts.useDirectWrite(path) {
if err := os.MkdirAll(filepath.Dir(path), 0o770); err != nil {
return fmt.Errorf("failed to create parent dir for %s: %w", path, err)
}
if err := os.WriteFile(path, data, 0o644); err != nil {
return fmt.Errorf("failed to write %s: %w", path, err)
}
return nil
}
if !isGreeterCachePath(path) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("failed to create parent dir for %s: %w", path, err)
}
if err := os.WriteFile(path, data, 0o644); err != nil {
return fmt.Errorf("failed to write %s: %w", path, err)
}
return nil
}
tmp, err := os.CreateTemp("", "dms-greeter-user-cache-*")
if err != nil {
return fmt.Errorf("failed to create temp file for %s: %w", path, err)
}
tmpPath := tmp.Name()
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
_ = os.Remove(tmpPath)
return fmt.Errorf("failed to write temp file for %s: %w", path, err)
}
if err := tmp.Close(); err != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("failed to close temp file for %s: %w", path, err)
}
defer os.Remove(tmpPath)
_ = privesc.Run(context.Background(), opts.sudoPassword, "rm", "-f", path)
if err := privesc.Run(context.Background(), opts.sudoPassword, "cp", tmpPath, path); err != nil {
return fmt.Errorf("failed to install %s: %w", path, err)
}
return setGreeterCacheFileOwnership(path, opts.sudoPassword)
}
func resolveUserProfileImageSource(homeDir string) string {
candidates := []string{
filepath.Join(homeDir, ".face"),
filepath.Join(homeDir, ".face.icon"),
}
if homeDir != "" {
username := filepath.Base(homeDir)
if username != "" && username != "." && username != string(filepath.Separator) {
candidates = append([]string{filepath.Join("/var/lib/AccountsService/icons", username)}, candidates...)
}
}
for _, src := range candidates {
st, err := os.Stat(src)
if err == nil && !st.IsDir() && st.Size() > 0 {
return src
}
}
return ""
}
func syncUserProfileImage(homeDir, userDir string, opts userSlotSyncOpts) error {
for _, name := range []string{"profile.jpg", "profile.jpeg", "profile.png", "profile.webp"} {
path := filepath.Join(userDir, name)
if opts.useDirectWrite(path) {
_ = os.Remove(path)
} else {
_ = privesc.Run(context.Background(), opts.sudoPassword, "rm", "-f", path)
}
}
src := resolveUserProfileImageSource(homeDir)
if src == "" {
return nil
}
ext := filepath.Ext(src)
if ext == "" {
ext = ".jpg"
}
dest := filepath.Join(userDir, "profile"+ext)
if err := copyFileWithPrivesc(src, dest, opts); err != nil {
return fmt.Errorf("failed to copy profile image for user cache slot: %w", err)
}
return nil
}
@@ -1,81 +0,0 @@
package greeter
import (
"path/filepath"
"testing"
)
func TestUserGreeterCacheDir(t *testing.T) {
t.Parallel()
got := userGreeterCacheDir("/var/cache/dms-greeter", "alice")
want := filepath.Join("/var/cache/dms-greeter", "users", "alice")
if got != want {
t.Fatalf("userGreeterCacheDir() = %q, want %q", got, want)
}
}
func TestResolveUserProfileImageSource(t *testing.T) {
t.Parallel()
homeDir := t.TempDir()
facePath := filepath.Join(homeDir, ".face")
writeTestFile(t, facePath, "face")
got := resolveUserProfileImageSource(homeDir)
if got != facePath {
t.Fatalf("resolveUserProfileImageSource() = %q, want %q", got, facePath)
}
}
func TestIsUserOwnedGreeterCacheSlot(t *testing.T) {
t.Parallel()
slot := filepath.Join(GreeterCacheDir, "users", "alice", "settings.json")
if !isUserOwnedGreeterCacheSlot(slot, "alice") {
t.Fatalf("expected alice to own %q", slot)
}
if isUserOwnedGreeterCacheSlot(slot, "bob") {
t.Fatalf("expected bob not to own alice slot")
}
if isUserOwnedGreeterCacheSlot(filepath.Join(GreeterCacheDir, "settings.json"), "alice") {
t.Fatalf("expected root cache file not to be a user slot")
}
}
func TestLocalizeSessionWallpapers(t *testing.T) {
t.Parallel()
homeDir := t.TempDir()
userDir := filepath.Join(homeDir, "users", "alice")
wallpaperPath := filepath.Join(homeDir, "wall.jpg")
writeTestFile(t, wallpaperPath, "wallpaper")
session := map[string]any{
"wallpaperPath": wallpaperPath,
"monitorWallpapers": map[string]any{
"DP-1": wallpaperPath,
},
}
if err := localizeSessionWallpapers(session, userDir, userSlotSyncOpts{}); err != nil {
t.Fatalf("localizeSessionWallpapers returned error: %v", err)
}
gotPath, ok := session["wallpaperPath"].(string)
if !ok || gotPath == "" {
t.Fatalf("expected localized wallpaperPath, got %#v", session["wallpaperPath"])
}
if gotPath == wallpaperPath {
t.Fatalf("expected copied wallpaper path, still points to source")
}
monitorMap, ok := session["monitorWallpapers"].(map[string]any)
if !ok {
t.Fatalf("expected monitorWallpapers map")
}
monitorPath, ok := monitorMap["DP-1"].(string)
if !ok || monitorPath == "" || monitorPath == wallpaperPath {
t.Fatalf("expected localized monitor wallpaper, got %#v", monitorMap["DP-1"])
}
}
+1 -3
View File
@@ -364,10 +364,8 @@ func (r *Runner) parseWindowManager() (deps.WindowManager, error) {
return deps.WindowManagerNiri, nil
case "hyprland":
return deps.WindowManagerHyprland, nil
case "mango", "mangowc":
return deps.WindowManagerMango, nil
default:
return 0, fmt.Errorf("invalid --compositor value %q: must be 'niri', 'hyprland', or 'mango'", r.cfg.Compositor)
return 0, fmt.Errorf("invalid --compositor value %q: must be 'niri' or 'hyprland'", r.cfg.Compositor)
}
}
+28 -683
View File
@@ -68,8 +68,6 @@ func (h *HyprlandProvider) GetCheatSheet() (*keybinds.CheatSheet, error) {
Effective: result.DMSStatus.Effective,
OverriddenBy: result.DMSStatus.OverriddenBy,
StatusMessage: result.DMSStatus.StatusMessage,
ConfigFormat: result.DMSStatus.ConfigFormat,
ReadOnly: result.DMSStatus.ReadOnly,
}
}
@@ -221,9 +219,6 @@ func (h *HyprlandProvider) validateAction(action string) error {
}
func (h *HyprlandProvider) SetBind(key, action, description string, options map[string]any) error {
if err := h.ensureWritableConfig(); err != nil {
return err
}
if err := h.validateAction(action); err != nil {
return err
}
@@ -247,10 +242,9 @@ func (h *HyprlandProvider) SetBind(key, action, description string, options map[
}
}
canonicalKey := canonicalHyprlandOverrideKey(key)
normalizedKey := hyprlandOverrideMapKey(canonicalKey)
normalizedKey := strings.ToLower(key)
existingBinds[normalizedKey] = &hyprlandOverrideBind{
Key: canonicalKey,
Key: key,
Action: action,
Description: description,
Flags: flags,
@@ -261,28 +255,21 @@ func (h *HyprlandProvider) SetBind(key, action, description string, options map[
}
func (h *HyprlandProvider) RemoveBind(key string) error {
if err := h.ensureWritableConfig(); err != nil {
return err
}
existingBinds, err := h.loadOverrideBinds()
if err != nil {
return nil
}
canonicalKey := canonicalHyprlandOverrideKey(key)
normalizedKey := hyprlandOverrideMapKey(canonicalKey)
existingBinds[normalizedKey] = &hyprlandOverrideBind{Key: canonicalKey, Unbind: true}
normalizedKey := strings.ToLower(key)
existingBinds[normalizedKey] = &hyprlandOverrideBind{Key: key, Unbind: true}
return h.writeOverrideBinds(existingBinds)
}
func (h *HyprlandProvider) ResetBind(key string) error {
if err := h.ensureWritableConfig(); err != nil {
return err
}
existingBinds, err := h.loadOverrideBinds()
if err != nil {
return nil
}
normalizedKey := hyprlandOverrideMapKey(key)
normalizedKey := strings.ToLower(key)
delete(existingBinds, normalizedKey)
return h.writeOverrideBinds(existingBinds)
}
@@ -297,46 +284,10 @@ type hyprlandOverrideBind struct {
Unbind bool
}
func (h *HyprlandProvider) ensureWritableConfig() error {
if h.isLegacyConfigReadOnly() {
return fmt.Errorf("hyprland legacy conf configs are read-only; run dms setup to migrate to Lua before editing keybinds")
}
return nil
}
func (h *HyprlandProvider) isLegacyConfigReadOnly() bool {
expanded, err := utils.ExpandPath(h.configPath)
if err != nil {
expanded = h.configPath
}
luaPath := filepath.Join(expanded, "hyprland.lua")
if st, err := os.Stat(luaPath); err == nil && st.Mode().IsRegular() {
return false
}
confPath := filepath.Join(expanded, "hyprland.conf")
if st, err := os.Stat(confPath); err == nil && st.Mode().IsRegular() {
return true
}
return false
}
func (h *HyprlandProvider) loadOverrideBinds() (map[string]*hyprlandOverrideBind, error) {
return readLuaOrHyprlangOverride(h.GetOverridePath())
}
func canonicalHyprlandOverrideKey(key string) string {
trimmed := strings.TrimSpace(key)
normalized := luaKeyComboToInternalKey(trimmed)
if normalized == "" {
return trimmed
}
return normalized
}
func hyprlandOverrideMapKey(key string) string {
return strings.ToLower(canonicalHyprlandOverrideKey(key))
}
func (h *HyprlandProvider) getBindSortPriority(action string) int {
switch {
case strings.HasPrefix(action, "exec") && strings.Contains(action, "dms"):
@@ -417,629 +368,24 @@ func normalizeLuaBindKeyPart(part string) string {
return part
}
type luaField struct {
name string
value string
}
func luaDispatcherTableCall(funcName string, fields ...luaField) string {
parts := make([]string, 0, len(fields))
for _, field := range fields {
if field.name == "" || field.value == "" {
continue
}
parts = append(parts, field.name+" = "+field.value)
}
return fmt.Sprintf(`%s({ %s })`, funcName, strings.Join(parts, ", "))
}
func luaStringField(name, value string) luaField {
return luaField{name: name, value: strconv.Quote(strings.TrimSpace(value))}
}
func luaBoolField(name string, value bool) luaField {
if value {
return luaField{name: name, value: "true"}
}
return luaField{name: name, value: "false"}
}
func luaNumberOrStringField(name, value string) luaField {
value = strings.TrimSpace(value)
if isBareLuaNumber(value) {
return luaField{name: name, value: value}
}
return luaStringField(name, value)
}
func isBareLuaNumber(value string) bool {
if value == "" || strings.HasPrefix(value, "+") {
return false
}
if value[0] == '-' {
value = value[1:]
}
if value == "" {
return false
}
digitsBeforeDot := 0
i := 0
for i < len(value) && value[i] >= '0' && value[i] <= '9' {
digitsBeforeDot++
i++
}
digitsAfterDot := 0
if i < len(value) && value[i] == '.' {
i++
for i < len(value) && value[i] >= '0' && value[i] <= '9' {
digitsAfterDot++
i++
}
}
return i == len(value) && (digitsBeforeDot > 0 || digitsAfterDot > 0)
}
func splitHyprlandAction(action string) (dispatcher, params string) {
action = strings.TrimSpace(action)
if action == "" {
return "", ""
}
idx := strings.IndexFunc(action, func(r rune) bool {
return r == ' ' || r == '\t' || r == '\r' || r == '\n'
})
if idx < 0 {
return strings.ToLower(action), ""
}
return strings.ToLower(strings.TrimSpace(action[:idx])), strings.TrimSpace(action[idx+1:])
}
func isKnownHyprlandDispatcher(dispatcher string) bool {
switch dispatcher {
case "exec", "execr", "spawn",
"killactive", "forcekillactive", "closewindow", "killwindow",
"signal", "signalwindow", "togglefloating", "setfloating", "settiled",
"workspace", "renameworkspace", "fullscreen", "fullscreenstate", "fakefullscreen",
"movetoworkspace", "movetoworkspacesilent", "pseudo", "movefocus",
"movewindow", "swapwindow", "centerwindow", "togglegroup", "changegroupactive",
"movegroupwindow", "focusmonitor", "movecursortocorner", "movecursor",
"workspaceopt", "exit", "movecurrentworkspacetomonitor", "focusworkspaceoncurrentmonitor",
"moveworkspacetomonitor", "togglespecialworkspace", "forcerendererreload",
"resizeactive", "moveactive", "cyclenext", "focuswindowbyclass", "focuswindow",
"tagwindow", "toggleswallow", "submap", "pass", "sendshortcut", "sendkeystate",
"layoutmsg", "splitratio", "dpms", "movewindowpixel", "resizewindowpixel",
"swapnext", "swapactiveworkspaces", "pin", "mouse", "bringactivetotop",
"alterzorder", "focusurgentorlast", "focuscurrentorlast", "lockgroups",
"lockactivegroup", "moveintogroup", "moveoutofgroup", "movewindoworgroup",
"moveintoorcreategroup", "setignoregrouplock", "denywindowfromgroup", "event",
"global", "setprop", "forceidle":
return true
default:
return false
}
}
func firstParam(params string) (head, rest string) {
params = strings.TrimSpace(params)
if params == "" {
return "", ""
}
fields := strings.Fields(params)
if len(fields) == 0 {
return "", ""
}
head = fields[0]
rest = strings.TrimSpace(strings.TrimPrefix(params, head))
return head, rest
}
func xyParams(params string) (x, y string, relative bool, ok bool) {
fields := strings.Fields(params)
if len(fields) > 0 && strings.EqualFold(fields[0], "exact") {
relative = false
fields = fields[1:]
} else {
relative = true
}
if len(fields) < 2 {
return "", "", relative, false
}
return fields[0], fields[1], relative, true
}
func dispatcherWorkspaceMove(params string, follow *bool) string {
workspace, window := firstParam(params)
if workspace == "" {
return ""
}
fields := []luaField{luaStringField("workspace", workspace)}
if follow != nil {
fields = append(fields, luaBoolField("follow", *follow))
}
if window != "" {
fields = append(fields, luaStringField("window", window))
}
return luaDispatcherTableCall("hl.dsp.window.move", fields...)
}
func dispatcherActiveMoveResize(funcName, params string) string {
x, y, relative, ok := xyParams(params)
if !ok {
return ""
}
if !isBareLuaNumber(x) || !isBareLuaNumber(y) {
return ""
}
return luaDispatcherTableCall(funcName,
luaNumberOrStringField("x", x),
luaNumberOrStringField("y", y),
luaBoolField("relative", relative),
)
}
func dispatcherWindowMoveResize(funcName, params string) string {
geometry, window := splitCommaParams(params)
x, y, relative, ok := xyParams(geometry)
if !ok {
return ""
}
if !isBareLuaNumber(x) || !isBareLuaNumber(y) {
return ""
}
fields := []luaField{
luaNumberOrStringField("x", x),
luaNumberOrStringField("y", y),
luaBoolField("relative", relative),
}
if window != "" {
fields = append(fields, luaStringField("window", window))
}
return luaDispatcherTableCall(funcName, fields...)
}
func splitCommaParams(params string) (left, right string) {
left = strings.TrimSpace(params)
if idx := strings.Index(left, ","); idx >= 0 {
right = strings.TrimSpace(left[idx+1:])
left = strings.TrimSpace(left[:idx])
}
return left, right
}
func luaHyprctlDispatchFunction(action string) string {
return fmt.Sprintf(`function() hl.exec_cmd(%s) end`, strconv.Quote("hyprctl dispatch "+strings.TrimSpace(action)))
}
func luaToggleActionValue(params string) string {
switch strings.ToLower(strings.TrimSpace(params)) {
case "on", "enable", "enabled", "set", "lock":
return "on"
case "off", "disable", "disabled", "unset", "unlock":
return "off"
default:
return "toggle"
}
}
func dispatcherToggleTableCall(funcName, params string) string {
return luaDispatcherTableCall(funcName, luaStringField("action", luaToggleActionValue(params)))
}
func dispatcherCycleNext(params string) string {
params = strings.TrimSpace(strings.ToLower(params))
if params == "" {
return `hl.dsp.window.cycle_next()`
}
fields := []luaField{}
for _, field := range strings.Fields(params) {
switch field {
case "prev", "previous", "b":
fields = append(fields, luaBoolField("next", false))
case "next", "f":
fields = append(fields, luaBoolField("next", true))
case "tiled":
fields = append(fields, luaBoolField("tiled", true))
case "floating":
fields = append(fields, luaBoolField("floating", true))
}
}
if len(fields) == 0 {
return ""
}
return luaDispatcherTableCall("hl.dsp.window.cycle_next", fields...)
}
func dispatcherSwapNext(params string) string {
switch strings.ToLower(strings.TrimSpace(params)) {
case "prev", "previous", "b":
return `hl.dsp.window.swap({ prev = true })`
default:
return `hl.dsp.window.swap({ next = true })`
}
}
func dispatcherGroupActive(params string) string {
switch strings.ToLower(strings.TrimSpace(params)) {
case "f", "next", "forward":
return `hl.dsp.group.next()`
case "b", "prev", "previous", "backward":
return `hl.dsp.group.prev()`
}
if isBareLuaNumber(params) {
return luaDispatcherTableCall("hl.dsp.group.active", luaNumberOrStringField("index", params))
}
return ""
}
func dispatcherMoveGroupWindow(params string) string {
switch strings.ToLower(strings.TrimSpace(params)) {
case "b", "prev", "previous", "backward":
return `hl.dsp.group.move_window({ forward = false })`
default:
return `hl.dsp.group.move_window({ forward = true })`
}
}
func dispatcherCursorMove(params string) string {
x, y, _, ok := xyParams(params)
if !ok || !isBareLuaNumber(x) || !isBareLuaNumber(y) {
return ""
}
return luaDispatcherTableCall("hl.dsp.cursor.move", luaNumberOrStringField("x", x), luaNumberOrStringField("y", y))
}
func dispatcherSignal(params string) string {
signal, window := firstParam(params)
if signal == "" || !isBareLuaNumber(signal) {
return ""
}
fields := []luaField{luaNumberOrStringField("signal", signal)}
if window != "" {
fields = append(fields, luaStringField("window", window))
}
return luaDispatcherTableCall("hl.dsp.window.signal", fields...)
}
func dispatcherSignalWindow(params string) string {
window, rest := firstParam(params)
signal, _ := firstParam(rest)
if signal == "" || !isBareLuaNumber(signal) {
return ""
}
fields := []luaField{luaNumberOrStringField("signal", signal)}
if window != "" {
fields = append(fields, luaStringField("window", window))
}
return luaDispatcherTableCall("hl.dsp.window.signal", fields...)
}
func dispatcherTagWindow(params string) string {
tag, window := firstParam(params)
if tag == "" {
return ""
}
fields := []luaField{luaStringField("tag", tag)}
if window != "" {
fields = append(fields, luaStringField("window", window))
}
return luaDispatcherTableCall("hl.dsp.window.tag", fields...)
}
func luaActionStringFromKnownHyprlandAction(action string) (string, bool) {
dispatcher, params := splitHyprlandAction(action)
switch dispatcher {
case "spawn", "exec":
return fmt.Sprintf(`hl.dsp.exec_cmd(%s)`, strconv.Quote(params)), true
case "execr":
return fmt.Sprintf(`hl.dsp.exec_raw(%s)`, strconv.Quote(params)), true
case "killactive":
return `hl.dsp.window.close()`, true
case "forcekillactive":
return `hl.dsp.window.kill()`, true
case "closewindow":
if params == "" {
return `hl.dsp.window.close()`, true
}
return luaDispatcherTableCall("hl.dsp.window.close", luaStringField("window", params)), true
case "killwindow":
if params == "" {
return `hl.dsp.window.kill()`, true
}
return luaDispatcherTableCall("hl.dsp.window.kill", luaStringField("window", params)), true
case "togglefloating":
return dispatcherToggleTableCall("hl.dsp.window.float", "toggle"), true
case "setfloating":
return dispatcherToggleTableCall("hl.dsp.window.float", "on"), true
case "settiled":
return dispatcherToggleTableCall("hl.dsp.window.float", "off"), true
case "fullscreen":
mode := strings.TrimSpace(params)
switch mode {
case "", "0":
return `hl.dsp.window.fullscreen({ mode = "fullscreen", action = "toggle" })`, true
case "1":
return `hl.dsp.window.fullscreen({ mode = "maximized", action = "toggle" })`, true
}
return luaHyprctlDispatchFunction(action), true
case "fullscreenstate":
internal, rest := firstParam(params)
client, _ := firstParam(rest)
if internal != "" && client != "" {
return luaDispatcherTableCall("hl.dsp.window.fullscreen_state",
luaNumberOrStringField("internal", internal),
luaNumberOrStringField("client", client),
), true
}
case "fakefullscreen":
return luaHyprctlDispatchFunction(action), true
case "pin":
if params == "" {
return `hl.dsp.window.pin()`, true
}
return dispatcherToggleTableCall("hl.dsp.window.pin", params), true
case "pseudo":
return dispatcherToggleTableCall("hl.dsp.window.pseudo", params), true
case "centerwindow":
return `hl.dsp.window.center()`, true
case "resizewindow":
return `hl.dsp.window.resize()`, true
case "movewindow":
if params == "" {
return `hl.dsp.window.drag()`, true
}
if monitor, ok := strings.CutPrefix(params, "mon:"); ok {
return luaDispatcherTableCall("hl.dsp.window.move", luaStringField("monitor", monitor)), true
}
return luaDispatcherTableCall("hl.dsp.window.move", luaStringField("direction", params)), true
case "swapwindow":
if params == "" {
return "", false
}
return luaDispatcherTableCall("hl.dsp.window.swap", luaStringField("direction", params)), true
case "swapnext":
return dispatcherSwapNext(params), true
case "resizeactive":
if expr := dispatcherActiveMoveResize("hl.dsp.window.resize", params); expr != "" {
return expr, true
}
return luaHyprctlDispatchFunction(action), true
case "moveactive":
if expr := dispatcherActiveMoveResize("hl.dsp.window.move", params); expr != "" {
return expr, true
}
return luaHyprctlDispatchFunction(action), true
case "resizewindowpixel":
if expr := dispatcherWindowMoveResize("hl.dsp.window.resize", params); expr != "" {
return expr, true
}
return luaHyprctlDispatchFunction(action), true
case "movewindowpixel":
if expr := dispatcherWindowMoveResize("hl.dsp.window.move", params); expr != "" {
return expr, true
}
return luaHyprctlDispatchFunction(action), true
case "workspace":
if params == "" {
return "", false
}
return luaDispatcherTableCall("hl.dsp.focus", luaStringField("workspace", params)), true
case "focusworkspaceoncurrentmonitor":
if params == "" {
return "", false
}
return luaDispatcherTableCall("hl.dsp.focus", luaStringField("workspace", params), luaBoolField("on_current_monitor", true)), true
case "movetoworkspace":
if expr := dispatcherWorkspaceMove(params, nil); expr != "" {
return expr, true
}
case "movetoworkspacesilent":
follow := false
if expr := dispatcherWorkspaceMove(params, &follow); expr != "" {
return expr, true
}
case "togglespecialworkspace":
if params == "" {
return `hl.dsp.workspace.toggle_special()`, true
}
return fmt.Sprintf(`hl.dsp.workspace.toggle_special(%s)`, strconv.Quote(params)), true
case "renameworkspace":
workspace, name := firstParam(params)
if workspace != "" {
fields := []luaField{luaStringField("workspace", workspace)}
if name != "" {
fields = append(fields, luaStringField("name", name))
}
return luaDispatcherTableCall("hl.dsp.workspace.rename", fields...), true
}
case "movecurrentworkspacetomonitor":
if params != "" {
return luaDispatcherTableCall("hl.dsp.workspace.move", luaStringField("monitor", params)), true
}
case "moveworkspacetomonitor":
workspace, monitor := firstParam(params)
if workspace != "" && monitor != "" {
return luaDispatcherTableCall("hl.dsp.workspace.move", luaStringField("workspace", workspace), luaStringField("monitor", monitor)), true
}
case "workspaceopt":
return luaHyprctlDispatchFunction(action), true
case "swapactiveworkspaces":
monitor1, rest := firstParam(params)
monitor2, _ := firstParam(rest)
if monitor1 != "" && monitor2 != "" {
return luaDispatcherTableCall("hl.dsp.workspace.swap_monitors", luaStringField("monitor1", monitor1), luaStringField("monitor2", monitor2)), true
}
case "movefocus":
if params != "" {
return luaDispatcherTableCall("hl.dsp.focus", luaStringField("direction", params)), true
}
case "focusmonitor":
if params != "" {
return luaDispatcherTableCall("hl.dsp.focus", luaStringField("monitor", params)), true
}
case "focuswindow":
if params != "" {
return luaDispatcherTableCall("hl.dsp.focus", luaStringField("window", params)), true
}
case "focuswindowbyclass":
if params != "" {
return luaDispatcherTableCall("hl.dsp.focus", luaStringField("window", "class:"+params)), true
}
case "focuscurrentorlast":
return `hl.dsp.focus({ last = true })`, true
case "focusurgentorlast":
return `hl.dsp.focus({ urgent_or_last = true })`, true
case "cyclenext":
if expr := dispatcherCycleNext(params); expr != "" {
return expr, true
}
return luaHyprctlDispatchFunction(action), true
case "layoutmsg":
if params != "" {
return fmt.Sprintf(`hl.dsp.layout(%s)`, strconv.Quote(params)), true
}
case "splitratio":
return luaHyprctlDispatchFunction(action), true
case "alterzorder":
mode, window := firstParam(params)
if mode != "" {
fields := []luaField{luaStringField("mode", mode)}
if window != "" {
fields = append(fields, luaStringField("window", window))
}
return luaDispatcherTableCall("hl.dsp.window.alter_zorder", fields...), true
}
case "setprop":
window, rest := firstParam(params)
prop, value := firstParam(rest)
if window != "" && prop != "" && value != "" {
return luaDispatcherTableCall("hl.dsp.window.set_prop",
luaStringField("window", window),
luaStringField("prop", prop),
luaStringField("value", value),
), true
}
case "bringactivetotop":
return `hl.dsp.window.bring_to_top()`, true
case "toggleswallow":
return `hl.dsp.window.toggle_swallow()`, true
case "signal":
if expr := dispatcherSignal(params); expr != "" {
return expr, true
}
case "signalwindow":
if expr := dispatcherSignalWindow(params); expr != "" {
return expr, true
}
case "tagwindow":
if expr := dispatcherTagWindow(params); expr != "" {
return expr, true
}
case "dpms":
dpmsAction := strings.TrimSpace(params)
switch dpmsAction {
case "on":
dpmsAction = "enable"
case "off":
dpmsAction = "disable"
}
if dpmsAction == "" {
return `hl.dsp.dpms({})`, true
}
return luaDispatcherTableCall("hl.dsp.dpms", luaStringField("action", dpmsAction)), true
case "exit":
return `hl.dsp.exit()`, true
case "submap":
return fmt.Sprintf(`hl.dsp.submap(%s)`, strconv.Quote(params)), true
case "global":
return fmt.Sprintf(`hl.dsp.global(%s)`, strconv.Quote(params)), true
case "event":
return fmt.Sprintf(`hl.dsp.event(%s)`, strconv.Quote(params)), true
case "pass":
if params == "" {
return `hl.dsp.pass({})`, true
}
return luaDispatcherTableCall("hl.dsp.pass", luaStringField("window", params)), true
case "sendshortcut":
mod, rest := firstParam(params)
key, window := firstParam(rest)
if mod != "" && key != "" {
fields := []luaField{luaStringField("mods", mod), luaStringField("key", key)}
if window != "" {
fields = append(fields, luaStringField("window", window))
}
return luaDispatcherTableCall("hl.dsp.send_shortcut", fields...), true
}
case "sendkeystate":
mod, rest := firstParam(params)
key, rest := firstParam(rest)
state, window := firstParam(rest)
if mod != "" && key != "" && state != "" {
fields := []luaField{luaStringField("mods", mod), luaStringField("key", key), luaStringField("state", state)}
if window != "" {
fields = append(fields, luaStringField("window", window))
}
return luaDispatcherTableCall("hl.dsp.send_key_state", fields...), true
}
case "movecursortocorner":
if params != "" && isBareLuaNumber(params) {
return luaDispatcherTableCall("hl.dsp.cursor.move_to_corner", luaNumberOrStringField("corner", params)), true
}
case "movecursor":
if expr := dispatcherCursorMove(params); expr != "" {
return expr, true
}
case "togglegroup":
return `hl.dsp.group.toggle()`, true
case "changegroupactive":
if expr := dispatcherGroupActive(params); expr != "" {
return expr, true
}
return luaHyprctlDispatchFunction(action), true
case "movegroupwindow":
return dispatcherMoveGroupWindow(params), true
case "moveintogroup":
if params != "" {
return luaDispatcherTableCall("hl.dsp.window.move", luaStringField("into_group", params)), true
}
case "moveintoorcreategroup":
if params != "" {
return luaDispatcherTableCall("hl.dsp.window.move", luaStringField("into_or_create_group", params)), true
}
case "moveoutofgroup":
if params != "" {
return luaDispatcherTableCall("hl.dsp.window.move", luaStringField("out_of_group", params)), true
}
return luaDispatcherTableCall("hl.dsp.window.move", luaBoolField("out_of_group", true)), true
case "movewindoworgroup":
if params != "" {
return luaDispatcherTableCall("hl.dsp.window.move", luaStringField("direction", params), luaBoolField("group_aware", true)), true
}
case "lockgroups":
return dispatcherToggleTableCall("hl.dsp.group.lock", params), true
case "lockactivegroup":
return dispatcherToggleTableCall("hl.dsp.group.lock_active", params), true
case "denywindowfromgroup":
return dispatcherToggleTableCall("hl.dsp.window.deny_from_group", params), true
case "setignoregrouplock":
return luaHyprctlDispatchFunction(action), true
case "forcerendererreload":
return `hl.dsp.force_renderer_reload()`, true
case "forceidle":
if params != "" && isBareLuaNumber(params) {
return fmt.Sprintf(`hl.dsp.force_idle(%s)`, params), true
}
}
if isKnownHyprlandDispatcher(dispatcher) {
return luaHyprctlDispatchFunction(action), true
}
return "", false
}
func luaActionStringFromHyprlangAction(action string) string {
action = strings.TrimSpace(action)
if expr, ok := luaActionStringFromKnownHyprlandAction(action); ok {
return expr
if strings.HasPrefix(action, "spawn ") {
return fmt.Sprintf(`hl.dsp.exec_cmd(%s)`, strconv.Quote(strings.TrimSpace(strings.TrimPrefix(action, "spawn "))))
}
if strings.HasPrefix(action, "exec ") {
return fmt.Sprintf(`hl.dsp.exec_cmd(%s)`, strconv.Quote(strings.TrimPrefix(action, "exec ")))
}
switch action {
case "killactive":
return `hl.dsp.window.kill()`
case "togglefloating":
return `hl.dsp.window.float({ action = "toggle" })`
case "exit":
return `hl.dsp.exit()`
default:
return fmt.Sprintf(`hl.dsp.exec_cmd(%s)`, strconv.Quote("hyprctl dispatch "+action))
}
return action
}
func luaExprToInternalAction(expr string) string {
@@ -1061,7 +407,7 @@ func luaBindOptions(bind *hyprlandOverrideBind) []string {
if strings.Contains(bind.Flags, "e") {
opts = append(opts, "repeating = true")
}
if bind.Description != "" {
if bind.Description != "" && strings.Contains(bind.Flags, "d") {
opts = append(opts, fmt.Sprintf("description = %s", strconv.Quote(bind.Description)))
}
return opts
@@ -1080,9 +426,13 @@ func writeLuaBindLine(sb *strings.Builder, bind *hyprlandOverrideBind) {
sb.WriteByte('\n')
if len(opts) > 0 {
fmt.Fprintf(sb, `hl.bind("%s", %s, { %s })`, key, expr, strings.Join(opts, ", "))
} else {
if bind.Description != "" {
fmt.Fprintf(sb, `hl.bind("%s", %s) -- %s`, key, expr, bind.Description)
} else {
fmt.Fprintf(sb, `hl.bind("%s", %s)`, key, expr)
}
}
sb.WriteByte('\n')
}
@@ -1100,9 +450,6 @@ func parseLuaBindOverrideLine(line string) (*hyprlandOverrideBind, bool) {
action := luaExprToInternalAction(actionExpr)
flags := luaBindOptFlags(optSuffix)
description := luaBindOptDescription(optSuffix)
if description == "" {
description = luaLineTrailingComment(line)
}
return &hyprlandOverrideBind{
Key: internalKey,
Action: action,
@@ -1151,12 +498,11 @@ func readLuaOrHyprlangOverride(path string) (map[string]*hyprlandOverrideBind, e
continue
}
if key, ok := parseLuaUnbindLine(line); ok {
pendingUnbinds[hyprlandOverrideMapKey(key)] = canonicalHyprlandOverrideKey(key)
pendingUnbinds[strings.ToLower(key)] = key
continue
}
if kb, ok := parseLuaBindOverrideLine(line); ok {
kb.Key = canonicalHyprlandOverrideKey(kb.Key)
normalizedKey := hyprlandOverrideMapKey(kb.Key)
normalizedKey := strings.ToLower(kb.Key)
binds[normalizedKey] = kb
delete(pendingUnbinds, normalizedKey)
continue
@@ -1174,8 +520,7 @@ func readLuaOrHyprlangOverride(path string) (map[string]*hyprlandOverrideBind, e
action = kb.Dispatcher + " " + kb.Params
}
flags := kb.Flags
keyStr = canonicalHyprlandOverrideKey(keyStr)
normalizedKey := hyprlandOverrideMapKey(keyStr)
normalizedKey := strings.ToLower(keyStr)
binds[normalizedKey] = &hyprlandOverrideBind{
Key: keyStr,
Action: action,
@@ -54,8 +54,6 @@ type HyprlandParser struct {
dmsProcessed bool
removedKeys map[string]bool // bare hl.unbind targets (negative overrides)
defaultDMSKeys map[string]bool // keys present in dms/binds.{lua,conf}
configFormat string
readOnly bool
}
func NewHyprlandParser(configDir string) *HyprlandParser {
@@ -312,8 +310,6 @@ type HyprlandDMSStatus struct {
Effective bool
OverriddenBy int
StatusMessage string
ConfigFormat string
ReadOnly bool
}
func (p *HyprlandParser) buildDMSStatus() *HyprlandDMSStatus {
@@ -323,8 +319,6 @@ func (p *HyprlandParser) buildDMSStatus() *HyprlandDMSStatus {
IncludePosition: p.dmsIncludePos,
TotalIncludes: p.includeCount,
BindsAfterDMS: p.bindsAfterDMS,
ConfigFormat: p.configFormat,
ReadOnly: p.readOnly,
}
switch {
@@ -404,13 +398,6 @@ func (p *HyprlandParser) ParseWithDMS() (*HyprlandSection, error) {
if err != nil {
return nil, err
}
if strings.EqualFold(filepath.Ext(mainConfig), ".lua") {
p.configFormat = "lua"
p.readOnly = false
} else {
p.configFormat = "hyprlang"
p.readOnly = true
}
section, err := p.parseFileWithSource(mainConfig, "")
if err != nil {
return nil, err
@@ -882,20 +869,23 @@ func parseLuaStringLiteral(line string, i int) (value string, next int, ok bool)
return "", i, false
}
// parseLuaFirstArgExpr parses a single Lua expression starting at i, stopping at
// the next top-level comma. It handles nested calls/tables and inline functions.
// parseLuaFirstArgExpr parses a single Lua expression starting at i, stopping when parentheses
// opened from the first '(' are balanced (handles nested () and {} and double-quoted strings).
func parseLuaFirstArgExpr(line string, start int) (expr string, next int, ok bool) {
start = skipLuaWS(line, start)
if start >= len(line) {
return "", start, false
}
i := start
parenDepth := 0
braceDepth := 0
bracketDepth := 0
functionDepth := 0
// Find first '(' of the call (e.g. hl.dsp.exec_cmd(...)
firstParen := strings.IndexByte(line[start:], '(')
if firstParen < 0 {
return "", start, false
}
i := start + firstParen
depth := 0
inStr := byte(0)
esc := false
exprStart := start
for ; i < len(line); i++ {
c := line[i]
if inStr != 0 {
@@ -912,66 +902,19 @@ func parseLuaFirstArgExpr(line string, start int) (expr string, next int, ok boo
}
continue
}
if c == '[' && i+1 < len(line) && line[i+1] == '[' {
if end := strings.Index(line[i+2:], "]]"); end >= 0 {
i += end + 3
continue
}
return "", start, false
}
if luaWordAt(line, i, "function") {
functionDepth++
i += len("function") - 1
continue
}
if luaWordAt(line, i, "end") && functionDepth > 0 {
functionDepth--
i += len("end") - 1
continue
}
switch c {
case '"', '\'':
inStr = c
case '(':
parenDepth++
depth++
case ')':
if parenDepth > 0 {
parenDepth--
}
case '{':
braceDepth++
case '}':
if braceDepth > 0 {
braceDepth--
}
case '[':
bracketDepth++
case ']':
if bracketDepth > 0 {
bracketDepth--
}
case ',':
if parenDepth == 0 && braceDepth == 0 && bracketDepth == 0 && functionDepth == 0 {
return strings.TrimSpace(line[start:i]), i, true
depth--
if depth == 0 {
return strings.TrimSpace(line[exprStart : i+1]), i + 1, true
}
}
}
expr = strings.TrimSpace(line[start:i])
return expr, i, expr != ""
}
func luaWordAt(line string, idx int, word string) bool {
if idx < 0 || idx+len(word) > len(line) || line[idx:idx+len(word)] != word {
return false
}
before := idx == 0 || !isLuaIdentByte(line[idx-1])
afterIdx := idx + len(word)
after := afterIdx >= len(line) || !isLuaIdentByte(line[afterIdx])
return before && after
}
func isLuaIdentByte(c byte) bool {
return c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')
return "", start, false
}
// parseLuaBindInvocation parses one hl.bind("KEY", expr [, opts]) on a single line.
@@ -1050,39 +993,19 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) {
if arg != "" {
if u, err := strconv.Unquote(arg); err == nil {
if strings.HasPrefix(u, "hyprctl dispatch ") {
return splitDispatchCommand(strings.TrimSpace(strings.TrimPrefix(u, "hyprctl dispatch ")))
rest := strings.TrimSpace(strings.TrimPrefix(u, "hyprctl dispatch "))
parts := strings.SplitN(rest, " ", 2)
if len(parts) == 1 {
return parts[0], ""
}
return parts[0], parts[1]
}
return "exec", u
}
}
return "exec", strings.TrimSpace(strings.TrimPrefix(expr, "hl.dsp.exec_cmd"))
case strings.HasPrefix(expr, "hl.dsp.exec_raw("):
return "execr", luaCallStringArgValue(expr, "hl.dsp.exec_raw")
case strings.HasPrefix(expr, "hl.dispatch("):
if arg := luaCallStringArgValue(expr, "hl.dispatch"); arg != "" {
return splitDispatchCommand(arg)
}
return "", ""
case strings.Contains(expr, "hl.exec_cmd("):
if arg := luaEmbeddedCallStringArgValue(expr, "hl.exec_cmd"); strings.HasPrefix(arg, "hyprctl dispatch ") {
return splitDispatchCommand(strings.TrimSpace(strings.TrimPrefix(arg, "hyprctl dispatch ")))
}
case strings.HasPrefix(expr, "hl.dsp.window.close("):
if window := luaTableStringField(expr, "window"); window != "" {
return "closewindow", window
}
if arg := luaCallStringArgValue(expr, "hl.dsp.window.close"); arg != "" {
return "closewindow", arg
}
case strings.Contains(expr, "hl.dsp.window.kill()"):
return "killactive", ""
case strings.HasPrefix(expr, "hl.dsp.window.kill("):
if window := luaTableStringField(expr, "window"); window != "" {
return "killwindow", window
}
if arg := luaCallStringArgValue(expr, "hl.dsp.window.kill"); arg != "" {
return "killwindow", arg
}
return "forcekillactive", ""
case strings.HasPrefix(expr, "hl.dsp.window.fullscreen("):
switch luaTableStringField(expr, "mode") {
case "maximized", "maximize":
@@ -1091,55 +1014,10 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) {
return "fullscreen", "0"
}
return "fullscreen", luaTableStringField(expr, "mode")
case strings.HasPrefix(expr, "hl.dsp.window.fullscreen_state("):
internal := luaStringValue(luaTableScalarField(expr, "internal"))
client := luaStringValue(luaTableScalarField(expr, "client"))
return joinDispatcherParams("fullscreenstate", internal, client)
case strings.HasPrefix(expr, "hl.dsp.window.float("):
switch luaToggleActionToLegacy(luaTableStringField(expr, "action")) {
case "on":
return "setfloating", ""
case "off":
return "settiled", ""
default:
return "togglefloating", ""
}
case strings.HasPrefix(expr, "hl.dsp.window.pseudo("):
action := luaToggleActionToLegacy(luaTableStringField(expr, "action"))
if action == "" || action == "toggle" {
return "pseudo", ""
}
return "pseudo", action
case strings.HasPrefix(expr, "hl.dsp.window.pin("):
if action := luaToggleActionToLegacy(luaTableStringField(expr, "action")); action != "" && action != "toggle" {
return "pin", action
}
return "pin", ""
case strings.Contains(expr, "hl.dsp.window.center()"):
return "centerwindow", ""
case strings.Contains(expr, "hl.dsp.window.bring_to_top()"):
return "bringactivetotop", ""
case strings.Contains(expr, "hl.dsp.window.toggle_swallow()"):
return "toggleswallow", ""
case strings.Contains(expr, "hl.dsp.group.toggle()"):
return "togglegroup", ""
case strings.Contains(expr, "hl.dsp.group.next()"):
return "changegroupactive", "f"
case strings.Contains(expr, "hl.dsp.group.prev()"):
return "changegroupactive", "b"
case strings.HasPrefix(expr, "hl.dsp.group.active("):
return "changegroupactive", luaStringValue(luaTableScalarField(expr, "index"))
case strings.HasPrefix(expr, "hl.dsp.group.move_window("):
if forward, ok := luaTableBoolField(expr, "forward"); ok && !forward {
return "movegroupwindow", "b"
}
return "movegroupwindow", "f"
case strings.HasPrefix(expr, "hl.dsp.group.lock_active("):
return "lockactivegroup", luaToggleActionToLockArg(luaTableStringField(expr, "action"))
case strings.HasPrefix(expr, "hl.dsp.group.lock("):
return "lockgroups", luaToggleActionToLockArg(luaTableStringField(expr, "action"))
case strings.HasPrefix(expr, "hl.dsp.window.deny_from_group("):
return "denywindowfromgroup", luaToggleActionToLegacy(luaTableStringField(expr, "action"))
case strings.HasPrefix(expr, "hl.dsp.focus("):
switch {
case luaTableStringField(expr, "direction") != "":
@@ -1147,58 +1025,18 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) {
case luaTableStringField(expr, "monitor") != "":
return "focusmonitor", luaTableStringField(expr, "monitor")
case luaTableStringField(expr, "workspace") != "":
if luaTableBoolFieldValue(expr, "on_current_monitor") {
return "focusworkspaceoncurrentmonitor", luaTableStringField(expr, "workspace")
}
return "workspace", luaTableStringField(expr, "workspace")
case luaTableStringField(expr, "window") != "":
return "focuswindow", luaTableStringField(expr, "window")
case luaTableBoolFieldValue(expr, "urgent_or_last"):
return "focusurgentorlast", ""
case luaTableBoolFieldValue(expr, "last"):
return "focuscurrentorlast", ""
}
case strings.HasPrefix(expr, "hl.dsp.window.move("):
switch {
case luaTableScalarField(expr, "x") != "" || luaTableScalarField(expr, "y") != "":
x := luaStringValue(luaTableScalarField(expr, "x"))
y := luaStringValue(luaTableScalarField(expr, "y"))
if x == "" {
x = "0"
}
if y == "" {
y = "0"
}
prefix := ""
if raw, ok := luaTableBoolField(expr, "relative"); ok && !raw {
prefix = "exact "
}
params := prefix + x + " " + y
if window := luaTableStringField(expr, "window"); window != "" {
return "movewindowpixel", params + "," + window
}
return "moveactive", params
case luaTableStringField(expr, "into_group") != "":
return "moveintogroup", luaTableStringField(expr, "into_group")
case luaTableStringField(expr, "into_or_create_group") != "":
return "moveintoorcreategroup", luaTableStringField(expr, "into_or_create_group")
case luaTableBoolFieldValue(expr, "out_of_group"):
return "moveoutofgroup", ""
case luaTableStringField(expr, "out_of_group") != "":
return "moveoutofgroup", luaTableStringField(expr, "out_of_group")
case luaTableStringField(expr, "direction") != "":
if luaTableBoolFieldValue(expr, "group_aware") {
return "movewindoworgroup", luaTableStringField(expr, "direction")
}
return "movewindow", luaTableStringField(expr, "direction")
case luaTableStringField(expr, "monitor") != "":
return "movewindow", "mon:" + luaTableStringField(expr, "monitor")
case luaTableStringField(expr, "workspace") != "":
action := "movetoworkspace"
if follow, ok := luaTableBoolField(expr, "follow"); ok && !follow {
action = "movetoworkspacesilent"
}
return joinDispatcherParams(action, luaTableStringField(expr, "workspace"), luaTableStringField(expr, "window"))
return "movetoworkspace", luaTableStringField(expr, "workspace")
}
case expr == "hl.dsp.window.drag()":
return "movewindow", ""
@@ -1214,184 +1052,25 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) {
if y == "" {
y = "0"
}
prefix := ""
if relative, ok := luaTableBoolField(expr, "relative"); ok && !relative {
prefix = "exact "
return "resizeactive", x + " " + y
}
params := prefix + x + " " + y
if window := luaTableStringField(expr, "window"); window != "" {
return "resizewindowpixel", params + "," + window
}
return "resizeactive", params
}
case strings.HasPrefix(expr, "hl.dsp.window.swap("):
switch {
case luaTableBoolFieldValue(expr, "next"):
return "swapnext", ""
case luaTableBoolFieldValue(expr, "prev"):
return "swapnext", "prev"
}
return "swapwindow", luaTableStringField(expr, "direction")
case strings.HasPrefix(expr, "hl.dsp.window.cycle_next("):
parts := []string{}
if next, ok := luaTableBoolField(expr, "next"); ok && !next {
parts = append(parts, "prev")
}
if luaTableBoolFieldValue(expr, "tiled") {
parts = append(parts, "tiled")
}
if luaTableBoolFieldValue(expr, "floating") {
parts = append(parts, "floating")
}
return "cyclenext", strings.Join(parts, " ")
case strings.HasPrefix(expr, "hl.dsp.window.signal("):
signal := luaStringValue(luaTableScalarField(expr, "signal"))
window := luaTableStringField(expr, "window")
if window != "" {
return joinDispatcherParams("signalwindow", window, signal)
}
return "signal", signal
case strings.HasPrefix(expr, "hl.dsp.window.tag("):
return joinDispatcherParams("tagwindow", luaTableStringField(expr, "tag"), luaTableStringField(expr, "window"))
case strings.HasPrefix(expr, "hl.dsp.window.alter_zorder("):
mode := luaTableStringField(expr, "mode")
if mode == "" {
mode = luaTableStringField(expr, "zheight")
}
return joinDispatcherParams("alterzorder", mode, luaTableStringField(expr, "window"))
case strings.HasPrefix(expr, "hl.dsp.window.set_prop("):
prop := luaTableStringField(expr, "prop")
if prop == "" {
prop = luaTableStringField(expr, "property")
}
return joinDispatcherParams("setprop", luaTableStringField(expr, "window"), prop, luaTableStringField(expr, "value"))
case strings.HasPrefix(expr, "hl.dsp.workspace.rename("):
return joinDispatcherParams("renameworkspace", luaTableStringField(expr, "workspace"), luaTableStringField(expr, "name"))
case strings.HasPrefix(expr, "hl.dsp.workspace.move("):
workspace := luaTableStringField(expr, "workspace")
monitor := luaTableStringField(expr, "monitor")
if workspace != "" {
return joinDispatcherParams("moveworkspacetomonitor", workspace, monitor)
}
return "movecurrentworkspacetomonitor", monitor
case strings.HasPrefix(expr, "hl.dsp.workspace.swap_monitors("):
return joinDispatcherParams("swapactiveworkspaces", luaTableStringField(expr, "monitor1"), luaTableStringField(expr, "monitor2"))
case strings.HasPrefix(expr, "hl.dsp.workspace.toggle_special("):
return "togglespecialworkspace", luaCallStringArgValue(expr, "hl.dsp.workspace.toggle_special")
case strings.HasPrefix(expr, "hl.dsp.layout("):
if arg := luaCallStringArgValue(expr, "hl.dsp.layout"); arg != "" {
return "layoutmsg", arg
arg := extractLuaCallStringArg(expr, "hl.dsp.layout")
if arg != "" {
if u, err := strconv.Unquote(arg); err == nil {
return "layoutmsg", u
}
}
case strings.HasPrefix(expr, "hl.dsp.dpms("):
if action := luaTableStringField(expr, "action"); action != "" {
switch action {
case "enable":
return "dpms", "on"
case "disable":
return "dpms", "off"
}
return "dpms", action
}
return "dpms", ""
case strings.HasPrefix(expr, "hl.dsp.submap("):
return "submap", luaCallStringArgValue(expr, "hl.dsp.submap")
case strings.HasPrefix(expr, "hl.dsp.global("):
return "global", luaCallStringArgValue(expr, "hl.dsp.global")
case strings.HasPrefix(expr, "hl.dsp.event("):
return "event", luaCallStringArgValue(expr, "hl.dsp.event")
case strings.HasPrefix(expr, "hl.dsp.pass("):
if window := luaTableStringField(expr, "window"); window != "" {
return "pass", window
}
return "pass", luaCallStringArgValue(expr, "hl.dsp.pass")
case strings.HasPrefix(expr, "hl.dsp.send_shortcut("):
return joinDispatcherParams("sendshortcut", luaTableModsField(expr), luaTableStringField(expr, "key"), luaTableStringField(expr, "window"))
case strings.HasPrefix(expr, "hl.dsp.send_key_state("):
return joinDispatcherParams("sendkeystate", luaTableModsField(expr), luaTableStringField(expr, "key"), luaTableStringField(expr, "state"), luaTableStringField(expr, "window"))
case strings.HasPrefix(expr, "hl.dsp.cursor.move_to_corner("):
return "movecursortocorner", luaStringValue(luaTableScalarField(expr, "corner"))
case strings.HasPrefix(expr, "hl.dsp.cursor.move("):
return joinDispatcherParams("movecursor", luaStringValue(luaTableScalarField(expr, "x")), luaStringValue(luaTableScalarField(expr, "y")))
case strings.Contains(expr, "hl.dsp.force_renderer_reload()"):
return "forcerendererreload", ""
case strings.HasPrefix(expr, "hl.dsp.force_idle("):
return "forceidle", luaCallScalarArgValue(expr, "hl.dsp.force_idle")
case strings.Contains(expr, "hl.dsp.exit()"):
return "exit", ""
default:
return expr, ""
}
return expr, ""
}
func splitDispatchCommand(command string) (dispatcher, params string) {
command = strings.TrimSpace(command)
if command == "" {
return "", ""
}
parts := strings.SplitN(command, " ", 2)
if len(parts) == 1 {
return parts[0], ""
}
return parts[0], strings.TrimSpace(parts[1])
}
func joinDispatcherParams(dispatcher string, values ...string) (string, string) {
parts := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
parts = append(parts, value)
}
}
return dispatcher, strings.Join(parts, " ")
}
func luaEmbeddedCallStringArgValue(expr, funcName string) string {
idx := strings.Index(expr, funcName+"(")
if idx < 0 {
return ""
}
return luaCallStringArgValue(expr[idx:], funcName)
}
func luaCallScalarArgValue(callExpr, funcName string) string {
callExpr = strings.TrimSpace(callExpr)
prefix := funcName + "("
if !strings.HasPrefix(callExpr, prefix) {
return ""
}
inner := strings.TrimSpace(callExpr[len(prefix):])
if inner == "" {
return ""
}
if s := luaCallStringArgValue(callExpr, funcName); s != "" {
return s
}
re := regexp.MustCompile(`^-?\d+(?:\.\d+)?`)
return re.FindString(inner)
}
func luaToggleActionToLegacy(action string) string {
switch strings.ToLower(strings.TrimSpace(action)) {
case "on", "enable", "enabled", "set", "lock":
return "on"
case "off", "disable", "disabled", "unset", "unlock":
return "off"
default:
return "toggle"
}
}
func luaToggleActionToLockArg(action string) string {
switch luaToggleActionToLegacy(action) {
case "on":
return "lock"
case "off":
return "unlock"
default:
return "toggle"
return "exec", "hyprctl dispatch lua:" + expr
}
return "exec", "hyprctl dispatch lua:" + expr
}
func extractLuaCallStringArg(callExpr, funcName string) string {
@@ -1421,46 +1100,10 @@ func extractLuaCallStringArg(callExpr, funcName string) string {
return ""
}
func luaCallStringArgValue(callExpr, funcName string) string {
arg := extractLuaCallStringArg(callExpr, funcName)
if arg == "" {
return ""
}
u, err := strconv.Unquote(arg)
if err != nil {
return ""
}
return u
}
func luaTableStringField(expr, field string) string {
return luaStringValue(luaTableScalarField(expr, field))
}
func luaTableModsField(expr string) string {
if mods := luaTableStringField(expr, "mods"); mods != "" {
return mods
}
return luaTableStringField(expr, "mod")
}
func luaTableBoolFieldValue(expr, field string) bool {
value, ok := luaTableBoolField(expr, field)
return ok && value
}
func luaTableBoolField(expr, field string) (bool, bool) {
raw := strings.ToLower(luaTableScalarField(expr, field))
switch raw {
case "true":
return true, true
case "false":
return false, true
default:
return false, false
}
}
func luaTableScalarField(expr, field string) string {
re := regexp.MustCompile(`(?s)\b` + regexp.QuoteMeta(field) + `\s*=\s*("(?:\\.|[^"])*"|'(?:\\.|[^'])*'|\[\[.*?\]\]|-?\d+(?:\.\d+)?|true|false)`)
m := re.FindStringSubmatch(expr)
@@ -1493,38 +1136,8 @@ func luaStringValue(raw string) string {
}
func luaLineTrailingComment(line string) string {
inString := byte(0)
escaped := false
for i := 0; i < len(line)-1; i++ {
c := line[i]
if inString != 0 {
if escaped {
escaped = false
continue
}
if c == '\\' && inString == '"' {
escaped = true
continue
}
if c == inString {
inString = 0
}
continue
}
if c == '"' || c == '\'' {
inString = c
continue
}
if c == '[' && line[i+1] == '[' {
if end := strings.Index(line[i+2:], "]]"); end >= 0 {
i += end + 3
continue
}
return ""
}
if c == '-' && line[i+1] == '-' {
return strings.TrimSpace(line[i+2:])
}
if idx := strings.Index(line, "--"); idx >= 0 {
return strings.TrimSpace(line[idx+2:])
}
return ""
}
@@ -70,37 +70,12 @@ func TestHyprlandLuaBindRoundTripHelpers(t *testing.T) {
wantParams string
}{
{`hl.dsp.exec_cmd([[dms ipc call brightness increment 5 ""]])`, "exec", `dms ipc call brightness increment 5 ""`},
{`hl.dsp.exec_cmd([[hyprctl dispatch workspace 1]])`, "workspace", "1"},
{`hl.dispatch("workspace 2")`, "workspace", "2"},
{`hl.dispatch([[customdispatcher arg one]])`, "customdispatcher", "arg one"},
{`hl.dsp.window.fullscreen({ mode = "maximized", action = "toggle" })`, "fullscreen", "1"},
{`hl.dsp.window.float({ action = "on" })`, "setfloating", ""},
{`hl.dsp.window.close()`, "killactive", ""},
{`hl.dsp.window.kill()`, "forcekillactive", ""},
{`hl.dsp.window.close({ window = "class:^(kitty)$" })`, "closewindow", "class:^(kitty)$"},
{`hl.dsp.focus({ workspace = "e+1" })`, "workspace", "e+1"},
{`hl.dsp.focus({ workspace = "2", on_current_monitor = true })`, "focusworkspaceoncurrentmonitor", "2"},
{`hl.dsp.window.move({ monitor = "l" })`, "movewindow", "mon:l"},
{`hl.dsp.window.move({ direction = "r", group_aware = true })`, "movewindoworgroup", "r"},
{`hl.dsp.window.move({ into_group = "l" })`, "moveintogroup", "l"},
{`hl.dsp.window.move({ out_of_group = true })`, "moveoutofgroup", ""},
{`hl.dsp.window.move({ workspace = "special:magic", follow = false })`, "movetoworkspacesilent", "special:magic"},
{`hl.dsp.window.resize({ x = -100, y = 0, relative = true })`, "resizeactive", "-100 0"},
{`hl.dsp.window.resize({ x = 1280, y = 720, relative = false })`, "resizeactive", "exact 1280 720"},
{`hl.dsp.window.resize({ x = 100, y = 50, relative = true, window = "class:^(app)$" })`, "resizewindowpixel", "100 50,class:^(app)$"},
{`hl.dsp.window.cycle_next({ next = false, tiled = true })`, "cyclenext", "prev tiled"},
{`hl.dsp.group.next()`, "changegroupactive", "f"},
{`hl.dsp.group.prev()`, "changegroupactive", "b"},
{`hl.dsp.group.active({ index = 2 })`, "changegroupactive", "2"},
{`hl.dsp.group.move_window({ forward = false })`, "movegroupwindow", "b"},
{`hl.dsp.group.lock({ action = "on" })`, "lockgroups", "lock"},
{`hl.dsp.group.lock_active({ action = "off" })`, "lockactivegroup", "unlock"},
{`hl.dsp.window.deny_from_group({ action = "toggle" })`, "denywindowfromgroup", "toggle"},
{`function() hl.exec_cmd("hyprctl dispatch splitratio +0.1") end`, "splitratio", "+0.1"},
{`hl.dsp.window.resize({ x = "-10%", y = 0, relative = true })`, "resizeactive", "-10% 0"},
{`hl.dsp.layout("togglesplit")`, "layoutmsg", "togglesplit"},
{`hl.dsp.dpms({ action = "toggle" })`, "dpms", "toggle"},
{`hl.dsp.workspace.rename({ workspace = "1", name = "work" })`, "renameworkspace", "1 work"},
{`hl.dsp.no_op()`, "hl.dsp.no_op()", ""},
}
for _, tt := range tests {
@@ -138,132 +113,12 @@ func TestWriteLuaBindLineMapsSpawnActionForHyprland(t *testing.T) {
})
want := `hl.unbind("SUPER + N")
hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"), { description = "Notepad: Toggle" })`
hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle")) -- Notepad: Toggle`
if got := strings.TrimSpace(sb.String()); got != want {
t.Fatalf("writeLuaBindLine() = %q, want %q", got, want)
}
}
func TestWriteLuaBindLineLeavesCustomLuaDispatcherRaw(t *testing.T) {
var sb strings.Builder
writeLuaBindLine(&sb, &hyprlandOverrideBind{
Key: "Super+u",
Action: "hl.dsp.no_op()",
Description: "Custom Lua",
})
want := `hl.unbind("SUPER + U")
hl.bind("SUPER + U", hl.dsp.no_op(), { description = "Custom Lua" })`
if got := strings.TrimSpace(sb.String()); got != want {
t.Fatalf("writeLuaBindLine() = %q, want %q", got, want)
}
}
func TestLuaActionStringFromHyprlangActionUsesNativeDispatchers(t *testing.T) {
tests := []struct {
action string
want string
}{
{"killactive", `hl.dsp.window.close()`},
{"forcekillactive", `hl.dsp.window.kill()`},
{"workspace 1", `hl.dsp.focus({ workspace = "1" })`},
{"movetoworkspace 2", `hl.dsp.window.move({ workspace = "2" })`},
{"movetoworkspacesilent special:magic", `hl.dsp.window.move({ workspace = "special:magic", follow = false })`},
{"focusmonitor DP-1", `hl.dsp.focus({ monitor = "DP-1" })`},
{"resizeactive exact 1280 720", `hl.dsp.window.resize({ x = 1280, y = 720, relative = false })`},
{"dpms toggle", `hl.dsp.dpms({ action = "toggle" })`},
{"renameworkspace 1 work", `hl.dsp.workspace.rename({ workspace = "1", name = "work" })`},
{"changegroupactive f", `hl.dsp.group.next()`},
{"changegroupactive b", `hl.dsp.group.prev()`},
{"changegroupactive 2", `hl.dsp.group.active({ index = 2 })`},
{"moveintogroup l", `hl.dsp.window.move({ into_group = "l" })`},
{"moveoutofgroup", `hl.dsp.window.move({ out_of_group = true })`},
{"movewindoworgroup r", `hl.dsp.window.move({ direction = "r", group_aware = true })`},
{"movegroupwindow b", `hl.dsp.group.move_window({ forward = false })`},
{"lockgroups lock", `hl.dsp.group.lock({ action = "on" })`},
{"lockactivegroup unlock", `hl.dsp.group.lock_active({ action = "off" })`},
{"denywindowfromgroup toggle", `hl.dsp.window.deny_from_group({ action = "toggle" })`},
{"cyclenext prev", `hl.dsp.window.cycle_next({ next = false })`},
{"setfloating", `hl.dsp.window.float({ action = "on" })`},
{"settiled", `hl.dsp.window.float({ action = "off" })`},
{"bringactivetotop", `hl.dsp.window.bring_to_top()`},
{"toggleswallow", `hl.dsp.window.toggle_swallow()`},
{"forceidle 300", `hl.dsp.force_idle(300)`},
}
for _, tt := range tests {
t.Run(tt.action, func(t *testing.T) {
got := luaActionStringFromHyprlangAction(tt.action)
if got != tt.want {
t.Fatalf("luaActionStringFromHyprlangAction(%q) = %q, want %q", tt.action, got, tt.want)
}
if strings.Contains(got, "hyprctl dispatch") {
t.Fatalf("expected native Lua dispatcher, got legacy dispatch wrapper: %q", got)
}
})
}
}
func TestLuaActionStringFallsBackForUnsupportedResizePercentages(t *testing.T) {
got := luaActionStringFromHyprlangAction("resizeactive exact 100% 100%")
want := `function() hl.exec_cmd("hyprctl dispatch resizeactive exact 100% 100%") end`
if got != want {
t.Fatalf("luaActionStringFromHyprlangAction() = %q, want %q", got, want)
}
}
func TestParseLuaBindLineHandlesFunctionDispatcherFallback(t *testing.T) {
line := `hl.bind("SUPER + R", function() hl.exec_cmd("hyprctl dispatch resizeactive exact 100% 100%") end, { description = "Unsupported Resize" })`
got, ok := parseLuaBindOverrideLine(line)
if !ok {
t.Fatalf("expected line to parse")
}
if got.Action != "resizeactive exact 100% 100%" {
t.Fatalf("Action = %q, want resizeactive exact 100%% 100%%", got.Action)
}
if got.Description != "Unsupported Resize" {
t.Fatalf("Description = %q, want Unsupported Resize", got.Description)
}
}
func TestLuaActionStringLeavesCustomLuaDispatcherRaw(t *testing.T) {
got := luaActionStringFromHyprlangAction("hl.dsp.no_op()")
want := `hl.dsp.no_op()`
if got != want {
t.Fatalf("luaActionStringFromHyprlangAction() = %q, want %q", got, want)
}
if strings.Contains(got, "hl.dispatch") || strings.Contains(got, "hyprctl dispatch") {
t.Fatalf("expected custom Lua dispatcher expression to stay raw, got %q", got)
}
}
func TestReadLuaOverrideMigratesTrailingCommentToDescription(t *testing.T) {
tmpDir := t.TempDir()
overridePath := filepath.Join(tmpDir, "binds-user.lua")
contents := `hl.unbind("SUPER + N")
hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle")) -- Notepad: Toggle
hl.bind("SUPER + H", hl.dsp.exec_cmd("app --help"))
`
if err := os.WriteFile(overridePath, []byte(contents), 0o644); err != nil {
t.Fatal(err)
}
binds, err := readLuaOrHyprlangOverride(overridePath)
if err != nil {
t.Fatal(err)
}
got := binds["super+n"]
if got == nil {
t.Fatalf("expected SUPER+N override, got %#v", binds)
}
if got.Description != "Notepad: Toggle" {
t.Fatalf("expected trailing comment to be preserved as description, got %q", got.Description)
}
if got := binds["super+h"]; got == nil || got.Description != "" {
t.Fatalf("expected -- inside a Lua string to stay out of the description, got %#v", got)
}
}
func TestHyprlandLuaBindsUserOverridesDefaults(t *testing.T) {
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
@@ -428,64 +283,6 @@ func TestHyprlandRemoveBindWritesNegativeOverrideForDefault(t *testing.T) {
}
}
func TestHyprlandSetBindLeavesConfOnlyInstallReadOnly(t *testing.T) {
tmpDir := t.TempDir()
if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.conf"), []byte("bind = SUPER, T, exec, kitty\n"), 0o644); err != nil {
t.Fatal(err)
}
provider := NewHyprlandProvider(tmpDir)
err := provider.SetBind("SUPER+N", "workspace 1", "Workspace 1", nil)
if err == nil {
t.Fatal("expected SetBind to reject conf-only Hyprland config")
}
if !strings.Contains(err.Error(), "read-only") {
t.Fatalf("expected read-only error, got %v", err)
}
if _, err := os.Stat(filepath.Join(tmpDir, "dms", "binds-user.lua")); !os.IsNotExist(err) {
t.Fatalf("expected no Lua override to be created for conf-only config, stat err=%v", err)
}
}
func TestHyprlandSetBindUpdatesSpacedLuaOverrideWithoutDuplicates(t *testing.T) {
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
t.Fatal(err)
}
override := `-- DMS user keybind overrides
hl.unbind("SUPER + SHIFT + S")
hl.bind("SUPER + 1", hl.dsp.exec_cmd("hyprctl dispatch workspace 1"))
`
if err := os.WriteFile(filepath.Join(dmsDir, "binds-user.lua"), []byte(override), 0o644); err != nil {
t.Fatal(err)
}
provider := NewHyprlandProvider(tmpDir)
if err := provider.SetBind("SUPER + 1", "workspace 1", "", nil); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(dmsDir, "binds-user.lua"))
if err != nil {
t.Fatal(err)
}
got := string(data)
if strings.Count(got, `hl.unbind("SUPER + 1")`) != 1 {
t.Fatalf("expected one SUPER+1 unbind, got:\n%s", got)
}
if strings.Count(got, `hl.bind("SUPER + 1", hl.dsp.focus({ workspace = "1" }))`) != 1 {
t.Fatalf("expected one native SUPER+1 bind, got:\n%s", got)
}
if strings.Contains(got, "hyprctl dispatch workspace 1") {
t.Fatalf("expected old hyprctl workspace dispatcher to be replaced, got:\n%s", got)
}
if !strings.Contains(got, `hl.unbind("SUPER + SHIFT + S")`) {
t.Fatalf("expected unrelated override to be preserved, got:\n%s", got)
}
}
func TestHyprlandRemoveBindReplacesExistingOverrideWithNegativeOverride(t *testing.T) {
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
+5 -8
View File
@@ -397,14 +397,6 @@ func (m *MangoWCProvider) writeBindLine(sb *strings.Builder, bind *mangowcOverri
mods, key := m.parseKeyString(bind.Key)
command, params := m.parseAction(bind.Action)
// Description goes on the line ABOVE the bind: mango doesn't strip inline `#`
// comments from a value, so a trailing comment would break spawn (extra argv).
if bind.Description != "" {
sb.WriteString("# ")
sb.WriteString(bind.Description)
sb.WriteString("\n")
}
sb.WriteString("bind=")
if mods == "" {
sb.WriteString("none")
@@ -421,6 +413,11 @@ func (m *MangoWCProvider) writeBindLine(sb *strings.Builder, bind *mangowcOverri
sb.WriteString(params)
}
if bind.Description != "" {
sb.WriteString(" # ")
sb.WriteString(bind.Description)
}
sb.WriteString("\n")
}
@@ -216,37 +216,101 @@ func mangowcAutogenerateComment(command, params string) string {
}
}
func (p *MangoWCParser) getKeybindAtLine(lineNumber int, precedingComment string) *MangoWCKeyBinding {
func (p *MangoWCParser) getKeybindAtLine(lineNumber int) *MangoWCKeyBinding {
if lineNumber >= len(p.contentLines) {
return nil
}
return p.getKeybindAtLineContent(p.contentLines[lineNumber], precedingComment)
line := p.contentLines[lineNumber]
bindMatch := regexp.MustCompile(`^(bind[lsr]*)\s*=\s*(.+)$`)
matches := bindMatch.FindStringSubmatch(line)
if len(matches) < 3 {
return nil
}
bindType := matches[1]
content := matches[2]
parts := strings.SplitN(content, "#", 2)
keys := parts[0]
var comment string
if len(parts) > 1 {
comment = strings.TrimSpace(parts[1])
}
if strings.HasPrefix(comment, MangoWCHideComment) {
return nil
}
keyFields := strings.SplitN(keys, ",", 4)
if len(keyFields) < 3 {
return nil
}
mods := strings.TrimSpace(keyFields[0])
key := strings.TrimSpace(keyFields[1])
command := strings.TrimSpace(keyFields[2])
var params string
if len(keyFields) > 3 {
params = strings.TrimSpace(keyFields[3])
}
if comment == "" {
comment = mangowcAutogenerateComment(command, params)
}
var modList []string
if mods != "" && !strings.EqualFold(mods, "none") {
modstring := mods + string(MangoWCModSeparators[0])
p := 0
for index, char := range modstring {
isModSep := false
for _, sep := range MangoWCModSeparators {
if char == sep {
isModSep = true
break
}
}
if isModSep {
if index-p > 1 {
modList = append(modList, modstring[p:index])
}
p = index + 1
}
}
}
_ = bindType
return &MangoWCKeyBinding{
Mods: modList,
Key: key,
Command: command,
Params: params,
Comment: comment,
}
}
func (p *MangoWCParser) ParseKeys() []MangoWCKeyBinding {
var keybinds []MangoWCKeyBinding
var pendingComment string
for lineNumber := 0; lineNumber < len(p.contentLines); lineNumber++ {
trimmed := strings.TrimSpace(p.contentLines[lineNumber])
if trimmed == "" {
pendingComment = ""
continue
}
if strings.HasPrefix(trimmed, "#") {
pendingComment = strings.TrimSpace(strings.TrimPrefix(trimmed, "#"))
continue
}
if !strings.HasPrefix(trimmed, "bind") {
pendingComment = ""
line := p.contentLines[lineNumber]
if line == "" || strings.HasPrefix(strings.TrimSpace(line), "#") {
continue
}
keybind := p.getKeybindAtLine(lineNumber, pendingComment)
if !strings.HasPrefix(strings.TrimSpace(line), "bind") {
continue
}
keybind := p.getKeybindAtLine(lineNumber)
if keybind != nil {
keybinds = append(keybinds, *keybind)
}
pendingComment = ""
}
return keybinds
@@ -395,35 +459,21 @@ func (p *MangoWCParser) parseFileWithSource(filePath string) ([]MangoWCKeyBindin
p.currentSource = absPath
var keybinds []MangoWCKeyBinding
var pendingComment string
lines := strings.Split(string(data), "\n")
for _, line := range lines {
for lineNum, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
pendingComment = ""
continue
}
if strings.HasPrefix(trimmed, "source") {
p.handleSource(trimmed, filepath.Dir(absPath), &keybinds)
pendingComment = ""
continue
}
if strings.HasPrefix(trimmed, "#") {
pendingComment = strings.TrimSpace(strings.TrimPrefix(trimmed, "#"))
continue
}
if !strings.HasPrefix(trimmed, "bind") {
pendingComment = ""
continue
}
kb := p.getKeybindAtLineContent(line, pendingComment)
pendingComment = ""
kb := p.getKeybindAtLineContent(line, lineNum)
if kb == nil {
continue
}
@@ -479,10 +529,7 @@ func (p *MangoWCParser) parseDMSBindsDirectly(dmsBindsPath string) []MangoWCKeyB
return keybinds
}
// getKeybindAtLineContent parses one `bind=` line. precedingComment (a `# ...`
// line directly above) is the description: mango feeds inline comments to spawn
// as argv, so DMS keeps descriptions on the line above; inline `#` is a fallback.
func (p *MangoWCParser) getKeybindAtLineContent(line string, precedingComment string) *MangoWCKeyBinding {
func (p *MangoWCParser) getKeybindAtLineContent(line string, _ int) *MangoWCKeyBinding {
bindMatch := regexp.MustCompile(`^(bind[lsr]*)\s*=\s*(.+)$`)
matches := bindMatch.FindStringSubmatch(line)
if len(matches) < 3 {
@@ -497,9 +544,6 @@ func (p *MangoWCParser) getKeybindAtLineContent(line string, precedingComment st
if len(parts) > 1 {
comment = strings.TrimSpace(parts[1])
}
if comment == "" {
comment = strings.TrimSpace(precedingComment)
}
if strings.HasPrefix(comment, MangoWCHideComment) {
return nil
@@ -174,7 +174,7 @@ func TestMangoWCGetKeybindAtLine(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
parser := NewMangoWCParser("")
parser.contentLines = []string{tt.line}
result := parser.getKeybindAtLine(0, "")
result := parser.getKeybindAtLine(0)
if tt.expected == nil {
if result != nil {
@@ -421,7 +421,7 @@ func TestMangoWCInvalidBindLines(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
parser := NewMangoWCParser("")
parser.contentLines = []string{tt.line}
result := parser.getKeybindAtLine(0, "")
result := parser.getKeybindAtLine(0)
if result != nil {
t.Errorf("expected nil for invalid line, got %+v", result)
-2
View File
@@ -25,8 +25,6 @@ type DMSBindsStatus struct {
Effective bool `json:"effective"`
OverriddenBy int `json:"overriddenBy"`
StatusMessage string `json:"statusMessage"`
ConfigFormat string `json:"configFormat,omitempty"`
ReadOnly bool `json:"readOnly,omitempty"`
}
type CheatSheet struct {
@@ -2,7 +2,6 @@ package clipboard
import (
"encoding/json"
"errors"
"fmt"
"net"
@@ -74,10 +73,6 @@ func handleGetEntry(conn net.Conn, req models.Request, m *Manager) {
entry, err := m.GetEntry(uint64(id))
if err != nil {
if errors.Is(err, errEntryNotFound) {
models.Respond[any](conn, req.ID, nil)
return
}
models.RespondError(conn, req.ID, err.Error())
return
}
+6 -25
View File
@@ -3,7 +3,6 @@ package clipboard
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"image"
_ "image/gif"
@@ -35,8 +34,6 @@ import (
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
)
var errEntryNotFound = errors.New("entry not found")
// These mime types won't be stored in history
var sensitiveMimeTypes = []string{
"x-kde-passwordManagerHint",
@@ -575,16 +572,16 @@ func (m *Manager) hasSensitiveMimeType(mimes []string) bool {
func (m *Manager) selectMimeType(mimes []string) string {
preferredTypes := []string{
"text/uri-list",
"image/png",
"image/jpeg",
"image/gif",
"image/bmp",
"image/tiff",
"text/plain;charset=utf-8",
"text/plain",
"UTF8_STRING",
"STRING",
"TEXT",
"image/png",
"image/jpeg",
"image/gif",
"image/bmp",
"image/tiff",
}
for _, pref := range preferredTypes {
@@ -767,25 +764,9 @@ func stateEqual(a, b *State) bool {
if len(a.History) != len(b.History) {
return false
}
for i := range a.History {
if !entryStateEqual(a.History[i], b.History[i]) {
return false
}
}
return true
}
func entryStateEqual(a, b Entry) bool {
return a.ID == b.ID &&
a.Hash == b.Hash &&
a.Pinned == b.Pinned &&
a.IsImage == b.IsImage &&
a.MimeType == b.MimeType &&
a.Preview == b.Preview &&
a.Size == b.Size &&
a.Timestamp.Equal(b.Timestamp)
}
func (m *Manager) GetHistory() []Entry {
if m.db == nil {
return nil
@@ -873,7 +854,7 @@ func (m *Manager) GetEntry(id uint64) (*Entry, error) {
return nil, err
}
if !found {
return nil, errEntryNotFound
return nil, fmt.Errorf("entry not found")
}
return &entry, nil
+2 -141
View File
@@ -1,52 +1,17 @@
package clipboard
import (
"bytes"
"encoding/json"
"net"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
mocks_wlcontext "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/wlcontext"
)
type clipboardTestConn struct {
net.Conn
writeBuf *bytes.Buffer
}
func newClipboardTestConn() *clipboardTestConn {
return &clipboardTestConn{writeBuf: &bytes.Buffer{}}
}
func (c *clipboardTestConn) Write(b []byte) (int, error) {
return c.writeBuf.Write(b)
}
func newTestManagerWithDB(t *testing.T) *Manager {
t.Helper()
db, err := openDB(filepath.Join(t.TempDir(), "clipboard.db"))
require.NoError(t, err)
t.Cleanup(func() {
db.Close()
})
return &Manager{
config: DefaultConfig(),
db: db,
}
}
func TestEncodeDecodeEntry_Roundtrip(t *testing.T) {
original := Entry{
ID: 12345,
@@ -166,113 +131,11 @@ func TestStateEqual_HistoryLengthDiffers(t *testing.T) {
}
func TestStateEqual_BothEqual(t *testing.T) {
ts := time.Now().Truncate(time.Second)
entry := Entry{
ID: 1,
Hash: 100,
MimeType: "image/png",
Preview: "[[ image 1 KiB png 32x32 ]]",
Size: 1024,
Timestamp: ts,
IsImage: true,
Pinned: true,
}
a := &State{Enabled: true, History: []Entry{entry}}
b := &State{Enabled: true, History: []Entry{entry}}
a := &State{Enabled: true, History: []Entry{{ID: 1}, {ID: 2}}}
b := &State{Enabled: true, History: []Entry{{ID: 3}, {ID: 4}}}
assert.True(t, stateEqual(a, b))
}
func TestStateEqual_SameLengthDifferentIDs(t *testing.T) {
ts := time.Now().Truncate(time.Second)
a := &State{Enabled: true, History: []Entry{{ID: 1, Hash: 100, Timestamp: ts}}}
b := &State{Enabled: true, History: []Entry{{ID: 2, Hash: 100, Timestamp: ts}}}
assert.False(t, stateEqual(a, b))
}
func TestStateEqual_MetadataDiffers(t *testing.T) {
ts := time.Now().Truncate(time.Second)
base := Entry{
ID: 1,
Hash: 100,
MimeType: "image/png",
Preview: "[[ image 1 KiB png 32x32 ]]",
Size: 1024,
Timestamp: ts,
IsImage: true,
Pinned: false,
}
tests := []struct {
name string
mutate func(*Entry)
}{
{name: "hash", mutate: func(e *Entry) { e.Hash = 101 }},
{name: "pinned", mutate: func(e *Entry) { e.Pinned = true }},
{name: "is image", mutate: func(e *Entry) { e.IsImage = false }},
{name: "mime type", mutate: func(e *Entry) { e.MimeType = "image/jpeg" }},
{name: "preview", mutate: func(e *Entry) { e.Preview = "[[ image 2 KiB jpeg 64x64 ]]" }},
{name: "size", mutate: func(e *Entry) { e.Size = 2048 }},
{name: "timestamp", mutate: func(e *Entry) { e.Timestamp = ts.Add(time.Second) }},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
changed := base
tt.mutate(&changed)
a := &State{Enabled: true, History: []Entry{base}}
b := &State{Enabled: true, History: []Entry{changed}}
assert.False(t, stateEqual(a, b))
})
}
}
func TestHandleGetEntry_ReturnsExistingEntry(t *testing.T) {
m := newTestManagerWithDB(t)
err := m.storeEntry(Entry{
Data: []byte("hello world"),
MimeType: "text/plain;charset=utf-8",
Preview: "hello world",
Size: len("hello world"),
Timestamp: time.Now().Truncate(time.Second),
IsImage: false,
})
require.NoError(t, err)
history := m.GetHistory()
require.Len(t, history, 1)
conn := newClipboardTestConn()
handleGetEntry(conn, models.Request{
ID: 1,
Params: map[string]any{"id": float64(history[0].ID)},
}, m)
var resp models.Response[Entry]
require.NoError(t, json.NewDecoder(conn.writeBuf).Decode(&resp))
assert.Empty(t, resp.Error)
require.NotNil(t, resp.Result)
assert.Equal(t, history[0].ID, resp.Result.ID)
assert.Equal(t, []byte("hello world"), resp.Result.Data)
}
func TestHandleGetEntry_MissingIDReturnsNullResult(t *testing.T) {
m := newTestManagerWithDB(t)
conn := newClipboardTestConn()
handleGetEntry(conn, models.Request{
ID: 1,
Params: map[string]any{"id": float64(999)},
}, m)
var resp models.Response[any]
require.NoError(t, json.NewDecoder(conn.writeBuf).Decode(&resp))
assert.Empty(t, resp.Error)
assert.Nil(t, resp.Result)
}
func TestManager_ConcurrentSubscriberAccess(t *testing.T) {
m := &Manager{
subscribers: make(map[string]chan State),
@@ -547,8 +410,6 @@ func TestSelectMimeType(t *testing.T) {
{[]string{"text/plain;charset=utf-8", "text/html"}, "text/plain;charset=utf-8"},
{[]string{"text/html", "text/plain"}, "text/plain"},
{[]string{"text/html", "image/png"}, "image/png"},
{[]string{"image/png", "text/plain"}, "image/png"},
{[]string{"text/plain", "image/png"}, "image/png"},
{[]string{"image/png", "image/jpeg"}, "image/png"},
{[]string{"image/png"}, "image/png"},
{[]string{"application/octet-stream"}, "application/octet-stream"},
@@ -27,19 +27,16 @@ type linkInfo struct {
}
func (l *linkInfo) isWired() bool {
if looksVirtual(l.name) {
return false
}
if l.linkType != "" {
return l.linkType == "ether"
}
return !strings.HasPrefix(l.name, "wlan") && !strings.HasPrefix(l.name, "wlp")
if looksVirtual(l.name) || strings.HasPrefix(l.name, "wlan") || strings.HasPrefix(l.name, "wlp") {
return false
}
return true
}
func (l *linkInfo) isWireless() bool {
if looksVirtual(l.name) {
return false
}
if l.linkType != "" {
return l.linkType == "wlan"
}
@@ -48,7 +45,7 @@ func (l *linkInfo) isWireless() bool {
func looksVirtual(name string) bool {
virtualPrefixes := []string{
"lo", "docker", "podman", "veth", "virbr", "br-", "vnet", "tun", "tap",
"lo", "docker", "veth", "virbr", "br-", "vnet", "tun", "tap",
"vboxnet", "vmnet", "kube", "cni", "flannel", "cali",
}
for _, prefix := range virtualPrefixes {
@@ -113,12 +110,6 @@ func (b *SystemdNetworkdBackend) Close() {
}
}
type enumeratedLink struct {
ifindex int32
name string
path dbus.ObjectPath
}
func (b *SystemdNetworkdBackend) enumerateLinks() error {
obj := b.conn.Object(networkdBusName, b.managerPath)
@@ -132,48 +123,25 @@ func (b *SystemdNetworkdBackend) enumerateLinks() error {
return fmt.Errorf("ListLinks: %w", err)
}
fresh := make([]enumeratedLink, len(links))
for i, l := range links {
fresh[i] = enumeratedLink{ifindex: l.Ifindex, name: l.Name, path: l.Path}
}
b.linksMutex.Lock()
defer b.linksMutex.Unlock()
b.syncLinks(fresh)
return nil
}
// syncLinks reconciles the cached link map against the freshly enumerated set:
// it adds links not seen before (querying their Type once), refreshes the
// ifindex of survivors, and prunes links that no longer appear. Pruning is what
// keeps torn-down container interfaces (podman bridges, veth pairs) from
// lingering as routable and being mistaken for the wired uplink.
// Callers must hold linksMutex.
func (b *SystemdNetworkdBackend) syncLinks(fresh []enumeratedLink) {
present := make(map[string]bool, len(fresh))
for _, l := range fresh {
present[l.name] = true
if existing, ok := b.links[l.name]; ok && existing.path == l.path {
existing.ifindex = l.ifindex
for _, l := range links {
if existing, ok := b.links[l.Name]; ok && existing.path == l.Path {
existing.ifindex = l.Ifindex
continue
}
info := &linkInfo{
ifindex: l.ifindex,
name: l.name,
path: l.path,
linkType: b.fetchLinkType(l.path),
ifindex: l.Ifindex,
name: l.Name,
path: l.Path,
linkType: b.fetchLinkType(l.Path),
}
b.links[l.name] = info
log.Debugf("networkd: enumerated link %s (ifindex=%d, path=%s, type=%q)", l.name, l.ifindex, l.path, info.linkType)
b.links[l.Name] = info
log.Debugf("networkd: enumerated link %s (ifindex=%d, path=%s, type=%q)", l.Name, l.Ifindex, l.Path, info.linkType)
}
for name := range b.links {
if !present[name] {
log.Debugf("networkd: pruned stale link %s", name)
delete(b.links, name)
}
}
return nil
}
// fetchLinkType queries networkd's Describe method and extracts the link Type
@@ -160,12 +160,6 @@ func TestLinkInfo_Classify(t *testing.T) {
{"loopback type", "lo", "loopback", false, false},
{"none type (tun overlay)", "nebula.homelab", "none", false, false},
{"none type (wireguard)", "wg0", "none", false, false},
// Virtual interfaces report Type=ether but must never be mistaken for
// the wired uplink — stale podman/veth links would otherwise poison
// ethernet detection.
{"veth ether excluded", "veth1234", "ether", false, false},
{"podman bridge ether excluded", "podman3", "ether", false, false},
{"docker bridge ether excluded", "docker0", "ether", false, false},
// Fallback path: linkType unavailable, name-prefix heuristic applies.
{"fallback enp wired", "enp141s0", "", true, false},
{"fallback wlan wireless", "wlan0", "", false, true},
@@ -211,46 +205,8 @@ func TestParseDescribeType(t *testing.T) {
}
}
func TestSyncLinks_PrunesRemovedLinks(t *testing.T) {
// Stale container interfaces (torn-down podman bridges, veth pairs) must
// not linger in the link map after they disappear from ListLinks — kept as
// routable, they stole the wired-uplink slot from the real ethernet NIC.
backend, _ := NewSystemdNetworkdBackend()
backend.links = map[string]*linkInfo{
"eno1": {ifindex: 2, name: "eno1", path: "/org/freedesktop/network1/link/_32", linkType: "ether", opState: "routable"},
"podman3": {ifindex: 9, name: "podman3", path: "/org/freedesktop/network1/link/_39", linkType: "ether", opState: "routable"},
"veth0": {ifindex: 10, name: "veth0", path: "/org/freedesktop/network1/link/_310", linkType: "ether", opState: "routable"},
}
backend.syncLinks([]enumeratedLink{
{ifindex: 2, name: "eno1", path: "/org/freedesktop/network1/link/_32"},
})
assert.Len(t, backend.links, 1)
assert.Contains(t, backend.links, "eno1")
assert.NotContains(t, backend.links, "podman3")
assert.NotContains(t, backend.links, "veth0")
}
func TestSyncLinks_RefreshesSurvivingLink(t *testing.T) {
// A link that survives keeps its cached Type — Describe is only queried for
// newly seen links — while picking up a refreshed ifindex.
backend, _ := NewSystemdNetworkdBackend()
backend.links = map[string]*linkInfo{
"eno1": {ifindex: 2, name: "eno1", path: "/org/freedesktop/network1/link/_32", linkType: "ether"},
}
backend.syncLinks([]enumeratedLink{
{ifindex: 7, name: "eno1", path: "/org/freedesktop/network1/link/_32"},
})
assert.Len(t, backend.links, 1)
assert.Equal(t, int32(7), backend.links["eno1"].ifindex)
assert.Equal(t, "ether", backend.links["eno1"].linkType)
}
func TestLooksVirtual(t *testing.T) {
virtual := []string{"lo", "docker0", "veth123", "virbr0", "br-abc", "vnet0", "tun0", "tap0", "vboxnet0", "vmnet1", "kube-ipvs0", "cni0", "flannel.1", "cali-abc", "podman0", "podman3"}
virtual := []string{"lo", "docker0", "veth123", "virbr0", "br-abc", "vnet0", "tun0", "tap0", "vboxnet0", "vmnet1", "kube-ipvs0", "cni0", "flannel.1", "cali-abc"}
for _, n := range virtual {
assert.True(t, looksVirtual(n), "%s should look virtual", n)
}
-1
View File
@@ -418,7 +418,6 @@ func handleConnection(conn net.Conn) {
conn.Write(capsData)
conn.Write([]byte("\n"))
scanner := bufio.NewScanner(conn)
scanner.Buffer(make([]byte, bufio.MaxScanTokenSize), 64*1024*1024) // grow up to 64 MB for large clipboard payloads
for scanner.Scan() {
line := scanner.Bytes()
+11 -20
View File
@@ -103,7 +103,15 @@ func (m Model) updateDeployingConfigsState(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m Model) deployConfigurations() tea.Cmd {
return func() tea.Msg {
// Determine the selected window manager
wm := m.selectedWindowManager()
var wm deps.WindowManager
switch m.selectedWM {
case 0:
wm = deps.WindowManagerNiri
case 1:
wm = deps.WindowManagerHyprland
default:
wm = deps.WindowManagerNiri
}
// Determine the selected terminal
var terminal deps.Terminal
@@ -280,8 +288,7 @@ func (m Model) checkExistingConfigurations() tea.Cmd {
return func() tea.Msg {
var configs []ExistingConfigInfo
switch m.selectedWindowManager() {
case deps.WindowManagerNiri:
if m.selectedWM == 0 {
niriPath := filepath.Join(os.Getenv("HOME"), ".config", "niri", "config.kdl")
niriExists := false
if _, err := os.Stat(niriPath); err == nil {
@@ -292,23 +299,7 @@ func (m Model) checkExistingConfigurations() tea.Cmd {
Path: niriPath,
Exists: niriExists,
})
case deps.WindowManagerMango:
mangoConfPath := filepath.Join(os.Getenv("HOME"), ".config", "mango", "config.conf")
mangoMainPath := filepath.Join(os.Getenv("HOME"), ".config", "mango", "mango.conf")
mangoPath := mangoConfPath
mangoExists := false
if _, err := os.Stat(mangoConfPath); err == nil {
mangoExists = true
} else if _, err := os.Stat(mangoMainPath); err == nil {
mangoPath = mangoMainPath
mangoExists = true
}
configs = append(configs, ExistingConfigInfo{
ConfigType: "Mango",
Path: mangoPath,
Exists: mangoExists,
})
default:
} else {
hyprlandLuaPath := filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.lua")
hyprlandConfPath := filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf")
hyprlandPath := hyprlandLuaPath
+7 -5
View File
@@ -209,7 +209,12 @@ func (m Model) installPackages() tea.Cmd {
}
// Convert TUI selection to deps enum
wm := m.selectedWindowManager()
var wm deps.WindowManager
if m.selectedWM == 0 {
wm = deps.WindowManagerNiri
} else {
wm = deps.WindowManagerHyprland
}
installerProgressChan := make(chan distros.InstallProgressMsg, 100)
@@ -240,11 +245,8 @@ func (m Model) installPackages() tea.Cmd {
}
if greeterSelected {
compositorName := "niri"
switch m.selectedWindowManager() {
case deps.WindowManagerHyprland:
if m.selectedWM == 1 {
compositorName = "Hyprland"
case deps.WindowManagerMango:
compositorName = "mango"
}
m.packageProgressChan <- packageInstallProgressMsg{
progress: 0.92,
+1 -2
View File
@@ -4,7 +4,6 @@ import (
"fmt"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
tea "github.com/charmbracelet/bubbletea"
)
@@ -66,7 +65,7 @@ func (m Model) updateGentooUseFlagsState(msg tea.Msg) (tea.Model, tea.Cmd) {
m.skipGentooUseFlags = !m.skipGentooUseFlags
return m, nil
case "enter":
if m.selectedWindowManager() == deps.WindowManagerHyprland {
if m.selectedWM == 1 {
return m, m.checkGCCVersion()
}
return m.enterAuthPhase()
+1 -19
View File
@@ -199,21 +199,8 @@ func (m Model) viewInstallComplete() string {
b.WriteString("\n")
}
wm := m.selectedWindowManager()
// mango launches DMS via `exec_once=dms run` (not a systemd session target)
loginHint := "If you do not have a greeter, login with \"niri-session\" or \"Hyprland\""
switch wm {
case deps.WindowManagerNiri:
loginHint = "If you do not have a greeter, login with \"niri-session\""
case deps.WindowManagerHyprland:
loginHint = "If you do not have a greeter, login with \"Hyprland\""
case deps.WindowManagerMango:
loginHint = "If you do not have a greeter, login with \"mango\""
}
b.WriteString("\n")
info := m.styles.Normal.Render("Your system is ready! Log out and log back in to start using\nyour new desktop environment.\n" + loginHint)
info := m.styles.Normal.Render("Your system is ready! Log out and log back in to start using\nyour new desktop environment.\nIf you do not have a greeter, login with \"niri-session\" or \"Hyprland\"")
b.WriteString(info)
b.WriteString("\n\n")
@@ -222,13 +209,8 @@ func (m Model) viewInstallComplete() string {
labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(theme.Subtle))
b.WriteString(labelStyle.Render("Troubleshooting:") + "\n")
if wm == deps.WindowManagerMango {
b.WriteString(labelStyle.Render(" Disable autostart: ") + cmdStyle.Render("remove 'exec_once=dms run' from ~/.config/mango/config.conf") + "\n")
b.WriteString(labelStyle.Render(" View logs: ") + cmdStyle.Render("qs -p ~/.config/quickshell/dms log") + "\n")
} else {
b.WriteString(labelStyle.Render(" Disable autostart: ") + cmdStyle.Render("systemctl --user disable dms") + "\n")
b.WriteString(labelStyle.Render(" View logs: ") + cmdStyle.Render("journalctl --user -u dms") + "\n")
}
if m.osInfo != nil {
if cmd := uninstallCommand(m.osInfo.Distribution.ID, m.dependencies); cmd != "" {
+10 -27
View File
@@ -10,26 +10,6 @@ import (
tea "github.com/charmbracelet/bubbletea"
)
// windowManagerOptions returns the WM enums in selection-list order (debian omits
// Hyprland). selectedWM indexes into this, so all index->WM conversions use it.
func (m Model) windowManagerOptions() []deps.WindowManager {
opts := []deps.WindowManager{deps.WindowManagerNiri}
if m.osInfo == nil || m.osInfo.Distribution.ID != "debian" {
opts = append(opts, deps.WindowManagerHyprland)
}
opts = append(opts, deps.WindowManagerMango)
return opts
}
// selectedWindowManager maps the current selectedWM index to its WM enum.
func (m Model) selectedWindowManager() deps.WindowManager {
opts := m.windowManagerOptions()
if m.selectedWM >= 0 && m.selectedWM < len(opts) {
return opts[m.selectedWM]
}
return deps.WindowManagerNiri
}
func (m Model) viewSelectWindowManager() string {
var b strings.Builder
@@ -54,11 +34,6 @@ func (m Model) viewSelectWindowManager() string {
}{"Hyprland", "Dynamic tiling Wayland compositor."})
}
options = append(options, struct {
name string
description string
}{"mango", "dwl-based dynamic tiling Wayland compositor."})
for i, option := range options {
if i == m.selectedWM {
selected := m.styles.SelectedOption.Render("▶ " + option.name)
@@ -177,7 +152,10 @@ func (m Model) updateSelectTerminalState(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m Model) updateSelectWindowManagerState(msg tea.Msg) (tea.Model, tea.Cmd) {
if keyMsg, ok := msg.(tea.KeyMsg); ok {
maxWMIndex := len(m.windowManagerOptions()) - 1
maxWMIndex := 1
if m.osInfo != nil && m.osInfo.Distribution.ID == "debian" {
maxWMIndex = 0
}
switch keyMsg.String() {
case "up":
@@ -212,7 +190,12 @@ func (m Model) detectDependencies() tea.Cmd {
}
// Convert TUI selection to deps enum
wm := m.selectedWindowManager()
var wm deps.WindowManager
if m.selectedWM == 0 {
wm = deps.WindowManagerNiri // First option is Niri
} else {
wm = deps.WindowManagerHyprland // Second option is Hyprland
}
var terminal deps.Terminal
if m.osInfo != nil && m.osInfo.Distribution.ID == "gentoo" {
@@ -44,8 +44,6 @@ type HyprlandRulesParser struct {
dmsIncludePos int
rulesAfterDMS int
dmsProcessed bool
configFormat string
readOnly bool
requireLineInMain int // hyprland.lua line (1-based) where require("dms.windowrules") occurs; else -1
primaryHyprLua string // absolute path to ~/.config/hypr/hyprland.lua when that is the main config
@@ -84,15 +82,10 @@ func (p *HyprlandRulesParser) Parse() ([]HyprlandWindowRule, error) {
}
if strings.EqualFold(filepath.Ext(mainConfig), ".lua") {
p.configFormat = "lua"
p.readOnly = false
p.probeRequireWindowrulesLine(mainConfig)
if ap, err := filepath.Abs(mainConfig); err == nil {
p.primaryHyprLua = ap
}
} else {
p.configFormat = "hyprlang"
p.readOnly = true
}
if err := p.parseFile(mainConfig); err != nil {
@@ -307,8 +300,6 @@ func (p *HyprlandRulesParser) buildDMSStatus() *windowrules.DMSRulesStatus {
IncludePosition: p.dmsIncludePos,
TotalIncludes: p.includeCount,
RulesAfterDMS: p.rulesAfterDMS,
ConfigFormat: p.configFormat,
ReadOnly: p.readOnly,
}
switch {
@@ -460,9 +451,6 @@ func (p *HyprlandWritableProvider) GetRuleSet() (*windowrules.RuleSet, error) {
}
func (p *HyprlandWritableProvider) SetRule(rule windowrules.WindowRule) error {
if err := p.ensureWritableConfig(); err != nil {
return err
}
rules, err := p.LoadDMSRules()
if err != nil {
rules = []windowrules.WindowRule{}
@@ -484,9 +472,6 @@ func (p *HyprlandWritableProvider) SetRule(rule windowrules.WindowRule) error {
}
func (p *HyprlandWritableProvider) RemoveRule(id string) error {
if err := p.ensureWritableConfig(); err != nil {
return err
}
rules, err := p.LoadDMSRules()
if err != nil {
return err
@@ -503,9 +488,6 @@ func (p *HyprlandWritableProvider) RemoveRule(id string) error {
}
func (p *HyprlandWritableProvider) ReorderRules(ids []string) error {
if err := p.ensureWritableConfig(); err != nil {
return err
}
rules, err := p.LoadDMSRules()
if err != nil {
return err
@@ -531,29 +513,6 @@ func (p *HyprlandWritableProvider) ReorderRules(ids []string) error {
return p.writeDMSRules(newRules)
}
func (p *HyprlandWritableProvider) ensureWritableConfig() error {
if p.isLegacyConfigReadOnly() {
return fmt.Errorf("hyprland legacy conf configs are read-only; run dms setup to migrate to Lua before editing window rules")
}
return nil
}
func (p *HyprlandWritableProvider) isLegacyConfigReadOnly() bool {
expanded, err := utils.ExpandPath(p.configDir)
if err != nil {
expanded = p.configDir
}
luaPath := filepath.Join(expanded, "hyprland.lua")
if st, err := os.Stat(luaPath); err == nil && st.Mode().IsRegular() {
return false
}
confPath := filepath.Join(expanded, "hyprland.conf")
if st, err := os.Stat(confPath); err == nil && st.Mode().IsRegular() {
return true
}
return false
}
var dmsRuleCommentRegex = regexp.MustCompile(`^#\s*DMS-RULE:\s*id=([^,]+),\s*name=(.*)$`)
var dmsRuleLuaHDRRegex = regexp.MustCompile(`^\s*--\s*DMS-RULE:\s*id=([^,]+),\s*name=(.*)$`)
@@ -799,7 +758,11 @@ func (p *HyprlandWritableProvider) loadDMSRulesFromLua(data []byte, rulesPath st
Actions: *acts,
}
if wr.ID == "" {
wr.ID = fmt.Sprintf("dms_rule_%d", len(rules))
if wr.MatchCriteria.AppID != "" {
wr.ID = wr.MatchCriteria.AppID
} else {
wr.ID = wr.MatchCriteria.Title
}
}
rules = append(rules, wr)
}
@@ -188,27 +188,6 @@ func TestHyprlandSetAndLoadDMSRules(t *testing.T) {
}
}
func TestHyprlandSetRuleLeavesConfOnlyInstallReadOnly(t *testing.T) {
tmpDir := t.TempDir()
if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.conf"), []byte("windowrulev2 = float, class:^(kitty)$\n"), 0o644); err != nil {
t.Fatal(err)
}
provider := NewHyprlandWritableProvider(tmpDir)
rule := newTestWindowRule("test_id", "Test Rule", "^(firefox)$")
rule.Actions.OpenFloating = boolPtr(true)
err := provider.SetRule(rule)
if err == nil {
t.Fatal("expected SetRule to reject conf-only Hyprland config")
}
if !strings.Contains(err.Error(), "read-only") {
t.Fatalf("expected read-only error, got %v", err)
}
if _, err := os.Stat(filepath.Join(tmpDir, "dms", "windowrules.lua")); !os.IsNotExist(err) {
t.Fatalf("expected no Lua windowrules file to be created for conf-only config, stat err=%v", err)
}
}
func TestHyprlandRemoveRule(t *testing.T) {
tmpDir := t.TempDir()
provider := NewHyprlandWritableProvider(tmpDir)
@@ -1,378 +0,0 @@
package providers
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules"
)
// Mango window rules are flat `windowrule=key:value,...` lines. DMS-managed rules
// live in dms/windowrules.conf (sourced from config.conf), each preceded by an
// `# @id=<id> @name=<name>` comment so they round-trip.
type MangoWindowRule struct {
Source string
Fields map[string]string
}
var mangoWindowRuleRegex = regexp.MustCompile(`^windowrule\s*=\s*(.+)$`)
var mangoMetaCommentRegex = regexp.MustCompile(`^#\s*@id=(\S*)\s*@name=(.*)$`)
func parseMangoWindowRuleLine(value string) map[string]string {
fields := map[string]string{}
for _, pair := range strings.Split(value, ",") {
pair = strings.TrimSpace(pair)
if pair == "" {
continue
}
colon := strings.Index(pair, ":")
if colon < 0 {
continue
}
key := strings.TrimSpace(pair[:colon])
val := strings.TrimSpace(pair[colon+1:])
if key != "" {
fields[key] = val
}
}
return fields
}
// mangoConfigPath returns the main mango config (config.conf or mango.conf).
func mangoConfigPath(configDir string) string {
candidates := []string{
filepath.Join(configDir, "config.conf"),
filepath.Join(configDir, "mango.conf"),
}
for _, c := range candidates {
if _, err := os.Stat(c); err == nil {
return c
}
}
return candidates[0]
}
func mangoOverridePath(configDir string) string {
return filepath.Join(configDir, "dms", "windowrules.conf")
}
// parseMangoRulesFile reads a config file and returns its windowrule= lines.
func parseMangoRulesFile(path, source string) []MangoWindowRule {
data, err := os.ReadFile(path)
if err != nil {
return nil
}
var rules []MangoWindowRule
for _, line := range strings.Split(string(data), "\n") {
trimmed := strings.TrimSpace(line)
if m := mangoWindowRuleRegex.FindStringSubmatch(trimmed); m != nil {
rules = append(rules, MangoWindowRule{Source: source, Fields: parseMangoWindowRuleLine(m[1])})
}
}
return rules
}
type MangoRulesParseResult struct {
Rules []MangoWindowRule
DMSRulesIncluded bool
DMSStatus *windowrules.DMSRulesStatus
}
func ParseMangoWindowRules(configDir string) (*MangoRulesParseResult, error) {
mainPath := mangoConfigPath(configDir)
overridePath := mangoOverridePath(configDir)
var rules []MangoWindowRule
rules = append(rules, parseMangoRulesFile(mainPath, "config.conf")...)
rules = append(rules, parseMangoRulesFile(overridePath, "dms/windowrules.conf")...)
included := mangoDMSRulesIncluded(mainPath)
return &MangoRulesParseResult{
Rules: rules,
DMSRulesIncluded: included,
DMSStatus: &windowrules.DMSRulesStatus{
Exists: fileExists(overridePath),
Included: included,
Effective: included,
ConfigFormat: "conf",
StatusMessage: mangoIncludeMessage(included),
},
}, nil
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func mangoDMSRulesIncluded(mainPath string) bool {
data, err := os.ReadFile(mainPath)
if err != nil {
return false
}
for _, line := range strings.Split(string(data), "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "source") && strings.Contains(trimmed, "dms/windowrules.conf") {
return true
}
}
return false
}
func mangoIncludeMessage(included bool) string {
if included {
return "DMS window rules are sourced from config.conf"
}
return "Add `source=./dms/windowrules.conf` to config.conf to apply DMS window rules"
}
func mangoBoolField(fields map[string]string, key string) *bool {
v, ok := fields[key]
if !ok {
return nil
}
b := v == "1" || strings.EqualFold(v, "true")
return &b
}
func mangoBoolStr(b *bool) string {
if b != nil && *b {
return "1"
}
return "0"
}
func ConvertMangoRulesToWindowRules(mangoRules []MangoWindowRule) []windowrules.WindowRule {
result := make([]windowrules.WindowRule, 0, len(mangoRules))
for i, mr := range mangoRules {
f := mr.Fields
actions := windowrules.Actions{
OpenFloating: mangoBoolField(f, "isfloating"),
OpenFullscreen: mangoBoolField(f, "isfullscreen"),
NoBlur: mangoBoolField(f, "noblur"),
NoBorder: mangoBoolField(f, "isnoborder"),
NoShadow: mangoBoolField(f, "isnoshadow"),
NoRounding: mangoBoolField(f, "isnoradius"),
NoAnim: mangoBoolField(f, "isnoanimation"),
}
if tags, ok := f["tags"]; ok {
actions.Workspace = tags
}
if mon, ok := f["monitor"]; ok {
actions.Monitor = mon
}
if w, ok := f["width"]; ok {
if h, ok2 := f["height"]; ok2 {
actions.Size = w + "x" + h
}
}
result = append(result, windowrules.WindowRule{
ID: fmt.Sprintf("rule_%d", i),
Enabled: true,
Source: mr.Source,
MatchCriteria: windowrules.MatchCriteria{
AppID: f["appid"],
Title: f["title"],
},
Actions: actions,
})
}
return result
}
// formatMangoRule serializes a shared WindowRule into a mango windowrule= line.
func formatMangoRule(rule windowrules.WindowRule) string {
var parts []string
add := func(k, v string) {
if v != "" {
parts = append(parts, k+":"+v)
}
}
add("appid", rule.MatchCriteria.AppID)
add("title", rule.MatchCriteria.Title)
add("tags", rule.Actions.Workspace)
add("monitor", rule.Actions.Monitor)
if rule.Actions.Size != "" {
if w, h, ok := splitSize(rule.Actions.Size); ok {
add("width", w)
add("height", h)
}
}
addBool := func(k string, b *bool) {
if b != nil {
parts = append(parts, k+":"+mangoBoolStr(b))
}
}
addBool("isfloating", rule.Actions.OpenFloating)
addBool("isfullscreen", rule.Actions.OpenFullscreen)
addBool("noblur", rule.Actions.NoBlur)
addBool("isnoborder", rule.Actions.NoBorder)
addBool("isnoshadow", rule.Actions.NoShadow)
addBool("isnoradius", rule.Actions.NoRounding)
addBool("isnoanimation", rule.Actions.NoAnim)
return "windowrule=" + strings.Join(parts, ",")
}
func splitSize(size string) (w, h string, ok bool) {
for _, sep := range []string{"x", "X", " "} {
if parts := strings.Split(size, sep); len(parts) == 2 {
w = strings.TrimSpace(parts[0])
h = strings.TrimSpace(parts[1])
if _, err := strconv.ParseFloat(w, 64); err == nil {
return w, h, true
}
}
}
return "", "", false
}
type MangoWritableProvider struct {
configDir string
}
func NewMangoWritableProvider(configDir string) *MangoWritableProvider {
return &MangoWritableProvider{configDir: configDir}
}
func (p *MangoWritableProvider) Name() string { return "mango" }
func (p *MangoWritableProvider) GetOverridePath() string {
return mangoOverridePath(p.configDir)
}
func (p *MangoWritableProvider) GetRuleSet() (*windowrules.RuleSet, error) {
result, err := ParseMangoWindowRules(p.configDir)
if err != nil {
return nil, err
}
return &windowrules.RuleSet{
Title: "Mango Window Rules",
Provider: "mango",
Rules: ConvertMangoRulesToWindowRules(result.Rules),
DMSRulesIncluded: result.DMSRulesIncluded,
DMSStatus: result.DMSStatus,
}, nil
}
func (p *MangoWritableProvider) SetRule(rule windowrules.WindowRule) error {
rules, err := p.LoadDMSRules()
if err != nil {
rules = []windowrules.WindowRule{}
}
found := false
for i, r := range rules {
if r.ID == rule.ID {
rules[i] = rule
found = true
break
}
}
if !found {
rules = append(rules, rule)
}
return p.writeDMSRules(rules)
}
func (p *MangoWritableProvider) RemoveRule(id string) error {
rules, err := p.LoadDMSRules()
if err != nil {
return err
}
newRules := make([]windowrules.WindowRule, 0, len(rules))
for _, r := range rules {
if r.ID != id {
newRules = append(newRules, r)
}
}
return p.writeDMSRules(newRules)
}
func (p *MangoWritableProvider) ReorderRules(ids []string) error {
rules, err := p.LoadDMSRules()
if err != nil {
return err
}
ruleMap := make(map[string]windowrules.WindowRule, len(rules))
for _, r := range rules {
ruleMap[r.ID] = r
}
newRules := make([]windowrules.WindowRule, 0, len(ids))
for _, id := range ids {
if r, ok := ruleMap[id]; ok {
newRules = append(newRules, r)
delete(ruleMap, id)
}
}
for _, r := range ruleMap {
newRules = append(newRules, r)
}
return p.writeDMSRules(newRules)
}
// LoadDMSRules parses only the DMS override file, preserving @id/@name metadata.
func (p *MangoWritableProvider) LoadDMSRules() ([]windowrules.WindowRule, error) {
data, err := os.ReadFile(p.GetOverridePath())
if err != nil {
if os.IsNotExist(err) {
return []windowrules.WindowRule{}, nil
}
return nil, err
}
var rules []windowrules.WindowRule
var curID, curName string
idx := 0
for _, line := range strings.Split(string(data), "\n") {
trimmed := strings.TrimSpace(line)
if m := mangoMetaCommentRegex.FindStringSubmatch(trimmed); m != nil {
curID = m[1]
curName = strings.TrimSpace(m[2])
continue
}
if m := mangoWindowRuleRegex.FindStringSubmatch(trimmed); m != nil {
converted := ConvertMangoRulesToWindowRules([]MangoWindowRule{{Source: "dms/windowrules.conf", Fields: parseMangoWindowRuleLine(m[1])}})
wr := converted[0]
if curID != "" {
wr.ID = curID
} else {
wr.ID = fmt.Sprintf("rule_%d", idx)
}
wr.Name = curName
rules = append(rules, wr)
curID, curName = "", ""
idx++
}
}
return rules, nil
}
func (p *MangoWritableProvider) writeDMSRules(rules []windowrules.WindowRule) error {
overridePath := p.GetOverridePath()
if err := os.MkdirAll(filepath.Dir(overridePath), 0o755); err != nil {
return err
}
var sb strings.Builder
sb.WriteString("# Auto-generated by DMS - DMS-managed mango window rules\n\n")
for i, r := range rules {
id := r.ID
if id == "" {
id = fmt.Sprintf("rule_%d", i)
}
fmt.Fprintf(&sb, "# @id=%s @name=%s\n", id, r.Name)
sb.WriteString(formatMangoRule(r))
sb.WriteString("\n\n")
}
return os.WriteFile(overridePath, []byte(sb.String()), 0o644)
}
@@ -1,116 +0,0 @@
package providers
import (
"os"
"path/filepath"
"testing"
"github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules"
)
func TestParseMangoWindowRuleLine(t *testing.T) {
fields := parseMangoWindowRuleLine("appid:firefox,title:Gmail,isfloating:1,tags:2,monitor:HDMI-A-1")
if fields["appid"] != "firefox" {
t.Errorf("appid = %q, want firefox", fields["appid"])
}
if fields["title"] != "Gmail" {
t.Errorf("title = %q, want Gmail", fields["title"])
}
if fields["isfloating"] != "1" {
t.Errorf("isfloating = %q, want 1", fields["isfloating"])
}
if fields["tags"] != "2" {
t.Errorf("tags = %q, want 2", fields["tags"])
}
if fields["monitor"] != "HDMI-A-1" {
t.Errorf("monitor = %q, want HDMI-A-1", fields["monitor"])
}
}
func TestConvertMangoRulesToWindowRules(t *testing.T) {
mangoRules := []MangoWindowRule{
{Source: "config.conf", Fields: parseMangoWindowRuleLine("appid:discord,tags:9,isfloating:1,noblur:1")},
}
rules := ConvertMangoRulesToWindowRules(mangoRules)
if len(rules) != 1 {
t.Fatalf("got %d rules, want 1", len(rules))
}
r := rules[0]
if r.MatchCriteria.AppID != "discord" {
t.Errorf("AppID = %q, want discord", r.MatchCriteria.AppID)
}
if r.Actions.Workspace != "9" {
t.Errorf("Workspace = %q, want 9", r.Actions.Workspace)
}
if r.Actions.OpenFloating == nil || !*r.Actions.OpenFloating {
t.Errorf("OpenFloating = %v, want true", r.Actions.OpenFloating)
}
if r.Actions.NoBlur == nil || !*r.Actions.NoBlur {
t.Errorf("NoBlur = %v, want true", r.Actions.NoBlur)
}
}
func TestMangoSetAndLoadRoundTrip(t *testing.T) {
tmpDir := t.TempDir()
provider := NewMangoWritableProvider(tmpDir)
floating := true
rule := windowrules.WindowRule{
ID: "rule_test",
Name: "Float Discord",
Enabled: true,
MatchCriteria: windowrules.MatchCriteria{
AppID: "discord",
},
Actions: windowrules.Actions{
OpenFloating: &floating,
Workspace: "9",
Size: "1000x900",
},
}
if err := provider.SetRule(rule); err != nil {
t.Fatalf("SetRule: %v", err)
}
expectedPath := filepath.Join(tmpDir, "dms", "windowrules.conf")
if _, err := os.Stat(expectedPath); err != nil {
t.Fatalf("override file not written: %v", err)
}
loaded, err := provider.LoadDMSRules()
if err != nil {
t.Fatalf("LoadDMSRules: %v", err)
}
if len(loaded) != 1 {
t.Fatalf("got %d rules, want 1", len(loaded))
}
got := loaded[0]
if got.ID != "rule_test" {
t.Errorf("ID = %q, want rule_test", got.ID)
}
if got.Name != "Float Discord" {
t.Errorf("Name = %q, want 'Float Discord'", got.Name)
}
if got.MatchCriteria.AppID != "discord" {
t.Errorf("AppID = %q, want discord", got.MatchCriteria.AppID)
}
if got.Actions.Workspace != "9" {
t.Errorf("Workspace = %q, want 9", got.Actions.Workspace)
}
if got.Actions.Size != "1000x900" {
t.Errorf("Size = %q, want 1000x900", got.Actions.Size)
}
if got.Actions.OpenFloating == nil || !*got.Actions.OpenFloating {
t.Errorf("OpenFloating = %v, want true", got.Actions.OpenFloating)
}
// Remove and confirm empty.
if err := provider.RemoveRule("rule_test"); err != nil {
t.Fatalf("RemoveRule: %v", err)
}
loaded, _ = provider.LoadDMSRules()
if len(loaded) != 0 {
t.Errorf("after remove got %d rules, want 0", len(loaded))
}
}
@@ -14,18 +14,6 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules"
)
type NiriMatch struct {
AppID string
Title string
IsFloating *bool
IsActive *bool
IsFocused *bool
IsActiveInColumn *bool
IsWindowCastTarget *bool
IsUrgent *bool
AtStartup *bool
}
type NiriWindowRule struct {
MatchAppID string
MatchTitle string
@@ -36,7 +24,6 @@ type NiriWindowRule struct {
MatchIsWindowCastTarget *bool
MatchIsUrgent *bool
MatchAtStartup *bool
Matches []NiriMatch
Opacity *float64
OpenFloating *bool
OpenMaximized *bool
@@ -63,13 +50,6 @@ type NiriWindowRule struct {
FocusRingOff *bool
BorderOff *bool
DrawBorderWithBg *bool
BgBlur *bool
BgXray *bool
BgNoise *float64
BgSaturation *float64
DefaultFloatingX *int
DefaultFloatingY *int
DefaultFloatingRelative string
Source string
}
@@ -211,7 +191,7 @@ func (p *NiriRulesParser) parseWindowRuleNode(node *document.Node) {
switch childName {
case "match":
rule.Matches = append(rule.Matches, p.parseMatchNode(child))
p.parseMatchNode(child, &rule)
case "opacity":
if len(child.Arguments) > 0 {
val := child.Arguments[0].ResolvedValue()
@@ -317,26 +297,9 @@ func (p *NiriRulesParser) parseWindowRuleNode(node *document.Node) {
case "draw-border-with-background":
b := p.parseBoolArg(child)
rule.DrawBorderWithBg = &b
case "background-effect":
p.parseBackgroundEffectNode(child, &rule)
case "default-floating-position":
p.parseFloatingPositionNode(child, &rule)
}
}
if len(rule.Matches) > 0 {
first := rule.Matches[0]
rule.MatchAppID = first.AppID
rule.MatchTitle = first.Title
rule.MatchIsFloating = first.IsFloating
rule.MatchIsActive = first.IsActive
rule.MatchIsFocused = first.IsFocused
rule.MatchIsActiveInColumn = first.IsActiveInColumn
rule.MatchIsWindowCastTarget = first.IsWindowCastTarget
rule.MatchIsUrgent = first.IsUrgent
rule.MatchAtStartup = first.AtStartup
}
p.rules = append(p.rules, rule)
}
@@ -363,47 +326,45 @@ func (p *NiriRulesParser) parseSizeNode(node *document.Node) string {
return ""
}
func (p *NiriRulesParser) parseMatchNode(node *document.Node) NiriMatch {
m := NiriMatch{}
func (p *NiriRulesParser) parseMatchNode(node *document.Node, rule *NiriWindowRule) {
if node.Properties == nil {
return m
return
}
if val, ok := node.Properties.Get("app-id"); ok {
m.AppID = val.ValueString()
rule.MatchAppID = val.ValueString()
}
if val, ok := node.Properties.Get("title"); ok {
m.Title = val.ValueString()
rule.MatchTitle = val.ValueString()
}
if val, ok := node.Properties.Get("is-floating"); ok {
b := val.ValueString() == "true"
m.IsFloating = &b
rule.MatchIsFloating = &b
}
if val, ok := node.Properties.Get("is-active"); ok {
b := val.ValueString() == "true"
m.IsActive = &b
rule.MatchIsActive = &b
}
if val, ok := node.Properties.Get("is-focused"); ok {
b := val.ValueString() == "true"
m.IsFocused = &b
rule.MatchIsFocused = &b
}
if val, ok := node.Properties.Get("is-active-in-column"); ok {
b := val.ValueString() == "true"
m.IsActiveInColumn = &b
rule.MatchIsActiveInColumn = &b
}
if val, ok := node.Properties.Get("is-window-cast-target"); ok {
b := val.ValueString() == "true"
m.IsWindowCastTarget = &b
rule.MatchIsWindowCastTarget = &b
}
if val, ok := node.Properties.Get("is-urgent"); ok {
b := val.ValueString() == "true"
m.IsUrgent = &b
rule.MatchIsUrgent = &b
}
if val, ok := node.Properties.Get("at-startup"); ok {
b := val.ValueString() == "true"
m.AtStartup = &b
rule.MatchAtStartup = &b
}
return m
}
func (p *NiriRulesParser) parseBorderNode(node *document.Node, rule *NiriWindowRule) {
@@ -424,64 +385,6 @@ func (p *NiriRulesParser) parseBorderNode(node *document.Node, rule *NiriWindowR
}
}
func (p *NiriRulesParser) parseBackgroundEffectNode(node *document.Node, rule *NiriWindowRule) {
if node.Children == nil {
return
}
for _, child := range node.Children {
switch child.Name.String() {
case "blur":
b := p.parseBoolArg(child)
rule.BgBlur = &b
case "xray":
b := p.parseBoolArg(child)
rule.BgXray = &b
case "noise":
if f, ok := p.parseFloatArg(child); ok {
rule.BgNoise = &f
}
case "saturation":
if f, ok := p.parseFloatArg(child); ok {
rule.BgSaturation = &f
}
}
}
}
func (p *NiriRulesParser) parseFloatingPositionNode(node *document.Node, rule *NiriWindowRule) {
if node.Properties == nil {
return
}
if val, ok := node.Properties.Get("x"); ok {
if n, err := strconv.Atoi(strings.TrimSpace(val.ValueString())); err == nil {
rule.DefaultFloatingX = &n
}
}
if val, ok := node.Properties.Get("y"); ok {
if n, err := strconv.Atoi(strings.TrimSpace(val.ValueString())); err == nil {
rule.DefaultFloatingY = &n
}
}
if val, ok := node.Properties.Get("relative-to"); ok {
rule.DefaultFloatingRelative = val.ValueString()
}
}
func (p *NiriRulesParser) parseFloatArg(node *document.Node) (float64, bool) {
if len(node.Arguments) == 0 {
return 0, false
}
val := node.Arguments[0].ResolvedValue()
switch v := val.(type) {
case float64:
return v, true
case int64:
return float64(v), true
}
return 0, false
}
func (p *NiriRulesParser) parseFocusRingNode(node *document.Node, rule *NiriWindowRule) {
if node.Children == nil {
return
@@ -558,27 +461,6 @@ func ParseNiriWindowRules(configDir string) (*NiriRulesParseResult, error) {
}, nil
}
func convertNiriMatches(matches []NiriMatch) []windowrules.MatchCriteria {
if len(matches) == 0 {
return nil
}
result := make([]windowrules.MatchCriteria, 0, len(matches))
for _, m := range matches {
result = append(result, windowrules.MatchCriteria{
AppID: m.AppID,
Title: m.Title,
IsFloating: m.IsFloating,
IsActive: m.IsActive,
IsFocused: m.IsFocused,
IsActiveInColumn: m.IsActiveInColumn,
IsWindowCastTarget: m.IsWindowCastTarget,
IsUrgent: m.IsUrgent,
AtStartup: m.AtStartup,
})
}
return result
}
func ConvertNiriRulesToWindowRules(niriRules []NiriWindowRule) []windowrules.WindowRule {
result := make([]windowrules.WindowRule, 0, len(niriRules))
for i, nr := range niriRules {
@@ -597,7 +479,6 @@ func ConvertNiriRulesToWindowRules(niriRules []NiriWindowRule) []windowrules.Win
IsUrgent: nr.MatchIsUrgent,
AtStartup: nr.MatchAtStartup,
},
Matches: convertNiriMatches(nr.Matches),
Actions: windowrules.Actions{
Opacity: nr.Opacity,
OpenFloating: nr.OpenFloating,
@@ -625,13 +506,6 @@ func ConvertNiriRulesToWindowRules(niriRules []NiriWindowRule) []windowrules.Win
FocusRingOff: nr.FocusRingOff,
BorderOff: nr.BorderOff,
DrawBorderWithBg: nr.DrawBorderWithBg,
BackgroundBlur: nr.BgBlur,
BackgroundXray: nr.BgXray,
BackgroundNoise: nr.BgNoise,
BackgroundSaturation: nr.BgSaturation,
DefaultFloatingX: nr.DefaultFloatingX,
DefaultFloatingY: nr.DefaultFloatingY,
DefaultFloatingRelativeTo: nr.DefaultFloatingRelative,
},
}
result = append(result, wr)
@@ -810,7 +684,6 @@ func (p *NiriWritableProvider) LoadDMSRules() ([]windowrules.WindowRule, error)
IsUrgent: nr.MatchIsUrgent,
AtStartup: nr.MatchAtStartup,
},
Matches: convertNiriMatches(nr.Matches),
Actions: windowrules.Actions{
Opacity: nr.Opacity,
OpenFloating: nr.OpenFloating,
@@ -838,13 +711,6 @@ func (p *NiriWritableProvider) LoadDMSRules() ([]windowrules.WindowRule, error)
FocusRingOff: nr.FocusRingOff,
BorderOff: nr.BorderOff,
DrawBorderWithBg: nr.DrawBorderWithBg,
BackgroundBlur: nr.BgBlur,
BackgroundXray: nr.BgXray,
BackgroundNoise: nr.BgNoise,
BackgroundSaturation: nr.BgSaturation,
DefaultFloatingX: nr.DefaultFloatingX,
DefaultFloatingY: nr.DefaultFloatingY,
DefaultFloatingRelativeTo: nr.DefaultFloatingRelative,
},
}
@@ -874,7 +740,15 @@ func (p *NiriWritableProvider) writeDMSRules(rules []windowrules.WindowRule) err
return os.WriteFile(rulesPath, []byte(strings.Join(lines, "\n")), 0644)
}
func formatNiriMatchLine(m windowrules.MatchCriteria) (string, bool) {
func (p *NiriWritableProvider) formatRule(rule windowrules.WindowRule) string {
var lines []string
lines = append(lines, fmt.Sprintf("// @id=%s @name=%s", rule.ID, rule.Name))
lines = append(lines, "window-rule {")
m := rule.MatchCriteria
if m.AppID != "" || m.Title != "" || m.IsFloating != nil || m.IsActive != nil ||
m.IsFocused != nil || m.IsActiveInColumn != nil || m.IsWindowCastTarget != nil ||
m.IsUrgent != nil || m.AtStartup != nil {
var matchProps []string
if m.AppID != "" {
matchProps = append(matchProps, fmt.Sprintf("app-id=%q", m.AppID))
@@ -903,25 +777,7 @@ func formatNiriMatchLine(m windowrules.MatchCriteria) (string, bool) {
if m.AtStartup != nil {
matchProps = append(matchProps, fmt.Sprintf("at-startup=%t", *m.AtStartup))
}
if len(matchProps) == 0 {
return "", false
}
return " match " + strings.Join(matchProps, " "), true
}
func (p *NiriWritableProvider) formatRule(rule windowrules.WindowRule) string {
var lines []string
lines = append(lines, fmt.Sprintf("// @id=%s @name=%s", rule.ID, rule.Name))
lines = append(lines, "window-rule {")
matches := rule.Matches
if len(matches) == 0 {
matches = []windowrules.MatchCriteria{rule.MatchCriteria}
}
for _, m := range matches {
if line, ok := formatNiriMatchLine(m); ok {
lines = append(lines, line)
}
lines = append(lines, " match "+strings.Join(matchProps, " "))
}
a := rule.Actions
@@ -1002,39 +858,10 @@ func (p *NiriWritableProvider) formatRule(rule windowrules.WindowRule) string {
lines = append(lines, fmt.Sprintf(" draw-border-with-background %t", *a.DrawBorderWithBg))
}
if a.BackgroundBlur != nil || a.BackgroundXray != nil || a.BackgroundNoise != nil || a.BackgroundSaturation != nil {
lines = append(lines, " background-effect {")
if a.BackgroundBlur != nil {
lines = append(lines, fmt.Sprintf(" blur %t", *a.BackgroundBlur))
}
if a.BackgroundXray != nil {
lines = append(lines, fmt.Sprintf(" xray %t", *a.BackgroundXray))
}
if a.BackgroundNoise != nil {
lines = append(lines, fmt.Sprintf(" noise %s", formatFloat(*a.BackgroundNoise)))
}
if a.BackgroundSaturation != nil {
lines = append(lines, fmt.Sprintf(" saturation %s", formatFloat(*a.BackgroundSaturation)))
}
lines = append(lines, " }")
}
if a.DefaultFloatingX != nil && a.DefaultFloatingY != nil {
line := fmt.Sprintf(" default-floating-position x=%d y=%d", *a.DefaultFloatingX, *a.DefaultFloatingY)
if a.DefaultFloatingRelativeTo != "" {
line += fmt.Sprintf(" relative-to=%q", a.DefaultFloatingRelativeTo)
}
lines = append(lines, line)
}
lines = append(lines, "}")
return strings.Join(lines, "\n")
}
func formatFloat(f float64) string {
return strconv.FormatFloat(f, 'f', -1, 64)
}
func formatSizeProperty(name, value string) string {
parts := strings.SplitN(value, " ", 2)
if len(parts) == 2 {
-11
View File
@@ -43,14 +43,6 @@ type Actions struct {
FocusRingOff *bool `json:"focusRingOff,omitempty"`
BorderOff *bool `json:"borderOff,omitempty"`
DrawBorderWithBg *bool `json:"drawBorderWithBackground,omitempty"`
BackgroundBlur *bool `json:"backgroundBlur,omitempty"`
BackgroundXray *bool `json:"backgroundXray,omitempty"`
BackgroundNoise *float64 `json:"backgroundNoise,omitempty"`
BackgroundSaturation *float64 `json:"backgroundSaturation,omitempty"`
DefaultFloatingX *int `json:"defaultFloatingX,omitempty"`
DefaultFloatingY *int `json:"defaultFloatingY,omitempty"`
DefaultFloatingRelativeTo string `json:"defaultFloatingRelativeTo,omitempty"`
Size string `json:"size,omitempty"`
Move string `json:"move,omitempty"`
Monitor string `json:"monitor,omitempty"`
@@ -74,7 +66,6 @@ type WindowRule struct {
Name string `json:"name,omitempty"`
Enabled bool `json:"enabled"`
MatchCriteria MatchCriteria `json:"matchCriteria"`
Matches []MatchCriteria `json:"matches,omitempty"`
Actions Actions `json:"actions"`
Source string `json:"source,omitempty"`
}
@@ -88,8 +79,6 @@ type DMSRulesStatus struct {
Effective bool `json:"effective"`
OverriddenBy int `json:"overriddenBy"`
StatusMessage string `json:"statusMessage"`
ConfigFormat string `json:"configFormat,omitempty"`
ReadOnly bool `json:"readOnly,omitempty"`
}
type RuleSet struct {
-7
View File
@@ -48,13 +48,6 @@ fragments.
keyboard shortcuts in `dms/binds-user.lua`, or use the Keyboard Shortcuts page in
DMS Settings.
Stock configs include a 3-finger horizontal touchpad gesture for workspace
switching (`hl.gesture` in `dms/binds.lua`) and basic touchpad settings
(`tap_to_click`, `natural_scroll` in `hyprland.lua`). To customize or disable
gestures, add your own `hl.gesture(...)` lines to `dms/binds-user.lua`, or unset
a stock gesture with `action = "unset"` matching the original fingers,
direction, and modifiers.
Most other existing non-empty Lua fragments are preserved.
## Legacy Config Migration
-111
View File
@@ -282,53 +282,6 @@ dms ipc call inhibit toggle
dms ipc call inhibit enable
```
## Target: `powerprofile`
Power profile control via `power-profiles-daemon`. Changes stay in sync with DMS UI and trigger the power profile OSD when enabled.
Requires `power-profiles-daemon` to be installed and running. Works on all compositors.
### Functions
**`open`**
- Show the power profile picker modal
- Returns: Success confirmation or error if daemon unavailable
**`close`**
- Close the power profile picker modal
- Returns: Success confirmation
**`toggle`**
- Toggle power profile picker modal visibility
- Returns: Success confirmation or error if daemon unavailable
**`list`**
- List available profile slugs, one per line
- Returns: `power-saver`, `balanced`, and `performance` when supported
**`status`**
- Get the currently active profile slug
- Returns: `power-saver`, `balanced`, `performance`, or error if daemon unavailable
**`set <profile>`**
- Set the active power profile
- Parameters: Profile slug or alias — `power-saver` (`powersaver`, `saver`, `0`), `balanced` (`1`), `performance` (`2`)
- Returns: Success confirmation or error if profile unknown, unsupported, or write failed
**`cycle`**
- Cycle to the next available profile in order: power-saver → balanced → performance → power-saver
- Returns: Success confirmation or error if daemon unavailable or write failed
### Examples
```bash
dms ipc call powerprofile status
dms ipc call powerprofile list
dms ipc call powerprofile cycle
dms ipc call powerprofile set balanced
dms ipc call powerprofile set performance
dms ipc call powerprofile toggle
```
## Target: `wallpaper`
Wallpaper management and retrieval with support for per-monitor configurations.
@@ -532,54 +485,6 @@ dms ipc call systemupdater close
dms ipc call systemupdater updatestatus
```
## Target: `defaultApp`
Launch applications configured in Settings > Default Apps.
### Functions
**`browser`**
- Launch the configured default web browser
- Returns: Launch request confirmation
**`fileManager`**
- Launch the configured default file manager
- Returns: Launch request confirmation
**`textEditor`**
- Launch the configured default text editor
- Returns: Launch request confirmation
**`pdfReader`**
- Launch the configured default PDF reader
- Returns: Launch request confirmation
**`imageViewer`**
- Launch the configured default image viewer
- Returns: Launch request confirmation
**`videoPlayer`**
- Launch the configured default video player
- Returns: Launch request confirmation
**`musicPlayer`**
- Launch the configured default music player
- Returns: Launch request confirmation
**`mail`**
- Launch the configured default mail client
- Returns: Launch request confirmation
**`calendar`**
- Launch the configured default calendar application
- Returns: Launch request confirmation
### Examples
```bash
dms ipc call defaultApp browser
dms ipc call defaultApp fileManager
```
## Modal Controls
These targets control various modal windows and overlays.
@@ -638,18 +543,6 @@ Power menu modal control for system power actions.
- `close` - Hide power menu modal
- `toggle` - Toggle power menu modal visibility
### Target: `powerprofile`
Power profile picker modal and profile control via `power-profiles-daemon`.
**Functions:**
- `open` - Show power profile picker modal
- `close` - Hide power profile picker modal
- `toggle` - Toggle power profile picker modal visibility
- `list` - List available profile slugs
- `status` - Get current profile slug
- `set <profile>` - Set profile by slug or alias (`power-saver`, `balanced`, `performance`)
- `cycle` - Cycle to the next available profile
### Target: `control-center`
Control Center popout containing network, bluetooth, audio, power, and other quick settings.
@@ -780,10 +673,6 @@ dms ipc call processlist toggle
# Show power menu
dms ipc call powermenu toggle
# Cycle or set power profile (requires power-profiles-daemon)
dms ipc call powerprofile cycle
dms ipc call powerprofile toggle
# Open notepad
dms ipc call notepad toggle
-10
View File
@@ -57,15 +57,9 @@ const KEY_MAP = {
16842802: "XF86Eject",
16842791: "XF86Calculator",
16842806: "XF86Explorer",
16777360: "XF86HomePage",
16842794: "XF86HomePage",
16777362: "XF86Search",
16777426: "XF86Search",
16777376: "XF86Mail",
16777427: "XF86Mail",
16777377: "XF86AudioMedia",
16777419: "XF86Calculator",
16777429: "XF86Explorer",
16777442: "XF86Launch0",
16777443: "XF86Launch1",
33: "1",
@@ -135,10 +129,6 @@ function xkbKeyFromQtKey(qk) {
return String.fromCharCode(qk);
if (qk >= 16777264 && qk <= 16777298)
return "F" + (qk - 16777264 + 1);
if (qk >= 16777378 && qk <= 16777387)
return "XF86Launch" + (qk - 16777378);
if (qk >= 16777388 && qk <= 16777393)
return "XF86Launch" + String.fromCharCode(65 + qk - 16777388);
return KEY_MAP[qk] || "";
}
-33
View File
@@ -11,15 +11,6 @@ const DMS_ACTIONS = [
{ id: "spawn dms ipc call spotlight toggle", label: "Default Launcher: Toggle" },
{ id: "spawn dms ipc call spotlight open", label: "Default Launcher: Open" },
{ id: "spawn dms ipc call spotlight close", label: "Default Launcher: Close" },
{ id: "spawn dms ipc call defaultApp browser", label: "Default Web Browser: Open" },
{ id: "spawn dms ipc call defaultApp fileManager", label: "Default File Manager: Open" },
{ id: "spawn dms ipc call defaultApp mail", label: "Default Mail: Open" },
{ id: "spawn dms ipc call defaultApp calendar", label: "Default Calendar: Open" },
{ id: "spawn dms ipc call defaultApp textEditor", label: "Default Text Editor: Open" },
{ id: "spawn dms ipc call defaultApp pdfReader", label: "Default PDF Reader: Open" },
{ id: "spawn dms ipc call defaultApp imageViewer", label: "Default Image Viewer: Open" },
{ id: "spawn dms ipc call defaultApp videoPlayer", label: "Default Video Player: Open" },
{ id: "spawn dms ipc call defaultApp musicPlayer", label: "Default Music Player: Open" },
{ id: "spawn dms ipc call spotlight-bar toggle", label: "Spotlight Bar: Toggle" },
{ id: "spawn dms ipc call spotlight-bar open", label: "Spotlight Bar: Open" },
{ id: "spawn dms ipc call spotlight-bar close", label: "Spotlight Bar: Close" },
@@ -779,26 +770,6 @@ const DMS_ACTION_ARGS = {
}
};
const DMS_AMOUNT_LABELS = {
"audio increment": "Volume Up",
"audio decrement": "Volume Down",
"mpris increment": "Player Volume Up",
"mpris decrement": "Player Volume Down",
"brightness increment": "Brightness Up",
"brightness decrement": "Brightness Down"
};
function getDmsAmountLabel(action) {
var parsed = parseDmsActionArgs(action);
var label = DMS_AMOUNT_LABELS[parsed.base];
if (!label)
return null;
var amount = parsed.args?.amount;
if (amount === undefined || amount === null || amount === "")
return label;
return label + " (" + amount + "%)";
}
function getActionTypes() {
return ACTION_TYPES;
}
@@ -873,10 +844,6 @@ function getActionLabel(action, compositor) {
if (!action)
return "";
var amountLabel = getDmsAmountLabel(action);
if (amountLabel)
return amountLabel;
var dmsAct = findDmsAction(action);
if (dmsAct)
return dmsAct.label;
-91
View File
@@ -1,91 +0,0 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Wayland
import qs.Services
Singleton {
id: root
readonly property var log: Log.scoped("LayerShell")
function _toLayer(name) {
switch (name) {
case "background":
return WlrLayer.Background;
case "bottom":
return WlrLayer.Bottom;
case "top":
return WlrLayer.Top;
case "overlay":
return WlrLayer.Overlay;
}
return undefined;
}
function _toName(layer) {
switch (layer) {
case WlrLayer.Background:
return "background";
case WlrLayer.Bottom:
return "bottom";
case WlrLayer.Top:
return "top";
case WlrLayer.Overlay:
return "overlay";
}
return "top";
}
// Resolve a WlrLayer from a DMS_*_LAYER env override.
// name: env var to read, e.g. "DMS_OSD_LAYER"
// fallback: WlrLayer used when the var is unset or unrecognized
// opts (optional):
// allow: array of honored layer names; recognized names outside it
// are treated as invalid
// invalidLayer: WlrLayer used for a recognized-but-disallowed value
// (default: fallback)
// label: context for the diagnostic, e.g. "OSDs"; omit to stay silent
// error: log at error level instead of warn
function fromEnv(name, fallback, opts) {
const value = Quickshell.env(name);
if (!value)
return fallback;
const requested = _toLayer(value);
if (requested === undefined)
return fallback;
const allow = opts?.allow;
if (!allow || allow.indexOf(value) !== -1)
return requested;
const invalid = opts?.invalidLayer ?? fallback;
if (opts?.label) {
const msg = `'${value}' layer is not valid for ${opts.label}. Defaulting to '${_toName(invalid)}' layer.`;
if (opts?.error)
log.error(msg);
else
log.warn(msg);
}
return invalid;
}
// For call sites that only need "is the override the overlay layer?".
// Honors "overlay" (true) and bottom/background/top (false); anything else
// returns `fallback`.
function envUsesOverlay(name, fallback) {
switch (Quickshell.env(name)) {
case "overlay":
return true;
case "bottom":
case "background":
case "top":
return false;
default:
return fallback;
}
}
}
-1
View File
@@ -13,7 +13,6 @@ Singleton {
property var currentModalsByScreen: ({})
function openModal(modal) {
PopoutManager.screenshotActive = false;
const screenName = modal.effectiveScreen?.name ?? "unknown";
currentModalsByScreen[screenName] = modal;
modalChanged();
-5
View File
@@ -10,9 +10,6 @@ Singleton {
property var currentPopoutsByScreen: ({})
property var currentPopoutTriggers: ({})
// Set by the screenshot IPC handshake (dms screenshot region select); cleared by end() or any popout/modal open.
property bool screenshotActive: false
signal popoutOpening
signal popoutChanged
@@ -50,7 +47,6 @@ Singleton {
function showPopout(popout) {
if (!popout || !popout.screen)
return;
screenshotActive = false;
popoutOpening();
const screenName = popout.screen.name;
@@ -101,7 +97,6 @@ Singleton {
function requestPopout(popout, tabIndex, triggerSource) {
if (!popout || !popout.screen)
return;
screenshotActive = false;
const screenName = popout.screen.name;
const currentPopout = currentPopoutsByScreen[screenName];
const triggerId = triggerSource !== undefined ? triggerSource : tabIndex;
+4 -34
View File
@@ -154,8 +154,6 @@ Singleton {
property var trayItemOrder: []
property var recentColors: []
property bool showThirdPartyPlugins: false
property bool pluginBrowserInstalledFirst: false
property string pluginBrowserSortMode: "default"
property string launchPrefix: ""
property string lastBrightnessDevice: ""
property var brightnessExponentialDevices: ({})
@@ -966,20 +964,6 @@ Singleton {
saveSettings();
}
function setPluginBrowserInstalledFirst(enabled) {
pluginBrowserInstalledFirst = enabled;
saveSettings();
}
function setPluginBrowserSortMode(mode) {
if (mode === "type" || mode === "contributor")
mode = "author";
if (mode !== "default" && mode !== "name" && mode !== "author" && mode !== "category")
mode = "default";
pluginBrowserSortMode = mode;
saveSettings();
}
function setLaunchPrefix(prefix) {
launchPrefix = prefix;
saveSettings();
@@ -1369,27 +1353,13 @@ Singleton {
}
}
readonly property string _greeterCacheDir: Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter"
property string greeterSessionBaseDir: root._greeterCacheDir
function setGreeterSessionBaseDir(dir) {
const next = dir || root._greeterCacheDir;
if (greeterSessionBaseDir === next)
return;
greeterSessionBaseDir = next;
if (isGreeterMode)
greeterSessionFile.reload();
}
function resetGreeterSessionBaseDir() {
setGreeterSessionBaseDir(root._greeterCacheDir);
}
FileView {
id: greeterSessionFile
path: root.greeterSessionBaseDir ? (root.greeterSessionBaseDir + "/session.json") : ""
path: {
const greetCfgDir = Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter";
return greetCfgDir + "/session.json";
}
preload: isGreeterMode
blockLoading: false
blockWrites: true
+2 -27
View File
@@ -173,7 +173,6 @@ Singleton {
property int hyprlandLayoutGapsOverride: -1
property int hyprlandLayoutRadiusOverride: -1
property int hyprlandLayoutBorderSize: -1
property bool hyprlandResizeOnBorder: false
property int mangoLayoutGapsOverride: -1
property int mangoLayoutRadiusOverride: -1
property int mangoLayoutBorderSize: -1
@@ -316,8 +315,6 @@ Singleton {
property bool controlCenterShowBatteryIcon: false
property bool controlCenterShowPrinterIcon: false
property bool controlCenterShowScreenSharingIcon: true
property bool controlCenterShowIdleInhibitorIcon: false
property bool controlCenterShowDoNotDisturbIcon: false
property bool showPrivacyButton: true
property bool privacyShowMicIcon: false
property bool privacyShowCameraIcon: false
@@ -373,7 +370,6 @@ Singleton {
property bool showWorkspaceApps: false
property bool workspaceDragReorder: true
property bool groupWorkspaceApps: true
property bool groupActiveWorkspaceApps: false
property int maxWorkspaceIcons: 3
property int workspaceAppIconSizeOffset: 0
property bool workspaceFollowFocus: false
@@ -409,7 +405,6 @@ Singleton {
property int appsDockEnlargePercentage: 125
property int appsDockIconSizePercentage: 100
property bool keyboardLayoutNameCompactMode: false
property bool keyboardLayoutNameShowIcon: false
property bool runningAppsCurrentWorkspace: true
property bool runningAppsGroupByApp: false
property bool runningAppsCurrentMonitor: false
@@ -419,7 +414,6 @@ Singleton {
property string lockDateFormat: ""
property bool greeterRememberLastSession: true
property bool greeterRememberLastUser: true
property bool greeterAutoLogin: false
property bool greeterEnableFprint: false
property bool greeterEnableU2f: false
property string greeterWallpaperPath: ""
@@ -490,9 +484,6 @@ Singleton {
},
"dwl": {
"cursorHideTimeout": 0
},
"mango": {
"cursorHideTimeout": 0
}
})
property var availableCursorThemes: ["System Default"]
@@ -1225,8 +1216,6 @@ Singleton {
HyprlandService.generateLayoutConfig();
if (CompositorService.isDwl && typeof DwlService !== "undefined")
DwlService.generateLayoutConfig();
if (CompositorService.isMango && typeof MangoService !== "undefined")
MangoService.generateLayoutConfig();
}
function applyStoredIconTheme() {
@@ -1344,15 +1333,6 @@ Singleton {
});
}
function scheduleGreeterAutoLoginSync() {
if (isGreeterMode)
return;
Qt.callLater(() => {
Processes.settingsRoot = root;
Processes.scheduleGreeterAutoLoginSync();
});
}
readonly property var _hooks: ({
"applyStoredTheme": applyStoredTheme,
"regenSystemThemes": regenSystemThemes,
@@ -1360,8 +1340,7 @@ Singleton {
"applyStoredIconTheme": applyStoredIconTheme,
"updateBarConfigs": updateBarConfigs,
"updateCompositorCursor": updateCompositorCursor,
"scheduleAuthApply": scheduleAuthApply,
"scheduleGreeterAutoLoginSync": scheduleGreeterAutoLoginSync
"scheduleAuthApply": scheduleAuthApply
})
function set(key, value) {
@@ -2240,7 +2219,7 @@ Singleton {
function getFilteredScreens(componentId) {
var prefs = screenPreferences && screenPreferences[componentId] || ["all"];
if (!prefs || prefs.length === 0 || prefs.includes("all") || (typeof prefs[0] === "string" && prefs[0] === "all")) {
if (prefs.includes("all") || (typeof prefs[0] === "string" && prefs[0] === "all")) {
return Quickshell.screens;
}
var filtered = Quickshell.screens.filter(screen => isScreenInPreferences(screen, prefs));
@@ -2451,10 +2430,6 @@ Singleton {
DwlService.generateCursorConfig();
return;
}
if (CompositorService.isMango && typeof MangoService !== "undefined") {
MangoService.generateCursorConfig();
return;
}
}
function updateXResources() {
+3 -21
View File
@@ -970,7 +970,6 @@ Singleton {
readonly property int shorterDuration: (typeof SettingsData !== "undefined" && SettingsData.animationSpeed === SettingsData.AnimationSpeed.Custom) ? SettingsData.customAnimationDuration : currentDurations.shorter
readonly property int shortDuration: (typeof SettingsData !== "undefined" && SettingsData.animationSpeed === SettingsData.AnimationSpeed.Custom) ? SettingsData.customAnimationDuration : currentDurations.short
readonly property bool snapListModelChanges: shortDuration <= 0
readonly property int mediumDuration: (typeof SettingsData !== "undefined" && SettingsData.animationSpeed === SettingsData.AnimationSpeed.Custom) ? SettingsData.customAnimationDuration : currentDurations.medium
readonly property int longDuration: (typeof SettingsData !== "undefined" && SettingsData.animationSpeed === SettingsData.AnimationSpeed.Custom) ? SettingsData.customAnimationDuration : currentDurations.long
readonly property int extraLongDuration: (typeof SettingsData !== "undefined" && SettingsData.animationSpeed === SettingsData.AnimationSpeed.Custom) ? SettingsData.customAnimationDuration : currentDurations.extraLong
@@ -2080,29 +2079,12 @@ Singleton {
}
}
readonly property string _greeterCacheDir: Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter"
property string greeterColorsBaseDir: root._greeterCacheDir
function setGreeterColorsBaseDir(dir) {
const next = dir || root._greeterCacheDir;
if (greeterColorsBaseDir === next)
return;
greeterColorsBaseDir = next;
if (typeof SessionData !== "undefined" && SessionData.isGreeterMode)
dynamicColorsFileView.reload();
}
function resetGreeterColorsBaseDir() {
setGreeterColorsBaseDir(root._greeterCacheDir);
}
FileView {
id: dynamicColorsFileView
path: {
if (SessionData.isGreeterMode)
return root.greeterColorsBaseDir ? (root.greeterColorsBaseDir + "/colors.json") : "";
return stateDir + "/dms-colors.json";
const greetCfgDir = Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter";
const colorsPath = SessionData.isGreeterMode ? greetCfgDir + "/colors.json" : stateDir + "/dms-colors.json";
return colorsPath;
}
blockLoading: false
watchChanges: !SessionData.isGreeterMode
-165
View File
@@ -12,35 +12,6 @@ Singleton {
property var settingsRoot: null
onSettingsRootChanged: {
if (settingsRoot && !settingsRoot.isGreeterMode)
consumeGreeterAutoLoginPendingSync();
}
readonly property string greeterAutoLoginPendingSyncPath: (Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter") + "/.local/state/auto-login-sync-pending"
function consumeGreeterAutoLoginPendingSync() {
if (!settingsRoot || settingsRoot.isGreeterMode)
return;
greeterAutoLoginPendingCheckProcess.running = true;
}
property var greeterAutoLoginPendingCheckProcess: Process {
command: ["sh", "-c", "if [ -f " + JSON.stringify(root.greeterAutoLoginPendingSyncPath) + " ]; then rm -f " + JSON.stringify(root.greeterAutoLoginPendingSyncPath) + "; echo pending; fi"]
running: false
stdout: StdioCollector {
onStreamFinished: {
if ((text || "").trim() !== "pending" || !root.settingsRoot)
return;
if (!root.settingsRoot.greeterAutoLogin)
root.settingsRoot.set("greeterAutoLogin", true);
else
root.scheduleGreeterAutoLoginSync();
}
}
}
property string greetdPamText: ""
property string systemAuthPamText: ""
property string commonAuthPamText: ""
@@ -325,66 +296,6 @@ Singleton {
authApplyDebounce.restart();
}
// --- Greeter auto-login sync pipeline ---
property bool greeterAutoLoginSyncRunning: false
property bool greeterAutoLoginSyncQueued: false
property bool greeterAutoLoginSyncRerunRequested: false
property string greeterAutoLoginSyncStdout: ""
property string greeterAutoLoginSyncStderr: ""
property string greeterAutoLoginSyncTerminalFallbackStderr: ""
function scheduleGreeterAutoLoginSync() {
if (!settingsRoot || settingsRoot.isGreeterMode)
return;
greeterAutoLoginSyncQueued = true;
if (greeterAutoLoginSyncRunning) {
greeterAutoLoginSyncRerunRequested = true;
return;
}
greeterAutoLoginSyncDebounce.restart();
}
function beginGreeterAutoLoginSync() {
if (!greeterAutoLoginSyncQueued || greeterAutoLoginSyncRunning || !settingsRoot || settingsRoot.isGreeterMode)
return;
greeterAutoLoginSyncQueued = false;
greeterAutoLoginSyncRerunRequested = false;
greeterAutoLoginSyncStdout = "";
greeterAutoLoginSyncStderr = "";
greeterAutoLoginSyncTerminalFallbackStderr = "";
greeterAutoLoginSyncRunning = true;
greeterAutoLoginSyncSudoProbeProcess.running = true;
}
function launchGreeterAutoLoginSyncTerminalFallback(details) {
ToastService.showWarning(I18n.tr("Opening terminal to update greetd"), I18n.tr("DMS needs administrator access. The terminal closes automatically when done.") + (details ? "\n\n" + details : ""), "dms greeter sync --autologin-only", "greeter-autologin-sync");
greeterAutoLoginSyncTerminalFallbackStderr = "";
greeterAutoLoginSyncTerminalFallbackProcess.running = true;
}
function greeterAutoLoginSyncSuccessToast(details) {
const enabling = settingsRoot && settingsRoot.greeterAutoLogin;
// Clear the sticky in-progress toast, then confirm with an auto-dismissing toast.
ToastService.dismissCategory("greeter-autologin-sync");
if (enabling) {
ToastService.showWarning(I18n.tr("Auto-login enabled"), I18n.tr("You'll skip the greeter password after the next reboot. The lock screen and signing out still require your password.") + (details ? "\n\n" + details : ""));
} else {
ToastService.showInfo(I18n.tr("Auto-login disabled"), I18n.tr("You'll enter your password at the greeter after the next reboot.") + (details ? "\n\n" + details : ""));
}
}
function finishGreeterAutoLoginSync() {
const shouldRerun = greeterAutoLoginSyncQueued || greeterAutoLoginSyncRerunRequested;
greeterAutoLoginSyncRunning = false;
greeterAutoLoginSyncRerunRequested = false;
if (shouldRerun)
greeterAutoLoginSyncDebounce.restart();
}
// --- PAM parsing helpers ---
function stripPamComment(line) {
@@ -522,82 +433,6 @@ Singleton {
onTriggered: root.beginAuthApply()
}
Timer {
id: greeterAutoLoginSyncDebounce
interval: 300
repeat: false
onTriggered: root.beginGreeterAutoLoginSync()
}
property var greeterAutoLoginSyncProcess: Process {
command: ["dms", "greeter", "sync", "--yes", "--autologin-only"]
running: false
stdout: StdioCollector {
onStreamFinished: root.greeterAutoLoginSyncStdout = text || ""
}
stderr: StdioCollector {
onStreamFinished: root.greeterAutoLoginSyncStderr = text || ""
}
onExited: exitCode => {
const out = (root.greeterAutoLoginSyncStdout || "").trim();
const err = (root.greeterAutoLoginSyncStderr || "").trim();
if (exitCode === 0) {
let details = out;
if (err !== "")
details = details !== "" ? details + "\n\nstderr:\n" + err : "stderr:\n" + err;
root.greeterAutoLoginSyncSuccessToast(details);
root.finishGreeterAutoLoginSync();
return;
}
let details = "";
if (out !== "")
details = out;
if (err !== "")
details = details !== "" ? details + "\n\nstderr:\n" + err : "stderr:\n" + err;
root.launchGreeterAutoLoginSyncTerminalFallback(details);
}
}
property var greeterAutoLoginSyncSudoProbeProcess: Process {
command: ["sudo", "-n", "true"]
running: false
onExited: exitCode => {
const enabling = root.settingsRoot && root.settingsRoot.greeterAutoLogin;
if (exitCode === 0) {
ToastService.showWarning(enabling ? I18n.tr("Applying auto-login on startup…") : I18n.tr("Disabling auto-login on startup…"), "", "dms greeter sync --autologin-only", "greeter-autologin-sync");
root.greeterAutoLoginSyncProcess.running = true;
return;
}
root.launchGreeterAutoLoginSyncTerminalFallback();
}
}
property var greeterAutoLoginSyncTerminalFallbackProcess: Process {
command: ["dms", "greeter", "sync", "--terminal", "--yes", "--autologin-only"]
running: false
stderr: StdioCollector {
onStreamFinished: root.greeterAutoLoginSyncTerminalFallbackStderr = text || ""
}
onExited: exitCode => {
if (exitCode === 0) {
root.greeterAutoLoginSyncSuccessToast("");
} else {
let details = (root.greeterAutoLoginSyncTerminalFallbackStderr || "").trim();
ToastService.showError(I18n.tr("Couldn't open a terminal for the auto-login update.") + " (exit " + exitCode + ")", details, "dms greeter sync --autologin-only", "greeter-autologin-sync");
}
root.finishGreeterAutoLoginSync();
}
}
property var authApplyProcess: Process {
command: ["dms", "auth", "sync", "--yes"]
running: false
@@ -56,8 +56,6 @@ var SPEC = {
trayItemOrder: { def: [] },
recentColors: { def: [] },
showThirdPartyPlugins: { def: false },
pluginBrowserInstalledFirst: { def: false },
pluginBrowserSortMode: { def: "default" },
launchPrefix: { def: "" },
lastBrightnessDevice: { def: "" },
@@ -29,7 +29,6 @@ var SPEC = {
hyprlandLayoutGapsOverride: { def: -1, onChange: "updateCompositorLayout" },
hyprlandLayoutRadiusOverride: { def: -1, onChange: "updateCompositorLayout" },
hyprlandLayoutBorderSize: { def: -1, onChange: "updateCompositorLayout" },
hyprlandResizeOnBorder: { def: false, onChange: "updateCompositorLayout" },
mangoLayoutGapsOverride: { def: -1, onChange: "updateCompositorLayout" },
mangoLayoutRadiusOverride: { def: -1, onChange: "updateCompositorLayout" },
mangoLayoutBorderSize: { def: -1, onChange: "updateCompositorLayout" },
@@ -105,8 +104,6 @@ var SPEC = {
controlCenterShowBatteryIcon: { def: false },
controlCenterShowPrinterIcon: { def: false },
controlCenterShowScreenSharingIcon: { def: true },
controlCenterShowIdleInhibitorIcon: { def: false },
controlCenterShowDoNotDisturbIcon: { def: false },
showPrivacyButton: { def: true },
privacyShowMicIcon: { def: false },
@@ -135,7 +132,6 @@ var SPEC = {
maxWorkspaceIcons: { def: 3 },
workspaceAppIconSizeOffset: { def: 0 },
groupWorkspaceApps: { def: true },
groupActiveWorkspaceApps: { def: false },
workspaceFollowFocus: { def: false },
showOccupiedWorkspacesOnly: { def: false },
reverseScrolling: { def: false },
@@ -169,7 +165,6 @@ var SPEC = {
appsDockEnlargePercentage: { def: 125 },
appsDockIconSizePercentage: { def: 100 },
keyboardLayoutNameCompactMode: { def: false },
keyboardLayoutNameShowIcon: { def: false},
runningAppsCurrentWorkspace: { def: true },
runningAppsGroupByApp: { def: false },
runningAppsCurrentMonitor: { def: false },
@@ -187,7 +182,6 @@ var SPEC = {
lockDateFormat: { def: "" },
greeterRememberLastSession: { def: true },
greeterRememberLastUser: { def: true },
greeterAutoLogin: { def: false, onChange: "scheduleGreeterAutoLoginSync" },
greeterEnableFprint: { def: false, onChange: "scheduleAuthApply" },
greeterEnableU2f: { def: false, onChange: "scheduleAuthApply" },
greeterWallpaperPath: { def: "" },
-18
View File
@@ -1185,24 +1185,6 @@ Item {
}
}
LazyLoader {
id: powerProfileModalLoader
active: false
PowerProfileModal {
id: powerProfileModal
Component.onCompleted: {
PopoutService.powerProfileModal = powerProfileModal;
}
}
Component.onCompleted: {
PopoutService.powerProfileModalLoader = powerProfileModalLoader;
}
}
DMSShellIPC {
powerMenuModalLoader: powerMenuModalLoader
processListModalLoader: processListModalLoader
-176
View File
@@ -1,10 +1,8 @@
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Hyprland
import Quickshell.Wayland
import Quickshell.Services.SystemTray
import Quickshell.Services.UPower
import qs.Common
import qs.Services
import qs.Modules.Settings.DisplayConfig
@@ -57,93 +55,6 @@ Item {
return currentBar;
}
readonly property var defaultAppMimeTypes: ({
browser: "x-scheme-handler/https",
fileManager: "inode/directory",
textEditor: "text/plain",
imageViewer: "image/png",
videoPlayer: "video/mp4",
musicPlayer: "audio/mpeg",
pdfReader: "application/pdf",
mail: "x-scheme-handler/mailto",
calendar: "x-scheme-handler/calendar"
})
function launchDesktopId(desktopId, appName) {
if (!desktopId || desktopId.length === 0) {
log.warn("No default app configured for:", appName);
return false;
}
let entry = DesktopEntries.heuristicLookup(desktopId);
if (!entry && desktopId.endsWith(".desktop")) {
entry = DesktopEntries.heuristicLookup(desktopId.slice(0, -8));
}
if (!entry) {
log.warn("Default app desktop entry not found:", desktopId, "for:", appName);
return false;
}
SessionService.launchDesktopEntry(entry);
AppUsageHistoryData.addAppUsage(entry);
return true;
}
function launchDefaultMimeApp(appName, mimeType) {
DMSService.sendRequest("mime.getDefault", {
"mimeType": mimeType
}, response => {
if (response.error) {
log.warn("Failed to resolve default app:", appName, response.error);
return;
}
const result = response.result || {};
root.launchDesktopId(result.desktopId || "", appName);
});
return `DEFAULTAPP_LAUNCH_REQUESTED: ${appName}`;
}
IpcHandler {
function browser(): string {
return root.launchDefaultMimeApp("browser", root.defaultAppMimeTypes.browser);
}
function fileManager(): string {
return root.launchDefaultMimeApp("fileManager", root.defaultAppMimeTypes.fileManager);
}
function textEditor(): string {
return root.launchDefaultMimeApp("textEditor", root.defaultAppMimeTypes.textEditor);
}
function imageViewer(): string {
return root.launchDefaultMimeApp("imageViewer", root.defaultAppMimeTypes.imageViewer);
}
function videoPlayer(): string {
return root.launchDefaultMimeApp("videoPlayer", root.defaultAppMimeTypes.videoPlayer);
}
function musicPlayer(): string {
return root.launchDefaultMimeApp("musicPlayer", root.defaultAppMimeTypes.musicPlayer);
}
function pdfReader(): string {
return root.launchDefaultMimeApp("pdfReader", root.defaultAppMimeTypes.pdfReader);
}
function mail(): string {
return root.launchDefaultMimeApp("mail", root.defaultAppMimeTypes.mail);
}
function calendar(): string {
return root.launchDefaultMimeApp("calendar", root.defaultAppMimeTypes.calendar);
}
target: "defaultApp"
}
IpcHandler {
function open() {
root.powerMenuModalLoader.active = true;
@@ -250,21 +161,6 @@ Item {
target: "control-center"
}
IpcHandler {
// Screenshot region-select handshake
function begin(): string {
PopoutManager.screenshotActive = true;
return "SCREENSHOT_MODE_ON";
}
function end(): string {
PopoutManager.screenshotActive = false;
return "SCREENSHOT_MODE_OFF";
}
target: "screenshot"
}
IpcHandler {
function resolveTabIndex(tab: string): int {
switch ((tab || "").toLowerCase()) {
@@ -340,9 +236,6 @@ Item {
if (CompositorService.isDwl && DwlService.activeOutput) {
return DwlService.activeOutput;
}
if (CompositorService.isMango && MangoService.activeOutput) {
return MangoService.activeOutput;
}
return "";
}
@@ -1982,73 +1875,4 @@ Item {
target: "tray"
}
IpcHandler {
function open(): string {
if (!PowerProfileWatcher.available)
return "ERROR: power-profiles-daemon not available";
PopoutService.openPowerProfileModal();
return "POWERPROFILE_OPEN_SUCCESS";
}
function close(): string {
PopoutService.closePowerProfileModal();
return "POWERPROFILE_CLOSE_SUCCESS";
}
function toggle(): string {
if (!PowerProfileWatcher.available)
return "ERROR: power-profiles-daemon not available";
PopoutService.togglePowerProfileModal();
return "POWERPROFILE_TOGGLE_SUCCESS";
}
function list(): string {
if (!PowerProfileWatcher.available)
return "ERROR: power-profiles-daemon not available";
return PowerProfileWatcher.availableProfiles.map(profile => PowerProfileWatcher.profileSlug(profile)).join("\n");
}
function status(): string {
if (!PowerProfileWatcher.available)
return "ERROR: power-profiles-daemon not available";
return PowerProfileWatcher.profileSlug(PowerProfiles.profile);
}
function set(profile: string): string {
if (!PowerProfileWatcher.available)
return "ERROR: power-profiles-daemon not available";
if (!profile)
return "ERROR: No profile specified";
const parsed = PowerProfileWatcher.parseProfileSlug(profile);
if (parsed === -1)
return "ERROR: Unknown power profile. Supported options: power-saver, balanced, performance";
if (parsed === PowerProfile.Performance && !PowerProfiles.hasPerformanceProfile)
return "ERROR: Performance profile not supported by hardware";
if (!PowerProfileWatcher.applyProfile(parsed))
return "ERROR: Failed to set power profile";
return "POWERPROFILE_SET_SUCCESS";
}
function cycle(): string {
if (!PowerProfileWatcher.available)
return "ERROR: power-profiles-daemon not available";
if (!PowerProfileWatcher.cycleProfile())
return "ERROR: Failed to set power profile";
return "POWERPROFILE_CYCLE_SUCCESS";
}
target: "powerprofile"
}
}
@@ -26,8 +26,7 @@ Item {
ClipboardHeader {
id: header
width: parent.width
recentsCount: modal.unpinnedEntries.length
savedCount: modal.pinnedEntries.length
totalCount: modal.totalCount
showKeyboardHints: modal.showKeyboardHints
activeTab: modal.activeTab
pinnedCount: modal.pinnedCount
@@ -66,6 +65,15 @@ Item {
forceActiveFocus();
});
}
Connections {
target: modal
function onOpened() {
Qt.callLater(function () {
searchField.forceActiveFocus();
});
}
}
}
}
@@ -100,20 +108,6 @@ Item {
pressDelay: 0
flickableDirection: Flickable.VerticalFlick
states: [
State {
name: "snap"
when: Theme.snapListModelChanges
PropertyChanges {
target: clipboardListView
add: null
remove: null
displaced: null
move: null
}
}
]
function ensureVisible(index) {
if (index < 0 || index >= count) {
return;
@@ -174,20 +168,6 @@ Item {
pressDelay: 0
flickableDirection: Flickable.VerticalFlick
states: [
State {
name: "snap"
when: Theme.snapListModelChanges
PropertyChanges {
target: savedListView
add: null
remove: null
displaced: null
move: null
}
}
]
function ensureVisible(index) {
if (index < 0 || index >= count) {
return;
+21 -32
View File
@@ -29,29 +29,32 @@ Item {
}
try {
const decoded = Qt.atob(sanitized);
if (!decoded) {
const chars = new Array(sanitized.length);
for (let i = 0; i < sanitized.length; i++) {
chars[i] = sanitized.charAt(i);
}
let buffer = null;
if (typeof Qt !== "undefined" && typeof Qt.atob === "function") {
buffer = Qt.atob(chars);
} else if (typeof atob === "function") {
const binary = atob(sanitized);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
buffer = bytes.buffer;
}
if (!buffer || buffer.byteLength === 0) {
return data;
}
const bytes = new Uint8Array(buffer);
let binary = "";
if (typeof decoded === "string") {
// Pre-6.11 Qt.atob returns a binary string directly
binary = decoded;
} else {
// Qt 6.11+ Qt.atob returns an ArrayBuffer convert to avoid O(n²) concat/stack limits
const bytes = new Uint8Array(decoded);
const chunkSize = 8192;
const chunks = [];
for (let i = 0; i < bytes.length; i += chunkSize) {
chunks.push(String.fromCharCode.apply(null, bytes.subarray(i, i + chunkSize)));
}
binary = chunks.join("");
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
if (!binary) {
return data;
}
try {
return decodeURIComponent(escape(binary));
} catch (e) {
@@ -71,7 +74,6 @@ Item {
Qt.callLater(function () {
if (editField) {
editField.forceActiveFocus();
editField.cursorPosition = editField.text.length;
}
});
@@ -89,10 +91,6 @@ Item {
if (!root.entry || root.entry.id !== requestedId) {
return;
}
if (!response.result) {
ClipboardService.refresh();
return;
}
const result = response.result;
let fullText = "";
if (result?.data) {
@@ -105,18 +103,8 @@ Item {
return;
}
root.editorText = fullText;
if (editField) {
if (fullText.length > 50000) {
Qt.callLater(function () {
if (editField) {
editField.text = fullText;
editField.cursorPosition = fullText.length;
}
});
} else {
editField.text = fullText;
editField.cursorPosition = fullText.length;
}
}
});
}
@@ -264,6 +252,7 @@ Item {
id: editField
width: editScroll.width
height: Math.max(editScroll.height, contentHeight)
text: root.editorText
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
wrapMode: TextEdit.Wrap
@@ -78,11 +78,12 @@ Rectangle {
onClicked: {
if (entryType === "image") {
return;
}
// TODO - forward to editing software
} else {
editRequested();
}
}
}
DankActionButton {
iconName: "close"
@@ -6,8 +6,7 @@ import qs.Modals.Clipboard
Item {
id: header
property int recentsCount: 0
property int savedCount: 0
property int totalCount: 0
property bool showKeyboardHints: false
property string activeTab: "recents"
property int pinnedCount: 0
@@ -32,7 +31,7 @@ Item {
}
StyledText {
text: (header.activeTab === "saved" ? I18n.tr("Clipboard Saved") : I18n.tr("Clipboard History")) + ` (${header.activeTab === "saved" ? header.savedCount : header.recentsCount})`
text: I18n.tr("Clipboard History") + ` (${totalCount})`
font.pixelSize: Theme.fontSizeLarge
color: Theme.surfaceText
font.weight: Font.Medium
@@ -49,7 +48,6 @@ Item {
iconName: "push_pin"
iconSize: Theme.iconSize - 4
iconColor: header.activeTab === "saved" ? Theme.primary : Theme.surfaceText
backgroundColor: header.activeTab === "saved" ? Theme.primarySelected : "transparent"
visible: header.pinnedCount > 0
tooltipText: header.activeTab === "saved" ? I18n.tr("Recent") : I18n.tr("Saved")
onClicked: tabChanged(header.activeTab === "saved" ? "recents" : "saved")
@@ -1,210 +0,0 @@
pragma ComponentBehavior: Bound
import QtQuick
import qs.Common
import qs.Services
FocusScope {
id: root
property var clearConfirmDialog: null
property string activeTab: "recents"
property bool showKeyboardHints: false
property int activeImageLoads: 0
readonly property int maxConcurrentLoads: 3
property string mode: "history"
property string searchText: ClipboardService.searchText
readonly property bool clipboardAvailable: ClipboardService.clipboardAvailable
readonly property bool wtypeAvailable: ClipboardService.wtypeAvailable
readonly property int totalCount: ClipboardService.totalCount
readonly property var clipboardEntries: ClipboardService.clipboardEntries
readonly property var pinnedEntries: ClipboardService.pinnedEntries
readonly property int pinnedCount: ClipboardService.pinnedCount
readonly property var unpinnedEntries: ClipboardService.unpinnedEntries
readonly property int selectedIndex: ClipboardService.selectedIndex
readonly property bool keyboardNavigationActive: ClipboardService.keyboardNavigationActive
readonly property var modalFocusScope: root
property alias searchField: historyContent.searchField
property alias editorView: editorView
property alias keyboardController: keyboardController
signal closeRequested
signal instantCloseRequested
onActiveTabChanged: {
ClipboardService.selectedIndex = 0;
ClipboardService.keyboardNavigationActive = false;
}
onSearchTextChanged: ClipboardService.searchText = searchText
function hide() {
closeRequested();
}
function pasteSelected() {
ClipboardService.pasteSelected(() => root.instantCloseRequested());
}
function copyEntry(entry) {
ClipboardService.copyEntry(entry, () => root.closeRequested());
}
function deleteEntry(entry) {
ClipboardService.deleteEntry(entry);
}
function deletePinnedEntry(entry) {
ClipboardService.deletePinnedEntry(entry, clearConfirmDialog);
}
function pinEntry(entry) {
ClipboardService.pinEntry(entry);
}
function unpinEntry(entry) {
ClipboardService.unpinEntry(entry);
}
function clearAll() {
ClipboardService.clearAll();
}
function getEntryPreview(entry) {
return ClipboardService.getEntryPreview(entry);
}
function getEntryType(entry) {
return ClipboardService.getEntryType(entry);
}
function updateFilteredModel() {
ClipboardService.updateFilteredModel();
}
function refreshClipboard() {
ClipboardService.refresh();
}
function editEntry(entry) {
if (!entry || entry.isImage) {
return;
}
editorView.setEntry(entry);
mode = "editor";
}
function resetState() {
activeImageLoads = 0;
mode = "history";
ClipboardService.reset();
keyboardController.reset();
}
focus: true
Keys.onPressed: function (event) {
keyboardController.handleKey(event);
}
ClipboardKeyboardController {
id: keyboardController
modal: root
}
Item {
id: historyView
anchors.fill: parent
opacity: 1
scale: 1
visible: opacity > 0.01
enabled: root.mode === "history"
ClipboardContent {
id: historyContent
anchors.fill: parent
modal: root
clearConfirmDialog: root.clearConfirmDialog
}
}
ClipboardEditor {
id: editorView
anchors.fill: parent
opacity: 0
scale: 0.98
visible: opacity > 0.01
enabled: root.mode === "editor"
focus: root.mode === "editor"
modal: root
keyController: keyboardController
}
states: [
State {
name: "history"
when: root.mode === "history"
PropertyChanges {
target: historyView
opacity: 1
scale: 1
}
PropertyChanges {
target: editorView
opacity: 0
scale: 0.98
}
},
State {
name: "editor"
when: root.mode === "editor"
PropertyChanges {
target: historyView
opacity: 0
scale: 0.98
}
PropertyChanges {
target: editorView
opacity: 1
scale: 1
}
}
]
transitions: [
Transition {
from: "history"
to: "editor"
ParallelAnimation {
NumberAnimation {
property: "opacity"
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
NumberAnimation {
property: "scale"
duration: Theme.shortDuration
easing.type: Theme.emphasizedEasing
}
}
},
Transition {
from: "editor"
to: "history"
ParallelAnimation {
NumberAnimation {
property: "opacity"
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
NumberAnimation {
property: "scale"
duration: Theme.shortDuration
easing.type: Theme.emphasizedEasing
}
}
}
]
}
@@ -17,28 +17,74 @@ DankModal {
active: clipboardHistoryModal.useHyprlandFocusGrab && clipboardHistoryModal.shouldHaveFocus
}
property string activeTab: "recents"
onActiveTabChanged: {
ClipboardService.selectedIndex = 0;
ClipboardService.keyboardNavigationActive = false;
}
property bool showKeyboardHints: false
property Component clipboardContent
property int activeImageLoads: 0
readonly property int maxConcurrentLoads: 3
readonly property bool clipboardAvailable: ClipboardService.clipboardAvailable
readonly property bool wtypeAvailable: ClipboardService.wtypeAvailable
readonly property int totalCount: ClipboardService.totalCount
readonly property var clipboardEntries: ClipboardService.clipboardEntries
readonly property var pinnedEntries: ClipboardService.pinnedEntries
readonly property int pinnedCount: ClipboardService.pinnedCount
readonly property var unpinnedEntries: ClipboardService.unpinnedEntries
readonly property int selectedIndex: ClipboardService.selectedIndex
readonly property bool keyboardNavigationActive: ClipboardService.keyboardNavigationActive
property string searchText: ClipboardService.searchText
onSearchTextChanged: ClipboardService.searchText = searchText
Ref {
service: ClipboardService
}
property string mode: "history"
onModeChanged: {
if (mode !== "history") {
return;
}
Qt.callLater(function () {
if (contentLoader.item?.searchField) {
contentLoader.item.searchField.forceActiveFocus();
}
});
}
function updateFilteredModel() {
ClipboardService.updateFilteredModel();
}
function pasteSelected() {
ClipboardService.pasteSelected(instantClose);
}
function toggle() {
if (shouldBeVisible) {
hide();
return;
}
} else {
show();
}
}
function show() {
open();
mode = "history";
activeImageLoads = 0;
shouldHaveFocus = true;
ClipboardService.reset();
keyboardController.reset();
Qt.callLater(function () {
if (contentLoader.item) {
contentLoader.item.resetState();
}
if (clipboardHistoryModal.clipboardAvailable) {
if (clipboardAvailable) {
if (Theme.isConnectedEffect) {
Qt.callLater(() => {
if (clipboardHistoryModal.shouldBeVisible) {
if (clipboardHistoryModal.shouldBeVisible)
ClipboardService.refresh();
}
});
} else {
ClipboardService.refresh();
@@ -56,12 +102,61 @@ DankModal {
}
onDialogClosed: {
if (contentLoader.item) {
contentLoader.item.resetState();
}
activeImageLoads = 0;
ClipboardService.reset();
keyboardController.reset();
}
readonly property bool clipboardAvailable: ClipboardService.clipboardAvailable
function refreshClipboard() {
ClipboardService.refresh();
}
function copyEntry(entry) {
ClipboardService.copyEntry(entry, hide);
}
function deleteEntry(entry) {
ClipboardService.deleteEntry(entry);
}
function deletePinnedEntry(entry) {
ClipboardService.deletePinnedEntry(entry, clearConfirmDialog);
}
function pinEntry(entry) {
ClipboardService.pinEntry(entry);
}
function unpinEntry(entry) {
ClipboardService.unpinEntry(entry);
}
function clearAll() {
ClipboardService.clearAll();
}
function getEntryPreview(entry) {
return ClipboardService.getEntryPreview(entry);
}
function getEntryType(entry) {
return ClipboardService.getEntryType(entry);
}
function editEntry(entry) {
if (!entry) {
return;
}
if (entry.isImage) {
return;
}
const editor = contentLoader.item?.editorView;
if (!editor) {
return;
}
editor.setEntry(entry);
mode = "editor";
}
visible: false
modalWidth: ClipboardConstants.modalWidth
@@ -71,11 +166,16 @@ DankModal {
borderColor: Theme.outlineMedium
borderWidth: 1
enableShadow: true
closeOnEscapeKey: (contentLoader.item?.mode ?? "history") !== "editor"
closeOnEscapeKey: mode !== "editor"
onBackgroundClicked: hide()
modalFocusScope.Keys.onPressed: function (event) {
keyboardController.handleKey(event);
}
content: clipboardContent
Ref {
service: ClipboardService
ClipboardKeyboardController {
id: keyboardController
modal: clipboardHistoryModal
}
ConfirmModal {
@@ -100,11 +200,112 @@ DankModal {
}
}
content: Component {
ClipboardHistoryContent {
clearConfirmDialog: clearConfirmDialog
onCloseRequested: clipboardHistoryModal.hide()
onInstantCloseRequested: clipboardHistoryModal.instantClose()
property var confirmDialog: clearConfirmDialog
clipboardContent: Component {
Item {
id: viewContainer
property alias editorView: editorView
property alias searchField: historyContent.searchField
anchors.fill: parent
Item {
id: historyView
anchors.fill: parent
opacity: 1
scale: 1
visible: opacity > 0.01
enabled: clipboardHistoryModal.mode === "history"
ClipboardContent {
id: historyContent
anchors.fill: parent
modal: clipboardHistoryModal
clearConfirmDialog: clipboardHistoryModal.confirmDialog
}
}
ClipboardEditor {
id: editorView
anchors.fill: parent
opacity: 0
scale: 0.98
visible: opacity > 0.01
enabled: clipboardHistoryModal.mode === "editor"
focus: clipboardHistoryModal.mode === "editor"
modal: clipboardHistoryModal
keyController: keyboardController
}
states: [
State {
name: "history"
when: clipboardHistoryModal.mode === "history"
PropertyChanges {
target: historyView
opacity: 1
scale: 1
}
PropertyChanges {
target: editorView
opacity: 0
scale: 0.98
}
},
State {
name: "editor"
when: clipboardHistoryModal.mode === "editor"
PropertyChanges {
target: historyView
opacity: 0
scale: 0.98
}
PropertyChanges {
target: editorView
opacity: 1
scale: 1
}
}
]
transitions: [
Transition {
from: "history"
to: "editor"
ParallelAnimation {
NumberAnimation {
property: "opacity"
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
NumberAnimation {
property: "scale"
duration: Theme.shortDuration
easing.type: Theme.emphasizedEasing
}
}
},
Transition {
from: "editor"
to: "history"
ParallelAnimation {
NumberAnimation {
property: "opacity"
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
NumberAnimation {
property: "scale"
duration: Theme.shortDuration
easing.type: Theme.emphasizedEasing
}
}
}
]
}
}
}
@@ -15,20 +15,47 @@ DankPopout {
property var parentWidget: null
property var triggerScreen: null
property string activeTab: "recents"
property bool showKeyboardHints: false
property int activeImageLoads: 0
readonly property int maxConcurrentLoads: 3
readonly property bool clipboardAvailable: ClipboardService.clipboardAvailable
readonly property bool wtypeAvailable: ClipboardService.wtypeAvailable
readonly property int totalCount: ClipboardService.totalCount
readonly property var clipboardEntries: ClipboardService.clipboardEntries
readonly property var pinnedEntries: ClipboardService.pinnedEntries
readonly property int pinnedCount: ClipboardService.pinnedCount
readonly property var confirmDialog: clearConfirmDialog
readonly property var unpinnedEntries: ClipboardService.unpinnedEntries
readonly property int selectedIndex: ClipboardService.selectedIndex
readonly property bool keyboardNavigationActive: ClipboardService.keyboardNavigationActive
property string searchText: ClipboardService.searchText
onSearchTextChanged: ClipboardService.searchText = searchText
readonly property var modalFocusScope: contentLoader.item ?? null
Ref {
service: ClipboardService
}
function updateFilteredModel() {
ClipboardService.updateFilteredModel();
}
function pasteSelected() {
ClipboardService.pasteSelected(instantClose);
}
function instantClose() {
close();
}
function show() {
open();
activeImageLoads = 0;
ClipboardService.reset();
keyboardController.reset();
Qt.callLater(function () {
if (contentLoader.item) {
contentLoader.item.activeTab = activeTab;
contentLoader.item.resetState();
}
if (contentLoader.item?.searchField) {
contentLoader.item.searchField.text = "";
contentLoader.item.searchField.forceActiveFocus();
@@ -38,12 +65,47 @@ DankPopout {
function hide() {
close();
activeImageLoads = 0;
ClipboardService.reset();
keyboardController.reset();
}
function refreshClipboard() {
ClipboardService.refresh();
}
function copyEntry(entry) {
ClipboardService.copyEntry(entry, hide);
}
function deleteEntry(entry) {
ClipboardService.deleteEntry(entry);
}
function deletePinnedEntry(entry) {
ClipboardService.deletePinnedEntry(entry, clearConfirmDialog);
}
function pinEntry(entry) {
ClipboardService.pinEntry(entry);
}
function unpinEntry(entry) {
ClipboardService.unpinEntry(entry);
}
function clearAll() {
ClipboardService.clearAll();
}
function getEntryPreview(entry) {
return ClipboardService.getEntryPreview(entry);
}
function getEntryType(entry) {
return ClipboardService.getEntryType(entry);
}
popupWidth: ClipboardConstants.popoutWidth
popupHeight: ClipboardConstants.popoutHeight
triggerWidth: 55
@@ -55,25 +117,20 @@ DankPopout {
onBackgroundClicked: hide()
onShouldBeVisibleChanged: {
if (!shouldBeVisible) {
if (!shouldBeVisible)
return;
}
if (clipboardAvailable) {
if (Theme.isConnectedEffect) {
Qt.callLater(() => {
if (root.shouldBeVisible) {
if (root.shouldBeVisible)
ClipboardService.refresh();
}
});
} else {
ClipboardService.refresh();
}
}
keyboardController.reset();
Qt.callLater(function () {
if (contentLoader.item) {
contentLoader.item.activeTab = activeTab;
contentLoader.item.resetState();
}
if (contentLoader.item?.searchField) {
contentLoader.item.searchField.text = "";
contentLoader.item.searchField.forceActiveFocus();
@@ -82,13 +139,14 @@ DankPopout {
}
onPopoutClosed: {
if (contentLoader.item) {
contentLoader.item.resetState();
}
activeImageLoads = 0;
ClipboardService.reset();
keyboardController.reset();
}
Ref {
service: ClipboardService
ClipboardKeyboardController {
id: keyboardController
modal: root
}
ConfirmModal {
@@ -97,20 +155,48 @@ DankPopout {
confirmButtonColor: Theme.primary
}
property var confirmDialog: clearConfirmDialog
content: Component {
ClipboardHistoryContent {
FocusScope {
id: contentFocusScope
LayoutMirroring.enabled: I18n.isRtl
LayoutMirroring.childrenInherit: true
clearConfirmDialog: clearConfirmDialog
onCloseRequested: root.hide()
onInstantCloseRequested: root.close()
focus: true
property alias searchField: clipboardContentItem.searchField
Keys.onPressed: function (event) {
keyboardController.handleKey(event);
}
Component.onCompleted: {
activeTab = root.activeTab;
if (root.shouldBeVisible) {
if (root.shouldBeVisible)
forceActiveFocus();
}
Connections {
target: root
function onShouldBeVisibleChanged() {
if (root.shouldBeVisible) {
Qt.callLater(() => contentFocusScope.forceActiveFocus());
}
}
function onOpened() {
Qt.callLater(() => {
if (clipboardContentItem.searchField) {
clipboardContentItem.searchField.forceActiveFocus();
}
});
}
}
ClipboardContent {
id: clipboardContentItem
modal: root
clearConfirmDialog: root.confirmDialog
}
}
}
@@ -13,7 +13,6 @@ Item {
required property var modal
required property var listView
required property int itemIndex
property bool disposed: false
Image {
id: thumbnailImage
@@ -21,13 +20,6 @@ Item {
property bool isVisible: false
property string cachedImageData: ""
property bool loadQueued: false
property bool activeLoad: false
property bool completed: false
property int loadGeneration: 0
property var activeEntryId: null
property var activeRequest: null
property var currentEntryId: entry && entry.id !== undefined ? entry.id : null
property string currentEntryType: entryType
anchors.fill: parent
source: cachedImageData ? `data:image/png;base64,${cachedImageData}` : ""
@@ -39,119 +31,29 @@ Item {
sourceSize.width: 128
sourceSize.height: 128
onCurrentEntryIdChanged: {
if (thumbnailImage.completed) {
thumbnailImage.resetForEntry();
}
}
onCurrentEntryTypeChanged: {
if (thumbnailImage.completed) {
thumbnailImage.resetForEntry();
}
}
function hasValidEntryId() {
return entry && entry.id !== undefined && entry.id !== null;
}
function releaseActiveLoad() {
if (!thumbnailImage.activeLoad) {
return;
}
thumbnailImage.activeLoad = false;
if (modal && modal.activeImageLoads > 0) {
modal.activeImageLoads--;
}
}
function finishLoad(request) {
thumbnailImage.loadQueued = false;
thumbnailImage.activeEntryId = null;
if (!request || thumbnailImage.activeRequest === request) {
thumbnailImage.activeRequest = null;
}
thumbnailImage.releaseActiveLoad();
}
function cancelLoad() {
if (thumbnailImage.activeRequest) {
thumbnailImage.activeRequest.cancelled = true;
thumbnailImage.activeRequest = null;
}
retryTimer.stop();
visibilityTimer.stop();
thumbnailImage.loadQueued = false;
thumbnailImage.activeEntryId = null;
thumbnailImage.releaseActiveLoad();
}
function resetForEntry() {
thumbnailImage.loadGeneration++;
thumbnailImage.cachedImageData = "";
thumbnailImage.isVisible = false;
thumbnailImage.cancelLoad();
Qt.callLater(function () {
if (thumbnail.disposed) {
return;
}
thumbnailImage.checkVisibility();
});
}
function startLoad() {
if (!modal) {
thumbnailImage.loadQueued = false;
return;
}
modal.activeImageLoads++;
thumbnailImage.activeLoad = true;
thumbnailImage.loadImage();
}
function tryLoadImage() {
if (thumbnail.disposed || thumbnailImage.loadQueued || entryType !== "image" || thumbnailImage.cachedImageData || !thumbnailImage.hasValidEntryId()) {
if (thumbnailImage.loadQueued || entryType !== "image" || thumbnailImage.cachedImageData) {
return;
}
thumbnailImage.loadQueued = true;
if (modal && modal.activeImageLoads < modal.maxConcurrentLoads) {
thumbnailImage.startLoad();
if (modal.activeImageLoads < modal.maxConcurrentLoads) {
modal.activeImageLoads++;
thumbnailImage.loadImage();
} else {
retryTimer.restart();
}
}
function loadImage() {
if (!thumbnailImage.hasValidEntryId()) {
thumbnailImage.finishLoad();
return;
}
const requestedId = entry.id;
const generation = thumbnailImage.loadGeneration;
const request = {
"cancelled": false
};
thumbnailImage.activeEntryId = requestedId;
thumbnailImage.activeRequest = request;
DMSService.sendRequest("clipboard.getEntry", {
"id": requestedId
"id": entry.id
}, function (response) {
if (request.cancelled) {
return;
}
if (thumbnail.disposed || generation !== thumbnailImage.loadGeneration || thumbnailImage.activeRequest !== request || thumbnailImage.activeEntryId !== requestedId) {
return;
}
thumbnailImage.finishLoad(request);
if (!entry || entry.id !== requestedId || entryType !== "image") {
return;
thumbnailImage.loadQueued = false;
if (modal.activeImageLoads > 0) {
modal.activeImageLoads--;
}
if (response.error) {
log.warn("Failed to load image:", requestedId);
return;
}
if (!response.result) {
ClipboardService.refresh();
log.warn("Failed to load image:", entry.id);
return;
}
const data = response.result?.data;
@@ -168,8 +70,9 @@ Item {
if (!thumbnailImage.loadQueued) {
return;
}
if (modal && modal.activeImageLoads < modal.maxConcurrentLoads) {
thumbnailImage.startLoad();
if (modal.activeImageLoads < modal.maxConcurrentLoads) {
modal.activeImageLoads++;
thumbnailImage.loadImage();
} else {
retryTimer.restart();
}
@@ -177,8 +80,7 @@ Item {
}
Component.onCompleted: {
thumbnailImage.completed = true;
if (entryType !== "image" || listView.height <= 0 || !thumbnailImage.hasValidEntryId()) {
if (entryType !== "image" || listView.height <= 0) {
return;
}
@@ -192,11 +94,6 @@ Item {
}
}
Component.onDestruction: {
thumbnail.disposed = true;
thumbnailImage.cancelLoad();
}
Timer {
id: visibilityTimer
interval: 100
@@ -204,7 +101,7 @@ Item {
}
function checkVisibility() {
if (thumbnail.disposed || entryType !== "image" || listView.height <= 0 || isVisible || !thumbnailImage.hasValidEntryId()) {
if (entryType !== "image" || listView.height <= 0 || isVisible) {
return;
}
const itemY = itemIndex * (ClipboardConstants.itemHeight + listView.spacing);
@@ -262,7 +262,6 @@ Item {
clickCatcher.visible = true;
if (!contentWindow.visible)
contentWindow.visible = true;
opened();
shouldHaveFocus = false;
Qt.callLater(() => shouldHaveFocus = Qt.binding(() => shouldBeVisible));
});
@@ -498,12 +497,22 @@ Item {
}
WlrLayershell.namespace: root.layerNamespace
WlrLayershell.layer: root.useOverlayLayer ? WlrLayer.Overlay : LayerShell.fromEnv("DMS_MODAL_LAYER", WlrLayer.Top, {
"allow": ["top", "overlay"],
"invalidLayer": WlrLayer.Top,
"label": "modals",
"error": true
})
WlrLayershell.layer: {
if (root.useOverlayLayer)
return WlrLayershell.Overlay;
switch (Quickshell.env("DMS_MODAL_LAYER")) {
case "bottom":
log.error("'bottom' layer is not valid for modals. Defaulting to 'top' layer.");
return WlrLayershell.Top;
case "background":
log.error("'background' layer is not valid for modals. Defaulting to 'top' layer.");
return WlrLayershell.Top;
case "overlay":
return WlrLayershell.Overlay;
default:
return WlrLayershell.Top;
}
}
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: {
if (customKeyboardFocus !== null)
@@ -536,13 +545,15 @@ Item {
implicitHeight: root.useSingleWindow ? 0 : root.alignedHeight + (shadowBuffer * 2)
onVisibleChanged: {
if (visible)
return;
if (visible) {
opened();
} else {
if (Qt.inputMethod) {
Qt.inputMethod.hide();
Qt.inputMethod.reset();
}
}
}
MouseArea {
anchors.fill: parent
@@ -90,7 +90,6 @@ Item {
if (!useSingleWindow)
clickCatcher.visible = true;
contentWindow.visible = true;
opened();
shouldHaveFocus = false;
Qt.callLater(() => shouldHaveFocus = Qt.binding(() => shouldBeVisible));
}
@@ -252,12 +251,22 @@ Item {
}
WlrLayershell.namespace: root.layerNamespace
WlrLayershell.layer: root.useOverlayLayer ? WlrLayer.Overlay : LayerShell.fromEnv("DMS_MODAL_LAYER", WlrLayer.Top, {
"allow": ["top", "overlay"],
"invalidLayer": WlrLayer.Top,
"label": "modals",
"error": true
})
WlrLayershell.layer: {
if (root.useOverlayLayer)
return WlrLayershell.Overlay;
switch (Quickshell.env("DMS_MODAL_LAYER")) {
case "bottom":
log.error("'bottom' layer is not valid for modals. Defaulting to 'top' layer.");
return WlrLayershell.Top;
case "background":
log.error("'background' layer is not valid for modals. Defaulting to 'top' layer.");
return WlrLayershell.Top;
case "overlay":
return WlrLayershell.Overlay;
default:
return WlrLayershell.Top;
}
}
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: {
if (customKeyboardFocus !== null)
@@ -287,13 +296,15 @@ Item {
implicitHeight: root.useSingleWindow ? 0 : root.alignedHeight + (shadowBuffer * 2)
onVisibleChanged: {
if (visible)
return;
if (visible) {
opened();
} else {
if (Qt.inputMethod) {
Qt.inputMethod.hide();
Qt.inputMethod.reset();
}
}
}
MouseArea {
anchors.fill: parent
@@ -57,11 +57,7 @@ Rectangle {
return;
if (response.error)
return;
if (!response.result) {
ClipboardService.refresh();
return;
}
const result = response.result;
const result = response.result ?? {};
const mimeType = (result.mimeType ?? entry?.mimeType ?? "").toString();
const data = (result.data ?? "").toString();
if (data.length === 0 || !resolvedSourceUrl(data, mimeType))
@@ -1721,15 +1721,11 @@ Item {
return "";
var idx = text.toLowerCase().indexOf(lowerQuery);
if (idx === -1)
return _escapeRichText(text);
return text;
var before = text.substring(0, idx);
var match = text.substring(idx, idx + queryLen);
var after = text.substring(idx + queryLen);
return '<span style="color:' + baseColor + '">' + _escapeRichText(before) + '</span><span style="color:' + highlightColor + '; font-weight:600">' + _escapeRichText(match) + '</span><span style="color:' + baseColor + '">' + _escapeRichText(after) + '</span>';
}
function _escapeRichText(text) {
return String(text).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
return '<span style="color:' + baseColor + '">' + before + '</span><span style="color:' + highlightColor + '; font-weight:600">' + match + '</span><span style="color:' + baseColor + '">' + after + '</span>';
}
function getCurrentSectionViewMode() {
@@ -42,12 +42,20 @@ Item {
readonly property real screenHeight: effectiveScreen?.height ?? 1080
readonly property real dpr: effectiveScreen ? CompositorService.getScreenScale(effectiveScreen) : 1
readonly property bool usesOverlayLayer: SettingsData.launcherUseOverlayLayer || triggerUsesOverlayLayer
readonly property var effectiveLauncherLayer: LayerShell.fromEnv("DMS_MODAL_LAYER", root.usesOverlayLayer ? WlrLayer.Overlay : WlrLayer.Top, {
"allow": ["top", "overlay"],
"invalidLayer": WlrLayer.Top,
"label": "modals",
"error": true
})
readonly property var effectiveLauncherLayer: {
switch (Quickshell.env("DMS_MODAL_LAYER")) {
case "bottom":
log.error("'bottom' layer is not valid for modals. Defaulting to 'top' layer.");
return WlrLayershell.Top;
case "background":
log.error("'background' layer is not valid for modals. Defaulting to 'top' layer.");
return WlrLayershell.Top;
case "overlay":
return WlrLayershell.Overlay;
default:
return root.usesOverlayLayer ? WlrLayershell.Overlay : WlrLayershell.Top;
}
}
readonly property int baseWidth: {
switch (SettingsData.dankLauncherV2Size) {
@@ -681,7 +689,7 @@ Item {
WlrLayershell.namespace: "dms:spotlight"
WlrLayershell.layer: root.effectiveLauncherLayer
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: PopoutManager.screenshotActive ? WlrKeyboardFocus.None : (keyboardActive ? (root.useHyprlandFocusGrab ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) : WlrKeyboardFocus.None)
WlrLayershell.keyboardFocus: keyboardActive ? (root.useHyprlandFocusGrab ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) : WlrKeyboardFocus.None
anchors {
left: true
@@ -32,12 +32,20 @@ Item {
readonly property real dpr: effectiveScreen ? CompositorService.getScreenScale(effectiveScreen) : 1
readonly property bool useBackgroundDarken: !SettingsData.frameEnabled && SettingsData.modalDarkenBackground
readonly property bool usesOverlayLayer: useBackgroundDarken || SettingsData.launcherUseOverlayLayer || triggerUsesOverlayLayer
readonly property var effectiveLauncherLayer: LayerShell.fromEnv("DMS_MODAL_LAYER", root.usesOverlayLayer ? WlrLayer.Overlay : WlrLayer.Top, {
"allow": ["top", "overlay"],
"invalidLayer": WlrLayer.Top,
"label": "modals",
"error": true
})
readonly property var effectiveLauncherLayer: {
switch (Quickshell.env("DMS_MODAL_LAYER")) {
case "bottom":
log.error("'bottom' layer is not valid for modals. Defaulting to 'top' layer.");
return WlrLayershell.Top;
case "background":
log.error("'background' layer is not valid for modals. Defaulting to 'top' layer.");
return WlrLayershell.Top;
case "overlay":
return WlrLayershell.Overlay;
default:
return root.usesOverlayLayer ? WlrLayershell.Overlay : WlrLayershell.Top;
}
}
readonly property int _openDuration: 50
readonly property int _closeDuration: 40
@@ -337,7 +345,7 @@ Item {
WlrLayershell.namespace: "dms:spotlight"
WlrLayershell.layer: root.effectiveLauncherLayer
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: PopoutManager.screenshotActive ? WlrKeyboardFocus.None : (keyboardActive ? (root.useHyprlandFocusGrab ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) : WlrKeyboardFocus.None)
WlrLayershell.keyboardFocus: keyboardActive ? (root.useHyprlandFocusGrab ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) : WlrKeyboardFocus.None
anchors {
top: true
@@ -81,12 +81,20 @@ Item {
readonly property color backgroundColor: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
readonly property bool useBackgroundDarken: !SettingsData.frameEnabled && SettingsData.modalDarkenBackground
readonly property bool usesOverlayLayer: useBackgroundDarken || SettingsData.launcherUseOverlayLayer || triggerUsesOverlayLayer
readonly property var effectiveLauncherLayer: LayerShell.fromEnv("DMS_MODAL_LAYER", root.usesOverlayLayer ? WlrLayer.Overlay : WlrLayer.Top, {
"allow": ["top", "overlay"],
"invalidLayer": WlrLayer.Top,
"label": "modals",
"error": true
})
readonly property var effectiveLauncherLayer: {
switch (Quickshell.env("DMS_MODAL_LAYER")) {
case "bottom":
log.error("'bottom' layer is not valid for modals. Defaulting to 'top' layer.");
return WlrLayershell.Top;
case "background":
log.error("'background' layer is not valid for modals. Defaulting to 'top' layer.");
return WlrLayershell.Top;
case "overlay":
return WlrLayershell.Overlay;
default:
return root.usesOverlayLayer ? WlrLayershell.Overlay : WlrLayershell.Top;
}
}
readonly property real cornerRadius: Theme.cornerRadius
readonly property color borderColor: {
if (!SettingsData.dankLauncherV2BorderEnabled)
@@ -373,7 +381,7 @@ Item {
WlrLayershell.namespace: "dms:spotlight"
WlrLayershell.layer: root.effectiveLauncherLayer
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: PopoutManager.screenshotActive ? WlrKeyboardFocus.None : (keyboardActive ? (root.useHyprlandFocusGrab ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) : WlrKeyboardFocus.None)
WlrLayershell.keyboardFocus: keyboardActive ? (root.useHyprlandFocusGrab ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) : WlrKeyboardFocus.None
anchors {
top: true
@@ -446,7 +446,7 @@ Item {
WlrLayershell.namespace: "dms:launcher-context-menu"
WlrLayershell.layer: WlrLayershell.Overlay
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: PopoutManager.screenshotActive ? WlrKeyboardFocus.None : (root.renderActive ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None)
WlrLayershell.keyboardFocus: root.renderActive ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None
anchors {
top: true
@@ -25,7 +25,7 @@ Popup {
dangerous: true
},
{
text: I18n.tr("Copy path"),
text: I18n.tr("Copy Path"),
icon: "content_copy",
action: copyPath,
enabled: filePath.length > 0
@@ -322,8 +322,6 @@ Item {
url = "https://danklinux.com/docs/dankmaterialshell/compositors#dms-keybindings-1";
else if (CompositorService.isDwl)
url = "https://danklinux.com/docs/dankmaterialshell/compositors#dms-keybindings-2";
else if (CompositorService.isMango)
url = "https://danklinux.com/docs/dankmaterialshell/compositors#dms-keybindings-2";
Qt.openUrlExternally(url);
}
}
@@ -130,7 +130,7 @@ Item {
title: I18n.tr("Multi-Monitor", "greeter feature card title")
description: I18n.tr("Per-screen config", "greeter feature card description")
onClicked: {
const hasDisplayConfig = CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isDwl || CompositorService.isMango;
const hasDisplayConfig = CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isDwl;
PopoutService.openSettingsWithTab(hasDisplayConfig ? "display_config" : "display_widgets");
}
}
+4 -4
View File
@@ -81,7 +81,7 @@ DankModal {
StyledText {
Layout.alignment: Qt.AlignLeft
text: KeybindsService.cheatsheet.title || I18n.tr("Keybinds")
text: KeybindsService.cheatsheet.title || i18n("Keybinds")
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.Bold
color: Theme.primary
@@ -133,9 +133,9 @@ DankModal {
let hasSubcats = false;
for (let i = 0; i < binds.length; i++) {
const bind = binds[i];
const keyLower = (bind.key || "").toLowerCase();
const descLower = (bind.desc || "").toLowerCase();
const actionLower = (bind.action || "").toLowerCase();
const keyLower = bind.key.toLowerCase();
const descLower = bind.desc.toLowerCase();
const actionLower = bind.action.toLowerCase();
if (bind.hideOnOverlay)
continue;
-277
View File
@@ -1,277 +0,0 @@
import QtQuick
import qs.Common
import qs.Modals.Common
import qs.Services
import qs.Widgets
import Quickshell.Services.UPower
DankModal {
id: root
layerNamespace: "dms:power-profiles"
keepPopoutsOpen: true
property int selectedIndex: 0
property var profileModel: PowerProfileWatcher.availableProfiles
function openCentered() {
open();
}
function hideDialog() {
close();
}
shouldBeVisible: false
modalWidth: 440
modalHeight: 290
enableShadow: true
onBackgroundClicked: hideDialog()
onShouldBeVisibleChanged: {
if (!shouldBeVisible)
return;
if (typeof PowerProfiles !== "undefined") {
const current = PowerProfiles.profile;
const idx = profileModel.indexOf(current);
if (idx !== -1) {
selectedIndex = idx;
}
}
}
onShouldHaveFocusChanged: {
if (!shouldHaveFocus)
return;
Qt.callLater(() => modalFocusScope.forceActiveFocus());
}
modalFocusScope.Keys.onPressed: event => {
if (event.isAutoRepeat) {
event.accepted = true;
return;
}
switch (event.key) {
case Qt.Key_Left:
case Qt.Key_Up:
case Qt.Key_Backtab:
selectedIndex = (selectedIndex - 1 + profileModel.length) % profileModel.length;
event.accepted = true;
break;
case Qt.Key_Right:
case Qt.Key_Down:
case Qt.Key_Tab:
selectedIndex = (selectedIndex + 1) % profileModel.length;
event.accepted = true;
break;
case Qt.Key_Space:
case Qt.Key_Return:
case Qt.Key_Enter:
if (selectedIndex >= 0 && selectedIndex < profileModel.length) {
setProfile(profileModel[selectedIndex]);
}
event.accepted = true;
break;
case Qt.Key_1:
if (profileModel.length > 0) {
setProfile(profileModel[0]);
}
event.accepted = true;
break;
case Qt.Key_2:
if (profileModel.length > 1) {
setProfile(profileModel[1]);
}
event.accepted = true;
break;
case Qt.Key_3:
if (profileModel.length > 2) {
setProfile(profileModel[2]);
}
event.accepted = true;
break;
case Qt.Key_Escape:
hideDialog();
event.accepted = true;
break;
}
}
function setProfile(profile) {
if (PowerProfileWatcher.applyProfile(profile)) {
hideDialog();
return;
}
if (!PowerProfileWatcher.available)
ToastService.showError(I18n.tr("power-profiles-daemon not available"));
else
ToastService.showError(I18n.tr("Failed to set power profile"));
}
content: Component {
Item {
anchors.fill: parent
Column {
anchors.fill: parent
anchors.margins: Theme.spacingL
spacing: Theme.spacingL
Row {
width: parent.width
Column {
width: parent.width - 40
spacing: Theme.spacingXS
StyledText {
text: I18n.tr("Power Mode")
font.pixelSize: Theme.fontSizeLarge
color: Theme.surfaceText
font.weight: Font.Medium
}
StyledText {
text: I18n.tr("Choose a power profile")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceTextMedium
width: parent.width
elide: Text.ElideRight
}
}
DankActionButton {
iconName: "close"
iconSize: Theme.iconSize - 4
iconColor: Theme.surfaceText
onClicked: root.hideDialog()
}
}
Row {
id: buttonsRow
width: parent.width
spacing: Theme.spacingM
anchors.horizontalCenter: parent.horizontalCenter
Repeater {
model: root.profileModel
Rectangle {
id: profileButton
required property int index
required property int modelData
readonly property bool isSelected: root.selectedIndex === index
readonly property bool isActive: (typeof PowerProfiles !== "undefined") && PowerProfiles.profile === modelData
width: (parent.width - Theme.spacingM * (root.profileModel.length - 1)) / root.profileModel.length
height: 120
radius: Theme.cornerRadius
color: {
if (isActive)
return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.16);
if (isSelected)
return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08);
if (mouseArea.containsMouse)
return Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.12);
return Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.06);
}
border.color: isActive ? Theme.primary : (isSelected ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.5) : "transparent")
border.width: (isActive || isSelected) ? 2 : 0
// Shortcut Key Badge on Top-Right Corner
Rectangle {
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: Theme.spacingS
width: 20
height: 20
radius: 4
color: isActive ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.2) : Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.08)
border.color: isActive ? Theme.primary : "transparent"
border.width: isActive ? 1 : 0
StyledText {
text: (index + 1).toString()
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Bold
color: isActive ? Theme.primary : Theme.surfaceTextMedium
anchors.centerIn: parent
}
}
Column {
anchors.centerIn: parent
spacing: Theme.spacingS
DankIcon {
name: Theme.getPowerProfileIcon(modelData)
size: Theme.iconSize + 16
color: isActive ? Theme.primary : Theme.surfaceText
anchors.horizontalCenter: parent.horizontalCenter
}
StyledText {
text: Theme.getPowerProfileLabel(modelData)
font.pixelSize: Theme.fontSizeMedium
color: isActive ? Theme.primary : Theme.surfaceText
font.weight: Font.Medium
anchors.horizontalCenter: parent.horizontalCenter
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onEntered: {
root.selectedIndex = index;
}
onClicked: {
root.setProfile(modelData);
}
}
}
}
}
// Selected power profile description
StyledText {
text: (root.selectedIndex >= 0 && root.selectedIndex < root.profileModel.length) ? Theme.getPowerProfileDescription(root.profileModel[root.selectedIndex]) : ""
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceTextMedium
horizontalAlignment: Text.AlignHCenter
anchors.horizontalCenter: parent.horizontalCenter
wrapMode: Text.WordWrap
width: parent.width - Theme.spacingL * 2
}
// Keyboard Shortcut Guide Footer
Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: Theme.spacingXS
opacity: 0.5
DankIcon {
name: "keyboard"
size: Theme.fontSizeSmall
color: Theme.surfaceText
}
StyledText {
text: I18n.tr("Use keys 1-3 or arrows, Enter/Space to select")
font.pixelSize: Theme.fontSizeSmall - 1
color: Theme.surfaceText
}
}
}
}
}
}
@@ -570,22 +570,5 @@ FocusScope {
Qt.callLater(() => item.forceActiveFocus());
}
}
Loader {
id: autoStartLoader
anchors.fill: parent
active: root.currentIndex === 36
visible: active
focus: active
sourceComponent: AutoStartTab {
parentModal: root.parentModal
}
onActiveChanged: {
if (active && item)
Qt.callLater(() => item.forceActiveFocus());
}
}
}
}
+1 -12
View File
@@ -245,13 +245,6 @@ Rectangle {
"icon": "app_registration",
"tabIndex": 19,
"hyprlandNiriOnly": true
},
{
"id": "autostart",
"text": I18n.tr("Autostart Apps"),
"icon": "line_start",
"tabIndex": 36,
"autostartOnly": true
}
]
},
@@ -311,7 +304,7 @@ Rectangle {
"text": I18n.tr("Window Rules"),
"icon": "select_window",
"tabIndex": 28,
"windowRulesCapable": true
"niriOnly": true
}
]
},
@@ -370,16 +363,12 @@ Rectangle {
return false;
if (item.hyprlandNiriOnly && !CompositorService.isNiri && !CompositorService.isHyprland)
return false;
if (item.windowRulesCapable && !CompositorService.isNiri && !CompositorService.isHyprland && !CompositorService.isMango)
return false;
if (item.niriOnly && !CompositorService.isNiri)
return false;
if (item.clipboardOnly && (!DMSService.isConnected || DMSService.apiVersion < 23))
return false;
if (item.updaterOnly && !SystemUpdateService.sysupdateAvailable)
return false;
if (item.autostartOnly && !DesktopService.autostartAvailable)
return false;
return true;
}
+1 -1
View File
@@ -250,7 +250,7 @@ DankModal {
DankButton {
visible: SessionsService.otherSessions().length === 0 && !root.lockOnSwitch
text: I18n.tr("Log Out")
text: I18n.tr("Log out")
iconName: "logout"
backgroundColor: Theme.primary
textColor: Theme.primaryText
+13 -734
View File
@@ -12,7 +12,6 @@ FloatingWindow {
property bool isEditMode: editingRule !== null
property bool isNiri: CompositorService.isNiri
property bool isHyprland: CompositorService.isHyprland
property bool isMango: CompositorService.isMango
property bool submitting: false
property var targetWindow: null
@@ -21,10 +20,6 @@ FloatingWindow {
readonly property int inputFieldHeight: Theme.fontSizeMedium + Theme.spacingL * 2
readonly property int sectionSpacing: Theme.spacingL
ListModel {
id: extraMatchModel
}
objectName: "windowRuleModal"
title: isEditMode ? I18n.tr("Edit Window Rule") : I18n.tr("Create Window Rule")
minimumSize: Qt.size(500, 600)
@@ -36,18 +31,6 @@ FloatingWindow {
nameInput.text = "";
appIdInput.text = "";
titleInput.text = "";
extraMatchModel.clear();
condFloating.triState = 0;
condActive.triState = 0;
condFocused.triState = 0;
condActiveInColumn.triState = 0;
condCastTarget.triState = 0;
condUrgent.triState = 0;
condAtStartup.triState = 0;
condXwayland.triState = 0;
condFullscreen.triState = 0;
condPinned.triState = 0;
condInitialised.triState = 0;
opacityEnabled.checked = false;
opacitySlider.value = 100;
floatingToggle.checked = false;
@@ -69,15 +52,6 @@ FloatingWindow {
clipToGeometryToggle.checked = false;
tiledStateToggle.checked = false;
drawBorderBgToggle.checked = false;
blurCond.triState = 0;
xrayCond.triState = 0;
noiseEnabled.checked = false;
noiseSlider.value = 5;
saturationEnabled.checked = false;
saturationSlider.value = 100;
floatingXInput.text = "";
floatingYInput.text = "";
floatingRelativeDropdown.currentValue = "top-left";
minWidthInput.text = "";
maxWidthInput.text = "";
minHeightInput.text = "";
@@ -96,14 +70,6 @@ FloatingWindow {
moveInput.text = "";
monitorInput.text = "";
hyprWorkspaceInput.text = "";
mangoTagsInput.text = "";
mangoMonitorInput.text = "";
mangoSizeInput.text = "";
mangoNoBlurToggle.checked = false;
mangoNoBorderToggle.checked = false;
mangoNoShadowToggle.checked = false;
mangoNoRoundingToggle.checked = false;
mangoNoAnimToggle.checked = false;
}
function show(window) {
@@ -112,48 +78,24 @@ FloatingWindow {
resetForm();
if (targetWindow) {
nameInput.text = targetWindow.appId || "";
if (targetWindow.appId)
appIdInput.text = isMango ? targetWindow.appId : "^" + targetWindow.appId + "$";
else
appIdInput.text = "";
appIdInput.text = targetWindow.appId ? "^" + targetWindow.appId + "$" : "";
}
visible = true;
Qt.callLater(() => nameInput.forceActiveFocus());
}
function triFromBool(v) {
if (v === true)
return 1;
if (v === false)
return 2;
return 0;
function showEdit(rule) {
if (!rule) {
show();
return;
}
editingRule = rule;
resetForm();
function populateForm(rule) {
nameInput.text = rule.name || "";
const matchList = (rule.matches && rule.matches.length > 0) ? rule.matches : [rule.matchCriteria || {}];
const match = matchList[0] || {};
const match = rule.matchCriteria || {};
appIdInput.text = match.appId || "";
titleInput.text = match.title || "";
extraMatchModel.clear();
for (let i = 1; i < matchList.length; i++) {
extraMatchModel.append({
"rowAppId": matchList[i].appId || "",
"rowTitle": matchList[i].title || ""
});
}
condFloating.triState = triFromBool(match.isFloating);
condActive.triState = triFromBool(match.isActive);
condFocused.triState = triFromBool(match.isFocused);
condActiveInColumn.triState = triFromBool(match.isActiveInColumn);
condCastTarget.triState = triFromBool(match.isWindowCastTarget);
condUrgent.triState = triFromBool(match.isUrgent);
condAtStartup.triState = triFromBool(match.atStartup);
condXwayland.triState = triFromBool(match.xwayland);
condFullscreen.triState = triFromBool(match.fullscreen);
condPinned.triState = triFromBool(match.pinned);
condInitialised.triState = triFromBool(match.initialised);
const actions = rule.actions || {};
const hasOpacity = actions.opacity !== undefined && actions.opacity !== null;
@@ -189,19 +131,6 @@ FloatingWindow {
drawBorderBgToggle.checked = actions.drawBorderWithBackground || false;
xrayCond.triState = triFromBool(actions.backgroundXray);
blurCond.triState = triFromBool(actions.backgroundBlur);
const hasNoise = actions.backgroundNoise !== undefined && actions.backgroundNoise !== null;
noiseEnabled.checked = hasNoise;
noiseSlider.value = hasNoise ? Math.round(actions.backgroundNoise * 100) : 5;
const hasSaturation = actions.backgroundSaturation !== undefined && actions.backgroundSaturation !== null;
saturationEnabled.checked = hasSaturation;
saturationSlider.value = hasSaturation ? Math.round(actions.backgroundSaturation * 100) : 100;
floatingXInput.text = (actions.defaultFloatingX !== undefined && actions.defaultFloatingX !== null) ? String(actions.defaultFloatingX) : "";
floatingYInput.text = (actions.defaultFloatingY !== undefined && actions.defaultFloatingY !== null) ? String(actions.defaultFloatingY) : "";
floatingRelativeDropdown.currentValue = actions.defaultFloatingRelativeTo || "top-left";
minWidthInput.text = actions.minWidth !== undefined ? String(actions.minWidth) : "";
maxWidthInput.text = actions.maxWidth !== undefined ? String(actions.maxWidth) : "";
minHeightInput.text = actions.minHeight !== undefined ? String(actions.minHeight) : "";
@@ -222,36 +151,6 @@ FloatingWindow {
monitorInput.text = actions.monitor || "";
hyprWorkspaceInput.text = actions.workspace || "";
mangoTagsInput.text = actions.workspace || "";
mangoMonitorInput.text = actions.monitor || "";
mangoSizeInput.text = actions.size || "";
mangoNoBlurToggle.checked = actions.noblur || false;
mangoNoBorderToggle.checked = actions.noborder || false;
mangoNoShadowToggle.checked = actions.noshadow || false;
mangoNoRoundingToggle.checked = actions.norounding || false;
mangoNoAnimToggle.checked = actions.noanim || false;
}
function showEdit(rule) {
if (!rule) {
show();
return;
}
editingRule = rule;
resetForm();
populateForm(rule);
visible = true;
Qt.callLater(() => nameInput.forceActiveFocus());
}
function showCopy(rule) {
if (!rule) {
show();
return;
}
editingRule = null;
resetForm();
populateForm(rule);
visible = true;
Qt.callLater(() => nameInput.forceActiveFocus());
}
@@ -262,13 +161,6 @@ FloatingWindow {
targetWindow = null;
}
function applyCond(obj, key, triState) {
if (triState === 1)
obj[key] = true;
else if (triState === 2)
obj[key] = false;
}
function submitAndClose() {
const matchCriteria = {};
if (appIdInput.text.trim())
@@ -276,38 +168,6 @@ FloatingWindow {
if (titleInput.text.trim())
matchCriteria.title = titleInput.text.trim();
applyCond(matchCriteria, "isFloating", condFloating.triState);
if (isNiri) {
applyCond(matchCriteria, "isActive", condActive.triState);
applyCond(matchCriteria, "isFocused", condFocused.triState);
applyCond(matchCriteria, "isActiveInColumn", condActiveInColumn.triState);
applyCond(matchCriteria, "isWindowCastTarget", condCastTarget.triState);
applyCond(matchCriteria, "isUrgent", condUrgent.triState);
applyCond(matchCriteria, "atStartup", condAtStartup.triState);
}
if (isHyprland) {
applyCond(matchCriteria, "xwayland", condXwayland.triState);
applyCond(matchCriteria, "fullscreen", condFullscreen.triState);
applyCond(matchCriteria, "pinned", condPinned.triState);
applyCond(matchCriteria, "initialised", condInitialised.triState);
}
const matches = [];
if (Object.keys(matchCriteria).length > 0)
matches.push(matchCriteria);
if (isNiri) {
for (let i = 0; i < extraMatchModel.count; i++) {
const row = extraMatchModel.get(i);
const m = {};
if ((row.rowAppId || "").trim())
m.appId = row.rowAppId.trim();
if ((row.rowTitle || "").trim())
m.title = row.rowTitle.trim();
if (Object.keys(m).length > 0)
matches.push(m);
}
}
const actions = {};
if (opacityEnabled.checked)
@@ -346,23 +206,6 @@ FloatingWindow {
actions.tiledState = true;
if (drawBorderBgToggle.checked && isNiri)
actions.drawBorderWithBackground = true;
if (isNiri) {
applyCond(actions, "backgroundBlur", blurCond.triState);
applyCond(actions, "backgroundXray", xrayCond.triState);
}
if (noiseEnabled.checked && isNiri)
actions.backgroundNoise = noiseSlider.value / 100;
if (saturationEnabled.checked && isNiri)
actions.backgroundSaturation = saturationSlider.value / 100;
const floatX = parseInt(floatingXInput.text);
const floatY = parseInt(floatingYInput.text);
if (isNiri && !isNaN(floatX) && !isNaN(floatY)) {
actions.defaultFloatingX = floatX;
actions.defaultFloatingY = floatY;
if (floatingRelativeDropdown.currentValue && floatingRelativeDropdown.currentValue !== "top-left")
actions.defaultFloatingRelativeTo = floatingRelativeDropdown.currentValue;
}
const minW = parseInt(minWidthInput.text);
const maxW = parseInt(maxWidthInput.text);
@@ -408,25 +251,6 @@ FloatingWindow {
actions.workspace = hyprWorkspaceInput.text.trim();
}
if (isMango) {
if (mangoTagsInput.text.trim())
actions.workspace = mangoTagsInput.text.trim();
if (mangoMonitorInput.text.trim())
actions.monitor = mangoMonitorInput.text.trim();
if (mangoSizeInput.text.trim())
actions.size = mangoSizeInput.text.trim();
if (mangoNoBlurToggle.checked)
actions.noblur = true;
if (mangoNoBorderToggle.checked)
actions.noborder = true;
if (mangoNoShadowToggle.checked)
actions.noshadow = true;
if (mangoNoRoundingToggle.checked)
actions.norounding = true;
if (mangoNoAnimToggle.checked)
actions.noanim = true;
}
const name = nameInput.text.trim() || matchCriteria.appId || I18n.tr("Rule");
const compositor = CompositorService.compositor;
@@ -436,8 +260,6 @@ FloatingWindow {
actions: actions,
enabled: true
};
if (isNiri && extraMatchModel.count > 0)
ruleData.matches = matches;
submitting = true;
@@ -451,8 +273,6 @@ FloatingWindow {
return;
if (shouldValidate)
NiriService.validate();
if (CompositorService.isMango)
MangoService.reloadConfig();
root.ruleSubmitted();
root.hide();
});
@@ -464,8 +284,6 @@ FloatingWindow {
return;
if (shouldValidate)
NiriService.validate();
if (CompositorService.isMango)
MangoService.reloadConfig();
root.ruleSubmitted();
root.hide();
});
@@ -551,61 +369,6 @@ FloatingWindow {
border.width: hasFocus ? 2 : 1
}
// Tri-state toggle: 0 = unset (Inherit/Any), 1 = true, 2 = false
component MatchCond: Rectangle {
id: mc
property string label: ""
property int triState: 0
property string unsetLabel: I18n.tr("Default")
property bool readOnly: false
readonly property var stateText: [mc.unsetLabel, "true", "false"]
readonly property var stateColor: [Theme.surfaceVariantText, Theme.primary, Theme.error]
width: condRow.implicitWidth + Theme.spacingM * 2
height: root.inputFieldHeight
radius: Theme.cornerRadius
color: Theme.surfaceHover
border.width: 1
border.color: mc.triState === 0 ? Theme.outlineStrong : mc.stateColor[mc.triState]
opacity: mc.readOnly ? 0.4 : 1
Row {
id: condRow
anchors.centerIn: parent
spacing: Theme.spacingXS
StyledText {
text: mc.label
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
Rectangle {
width: stateBadge.implicitWidth + Theme.spacingS * 2
height: 18
radius: 9
color: Theme.withAlpha(mc.stateColor[mc.triState], 0.15)
anchors.verticalCenter: parent.verticalCenter
StyledText {
id: stateBadge
anchors.centerIn: parent
text: mc.stateText[mc.triState]
font.pixelSize: Theme.fontSizeSmall - 2
color: mc.stateColor[mc.triState]
}
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
enabled: root.visible && !mc.readOnly
onClicked: mc.triState = (mc.triState + 1) % 3
}
}
FocusScope {
anchors.fill: parent
focus: true
@@ -708,7 +471,7 @@ FloatingWindow {
anchors.fill: parent
font.pixelSize: Theme.fontSizeSmall
textColor: Theme.surfaceText
placeholderText: isMango ? I18n.tr("App ID (e.g. firefox)") : isHyprland ? I18n.tr("Class regex (e.g. ^firefox$)") : I18n.tr("App ID regex (e.g. ^firefox$)")
placeholderText: isNiri ? I18n.tr("App ID regex (e.g. ^firefox$)") : I18n.tr("Class regex (e.g. ^firefox$)")
backgroundColor: "transparent"
enabled: root.visible
}
@@ -726,7 +489,7 @@ FloatingWindow {
anchors.fill: parent
font.pixelSize: Theme.fontSizeSmall
textColor: Theme.surfaceText
placeholderText: isMango ? I18n.tr("Title (optional)") : I18n.tr("Title regex (optional)")
placeholderText: I18n.tr("Title regex (optional)")
backgroundColor: "transparent"
enabled: root.visible
}
@@ -746,184 +509,11 @@ FloatingWindow {
onClicked: {
if (!root.targetWindow?.title)
return;
titleInput.text = isMango ? root.targetWindow.title : "^" + root.targetWindow.title + "$";
titleInput.text = "^" + root.targetWindow.title + "$";
}
}
}
StyledText {
width: parent.width
visible: root.isNiri
text: I18n.tr("The rule applies to any window matching one of these.")
font.pixelSize: Theme.fontSizeSmall - 1
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
}
Repeater {
model: extraMatchModel
delegate: Row {
width: parent.width
spacing: Theme.spacingS
InputField {
width: (parent.width - removeMatchBtn.width - Theme.spacingS * 2) / 2
hasFocus: extraAppId.activeFocus
DankTextField {
id: extraAppId
anchors.fill: parent
font.pixelSize: Theme.fontSizeSmall
textColor: Theme.surfaceText
placeholderText: root.isNiri ? I18n.tr("App ID regex") : I18n.tr("Class regex")
backgroundColor: "transparent"
enabled: root.visible
text: rowAppId
onTextEdited: extraMatchModel.setProperty(index, "rowAppId", text)
}
}
InputField {
width: (parent.width - removeMatchBtn.width - Theme.spacingS * 2) / 2
hasFocus: extraTitle.activeFocus
DankTextField {
id: extraTitle
anchors.fill: parent
font.pixelSize: Theme.fontSizeSmall
textColor: Theme.surfaceText
placeholderText: I18n.tr("Title regex (optional)")
backgroundColor: "transparent"
enabled: root.visible
text: rowTitle
onTextEdited: extraMatchModel.setProperty(index, "rowTitle", text)
}
}
DankActionButton {
id: removeMatchBtn
width: root.inputFieldHeight
height: root.inputFieldHeight
circular: false
iconName: "close"
iconSize: 16
iconColor: Theme.surfaceVariantText
tooltipText: I18n.tr("Remove match")
tooltipSide: "left"
onClicked: extraMatchModel.remove(index)
}
}
}
Item {
width: parent.width
height: root.inputFieldHeight
visible: root.isNiri
Row {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingS
DankIcon {
name: "add"
size: 18
color: Theme.primary
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: I18n.tr("Add match")
font.pixelSize: Theme.fontSizeSmall
color: Theme.primary
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: extraMatchModel.append({
"rowAppId": "",
"rowTitle": ""
})
}
}
SectionHeader {
title: I18n.tr("Match Conditions")
visible: isNiri || isHyprland
}
StyledText {
width: parent.width
visible: isNiri || isHyprland
text: I18n.tr("Optional state-based conditions applied to the first match.")
font.pixelSize: Theme.fontSizeSmall - 1
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
}
Flow {
width: parent.width
spacing: Theme.spacingS
visible: isNiri || isHyprland
MatchCond {
id: condFloating
label: I18n.tr("Floating")
}
MatchCond {
id: condActive
label: I18n.tr("Active")
visible: isNiri
}
MatchCond {
id: condFocused
label: I18n.tr("Focused")
visible: isNiri
}
MatchCond {
id: condActiveInColumn
label: I18n.tr("Active in Column")
visible: isNiri
}
MatchCond {
id: condCastTarget
label: I18n.tr("Cast Target")
visible: isNiri
}
MatchCond {
id: condUrgent
label: I18n.tr("Urgent")
visible: isNiri
}
MatchCond {
id: condAtStartup
label: I18n.tr("At Startup")
visible: isNiri
}
MatchCond {
id: condXwayland
label: I18n.tr("XWayland")
visible: isHyprland
}
MatchCond {
id: condFullscreen
label: I18n.tr("Fullscreen")
visible: isHyprland
}
MatchCond {
id: condPinned
label: I18n.tr("Pinned")
visible: isHyprland
}
MatchCond {
id: condInitialised
label: I18n.tr("Initialised")
visible: isHyprland
}
}
SectionHeader {
title: I18n.tr("Window Opening")
}
@@ -939,7 +529,6 @@ FloatingWindow {
CheckboxRow {
id: maximizedToggle
label: I18n.tr("Maximize")
visible: !isMango
}
CheckboxRow {
id: fullscreenToggle
@@ -960,7 +549,7 @@ FloatingWindow {
Row {
width: parent.width
spacing: Theme.spacingM
visible: isNiri || isHyprland
visible: true
Column {
width: (parent.width - Theme.spacingM) / 2
@@ -1079,13 +668,11 @@ FloatingWindow {
SectionHeader {
title: I18n.tr("Dynamic Properties")
visible: isNiri || isHyprland
}
Row {
width: parent.width
spacing: Theme.spacingM
visible: isNiri || isHyprland
CheckboxRow {
id: opacityEnabled
@@ -1095,7 +682,6 @@ FloatingWindow {
DankSlider {
id: opacitySlider
wheelEnabled: false
width: parent.width - 100
minimum: 10
maximum: 100
@@ -1124,7 +710,7 @@ FloatingWindow {
}
CheckboxRow {
id: drawBorderBgToggle
label: I18n.tr("Border with Background")
label: I18n.tr("Border with BG")
}
}
@@ -1191,7 +777,6 @@ FloatingWindow {
DankSlider {
id: scrollFactorSlider
wheelEnabled: false
width: parent.width - 120
minimum: 10
maximum: 200
@@ -1204,7 +789,6 @@ FloatingWindow {
Row {
width: parent.width
spacing: Theme.spacingM
visible: isNiri || isHyprland
CheckboxRow {
id: cornerRadiusEnabled
@@ -1214,7 +798,6 @@ FloatingWindow {
DankSlider {
id: cornerRadiusSlider
wheelEnabled: false
width: parent.width - 130
minimum: 0
maximum: 24
@@ -1224,192 +807,13 @@ FloatingWindow {
}
}
SectionHeader {
title: I18n.tr("Background Effect")
visible: isNiri
}
StyledText {
width: parent.width
visible: isNiri
text: I18n.tr("Xray blurs only the wallpaper (efficient) and is the default when Blur is on. Set Xray to Off for regular full blur of everything beneath the window (more expensive).")
font.pixelSize: Theme.fontSizeSmall - 1
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
}
Flow {
width: parent.width
spacing: Theme.spacingS
visible: isNiri
MatchCond {
id: blurCond
label: I18n.tr("Blur")
unsetLabel: I18n.tr("Inherit")
onTriStateChanged: {
if (triState === 2)
xrayCond.triState = 0;
}
}
MatchCond {
id: xrayCond
label: I18n.tr("X-Ray")
unsetLabel: I18n.tr("Inherit")
readOnly: blurCond.triState === 2
}
}
Row {
width: parent.width
spacing: Theme.spacingM
visible: isNiri
CheckboxRow {
id: noiseEnabled
label: I18n.tr("Noise")
anchors.verticalCenter: parent.verticalCenter
}
DankSlider {
id: noiseSlider
wheelEnabled: false
width: parent.width - 130
minimum: 0
maximum: 100
value: 5
enabled: noiseEnabled.checked
opacity: enabled ? 1 : 0.4
}
}
Row {
width: parent.width
spacing: Theme.spacingM
visible: isNiri
CheckboxRow {
id: saturationEnabled
label: I18n.tr("Saturation")
anchors.verticalCenter: parent.verticalCenter
}
DankSlider {
id: saturationSlider
wheelEnabled: false
width: parent.width - 130
minimum: 0
maximum: 200
value: 100
enabled: saturationEnabled.checked
opacity: enabled ? 1 : 0.4
}
}
SectionHeader {
title: I18n.tr("Floating Position")
visible: isNiri
}
StyledText {
width: parent.width
visible: isNiri
text: I18n.tr("Initial position for floating windows. Set both X and Y; anchor controls which corner/edge they're relative to.")
font.pixelSize: Theme.fontSizeSmall - 1
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
}
Row {
width: parent.width
spacing: Theme.spacingM
visible: isNiri
Column {
width: (parent.width - Theme.spacingM * 2) / 3
spacing: Theme.spacingXS
StyledText {
text: I18n.tr("X")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
width: parent.width
horizontalAlignment: Text.AlignLeft
}
InputField {
width: parent.width
hasFocus: floatingXInput.activeFocus
DankTextField {
id: floatingXInput
anchors.fill: parent
font.pixelSize: Theme.fontSizeSmall
textColor: Theme.surfaceText
placeholderText: "px"
backgroundColor: "transparent"
enabled: root.visible
}
}
}
Column {
width: (parent.width - Theme.spacingM * 2) / 3
spacing: Theme.spacingXS
StyledText {
text: I18n.tr("Y")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
width: parent.width
horizontalAlignment: Text.AlignLeft
}
InputField {
width: parent.width
hasFocus: floatingYInput.activeFocus
DankTextField {
id: floatingYInput
anchors.fill: parent
font.pixelSize: Theme.fontSizeSmall
textColor: Theme.surfaceText
placeholderText: "px"
backgroundColor: "transparent"
enabled: root.visible
}
}
}
Column {
width: (parent.width - Theme.spacingM * 2) / 3
spacing: Theme.spacingXS
StyledText {
text: I18n.tr("Anchor")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
width: parent.width
horizontalAlignment: Text.AlignLeft
}
DankDropdown {
id: floatingRelativeDropdown
width: parent.width
dropdownWidth: parent.width
compactMode: true
options: ["top-left", "top-right", "bottom-left", "bottom-right", "top", "bottom", "left", "right"]
}
}
}
SectionHeader {
title: I18n.tr("Size Constraints")
visible: isNiri || isHyprland
}
Row {
width: parent.width
spacing: Theme.spacingM
visible: isNiri || isHyprland
Column {
width: (parent.width - Theme.spacingM * 3) / 4
@@ -1692,131 +1096,6 @@ FloatingWindow {
}
}
SectionHeader {
title: I18n.tr("Mango Options")
visible: isMango
}
Flow {
width: parent.width
spacing: Theme.spacingL
visible: isMango
CheckboxRow {
id: mangoNoBlurToggle
label: I18n.tr("No Blur")
}
CheckboxRow {
id: mangoNoBorderToggle
label: I18n.tr("No Border")
}
CheckboxRow {
id: mangoNoShadowToggle
label: I18n.tr("No Shadow")
}
CheckboxRow {
id: mangoNoRoundingToggle
label: I18n.tr("No Rounding")
}
CheckboxRow {
id: mangoNoAnimToggle
label: I18n.tr("No Anim")
}
}
Row {
width: parent.width
spacing: Theme.spacingM
visible: isMango
Column {
width: (parent.width - Theme.spacingM) / 2
spacing: Theme.spacingXS
StyledText {
text: I18n.tr("Tags")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
width: parent.width
horizontalAlignment: Text.AlignLeft
}
InputField {
width: parent.width
hasFocus: mangoTagsInput.activeFocus
DankTextField {
id: mangoTagsInput
anchors.fill: parent
font.pixelSize: Theme.fontSizeSmall
textColor: Theme.surfaceText
placeholderText: "1"
backgroundColor: "transparent"
enabled: root.visible
}
}
}
Column {
width: (parent.width - Theme.spacingM) / 2
spacing: Theme.spacingXS
StyledText {
text: I18n.tr("Monitor")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
width: parent.width
horizontalAlignment: Text.AlignLeft
}
InputField {
width: parent.width
hasFocus: mangoMonitorInput.activeFocus
DankTextField {
id: mangoMonitorInput
anchors.fill: parent
font.pixelSize: Theme.fontSizeSmall
textColor: Theme.surfaceText
placeholderText: "HDMI-A-1"
backgroundColor: "transparent"
enabled: root.visible
}
}
}
}
Row {
width: parent.width
spacing: Theme.spacingM
visible: isMango
Column {
width: parent.width
spacing: Theme.spacingXS
StyledText {
text: I18n.tr("Size")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
width: parent.width
horizontalAlignment: Text.AlignLeft
}
InputField {
width: parent.width
hasFocus: mangoSizeInput.activeFocus
DankTextField {
id: mangoSizeInput
anchors.fill: parent
font.pixelSize: Theme.fontSizeSmall
textColor: Theme.surfaceText
placeholderText: "800x600"
backgroundColor: "transparent"
enabled: root.visible
}
}
}
}
Item {
width: 1
height: Theme.spacingM
@@ -126,14 +126,6 @@ Variants {
}
}
Connections {
target: SettingsData
function onWallpaperFillModeChanged() {
root._renderSettling = true;
renderSettleTimer.restart();
}
}
Timer {
id: renderSettleTimer
interval: 1000
@@ -15,6 +15,25 @@ Item {
property var pluginDetailInstance: null
property var widgetModel: null
property var collapseCallback: null
property real maxAvailableHeight: 9999
function getDetailHeight(section) {
switch (true) {
case section === "wifi":
case section === "bluetooth":
case section === "builtin_vpn":
case section === "builtin_tailscale":
return Math.min(350, maxAvailableHeight);
case section.startsWith("brightnessSlider_"):
return Math.min(400, maxAvailableHeight);
case section.startsWith("plugin_"):
if (pluginDetailInstance?.ccDetailHeight)
return Math.min(pluginDetailInstance.ccDetailHeight, maxAvailableHeight);
return Math.min(250, maxAvailableHeight);
default:
return Math.min(250, maxAvailableHeight);
}
}
Loader {
id: pluginDetailLoader
@@ -1,38 +0,0 @@
import QtQuick
import qs.Common
import qs.Widgets
Rectangle {
id: root
property var widgetData: null
signal showMountPathChanged(bool show)
width: 260
height: menuColumn.implicitHeight + Theme.spacingS * 2
radius: Theme.cornerRadius
color: Theme.surfaceContainer
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.16)
border.width: 1
MouseArea {
anchors.fill: parent
}
Column {
id: menuColumn
anchors.fill: parent
anchors.margins: Theme.spacingS
spacing: 2
DankToggle {
width: parent.width
text: I18n.tr("Show mount path", "toggle in control center disk usage widget to turn mount path display on or off")
checked: root.widgetData?.showMountPath !== false
onToggled: newChecked => {
root.showMountPathChanged(newChecked);
}
}
}
}
@@ -3,7 +3,6 @@ import qs.Common
import qs.Services
import qs.Modules.ControlCenter.Widgets
import qs.Modules.ControlCenter.Components
import "../utils/detailHeight.js" as DetailHeightUtils
import "../utils/layout.js" as LayoutUtils
Column {
@@ -26,7 +25,6 @@ Column {
signal moveWidget(int fromIndex, int toIndex)
signal toggleWidgetSize(int index)
signal collapseRequested
signal configRequested(int index, var widgetData, var anchor)
function requestCollapse() {
collapseRequested();
@@ -39,7 +37,6 @@ Column {
property real currentRowWidth: 0
property int expandedRowIndex: -1
property var colorPickerModal: null
property var activePluginDetailInstance: null
readonly property real _maxDetailHeight: {
const rows = layoutResult.rows;
@@ -56,8 +53,6 @@ Column {
}
readonly property real targetImplicitHeight: {
if (editMode)
return editModeGrid.implicitHeight;
const rows = layoutResult.rows;
let totalHeight = 0;
for (let i = 0; i < rows.length; i++) {
@@ -75,7 +70,15 @@ Column {
}
function detailHeightForSection(section) {
return DetailHeightUtils.detailHeightForSection(section, _maxDetailHeight, activePluginDetailInstance);
if (!section)
return 0;
if (section === "wifi" || section === "bluetooth" || section === "builtin_vpn")
return Math.min(350, _maxDetailHeight);
if (section.startsWith("brightnessSlider_"))
return Math.min(400, _maxDetailHeight);
if (section.startsWith("plugin_"))
return Math.min(250, _maxDetailHeight);
return Math.min(250, _maxDetailHeight);
}
function calculateRowsAndWidgets() {
@@ -102,40 +105,8 @@ Column {
item.z = 1000;
}
function componentForWidget(widgetData) {
const id = widgetData.id || "";
const widgetWidth = widgetData.width || 50;
if (id.startsWith("builtin_"))
return builtinPluginWidgetComponent;
if (id.startsWith("plugin_"))
return pluginWidgetComponent;
switch (id) {
case "wifi":
case "bluetooth":
case "audioOutput":
case "audioInput":
return compoundPillComponent;
case "volumeSlider":
return audioSliderComponent;
case "brightnessSlider":
return brightnessSliderComponent;
case "inputVolumeSlider":
return inputAudioSliderComponent;
case "battery":
return widgetWidth <= 25 ? smallBatteryComponent : batteryPillComponent;
case "diskUsage":
return widgetWidth <= 25 ? smallDiskUsageComponent : diskUsagePillComponent;
case "colorPicker":
return colorPickerPillComponent;
case "doNotDisturb":
return widgetWidth <= 25 ? smallToggleComponent : dndPillComponent;
default:
return widgetWidth <= 25 ? smallToggleComponent : toggleButtonComponent;
}
}
Repeater {
model: root.editMode ? [] : root.layoutResult.rows
model: root.layoutResult.rows
Column {
width: root.width
@@ -202,12 +173,36 @@ Column {
return id === "volumeSlider" || id === "brightnessSlider" || id === "inputVolumeSlider";
}
widgetComponent: root.componentForWidget(modelData)
widgetComponent: {
const id = modelData.id || "";
if (id.startsWith("builtin_")) {
return builtinPluginWidgetComponent;
} else if (id.startsWith("plugin_")) {
return pluginWidgetComponent;
} else if (id === "wifi" || id === "bluetooth" || id === "audioOutput" || id === "audioInput") {
return compoundPillComponent;
} else if (id === "volumeSlider") {
return audioSliderComponent;
} else if (id === "brightnessSlider") {
return brightnessSliderComponent;
} else if (id === "inputVolumeSlider") {
return inputAudioSliderComponent;
} else if (id === "battery") {
return widgetWidth <= 25 ? smallBatteryComponent : batteryPillComponent;
} else if (id === "diskUsage") {
return widgetWidth <= 25 ? smallDiskUsageComponent : diskUsagePillComponent;
} else if (id === "colorPicker") {
return colorPickerPillComponent;
} else if (id === "doNotDisturb") {
return widgetWidth <= 25 ? smallToggleComponent : dndPillComponent;
} else {
return widgetWidth <= 25 ? smallToggleComponent : toggleButtonComponent;
}
}
onWidgetMoved: (fromIndex, toIndex) => root.moveWidget(fromIndex, toIndex)
onRemoveWidget: index => root.removeWidget(index)
onToggleWidgetSize: index => root.toggleWidgetSize(index)
onConfigRequested: (idx, data, anchor) => root.configRequested(idx, data, anchor)
}
}
}
@@ -215,6 +210,7 @@ Column {
DetailHost {
id: detailHost
width: parent.width
maxAvailableHeight: root._maxDetailHeight
height: active ? (root.detailHeightForSection(root.expandedSection) + Theme.spacingS) : 0
clip: true
property string retainedSection: ""
@@ -251,19 +247,7 @@ Column {
retainedWidgetData = root.expandedWidgetData;
}
function syncActivePluginDetail() {
if (active) {
root.activePluginDetailInstance = pluginDetailInstance;
} else if (root.activePluginDetailInstance === pluginDetailInstance) {
root.activePluginDetailInstance = null;
}
}
onActiveChanged: {
retainActiveDetail();
syncActivePluginDetail();
}
onPluginDetailInstanceChanged: syncActivePluginDetail()
onActiveChanged: retainActiveDetail()
onHeightChanged: {
if (!active && height <= 0.5) {
retainedSection = "";
@@ -293,18 +277,6 @@ Column {
}
}
EditModeGrid {
id: editModeGrid
width: root.width
visible: root.editMode
active: root.editMode
model: root.model
componentProvider: root
onRemoveWidget: index => root.removeWidget(index)
onToggleWidgetSize: index => root.toggleWidgetSize(index)
onConfigRequested: (idx, data, anchor) => root.configRequested(idx, data, anchor)
}
Component {
id: errorPillComponent
ErrorPill {
@@ -897,7 +869,6 @@ Column {
mountPath: widgetData.mountPath || "/"
instanceId: widgetData.instanceId || ""
showMountPath: widgetData.showMountPath !== undefined ? widgetData.showMountPath : true
onExpandClicked: {
if (!root.editMode) {
@@ -917,7 +888,6 @@ Column {
mountPath: widgetData.mountPath || "/"
instanceId: widgetData.instanceId || ""
showMountPath: widgetData.showMountPath !== undefined ? widgetData.showMountPath : true
onClicked: {
if (!root.editMode) {
@@ -21,7 +21,6 @@ Item {
signal widgetMoved(int fromIndex, int toIndex)
signal removeWidget(int index)
signal toggleWidgetSize(int index)
signal configRequested(int index, var widgetData, var anchor)
width: {
const widgetWidth = widgetData?.width || 50;
@@ -237,7 +236,6 @@ Item {
}
Rectangle {
id: removeButton
width: 16
height: 16
radius: 8
@@ -280,34 +278,6 @@ Item {
}
}
readonly property bool hasConfigMenu: widgetData?.id === "diskUsage"
Rectangle {
id: configButton
width: 16
height: 16
radius: 8
color: Theme.primary
anchors.top: removeButton.top
anchors.right: removeButton.left
anchors.rightMargin: 4
visible: editMode && root.hasConfigMenu
z: 10
DankIcon {
anchors.centerIn: parent
name: "settings"
size: 12
color: Theme.primaryText
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.configRequested(root.widgetIndex, root.widgetData, configButton)
}
}
Rectangle {
id: dragHandle
width: 16

Some files were not shown because too many files have changed in this diff Show More