mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-06-10 05:03:28 -04:00
Compare commits
86 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f8b32cc298 | |||
| 8856d45887 | |||
| 38af56c6fd | |||
| 9111e4809d | |||
| d08c7c5e55 | |||
| 69f3dee25a | |||
| 8155970ba2 | |||
| d356957dad | |||
| e7ccb702a3 | |||
| bf3ce6deb2 | |||
| f5295fb35d | |||
| 6c5836722a | |||
| 5716249bd9 | |||
| 4d0aab773b | |||
| e50ac208e3 | |||
| bcb5617194 | |||
| d3c23ba737 | |||
| e0ab0a6b90 | |||
| 713ce5f430 | |||
| 8eb23bcc29 | |||
| 4181343ef3 | |||
| d16566aa8d | |||
| 45eb101f40 | |||
| 59431869dc | |||
| 6e7aca8b15 | |||
| 6f387b0481 | |||
| 82d4364032 | |||
| e3de54c941 | |||
| 6991b45fbe | |||
| e5fff91ae6 | |||
| 2f2d4c9d9b | |||
| bfca1b46a6 | |||
| b117c80e47 | |||
| d20aa3b80a | |||
| a34fda984d | |||
| 510269dda9 | |||
| d51b34797c | |||
| d2905072c0 | |||
| 1ee42506b6 | |||
| 84fe2d751f | |||
| 5d0fc48706 | |||
| 335c5b4ac5 | |||
| 8c20f448ed | |||
| 0a668df138 | |||
| 3e4d2b4d46 | |||
| 12e43d120e | |||
| a9845bf3cd | |||
| e51ceed175 | |||
| 304baf6f60 | |||
| 6b141a9b06 | |||
| 0c3659a612 | |||
| a44bef5796 | |||
| b1ac6b0ef9 | |||
| 98844a3b85 | |||
| a32b8911c7 | |||
| 3118e7b9c3 | |||
| 2ca2bc5fb8 | |||
| 4bfb08f6ef | |||
| 0689339780 | |||
| a265625851 | |||
| 389fffaf64 | |||
| b7daf3f64a | |||
| 461da22b08 | |||
| 2b661e241d | |||
| d7df3800c2 | |||
| f2961f9b6a | |||
| f2d5ee4692 | |||
| 7c2d5ce15e | |||
| 5ceb908b8b | |||
| d819865853 | |||
| 38176ab543 | |||
| 53936d7034 | |||
| aafc2ea4d7 | |||
| 8a4be4936a | |||
| af097d0f33 | |||
| 44867e7b43 | |||
| a366bf3ca0 | |||
| 89f86be00a | |||
| 12a744e985 | |||
| 54f272ba1e | |||
| 60b64f22c6 | |||
| 97666dc73d | |||
| 6c6756936b | |||
| 91f8ca4efe | |||
| 045ac59a44 | |||
| 078180fe42 |
@@ -0,0 +1,31 @@
|
||||
## 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
|
||||
@@ -26,4 +26,4 @@ jobs:
|
||||
go-version-file: core/go.mod
|
||||
|
||||
- name: run pre-commit hooks
|
||||
uses: j178/prek-action@v1
|
||||
uses: j178/prek-action@v2
|
||||
|
||||
@@ -42,7 +42,7 @@ configure passwordless sudo for your user.`,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.Flags().StringVarP(&compositor, "compositor", "c", "", "Compositor/WM to install: niri or hyprland (enables headless mode)")
|
||||
rootCmd.Flags().StringVarP(&compositor, "compositor", "c", "", "Compositor/WM to install: niri, hyprland, or mango (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 or hyprland)")
|
||||
return fmt.Errorf("--compositor is required for headless mode (niri, hyprland, or mango)")
|
||||
}
|
||||
if term == "" {
|
||||
return fmt.Errorf("--term is required for headless mode (ghostty, kitty, or alacritty)")
|
||||
|
||||
@@ -56,6 +56,8 @@ 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) {
|
||||
@@ -85,10 +87,7 @@ func runResolveInclude(cmd *cobra.Command, args []string) {
|
||||
}
|
||||
|
||||
func checkHyprlandInclude(filename string) (IncludeResult, error) {
|
||||
configDir, err := utils.ExpandPath("$HOME/.config/hypr")
|
||||
if err != nil {
|
||||
return IncludeResult{}, err
|
||||
}
|
||||
configDir := filepath.Join(utils.XDGConfigHome(), "hypr")
|
||||
|
||||
targetPath := filepath.Join(configDir, "dms", filename)
|
||||
result := IncludeResult{}
|
||||
@@ -106,6 +105,8 @@ 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
|
||||
@@ -115,6 +116,10 @@ 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
|
||||
@@ -183,10 +188,7 @@ func hyprlandFindIncludeHyprlang(filePath, target string, processed map[string]b
|
||||
}
|
||||
|
||||
func checkNiriInclude(filename string) (IncludeResult, error) {
|
||||
configDir, err := utils.ExpandPath("$HOME/.config/niri")
|
||||
if err != nil {
|
||||
return IncludeResult{}, err
|
||||
}
|
||||
configDir := filepath.Join(utils.XDGConfigHome(), "niri")
|
||||
|
||||
targetPath := filepath.Join(configDir, "dms", filename)
|
||||
result := IncludeResult{}
|
||||
@@ -262,10 +264,7 @@ func niriFindInclude(filePath, target string, processed map[string]bool) bool {
|
||||
}
|
||||
|
||||
func checkMangoWCInclude(filename string) (IncludeResult, error) {
|
||||
configDir, err := utils.ExpandPath("$HOME/.config/mango")
|
||||
if err != nil {
|
||||
return IncludeResult{}, err
|
||||
}
|
||||
configDir := filepath.Join(utils.XDGConfigHome(), "mango")
|
||||
|
||||
targetPath := filepath.Join(configDir, "dms", filename)
|
||||
result := IncludeResult{}
|
||||
|
||||
@@ -125,6 +125,7 @@ const (
|
||||
catConfigFiles
|
||||
catServices
|
||||
catEnvironment
|
||||
catFonts
|
||||
)
|
||||
|
||||
func (c category) String() string {
|
||||
@@ -147,6 +148,8 @@ func (c category) String() string {
|
||||
return "Services"
|
||||
case catEnvironment:
|
||||
return "Environment"
|
||||
case catFonts:
|
||||
return "Fonts"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
@@ -213,6 +216,7 @@ func runDoctor(cmd *cobra.Command, args []string) {
|
||||
checkConfigurationFiles(),
|
||||
checkSystemdServices(),
|
||||
checkEnvironmentVars(),
|
||||
checkFonts(),
|
||||
)
|
||||
|
||||
switch {
|
||||
@@ -947,9 +951,12 @@ 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 == "failed" || dmsState.active == "inactive":
|
||||
case dmsState.active == "inactive":
|
||||
status = statusError
|
||||
}
|
||||
results = append(results, checkResult{catServices, "dms.service", status, message, "", doctorDocsURL + "#services"})
|
||||
@@ -1132,3 +1139,100 @@ func formatResultsPlain(results []checkResult) string {
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func checkFonts() []checkResult {
|
||||
var results []checkResult
|
||||
url := doctorDocsURL + "#fonts"
|
||||
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
settingsPath := filepath.Join(configDir, "DankMaterialShell", "settings.json")
|
||||
|
||||
fontFamily := "Inter Variable"
|
||||
monoFontFamily := "Fira Code"
|
||||
|
||||
if data, err := os.ReadFile(settingsPath); err == nil {
|
||||
var settings struct {
|
||||
FontFamily string `json:"fontFamily"`
|
||||
MonoFontFamily string `json:"monoFontFamily"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &settings); err == nil {
|
||||
if settings.FontFamily != "" {
|
||||
fontFamily = settings.FontFamily
|
||||
}
|
||||
if settings.MonoFontFamily != "" {
|
||||
monoFontFamily = settings.MonoFontFamily
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !utils.CommandExists("fc-list") {
|
||||
results = append(results, checkResult{catFonts, "Fontconfig Tools", statusWarn, "fc-list not installed", "Cannot verify if fonts are cached.", url})
|
||||
return results
|
||||
}
|
||||
|
||||
// Retrieve font list
|
||||
output, err := exec.Command("fc-list", ":", "family").Output()
|
||||
if err != nil {
|
||||
results = append(results, checkResult{catFonts, "Fontconfig Cache", statusError, "Failed to query font list", "Fontconfig cache query failed. Try running 'fc-cache -fv'.", url})
|
||||
return results
|
||||
}
|
||||
|
||||
outStr := string(output)
|
||||
if len(strings.TrimSpace(outStr)) == 0 {
|
||||
results = append(results, checkResult{catFonts, "Fontconfig Cache", statusError, "Cache is empty", "No fonts found in fontconfig cache. Try running 'fc-cache -fv'.", url})
|
||||
return results
|
||||
}
|
||||
|
||||
lowerFonts := strings.ToLower(outStr)
|
||||
|
||||
// Helper to check if a font exists
|
||||
hasFont := func(name string) bool {
|
||||
target := strings.ToLower(strings.TrimSpace(name))
|
||||
if target == "" {
|
||||
return false
|
||||
}
|
||||
for _, line := range strings.Split(lowerFonts, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
// Each line can have comma-separated families
|
||||
families := strings.Split(line, ",")
|
||||
for _, fam := range families {
|
||||
if strings.TrimSpace(fam) == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Normal Font Check
|
||||
if hasFont(fontFamily) {
|
||||
results = append(results, checkResult{catFonts, "Normal Font", statusOK, fontFamily, "Available", url})
|
||||
} else {
|
||||
results = append(results, checkResult{
|
||||
catFonts, "Normal Font", statusWarn,
|
||||
fmt.Sprintf("'%s' not found", fontFamily),
|
||||
"Font is not registered. Try running 'fc-cache -fv' or install the font.",
|
||||
url,
|
||||
})
|
||||
}
|
||||
|
||||
// Monospace Font Check
|
||||
if hasFont(monoFontFamily) {
|
||||
results = append(results, checkResult{catFonts, "Monospace Font", statusOK, monoFontFamily, "Available", url})
|
||||
} else {
|
||||
results = append(results, checkResult{
|
||||
catFonts, "Monospace Font", statusWarn,
|
||||
fmt.Sprintf("'%s' not found", monoFontFamily),
|
||||
"Font is not registered. Try running 'fc-cache -fv' or install the font.",
|
||||
url,
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -61,20 +62,34 @@ 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",
|
||||
PreRunE: preRunPrivileged,
|
||||
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)
|
||||
},
|
||||
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")
|
||||
term, _ := cmd.Flags().GetBool("terminal")
|
||||
if term {
|
||||
if err := syncInTerminal(yes, auth, local); err != nil {
|
||||
if err := syncInTerminal(yes, auth, local, profile, autologinOnly); err != nil {
|
||||
log.Fatalf("Error launching sync in terminal: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := syncGreeter(yes, auth, local); err != nil {
|
||||
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 {
|
||||
log.Fatalf("Error syncing greeter: %v", err)
|
||||
}
|
||||
},
|
||||
@@ -85,6 +100,8 @@ 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", false, "Apply only greeter auto-login on startup settings to greetd (no theme or auth sync)")
|
||||
}
|
||||
|
||||
var greeterEnableCmd = &cobra.Command{
|
||||
@@ -512,8 +529,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) error {
|
||||
syncFlags := make([]string, 0, 3)
|
||||
func syncInTerminal(nonInteractive bool, forceAuth bool, local bool, profileOnly bool, autologinOnly bool) error {
|
||||
syncFlags := make([]string, 0, 5)
|
||||
if nonInteractive {
|
||||
syncFlags = append(syncFlags, "--yes")
|
||||
}
|
||||
@@ -523,11 +540,22 @@ func syncInTerminal(nonInteractive bool, forceAuth bool, local bool) error {
|
||||
if local {
|
||||
syncFlags = append(syncFlags, "--local")
|
||||
}
|
||||
if profileOnly {
|
||||
syncFlags = append(syncFlags, "--profile")
|
||||
}
|
||||
if autologinOnly {
|
||||
syncFlags = append(syncFlags, "--autologin")
|
||||
}
|
||||
shellSyncCmd := "dms greeter sync"
|
||||
if len(syncFlags) > 0 {
|
||||
shellSyncCmd += " " + strings.Join(syncFlags, " ")
|
||||
}
|
||||
shellCmd := shellSyncCmd + `; echo; echo "Sync finished. Closing in 3 seconds..."; sleep 3`
|
||||
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`
|
||||
}
|
||||
return runCommandInTerminal(shellCmd)
|
||||
}
|
||||
|
||||
@@ -541,7 +569,54 @@ func resolveLocalWrapperShell() (string, error) {
|
||||
return "", fmt.Errorf("could not find bash or sh in PATH for local greeter wrapper")
|
||||
}
|
||||
|
||||
func syncGreeter(nonInteractive bool, forceAuth bool, local bool) error {
|
||||
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)
|
||||
}
|
||||
|
||||
if !nonInteractive {
|
||||
fmt.Println("=== DMS Greeter Sync ===")
|
||||
fmt.Println()
|
||||
@@ -752,6 +827,26 @@ func syncGreeter(nonInteractive bool, forceAuth bool, local bool) error {
|
||||
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()
|
||||
@@ -837,7 +932,14 @@ func resolveLocalDMSPath() (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
func disableDisplayManager(dmName string) (bool, error) {
|
||||
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
|
||||
@@ -179,9 +181,39 @@ 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) {
|
||||
sc := screenshot.New(config)
|
||||
result, err := sc.Run()
|
||||
// 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()
|
||||
}()
|
||||
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
|
||||
@@ -102,32 +102,42 @@ 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",
|
||||
@@ -136,20 +146,26 @@ 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 "" },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -192,7 +208,7 @@ func detectCompositorForSetup() (string, error) {
|
||||
|
||||
switch len(compositors) {
|
||||
case 0:
|
||||
return "", fmt.Errorf("no supported compositors found (niri or Hyprland required)")
|
||||
return "", fmt.Errorf("no supported compositors found (niri, Hyprland, or mango required)")
|
||||
case 1:
|
||||
return strings.ToLower(compositors[0]), nil
|
||||
}
|
||||
@@ -224,6 +240,9 @@ 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)
|
||||
}
|
||||
@@ -235,9 +254,11 @@ func runSetupDmsConfig(name string) error {
|
||||
var dmsDir string
|
||||
switch compositor {
|
||||
case "niri":
|
||||
dmsDir = filepath.Join(os.Getenv("HOME"), ".config", "niri", "dms")
|
||||
dmsDir = filepath.Join(utils.XDGConfigHome(), "niri", "dms")
|
||||
case "hyprland":
|
||||
dmsDir = filepath.Join(os.Getenv("HOME"), ".config", "hypr", "dms")
|
||||
dmsDir = filepath.Join(utils.XDGConfigHome(), "hypr", "dms")
|
||||
case "mango", "mangowc":
|
||||
dmsDir = filepath.Join(utils.XDGConfigHome(), "mango", "dms")
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
||||
@@ -273,7 +294,14 @@ func runSetup() error {
|
||||
|
||||
wm, wmSelected := promptCompositor()
|
||||
terminal, terminalSelected := promptTerminal()
|
||||
useSystemd := promptSystemd()
|
||||
useSystemd := true
|
||||
if wmSelected {
|
||||
if wm == deps.WindowManagerMango {
|
||||
useSystemd = false
|
||||
} else {
|
||||
useSystemd = promptSystemd()
|
||||
}
|
||||
}
|
||||
|
||||
if !wmSelected && !terminalSelected {
|
||||
fmt.Println("No configurations selected. Exiting.")
|
||||
@@ -379,10 +407,11 @@ func promptCompositor() (deps.WindowManager, bool) {
|
||||
fmt.Println("Select compositor:")
|
||||
fmt.Println("1) Niri")
|
||||
fmt.Println("2) Hyprland")
|
||||
fmt.Println("3) None")
|
||||
fmt.Println("3) Mango")
|
||||
fmt.Println("4) None")
|
||||
|
||||
var response string
|
||||
fmt.Print("\nChoice (1-3): ")
|
||||
fmt.Print("\nChoice (1-4): ")
|
||||
fmt.Scanln(&response)
|
||||
response = strings.TrimSpace(response)
|
||||
|
||||
@@ -391,6 +420,8 @@ 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
|
||||
}
|
||||
@@ -447,6 +478,11 @@ 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 {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -26,7 +27,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"}, cobra.ShellCompDirectiveNoFileComp
|
||||
return []string{"hyprland", "niri", "mango"}, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
},
|
||||
@@ -40,7 +41,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"}, cobra.ShellCompDirectiveNoFileComp
|
||||
return []string{"hyprland", "niri", "mango"}, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
},
|
||||
@@ -54,7 +55,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"}, cobra.ShellCompDirectiveNoFileComp
|
||||
return []string{"hyprland", "niri", "mango"}, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
},
|
||||
@@ -68,7 +69,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"}, cobra.ShellCompDirectiveNoFileComp
|
||||
return []string{"hyprland", "niri", "mango"}, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
},
|
||||
@@ -82,7 +83,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"}, cobra.ShellCompDirectiveNoFileComp
|
||||
return []string{"hyprland", "niri", "mango"}, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
},
|
||||
@@ -120,6 +121,9 @@ func getCompositor(args []string) string {
|
||||
if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "" {
|
||||
return "hyprland"
|
||||
}
|
||||
if os.Getenv("MANGO_INSTANCE_SIGNATURE") != "" {
|
||||
return "mango"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -139,17 +143,14 @@ 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 or niri")
|
||||
log.Fatalf("Could not detect compositor. Please specify: hyprland, niri, or mango")
|
||||
}
|
||||
|
||||
var result WindowRulesListResult
|
||||
|
||||
switch compositor {
|
||||
case "niri":
|
||||
configDir, err := utils.ExpandPath("$HOME/.config/niri")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to expand niri config path: %v", err)
|
||||
}
|
||||
configDir := filepath.Join(utils.XDGConfigHome(), "niri")
|
||||
|
||||
parseResult, err := providers.ParseNiriWindowRules(configDir)
|
||||
if err != nil {
|
||||
@@ -182,10 +183,7 @@ func runWindowrulesList(cmd *cobra.Command, args []string) {
|
||||
result.DMSStatus = parseResult.DMSStatus
|
||||
|
||||
case "hyprland":
|
||||
configDir, err := utils.ExpandPath("$HOME/.config/hypr")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to expand hyprland config path: %v", err)
|
||||
}
|
||||
configDir := filepath.Join(utils.XDGConfigHome(), "hypr")
|
||||
|
||||
parseResult, err := providers.ParseHyprlandWindowRules(configDir)
|
||||
if err != nil {
|
||||
@@ -217,6 +215,38 @@ 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)
|
||||
}
|
||||
@@ -315,17 +345,14 @@ func runWindowrulesReorder(cmd *cobra.Command, args []string) {
|
||||
func getWindowRulesProvider(compositor string) windowrules.WritableProvider {
|
||||
switch compositor {
|
||||
case "niri":
|
||||
configDir, err := utils.ExpandPath("$HOME/.config/niri")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
configDir := filepath.Join(utils.XDGConfigHome(), "niri")
|
||||
return providers.NewNiriWritableProvider(configDir)
|
||||
case "hyprland":
|
||||
configDir, err := utils.ExpandPath("$HOME/.config/hypr")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
configDir := filepath.Join(utils.XDGConfigHome(), "hypr")
|
||||
return providers.NewHyprlandWritableProvider(configDir)
|
||||
case "mango", "mangowc":
|
||||
configDir := filepath.Join(utils.XDGConfigHome(), "mango")
|
||||
return providers.NewMangoWritableProvider(configDir)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@ 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()
|
||||
|
||||
@@ -22,6 +26,7 @@ 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)
|
||||
@@ -61,6 +66,7 @@ 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)
|
||||
|
||||
+152
-6
@@ -2,7 +2,9 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
@@ -192,6 +194,7 @@ func runShellInteractive(session bool) {
|
||||
}
|
||||
}()
|
||||
|
||||
ensureFontCache()
|
||||
log.Infof("Spawning quickshell with -p %s", configPath)
|
||||
|
||||
cmd := exec.CommandContext(ctx, "qs", "-p", configPath)
|
||||
@@ -227,8 +230,10 @@ func runShellInteractive(session bool) {
|
||||
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
tracker := &stderrTracker{parent: os.Stderr}
|
||||
cmd.Stderr = tracker
|
||||
|
||||
startTime := time.Now()
|
||||
if err := cmd.Start(); err != nil {
|
||||
log.Fatalf("Error starting quickshell: %v", err)
|
||||
}
|
||||
@@ -277,7 +282,9 @@ func runShellInteractive(session bool) {
|
||||
case <-errChan:
|
||||
cancel()
|
||||
os.Remove(socketPath)
|
||||
os.Exit(getProcessExitCode(cmd.ProcessState))
|
||||
exitCode := getProcessExitCode(cmd.ProcessState)
|
||||
logStartupFailure(startTime, exitCode, tracker)
|
||||
os.Exit(exitCode)
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
|
||||
@@ -294,7 +301,9 @@ func runShellInteractive(session bool) {
|
||||
cmd.Process.Signal(syscall.SIGTERM)
|
||||
}
|
||||
os.Remove(socketPath)
|
||||
os.Exit(getProcessExitCode(cmd.ProcessState))
|
||||
exitCode := getProcessExitCode(cmd.ProcessState)
|
||||
logStartupFailure(startTime, exitCode, tracker)
|
||||
os.Exit(exitCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -434,6 +443,7 @@ func runShellDaemon(session bool) {
|
||||
}
|
||||
}()
|
||||
|
||||
ensureFontCache()
|
||||
log.Infof("Spawning quickshell with -p %s", configPath)
|
||||
|
||||
cmd := exec.CommandContext(ctx, "qs", "-p", configPath)
|
||||
@@ -478,8 +488,10 @@ func runShellDaemon(session bool) {
|
||||
|
||||
cmd.Stdin = devNull
|
||||
cmd.Stdout = devNull
|
||||
cmd.Stderr = devNull
|
||||
tracker := &stderrTracker{parent: devNull}
|
||||
cmd.Stderr = tracker
|
||||
|
||||
startTime := time.Now()
|
||||
if err := cmd.Start(); err != nil {
|
||||
log.Fatalf("Error starting daemon: %v", err)
|
||||
}
|
||||
@@ -528,7 +540,9 @@ func runShellDaemon(session bool) {
|
||||
case <-errChan:
|
||||
cancel()
|
||||
os.Remove(socketPath)
|
||||
os.Exit(getProcessExitCode(cmd.ProcessState))
|
||||
exitCode := getProcessExitCode(cmd.ProcessState)
|
||||
logStartupFailure(startTime, exitCode, tracker)
|
||||
os.Exit(exitCode)
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
|
||||
@@ -543,7 +557,9 @@ func runShellDaemon(session bool) {
|
||||
cmd.Process.Signal(syscall.SIGTERM)
|
||||
}
|
||||
os.Remove(socketPath)
|
||||
os.Exit(getProcessExitCode(cmd.ProcessState))
|
||||
exitCode := getProcessExitCode(cmd.ProcessState)
|
||||
logStartupFailure(startTime, exitCode, tracker)
|
||||
os.Exit(exitCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -748,3 +764,133 @@ func printIPCHelp() {
|
||||
fmt.Printf(" %-16s %s\n", targetName, strings.Join(funcNames, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
// ensureFontCache rebuilds the fontconfig cache if user-configured fonts are missing while skipping defaults
|
||||
func ensureFontCache() {
|
||||
if _, err := exec.LookPath("fc-list"); err != nil {
|
||||
return
|
||||
}
|
||||
if _, err := exec.LookPath("fc-cache"); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var fontsToCheck []string
|
||||
|
||||
if configDir, err := os.UserConfigDir(); err == nil {
|
||||
settingsPath := filepath.Join(configDir, "DankMaterialShell", "settings.json")
|
||||
if data, err := os.ReadFile(settingsPath); err == nil {
|
||||
var settings struct {
|
||||
FontFamily string `json:"fontFamily"`
|
||||
MonoFontFamily string `json:"monoFontFamily"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &settings); err == nil {
|
||||
if settings.FontFamily != "" && settings.FontFamily != "Inter Variable" {
|
||||
fontsToCheck = append(fontsToCheck, settings.FontFamily)
|
||||
}
|
||||
if settings.MonoFontFamily != "" && settings.MonoFontFamily != "Fira Code" {
|
||||
fontsToCheck = append(fontsToCheck, settings.MonoFontFamily)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(fontsToCheck) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
output, err := exec.Command("fc-list", ":", "family").Output()
|
||||
if err != nil || len(strings.TrimSpace(string(output))) == 0 {
|
||||
log.Warnf("Font cache appears empty or corrupt, rebuilding...")
|
||||
rebuildFontCache()
|
||||
return
|
||||
}
|
||||
|
||||
cacheFonts := strings.ToLower(string(output))
|
||||
var missing []string
|
||||
for _, font := range fontsToCheck {
|
||||
if !fontInCache(strings.ToLower(font), cacheFonts) {
|
||||
missing = append(missing, font)
|
||||
}
|
||||
}
|
||||
|
||||
if len(missing) > 0 {
|
||||
log.Warnf("Font(s) not found in cache: %s — rebuilding...", strings.Join(missing, ", "))
|
||||
rebuildFontCache()
|
||||
}
|
||||
}
|
||||
|
||||
func fontInCache(target, cache string) bool {
|
||||
for _, line := range strings.Split(cache, "\n") {
|
||||
for _, fam := range strings.Split(strings.TrimSpace(line), ",") {
|
||||
if strings.TrimSpace(fam) == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func rebuildFontCache() {
|
||||
cmd := exec.Command("fc-cache", "-f")
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
log.Warnf("Failed to rebuild font cache: %v\n%s", err, string(output))
|
||||
} else {
|
||||
log.Infof("Font cache rebuilt successfully")
|
||||
}
|
||||
}
|
||||
|
||||
type stderrTracker struct {
|
||||
mu sync.Mutex
|
||||
buf strings.Builder
|
||||
parent io.Writer
|
||||
}
|
||||
|
||||
func (s *stderrTracker) Write(p []byte) (n int, err error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.buf.Len() < 8192 {
|
||||
s.buf.Write(p)
|
||||
}
|
||||
if s.parent != nil {
|
||||
return s.parent.Write(p)
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (s *stderrTracker) String() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.buf.String()
|
||||
}
|
||||
|
||||
// logStartupFailure logs diagnostic advice if qs crashes within 5s of launch.
|
||||
func logStartupFailure(startTime time.Time, exitCode int, tracker *stderrTracker) {
|
||||
if time.Since(startTime) >= 5*time.Second || exitCode == 0 || exitCode > 128 {
|
||||
return
|
||||
}
|
||||
if containsFontCrashSignature(tracker.String()) {
|
||||
log.Errorf("DMS startup failed due to a potential font/rendering crash. Try running 'fc-cache -fv' and restarting DMS.")
|
||||
} else {
|
||||
log.Errorf("DMS startup failed (exit code %d). Run 'dms doctor' for more diagnostics.", exitCode)
|
||||
}
|
||||
}
|
||||
|
||||
func containsFontCrashSignature(logStr string) bool {
|
||||
logStr = strings.ToLower(logStr)
|
||||
signatures := []string{
|
||||
"fontconfig",
|
||||
"freetype",
|
||||
"ft_load_glyph",
|
||||
"ft_face",
|
||||
"fc-list",
|
||||
"fc-cache",
|
||||
"glyph",
|
||||
"typeface",
|
||||
}
|
||||
for _, sig := range signatures {
|
||||
if strings.Contains(logStr, sig) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -73,6 +73,10 @@ 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"),
|
||||
},
|
||||
@@ -126,6 +130,14 @@ 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 {
|
||||
@@ -269,6 +281,96 @@ 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
|
||||
|
||||
@@ -600,6 +702,10 @@ 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
|
||||
|
||||
@@ -20,13 +20,17 @@ func TestCleanupStrayHyprlandConfFile(t *testing.T) {
|
||||
td := t.TempDir()
|
||||
t.Setenv("HOME", td)
|
||||
configDir := filepath.Join(td, ".config", "hypr")
|
||||
require.NoError(t, os.MkdirAll(configDir, 0o755))
|
||||
dmsDir := filepath.Join(configDir, "dms")
|
||||
require.NoError(t, os.MkdirAll(dmsDir, 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))
|
||||
})
|
||||
|
||||
@@ -34,20 +38,25 @@ func TestCleanupStrayHyprlandConfFile(t *testing.T) {
|
||||
td := t.TempDir()
|
||||
t.Setenv("HOME", td)
|
||||
configDir := filepath.Join(td, ".config", "hypr")
|
||||
require.NoError(t, os.MkdirAll(configDir, 0o755))
|
||||
dmsDir := filepath.Join(configDir, "dms")
|
||||
require.NoError(t, os.MkdirAll(dmsDir, 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"))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -404,6 +413,7 @@ 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))
|
||||
@@ -423,10 +433,12 @@ 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"))
|
||||
@@ -485,7 +497,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.exec_cmd([[hyprctl dispatch resizeactive -10% 0]]), { repeating = true })`)
|
||||
assert.Contains(t, string(managed), `hl.bind("SUPER + minus", hl.dsp.window.resize({ x = -100, y = 0, relative = true }), { repeating = true })`)
|
||||
|
||||
user, err := os.ReadFile(filepath.Join(dmsDir, "binds-user.lua"))
|
||||
require.NoError(t, err)
|
||||
@@ -508,6 +520,18 @@ func TestHyprlandConfigStructure(t *testing.T) {
|
||||
assert.Contains(t, HyprlandLuaConfig, "input =")
|
||||
}
|
||||
|
||||
func TestMangoConfigStructure(t *testing.T) {
|
||||
assert.Contains(t, MangoConfig, "exec-once=dms run")
|
||||
assert.NotContains(t, MangoConfig, "exec_once=dms run")
|
||||
assert.Contains(t, MangoConfig, "source=./dms/binds.conf")
|
||||
assert.Contains(t, MangoBindsConfig, "bind=SUPER,H,focusdir,left")
|
||||
assert.Contains(t, MangoBindsConfig, "bind=SUPER,J,focusdir,down")
|
||||
assert.Contains(t, MangoBindsConfig, "bind=SUPER,K,focusdir,up")
|
||||
assert.Contains(t, MangoBindsConfig, "bind=SUPER,L,focusdir,right")
|
||||
assert.Contains(t, MangoBindsConfig, "gesturebind=none,right,3,viewtoleft_have_client")
|
||||
assert.Contains(t, MangoBindsConfig, "gesturebind=none,left,3,viewtoright_have_client")
|
||||
}
|
||||
|
||||
func TestGhosttyConfigStructure(t *testing.T) {
|
||||
assert.Contains(t, GhosttyConfig, "window-decoration = false")
|
||||
assert.Contains(t, GhosttyConfig, "background-opacity = 1.0")
|
||||
|
||||
@@ -11,6 +11,7 @@ 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
|
||||
@@ -38,7 +39,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.kill())
|
||||
hl.bind("SUPER + Q", hl.dsp.window.close())
|
||||
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" }))
|
||||
@@ -112,6 +113,9 @@ 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" }))
|
||||
@@ -140,7 +144,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.exec_cmd([[hyprctl dispatch resizeactive exact 100% 100%]]))
|
||||
hl.bind("SUPER + CTRL + F", hl.dsp.window.fullscreen({ mode = "maximized", action = "set" }))
|
||||
|
||||
-- === Move/resize windows with mainMod + LMB/RMB and dragging ===
|
||||
hl.bind("SUPER + mouse:272", hl.dsp.window.drag(), { mouse = true, description = "Move window" })
|
||||
@@ -150,10 +154,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.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 })
|
||||
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 })
|
||||
|
||||
-- === Screenshots ===
|
||||
hl.bind("Print", hl.dsp.exec_cmd("dms screenshot"))
|
||||
|
||||
@@ -13,6 +13,10 @@ hl.config({
|
||||
input = {
|
||||
kb_layout = "us",
|
||||
numlock_by_default = true,
|
||||
touchpad = {
|
||||
tap_to_click = true,
|
||||
natural_scroll = true,
|
||||
},
|
||||
},
|
||||
general = {
|
||||
gaps_in = 5,
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
# DMS default keybinds (MangoWM) — managed by DMS, regenerated by `dms setup`.
|
||||
# Format: bind=MODS,key,action[,args]
|
||||
# Put bind descriptions above bind lines; inline # comments break Mango spawn args.
|
||||
|
||||
# === 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 ===
|
||||
bind=none,XF86AudioRaiseVolume,spawn,dms ipc call audio increment 3
|
||||
bind=none,XF86AudioLowerVolume,spawn,dms ipc call audio decrement 3
|
||||
bind=none,XF86AudioMute,spawn,dms ipc call audio mute
|
||||
bind=none,XF86AudioMicMute,spawn,dms ipc call audio micmute
|
||||
bind=none,XF86AudioPlay,spawn,dms ipc call mpris playPause
|
||||
bind=none,XF86AudioPause,spawn,dms ipc call mpris playPause
|
||||
bind=none,XF86AudioPrev,spawn,dms ipc call mpris previous
|
||||
bind=none,XF86AudioNext,spawn,dms ipc call mpris next
|
||||
|
||||
# === Brightness Controls ===
|
||||
bind=none,XF86MonBrightnessUp,spawn,dms ipc call brightness increment 5
|
||||
bind=none,XF86MonBrightnessDown,spawn,dms ipc call brightness decrement 5
|
||||
|
||||
# === Window Management ===
|
||||
# Close Window
|
||||
bind=SUPER,q,killclient,
|
||||
bind=SUPER,f,togglefullscreen,
|
||||
bind=SUPER,a,togglemaximizescreen,
|
||||
bind=SUPER+SHIFT,space,togglefloating,
|
||||
bind=SUPER,o,toggleoverview
|
||||
bind=ALT,Tab,toggleoverview
|
||||
# Exit Compositor
|
||||
bind=SUPER+SHIFT,e,quit,
|
||||
|
||||
# === Focus Navigation ===
|
||||
bind=SUPER,Tab,focusstack,next
|
||||
bind=SUPER+SHIFT,Tab,focusstack,prev
|
||||
bind=SUPER,Left,focusdir,left
|
||||
bind=SUPER,H,focusdir,left
|
||||
bind=SUPER,Right,focusdir,right
|
||||
bind=SUPER,L,focusdir,right
|
||||
bind=SUPER,Up,focusdir,up
|
||||
bind=SUPER,K,focusdir,up
|
||||
bind=SUPER,Down,focusdir,down
|
||||
bind=SUPER,J,focusdir,down
|
||||
|
||||
# === Window Movement ===
|
||||
bind=SUPER+SHIFT,Left,exchange_client,left
|
||||
bind=SUPER+SHIFT,Right,exchange_client,right
|
||||
bind=SUPER+SHIFT,Up,exchange_client,up
|
||||
bind=SUPER+SHIFT,Down,exchange_client,down
|
||||
bind=SUPER+SHIFT,H,exchange_client,left
|
||||
bind=SUPER+SHIFT,L,exchange_client,right
|
||||
bind=SUPER+SHIFT,K,exchange_client,up
|
||||
bind=SUPER+SHIFT,J,exchange_client,down
|
||||
|
||||
# === Monitor Navigation ===
|
||||
bind=SUPER+ALT,Left,focusmon,left
|
||||
bind=SUPER+ALT,Right,focusmon,right
|
||||
bind=SUPER+ALT+SHIFT,Left,tagmon,left
|
||||
bind=SUPER+ALT+SHIFT,Right,tagmon,right
|
||||
|
||||
# === Layout ===
|
||||
# Cycle Layout - Gaps, Floating, Tiling
|
||||
bind=SUPER+ALT,j,switch_layout
|
||||
bind=SUPER+SHIFT,equal,incgaps,1
|
||||
bind=SUPER+SHIFT,minus,incgaps,-1
|
||||
|
||||
# === Tags (1-9): view tag ===
|
||||
bind=SUPER,1,view,1
|
||||
bind=SUPER,2,view,2
|
||||
bind=SUPER,3,view,3
|
||||
bind=SUPER,4,view,4
|
||||
bind=SUPER,5,view,5
|
||||
bind=SUPER,6,view,6
|
||||
bind=SUPER,7,view,7
|
||||
bind=SUPER,8,view,8
|
||||
bind=SUPER,9,view,9
|
||||
|
||||
# === Tags (1-9): move focused window to tag ===
|
||||
bind=SUPER+SHIFT,1,tag,1
|
||||
bind=SUPER+SHIFT,2,tag,2
|
||||
bind=SUPER+SHIFT,3,tag,3
|
||||
bind=SUPER+SHIFT,4,tag,4
|
||||
bind=SUPER+SHIFT,5,tag,5
|
||||
bind=SUPER+SHIFT,6,tag,6
|
||||
bind=SUPER+SHIFT,7,tag,7
|
||||
bind=SUPER+SHIFT,8,tag,8
|
||||
bind=SUPER+SHIFT,9,tag,9
|
||||
|
||||
# === Touchpad Gestures ===
|
||||
# 3-finger horizontal swipe: switch between occupied workspaces
|
||||
gesturebind=none,right,3,viewtoleft_have_client
|
||||
gesturebind=none,left,3,viewtoright_have_client
|
||||
# 4-finger vertical swipe: toggle the overview
|
||||
gesturebind=none,up,4,toggleoverview
|
||||
gesturebind=none,down,4,toggleoverview
|
||||
@@ -0,0 +1,6 @@
|
||||
# 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
|
||||
@@ -0,0 +1,8 @@
|
||||
# Auto-generated by DMS. Regenerated from DMS settings (dms/layout.conf).
|
||||
|
||||
border_radius=12
|
||||
gappih=5
|
||||
gappiv=5
|
||||
gappoh=5
|
||||
gappov=5
|
||||
borderpx=2
|
||||
@@ -0,0 +1,18 @@
|
||||
# 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,6 +1,6 @@
|
||||
binds {
|
||||
// === System & Overview ===
|
||||
Mod+D repeat=false { toggle-overview; }
|
||||
Mod+O repeat=false { toggle-overview; }
|
||||
Mod+Tab repeat=false { toggle-overview; }
|
||||
Mod+Shift+Slash { show-hotkey-overlay; }
|
||||
|
||||
|
||||
@@ -138,11 +138,9 @@ func readExistingHyprlandConfig(configDir string) (data string, sourcePath strin
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
func CleanupStrayHyprlandConfFile(logFn func(format string, v ...any)) {
|
||||
if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") == "" {
|
||||
return
|
||||
@@ -156,19 +154,44 @@ 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 _, err := os.Stat(confPath); err != nil {
|
||||
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 {
|
||||
return
|
||||
}
|
||||
|
||||
ts := time.Now().Format("2006-01-02_15-04-05")
|
||||
dst := filepath.Join(configDir, hyprlandBackupDirName, ts, "hyprland.conf")
|
||||
if err := moveHyprlandConfigFile(confPath, dst); err != nil {
|
||||
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 {
|
||||
if logFn != nil {
|
||||
logFn("Could not move stray hyprland.conf: %v", err)
|
||||
logFn("Could not move stray Hyprland conf file %s: %v", src, err)
|
||||
}
|
||||
return
|
||||
continue
|
||||
}
|
||||
moved++
|
||||
if logFn != nil {
|
||||
logFn("Moved stray hyprland.conf to %s", dst)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
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
|
||||
@@ -35,6 +35,7 @@ type WindowManager int
|
||||
const (
|
||||
WindowManagerHyprland WindowManager = iota
|
||||
WindowManagerNiri
|
||||
WindowManagerMango
|
||||
)
|
||||
|
||||
type Terminal int
|
||||
|
||||
@@ -112,6 +112,11 @@ 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())
|
||||
|
||||
@@ -172,6 +177,11 @@ 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))
|
||||
}
|
||||
@@ -199,6 +209,9 @@ 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
|
||||
@@ -222,6 +235,13 @@ 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}
|
||||
@@ -724,7 +744,7 @@ func (a *ArchDistribution) installSingleAURPackageInternal(ctx context.Context,
|
||||
continue
|
||||
}
|
||||
seen[dep] = true
|
||||
if a.isInSystemRepo(dep) {
|
||||
if isSonameProvides(dep) || a.isInSystemRepo(dep) {
|
||||
systemPkgs = append(systemPkgs, dep)
|
||||
} else {
|
||||
aurPkgs = append(aurPkgs, dep)
|
||||
|
||||
@@ -337,6 +337,36 @@ 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",
|
||||
|
||||
@@ -77,7 +77,11 @@ func (f *FedoraDistribution) DetectDependenciesWithTerminal(ctx context.Context,
|
||||
|
||||
// Common detections using base methods
|
||||
dependencies = append(dependencies, f.detectGit())
|
||||
dependencies = append(dependencies, f.detectWindowManager(wm))
|
||||
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.detectQuickshell())
|
||||
dependencies = append(dependencies, f.detectDMSGreeter())
|
||||
dependencies = append(dependencies, f.detectXDGPortal())
|
||||
@@ -93,6 +97,11 @@ 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())
|
||||
|
||||
@@ -139,6 +148,10 @@ 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
|
||||
@@ -159,7 +172,7 @@ func (f *FedoraDistribution) getDmsMapping(variant deps.PackageVariant) PackageM
|
||||
}
|
||||
|
||||
func (f *FedoraDistribution) getHyprlandMapping(_ deps.PackageVariant) PackageMapping {
|
||||
return PackageMapping{Name: "hyprland", Repository: RepoTypeCOPR, RepoURL: "sdegler/hyprland"}
|
||||
return PackageMapping{Name: "hyprland", Repository: RepoTypeCOPR, RepoURL: "lionheartp/Hyprland"}
|
||||
}
|
||||
|
||||
func (f *FedoraDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping {
|
||||
@@ -297,6 +310,22 @@ 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{
|
||||
@@ -423,6 +452,30 @@ 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)
|
||||
|
||||
|
||||
@@ -106,6 +106,11 @@ 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())
|
||||
|
||||
@@ -176,6 +181,10 @@ 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
|
||||
@@ -197,6 +206,10 @@ 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",
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -191,6 +192,421 @@ 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 {
|
||||
@@ -264,6 +680,9 @@ func DetectCompositors() []string {
|
||||
if utils.CommandExists("Hyprland") {
|
||||
compositors = append(compositors, "Hyprland")
|
||||
}
|
||||
if utils.CommandExists("mango") {
|
||||
compositors = append(compositors, "mango")
|
||||
}
|
||||
|
||||
return compositors
|
||||
}
|
||||
@@ -572,6 +991,7 @@ 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"),
|
||||
@@ -1255,6 +1675,20 @@ 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
|
||||
}
|
||||
@@ -1719,29 +2153,10 @@ vt = 1
|
||||
commandLine := fmt.Sprintf(`command = "%s"`, commandValue)
|
||||
newConfig := upsertDefaultSession(configContent, greeterUser, commandLine)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package greeter
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -96,3 +97,147 @@ 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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,548 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
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"])
|
||||
}
|
||||
}
|
||||
@@ -364,8 +364,10 @@ 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' or 'hyprland'", r.cfg.Compositor)
|
||||
return 0, fmt.Errorf("invalid --compositor value %q: must be 'niri', 'hyprland', or 'mango'", r.cfg.Compositor)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,8 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,6 +221,9 @@ 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
|
||||
}
|
||||
@@ -242,9 +247,10 @@ func (h *HyprlandProvider) SetBind(key, action, description string, options map[
|
||||
}
|
||||
}
|
||||
|
||||
normalizedKey := strings.ToLower(key)
|
||||
canonicalKey := canonicalHyprlandOverrideKey(key)
|
||||
normalizedKey := hyprlandOverrideMapKey(canonicalKey)
|
||||
existingBinds[normalizedKey] = &hyprlandOverrideBind{
|
||||
Key: key,
|
||||
Key: canonicalKey,
|
||||
Action: action,
|
||||
Description: description,
|
||||
Flags: flags,
|
||||
@@ -255,21 +261,28 @@ 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
|
||||
}
|
||||
normalizedKey := strings.ToLower(key)
|
||||
existingBinds[normalizedKey] = &hyprlandOverrideBind{Key: key, Unbind: true}
|
||||
canonicalKey := canonicalHyprlandOverrideKey(key)
|
||||
normalizedKey := hyprlandOverrideMapKey(canonicalKey)
|
||||
existingBinds[normalizedKey] = &hyprlandOverrideBind{Key: canonicalKey, 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 := strings.ToLower(key)
|
||||
normalizedKey := hyprlandOverrideMapKey(key)
|
||||
delete(existingBinds, normalizedKey)
|
||||
return h.writeOverrideBinds(existingBinds)
|
||||
}
|
||||
@@ -284,10 +297,46 @@ 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"):
|
||||
@@ -368,24 +417,629 @@ 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 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))
|
||||
if expr, ok := luaActionStringFromKnownHyprlandAction(action); ok {
|
||||
return expr
|
||||
}
|
||||
return action
|
||||
}
|
||||
|
||||
func luaExprToInternalAction(expr string) string {
|
||||
@@ -407,7 +1061,7 @@ func luaBindOptions(bind *hyprlandOverrideBind) []string {
|
||||
if strings.Contains(bind.Flags, "e") {
|
||||
opts = append(opts, "repeating = true")
|
||||
}
|
||||
if bind.Description != "" && strings.Contains(bind.Flags, "d") {
|
||||
if bind.Description != "" {
|
||||
opts = append(opts, fmt.Sprintf("description = %s", strconv.Quote(bind.Description)))
|
||||
}
|
||||
return opts
|
||||
@@ -426,13 +1080,9 @@ 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')
|
||||
}
|
||||
|
||||
@@ -450,6 +1100,9 @@ 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,
|
||||
@@ -498,11 +1151,12 @@ func readLuaOrHyprlangOverride(path string) (map[string]*hyprlandOverrideBind, e
|
||||
continue
|
||||
}
|
||||
if key, ok := parseLuaUnbindLine(line); ok {
|
||||
pendingUnbinds[strings.ToLower(key)] = key
|
||||
pendingUnbinds[hyprlandOverrideMapKey(key)] = canonicalHyprlandOverrideKey(key)
|
||||
continue
|
||||
}
|
||||
if kb, ok := parseLuaBindOverrideLine(line); ok {
|
||||
normalizedKey := strings.ToLower(kb.Key)
|
||||
kb.Key = canonicalHyprlandOverrideKey(kb.Key)
|
||||
normalizedKey := hyprlandOverrideMapKey(kb.Key)
|
||||
binds[normalizedKey] = kb
|
||||
delete(pendingUnbinds, normalizedKey)
|
||||
continue
|
||||
@@ -520,7 +1174,8 @@ func readLuaOrHyprlangOverride(path string) (map[string]*hyprlandOverrideBind, e
|
||||
action = kb.Dispatcher + " " + kb.Params
|
||||
}
|
||||
flags := kb.Flags
|
||||
normalizedKey := strings.ToLower(keyStr)
|
||||
keyStr = canonicalHyprlandOverrideKey(keyStr)
|
||||
normalizedKey := hyprlandOverrideMapKey(keyStr)
|
||||
binds[normalizedKey] = &hyprlandOverrideBind{
|
||||
Key: keyStr,
|
||||
Action: action,
|
||||
|
||||
@@ -54,6 +54,8 @@ 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 {
|
||||
@@ -310,6 +312,8 @@ type HyprlandDMSStatus struct {
|
||||
Effective bool
|
||||
OverriddenBy int
|
||||
StatusMessage string
|
||||
ConfigFormat string
|
||||
ReadOnly bool
|
||||
}
|
||||
|
||||
func (p *HyprlandParser) buildDMSStatus() *HyprlandDMSStatus {
|
||||
@@ -319,6 +323,8 @@ func (p *HyprlandParser) buildDMSStatus() *HyprlandDMSStatus {
|
||||
IncludePosition: p.dmsIncludePos,
|
||||
TotalIncludes: p.includeCount,
|
||||
BindsAfterDMS: p.bindsAfterDMS,
|
||||
ConfigFormat: p.configFormat,
|
||||
ReadOnly: p.readOnly,
|
||||
}
|
||||
|
||||
switch {
|
||||
@@ -398,6 +404,13 @@ 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
|
||||
@@ -869,23 +882,20 @@ 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 when parentheses
|
||||
// opened from the first '(' are balanced (handles nested () and {} and double-quoted strings).
|
||||
// parseLuaFirstArgExpr parses a single Lua expression starting at i, stopping at
|
||||
// the next top-level comma. It handles nested calls/tables and inline functions.
|
||||
func parseLuaFirstArgExpr(line string, start int) (expr string, next int, ok bool) {
|
||||
start = skipLuaWS(line, start)
|
||||
if start >= len(line) {
|
||||
return "", start, false
|
||||
}
|
||||
// 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
|
||||
i := start
|
||||
parenDepth := 0
|
||||
braceDepth := 0
|
||||
bracketDepth := 0
|
||||
functionDepth := 0
|
||||
inStr := byte(0)
|
||||
esc := false
|
||||
exprStart := start
|
||||
for ; i < len(line); i++ {
|
||||
c := line[i]
|
||||
if inStr != 0 {
|
||||
@@ -902,19 +912,66 @@ 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 '(':
|
||||
depth++
|
||||
parenDepth++
|
||||
case ')':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return strings.TrimSpace(line[exprStart : i+1]), i + 1, true
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", start, false
|
||||
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')
|
||||
}
|
||||
|
||||
// parseLuaBindInvocation parses one hl.bind("KEY", expr [, opts]) on a single line.
|
||||
@@ -993,19 +1050,39 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) {
|
||||
if arg != "" {
|
||||
if u, err := strconv.Unquote(arg); err == nil {
|
||||
if strings.HasPrefix(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 splitDispatchCommand(strings.TrimSpace(strings.TrimPrefix(u, "hyprctl dispatch ")))
|
||||
}
|
||||
return "exec", u
|
||||
}
|
||||
}
|
||||
return "exec", strings.TrimSpace(strings.TrimPrefix(expr, "hl.dsp.exec_cmd"))
|
||||
case strings.Contains(expr, "hl.dsp.window.kill()"):
|
||||
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
|
||||
}
|
||||
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":
|
||||
@@ -1014,10 +1091,55 @@ 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") != "":
|
||||
@@ -1025,18 +1147,58 @@ 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") != "":
|
||||
return "movetoworkspace", luaTableStringField(expr, "workspace")
|
||||
action := "movetoworkspace"
|
||||
if follow, ok := luaTableBoolField(expr, "follow"); ok && !follow {
|
||||
action = "movetoworkspacesilent"
|
||||
}
|
||||
return joinDispatcherParams(action, luaTableStringField(expr, "workspace"), luaTableStringField(expr, "window"))
|
||||
}
|
||||
case expr == "hl.dsp.window.drag()":
|
||||
return "movewindow", ""
|
||||
@@ -1052,25 +1214,184 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) {
|
||||
if y == "" {
|
||||
y = "0"
|
||||
}
|
||||
return "resizeactive", x + " " + y
|
||||
prefix := ""
|
||||
if relative, ok := luaTableBoolField(expr, "relative"); ok && !relative {
|
||||
prefix = "exact "
|
||||
}
|
||||
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("):
|
||||
arg := extractLuaCallStringArg(expr, "hl.dsp.layout")
|
||||
if arg != "" {
|
||||
if u, err := strconv.Unquote(arg); err == nil {
|
||||
return "layoutmsg", u
|
||||
}
|
||||
if arg := luaCallStringArgValue(expr, "hl.dsp.layout"); arg != "" {
|
||||
return "layoutmsg", arg
|
||||
}
|
||||
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 "exec", "hyprctl dispatch lua:" + expr
|
||||
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
|
||||
}
|
||||
|
||||
func extractLuaCallStringArg(callExpr, funcName string) string {
|
||||
@@ -1100,10 +1421,46 @@ 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)
|
||||
@@ -1136,8 +1493,38 @@ func luaStringValue(raw string) string {
|
||||
}
|
||||
|
||||
func luaLineTrailingComment(line string) string {
|
||||
if idx := strings.Index(line, "--"); idx >= 0 {
|
||||
return strings.TrimSpace(line[idx+2:])
|
||||
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:])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -70,12 +70,37 @@ 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.resize({ x = "-10%", y = 0, relative = true })`, "resizeactive", "-10% 0"},
|
||||
{`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.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 {
|
||||
@@ -113,12 +138,132 @@ func TestWriteLuaBindLineMapsSpawnActionForHyprland(t *testing.T) {
|
||||
})
|
||||
|
||||
want := `hl.unbind("SUPER + N")
|
||||
hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle")) -- Notepad: Toggle`
|
||||
hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"), { description = "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")
|
||||
@@ -283,6 +428,64 @@ 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")
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
)
|
||||
@@ -228,11 +229,20 @@ func (m *MangoWCProvider) SetBind(key, action, description string, options map[s
|
||||
}
|
||||
|
||||
normalizedKey := strings.ToLower(key)
|
||||
prefix := "bind"
|
||||
if existing, ok := existingBinds[normalizedKey]; ok && existing.Prefix != "" {
|
||||
prefix = existing.Prefix
|
||||
}
|
||||
if optionPrefix := m.bindPrefixFromOptions(options); optionPrefix != "" {
|
||||
prefix = optionPrefix
|
||||
}
|
||||
|
||||
existingBinds[normalizedKey] = &mangowcOverrideBind{
|
||||
Key: key,
|
||||
Action: action,
|
||||
Description: description,
|
||||
Options: options,
|
||||
Prefix: prefix,
|
||||
}
|
||||
|
||||
return m.writeOverrideBinds(existingBinds)
|
||||
@@ -246,7 +256,7 @@ func (m *MangoWCProvider) RemoveBind(key string) error {
|
||||
|
||||
normalizedKey := strings.ToLower(key)
|
||||
delete(existingBinds, normalizedKey)
|
||||
return m.writeOverrideBinds(existingBinds)
|
||||
return m.writeOverrideBindsWithRemoved(existingBinds, map[string]bool{normalizedKey: true})
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) ResetBind(key string) error {
|
||||
@@ -258,6 +268,7 @@ type mangowcOverrideBind struct {
|
||||
Action string
|
||||
Description string
|
||||
Options map[string]any
|
||||
Prefix string
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) loadOverrideBinds() (map[string]*mangowcOverrideBind, error) {
|
||||
@@ -272,34 +283,63 @@ func (m *MangoWCProvider) loadOverrideBinds() (map[string]*mangowcOverrideBind,
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
var pendingComment string
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
pendingComment = ""
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "#") {
|
||||
pendingComment = strings.TrimSpace(strings.TrimPrefix(trimmed, "#"))
|
||||
if isMangoWCSectionComment(pendingComment) {
|
||||
pendingComment = ""
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(line, "bind") {
|
||||
bind, ok := m.parseOverrideBindLine(line, pendingComment)
|
||||
pendingComment = ""
|
||||
if !ok || bind == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
binds[strings.ToLower(bind.Key)] = bind
|
||||
}
|
||||
|
||||
return binds, nil
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) parseOverrideBindLine(line, precedingComment string) (*mangowcOverrideBind, bool) {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
parts := strings.SplitN(trimmed, "=", 2)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
return nil, false
|
||||
}
|
||||
|
||||
prefix := strings.TrimSpace(parts[0])
|
||||
if !m.isBindPrefix(prefix) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
content := strings.TrimSpace(parts[1])
|
||||
commentParts := strings.SplitN(content, "#", 2)
|
||||
bindContent := strings.TrimSpace(commentParts[0])
|
||||
|
||||
var comment string
|
||||
description := strings.TrimSpace(precedingComment)
|
||||
if isMangoWCSectionComment(description) {
|
||||
description = ""
|
||||
}
|
||||
if len(commentParts) > 1 {
|
||||
comment = strings.TrimSpace(commentParts[1])
|
||||
description = strings.TrimSpace(commentParts[1])
|
||||
}
|
||||
if strings.HasPrefix(description, MangoWCHideComment) {
|
||||
return nil, true
|
||||
}
|
||||
|
||||
fields := strings.SplitN(bindContent, ",", 4)
|
||||
if len(fields) < 3 {
|
||||
continue
|
||||
return nil, false
|
||||
}
|
||||
|
||||
mods := strings.TrimSpace(fields[0])
|
||||
@@ -311,21 +351,29 @@ func (m *MangoWCProvider) loadOverrideBinds() (map[string]*mangowcOverrideBind,
|
||||
params = strings.TrimSpace(fields[3])
|
||||
}
|
||||
|
||||
keyStr := m.buildKeyString(mods, keyName)
|
||||
normalizedKey := strings.ToLower(keyStr)
|
||||
action := command
|
||||
if params != "" {
|
||||
action = command + " " + params
|
||||
}
|
||||
|
||||
binds[normalizedKey] = &mangowcOverrideBind{
|
||||
Key: keyStr,
|
||||
return &mangowcOverrideBind{
|
||||
Key: m.buildKeyString(mods, keyName),
|
||||
Action: action,
|
||||
Description: comment,
|
||||
}
|
||||
Description: description,
|
||||
Prefix: prefix,
|
||||
}, true
|
||||
}
|
||||
|
||||
return binds, nil
|
||||
func (m *MangoWCProvider) isBindPrefix(prefix string) bool {
|
||||
if !strings.HasPrefix(prefix, "bind") {
|
||||
return false
|
||||
}
|
||||
for _, ch := range strings.TrimPrefix(prefix, "bind") {
|
||||
if !strings.ContainsRune("lsrp", ch) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) buildKeyString(mods, key string) string {
|
||||
@@ -362,21 +410,113 @@ func (m *MangoWCProvider) getBindSortPriority(action string) int {
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) writeOverrideBinds(binds map[string]*mangowcOverrideBind) error {
|
||||
return m.writeOverrideBindsWithRemoved(binds, nil)
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) writeOverrideBindsWithRemoved(binds map[string]*mangowcOverrideBind, removed map[string]bool) error {
|
||||
overridePath := m.GetOverridePath()
|
||||
content := m.generateBindsContent(binds)
|
||||
existingContent := ""
|
||||
if data, err := os.ReadFile(overridePath); err == nil {
|
||||
existingContent = string(data)
|
||||
}
|
||||
|
||||
content := m.generatePreservedBindsContent(existingContent, binds, removed)
|
||||
return os.WriteFile(overridePath, []byte(content), 0o644)
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) generateBindsContent(binds map[string]*mangowcOverrideBind) string {
|
||||
if len(binds) == 0 {
|
||||
return ""
|
||||
func (m *MangoWCProvider) generatePreservedBindsContent(existingContent string, binds map[string]*mangowcOverrideBind, removed map[string]bool) string {
|
||||
useStockScaffold := m.shouldUseStockScaffold(existingContent)
|
||||
source := existingContent
|
||||
if useStockScaffold {
|
||||
source = m.stockBindsScaffold(binds)
|
||||
}
|
||||
|
||||
remaining := make(map[string]*mangowcOverrideBind, len(binds))
|
||||
for key, bind := range binds {
|
||||
remaining[key] = bind
|
||||
}
|
||||
if useStockScaffold {
|
||||
m.dropReplacedStockBinds(remaining)
|
||||
}
|
||||
|
||||
var lines []string
|
||||
for _, line := range strings.Split(source, "\n") {
|
||||
templateBind, ok := m.parseOverrideBindLine(line, m.previousComment(lines))
|
||||
if !ok || templateBind == nil {
|
||||
lines = append(lines, line)
|
||||
continue
|
||||
}
|
||||
|
||||
normalizedKey := strings.ToLower(templateBind.Key)
|
||||
m.dropPreviousDescriptionComment(&lines)
|
||||
|
||||
if bind, exists := remaining[normalizedKey]; exists {
|
||||
if useStockScaffold && bind.Description == "" {
|
||||
bind = m.copyBindWithDescription(bind, templateBind.Description)
|
||||
}
|
||||
m.writeBindLineToLines(&lines, bind)
|
||||
delete(remaining, normalizedKey)
|
||||
continue
|
||||
}
|
||||
|
||||
if useStockScaffold && !removed[normalizedKey] {
|
||||
m.writeBindLineToLines(&lines, templateBind)
|
||||
}
|
||||
}
|
||||
|
||||
if len(remaining) > 0 {
|
||||
m.trimTrailingEmptyLines(&lines)
|
||||
if len(lines) > 0 {
|
||||
lines = append(lines, "")
|
||||
}
|
||||
lines = append(lines, "# === Custom Keybinds ===")
|
||||
for _, bind := range m.sortedBinds(remaining) {
|
||||
m.writeBindLineToLines(&lines, bind)
|
||||
}
|
||||
}
|
||||
|
||||
m.trimTrailingEmptyLines(&lines)
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(lines, "\n") + "\n"
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) shouldUseStockScaffold(content string) bool {
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(content, "gesturebind=") && strings.Contains(content, "# ===") {
|
||||
return false
|
||||
}
|
||||
return !strings.Contains(content, "gesturebind=") && (strings.Count(content, "\nbind=")+strings.Count(content, "\nbindl=")+strings.Count(content, "\nbinds=")+strings.Count(content, "\nbindr=")+strings.Count(content, "\nbindp=") >= 10 || strings.Contains(content, "dms ipc call"))
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) stockBindsScaffold(binds map[string]*mangowcOverrideBind) string {
|
||||
terminalCommand := "ghostty"
|
||||
for _, key := range []string{"super+t", "super+return"} {
|
||||
if bind, ok := binds[key]; ok {
|
||||
command, params := m.parseAction(bind.Action)
|
||||
if command == "spawn" && strings.TrimSpace(params) != "" && !strings.Contains(params, "dms ") {
|
||||
terminalCommand = params
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.ReplaceAll(config.MangoBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) dropReplacedStockBinds(binds map[string]*mangowcOverrideBind) {
|
||||
if bind, ok := binds["super+j"]; ok && bind.Action == "switch_layout" {
|
||||
delete(binds, "super+j")
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) sortedBinds(binds map[string]*mangowcOverrideBind) []*mangowcOverrideBind {
|
||||
bindList := make([]*mangowcOverrideBind, 0, len(binds))
|
||||
for _, bind := range binds {
|
||||
bindList = append(bindList, bind)
|
||||
}
|
||||
|
||||
sort.Slice(bindList, func(i, j int) bool {
|
||||
pi, pj := m.getBindSortPriority(bindList[i].Action), m.getBindSortPriority(bindList[j].Action)
|
||||
if pi != pj {
|
||||
@@ -384,20 +524,75 @@ func (m *MangoWCProvider) generateBindsContent(binds map[string]*mangowcOverride
|
||||
}
|
||||
return bindList[i].Key < bindList[j].Key
|
||||
})
|
||||
|
||||
var sb strings.Builder
|
||||
for _, bind := range bindList {
|
||||
m.writeBindLine(&sb, bind)
|
||||
return bindList
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
func (m *MangoWCProvider) writeBindLineToLines(lines *[]string, bind *mangowcOverrideBind) {
|
||||
var sb strings.Builder
|
||||
m.writeBindLine(&sb, bind)
|
||||
text := strings.TrimSuffix(sb.String(), "\n")
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
*lines = append(*lines, strings.Split(text, "\n")...)
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) previousComment(lines []string) string {
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
trimmed := strings.TrimSpace(lines[len(lines)-1])
|
||||
if !strings.HasPrefix(trimmed, "#") {
|
||||
return ""
|
||||
}
|
||||
comment := strings.TrimSpace(strings.TrimPrefix(trimmed, "#"))
|
||||
if isMangoWCSectionComment(comment) {
|
||||
return ""
|
||||
}
|
||||
return comment
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) dropPreviousDescriptionComment(lines *[]string) {
|
||||
if len(*lines) == 0 {
|
||||
return
|
||||
}
|
||||
trimmed := strings.TrimSpace((*lines)[len(*lines)-1])
|
||||
if !strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "# ===") {
|
||||
return
|
||||
}
|
||||
*lines = (*lines)[:len(*lines)-1]
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) trimTrailingEmptyLines(lines *[]string) {
|
||||
for len(*lines) > 0 && strings.TrimSpace((*lines)[len(*lines)-1]) == "" {
|
||||
*lines = (*lines)[:len(*lines)-1]
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) copyBindWithDescription(bind *mangowcOverrideBind, description string) *mangowcOverrideBind {
|
||||
copy := *bind
|
||||
copy.Description = description
|
||||
return ©
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) writeBindLine(sb *strings.Builder, bind *mangowcOverrideBind) {
|
||||
mods, key := m.parseKeyString(bind.Key)
|
||||
command, params := m.parseAction(bind.Action)
|
||||
|
||||
sb.WriteString("bind=")
|
||||
// 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")
|
||||
}
|
||||
|
||||
prefix := bind.Prefix
|
||||
if prefix == "" {
|
||||
prefix = "bind"
|
||||
}
|
||||
sb.WriteString(prefix)
|
||||
sb.WriteString("=")
|
||||
if mods == "" {
|
||||
sb.WriteString("none")
|
||||
} else {
|
||||
@@ -413,12 +608,37 @@ func (m *MangoWCProvider) writeBindLine(sb *strings.Builder, bind *mangowcOverri
|
||||
sb.WriteString(params)
|
||||
}
|
||||
|
||||
if bind.Description != "" {
|
||||
sb.WriteString(" # ")
|
||||
sb.WriteString(bind.Description)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
sb.WriteString("\n")
|
||||
func (m *MangoWCProvider) bindPrefixFromOptions(options map[string]any) string {
|
||||
if options == nil {
|
||||
return ""
|
||||
}
|
||||
value, ok := options["flags"]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
flags := ""
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
flags = v
|
||||
case fmt.Stringer:
|
||||
flags = v.String()
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
flags = strings.TrimSpace(flags)
|
||||
if flags == "" {
|
||||
return "bind"
|
||||
}
|
||||
var clean strings.Builder
|
||||
for _, ch := range flags {
|
||||
if strings.ContainsRune("lsrp", ch) && !strings.ContainsRune(clean.String(), ch) {
|
||||
clean.WriteRune(ch)
|
||||
}
|
||||
}
|
||||
return "bind" + clean.String()
|
||||
}
|
||||
|
||||
func (m *MangoWCProvider) parseKeyString(keyStr string) (mods, key string) {
|
||||
|
||||
@@ -15,6 +15,10 @@ const (
|
||||
|
||||
var MangoWCModSeparators = []rune{'+', ' '}
|
||||
|
||||
func isMangoWCSectionComment(comment string) bool {
|
||||
return strings.HasPrefix(strings.TrimSpace(comment), "===")
|
||||
}
|
||||
|
||||
type MangoWCKeyBinding struct {
|
||||
Mods []string `json:"mods"`
|
||||
Key string `json:"key"`
|
||||
@@ -216,101 +220,40 @@ func mangowcAutogenerateComment(command, params string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func (p *MangoWCParser) getKeybindAtLine(lineNumber int) *MangoWCKeyBinding {
|
||||
func (p *MangoWCParser) getKeybindAtLine(lineNumber int, precedingComment string) *MangoWCKeyBinding {
|
||||
if lineNumber >= len(p.contentLines) {
|
||||
return nil
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
return p.getKeybindAtLineContent(p.contentLines[lineNumber], precedingComment)
|
||||
}
|
||||
|
||||
func (p *MangoWCParser) ParseKeys() []MangoWCKeyBinding {
|
||||
var keybinds []MangoWCKeyBinding
|
||||
var pendingComment string
|
||||
|
||||
for lineNumber := 0; lineNumber < len(p.contentLines); lineNumber++ {
|
||||
line := p.contentLines[lineNumber]
|
||||
if line == "" || strings.HasPrefix(strings.TrimSpace(line), "#") {
|
||||
trimmed := strings.TrimSpace(p.contentLines[lineNumber])
|
||||
if trimmed == "" {
|
||||
pendingComment = ""
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "#") {
|
||||
pendingComment = strings.TrimSpace(strings.TrimPrefix(trimmed, "#"))
|
||||
if isMangoWCSectionComment(pendingComment) {
|
||||
pendingComment = ""
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(trimmed, "bind") {
|
||||
pendingComment = ""
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(strings.TrimSpace(line), "bind") {
|
||||
continue
|
||||
}
|
||||
|
||||
keybind := p.getKeybindAtLine(lineNumber)
|
||||
keybind := p.getKeybindAtLine(lineNumber, pendingComment)
|
||||
if keybind != nil {
|
||||
keybinds = append(keybinds, *keybind)
|
||||
}
|
||||
pendingComment = ""
|
||||
}
|
||||
|
||||
return keybinds
|
||||
@@ -459,21 +402,38 @@ func (p *MangoWCParser) parseFileWithSource(filePath string) ([]MangoWCKeyBindin
|
||||
p.currentSource = absPath
|
||||
|
||||
var keybinds []MangoWCKeyBinding
|
||||
var pendingComment string
|
||||
lines := strings.Split(string(data), "\n")
|
||||
|
||||
for lineNum, line := range lines {
|
||||
for _, 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, "#"))
|
||||
if isMangoWCSectionComment(pendingComment) {
|
||||
pendingComment = ""
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(trimmed, "bind") {
|
||||
pendingComment = ""
|
||||
continue
|
||||
}
|
||||
|
||||
kb := p.getKeybindAtLineContent(line, lineNum)
|
||||
kb := p.getKeybindAtLineContent(line, pendingComment)
|
||||
pendingComment = ""
|
||||
if kb == nil {
|
||||
continue
|
||||
}
|
||||
@@ -529,8 +489,11 @@ func (p *MangoWCParser) parseDMSBindsDirectly(dmsBindsPath string) []MangoWCKeyB
|
||||
return keybinds
|
||||
}
|
||||
|
||||
func (p *MangoWCParser) getKeybindAtLineContent(line string, _ int) *MangoWCKeyBinding {
|
||||
bindMatch := regexp.MustCompile(`^(bind[lsr]*)\s*=\s*(.+)$`)
|
||||
// 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 {
|
||||
bindMatch := regexp.MustCompile(`^(bind[lsrp]*)\s*=\s*(.+)$`)
|
||||
matches := bindMatch.FindStringSubmatch(line)
|
||||
if len(matches) < 3 {
|
||||
return nil
|
||||
@@ -544,6 +507,12 @@ func (p *MangoWCParser) getKeybindAtLineContent(line string, _ int) *MangoWCKeyB
|
||||
if len(parts) > 1 {
|
||||
comment = strings.TrimSpace(parts[1])
|
||||
}
|
||||
if comment == "" {
|
||||
comment = strings.TrimSpace(precedingComment)
|
||||
if isMangoWCSectionComment(comment) {
|
||||
comment = ""
|
||||
}
|
||||
}
|
||||
|
||||
if strings.HasPrefix(comment, MangoWCHideComment) {
|
||||
return nil
|
||||
|
||||
@@ -73,6 +73,7 @@ func TestMangoWCGetKeybindAtLine(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
line string
|
||||
precedingComment string
|
||||
expected *MangoWCKeyBinding
|
||||
}{
|
||||
{
|
||||
@@ -157,6 +158,41 @@ func TestMangoWCGetKeybindAtLine(t *testing.T) {
|
||||
Comment: "dms ipc call lock lock",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bindp_flag",
|
||||
line: "bindp=SUPER,p,spawn,pass-through",
|
||||
expected: &MangoWCKeyBinding{
|
||||
Mods: []string{"SUPER"},
|
||||
Key: "p",
|
||||
Command: "spawn",
|
||||
Params: "pass-through",
|
||||
Comment: "pass-through",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "preceding_comment",
|
||||
line: "bind=SUPER+SHIFT,S,spawn,dms screenshot",
|
||||
precedingComment: "Screenshot: Interactive",
|
||||
expected: &MangoWCKeyBinding{
|
||||
Mods: []string{"SUPER", "SHIFT"},
|
||||
Key: "S",
|
||||
Command: "spawn",
|
||||
Params: "dms screenshot",
|
||||
Comment: "Screenshot: Interactive",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "section_header_not_description",
|
||||
line: "bind=none,XF86AudioRaiseVolume,spawn,dms ipc call audio increment 3",
|
||||
precedingComment: "=== Audio Controls ===",
|
||||
expected: &MangoWCKeyBinding{
|
||||
Mods: []string{},
|
||||
Key: "XF86AudioRaiseVolume",
|
||||
Command: "spawn",
|
||||
Params: "dms ipc call audio increment 3",
|
||||
Comment: "dms ipc call audio increment 3",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "keybind_with_spaces",
|
||||
line: "bind = SUPER, r, reload_config",
|
||||
@@ -174,7 +210,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, tt.precedingComment)
|
||||
|
||||
if tt.expected == nil {
|
||||
if result != nil {
|
||||
@@ -421,7 +457,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)
|
||||
|
||||
@@ -3,7 +3,10 @@ package providers
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
|
||||
)
|
||||
|
||||
func TestMangoWCProviderName(t *testing.T) {
|
||||
@@ -318,3 +321,138 @@ bind=Ctrl,1,view,1,0
|
||||
t.Error("Did not find terminal keybind with correct key and description")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMangoWCSetBindPreservesStockCommentsAndGestures(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dmsDir := filepath.Join(tmpDir, "dms")
|
||||
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
||||
t.Fatalf("failed to create dms dir: %v", err)
|
||||
}
|
||||
bindsPath := filepath.Join(dmsDir, "binds.conf")
|
||||
stock := strings.ReplaceAll(config.MangoBindsConfig, "{{TERMINAL_COMMAND}}", "ghostty")
|
||||
if err := os.WriteFile(bindsPath, []byte(stock), 0o644); err != nil {
|
||||
t.Fatalf("failed to write stock binds: %v", err)
|
||||
}
|
||||
|
||||
provider := NewMangoWCProvider(tmpDir)
|
||||
if err := provider.SetBind("SUPER+SHIFT+S", "spawn dms screenshot", "Screenshot: Interactive", nil); err != nil {
|
||||
t.Fatalf("SetBind failed: %v", err)
|
||||
}
|
||||
|
||||
contentBytes, err := os.ReadFile(bindsPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read binds: %v", err)
|
||||
}
|
||||
content := string(contentBytes)
|
||||
|
||||
for _, want := range []string{
|
||||
"# === Application Launchers ===",
|
||||
"# === Touchpad Gestures ===",
|
||||
"gesturebind=none,right,3,viewtoleft_have_client",
|
||||
"gesturebind=none,left,3,viewtoright_have_client",
|
||||
"# Screenshot: Interactive\nbind=SUPER+SHIFT,S,spawn,dms screenshot",
|
||||
} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Fatalf("expected saved binds to contain %q\ncontent:\n%s", want, content)
|
||||
}
|
||||
}
|
||||
if strings.Contains(content, "# === Audio Controls ===\n# === Audio Controls ===") {
|
||||
t.Fatalf("section header should not be duplicated as a bind description\ncontent:\n%s", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMangoWCSetBindRestoresScaffoldForStrippedFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dmsDir := filepath.Join(tmpDir, "dms")
|
||||
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
||||
t.Fatalf("failed to create dms dir: %v", err)
|
||||
}
|
||||
bindsPath := filepath.Join(dmsDir, "binds.conf")
|
||||
stripped := `bind=SUPER,t,spawn,ghostty
|
||||
bind=SUPER,Return,spawn,ghostty
|
||||
bind=SUPER,space,spawn,dms ipc call spotlight toggle
|
||||
bind=SUPER,v,spawn,dms ipc call clipboard toggle
|
||||
bind=SUPER,q,killclient
|
||||
bind=SUPER,Left,focusdir,left
|
||||
bind=SUPER,Right,focusdir,right
|
||||
bind=SUPER,Up,focusdir,up
|
||||
bind=SUPER,Down,focusdir,down
|
||||
bind=SUPER,1,view,1
|
||||
bind=SUPER,2,view,2
|
||||
bind=SUPER,3,view,3
|
||||
`
|
||||
if err := os.WriteFile(bindsPath, []byte(stripped), 0o644); err != nil {
|
||||
t.Fatalf("failed to write stripped binds: %v", err)
|
||||
}
|
||||
|
||||
provider := NewMangoWCProvider(tmpDir)
|
||||
if err := provider.SetBind("SUPER+SHIFT+S", "spawn dms screenshot", "Screenshot: Interactive", nil); err != nil {
|
||||
t.Fatalf("SetBind failed: %v", err)
|
||||
}
|
||||
|
||||
contentBytes, err := os.ReadFile(bindsPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read binds: %v", err)
|
||||
}
|
||||
content := string(contentBytes)
|
||||
|
||||
for _, want := range []string{
|
||||
"# DMS default keybinds (MangoWM)",
|
||||
"# === Touchpad Gestures ===",
|
||||
"gesturebind=none,right,3,viewtoleft_have_client",
|
||||
"bind=SUPER,H,focusdir,left",
|
||||
"bind=SUPER,J,focusdir,down",
|
||||
"bind=SUPER,K,focusdir,up",
|
||||
"bind=SUPER,L,focusdir,right",
|
||||
"# === Custom Keybinds ===",
|
||||
"# Screenshot: Interactive\nbind=SUPER+SHIFT,S,spawn,dms screenshot",
|
||||
"bind=SUPER,t,spawn,ghostty",
|
||||
} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Fatalf("expected restored binds to contain %q\ncontent:\n%s", want, content)
|
||||
}
|
||||
}
|
||||
if strings.Contains(content, "{{TERMINAL_COMMAND}}") {
|
||||
t.Fatalf("terminal placeholder should have been resolved\ncontent:\n%s", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMangoWCRemoveBindPreservesNonBindLines(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dmsDir := filepath.Join(tmpDir, "dms")
|
||||
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
||||
t.Fatalf("failed to create dms dir: %v", err)
|
||||
}
|
||||
bindsPath := filepath.Join(dmsDir, "binds.conf")
|
||||
stock := strings.ReplaceAll(config.MangoBindsConfig, "{{TERMINAL_COMMAND}}", "ghostty")
|
||||
if err := os.WriteFile(bindsPath, []byte(stock), 0o644); err != nil {
|
||||
t.Fatalf("failed to write stock binds: %v", err)
|
||||
}
|
||||
|
||||
provider := NewMangoWCProvider(tmpDir)
|
||||
if err := provider.RemoveBind("SUPER+Tab"); err != nil {
|
||||
t.Fatalf("RemoveBind failed: %v", err)
|
||||
}
|
||||
|
||||
contentBytes, err := os.ReadFile(bindsPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read binds: %v", err)
|
||||
}
|
||||
content := string(contentBytes)
|
||||
|
||||
if strings.Contains(content, "bind=SUPER,Tab,focusstack,next") {
|
||||
t.Fatalf("removed bind should be absent\ncontent:\n%s", content)
|
||||
}
|
||||
if strings.Contains(content, "# Focus Next Window") {
|
||||
t.Fatalf("removed bind description should be absent\ncontent:\n%s", content)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"# === Focus Navigation ===",
|
||||
"# === Touchpad Gestures ===",
|
||||
"gesturebind=none,down,4,toggleoverview",
|
||||
} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Fatalf("expected non-bind line %q to be preserved\ncontent:\n%s", want, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ 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,6 +2,7 @@ package clipboard
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
@@ -73,6 +74,10 @@ 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
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package clipboard
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
@@ -34,6 +35,8 @@ 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",
|
||||
@@ -572,16 +575,16 @@ func (m *Manager) hasSensitiveMimeType(mimes []string) bool {
|
||||
func (m *Manager) selectMimeType(mimes []string) string {
|
||||
preferredTypes := []string{
|
||||
"text/uri-list",
|
||||
"text/plain;charset=utf-8",
|
||||
"text/plain",
|
||||
"UTF8_STRING",
|
||||
"STRING",
|
||||
"TEXT",
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/bmp",
|
||||
"image/tiff",
|
||||
"text/plain;charset=utf-8",
|
||||
"text/plain",
|
||||
"UTF8_STRING",
|
||||
"STRING",
|
||||
"TEXT",
|
||||
}
|
||||
|
||||
for _, pref := range preferredTypes {
|
||||
@@ -764,9 +767,25 @@ 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
|
||||
@@ -854,7 +873,7 @@ func (m *Manager) GetEntry(id uint64) (*Entry, error) {
|
||||
return nil, err
|
||||
}
|
||||
if !found {
|
||||
return nil, fmt.Errorf("entry not found")
|
||||
return nil, errEntryNotFound
|
||||
}
|
||||
|
||||
return &entry, nil
|
||||
|
||||
@@ -1,17 +1,52 @@
|
||||
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,
|
||||
@@ -131,11 +166,113 @@ func TestStateEqual_HistoryLengthDiffers(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestStateEqual_BothEqual(t *testing.T) {
|
||||
a := &State{Enabled: true, History: []Entry{{ID: 1}, {ID: 2}}}
|
||||
b := &State{Enabled: true, History: []Entry{{ID: 3}, {ID: 4}}}
|
||||
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}}
|
||||
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),
|
||||
@@ -410,6 +547,8 @@ 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,16 +27,19 @@ type linkInfo struct {
|
||||
}
|
||||
|
||||
func (l *linkInfo) isWired() bool {
|
||||
if looksVirtual(l.name) {
|
||||
return false
|
||||
}
|
||||
if l.linkType != "" {
|
||||
return l.linkType == "ether"
|
||||
}
|
||||
if looksVirtual(l.name) || strings.HasPrefix(l.name, "wlan") || strings.HasPrefix(l.name, "wlp") {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
return !strings.HasPrefix(l.name, "wlan") && !strings.HasPrefix(l.name, "wlp")
|
||||
}
|
||||
|
||||
func (l *linkInfo) isWireless() bool {
|
||||
if looksVirtual(l.name) {
|
||||
return false
|
||||
}
|
||||
if l.linkType != "" {
|
||||
return l.linkType == "wlan"
|
||||
}
|
||||
@@ -45,7 +48,7 @@ func (l *linkInfo) isWireless() bool {
|
||||
|
||||
func looksVirtual(name string) bool {
|
||||
virtualPrefixes := []string{
|
||||
"lo", "docker", "veth", "virbr", "br-", "vnet", "tun", "tap",
|
||||
"lo", "docker", "podman", "veth", "virbr", "br-", "vnet", "tun", "tap",
|
||||
"vboxnet", "vmnet", "kube", "cni", "flannel", "cali",
|
||||
}
|
||||
for _, prefix := range virtualPrefixes {
|
||||
@@ -110,6 +113,12 @@ 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)
|
||||
|
||||
@@ -123,25 +132,48 @@ 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)
|
||||
|
||||
for _, l := range links {
|
||||
if existing, ok := b.links[l.Name]; ok && existing.path == l.Path {
|
||||
existing.ifindex = l.Ifindex
|
||||
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
|
||||
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)
|
||||
}
|
||||
|
||||
return nil
|
||||
for name := range b.links {
|
||||
if !present[name] {
|
||||
log.Debugf("networkd: pruned stale link %s", name)
|
||||
delete(b.links, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fetchLinkType queries networkd's Describe method and extracts the link Type
|
||||
|
||||
@@ -160,6 +160,12 @@ 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},
|
||||
@@ -205,8 +211,46 @@ 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"}
|
||||
virtual := []string{"lo", "docker0", "veth123", "virbr0", "br-abc", "vnet0", "tun0", "tap0", "vboxnet0", "vmnet1", "kube-ipvs0", "cni0", "flannel.1", "cali-abc", "podman0", "podman3"}
|
||||
for _, n := range virtual {
|
||||
assert.True(t, looksVirtual(n), "%s should look virtual", n)
|
||||
}
|
||||
|
||||
@@ -418,6 +418,7 @@ 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()
|
||||
|
||||
|
||||
@@ -103,15 +103,7 @@ 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
|
||||
var wm deps.WindowManager
|
||||
switch m.selectedWM {
|
||||
case 0:
|
||||
wm = deps.WindowManagerNiri
|
||||
case 1:
|
||||
wm = deps.WindowManagerHyprland
|
||||
default:
|
||||
wm = deps.WindowManagerNiri
|
||||
}
|
||||
wm := m.selectedWindowManager()
|
||||
|
||||
// Determine the selected terminal
|
||||
var terminal deps.Terminal
|
||||
@@ -288,7 +280,8 @@ func (m Model) checkExistingConfigurations() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
var configs []ExistingConfigInfo
|
||||
|
||||
if m.selectedWM == 0 {
|
||||
switch m.selectedWindowManager() {
|
||||
case deps.WindowManagerNiri:
|
||||
niriPath := filepath.Join(os.Getenv("HOME"), ".config", "niri", "config.kdl")
|
||||
niriExists := false
|
||||
if _, err := os.Stat(niriPath); err == nil {
|
||||
@@ -299,7 +292,23 @@ func (m Model) checkExistingConfigurations() tea.Cmd {
|
||||
Path: niriPath,
|
||||
Exists: niriExists,
|
||||
})
|
||||
} else {
|
||||
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:
|
||||
hyprlandLuaPath := filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.lua")
|
||||
hyprlandConfPath := filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf")
|
||||
hyprlandPath := hyprlandLuaPath
|
||||
|
||||
@@ -209,12 +209,7 @@ func (m Model) installPackages() tea.Cmd {
|
||||
}
|
||||
|
||||
// Convert TUI selection to deps enum
|
||||
var wm deps.WindowManager
|
||||
if m.selectedWM == 0 {
|
||||
wm = deps.WindowManagerNiri
|
||||
} else {
|
||||
wm = deps.WindowManagerHyprland
|
||||
}
|
||||
wm := m.selectedWindowManager()
|
||||
|
||||
installerProgressChan := make(chan distros.InstallProgressMsg, 100)
|
||||
|
||||
@@ -245,8 +240,11 @@ func (m Model) installPackages() tea.Cmd {
|
||||
}
|
||||
if greeterSelected {
|
||||
compositorName := "niri"
|
||||
if m.selectedWM == 1 {
|
||||
switch m.selectedWindowManager() {
|
||||
case deps.WindowManagerHyprland:
|
||||
compositorName = "Hyprland"
|
||||
case deps.WindowManagerMango:
|
||||
compositorName = "mango"
|
||||
}
|
||||
m.packageProgressChan <- packageInstallProgressMsg{
|
||||
progress: 0.92,
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
@@ -65,7 +66,7 @@ func (m Model) updateGentooUseFlagsState(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.skipGentooUseFlags = !m.skipGentooUseFlags
|
||||
return m, nil
|
||||
case "enter":
|
||||
if m.selectedWM == 1 {
|
||||
if m.selectedWindowManager() == deps.WindowManagerHyprland {
|
||||
return m, m.checkGCCVersion()
|
||||
}
|
||||
return m.enterAuthPhase()
|
||||
|
||||
@@ -199,8 +199,21 @@ 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.\nIf you do not have a greeter, login with \"niri-session\" or \"Hyprland\"")
|
||||
info := m.styles.Normal.Render("Your system is ready! Log out and log back in to start using\nyour new desktop environment.\n" + loginHint)
|
||||
b.WriteString(info)
|
||||
b.WriteString("\n\n")
|
||||
|
||||
@@ -209,8 +222,13 @@ 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,6 +10,26 @@ 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
|
||||
|
||||
@@ -34,6 +54,11 @@ 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)
|
||||
@@ -152,10 +177,7 @@ 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 := 1
|
||||
if m.osInfo != nil && m.osInfo.Distribution.ID == "debian" {
|
||||
maxWMIndex = 0
|
||||
}
|
||||
maxWMIndex := len(m.windowManagerOptions()) - 1
|
||||
|
||||
switch keyMsg.String() {
|
||||
case "up":
|
||||
@@ -190,12 +212,7 @@ func (m Model) detectDependencies() tea.Cmd {
|
||||
}
|
||||
|
||||
// Convert TUI selection to deps enum
|
||||
var wm deps.WindowManager
|
||||
if m.selectedWM == 0 {
|
||||
wm = deps.WindowManagerNiri // First option is Niri
|
||||
} else {
|
||||
wm = deps.WindowManagerHyprland // Second option is Hyprland
|
||||
}
|
||||
wm := m.selectedWindowManager()
|
||||
|
||||
var terminal deps.Terminal
|
||||
if m.osInfo != nil && m.osInfo.Distribution.ID == "gentoo" {
|
||||
|
||||
@@ -44,6 +44,8 @@ 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
|
||||
@@ -82,10 +84,15 @@ 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 {
|
||||
@@ -300,6 +307,8 @@ func (p *HyprlandRulesParser) buildDMSStatus() *windowrules.DMSRulesStatus {
|
||||
IncludePosition: p.dmsIncludePos,
|
||||
TotalIncludes: p.includeCount,
|
||||
RulesAfterDMS: p.rulesAfterDMS,
|
||||
ConfigFormat: p.configFormat,
|
||||
ReadOnly: p.readOnly,
|
||||
}
|
||||
|
||||
switch {
|
||||
@@ -451,6 +460,9 @@ 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{}
|
||||
@@ -472,6 +484,9 @@ 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
|
||||
@@ -488,6 +503,9 @@ 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
|
||||
@@ -513,6 +531,29 @@ 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=(.*)$`)
|
||||
|
||||
@@ -758,11 +799,7 @@ func (p *HyprlandWritableProvider) loadDMSRulesFromLua(data []byte, rulesPath st
|
||||
Actions: *acts,
|
||||
}
|
||||
if wr.ID == "" {
|
||||
if wr.MatchCriteria.AppID != "" {
|
||||
wr.ID = wr.MatchCriteria.AppID
|
||||
} else {
|
||||
wr.ID = wr.MatchCriteria.Title
|
||||
}
|
||||
wr.ID = fmt.Sprintf("dms_rule_%d", len(rules))
|
||||
}
|
||||
rules = append(rules, wr)
|
||||
}
|
||||
|
||||
@@ -188,6 +188,27 @@ 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)
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
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,6 +14,18 @@ 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
|
||||
@@ -24,6 +36,7 @@ type NiriWindowRule struct {
|
||||
MatchIsWindowCastTarget *bool
|
||||
MatchIsUrgent *bool
|
||||
MatchAtStartup *bool
|
||||
Matches []NiriMatch
|
||||
Opacity *float64
|
||||
OpenFloating *bool
|
||||
OpenMaximized *bool
|
||||
@@ -50,6 +63,13 @@ 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
|
||||
}
|
||||
|
||||
@@ -191,7 +211,7 @@ func (p *NiriRulesParser) parseWindowRuleNode(node *document.Node) {
|
||||
|
||||
switch childName {
|
||||
case "match":
|
||||
p.parseMatchNode(child, &rule)
|
||||
rule.Matches = append(rule.Matches, p.parseMatchNode(child))
|
||||
case "opacity":
|
||||
if len(child.Arguments) > 0 {
|
||||
val := child.Arguments[0].ResolvedValue()
|
||||
@@ -297,9 +317,26 @@ 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)
|
||||
}
|
||||
|
||||
@@ -326,45 +363,47 @@ func (p *NiriRulesParser) parseSizeNode(node *document.Node) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (p *NiriRulesParser) parseMatchNode(node *document.Node, rule *NiriWindowRule) {
|
||||
func (p *NiriRulesParser) parseMatchNode(node *document.Node) NiriMatch {
|
||||
m := NiriMatch{}
|
||||
if node.Properties == nil {
|
||||
return
|
||||
return m
|
||||
}
|
||||
|
||||
if val, ok := node.Properties.Get("app-id"); ok {
|
||||
rule.MatchAppID = val.ValueString()
|
||||
m.AppID = val.ValueString()
|
||||
}
|
||||
if val, ok := node.Properties.Get("title"); ok {
|
||||
rule.MatchTitle = val.ValueString()
|
||||
m.Title = val.ValueString()
|
||||
}
|
||||
if val, ok := node.Properties.Get("is-floating"); ok {
|
||||
b := val.ValueString() == "true"
|
||||
rule.MatchIsFloating = &b
|
||||
m.IsFloating = &b
|
||||
}
|
||||
if val, ok := node.Properties.Get("is-active"); ok {
|
||||
b := val.ValueString() == "true"
|
||||
rule.MatchIsActive = &b
|
||||
m.IsActive = &b
|
||||
}
|
||||
if val, ok := node.Properties.Get("is-focused"); ok {
|
||||
b := val.ValueString() == "true"
|
||||
rule.MatchIsFocused = &b
|
||||
m.IsFocused = &b
|
||||
}
|
||||
if val, ok := node.Properties.Get("is-active-in-column"); ok {
|
||||
b := val.ValueString() == "true"
|
||||
rule.MatchIsActiveInColumn = &b
|
||||
m.IsActiveInColumn = &b
|
||||
}
|
||||
if val, ok := node.Properties.Get("is-window-cast-target"); ok {
|
||||
b := val.ValueString() == "true"
|
||||
rule.MatchIsWindowCastTarget = &b
|
||||
m.IsWindowCastTarget = &b
|
||||
}
|
||||
if val, ok := node.Properties.Get("is-urgent"); ok {
|
||||
b := val.ValueString() == "true"
|
||||
rule.MatchIsUrgent = &b
|
||||
m.IsUrgent = &b
|
||||
}
|
||||
if val, ok := node.Properties.Get("at-startup"); ok {
|
||||
b := val.ValueString() == "true"
|
||||
rule.MatchAtStartup = &b
|
||||
m.AtStartup = &b
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (p *NiriRulesParser) parseBorderNode(node *document.Node, rule *NiriWindowRule) {
|
||||
@@ -385,6 +424,64 @@ 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
|
||||
@@ -461,6 +558,27 @@ 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 {
|
||||
@@ -479,6 +597,7 @@ 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,
|
||||
@@ -506,6 +625,13 @@ 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)
|
||||
@@ -684,6 +810,7 @@ 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,
|
||||
@@ -711,6 +838,13 @@ 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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -740,15 +874,7 @@ func (p *NiriWritableProvider) writeDMSRules(rules []windowrules.WindowRule) err
|
||||
return os.WriteFile(rulesPath, []byte(strings.Join(lines, "\n")), 0644)
|
||||
}
|
||||
|
||||
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 {
|
||||
func formatNiriMatchLine(m windowrules.MatchCriteria) (string, bool) {
|
||||
var matchProps []string
|
||||
if m.AppID != "" {
|
||||
matchProps = append(matchProps, fmt.Sprintf("app-id=%q", m.AppID))
|
||||
@@ -777,7 +903,25 @@ func (p *NiriWritableProvider) formatRule(rule windowrules.WindowRule) string {
|
||||
if m.AtStartup != nil {
|
||||
matchProps = append(matchProps, fmt.Sprintf("at-startup=%t", *m.AtStartup))
|
||||
}
|
||||
lines = append(lines, " match "+strings.Join(matchProps, " "))
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
a := rule.Actions
|
||||
@@ -858,10 +1002,39 @@ 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 {
|
||||
|
||||
@@ -43,6 +43,14 @@ 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"`
|
||||
@@ -66,6 +74,7 @@ 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"`
|
||||
}
|
||||
@@ -79,6 +88,8 @@ 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 {
|
||||
|
||||
@@ -48,6 +48,13 @@ 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
@@ -282,6 +282,53 @@ 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.
|
||||
@@ -485,6 +532,54 @@ 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.
|
||||
@@ -543,6 +638,18 @@ 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.
|
||||
|
||||
@@ -673,6 +780,10 @@ 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
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ Singleton {
|
||||
readonly property int durMed: 450
|
||||
readonly property int durLong: 600
|
||||
|
||||
// Navigation feedback stays responsive even when ambient shell motion is slow.
|
||||
readonly property int settingsNavigationStateDuration: 180
|
||||
readonly property int settingsNavigationRippleDuration: 200
|
||||
|
||||
readonly property int slidePx: 80
|
||||
|
||||
readonly property var emphasized: [0.05, 0.00, 0.133333, 0.06, 0.166667, 0.40, 0.208333, 0.82, 0.25, 1.00, 1.00, 1.00]
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property var modalHandle
|
||||
required property string claimPrefix
|
||||
property string surfaceKind: "modal"
|
||||
property string screenName: ""
|
||||
property bool enabled: false
|
||||
property bool active: false
|
||||
property bool presented: false
|
||||
property bool dockBlocked: false
|
||||
property string dockSide: ""
|
||||
|
||||
property alias claimId: lease.claimId
|
||||
property alias claimedScreenName: lease.claimedScreenName
|
||||
|
||||
signal recoveryRequested
|
||||
|
||||
visible: false
|
||||
|
||||
function _isCurrentModal(name) {
|
||||
return !!name && ModalManager.isCurrentModal(modalHandle, name);
|
||||
}
|
||||
|
||||
ConnectedSurfaceLease {
|
||||
id: lease
|
||||
claimPrefix: root.claimPrefix
|
||||
screenName: root.screenName
|
||||
enabled: root.enabled
|
||||
active: root.active
|
||||
presented: root.presented
|
||||
dockBlocked: root.dockBlocked
|
||||
dockSide: root.dockSide
|
||||
isCurrentOwner: function(name) {
|
||||
return root._isCurrentModal(name);
|
||||
}
|
||||
hasOwner: function(name, ownerId) {
|
||||
return ConnectedModeState.hasModalOwner(name, ownerId);
|
||||
}
|
||||
statePresent: function(name, ownerId) {
|
||||
return ConnectedModeState.hasModalOwner(name, ownerId) && ConnectedModeState.hasSurfaceDescriptor(name, root.surfaceKind, ownerId);
|
||||
}
|
||||
claimState: function(name, state, ownerId) {
|
||||
return ConnectedModeState.claimModalState(name, state, ownerId);
|
||||
}
|
||||
ensureState: function(name, state, ownerId) {
|
||||
return ConnectedModeState.ensureModalState(name, state, ownerId);
|
||||
}
|
||||
releaseState: function(name, ownerId) {
|
||||
return ConnectedModeState.clearModalState(name, ownerId);
|
||||
}
|
||||
updateAnimationState: function(name, ownerId, animX, animY) {
|
||||
return ConnectedModeState.setModalAnim(name, animX, animY, ownerId);
|
||||
}
|
||||
updateBodyState: function(name, ownerId, bodyX, bodyY, bodyW, bodyH) {
|
||||
return ConnectedModeState.setModalBody(name, bodyX, bodyY, bodyW, bodyH, ownerId);
|
||||
}
|
||||
requestDockRetract: function(ownerId, name, side) {
|
||||
return ConnectedModeState.requestDockRetract(ownerId, name, side);
|
||||
}
|
||||
releaseDockRetract: function(ownerId) {
|
||||
return ConnectedModeState.releaseDockRetract(ownerId);
|
||||
}
|
||||
onRecoveryRequested: root.recoveryRequested()
|
||||
}
|
||||
|
||||
function publish(state) {
|
||||
return lease.publish(Object.assign({}, state, {
|
||||
"kind": root.surfaceKind,
|
||||
"screenName": root.screenName,
|
||||
"presented": root.presented,
|
||||
"dockRetractSide": root.dockBlocked ? root.dockSide : ""
|
||||
}), false);
|
||||
}
|
||||
|
||||
function updateAnim(animX, animY) {
|
||||
return lease.updateAnim(animX, animY);
|
||||
}
|
||||
|
||||
function updateBody(bodyX, bodyY, bodyW, bodyH) {
|
||||
return lease.updateBody(bodyX, bodyY, bodyW, bodyH);
|
||||
}
|
||||
|
||||
function release() {
|
||||
return lease.release();
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: ModalManager
|
||||
function onModalChanged() {
|
||||
lease.requestRecovery();
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: ConnectedModeState
|
||||
function onModalOwnersChanged() {
|
||||
lease.checkOwnershipRecovery();
|
||||
}
|
||||
function onModalStatesChanged() {
|
||||
lease.checkStateRecovery();
|
||||
}
|
||||
function onSurfaceDescriptorsChanged() {
|
||||
lease.checkStateRecovery();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,141 @@ pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import "ConnectedSurfaceDescriptor.js" as SurfaceDescriptor
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property var surfaceDescriptors: ({})
|
||||
|
||||
function _surfaceSlot(kind) {
|
||||
return SurfaceDescriptor.slotForKind(kind);
|
||||
}
|
||||
|
||||
function surfaceDescriptor(screenName, kind) {
|
||||
const slot = _surfaceSlot(kind);
|
||||
const screenDescriptors = screenName ? surfaceDescriptors[screenName] : null;
|
||||
const descriptor = screenDescriptors && screenDescriptors[slot] ? screenDescriptors[slot] : SurfaceDescriptor.empty(kind, screenName);
|
||||
let bodyRect = descriptor.bodyRect;
|
||||
let animationOffset = descriptor.animationOffset;
|
||||
if (slot === "popout" && popoutScreen === screenName) {
|
||||
bodyRect = {
|
||||
"x": popoutBodyX,
|
||||
"y": popoutBodyY,
|
||||
"width": popoutBodyW,
|
||||
"height": popoutBodyH
|
||||
};
|
||||
animationOffset = {
|
||||
"x": popoutAnimX,
|
||||
"y": popoutAnimY
|
||||
};
|
||||
} else if (slot === "modal" && modalStates[screenName]) {
|
||||
const modal = modalStates[screenName];
|
||||
bodyRect = {
|
||||
"x": modal.bodyX,
|
||||
"y": modal.bodyY,
|
||||
"width": modal.bodyW,
|
||||
"height": modal.bodyH
|
||||
};
|
||||
animationOffset = {
|
||||
"x": modal.animX,
|
||||
"y": modal.animY
|
||||
};
|
||||
} else if (slot === "dock" && dockStates[screenName]) {
|
||||
const dock = dockStates[screenName];
|
||||
const slide = dockSlides[screenName] || {
|
||||
"x": dock.slideX,
|
||||
"y": dock.slideY
|
||||
};
|
||||
bodyRect = {
|
||||
"x": dock.bodyX,
|
||||
"y": dock.bodyY,
|
||||
"width": dock.bodyW,
|
||||
"height": dock.bodyH
|
||||
};
|
||||
animationOffset = {
|
||||
"x": slide.x,
|
||||
"y": slide.y
|
||||
};
|
||||
} else if (slot === "notification" && notificationStates[screenName]) {
|
||||
const notification = notificationStates[screenName];
|
||||
bodyRect = {
|
||||
"x": notification.bodyX,
|
||||
"y": notification.bodyY,
|
||||
"width": notification.bodyW,
|
||||
"height": notification.bodyH
|
||||
};
|
||||
}
|
||||
return SurfaceDescriptor.normalize({
|
||||
"bodyRect": bodyRect,
|
||||
"animationOffset": animationOffset
|
||||
}, descriptor);
|
||||
}
|
||||
|
||||
function legacySurfaceState(screenName, kind) {
|
||||
return SurfaceDescriptor.toLegacyState(surfaceDescriptor(screenName, kind));
|
||||
}
|
||||
|
||||
function hasSurfaceDescriptor(screenName, kind, ownerId) {
|
||||
const descriptor = surfaceDescriptor(screenName, kind);
|
||||
return descriptor.phase !== "hidden" && (!ownerId || descriptor.ownerId === ownerId);
|
||||
}
|
||||
|
||||
function _setSurfaceDescriptor(screenName, slotKind, state, ownerId) {
|
||||
if (!screenName || !state)
|
||||
return false;
|
||||
const slot = _surfaceSlot(slotKind);
|
||||
const currentScreen = surfaceDescriptors[screenName] || {};
|
||||
const previous = currentScreen[slot] || SurfaceDescriptor.empty(state.kind || slotKind, screenName);
|
||||
let normalized = SurfaceDescriptor.normalize(Object.assign({}, state, {
|
||||
"ownerId": ownerId !== undefined ? ownerId : previous.ownerId,
|
||||
"screenName": screenName,
|
||||
"revision": previous.revision
|
||||
}), previous);
|
||||
if (SurfaceDescriptor.same(previous, normalized))
|
||||
return true;
|
||||
normalized = SurfaceDescriptor.withRevision(normalized, previous.revision + 1);
|
||||
const nextScreen = _cloneDict(currentScreen);
|
||||
nextScreen[slot] = normalized;
|
||||
const next = _cloneDict(surfaceDescriptors);
|
||||
next[screenName] = nextScreen;
|
||||
surfaceDescriptors = next;
|
||||
return true;
|
||||
}
|
||||
|
||||
function _clearSurfaceDescriptor(screenName, kind, ownerId) {
|
||||
if (!screenName)
|
||||
return false;
|
||||
const slot = _surfaceSlot(kind);
|
||||
const currentScreen = surfaceDescriptors[screenName];
|
||||
const current = currentScreen ? currentScreen[slot] : null;
|
||||
if (!current || (ownerId && current.ownerId !== ownerId))
|
||||
return false;
|
||||
const nextScreen = _cloneDict(currentScreen);
|
||||
delete nextScreen[slot];
|
||||
const next = _cloneDict(surfaceDescriptors);
|
||||
if (Object.keys(nextScreen).length > 0)
|
||||
next[screenName] = nextScreen;
|
||||
else
|
||||
delete next[screenName];
|
||||
surfaceDescriptors = next;
|
||||
return true;
|
||||
}
|
||||
|
||||
function _setSurfaceAnimation(screenName, kind, ownerId, x, y) {
|
||||
const current = surfaceDescriptor(screenName, kind);
|
||||
if (current.phase === "hidden" || (ownerId && current.ownerId !== ownerId))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function _setSurfaceBody(screenName, kind, ownerId, x, y, width, height) {
|
||||
const current = surfaceDescriptor(screenName, kind);
|
||||
if (current.phase === "hidden" || (ownerId && current.ownerId !== ownerId))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
readonly property var emptyDockState: ({
|
||||
"reveal": false,
|
||||
"barSide": "bottom",
|
||||
@@ -38,6 +169,10 @@ Singleton {
|
||||
// Dock slide offsets — hot-path updates separated from full geometry state
|
||||
property var dockSlides: ({})
|
||||
|
||||
// Surfaces are keyed by screen.name. FrameWindow watches to refresh connected chrome
|
||||
// after claim/release boundaries without tracking each animation frame
|
||||
property var surfaceRevisions: ({})
|
||||
|
||||
function _cloneDict(src) {
|
||||
const next = {};
|
||||
for (const k in src)
|
||||
@@ -45,16 +180,33 @@ Singleton {
|
||||
return next;
|
||||
}
|
||||
|
||||
function _bumpSurfaceRevision(screenName) {
|
||||
if (!screenName)
|
||||
return;
|
||||
const next = _cloneDict(surfaceRevisions);
|
||||
next[screenName] = Number(next[screenName] || 0) + 1;
|
||||
surfaceRevisions = next;
|
||||
}
|
||||
|
||||
function hasPopoutOwner(claimId) {
|
||||
return !!claimId && popoutOwnerId === claimId;
|
||||
}
|
||||
|
||||
function claimPopout(claimId, state) {
|
||||
if (!claimId)
|
||||
if (!claimId || !state)
|
||||
return false;
|
||||
|
||||
const previousScreen = popoutScreen;
|
||||
popoutOwnerId = claimId;
|
||||
return updatePopout(claimId, state);
|
||||
const ok = updatePopout(claimId, state);
|
||||
if (ok) {
|
||||
if (previousScreen && previousScreen !== popoutScreen) {
|
||||
_clearSurfaceDescriptor(previousScreen, "popout");
|
||||
_bumpSurfaceRevision(previousScreen);
|
||||
}
|
||||
_bumpSurfaceRevision(popoutScreen);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
function updatePopout(claimId, state) {
|
||||
@@ -84,6 +236,21 @@ Singleton {
|
||||
if (state.omitEndConnector !== undefined)
|
||||
popoutOmitEndConnector = !!state.omitEndConnector;
|
||||
|
||||
_setSurfaceDescriptor(popoutScreen, "popout", Object.assign({}, state, {
|
||||
"kind": "popout",
|
||||
"screenName": popoutScreen,
|
||||
"visible": popoutVisible,
|
||||
"presented": state.presented !== undefined ? !!state.presented : popoutVisible,
|
||||
"barSide": popoutBarSide,
|
||||
"bodyX": popoutBodyX,
|
||||
"bodyY": popoutBodyY,
|
||||
"bodyW": popoutBodyW,
|
||||
"bodyH": popoutBodyH,
|
||||
"animX": popoutAnimX,
|
||||
"animY": popoutAnimY,
|
||||
"omitStartConnector": popoutOmitStartConnector,
|
||||
"omitEndConnector": popoutOmitEndConnector
|
||||
}), claimId);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -91,6 +258,7 @@ Singleton {
|
||||
if (!hasPopoutOwner(claimId))
|
||||
return false;
|
||||
|
||||
const releasedScreen = popoutScreen;
|
||||
popoutOwnerId = "";
|
||||
popoutVisible = false;
|
||||
popoutBarSide = "top";
|
||||
@@ -103,6 +271,8 @@ Singleton {
|
||||
popoutScreen = "";
|
||||
popoutOmitStartConnector = false;
|
||||
popoutOmitEndConnector = false;
|
||||
_clearSurfaceDescriptor(releasedScreen, "popout", claimId);
|
||||
_bumpSurfaceRevision(releasedScreen);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -119,6 +289,7 @@ Singleton {
|
||||
if (!isNaN(nextY) && popoutAnimY !== nextY)
|
||||
popoutAnimY = nextY;
|
||||
}
|
||||
_setSurfaceAnimation(popoutScreen, "popout", claimId, animX, animY);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -145,6 +316,7 @@ Singleton {
|
||||
if (!isNaN(nextH) && popoutBodyH !== nextH)
|
||||
popoutBodyH = nextH;
|
||||
}
|
||||
_setSurfaceBody(popoutScreen, "popout", claimId, bodyX, bodyY, bodyW, bodyH);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -172,12 +344,23 @@ Singleton {
|
||||
return false;
|
||||
|
||||
const normalized = _normalizeDockState(state);
|
||||
if (_sameDockState(dockStates[screenName], normalized))
|
||||
return true;
|
||||
|
||||
const descriptorState = Object.assign({}, state, normalized, {
|
||||
"kind": "dock",
|
||||
"screenName": screenName,
|
||||
"visible": normalized.reveal,
|
||||
"presented": normalized.reveal,
|
||||
"phase": normalized.reveal ? (state.phase || "open") : "hidden"
|
||||
});
|
||||
const previous = dockStates[screenName] || emptyDockState;
|
||||
const legacyChanged = !_sameDockState(dockStates[screenName], normalized);
|
||||
if (legacyChanged) {
|
||||
const next = _cloneDict(dockStates);
|
||||
next[screenName] = normalized;
|
||||
dockStates = next;
|
||||
}
|
||||
_setSurfaceDescriptor(screenName, "dock", descriptorState, "dock:" + screenName);
|
||||
if (!!previous.reveal !== !!normalized.reveal)
|
||||
_bumpSurfaceRevision(screenName);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -188,6 +371,7 @@ Singleton {
|
||||
const next = _cloneDict(dockStates);
|
||||
delete next[screenName];
|
||||
dockStates = next;
|
||||
_clearSurfaceDescriptor(screenName, "dock");
|
||||
|
||||
// Also clear corresponding slide
|
||||
if (dockSlides[screenName]) {
|
||||
@@ -195,6 +379,7 @@ Singleton {
|
||||
delete nextSlides[screenName];
|
||||
dockSlides = nextSlides;
|
||||
}
|
||||
_bumpSurfaceRevision(screenName);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -212,6 +397,7 @@ Singleton {
|
||||
"y": numY
|
||||
};
|
||||
dockSlides = next;
|
||||
_setSurfaceAnimation(screenName, "dock", "dock:" + screenName, numX, numY);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -258,12 +444,22 @@ Singleton {
|
||||
return false;
|
||||
|
||||
const normalized = _normalizeNotificationState(state);
|
||||
if (_sameNotificationState(notificationStates[screenName], normalized))
|
||||
return true;
|
||||
|
||||
const descriptorState = Object.assign({}, state, normalized, {
|
||||
"kind": "notification",
|
||||
"screenName": screenName,
|
||||
"presented": normalized.visible,
|
||||
"phase": normalized.visible ? (state.phase || "open") : "hidden"
|
||||
});
|
||||
const previous = notificationStates[screenName] || emptyNotificationState;
|
||||
const legacyChanged = !_sameNotificationState(notificationStates[screenName], normalized);
|
||||
if (legacyChanged) {
|
||||
const next = _cloneDict(notificationStates);
|
||||
next[screenName] = normalized;
|
||||
notificationStates = next;
|
||||
}
|
||||
_setSurfaceDescriptor(screenName, "notification", descriptorState, "notification:" + screenName);
|
||||
if (!!previous.visible !== !!normalized.visible)
|
||||
_bumpSurfaceRevision(screenName);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -274,6 +470,8 @@ Singleton {
|
||||
const next = _cloneDict(notificationStates);
|
||||
delete next[screenName];
|
||||
notificationStates = next;
|
||||
_clearSurfaceDescriptor(screenName, "notification");
|
||||
_bumpSurfaceRevision(screenName);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -330,52 +528,81 @@ Singleton {
|
||||
modalOwners = nextOwners;
|
||||
}
|
||||
const normalized = _normalizeModalState(state);
|
||||
if (_sameModalState(modalStates[screenName], normalized))
|
||||
return true;
|
||||
const next = _cloneDict(modalStates);
|
||||
next[screenName] = normalized;
|
||||
modalStates = next;
|
||||
_setSurfaceDescriptor(screenName, "modal", Object.assign({}, state, normalized, {
|
||||
"kind": state.kind || "modal",
|
||||
"screenName": screenName
|
||||
}), ownerId || "");
|
||||
_bumpSurfaceRevision(screenName);
|
||||
return true;
|
||||
}
|
||||
|
||||
function updateModalState(screenName, state, ownerId) {
|
||||
if (!screenName || !state)
|
||||
return false;
|
||||
if (ownerId && modalOwners[screenName] && modalOwners[screenName] !== ownerId)
|
||||
if (ownerId && modalOwners[screenName] !== ownerId)
|
||||
return false;
|
||||
const normalized = _normalizeModalState(state);
|
||||
if (_sameModalState(modalStates[screenName], normalized))
|
||||
return true;
|
||||
const descriptorState = Object.assign({}, state, normalized, {
|
||||
"kind": state.kind || (surfaceDescriptor(screenName, "modal").kind || "modal"),
|
||||
"screenName": screenName
|
||||
});
|
||||
if (!_sameModalState(modalStates[screenName], normalized)) {
|
||||
const next = _cloneDict(modalStates);
|
||||
next[screenName] = normalized;
|
||||
modalStates = next;
|
||||
}
|
||||
_setSurfaceDescriptor(screenName, "modal", descriptorState, ownerId || modalOwners[screenName] || "");
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasModalOwner(screenName, ownerId) {
|
||||
return !!screenName && !!ownerId && modalOwners[screenName] === ownerId;
|
||||
}
|
||||
|
||||
function ensureModalState(screenName, state, ownerId) {
|
||||
if (!screenName || !state || !ownerId)
|
||||
return false;
|
||||
const currentOwner = modalOwners[screenName] || "";
|
||||
if (currentOwner && currentOwner !== ownerId)
|
||||
return false;
|
||||
if (!currentOwner)
|
||||
return claimModalState(screenName, state, ownerId);
|
||||
return updateModalState(screenName, state, ownerId);
|
||||
}
|
||||
|
||||
function setModalState(screenName, state) {
|
||||
return updateModalState(screenName, state, null);
|
||||
}
|
||||
|
||||
function clearModalState(screenName, ownerId) {
|
||||
if (!screenName || !modalStates[screenName])
|
||||
if (!screenName)
|
||||
return false;
|
||||
if (ownerId && modalOwners[screenName] && modalOwners[screenName] !== ownerId)
|
||||
if (ownerId && modalOwners[screenName] !== ownerId)
|
||||
return false;
|
||||
if (!modalStates[screenName] && !modalOwners[screenName])
|
||||
return false;
|
||||
|
||||
if (modalStates[screenName]) {
|
||||
const next = _cloneDict(modalStates);
|
||||
delete next[screenName];
|
||||
modalStates = next;
|
||||
}
|
||||
|
||||
if (modalOwners[screenName]) {
|
||||
const nextOwners = _cloneDict(modalOwners);
|
||||
delete nextOwners[screenName];
|
||||
modalOwners = nextOwners;
|
||||
}
|
||||
_clearSurfaceDescriptor(screenName, "modal", ownerId);
|
||||
_bumpSurfaceRevision(screenName);
|
||||
return true;
|
||||
}
|
||||
|
||||
function setModalAnim(screenName, animX, animY, ownerId) {
|
||||
if (ownerId && modalOwners[screenName] && modalOwners[screenName] !== ownerId)
|
||||
if (ownerId && modalOwners[screenName] !== ownerId)
|
||||
return false;
|
||||
const cur = screenName ? modalStates[screenName] : null;
|
||||
if (!cur)
|
||||
@@ -390,11 +617,12 @@ Singleton {
|
||||
"animY": nay
|
||||
});
|
||||
modalStates = next;
|
||||
_setSurfaceAnimation(screenName, "modal", ownerId, animX, animY);
|
||||
return true;
|
||||
}
|
||||
|
||||
function setModalBody(screenName, bodyX, bodyY, bodyW, bodyH, ownerId) {
|
||||
if (ownerId && modalOwners[screenName] && modalOwners[screenName] !== ownerId)
|
||||
if (ownerId && modalOwners[screenName] !== ownerId)
|
||||
return false;
|
||||
const cur = screenName ? modalStates[screenName] : null;
|
||||
if (!cur)
|
||||
@@ -413,6 +641,7 @@ Singleton {
|
||||
"bodyH": nh
|
||||
});
|
||||
modalStates = next;
|
||||
_setSurfaceBody(screenName, "modal", ownerId, bodyX, bodyY, bodyW, bodyH);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -492,6 +721,12 @@ Singleton {
|
||||
const nextModalOwners = pruneKeyed(modalOwners);
|
||||
if (nextModalOwners !== null)
|
||||
modalOwners = nextModalOwners;
|
||||
const nextSurfaceRevisions = pruneKeyed(surfaceRevisions);
|
||||
if (nextSurfaceRevisions !== null)
|
||||
surfaceRevisions = nextSurfaceRevisions;
|
||||
const nextDescriptors = pruneKeyed(surfaceDescriptors);
|
||||
if (nextDescriptors !== null)
|
||||
surfaceDescriptors = nextDescriptors;
|
||||
|
||||
let retractChanged = false;
|
||||
const nextRetract = {};
|
||||
@@ -512,7 +747,12 @@ Singleton {
|
||||
Connections {
|
||||
target: Quickshell
|
||||
function onScreensChanged() {
|
||||
root._pruneToLiveScreens();
|
||||
screenPruneAction.schedule();
|
||||
}
|
||||
}
|
||||
|
||||
DeferredAction {
|
||||
id: screenPruneAction
|
||||
onTriggered: root._pruneToLiveScreens()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
.pragma library
|
||||
|
||||
var VALID_KINDS = {
|
||||
"popout": true,
|
||||
"modal": true,
|
||||
"launcher": true,
|
||||
"dock": true,
|
||||
"notification": true
|
||||
};
|
||||
|
||||
var VALID_PHASES = {
|
||||
"opening": true,
|
||||
"open": true,
|
||||
"closing": true,
|
||||
"hidden": true,
|
||||
"recovering": true
|
||||
};
|
||||
|
||||
function _number(value, fallback) {
|
||||
var n = Number(value);
|
||||
return isNaN(n) ? fallback : n;
|
||||
}
|
||||
|
||||
function _bool(value, fallback) {
|
||||
return value === undefined ? fallback : !!value;
|
||||
}
|
||||
|
||||
function _kind(value, fallback) {
|
||||
if (VALID_KINDS[value])
|
||||
return value;
|
||||
return VALID_KINDS[fallback] ? fallback : "modal";
|
||||
}
|
||||
|
||||
function _defaultBarSide(kind) {
|
||||
return kind === "popout" || kind === "notification" ? "top" : "bottom";
|
||||
}
|
||||
|
||||
function _barSide(value, fallback) {
|
||||
if (value === "top" || value === "bottom" || value === "left" || value === "right")
|
||||
return value;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function slotForKind(kind) {
|
||||
return kind === "launcher" ? "modal" : _kind(kind, "modal");
|
||||
}
|
||||
|
||||
function inferPhase(visible, presented, requestedPhase) {
|
||||
if (VALID_PHASES[requestedPhase])
|
||||
return requestedPhase;
|
||||
if (!visible && !presented)
|
||||
return "hidden";
|
||||
if (!visible && presented)
|
||||
return "closing";
|
||||
return "open";
|
||||
}
|
||||
|
||||
function normalize(input, defaults) {
|
||||
var source = input || {};
|
||||
var base = defaults || {};
|
||||
var kind = _kind(source.kind, base.kind);
|
||||
var defaultSide = _defaultBarSide(kind);
|
||||
var sourceRect = source.bodyRect || {};
|
||||
var baseRect = base.bodyRect || {};
|
||||
var sourceOffset = source.animationOffset || {};
|
||||
var baseOffset = base.animationOffset || {};
|
||||
var visible = _bool(source.visible !== undefined ? source.visible : source.reveal, _bool(base.visible !== undefined ? base.visible : base.reveal, false));
|
||||
var presented = _bool(source.presented, _bool(base.presented, visible));
|
||||
var bodyRect = {
|
||||
"x": _number(sourceRect.x !== undefined ? sourceRect.x : source.bodyX, _number(baseRect.x !== undefined ? baseRect.x : base.bodyX, 0)),
|
||||
"y": _number(sourceRect.y !== undefined ? sourceRect.y : source.bodyY, _number(baseRect.y !== undefined ? baseRect.y : base.bodyY, 0)),
|
||||
"width": Math.max(0, _number(sourceRect.width !== undefined ? sourceRect.width : source.bodyW, _number(baseRect.width !== undefined ? baseRect.width : base.bodyW, 0))),
|
||||
"height": Math.max(0, _number(sourceRect.height !== undefined ? sourceRect.height : source.bodyH, _number(baseRect.height !== undefined ? baseRect.height : base.bodyH, 0)))
|
||||
};
|
||||
var animationOffset = {
|
||||
"x": _number(sourceOffset.x !== undefined ? sourceOffset.x : (source.animX !== undefined ? source.animX : source.slideX), _number(baseOffset.x !== undefined ? baseOffset.x : (base.animX !== undefined ? base.animX : base.slideX), 0)),
|
||||
"y": _number(sourceOffset.y !== undefined ? sourceOffset.y : (source.animY !== undefined ? source.animY : source.slideY), _number(baseOffset.y !== undefined ? baseOffset.y : (base.animY !== undefined ? base.animY : base.slideY), 0))
|
||||
};
|
||||
var screenName = source.screenName !== undefined ? source.screenName : (source.screen !== undefined ? source.screen : (base.screenName !== undefined ? base.screenName : base.screen));
|
||||
var opacity = Math.max(0, Math.min(1, _number(source.opacity, _number(base.opacity, 1))));
|
||||
|
||||
return {
|
||||
"ownerId": String(source.ownerId !== undefined ? source.ownerId : (base.ownerId || "")),
|
||||
"kind": kind,
|
||||
"screenName": String(screenName || ""),
|
||||
"phase": inferPhase(visible, presented, source.phase !== undefined ? source.phase : base.phase),
|
||||
"visible": visible,
|
||||
"presented": presented,
|
||||
"barSide": _barSide(source.barSide, _barSide(base.barSide, defaultSide)),
|
||||
"bodyRect": bodyRect,
|
||||
"animationOffset": animationOffset,
|
||||
"scale": Math.max(0, _number(source.scale, _number(base.scale, 1))),
|
||||
"opacity": opacity,
|
||||
"omitStartConnector": _bool(source.omitStartConnector, _bool(base.omitStartConnector, false)),
|
||||
"omitEndConnector": _bool(source.omitEndConnector, _bool(base.omitEndConnector, false)),
|
||||
"dockRetractSide": String(source.dockRetractSide !== undefined ? source.dockRetractSide : (base.dockRetractSide || "")),
|
||||
"revision": Math.max(0, Math.floor(_number(source.revision, _number(base.revision, 0))))
|
||||
};
|
||||
}
|
||||
|
||||
function empty(kind, screenName) {
|
||||
return normalize({
|
||||
"kind": kind,
|
||||
"screenName": screenName || "",
|
||||
"phase": "hidden",
|
||||
"visible": false,
|
||||
"presented": false
|
||||
});
|
||||
}
|
||||
|
||||
function withRevision(descriptor, revision) {
|
||||
var next = normalize(descriptor);
|
||||
next.revision = Math.max(0, Math.floor(_number(revision, next.revision)));
|
||||
return next;
|
||||
}
|
||||
|
||||
function withAnimationOffset(descriptor, x, y) {
|
||||
var next = normalize(descriptor);
|
||||
next.animationOffset = {
|
||||
"x": x === undefined ? next.animationOffset.x : _number(x, next.animationOffset.x),
|
||||
"y": y === undefined ? next.animationOffset.y : _number(y, next.animationOffset.y)
|
||||
};
|
||||
return next;
|
||||
}
|
||||
|
||||
function withBodyRect(descriptor, x, y, width, height) {
|
||||
var next = normalize(descriptor);
|
||||
next.bodyRect = {
|
||||
"x": x === undefined ? next.bodyRect.x : _number(x, next.bodyRect.x),
|
||||
"y": y === undefined ? next.bodyRect.y : _number(y, next.bodyRect.y),
|
||||
"width": width === undefined ? next.bodyRect.width : Math.max(0, _number(width, next.bodyRect.width)),
|
||||
"height": height === undefined ? next.bodyRect.height : Math.max(0, _number(height, next.bodyRect.height))
|
||||
};
|
||||
return next;
|
||||
}
|
||||
|
||||
function same(a, b, threshold) {
|
||||
if (!a || !b)
|
||||
return false;
|
||||
var epsilon = threshold === undefined ? 0.5 : Math.max(0, Number(threshold));
|
||||
return a.ownerId === b.ownerId
|
||||
&& a.kind === b.kind
|
||||
&& a.screenName === b.screenName
|
||||
&& a.phase === b.phase
|
||||
&& a.visible === b.visible
|
||||
&& a.presented === b.presented
|
||||
&& a.barSide === b.barSide
|
||||
&& Math.abs(a.bodyRect.x - b.bodyRect.x) < epsilon
|
||||
&& Math.abs(a.bodyRect.y - b.bodyRect.y) < epsilon
|
||||
&& Math.abs(a.bodyRect.width - b.bodyRect.width) < epsilon
|
||||
&& Math.abs(a.bodyRect.height - b.bodyRect.height) < epsilon
|
||||
&& Math.abs(a.animationOffset.x - b.animationOffset.x) < epsilon
|
||||
&& Math.abs(a.animationOffset.y - b.animationOffset.y) < epsilon
|
||||
&& Math.abs(a.scale - b.scale) < 0.0001
|
||||
&& Math.abs(a.opacity - b.opacity) < 0.0001
|
||||
&& a.omitStartConnector === b.omitStartConnector
|
||||
&& a.omitEndConnector === b.omitEndConnector
|
||||
&& a.dockRetractSide === b.dockRetractSide;
|
||||
}
|
||||
|
||||
function toLegacyState(descriptor) {
|
||||
var d = normalize(descriptor);
|
||||
return {
|
||||
"visible": d.visible,
|
||||
"reveal": d.visible,
|
||||
"barSide": d.barSide,
|
||||
"bodyX": d.bodyRect.x,
|
||||
"bodyY": d.bodyRect.y,
|
||||
"bodyW": d.bodyRect.width,
|
||||
"bodyH": d.bodyRect.height,
|
||||
"animX": d.animationOffset.x,
|
||||
"animY": d.animationOffset.y,
|
||||
"slideX": d.animationOffset.x,
|
||||
"slideY": d.animationOffset.y,
|
||||
"screen": d.screenName,
|
||||
"omitStartConnector": d.omitStartConnector,
|
||||
"omitEndConnector": d.omitEndConnector
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
.pragma library
|
||||
|
||||
function _number(value, fallback) {
|
||||
var n = Number(value);
|
||||
return isNaN(n) ? fallback : n;
|
||||
}
|
||||
|
||||
function snap(value, dpr) {
|
||||
var scale = dpr || 1;
|
||||
return Math.round(_number(value, 0) * scale) / scale;
|
||||
}
|
||||
|
||||
function isHorizontal(side) {
|
||||
return side === "top" || side === "bottom";
|
||||
}
|
||||
|
||||
function isVertical(side) {
|
||||
return side === "left" || side === "right";
|
||||
}
|
||||
|
||||
function bodyRect(descriptor, dpr) {
|
||||
var source = descriptor && descriptor.bodyRect ? descriptor.bodyRect : descriptor || {};
|
||||
return {
|
||||
"x": snap(source.x !== undefined ? source.x : source.bodyX, dpr),
|
||||
"y": snap(source.y !== undefined ? source.y : source.bodyY, dpr),
|
||||
"width": Math.max(0, snap(source.width !== undefined ? source.width : source.bodyW, dpr)),
|
||||
"height": Math.max(0, snap(source.height !== undefined ? source.height : source.bodyH, dpr))
|
||||
};
|
||||
}
|
||||
|
||||
function animatedBodyRect(descriptor, dpr) {
|
||||
var rect = bodyRect(descriptor, dpr);
|
||||
var offset = descriptor && descriptor.animationOffset ? descriptor.animationOffset : descriptor || {};
|
||||
var side = descriptor && descriptor.barSide ? descriptor.barSide : "bottom";
|
||||
var dx = isVertical(side) ? Math.max(-rect.width, Math.min(_number(offset.x !== undefined ? offset.x : offset.animX, 0), rect.width)) : 0;
|
||||
var dy = isHorizontal(side) ? Math.max(-rect.height, Math.min(_number(offset.y !== undefined ? offset.y : offset.animY, 0), rect.height)) : 0;
|
||||
|
||||
return {
|
||||
"x": snap(rect.x + (side === "right" ? dx : 0), dpr),
|
||||
"y": snap(rect.y + (side === "bottom" ? dy : 0), dpr),
|
||||
"width": Math.max(0, snap(rect.width - Math.abs(dx), dpr)),
|
||||
"height": Math.max(0, snap(rect.height - Math.abs(dy), dpr)),
|
||||
"dx": snap(dx, dpr),
|
||||
"dy": snap(dy, dpr)
|
||||
};
|
||||
}
|
||||
|
||||
function translatedBodyRect(descriptor, dpr) {
|
||||
var rect = bodyRect(descriptor, dpr);
|
||||
var offset = descriptor && descriptor.animationOffset ? descriptor.animationOffset : {};
|
||||
return {
|
||||
"x": snap(rect.x + _number(offset.x, 0), dpr),
|
||||
"y": snap(rect.y + _number(offset.y, 0), dpr),
|
||||
"width": rect.width,
|
||||
"height": rect.height
|
||||
};
|
||||
}
|
||||
|
||||
function connectorRadii(descriptor, rect, connectedRadius, surfaceRadius, dpr, nearIncludesSurface) {
|
||||
var side = descriptor && descriptor.barSide ? descriptor.barSide : "bottom";
|
||||
var horizontal = isHorizontal(side);
|
||||
var extent = horizontal ? rect.height : rect.width;
|
||||
var crossSize = horizontal ? rect.width : rect.height;
|
||||
var nearLimit = nearIncludesSurface ? Math.min(connectedRadius, surfaceRadius, extent, crossSize / 2) : Math.min(connectedRadius, extent, crossSize / 2);
|
||||
var farLimit = Math.min(connectedRadius, surfaceRadius, crossSize / 2);
|
||||
var near = snap(Math.max(0, nearLimit), dpr);
|
||||
var far = snap(Math.max(0, farLimit), dpr);
|
||||
var omitStart = !!(descriptor && descriptor.omitStartConnector);
|
||||
var omitEnd = !!(descriptor && descriptor.omitEndConnector);
|
||||
return {
|
||||
"near": near,
|
||||
"far": far,
|
||||
"start": omitStart ? 0 : near,
|
||||
"end": omitEnd ? 0 : near,
|
||||
"farStart": omitStart ? far : 0,
|
||||
"farEnd": omitEnd ? far : 0,
|
||||
"farExtent": Math.max(omitStart ? far : 0, omitEnd ? far : 0)
|
||||
};
|
||||
}
|
||||
|
||||
function _connectorWidth(side, spacing, radius) {
|
||||
return isVertical(side) ? spacing + radius : radius;
|
||||
}
|
||||
|
||||
function _connectorHeight(side, spacing, radius) {
|
||||
return isVertical(side) ? radius : spacing + radius;
|
||||
}
|
||||
|
||||
function connectorRect(side, rect, placement, spacing, radius, dpr) {
|
||||
var width = _connectorWidth(side, spacing, radius);
|
||||
var height = _connectorHeight(side, spacing, radius);
|
||||
var seamX = isVertical(side) ? (side === "left" ? rect.x : rect.x + rect.width) : (placement === "left" ? rect.x : rect.x + rect.width);
|
||||
var seamY = side === "top" ? rect.y : (side === "bottom" ? rect.y + rect.height : (placement === "left" ? rect.y : rect.y + rect.height));
|
||||
var x = isVertical(side) ? (side === "left" ? seamX : seamX - width) : (placement === "left" ? seamX - width : seamX);
|
||||
var y = side === "top" ? seamY : (side === "bottom" ? seamY - height : (placement === "left" ? seamY - height : seamY));
|
||||
return {
|
||||
"x": snap(x, dpr),
|
||||
"y": snap(y, dpr),
|
||||
"width": Math.max(0, snap(width, dpr)),
|
||||
"height": Math.max(0, snap(height, dpr))
|
||||
};
|
||||
}
|
||||
|
||||
function farConnectorRect(side, rect, placement, radius, dpr) {
|
||||
var x;
|
||||
var y;
|
||||
if (isHorizontal(side)) {
|
||||
x = placement === "left" ? rect.x : rect.x + rect.width - radius;
|
||||
y = side === "top" ? rect.y + rect.height : rect.y - radius;
|
||||
} else {
|
||||
x = side === "left" ? rect.x + rect.width : rect.x - radius;
|
||||
y = placement === "left" ? rect.y : rect.y + rect.height - radius;
|
||||
}
|
||||
return {
|
||||
"x": snap(x, dpr),
|
||||
"y": snap(y, dpr),
|
||||
"width": Math.max(0, snap(radius, dpr)),
|
||||
"height": Math.max(0, snap(radius, dpr))
|
||||
};
|
||||
}
|
||||
|
||||
function farBodyCapRect(side, rect, placement, radius, dpr) {
|
||||
var x;
|
||||
var y;
|
||||
if (isHorizontal(side)) {
|
||||
x = placement === "left" ? rect.x : rect.x + rect.width - radius;
|
||||
y = side === "top" ? rect.y + rect.height - radius : rect.y;
|
||||
} else {
|
||||
x = side === "left" ? rect.x + rect.width - radius : rect.x;
|
||||
y = placement === "left" ? rect.y : rect.y + rect.height - radius;
|
||||
}
|
||||
return {
|
||||
"x": snap(x, dpr),
|
||||
"y": snap(y, dpr),
|
||||
"width": Math.max(0, snap(radius, dpr)),
|
||||
"height": Math.max(0, snap(radius, dpr))
|
||||
};
|
||||
}
|
||||
|
||||
function chromeBounds(rect, side, startRadius, endRadius, farExtent, dpr) {
|
||||
var horizontal = isHorizontal(side);
|
||||
var bodyOffsetX = horizontal ? startRadius : (side === "right" ? farExtent : 0);
|
||||
var bodyOffsetY = horizontal ? (side === "bottom" ? farExtent : 0) : startRadius;
|
||||
return {
|
||||
"x": snap(rect.x - bodyOffsetX, dpr),
|
||||
"y": snap(rect.y - bodyOffsetY, dpr),
|
||||
"width": Math.max(0, snap(horizontal ? rect.width + startRadius + endRadius : rect.width + farExtent, dpr)),
|
||||
"height": Math.max(0, snap(horizontal ? rect.height + farExtent : rect.height + startRadius + endRadius, dpr)),
|
||||
"bodyOffsetX": snap(bodyOffsetX, dpr),
|
||||
"bodyOffsetY": snap(bodyOffsetY, dpr)
|
||||
};
|
||||
}
|
||||
|
||||
function fillBounds(rect, side, seamOverlap, dpr) {
|
||||
var overlapX = isHorizontal(side) ? seamOverlap : 0;
|
||||
var overlapY = isVertical(side) ? seamOverlap : 0;
|
||||
return {
|
||||
"x": snap(rect.x - overlapX, dpr),
|
||||
"y": snap(rect.y - overlapY, dpr),
|
||||
"width": Math.max(0, snap(rect.width + overlapX * 2, dpr)),
|
||||
"height": Math.max(0, snap(rect.height + overlapY * 2, dpr))
|
||||
};
|
||||
}
|
||||
|
||||
function clipEnvelope(rect, side, radii, seamOverlap, dpr) {
|
||||
var fill = fillBounds(rect, side, seamOverlap, dpr);
|
||||
var chrome = chromeBounds(fill, side, radii.start, radii.end, radii.farExtent, dpr);
|
||||
return {
|
||||
"x": chrome.x,
|
||||
"y": chrome.y,
|
||||
"width": chrome.width,
|
||||
"height": chrome.height,
|
||||
"bodyX": snap(fill.x - chrome.x, dpr),
|
||||
"bodyY": snap(fill.y - chrome.y, dpr),
|
||||
"bodyWidth": fill.width,
|
||||
"bodyHeight": fill.height
|
||||
};
|
||||
}
|
||||
|
||||
function blurRegions(descriptor, rect, radii, dpr) {
|
||||
var side = descriptor.barSide;
|
||||
var regions = [bodyRect(rect, dpr)];
|
||||
if (radii.start > 0)
|
||||
regions.push(connectorRect(side, rect, "left", 0, radii.start, dpr));
|
||||
if (radii.end > 0)
|
||||
regions.push(connectorRect(side, rect, "right", 0, radii.end, dpr));
|
||||
if (radii.farStart > 0) {
|
||||
regions.push(farConnectorRect(side, rect, "left", radii.farStart, dpr));
|
||||
regions.push(farBodyCapRect(side, rect, "left", radii.farStart, dpr));
|
||||
}
|
||||
if (radii.farEnd > 0) {
|
||||
regions.push(farConnectorRect(side, rect, "right", radii.farEnd, dpr));
|
||||
regions.push(farBodyCapRect(side, rect, "right", radii.farEnd, dpr));
|
||||
}
|
||||
return regions;
|
||||
}
|
||||
|
||||
function unionBounds(rects, padding, dpr) {
|
||||
var minX = Infinity;
|
||||
var minY = Infinity;
|
||||
var maxX = -Infinity;
|
||||
var maxY = -Infinity;
|
||||
for (var i = 0; i < rects.length; i++) {
|
||||
var rect = rects[i];
|
||||
if (!rect || rect.width <= 0 || rect.height <= 0)
|
||||
continue;
|
||||
minX = Math.min(minX, rect.x);
|
||||
minY = Math.min(minY, rect.y);
|
||||
maxX = Math.max(maxX, rect.x + rect.width);
|
||||
maxY = Math.max(maxY, rect.y + rect.height);
|
||||
}
|
||||
if (minX === Infinity)
|
||||
return {"x": 0, "y": 0, "width": 0, "height": 0};
|
||||
var pad = Math.max(0, _number(padding, 0));
|
||||
return {
|
||||
"x": snap(minX - pad, dpr),
|
||||
"y": snap(minY - pad, dpr),
|
||||
"width": Math.max(0, snap(maxX - minX + pad * 2, dpr)),
|
||||
"height": Math.max(0, snap(maxY - minY + pad * 2, dpr))
|
||||
};
|
||||
}
|
||||
|
||||
function shadowSourceBounds(descriptor, rect, radii, padding, dpr) {
|
||||
return unionBounds(blurRegions(descriptor, rect, radii, dpr), padding, dpr);
|
||||
}
|
||||
|
||||
function stableEqual(a, b, dpr) {
|
||||
if (!a || !b)
|
||||
return false;
|
||||
var threshold = 0.5 / (dpr || 1);
|
||||
return Math.abs(a.x - b.x) < threshold && Math.abs(a.y - b.y) < threshold && Math.abs(a.width - b.width) < threshold && Math.abs(a.height - b.height) < threshold;
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property string claimPrefix
|
||||
required property var isCurrentOwner
|
||||
required property var hasOwner
|
||||
required property var claimState
|
||||
required property var ensureState
|
||||
required property var releaseState
|
||||
|
||||
property var statePresent: null
|
||||
property var updateAnimationState: null
|
||||
property var updateBodyState: null
|
||||
property var requestDockRetract: null
|
||||
property var releaseDockRetract: null
|
||||
|
||||
property string screenName: ""
|
||||
property bool enabled: false
|
||||
property bool active: false
|
||||
property bool presented: false
|
||||
property bool dockBlocked: false
|
||||
property string dockSide: ""
|
||||
property bool renewTokenOnRecovery: true
|
||||
|
||||
property string claimId: ""
|
||||
property string claimedScreenName: ""
|
||||
property int _claimSerial: 0
|
||||
|
||||
signal recoveryRequested
|
||||
|
||||
visible: false
|
||||
|
||||
function _nextClaimId() {
|
||||
_claimSerial += 1;
|
||||
return claimPrefix + ":" + (new Date()).getTime() + ":" + _claimSerial + ":" + Math.floor(Math.random() * 1000000);
|
||||
}
|
||||
|
||||
function _isCurrent(name) {
|
||||
return !!name && !!isCurrentOwner && !!isCurrentOwner(name);
|
||||
}
|
||||
|
||||
function _hasOwner(name, ownerId) {
|
||||
return !!name && !!ownerId && !!hasOwner && !!hasOwner(name, ownerId);
|
||||
}
|
||||
|
||||
function _hasState(name, ownerId) {
|
||||
return !statePresent || !!statePresent(name, ownerId);
|
||||
}
|
||||
|
||||
function _shouldRecover() {
|
||||
return active && enabled && _isCurrent(screenName);
|
||||
}
|
||||
|
||||
function requestRecovery() {
|
||||
if (!_shouldRecover())
|
||||
return false;
|
||||
recoveryRequested();
|
||||
return true;
|
||||
}
|
||||
|
||||
function checkOwnershipRecovery() {
|
||||
if (!_shouldRecover())
|
||||
return false;
|
||||
if (claimedScreenName === screenName && _hasOwner(screenName, claimId))
|
||||
return false;
|
||||
recoveryRequested();
|
||||
return true;
|
||||
}
|
||||
|
||||
function checkStateRecovery() {
|
||||
if (!_shouldRecover())
|
||||
return false;
|
||||
if (claimedScreenName === screenName && _hasOwner(screenName, claimId) && _hasState(screenName, claimId))
|
||||
return false;
|
||||
recoveryRequested();
|
||||
return true;
|
||||
}
|
||||
|
||||
function checkRecovery() {
|
||||
return checkStateRecovery();
|
||||
}
|
||||
|
||||
function beginClaim() {
|
||||
if (claimId && releaseDockRetract)
|
||||
releaseDockRetract(claimId);
|
||||
claimId = _nextClaimId();
|
||||
claimedScreenName = "";
|
||||
return claimId;
|
||||
}
|
||||
|
||||
function _syncDockRetract() {
|
||||
if (!claimId)
|
||||
return;
|
||||
if (dockBlocked && presented && dockSide && requestDockRetract)
|
||||
requestDockRetract(claimId, screenName, dockSide);
|
||||
else if (releaseDockRetract)
|
||||
releaseDockRetract(claimId);
|
||||
}
|
||||
|
||||
function publish(state, forceClaim) {
|
||||
if (!enabled || !screenName || !state) {
|
||||
release();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (claimedScreenName && claimedScreenName !== screenName)
|
||||
release();
|
||||
|
||||
const current = _isCurrent(screenName);
|
||||
let claiming = !!forceClaim || !claimId;
|
||||
if (claiming && !current)
|
||||
return false;
|
||||
if (!claimId)
|
||||
beginClaim();
|
||||
|
||||
let published = claiming ? claimState(screenName, state, claimId) : ensureState(screenName, state, claimId);
|
||||
if (!published && !claiming && current) {
|
||||
if (renewTokenOnRecovery) {
|
||||
beginClaim();
|
||||
} else if (releaseDockRetract) {
|
||||
releaseDockRetract(claimId);
|
||||
}
|
||||
published = claimState(screenName, state, claimId);
|
||||
}
|
||||
if (!published)
|
||||
return false;
|
||||
|
||||
claimedScreenName = screenName;
|
||||
_syncDockRetract();
|
||||
return true;
|
||||
}
|
||||
|
||||
function updateAnim(animX, animY) {
|
||||
if (!enabled || !claimId || !claimedScreenName || !updateAnimationState)
|
||||
return false;
|
||||
if (!_hasOwner(claimedScreenName, claimId)) {
|
||||
requestRecovery();
|
||||
return false;
|
||||
}
|
||||
return updateAnimationState(claimedScreenName, claimId, animX, animY);
|
||||
}
|
||||
|
||||
function updateBody(bodyX, bodyY, bodyW, bodyH) {
|
||||
if (!enabled || !claimId || !claimedScreenName || !updateBodyState)
|
||||
return false;
|
||||
if (!_hasOwner(claimedScreenName, claimId)) {
|
||||
requestRecovery();
|
||||
return false;
|
||||
}
|
||||
return updateBodyState(claimedScreenName, claimId, bodyX, bodyY, bodyW, bodyH);
|
||||
}
|
||||
|
||||
function release() {
|
||||
if (!claimId) {
|
||||
claimedScreenName = "";
|
||||
return false;
|
||||
}
|
||||
|
||||
const releasedClaimId = claimId;
|
||||
const releasedScreenName = claimedScreenName;
|
||||
claimId = "";
|
||||
claimedScreenName = "";
|
||||
|
||||
if (releaseDockRetract)
|
||||
releaseDockRetract(releasedClaimId);
|
||||
if (releasedScreenName)
|
||||
return !!releaseState(releasedScreenName, releasedClaimId);
|
||||
return false;
|
||||
}
|
||||
|
||||
Component.onDestruction: release()
|
||||
}
|
||||
@@ -57,9 +57,15 @@ 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",
|
||||
@@ -129,6 +135,10 @@ 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] || "";
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,15 @@ 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" },
|
||||
@@ -770,6 +779,26 @@ 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;
|
||||
}
|
||||
@@ -844,6 +873,10 @@ function getActionLabel(action, compositor) {
|
||||
if (!action)
|
||||
return "";
|
||||
|
||||
var amountLabel = getDmsAmountLabel(action);
|
||||
if (amountLabel)
|
||||
return amountLabel;
|
||||
|
||||
var dmsAct = findDmsAction(action);
|
||||
if (dmsAct)
|
||||
return dmsAct.label;
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ Singleton {
|
||||
property var currentModalsByScreen: ({})
|
||||
|
||||
function openModal(modal) {
|
||||
PopoutManager.screenshotActive = false;
|
||||
const screenName = modal.effectiveScreen?.name ?? "unknown";
|
||||
currentModalsByScreen[screenName] = modal;
|
||||
modalChanged();
|
||||
@@ -25,6 +26,11 @@ Singleton {
|
||||
});
|
||||
}
|
||||
|
||||
function isCurrentModal(modal, screenName) {
|
||||
const name = screenName || modal?.effectiveScreen?.name || "unknown";
|
||||
return currentModalsByScreen[name] === modal;
|
||||
}
|
||||
|
||||
function closeModal(modal) {
|
||||
const screenName = modal.effectiveScreen?.name ?? "unknown";
|
||||
if (currentModalsByScreen[screenName] === modal) {
|
||||
|
||||
@@ -10,6 +10,9 @@ 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
|
||||
|
||||
@@ -47,6 +50,7 @@ Singleton {
|
||||
function showPopout(popout) {
|
||||
if (!popout || !popout.screen)
|
||||
return;
|
||||
screenshotActive = false;
|
||||
popoutOpening();
|
||||
|
||||
const screenName = popout.screen.name;
|
||||
@@ -94,9 +98,15 @@ Singleton {
|
||||
return currentPopoutsByScreen[screen.name] || null;
|
||||
}
|
||||
|
||||
function isCurrentPopout(popout, screenName) {
|
||||
const name = screenName || popout?.screen?.name || "";
|
||||
return !!name && currentPopoutsByScreen[name] === popout;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -154,6 +154,8 @@ 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: ({})
|
||||
@@ -964,6 +966,20 @@ 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();
|
||||
@@ -1353,13 +1369,27 @@ 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: {
|
||||
const greetCfgDir = Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter";
|
||||
return greetCfgDir + "/session.json";
|
||||
}
|
||||
path: root.greeterSessionBaseDir ? (root.greeterSessionBaseDir + "/session.json") : ""
|
||||
preload: isGreeterMode
|
||||
blockLoading: false
|
||||
blockWrites: true
|
||||
|
||||
@@ -173,9 +173,11 @@ 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
|
||||
property bool mangoTrackpadNaturalScrolling: true
|
||||
|
||||
property int firstDayOfWeek: -1
|
||||
property bool showWeekNumber: false
|
||||
@@ -315,6 +317,8 @@ 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
|
||||
@@ -370,6 +374,7 @@ 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
|
||||
@@ -405,6 +410,7 @@ 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
|
||||
@@ -414,6 +420,7 @@ 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: ""
|
||||
@@ -484,6 +491,9 @@ Singleton {
|
||||
},
|
||||
"dwl": {
|
||||
"cursorHideTimeout": 0
|
||||
},
|
||||
"mango": {
|
||||
"cursorHideTimeout": 0
|
||||
}
|
||||
})
|
||||
property var availableCursorThemes: ["System Default"]
|
||||
@@ -1216,6 +1226,8 @@ Singleton {
|
||||
HyprlandService.generateLayoutConfig();
|
||||
if (CompositorService.isDwl && typeof DwlService !== "undefined")
|
||||
DwlService.generateLayoutConfig();
|
||||
if (CompositorService.isMango && typeof MangoService !== "undefined")
|
||||
MangoService.generateLayoutConfig();
|
||||
}
|
||||
|
||||
function applyStoredIconTheme() {
|
||||
@@ -1333,6 +1345,15 @@ Singleton {
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleGreeterAutoLoginSync() {
|
||||
if (isGreeterMode)
|
||||
return;
|
||||
Qt.callLater(() => {
|
||||
Processes.settingsRoot = root;
|
||||
Processes.scheduleGreeterAutoLoginSync();
|
||||
});
|
||||
}
|
||||
|
||||
readonly property var _hooks: ({
|
||||
"applyStoredTheme": applyStoredTheme,
|
||||
"regenSystemThemes": regenSystemThemes,
|
||||
@@ -1340,7 +1361,8 @@ Singleton {
|
||||
"applyStoredIconTheme": applyStoredIconTheme,
|
||||
"updateBarConfigs": updateBarConfigs,
|
||||
"updateCompositorCursor": updateCompositorCursor,
|
||||
"scheduleAuthApply": scheduleAuthApply
|
||||
"scheduleAuthApply": scheduleAuthApply,
|
||||
"scheduleGreeterAutoLoginSync": scheduleGreeterAutoLoginSync
|
||||
})
|
||||
|
||||
function set(key, value) {
|
||||
@@ -2219,7 +2241,10 @@ Singleton {
|
||||
|
||||
function getFilteredScreens(componentId) {
|
||||
var prefs = screenPreferences && screenPreferences[componentId] || ["all"];
|
||||
if (prefs.includes("all") || (typeof prefs[0] === "string" && prefs[0] === "all")) {
|
||||
if (componentId === "wallpaper" && Array.isArray(prefs) && prefs.length === 0) {
|
||||
return [];
|
||||
}
|
||||
if (!prefs || prefs.length === 0 || prefs.includes("all") || (typeof prefs[0] === "string" && prefs[0] === "all")) {
|
||||
return Quickshell.screens;
|
||||
}
|
||||
var filtered = Quickshell.screens.filter(screen => isScreenInPreferences(screen, prefs));
|
||||
@@ -2430,6 +2455,10 @@ Singleton {
|
||||
DwlService.generateCursorConfig();
|
||||
return;
|
||||
}
|
||||
if (CompositorService.isMango && typeof MangoService !== "undefined") {
|
||||
MangoService.generateCursorConfig();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function updateXResources() {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Common
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property string selectedBarId: "default"
|
||||
|
||||
function normalizeSelectedBar() {
|
||||
if (SettingsData.getBarConfig(selectedBarId))
|
||||
return;
|
||||
selectedBarId = SettingsData.barConfigs[0]?.id ?? "default";
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: SettingsData
|
||||
|
||||
function onBarConfigsChanged() {
|
||||
root.normalizeSelectedBar();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -970,6 +970,7 @@ 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
|
||||
@@ -2079,12 +2080,29 @@ 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: {
|
||||
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;
|
||||
if (SessionData.isGreeterMode)
|
||||
return root.greeterColorsBaseDir ? (root.greeterColorsBaseDir + "/colors.json") : "";
|
||||
return stateDir + "/dms-colors.json";
|
||||
}
|
||||
blockLoading: false
|
||||
watchChanges: !SessionData.isGreeterMode
|
||||
|
||||
@@ -12,6 +12,35 @@ 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: ""
|
||||
@@ -296,6 +325,66 @@ 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", "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) {
|
||||
@@ -433,6 +522,82 @@ Singleton {
|
||||
onTriggered: root.beginAuthApply()
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: greeterAutoLoginSyncDebounce
|
||||
interval: 300
|
||||
repeat: false
|
||||
onTriggered: root.beginGreeterAutoLoginSync()
|
||||
}
|
||||
|
||||
property var greeterAutoLoginSyncProcess: Process {
|
||||
command: ["dms", "greeter", "sync", "--yes", "--autologin"]
|
||||
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", "greeter-autologin-sync");
|
||||
root.greeterAutoLoginSyncProcess.running = true;
|
||||
return;
|
||||
}
|
||||
|
||||
root.launchGreeterAutoLoginSyncTerminalFallback();
|
||||
}
|
||||
}
|
||||
|
||||
property var greeterAutoLoginSyncTerminalFallbackProcess: Process {
|
||||
command: ["dms", "greeter", "sync", "--terminal", "--yes", "--autologin"]
|
||||
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", "greeter-autologin-sync");
|
||||
}
|
||||
root.finishGreeterAutoLoginSync();
|
||||
}
|
||||
}
|
||||
|
||||
property var authApplyProcess: Process {
|
||||
command: ["dms", "auth", "sync", "--yes"]
|
||||
running: false
|
||||
|
||||
@@ -56,6 +56,8 @@ var SPEC = {
|
||||
trayItemOrder: { def: [] },
|
||||
recentColors: { def: [] },
|
||||
showThirdPartyPlugins: { def: false },
|
||||
pluginBrowserInstalledFirst: { def: false },
|
||||
pluginBrowserSortMode: { def: "default" },
|
||||
launchPrefix: { def: "" },
|
||||
lastBrightnessDevice: { def: "" },
|
||||
|
||||
|
||||
@@ -29,9 +29,11 @@ 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" },
|
||||
mangoTrackpadNaturalScrolling: { def: true, onChange: "updateCompositorCursor" },
|
||||
|
||||
firstDayOfWeek: { def: -1 },
|
||||
showWeekNumber: { def: false },
|
||||
@@ -104,6 +106,8 @@ var SPEC = {
|
||||
controlCenterShowBatteryIcon: { def: false },
|
||||
controlCenterShowPrinterIcon: { def: false },
|
||||
controlCenterShowScreenSharingIcon: { def: true },
|
||||
controlCenterShowIdleInhibitorIcon: { def: false },
|
||||
controlCenterShowDoNotDisturbIcon: { def: false },
|
||||
|
||||
showPrivacyButton: { def: true },
|
||||
privacyShowMicIcon: { def: false },
|
||||
@@ -132,6 +136,7 @@ var SPEC = {
|
||||
maxWorkspaceIcons: { def: 3 },
|
||||
workspaceAppIconSizeOffset: { def: 0 },
|
||||
groupWorkspaceApps: { def: true },
|
||||
groupActiveWorkspaceApps: { def: false },
|
||||
workspaceFollowFocus: { def: false },
|
||||
showOccupiedWorkspacesOnly: { def: false },
|
||||
reverseScrolling: { def: false },
|
||||
@@ -165,6 +170,7 @@ var SPEC = {
|
||||
appsDockEnlargePercentage: { def: 125 },
|
||||
appsDockIconSizePercentage: { def: 100 },
|
||||
keyboardLayoutNameCompactMode: { def: false },
|
||||
keyboardLayoutNameShowIcon: { def: false},
|
||||
runningAppsCurrentWorkspace: { def: true },
|
||||
runningAppsGroupByApp: { def: false },
|
||||
runningAppsCurrentMonitor: { def: false },
|
||||
@@ -182,6 +188,7 @@ 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: "" },
|
||||
@@ -231,7 +238,7 @@ var SPEC = {
|
||||
qt6ctAvailable: { def: false, persist: false },
|
||||
gtkAvailable: { def: false, persist: false },
|
||||
|
||||
cursorSettings: { def: { theme: "System Default", size: 24, niri: { hideWhenTyping: false, hideAfterInactiveMs: 0 }, hyprland: { hideOnKeyPress: false, hideOnTouch: false, inactiveTimeout: 0 }, dwl: { cursorHideTimeout: 0 } }, onChange: "updateCompositorCursor" },
|
||||
cursorSettings: { def: { theme: "System Default", size: 24, niri: { hideWhenTyping: false, hideAfterInactiveMs: 0 }, hyprland: { hideOnKeyPress: false, hideOnTouch: false, inactiveTimeout: 0 }, dwl: { cursorHideTimeout: 0 }, mango: { cursorHideTimeout: 0 } }, onChange: "updateCompositorCursor" },
|
||||
availableCursorThemes: { def: ["System Default"], persist: false },
|
||||
systemDefaultCursorTheme: { def: "", persist: false },
|
||||
|
||||
@@ -587,6 +594,7 @@ function getValidKeys() {
|
||||
|
||||
function set(root, key, value, saveFn, hooks) {
|
||||
if (!(key in SPEC)) return;
|
||||
if (value === undefined || value === null) value = SPEC[key].def;
|
||||
root[key] = value;
|
||||
var hookName = SPEC[key].onChange;
|
||||
if (hookName && hooks && hooks[hookName]) {
|
||||
|
||||
+38
-2
@@ -328,6 +328,16 @@ Item {
|
||||
}
|
||||
|
||||
property bool hadRealScreen: true
|
||||
property var previousRealScreenNames: []
|
||||
|
||||
function _getRealScreenNames() {
|
||||
const names = [];
|
||||
for (let i = 0; i < Quickshell.screens.length; i++) {
|
||||
if (Quickshell.screens[i].name.length > 0)
|
||||
names.push(Quickshell.screens[i].name);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function _hasRealScreen() {
|
||||
for (let i = 0; i < Quickshell.screens.length; i++) {
|
||||
@@ -353,14 +363,20 @@ Item {
|
||||
target: Quickshell
|
||||
function onScreensChanged() {
|
||||
const hasReal = root._hasRealScreen();
|
||||
const currentNames = root._getRealScreenNames();
|
||||
log.info("Screens changed:", Quickshell.screens.length,
|
||||
Quickshell.screens.map(s => "'" + s.name + "'").join(","),
|
||||
"hasReal:", hasReal, "hadReal:", root.hadRealScreen);
|
||||
if (!root.hadRealScreen && hasReal) {
|
||||
log.info("Real screen reappeared after placeholder state, triggering surface recovery");
|
||||
const fullReconnect = !root.hadRealScreen && hasReal;
|
||||
const partialReconnect = root.previousRealScreenNames.length > 0
|
||||
&& currentNames.some(name => !root.previousRealScreenNames.includes(name));
|
||||
if (fullReconnect || partialReconnect) {
|
||||
log.info("Screen reconnect detected, triggering surface recovery",
|
||||
"full:", fullReconnect, "partial:", partialReconnect);
|
||||
root.triggerSurfaceRecovery("screen-reconnect");
|
||||
}
|
||||
root.hadRealScreen = hasReal;
|
||||
root.previousRealScreenNames = currentNames;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1124,6 +1140,7 @@ Item {
|
||||
id: powerMenuModal
|
||||
|
||||
onPowerActionRequested: (action, title, message) => {
|
||||
PopoutService.closeControlCenter();
|
||||
switch (action) {
|
||||
case "logout":
|
||||
SessionService.logout();
|
||||
@@ -1144,6 +1161,7 @@ Item {
|
||||
}
|
||||
|
||||
onLockRequested: {
|
||||
PopoutService.closeControlCenter();
|
||||
lock.activate();
|
||||
}
|
||||
|
||||
@@ -1185,6 +1203,24 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
LazyLoader {
|
||||
id: powerProfileModalLoader
|
||||
|
||||
active: false
|
||||
|
||||
PowerProfileModal {
|
||||
id: powerProfileModal
|
||||
|
||||
Component.onCompleted: {
|
||||
PopoutService.powerProfileModal = powerProfileModal;
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
PopoutService.powerProfileModalLoader = powerProfileModalLoader;
|
||||
}
|
||||
}
|
||||
|
||||
DMSShellIPC {
|
||||
powerMenuModalLoader: powerMenuModalLoader
|
||||
processListModalLoader: processListModalLoader
|
||||
|
||||
+177
-1
@@ -1,8 +1,10 @@
|
||||
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
|
||||
@@ -55,6 +57,93 @@ 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;
|
||||
@@ -161,6 +250,21 @@ 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()) {
|
||||
@@ -236,6 +340,9 @@ Item {
|
||||
if (CompositorService.isDwl && DwlService.activeOutput) {
|
||||
return DwlService.activeOutput;
|
||||
}
|
||||
if (CompositorService.isMango && MangoService.activeOutput) {
|
||||
return MangoService.activeOutput;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -840,7 +947,7 @@ Item {
|
||||
|
||||
function tabs(): string {
|
||||
if (!PopoutService.settingsModal)
|
||||
return "wallpaper\ntheme\ntypography\ntime_weather\nsounds\ndankbar\ndankbar_settings\ndankbar_widgets\nworkspaces\nmedia_player\nnotifications\nosd\nrunning_apps\nupdater\ndock\nlauncher\nkeybinds\ndisplays\nnetwork\nprinters\nlock_screen\npower_sleep\nplugins\nabout";
|
||||
return "wallpaper\ntheme\ntypography\ntime_weather\nsounds\ndankbar\ndankbar_settings\ndankbar_appearance\ndankbar_widgets\nframe\nworkspaces\ncompositor\nmedia_player\nnotifications\nosd\nrunning_apps\nupdater\ndock\nlauncher\nkeybinds\ndisplays\nnetwork\nprinters\nlock_screen\npower_sleep\nplugins\nabout";
|
||||
var modal = PopoutService.settingsModal;
|
||||
var ids = [];
|
||||
var structure = modal.sidebar?.categoryStructure ?? [];
|
||||
@@ -1875,4 +1982,73 @@ 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,7 +26,8 @@ Item {
|
||||
ClipboardHeader {
|
||||
id: header
|
||||
width: parent.width
|
||||
totalCount: modal.totalCount
|
||||
recentsCount: modal.unpinnedEntries.length
|
||||
savedCount: modal.pinnedEntries.length
|
||||
showKeyboardHints: modal.showKeyboardHints
|
||||
activeTab: modal.activeTab
|
||||
pinnedCount: modal.pinnedCount
|
||||
@@ -65,15 +66,6 @@ Item {
|
||||
forceActiveFocus();
|
||||
});
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: modal
|
||||
function onOpened() {
|
||||
Qt.callLater(function () {
|
||||
searchField.forceActiveFocus();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +100,20 @@ 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;
|
||||
@@ -145,6 +151,7 @@ Item {
|
||||
onDeleteRequested: clipboardContent.modal.deleteEntry(modelData)
|
||||
onPinRequested: clipboardContent.modal.pinEntry(modelData)
|
||||
onUnpinRequested: clipboardContent.modal.unpinEntry(modelData)
|
||||
onEditRequested: clipboardContent.modal.editEntry(modelData)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,6 +174,20 @@ 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;
|
||||
@@ -204,6 +225,7 @@ Item {
|
||||
onDeleteRequested: clipboardContent.modal.deletePinnedEntry(modelData)
|
||||
onPinRequested: clipboardContent.modal.pinEntry(modelData)
|
||||
onUnpinRequested: clipboardContent.modal.unpinEntry(modelData)
|
||||
onEditRequested: clipboardContent.modal.editEntry(modelData)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property var modal
|
||||
property var keyController: null
|
||||
|
||||
property var entry: null
|
||||
property string editorText: ""
|
||||
|
||||
function decodeEntryData(data) {
|
||||
if (!data) {
|
||||
return "";
|
||||
}
|
||||
if (typeof data !== "string") {
|
||||
return String(data);
|
||||
}
|
||||
|
||||
const sanitized = data.replace(/\s+/g, "");
|
||||
if (sanitized.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = Qt.atob(sanitized);
|
||||
if (!decoded) {
|
||||
return data;
|
||||
}
|
||||
|
||||
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("");
|
||||
}
|
||||
|
||||
if (!binary) {
|
||||
return data;
|
||||
}
|
||||
try {
|
||||
return decodeURIComponent(escape(binary));
|
||||
} catch (e) {
|
||||
return binary;
|
||||
}
|
||||
} catch (e) {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
function setEntry(newEntry) {
|
||||
entry = newEntry;
|
||||
editorText = newEntry?.text ?? newEntry?.preview ?? "";
|
||||
if (editField) {
|
||||
editField.text = editorText;
|
||||
}
|
||||
Qt.callLater(function () {
|
||||
if (editField) {
|
||||
editField.forceActiveFocus();
|
||||
editField.cursorPosition = editField.text.length;
|
||||
}
|
||||
});
|
||||
|
||||
if (!newEntry || newEntry.isImage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestedId = newEntry.id;
|
||||
DMSService.sendRequest("clipboard.getEntry", {
|
||||
"id": requestedId
|
||||
}, function (response) {
|
||||
if (response.error) {
|
||||
return;
|
||||
}
|
||||
if (!root.entry || root.entry.id !== requestedId) {
|
||||
return;
|
||||
}
|
||||
if (!response.result) {
|
||||
ClipboardService.refresh();
|
||||
return;
|
||||
}
|
||||
const result = response.result;
|
||||
let fullText = "";
|
||||
if (result?.data) {
|
||||
fullText = root.decodeEntryData(result.data);
|
||||
} else {
|
||||
fullText = result?.preview ?? "";
|
||||
}
|
||||
|
||||
if (!fullText || fullText.length === 0) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function saveEntry(action) {
|
||||
const saveAction = action ?? "history";
|
||||
DMSService.sendRequest("clipboard.copy", {
|
||||
"text": root.editorText
|
||||
}, function (response) {
|
||||
if (response.error) {
|
||||
ToastService.showError(I18n.tr("Failed to update clipboard"));
|
||||
return;
|
||||
}
|
||||
if (saveAction === "history") {
|
||||
modal.mode = "history";
|
||||
Qt.callLater(function () {
|
||||
ClipboardService.reset();
|
||||
ClipboardService.refresh();
|
||||
if (keyController) {
|
||||
keyController.reset();
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (saveAction === "close") {
|
||||
modal.hide();
|
||||
return;
|
||||
}
|
||||
if (saveAction === "paste") {
|
||||
ClipboardService.pasteClipboard(modal.hide);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function positionSaveMenu() {
|
||||
saveMenu.width = Math.max(saveMenuColumn.implicitWidth + saveMenu.padding * 2, saveButton.width);
|
||||
const pos = saveButton.mapToItem(Overlay.overlay, 0, 0);
|
||||
const popupW = saveMenu.width;
|
||||
const popupH = saveMenu.height;
|
||||
const overlayW = Overlay.overlay.width;
|
||||
const overlayH = Overlay.overlay.height;
|
||||
|
||||
let x = pos.x + (saveButton.width - popupW) / 2;
|
||||
let y = pos.y + saveButton.height + 4;
|
||||
if (y + popupH > overlayH) {
|
||||
y = pos.y - popupH - 4;
|
||||
}
|
||||
|
||||
x = Math.max(8, Math.min(x, overlayW - popupW - 8));
|
||||
y = Math.max(8, y);
|
||||
|
||||
saveMenu.x = x;
|
||||
saveMenu.y = y;
|
||||
}
|
||||
|
||||
function toggleSaveMenu() {
|
||||
if (saveMenu.visible) {
|
||||
saveMenu.close();
|
||||
return;
|
||||
}
|
||||
saveMenu.open();
|
||||
positionSaveMenu();
|
||||
Qt.callLater(positionSaveMenu);
|
||||
}
|
||||
|
||||
Shortcut {
|
||||
sequences: ["Escape"]
|
||||
enabled: modal.mode === "editor"
|
||||
onActivated: modal.mode = "history"
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.spacingM
|
||||
spacing: Theme.spacingM
|
||||
|
||||
Item {
|
||||
id: editorHeader
|
||||
width: parent.width
|
||||
height: ClipboardConstants.headerHeight
|
||||
|
||||
DankActionButton {
|
||||
iconName: "arrow_back"
|
||||
iconSize: Theme.iconSize - 4
|
||||
iconColor: Theme.surfaceText
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: modal.mode = "history"
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Edit Clipboard")
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
color: Theme.surfaceText
|
||||
font.weight: Font.Medium
|
||||
anchors.centerIn: parent
|
||||
}
|
||||
|
||||
DankActionButton {
|
||||
iconName: "close"
|
||||
iconSize: Theme.iconSize - 4
|
||||
iconColor: Theme.surfaceText
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: modal.mode = "history"
|
||||
}
|
||||
}
|
||||
|
||||
StyledRect {
|
||||
id: editFieldContainer
|
||||
width: parent.width
|
||||
height: Math.max(Theme.fontSizeMedium * 8, parent.height - editorHeader.height - editorActions.height - Theme.spacingM * 2)
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
|
||||
border.color: editField.activeFocus ? Theme.primary : Theme.outlineMedium
|
||||
border.width: editField.activeFocus ? 2 : 1
|
||||
clip: true
|
||||
|
||||
DankIcon {
|
||||
id: editIcon
|
||||
name: "edit"
|
||||
size: Theme.iconSize
|
||||
color: editField.activeFocus ? Theme.primary : Theme.surfaceVariantText
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Theme.spacingM
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: Theme.spacingM
|
||||
}
|
||||
|
||||
DankFlickable {
|
||||
id: editScroll
|
||||
anchors.left: editIcon.right
|
||||
anchors.leftMargin: Theme.spacingS
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.rightMargin: Theme.spacingM
|
||||
anchors.topMargin: Theme.spacingS
|
||||
anchors.bottomMargin: Theme.spacingS
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: editField.height
|
||||
|
||||
TextEdit {
|
||||
id: editField
|
||||
width: editScroll.width
|
||||
height: Math.max(editScroll.height, contentHeight)
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: Theme.surfaceText
|
||||
wrapMode: TextEdit.Wrap
|
||||
selectByMouse: true
|
||||
onTextChanged: root.editorText = text
|
||||
Keys.onPressed: function (event) {
|
||||
const hasCtrl = (event.modifiers & Qt.ControlModifier) !== 0;
|
||||
const hasShift = (event.modifiers & Qt.ShiftModifier) !== 0;
|
||||
|
||||
if (hasCtrl && event.key === Qt.Key_S) {
|
||||
root.saveEntry(hasShift ? "close" : "history");
|
||||
event.accepted = true;
|
||||
return;
|
||||
}
|
||||
if (hasCtrl && hasShift && event.key === Qt.Key_V) {
|
||||
root.saveEntry("paste");
|
||||
event.accepted = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Edit clipboard text")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: Theme.outlineButton
|
||||
anchors.left: editScroll.left
|
||||
anchors.right: editScroll.right
|
||||
anchors.top: editScroll.top
|
||||
anchors.bottom: editScroll.bottom
|
||||
visible: editField.text.length === 0 && !editField.activeFocus
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
id: editorActions
|
||||
width: parent.width
|
||||
spacing: Theme.spacingS
|
||||
|
||||
Item {
|
||||
id: buttonSpacer
|
||||
width: Math.max(0, parent.width - cancelButton.width - saveButton.width - Theme.spacingS)
|
||||
height: 1
|
||||
}
|
||||
|
||||
DankButton {
|
||||
id: cancelButton
|
||||
text: I18n.tr("Cancel")
|
||||
backgroundColor: Theme.surfaceContainerHigh
|
||||
textColor: Theme.surfaceText
|
||||
onClicked: modal.mode = "history"
|
||||
}
|
||||
|
||||
Item {
|
||||
id: saveButton
|
||||
|
||||
readonly property int buttonHeight: cancelButton.buttonHeight
|
||||
readonly property int arrowWidth: Theme.iconSizeLarge
|
||||
|
||||
width: cancelButton.width
|
||||
height: buttonHeight
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.primary
|
||||
}
|
||||
|
||||
Item {
|
||||
id: saveMainArea
|
||||
anchors.left: parent.left
|
||||
anchors.right: saveArrowArea.left
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Save")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
font.weight: Font.Medium
|
||||
color: Theme.onPrimary
|
||||
anchors.centerIn: saveMainArea
|
||||
}
|
||||
|
||||
Item {
|
||||
id: saveArrowArea
|
||||
width: saveButton.arrowWidth
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: 1
|
||||
height: parent.height - cancelButton.horizontalPadding
|
||||
color: Theme.withAlpha(Theme.onPrimary, 0.2)
|
||||
anchors.right: saveArrowArea.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
DankIcon {
|
||||
name: saveMenu.visible ? "expand_less" : "expand_more"
|
||||
size: Theme.iconSizeSmall
|
||||
color: Theme.onPrimary
|
||||
anchors.centerIn: saveArrowArea
|
||||
}
|
||||
|
||||
StateLayer {
|
||||
z: 1
|
||||
anchors.fill: saveMainArea
|
||||
stateColor: Theme.onPrimary
|
||||
onClicked: root.saveEntry("history")
|
||||
}
|
||||
|
||||
StateLayer {
|
||||
z: 1
|
||||
anchors.fill: saveArrowArea
|
||||
stateColor: Theme.onPrimary
|
||||
onClicked: root.toggleSaveMenu()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Popup {
|
||||
id: saveMenu
|
||||
parent: Overlay.overlay
|
||||
padding: Theme.spacingM
|
||||
modal: true
|
||||
dim: false
|
||||
focus: true
|
||||
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||
|
||||
background: StyledRect {
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.surfaceContainer
|
||||
border.color: Theme.outlineMedium
|
||||
border.width: 1
|
||||
}
|
||||
|
||||
contentItem: Column {
|
||||
id: saveMenuColumn
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
StyledRect {
|
||||
implicitWidth: saveMenuRow.implicitWidth + Theme.spacingS * 2
|
||||
implicitHeight: saveMenuRow.implicitHeight + Theme.spacingS * 2
|
||||
radius: Theme.cornerRadius
|
||||
color: saveMenuSaveArea.containsMouse ? Theme.surfaceVariant : "transparent"
|
||||
|
||||
Row {
|
||||
id: saveMenuRow
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Theme.spacingS
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Theme.spacingS
|
||||
|
||||
DankIcon {
|
||||
name: "save"
|
||||
size: Theme.iconSizeSmall
|
||||
color: Theme.surfaceText
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Save")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: Theme.surfaceText
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: saveMenuSaveArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
saveMenu.close();
|
||||
root.saveEntry("history");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StyledRect {
|
||||
implicitWidth: saveMenuCloseRow.implicitWidth + Theme.spacingS * 2
|
||||
implicitHeight: saveMenuCloseRow.implicitHeight + Theme.spacingS * 2
|
||||
radius: Theme.cornerRadius
|
||||
color: saveMenuCloseArea.containsMouse ? Theme.surfaceVariant : "transparent"
|
||||
|
||||
Row {
|
||||
id: saveMenuCloseRow
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Theme.spacingS
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Theme.spacingS
|
||||
|
||||
DankIcon {
|
||||
name: "close"
|
||||
size: Theme.iconSizeSmall
|
||||
color: Theme.surfaceText
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Save and close")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: Theme.surfaceText
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: saveMenuCloseArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
saveMenu.close();
|
||||
root.saveEntry("close");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StyledRect {
|
||||
implicitWidth: saveMenuPasteRow.implicitWidth + Theme.spacingS * 2
|
||||
implicitHeight: saveMenuPasteRow.implicitHeight + Theme.spacingS * 2
|
||||
radius: Theme.cornerRadius
|
||||
color: saveMenuPasteArea.containsMouse ? Theme.surfaceVariant : "transparent"
|
||||
opacity: modal.wtypeAvailable ? 1 : 0.5
|
||||
|
||||
Row {
|
||||
id: saveMenuPasteRow
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Theme.spacingS
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Theme.spacingS
|
||||
|
||||
DankIcon {
|
||||
name: "content_paste"
|
||||
size: Theme.iconSizeSmall
|
||||
color: Theme.surfaceText
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Save and paste")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: Theme.surfaceText
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: saveMenuPasteArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
enabled: modal.wtypeAvailable
|
||||
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: {
|
||||
saveMenu.close();
|
||||
root.saveEntry("paste");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ Rectangle {
|
||||
signal deleteRequested
|
||||
signal pinRequested
|
||||
signal unpinRequested
|
||||
signal editRequested
|
||||
|
||||
readonly property string entryType: modal ? modal.getEntryType(entry) : "text"
|
||||
readonly property string entryPreview: modal ? modal.getEntryPreview(entry) : ""
|
||||
@@ -70,6 +71,19 @@ Rectangle {
|
||||
onClicked: entry.pinned ? unpinRequested() : pinRequested()
|
||||
}
|
||||
|
||||
DankActionButton {
|
||||
iconName: "edit"
|
||||
iconSize: Theme.iconSize - 6
|
||||
iconColor: Theme.surfaceText
|
||||
|
||||
onClicked: {
|
||||
if (entryType === "image") {
|
||||
return;
|
||||
}
|
||||
editRequested();
|
||||
}
|
||||
}
|
||||
|
||||
DankActionButton {
|
||||
iconName: "close"
|
||||
iconSize: Theme.iconSize - 6
|
||||
@@ -142,8 +156,11 @@ Rectangle {
|
||||
|
||||
MouseArea {
|
||||
id: mouseArea
|
||||
anchors.fill: parent
|
||||
anchors.rightMargin: 80
|
||||
anchors.left: parent.left
|
||||
anchors.right: actionButtons.left
|
||||
anchors.rightMargin: Theme.spacingS
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onPressed: mouse => {
|
||||
|
||||
@@ -6,7 +6,8 @@ import qs.Modals.Clipboard
|
||||
Item {
|
||||
id: header
|
||||
|
||||
property int totalCount: 0
|
||||
property int recentsCount: 0
|
||||
property int savedCount: 0
|
||||
property bool showKeyboardHints: false
|
||||
property string activeTab: "recents"
|
||||
property int pinnedCount: 0
|
||||
@@ -31,7 +32,7 @@ Item {
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Clipboard History") + ` (${totalCount})`
|
||||
text: (header.activeTab === "saved" ? I18n.tr("Clipboard Saved") : I18n.tr("Clipboard History")) + ` (${header.activeTab === "saved" ? header.savedCount : header.recentsCount})`
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
color: Theme.surfaceText
|
||||
font.weight: Font.Medium
|
||||
@@ -48,7 +49,8 @@ Item {
|
||||
iconName: "push_pin"
|
||||
iconSize: Theme.iconSize - 4
|
||||
iconColor: header.activeTab === "saved" ? Theme.primary : Theme.surfaceText
|
||||
visible: header.pinnedCount > 0
|
||||
backgroundColor: header.activeTab === "saved" ? Theme.primarySelected : "transparent"
|
||||
visible: header.pinnedCount > 0 || header.activeTab === "saved"
|
||||
tooltipText: header.activeTab === "saved" ? I18n.tr("Recent") : I18n.tr("Saved")
|
||||
onClicked: tabChanged(header.activeTab === "saved" ? "recents" : "saved")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
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: {
|
||||
if (activeTab === "saved" && pinnedCount === 0) {
|
||||
activeTab = "recents";
|
||||
return;
|
||||
}
|
||||
ClipboardService.selectedIndex = 0;
|
||||
ClipboardService.keyboardNavigationActive = false;
|
||||
}
|
||||
onPinnedCountChanged: {
|
||||
if (activeTab === "saved" && pinnedCount === 0) {
|
||||
activeTab = "recents";
|
||||
}
|
||||
}
|
||||
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,61 +17,28 @@ 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
|
||||
}
|
||||
|
||||
function updateFilteredModel() {
|
||||
ClipboardService.updateFilteredModel();
|
||||
}
|
||||
|
||||
function pasteSelected() {
|
||||
ClipboardService.pasteSelected(instantClose);
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (shouldBeVisible) {
|
||||
hide();
|
||||
} else {
|
||||
show();
|
||||
return;
|
||||
}
|
||||
show();
|
||||
}
|
||||
|
||||
function show() {
|
||||
open();
|
||||
activeImageLoads = 0;
|
||||
shouldHaveFocus = true;
|
||||
ClipboardService.reset();
|
||||
keyboardController.reset();
|
||||
|
||||
Qt.callLater(function () {
|
||||
if (clipboardAvailable) {
|
||||
if (contentLoader.item) {
|
||||
contentLoader.item.resetState();
|
||||
}
|
||||
if (clipboardHistoryModal.clipboardAvailable) {
|
||||
if (Theme.isConnectedEffect) {
|
||||
Qt.callLater(() => {
|
||||
if (clipboardHistoryModal.shouldBeVisible)
|
||||
if (clipboardHistoryModal.shouldBeVisible) {
|
||||
ClipboardService.refresh();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
ClipboardService.refresh();
|
||||
@@ -89,46 +56,12 @@ DankModal {
|
||||
}
|
||||
|
||||
onDialogClosed: {
|
||||
activeImageLoads = 0;
|
||||
ClipboardService.reset();
|
||||
keyboardController.reset();
|
||||
if (contentLoader.item) {
|
||||
contentLoader.item.resetState();
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
readonly property bool clipboardAvailable: ClipboardService.clipboardAvailable
|
||||
|
||||
visible: false
|
||||
modalWidth: ClipboardConstants.modalWidth
|
||||
@@ -138,15 +71,11 @@ DankModal {
|
||||
borderColor: Theme.outlineMedium
|
||||
borderWidth: 1
|
||||
enableShadow: true
|
||||
closeOnEscapeKey: (contentLoader.item?.mode ?? "history") !== "editor"
|
||||
onBackgroundClicked: hide()
|
||||
modalFocusScope.Keys.onPressed: function (event) {
|
||||
keyboardController.handleKey(event);
|
||||
}
|
||||
content: clipboardContent
|
||||
|
||||
ClipboardKeyboardController {
|
||||
id: keyboardController
|
||||
modal: clipboardHistoryModal
|
||||
Ref {
|
||||
service: ClipboardService
|
||||
}
|
||||
|
||||
ConfirmModal {
|
||||
@@ -171,12 +100,11 @@ DankModal {
|
||||
}
|
||||
}
|
||||
|
||||
property var confirmDialog: clearConfirmDialog
|
||||
|
||||
clipboardContent: Component {
|
||||
ClipboardContent {
|
||||
modal: clipboardHistoryModal
|
||||
clearConfirmDialog: clipboardHistoryModal.confirmDialog
|
||||
content: Component {
|
||||
ClipboardHistoryContent {
|
||||
clearConfirmDialog: clearConfirmDialog
|
||||
onCloseRequested: clipboardHistoryModal.hide()
|
||||
onInstantCloseRequested: clipboardHistoryModal.instantClose()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,47 +15,20 @@ 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 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 confirmDialog: clearConfirmDialog
|
||||
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();
|
||||
@@ -65,47 +38,12 @@ 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
|
||||
@@ -117,20 +55,25 @@ 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();
|
||||
@@ -139,14 +82,13 @@ DankPopout {
|
||||
}
|
||||
|
||||
onPopoutClosed: {
|
||||
activeImageLoads = 0;
|
||||
ClipboardService.reset();
|
||||
keyboardController.reset();
|
||||
if (contentLoader.item) {
|
||||
contentLoader.item.resetState();
|
||||
}
|
||||
}
|
||||
|
||||
ClipboardKeyboardController {
|
||||
id: keyboardController
|
||||
modal: root
|
||||
Ref {
|
||||
service: ClipboardService
|
||||
}
|
||||
|
||||
ConfirmModal {
|
||||
@@ -155,48 +97,20 @@ DankPopout {
|
||||
confirmButtonColor: Theme.primary
|
||||
}
|
||||
|
||||
property var confirmDialog: clearConfirmDialog
|
||||
|
||||
content: Component {
|
||||
FocusScope {
|
||||
id: contentFocusScope
|
||||
|
||||
ClipboardHistoryContent {
|
||||
LayoutMirroring.enabled: I18n.isRtl
|
||||
LayoutMirroring.childrenInherit: true
|
||||
|
||||
focus: true
|
||||
|
||||
property alias searchField: clipboardContentItem.searchField
|
||||
|
||||
Keys.onPressed: function (event) {
|
||||
keyboardController.handleKey(event);
|
||||
}
|
||||
clearConfirmDialog: clearConfirmDialog
|
||||
onCloseRequested: root.hide()
|
||||
onInstantCloseRequested: root.close()
|
||||
|
||||
Component.onCompleted: {
|
||||
if (root.shouldBeVisible)
|
||||
activeTab = root.activeTab;
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,24 @@ QtObject {
|
||||
}
|
||||
}
|
||||
|
||||
function editSelected() {
|
||||
const entries = modal.activeTab === "saved" ? ClipboardService.pinnedEntries : ClipboardService.unpinnedEntries;
|
||||
if (!entries || entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
const index = ClipboardService.selectedIndex >= 0 && ClipboardService.selectedIndex < entries.length ? ClipboardService.selectedIndex : 0;
|
||||
modal.editEntry(entries[index]);
|
||||
}
|
||||
|
||||
function handleKey(event) {
|
||||
if (modal.mode === "editor") {
|
||||
if (event.key === Qt.Key_Escape) {
|
||||
modal.mode = "history";
|
||||
event.accepted = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case Qt.Key_Escape:
|
||||
if (ClipboardService.keyboardNavigationActive) {
|
||||
@@ -152,6 +169,10 @@ QtObject {
|
||||
event.accepted = true;
|
||||
}
|
||||
return;
|
||||
case Qt.Key_E:
|
||||
editSelected();
|
||||
event.accepted = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ Rectangle {
|
||||
readonly property string hintsText: {
|
||||
if (!wtypeAvailable)
|
||||
return I18n.tr("Ctrl+Tab: Switch Tab • Ctrl+S: Pin/Unpin • Shift+Del: Clear All • Esc: Close");
|
||||
return enterToPaste ? I18n.tr("Ctrl+Tab: Switch Tab • Ctrl+S: Pin/Unpin • Shift+Enter: Copy • Shift+Del: Clear All • Esc: Close", "Keyboard hints when enter-to-paste is enabled") : I18n.tr("Ctrl+Tab: Switch Tab • Ctrl+S: Pin/Unpin • Shift+Enter: Paste • Shift+Del: Clear All • Esc: Close");
|
||||
return enterToPaste ? I18n.tr("Ctrl+Tab: Switch Tabs • Ctrl+S: Pin/Unpin • Shift+Enter: Copy • Shift+Del: Clear All • F10: Help • Esc: Close", "Keyboard hints when enter-to-paste is enabled") : I18n.tr("Ctrl+Tab: Switch Tabs • Ctrl+S: Pin/Unpin • Shift+Enter: Paste • Shift+Del: Clear All • F10: Help • Esc: Close");
|
||||
}
|
||||
|
||||
height: ClipboardConstants.keyboardHintsHeight
|
||||
@@ -22,13 +22,17 @@ Rectangle {
|
||||
z: 100
|
||||
|
||||
Column {
|
||||
width: parent.width - Theme.spacingL * 2
|
||||
anchors.centerIn: parent
|
||||
spacing: 2
|
||||
|
||||
StyledText {
|
||||
text: keyboardHints.enterToPaste ? I18n.tr("↑/↓: Navigate • Enter: Paste • Del: Delete • F10: Help", "Keyboard hints when enter-to-paste is enabled") : I18n.tr("↑/↓: Navigate • Enter/Ctrl+C: Copy • Del: Delete • F10: Help")
|
||||
text: keyboardHints.enterToPaste ? I18n.tr("↑/↓: Navigate • Enter: Paste • Ctrl+C: Copy • Del: Delete • Ctrl+E: Edit • Ctrl+S: Pin/Unpin • F10: Help", "Keyboard hints when enter-to-paste is enabled") : I18n.tr("↑/↓: Navigate • Enter/Ctrl+C: Copy • Del: Delete • Ctrl+E: Edit • Ctrl+S: Pin/Unpin • F10: Help")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceText
|
||||
width: parent.width
|
||||
wrapMode: Text.WordWrap
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
}
|
||||
|
||||
@@ -36,6 +40,9 @@ Rectangle {
|
||||
text: keyboardHints.hintsText
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceText
|
||||
width: parent.width
|
||||
wrapMode: Text.WordWrap
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ Item {
|
||||
required property var modal
|
||||
required property var listView
|
||||
required property int itemIndex
|
||||
property bool disposed: false
|
||||
|
||||
Image {
|
||||
id: thumbnailImage
|
||||
@@ -20,6 +21,13 @@ 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}` : ""
|
||||
@@ -31,29 +39,119 @@ 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 (thumbnailImage.loadQueued || entryType !== "image" || thumbnailImage.cachedImageData) {
|
||||
if (thumbnail.disposed || thumbnailImage.loadQueued || entryType !== "image" || thumbnailImage.cachedImageData || !thumbnailImage.hasValidEntryId()) {
|
||||
return;
|
||||
}
|
||||
thumbnailImage.loadQueued = true;
|
||||
if (modal.activeImageLoads < modal.maxConcurrentLoads) {
|
||||
modal.activeImageLoads++;
|
||||
thumbnailImage.loadImage();
|
||||
if (modal && modal.activeImageLoads < modal.maxConcurrentLoads) {
|
||||
thumbnailImage.startLoad();
|
||||
} 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": entry.id
|
||||
"id": requestedId
|
||||
}, function (response) {
|
||||
thumbnailImage.loadQueued = false;
|
||||
if (modal.activeImageLoads > 0) {
|
||||
modal.activeImageLoads--;
|
||||
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;
|
||||
}
|
||||
if (response.error) {
|
||||
log.warn("Failed to load image:", entry.id);
|
||||
log.warn("Failed to load image:", requestedId);
|
||||
return;
|
||||
}
|
||||
if (!response.result) {
|
||||
ClipboardService.refresh();
|
||||
return;
|
||||
}
|
||||
const data = response.result?.data;
|
||||
@@ -70,9 +168,8 @@ Item {
|
||||
if (!thumbnailImage.loadQueued) {
|
||||
return;
|
||||
}
|
||||
if (modal.activeImageLoads < modal.maxConcurrentLoads) {
|
||||
modal.activeImageLoads++;
|
||||
thumbnailImage.loadImage();
|
||||
if (modal && modal.activeImageLoads < modal.maxConcurrentLoads) {
|
||||
thumbnailImage.startLoad();
|
||||
} else {
|
||||
retryTimer.restart();
|
||||
}
|
||||
@@ -80,7 +177,8 @@ Item {
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
if (entryType !== "image" || listView.height <= 0) {
|
||||
thumbnailImage.completed = true;
|
||||
if (entryType !== "image" || listView.height <= 0 || !thumbnailImage.hasValidEntryId()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -94,6 +192,11 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
Component.onDestruction: {
|
||||
thumbnail.disposed = true;
|
||||
thumbnailImage.cancelLoad();
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: visibilityTimer
|
||||
interval: 100
|
||||
@@ -101,7 +204,7 @@ Item {
|
||||
}
|
||||
|
||||
function checkVisibility() {
|
||||
if (entryType !== "image" || listView.height <= 0 || isVisible) {
|
||||
if (thumbnail.disposed || entryType !== "image" || listView.height <= 0 || isVisible || !thumbnailImage.hasValidEntryId()) {
|
||||
return;
|
||||
}
|
||||
const itemY = itemIndex * (ClipboardConstants.itemHeight + listView.spacing);
|
||||
|
||||
@@ -105,52 +105,65 @@ Item {
|
||||
|
||||
property bool animationsEnabled: true
|
||||
|
||||
property string _chromeClaimId: ""
|
||||
property bool _fullSyncPending: false
|
||||
|
||||
function _nextChromeClaimId() {
|
||||
return layerNamespace + ":modal:" + (new Date()).getTime() + ":" + Math.floor(Math.random() * 1000);
|
||||
}
|
||||
|
||||
function _currentScreenName() {
|
||||
return effectiveScreen ? effectiveScreen.name : "";
|
||||
}
|
||||
|
||||
function _publishModalChromeState(isClaim) {
|
||||
const screenName = _currentScreenName();
|
||||
if (!screenName)
|
||||
return;
|
||||
ConnectedModalChrome {
|
||||
id: modalChrome
|
||||
modalHandle: root.modalHandle
|
||||
claimPrefix: root.layerNamespace + ":modal"
|
||||
surfaceKind: "modal"
|
||||
screenName: root._currentScreenName()
|
||||
enabled: root.frameOwnsConnectedChrome
|
||||
active: root.shouldBeVisible
|
||||
presented: root.shouldBeVisible || contentWindow.visible
|
||||
dockBlocked: root._dockBlocksEmergence
|
||||
dockSide: root.resolvedConnectedBarSide
|
||||
onRecoveryRequested: root._queueFullSync()
|
||||
}
|
||||
|
||||
function _publishModalChromeState() {
|
||||
const presented = shouldBeVisible || contentWindow.visible;
|
||||
const phase = !presented ? "hidden" : (!shouldBeVisible && contentWindow.visible ? "closing" : (!contentWindow.visible ? "opening" : "open"));
|
||||
const bodyRect = {
|
||||
"x": alignedX,
|
||||
"y": alignedY,
|
||||
"width": alignedWidth,
|
||||
"height": alignedHeight
|
||||
};
|
||||
const animationOffset = {
|
||||
"x": modalContainer ? modalContainer.animX : 0,
|
||||
"y": modalContainer ? modalContainer.animY : 0
|
||||
};
|
||||
const state = {
|
||||
"visible": shouldBeVisible || contentWindow.visible,
|
||||
"kind": "modal",
|
||||
"screenName": root._currentScreenName(),
|
||||
"phase": phase,
|
||||
"visible": presented,
|
||||
"presented": presented,
|
||||
"barSide": resolvedConnectedBarSide,
|
||||
"bodyRect": bodyRect,
|
||||
"animationOffset": animationOffset,
|
||||
"scale": 1,
|
||||
"opacity": Theme.connectedSurfaceColor.a,
|
||||
"bodyX": alignedX,
|
||||
"bodyY": alignedY,
|
||||
"bodyW": alignedWidth,
|
||||
"bodyH": alignedHeight,
|
||||
"animX": modalContainer ? modalContainer.animX : 0,
|
||||
"animY": modalContainer ? modalContainer.animY : 0,
|
||||
"animX": animationOffset.x,
|
||||
"animY": animationOffset.y,
|
||||
"omitStartConnector": false,
|
||||
"omitEndConnector": false
|
||||
"omitEndConnector": false,
|
||||
"dockRetractSide": root._dockBlocksEmergence ? resolvedConnectedBarSide : ""
|
||||
};
|
||||
if (isClaim)
|
||||
ConnectedModeState.claimModalState(screenName, state, _chromeClaimId);
|
||||
else
|
||||
ConnectedModeState.updateModalState(screenName, state, _chromeClaimId);
|
||||
return modalChrome.publish(state);
|
||||
}
|
||||
|
||||
function _syncModalChromeState() {
|
||||
if (!frameOwnsConnectedChrome) {
|
||||
_releaseModalChrome();
|
||||
return;
|
||||
}
|
||||
const isClaim = !_chromeClaimId;
|
||||
if (!_chromeClaimId)
|
||||
_chromeClaimId = _nextChromeClaimId();
|
||||
_publishModalChromeState(isClaim);
|
||||
if (_dockBlocksEmergence && (shouldBeVisible || contentWindow.visible))
|
||||
ConnectedModeState.requestDockRetract(_chromeClaimId, _currentScreenName(), resolvedConnectedBarSide);
|
||||
else
|
||||
ConnectedModeState.releaseDockRetract(_chromeClaimId);
|
||||
_publishModalChromeState();
|
||||
}
|
||||
|
||||
property bool _animSyncQueued: false
|
||||
@@ -187,32 +200,21 @@ Item {
|
||||
}
|
||||
|
||||
function _syncModalAnim() {
|
||||
if (!frameOwnsConnectedChrome || !_chromeClaimId)
|
||||
if (!frameOwnsConnectedChrome)
|
||||
return;
|
||||
const screenName = _currentScreenName();
|
||||
if (!screenName || !modalContainer)
|
||||
if (!modalContainer)
|
||||
return;
|
||||
ConnectedModeState.setModalAnim(screenName, modalContainer.animX, modalContainer.animY, _chromeClaimId);
|
||||
modalChrome.updateAnim(modalContainer.animX, modalContainer.animY);
|
||||
}
|
||||
|
||||
function _syncModalBody() {
|
||||
if (!frameOwnsConnectedChrome || !_chromeClaimId)
|
||||
if (!frameOwnsConnectedChrome)
|
||||
return;
|
||||
const screenName = _currentScreenName();
|
||||
if (!screenName)
|
||||
return;
|
||||
ConnectedModeState.setModalBody(screenName, alignedX, alignedY, alignedWidth, alignedHeight, _chromeClaimId);
|
||||
modalChrome.updateBody(alignedX, alignedY, alignedWidth, alignedHeight);
|
||||
}
|
||||
|
||||
function _releaseModalChrome() {
|
||||
if (!_chromeClaimId)
|
||||
return;
|
||||
ConnectedModeState.releaseDockRetract(_chromeClaimId);
|
||||
const claimId = _chromeClaimId;
|
||||
_chromeClaimId = "";
|
||||
const screenName = _currentScreenName();
|
||||
if (screenName)
|
||||
ConnectedModeState.clearModalState(screenName, claimId);
|
||||
modalChrome.release();
|
||||
}
|
||||
|
||||
onFrameOwnsConnectedChromeChanged: _syncModalChromeState()
|
||||
@@ -223,8 +225,6 @@ Item {
|
||||
onAlignedWidthChanged: _queueBodySync()
|
||||
onAlignedHeightChanged: _queueBodySync()
|
||||
|
||||
Component.onDestruction: _releaseModalChrome()
|
||||
|
||||
Connections {
|
||||
target: contentWindow
|
||||
function onVisibleChanged() {
|
||||
@@ -248,12 +248,12 @@ Item {
|
||||
clickCatcher.screen = focusedScreen;
|
||||
}
|
||||
|
||||
ModalManager.openModal(modalHandle);
|
||||
if (Theme.isDirectionalEffect || root.useBackground) {
|
||||
if (!useSingleWindow)
|
||||
clickCatcher.visible = true;
|
||||
contentWindow.visible = true;
|
||||
}
|
||||
ModalManager.openModal(modalHandle);
|
||||
|
||||
Qt.callLater(() => {
|
||||
animationsEnabled = true;
|
||||
@@ -262,6 +262,7 @@ Item {
|
||||
clickCatcher.visible = true;
|
||||
if (!contentWindow.visible)
|
||||
contentWindow.visible = true;
|
||||
opened();
|
||||
shouldHaveFocus = false;
|
||||
Qt.callLater(() => shouldHaveFocus = Qt.binding(() => shouldBeVisible));
|
||||
});
|
||||
@@ -316,8 +317,12 @@ Item {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (screenStillExists)
|
||||
if (screenStillExists) {
|
||||
if (root.shouldBeVisible)
|
||||
root._queueFullSync();
|
||||
return;
|
||||
}
|
||||
root._releaseModalChrome();
|
||||
const newScreen = CompositorService.getFocusedScreen();
|
||||
if (newScreen) {
|
||||
contentWindow.screen = newScreen;
|
||||
@@ -497,22 +502,12 @@ Item {
|
||||
}
|
||||
|
||||
WlrLayershell.namespace: root.layerNamespace
|
||||
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.layer: root.useOverlayLayer ? WlrLayer.Overlay : LayerShell.fromEnv("DMS_MODAL_LAYER", WlrLayer.Top, {
|
||||
"allow": ["top", "overlay"],
|
||||
"invalidLayer": WlrLayer.Top,
|
||||
"label": "modals",
|
||||
"error": true
|
||||
})
|
||||
WlrLayershell.exclusiveZone: -1
|
||||
WlrLayershell.keyboardFocus: {
|
||||
if (customKeyboardFocus !== null)
|
||||
@@ -545,15 +540,13 @@ Item {
|
||||
implicitHeight: root.useSingleWindow ? 0 : root.alignedHeight + (shadowBuffer * 2)
|
||||
|
||||
onVisibleChanged: {
|
||||
if (visible) {
|
||||
opened();
|
||||
} else {
|
||||
if (visible)
|
||||
return;
|
||||
if (Qt.inputMethod) {
|
||||
Qt.inputMethod.hide();
|
||||
Qt.inputMethod.reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
|
||||
@@ -90,6 +90,7 @@ Item {
|
||||
if (!useSingleWindow)
|
||||
clickCatcher.visible = true;
|
||||
contentWindow.visible = true;
|
||||
opened();
|
||||
shouldHaveFocus = false;
|
||||
Qt.callLater(() => shouldHaveFocus = Qt.binding(() => shouldBeVisible));
|
||||
}
|
||||
@@ -251,22 +252,12 @@ Item {
|
||||
}
|
||||
|
||||
WlrLayershell.namespace: root.layerNamespace
|
||||
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.layer: root.useOverlayLayer ? WlrLayer.Overlay : LayerShell.fromEnv("DMS_MODAL_LAYER", WlrLayer.Top, {
|
||||
"allow": ["top", "overlay"],
|
||||
"invalidLayer": WlrLayer.Top,
|
||||
"label": "modals",
|
||||
"error": true
|
||||
})
|
||||
WlrLayershell.exclusiveZone: -1
|
||||
WlrLayershell.keyboardFocus: {
|
||||
if (customKeyboardFocus !== null)
|
||||
@@ -296,15 +287,13 @@ Item {
|
||||
implicitHeight: root.useSingleWindow ? 0 : root.alignedHeight + (shadowBuffer * 2)
|
||||
|
||||
onVisibleChanged: {
|
||||
if (visible) {
|
||||
opened();
|
||||
} else {
|
||||
if (visible)
|
||||
return;
|
||||
if (Qt.inputMethod) {
|
||||
Qt.inputMethod.hide();
|
||||
Qt.inputMethod.reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
|
||||
@@ -57,7 +57,11 @@ Rectangle {
|
||||
return;
|
||||
if (response.error)
|
||||
return;
|
||||
const result = response.result ?? {};
|
||||
if (!response.result) {
|
||||
ClipboardService.refresh();
|
||||
return;
|
||||
}
|
||||
const result = response.result;
|
||||
const mimeType = (result.mimeType ?? entry?.mimeType ?? "").toString();
|
||||
const data = (result.data ?? "").toString();
|
||||
if (data.length === 0 || !resolvedSourceUrl(data, mimeType))
|
||||
|
||||
@@ -1721,11 +1721,15 @@ Item {
|
||||
return "";
|
||||
var idx = text.toLowerCase().indexOf(lowerQuery);
|
||||
if (idx === -1)
|
||||
return text;
|
||||
return _escapeRichText(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 + '">' + before + '</span><span style="color:' + highlightColor + '; font-weight:600">' + match + '</span><span style="color:' + baseColor + '">' + after + '</span>';
|
||||
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function getCurrentSectionViewMode() {
|
||||
|
||||
@@ -42,20 +42,12 @@ 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: {
|
||||
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 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 int baseWidth: {
|
||||
switch (SettingsData.dankLauncherV2Size) {
|
||||
@@ -240,52 +232,65 @@ Item {
|
||||
onTriggered: root._flushSync()
|
||||
}
|
||||
|
||||
property string _chromeClaimId: ""
|
||||
property bool _fullSyncPending: false
|
||||
|
||||
function _nextChromeClaimId() {
|
||||
return "dms:launcher-v2:" + (new Date()).getTime() + ":" + Math.floor(Math.random() * 1000);
|
||||
}
|
||||
|
||||
function _currentScreenName() {
|
||||
return effectiveScreen ? effectiveScreen.name : "";
|
||||
}
|
||||
|
||||
function _publishModalChromeState(isClaim) {
|
||||
const screenName = _currentScreenName();
|
||||
if (!screenName)
|
||||
return;
|
||||
ConnectedModalChrome {
|
||||
id: modalChrome
|
||||
modalHandle: root.modalHandle
|
||||
claimPrefix: "dms:launcher-v2"
|
||||
surfaceKind: "launcher"
|
||||
screenName: root._currentScreenName()
|
||||
enabled: root.frameOwnsConnectedChrome
|
||||
active: root.spotlightOpen
|
||||
presented: root.spotlightOpen || contentWindow.visible
|
||||
dockBlocked: root._dockBlocksEmergence
|
||||
dockSide: root.resolvedConnectedBarSide
|
||||
onRecoveryRequested: root._queueFullSync()
|
||||
}
|
||||
|
||||
function _publishModalChromeState() {
|
||||
const presented = spotlightOpen || contentWindow.visible;
|
||||
const phase = !presented ? "hidden" : (isClosing ? "closing" : (!contentWindow.visible ? "opening" : "open"));
|
||||
const bodyRect = {
|
||||
"x": _connectedChromeX,
|
||||
"y": _connectedChromeY,
|
||||
"width": _connectedChromeWidth,
|
||||
"height": _connectedChromeHeight
|
||||
};
|
||||
const animationOffset = {
|
||||
"x": contentContainer ? contentContainer.animX : 0,
|
||||
"y": contentContainer ? contentContainer.animY : 0
|
||||
};
|
||||
const state = {
|
||||
"visible": spotlightOpen || contentWindow.visible,
|
||||
"kind": "launcher",
|
||||
"screenName": root._currentScreenName(),
|
||||
"phase": phase,
|
||||
"visible": presented,
|
||||
"presented": presented,
|
||||
"barSide": resolvedConnectedBarSide,
|
||||
"bodyRect": bodyRect,
|
||||
"animationOffset": animationOffset,
|
||||
"scale": 1,
|
||||
"opacity": Theme.connectedSurfaceColor.a,
|
||||
"bodyX": _connectedChromeX,
|
||||
"bodyY": _connectedChromeY,
|
||||
"bodyW": _connectedChromeWidth,
|
||||
"bodyH": _connectedChromeHeight,
|
||||
"animX": contentContainer ? contentContainer.animX : 0,
|
||||
"animY": contentContainer ? contentContainer.animY : 0,
|
||||
"animX": animationOffset.x,
|
||||
"animY": animationOffset.y,
|
||||
"omitStartConnector": false,
|
||||
"omitEndConnector": false
|
||||
"omitEndConnector": false,
|
||||
"dockRetractSide": root._dockBlocksEmergence ? resolvedConnectedBarSide : ""
|
||||
};
|
||||
if (isClaim)
|
||||
ConnectedModeState.claimModalState(screenName, state, _chromeClaimId);
|
||||
else
|
||||
ConnectedModeState.updateModalState(screenName, state, _chromeClaimId);
|
||||
return modalChrome.publish(state);
|
||||
}
|
||||
|
||||
function _syncModalChromeState() {
|
||||
if (!frameOwnsConnectedChrome) {
|
||||
_releaseModalChrome();
|
||||
return;
|
||||
}
|
||||
const isClaim = !_chromeClaimId;
|
||||
if (!_chromeClaimId)
|
||||
_chromeClaimId = _nextChromeClaimId();
|
||||
_publishModalChromeState(isClaim);
|
||||
if (_dockBlocksEmergence && (spotlightOpen || contentWindow.visible))
|
||||
ConnectedModeState.requestDockRetract(_chromeClaimId, _currentScreenName(), resolvedConnectedBarSide);
|
||||
else
|
||||
ConnectedModeState.releaseDockRetract(_chromeClaimId);
|
||||
_publishModalChromeState();
|
||||
}
|
||||
|
||||
property bool _animSyncQueued: false
|
||||
@@ -322,32 +327,21 @@ Item {
|
||||
}
|
||||
|
||||
function _syncModalAnim() {
|
||||
if (!frameOwnsConnectedChrome || !_chromeClaimId)
|
||||
if (!frameOwnsConnectedChrome)
|
||||
return;
|
||||
const screenName = _currentScreenName();
|
||||
if (!screenName || !contentContainer)
|
||||
if (!contentContainer)
|
||||
return;
|
||||
ConnectedModeState.setModalAnim(screenName, contentContainer.animX, contentContainer.animY, _chromeClaimId);
|
||||
modalChrome.updateAnim(contentContainer.animX, contentContainer.animY);
|
||||
}
|
||||
|
||||
function _syncModalBody() {
|
||||
if (!frameOwnsConnectedChrome || !_chromeClaimId)
|
||||
if (!frameOwnsConnectedChrome)
|
||||
return;
|
||||
const screenName = _currentScreenName();
|
||||
if (!screenName)
|
||||
return;
|
||||
ConnectedModeState.setModalBody(screenName, _connectedChromeX, _connectedChromeY, _connectedChromeWidth, _connectedChromeHeight, _chromeClaimId);
|
||||
modalChrome.updateBody(_connectedChromeX, _connectedChromeY, _connectedChromeWidth, _connectedChromeHeight);
|
||||
}
|
||||
|
||||
function _releaseModalChrome() {
|
||||
if (!_chromeClaimId)
|
||||
return;
|
||||
ConnectedModeState.releaseDockRetract(_chromeClaimId);
|
||||
const claimId = _chromeClaimId;
|
||||
_chromeClaimId = "";
|
||||
const screenName = _currentScreenName();
|
||||
if (screenName)
|
||||
ConnectedModeState.clearModalState(screenName, claimId);
|
||||
modalChrome.release();
|
||||
}
|
||||
|
||||
onFrameOwnsConnectedChromeChanged: _syncModalChromeState()
|
||||
@@ -359,8 +353,6 @@ Item {
|
||||
onAlignedWidthChanged: _queueBodySync()
|
||||
onAlignedHeightChanged: _queueBodySync()
|
||||
|
||||
Component.onDestruction: _releaseModalChrome()
|
||||
|
||||
Connections {
|
||||
target: contentWindow
|
||||
function onVisibleChanged() {
|
||||
@@ -587,13 +579,17 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
if (!needsReset)
|
||||
if (!needsReset) {
|
||||
if (root.spotlightOpen)
|
||||
root._queueFullSync();
|
||||
return;
|
||||
}
|
||||
|
||||
const newScreen = CompositorService.getFocusedScreen() ?? Quickshell.screens[0];
|
||||
if (!newScreen)
|
||||
return;
|
||||
|
||||
root._releaseModalChrome();
|
||||
root._windowEnabled = false;
|
||||
backgroundWindow.screen = newScreen;
|
||||
contentWindow.screen = newScreen;
|
||||
@@ -689,7 +685,7 @@ Item {
|
||||
WlrLayershell.namespace: "dms:spotlight"
|
||||
WlrLayershell.layer: root.effectiveLauncherLayer
|
||||
WlrLayershell.exclusiveZone: -1
|
||||
WlrLayershell.keyboardFocus: keyboardActive ? (root.useHyprlandFocusGrab ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) : WlrKeyboardFocus.None
|
||||
WlrLayershell.keyboardFocus: PopoutManager.screenshotActive ? WlrKeyboardFocus.None : (keyboardActive ? (root.useHyprlandFocusGrab ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) : WlrKeyboardFocus.None)
|
||||
|
||||
anchors {
|
||||
left: true
|
||||
|
||||
@@ -32,20 +32,12 @@ 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: {
|
||||
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 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 int _openDuration: 50
|
||||
readonly property int _closeDuration: 40
|
||||
@@ -345,7 +337,7 @@ Item {
|
||||
WlrLayershell.namespace: "dms:spotlight"
|
||||
WlrLayershell.layer: root.effectiveLauncherLayer
|
||||
WlrLayershell.exclusiveZone: -1
|
||||
WlrLayershell.keyboardFocus: keyboardActive ? (root.useHyprlandFocusGrab ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) : WlrKeyboardFocus.None
|
||||
WlrLayershell.keyboardFocus: PopoutManager.screenshotActive ? WlrKeyboardFocus.None : (keyboardActive ? (root.useHyprlandFocusGrab ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) : WlrKeyboardFocus.None)
|
||||
|
||||
anchors {
|
||||
top: true
|
||||
|
||||
@@ -81,20 +81,12 @@ 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: {
|
||||
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 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 real cornerRadius: Theme.cornerRadius
|
||||
readonly property color borderColor: {
|
||||
if (!SettingsData.dankLauncherV2BorderEnabled)
|
||||
@@ -381,7 +373,7 @@ Item {
|
||||
WlrLayershell.namespace: "dms:spotlight"
|
||||
WlrLayershell.layer: root.effectiveLauncherLayer
|
||||
WlrLayershell.exclusiveZone: -1
|
||||
WlrLayershell.keyboardFocus: keyboardActive ? (root.useHyprlandFocusGrab ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) : WlrKeyboardFocus.None
|
||||
WlrLayershell.keyboardFocus: PopoutManager.screenshotActive ? WlrKeyboardFocus.None : (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: root.renderActive ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None
|
||||
WlrLayershell.keyboardFocus: PopoutManager.screenshotActive ? WlrKeyboardFocus.None : (root.renderActive ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None)
|
||||
|
||||
anchors {
|
||||
top: true
|
||||
|
||||
@@ -15,6 +15,7 @@ DankModal {
|
||||
|
||||
shouldBeVisible: false
|
||||
allowStacking: true
|
||||
useOverlayLayer: true
|
||||
modalWidth: 420
|
||||
modalHeight: contentLoader.item ? contentLoader.item.implicitHeight + Theme.spacingM * 2 : 200
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -25,6 +25,7 @@ DankModal {
|
||||
closeOnEscapeKey: true
|
||||
closeOnBackgroundClick: true
|
||||
allowStacking: true
|
||||
useOverlayLayer: true
|
||||
keepPopoutsOpen: true
|
||||
|
||||
onBackgroundClicked: close()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user