mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-06-20 10:05:22 -04:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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
|
||||
|
||||
@@ -54,8 +54,10 @@ func init() {
|
||||
}
|
||||
|
||||
type IncludeResult struct {
|
||||
Exists bool `json:"exists"`
|
||||
Included bool `json:"included"`
|
||||
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) {
|
||||
@@ -106,6 +108,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 +119,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
|
||||
|
||||
@@ -947,9 +947,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"})
|
||||
|
||||
@@ -59,22 +59,29 @@ 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,
|
||||
Use: "sync",
|
||||
Short: "Sync DMS theme and settings with greeter",
|
||||
Long: "Synchronize your current user's DMS theme, settings, and wallpaper configuration with the login greeter screen. Also updates a per-user cache slot at users/<username>/ for multi-account greeter theme preview.\n\nUse --profile on secondary accounts to sync only your own users/<username>/ slot without sudo or greetd changes.",
|
||||
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
profile, _ := cmd.Flags().GetBool("profile")
|
||||
if profile {
|
||||
return nil
|
||||
}
|
||||
return preRunPrivileged(cmd, args)
|
||||
},
|
||||
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")
|
||||
term, _ := cmd.Flags().GetBool("terminal")
|
||||
if term {
|
||||
if err := syncInTerminal(yes, auth, local); err != nil {
|
||||
if err := syncInTerminal(yes, auth, local, profile); err != nil {
|
||||
log.Fatalf("Error launching sync in terminal: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := syncGreeter(yes, auth, local); err != nil {
|
||||
if err := syncGreeter(yes, auth, local, profile); err != nil {
|
||||
log.Fatalf("Error syncing greeter: %v", err)
|
||||
}
|
||||
},
|
||||
@@ -85,6 +92,7 @@ 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)")
|
||||
}
|
||||
|
||||
var greeterEnableCmd = &cobra.Command{
|
||||
@@ -512,8 +520,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) error {
|
||||
syncFlags := make([]string, 0, 4)
|
||||
if nonInteractive {
|
||||
syncFlags = append(syncFlags, "--yes")
|
||||
}
|
||||
@@ -523,6 +531,9 @@ func syncInTerminal(nonInteractive bool, forceAuth bool, local bool) error {
|
||||
if local {
|
||||
syncFlags = append(syncFlags, "--local")
|
||||
}
|
||||
if profileOnly {
|
||||
syncFlags = append(syncFlags, "--profile")
|
||||
}
|
||||
shellSyncCmd := "dms greeter sync"
|
||||
if len(syncFlags) > 0 {
|
||||
shellSyncCmd += " " + strings.Join(syncFlags, " ")
|
||||
@@ -541,7 +552,11 @@ 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 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 +767,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 +872,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)
|
||||
|
||||
@@ -600,6 +600,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)
|
||||
|
||||
@@ -140,7 +140,7 @@ hl.bind("SUPER + bracketright", hl.dsp.layout("preselect r"))
|
||||
|
||||
-- === Sizing & Layout ===
|
||||
hl.bind("SUPER + R", hl.dsp.layout("togglesplit"))
|
||||
hl.bind("SUPER + CTRL + F", hl.dsp.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 +150,10 @@ hl.bind("SUPER + code:20", hl.dsp.window.resize({ x = -100, y = 0, relative = tr
|
||||
hl.bind("SUPER + code:21", hl.dsp.window.resize({ x = 100, y = 0, relative = true }), { description = "Shrink window left" })
|
||||
|
||||
-- === Manual Sizing ===
|
||||
hl.bind("SUPER + minus", hl.dsp.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"))
|
||||
|
||||
@@ -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 {
|
||||
return
|
||||
if info, err := os.Lstat(confPath); err == nil && !info.IsDir() {
|
||||
strayPaths = append(strayPaths, confPath)
|
||||
}
|
||||
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 {
|
||||
if logFn != nil {
|
||||
logFn("Could not move stray hyprland.conf: %v", err)
|
||||
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
|
||||
}
|
||||
if logFn != nil {
|
||||
logFn("Moved stray hyprland.conf to %s", dst)
|
||||
|
||||
ts := time.Now().Format("2006-01-02_15-04-05")
|
||||
moved := 0
|
||||
for _, src := range strayPaths {
|
||||
rel, err := filepath.Rel(configDir, src)
|
||||
if err != nil {
|
||||
rel = filepath.Base(src)
|
||||
}
|
||||
dst := filepath.Join(configDir, hyprlandBackupDirName, ts, rel)
|
||||
if err := moveHyprlandConfigFile(src, dst); err != nil {
|
||||
if logFn != nil {
|
||||
logFn("Could not move stray Hyprland conf file %s: %v", src, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
moved++
|
||||
if logFn != nil {
|
||||
logFn("Moved stray Hyprland conf file to %s", dst)
|
||||
}
|
||||
}
|
||||
if moved > 0 && logFn != nil {
|
||||
logFn("Moved %d stray Hyprland conf file(s) out of the active Lua config tree", moved)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -572,6 +573,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 +1257,16 @@ 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 strings.ToLower(compositor) != "niri" {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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,354 @@ 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 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 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 "killactive":
|
||||
return `hl.dsp.window.kill()`, true
|
||||
case "closewindow":
|
||||
if params == "" {
|
||||
return `hl.dsp.window.close()`, true
|
||||
}
|
||||
return fmt.Sprintf(`hl.dsp.window.close(%s)`, strconv.Quote(params)), true
|
||||
case "killwindow":
|
||||
if params == "" {
|
||||
return `hl.dsp.window.kill()`, true
|
||||
}
|
||||
return fmt.Sprintf(`hl.dsp.window.kill(%s)`, strconv.Quote(params)), true
|
||||
case "togglefloating":
|
||||
return `hl.dsp.window.float({ action = "toggle" })`, true
|
||||
case "setfloating":
|
||||
return `hl.dsp.window.float({ action = "set" })`, true
|
||||
case "settiled":
|
||||
return `hl.dsp.window.float({ action = "unset" })`, 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
|
||||
}
|
||||
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 "pin":
|
||||
if params == "" {
|
||||
return `hl.dsp.window.pin()`, 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 "resizeactive":
|
||||
if expr := dispatcherActiveMoveResize("hl.dsp.window.resize", params); expr != "" {
|
||||
return expr, true
|
||||
}
|
||||
case "moveactive":
|
||||
if expr := dispatcherActiveMoveResize("hl.dsp.window.move", params); expr != "" {
|
||||
return expr, 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 "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 "focuscurrentorlast":
|
||||
return `hl.dsp.focus({ last = true })`, true
|
||||
case "focusurgentorlast":
|
||||
return `hl.dsp.focus({ urgent_or_last = true })`, true
|
||||
case "layoutmsg":
|
||||
if params != "" {
|
||||
return fmt.Sprintf(`hl.dsp.layout(%s)`, strconv.Quote(params)), 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 "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 "togglegroup":
|
||||
return `hl.dsp.group.toggle()`, 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 fmt.Sprintf(`hl.dsp.exec_cmd(%s)`, strconv.Quote("hyprctl dispatch "+action))
|
||||
}
|
||||
|
||||
func luaExprToInternalAction(expr string) string {
|
||||
@@ -498,11 +877,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 +900,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
|
||||
@@ -1004,7 +1017,18 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) {
|
||||
}
|
||||
}
|
||||
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.window.close("):
|
||||
if arg := luaCallStringArgValue(expr, "hl.dsp.window.close"); arg != "" {
|
||||
return "closewindow", arg
|
||||
}
|
||||
return "closewindow", ""
|
||||
case strings.HasPrefix(expr, "hl.dsp.window.kill("):
|
||||
if luaTableBoolFieldValue(expr, "force") {
|
||||
return "forcekillactive", ""
|
||||
}
|
||||
if arg := luaCallStringArgValue(expr, "hl.dsp.window.kill"); arg != "" {
|
||||
return "killwindow", arg
|
||||
}
|
||||
return "killactive", ""
|
||||
case strings.HasPrefix(expr, "hl.dsp.window.fullscreen("):
|
||||
switch luaTableStringField(expr, "mode") {
|
||||
@@ -1014,8 +1038,26 @@ 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("):
|
||||
return "togglefloating", ""
|
||||
switch luaTableStringField(expr, "action") {
|
||||
case "set":
|
||||
return "setfloating", ""
|
||||
case "unset":
|
||||
return "settiled", ""
|
||||
default:
|
||||
return "togglefloating", ""
|
||||
}
|
||||
case strings.HasPrefix(expr, "hl.dsp.window.pin("):
|
||||
if action := 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.group.toggle()"):
|
||||
return "togglegroup", ""
|
||||
case strings.HasPrefix(expr, "hl.dsp.focus("):
|
||||
@@ -1025,18 +1067,43 @@ 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 "
|
||||
}
|
||||
return "moveactive", prefix + x + " " + y
|
||||
case 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,19 +1119,69 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) {
|
||||
if y == "" {
|
||||
y = "0"
|
||||
}
|
||||
return "resizeactive", x + " " + y
|
||||
}
|
||||
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
|
||||
prefix := ""
|
||||
if relative, ok := luaTableBoolField(expr, "relative"); ok && !relative {
|
||||
prefix = "exact "
|
||||
}
|
||||
return "resizeactive", prefix + x + " " + y
|
||||
}
|
||||
case strings.HasPrefix(expr, "hl.dsp.window.swap("):
|
||||
return "swapwindow", luaTableStringField(expr, "direction")
|
||||
case strings.HasPrefix(expr, "hl.dsp.window.alter_zorder("):
|
||||
mode := luaTableStringField(expr, "mode")
|
||||
if mode == "" {
|
||||
mode = luaTableStringField(expr, "zheight")
|
||||
}
|
||||
return joinDispatcherParams("alterzorder", mode, luaTableStringField(expr, "window"))
|
||||
case strings.HasPrefix(expr, "hl.dsp.window.set_prop("):
|
||||
prop := luaTableStringField(expr, "prop")
|
||||
if prop == "" {
|
||||
prop = luaTableStringField(expr, "property")
|
||||
}
|
||||
return joinDispatcherParams("setprop", luaTableStringField(expr, "window"), prop, luaTableStringField(expr, "value"))
|
||||
case strings.HasPrefix(expr, "hl.dsp.workspace.rename("):
|
||||
return joinDispatcherParams("renameworkspace", luaTableStringField(expr, "workspace"), luaTableStringField(expr, "name"))
|
||||
case strings.HasPrefix(expr, "hl.dsp.workspace.move("):
|
||||
workspace := luaTableStringField(expr, "workspace")
|
||||
monitor := luaTableStringField(expr, "monitor")
|
||||
if workspace != "" {
|
||||
return joinDispatcherParams("moveworkspacetomonitor", workspace, monitor)
|
||||
}
|
||||
return "movecurrentworkspacetomonitor", monitor
|
||||
case strings.HasPrefix(expr, "hl.dsp.workspace.swap_monitors("):
|
||||
return joinDispatcherParams("swapactiveworkspaces", luaTableStringField(expr, "monitor1"), luaTableStringField(expr, "monitor2"))
|
||||
case strings.HasPrefix(expr, "hl.dsp.workspace.toggle_special("):
|
||||
return "togglespecialworkspace", luaCallStringArgValue(expr, "hl.dsp.workspace.toggle_special")
|
||||
case strings.HasPrefix(expr, "hl.dsp.layout("):
|
||||
if arg := luaCallStringArgValue(expr, "hl.dsp.layout"); arg != "" {
|
||||
return "layoutmsg", arg
|
||||
}
|
||||
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.Contains(expr, "hl.dsp.exit()"):
|
||||
return "exit", ""
|
||||
default:
|
||||
@@ -1073,6 +1190,17 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) {
|
||||
return "exec", "hyprctl dispatch lua:" + expr
|
||||
}
|
||||
|
||||
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 extractLuaCallStringArg(callExpr, funcName string) string {
|
||||
callExpr = strings.TrimSpace(callExpr)
|
||||
prefix := funcName + "("
|
||||
@@ -1100,10 +1228,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)
|
||||
|
||||
@@ -70,12 +70,17 @@ 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.dsp.window.fullscreen({ mode = "maximized", action = "toggle" })`, "fullscreen", "1"},
|
||||
{`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({ 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.layout("togglesplit")`, "layoutmsg", "togglesplit"},
|
||||
{`hl.dsp.dpms({ action = "toggle" })`, "dpms", "toggle"},
|
||||
{`hl.dsp.workspace.rename({ workspace = "1", name = "work" })`, "renameworkspace", "1 work"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -119,6 +124,41 @@ hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle")) -- Notepad:
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuaActionStringFromHyprlangActionUsesNativeDispatchers(t *testing.T) {
|
||||
tests := []struct {
|
||||
action string
|
||||
want string
|
||||
}{
|
||||
{"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" })`},
|
||||
}
|
||||
|
||||
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 := `hl.dsp.exec_cmd("hyprctl dispatch resizeactive exact 100% 100%")`
|
||||
if got != want {
|
||||
t.Fatalf("luaActionStringFromHyprlangAction() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHyprlandLuaBindsUserOverridesDefaults(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dmsDir := filepath.Join(tmpDir, "dms")
|
||||
@@ -283,6 +323,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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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=(.*)$`)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -79,6 +79,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 {
|
||||
|
||||
+63
@@ -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.
|
||||
@@ -543,6 +590,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 +732,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
|
||||
|
||||
|
||||
@@ -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] || "";
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ Singleton {
|
||||
property var currentModalsByScreen: ({})
|
||||
|
||||
function openModal(modal) {
|
||||
PopoutManager.screenshotActive = false;
|
||||
const screenName = modal.effectiveScreen?.name ?? "unknown";
|
||||
currentModalsByScreen[screenName] = modal;
|
||||
modalChanged();
|
||||
|
||||
@@ -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;
|
||||
@@ -97,6 +101,7 @@ Singleton {
|
||||
function requestPopout(popout, tabIndex, triggerSource) {
|
||||
if (!popout || !popout.screen)
|
||||
return;
|
||||
screenshotActive = false;
|
||||
const screenName = popout.screen.name;
|
||||
const currentPopout = currentPopoutsByScreen[screenName];
|
||||
const triggerId = triggerSource !== undefined ? triggerSource : tabIndex;
|
||||
|
||||
@@ -1353,13 +1353,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1185,6 +1185,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
|
||||
|
||||
@@ -3,6 +3,7 @@ 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
|
||||
@@ -161,6 +162,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()) {
|
||||
@@ -1875,4 +1891,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,526 @@
|
||||
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;
|
||||
}
|
||||
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,6 +49,7 @@ Item {
|
||||
iconName: "push_pin"
|
||||
iconSize: Theme.iconSize - 4
|
||||
iconColor: header.activeTab === "saved" ? Theme.primary : Theme.surfaceText
|
||||
backgroundColor: header.activeTab === "saved" ? Theme.primarySelected : "transparent"
|
||||
visible: header.pinnedCount > 0
|
||||
tooltipText: header.activeTab === "saved" ? I18n.tr("Recent") : I18n.tr("Saved")
|
||||
onClicked: tabChanged(header.activeTab === "saved" ? "recents" : "saved")
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
|
||||
FocusScope {
|
||||
id: root
|
||||
|
||||
property var clearConfirmDialog: null
|
||||
|
||||
property string activeTab: "recents"
|
||||
property bool showKeyboardHints: false
|
||||
property int activeImageLoads: 0
|
||||
readonly property int maxConcurrentLoads: 3
|
||||
|
||||
property string mode: "history"
|
||||
property string searchText: ClipboardService.searchText
|
||||
|
||||
readonly property bool clipboardAvailable: ClipboardService.clipboardAvailable
|
||||
readonly property bool wtypeAvailable: ClipboardService.wtypeAvailable
|
||||
readonly property int totalCount: ClipboardService.totalCount
|
||||
readonly property var clipboardEntries: ClipboardService.clipboardEntries
|
||||
readonly property var pinnedEntries: ClipboardService.pinnedEntries
|
||||
readonly property int pinnedCount: ClipboardService.pinnedCount
|
||||
readonly property var unpinnedEntries: ClipboardService.unpinnedEntries
|
||||
readonly property int selectedIndex: ClipboardService.selectedIndex
|
||||
readonly property bool keyboardNavigationActive: ClipboardService.keyboardNavigationActive
|
||||
|
||||
readonly property var modalFocusScope: root
|
||||
property alias searchField: historyContent.searchField
|
||||
property alias editorView: editorView
|
||||
property alias keyboardController: keyboardController
|
||||
|
||||
signal closeRequested
|
||||
signal instantCloseRequested
|
||||
|
||||
onActiveTabChanged: {
|
||||
ClipboardService.selectedIndex = 0;
|
||||
ClipboardService.keyboardNavigationActive = false;
|
||||
}
|
||||
onSearchTextChanged: ClipboardService.searchText = searchText
|
||||
|
||||
function hide() {
|
||||
closeRequested();
|
||||
}
|
||||
|
||||
function pasteSelected() {
|
||||
ClipboardService.pasteSelected(() => root.instantCloseRequested());
|
||||
}
|
||||
|
||||
function copyEntry(entry) {
|
||||
ClipboardService.copyEntry(entry, () => root.closeRequested());
|
||||
}
|
||||
|
||||
function deleteEntry(entry) {
|
||||
ClipboardService.deleteEntry(entry);
|
||||
}
|
||||
|
||||
function deletePinnedEntry(entry) {
|
||||
ClipboardService.deletePinnedEntry(entry, clearConfirmDialog);
|
||||
}
|
||||
|
||||
function pinEntry(entry) {
|
||||
ClipboardService.pinEntry(entry);
|
||||
}
|
||||
|
||||
function unpinEntry(entry) {
|
||||
ClipboardService.unpinEntry(entry);
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
ClipboardService.clearAll();
|
||||
}
|
||||
|
||||
function getEntryPreview(entry) {
|
||||
return ClipboardService.getEntryPreview(entry);
|
||||
}
|
||||
|
||||
function getEntryType(entry) {
|
||||
return ClipboardService.getEntryType(entry);
|
||||
}
|
||||
|
||||
function updateFilteredModel() {
|
||||
ClipboardService.updateFilteredModel();
|
||||
}
|
||||
|
||||
function refreshClipboard() {
|
||||
ClipboardService.refresh();
|
||||
}
|
||||
|
||||
function editEntry(entry) {
|
||||
if (!entry || entry.isImage) {
|
||||
return;
|
||||
}
|
||||
editorView.setEntry(entry);
|
||||
mode = "editor";
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
activeImageLoads = 0;
|
||||
mode = "history";
|
||||
ClipboardService.reset();
|
||||
keyboardController.reset();
|
||||
}
|
||||
|
||||
focus: true
|
||||
Keys.onPressed: function (event) {
|
||||
keyboardController.handleKey(event);
|
||||
}
|
||||
|
||||
ClipboardKeyboardController {
|
||||
id: keyboardController
|
||||
modal: root
|
||||
}
|
||||
|
||||
Item {
|
||||
id: historyView
|
||||
anchors.fill: parent
|
||||
opacity: 1
|
||||
scale: 1
|
||||
visible: opacity > 0.01
|
||||
enabled: root.mode === "history"
|
||||
|
||||
ClipboardContent {
|
||||
id: historyContent
|
||||
anchors.fill: parent
|
||||
modal: root
|
||||
clearConfirmDialog: root.clearConfirmDialog
|
||||
}
|
||||
}
|
||||
|
||||
ClipboardEditor {
|
||||
id: editorView
|
||||
anchors.fill: parent
|
||||
opacity: 0
|
||||
scale: 0.98
|
||||
visible: opacity > 0.01
|
||||
enabled: root.mode === "editor"
|
||||
focus: root.mode === "editor"
|
||||
modal: root
|
||||
keyController: keyboardController
|
||||
}
|
||||
|
||||
states: [
|
||||
State {
|
||||
name: "history"
|
||||
when: root.mode === "history"
|
||||
PropertyChanges {
|
||||
target: historyView
|
||||
opacity: 1
|
||||
scale: 1
|
||||
}
|
||||
PropertyChanges {
|
||||
target: editorView
|
||||
opacity: 0
|
||||
scale: 0.98
|
||||
}
|
||||
},
|
||||
State {
|
||||
name: "editor"
|
||||
when: root.mode === "editor"
|
||||
PropertyChanges {
|
||||
target: historyView
|
||||
opacity: 0
|
||||
scale: 0.98
|
||||
}
|
||||
PropertyChanges {
|
||||
target: editorView
|
||||
opacity: 1
|
||||
scale: 1
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
transitions: [
|
||||
Transition {
|
||||
from: "history"
|
||||
to: "editor"
|
||||
ParallelAnimation {
|
||||
NumberAnimation {
|
||||
property: "opacity"
|
||||
duration: Theme.shortDuration
|
||||
easing.type: Theme.standardEasing
|
||||
}
|
||||
NumberAnimation {
|
||||
property: "scale"
|
||||
duration: Theme.shortDuration
|
||||
easing.type: Theme.emphasizedEasing
|
||||
}
|
||||
}
|
||||
},
|
||||
Transition {
|
||||
from: "editor"
|
||||
to: "history"
|
||||
ParallelAnimation {
|
||||
NumberAnimation {
|
||||
property: "opacity"
|
||||
duration: Theme.shortDuration
|
||||
easing.type: Theme.standardEasing
|
||||
}
|
||||
NumberAnimation {
|
||||
property: "scale"
|
||||
duration: Theme.shortDuration
|
||||
easing.type: Theme.emphasizedEasing
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -17,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -689,7 +689,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
|
||||
|
||||
@@ -345,7 +345,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
|
||||
|
||||
@@ -381,7 +381,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
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Modals.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
import Quickshell.Services.UPower
|
||||
|
||||
DankModal {
|
||||
id: root
|
||||
|
||||
layerNamespace: "dms:power-profiles"
|
||||
keepPopoutsOpen: true
|
||||
|
||||
property int selectedIndex: 0
|
||||
property var profileModel: PowerProfileWatcher.availableProfiles
|
||||
|
||||
function openCentered() {
|
||||
open();
|
||||
}
|
||||
|
||||
function hideDialog() {
|
||||
close();
|
||||
}
|
||||
|
||||
shouldBeVisible: false
|
||||
modalWidth: 440
|
||||
modalHeight: 290
|
||||
enableShadow: true
|
||||
onBackgroundClicked: hideDialog()
|
||||
|
||||
onShouldBeVisibleChanged: {
|
||||
if (!shouldBeVisible)
|
||||
return;
|
||||
|
||||
if (typeof PowerProfiles !== "undefined") {
|
||||
const current = PowerProfiles.profile;
|
||||
const idx = profileModel.indexOf(current);
|
||||
if (idx !== -1) {
|
||||
selectedIndex = idx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onShouldHaveFocusChanged: {
|
||||
if (!shouldHaveFocus)
|
||||
return;
|
||||
Qt.callLater(() => modalFocusScope.forceActiveFocus());
|
||||
}
|
||||
|
||||
modalFocusScope.Keys.onPressed: event => {
|
||||
if (event.isAutoRepeat) {
|
||||
event.accepted = true;
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case Qt.Key_Left:
|
||||
case Qt.Key_Up:
|
||||
case Qt.Key_Backtab:
|
||||
selectedIndex = (selectedIndex - 1 + profileModel.length) % profileModel.length;
|
||||
event.accepted = true;
|
||||
break;
|
||||
case Qt.Key_Right:
|
||||
case Qt.Key_Down:
|
||||
case Qt.Key_Tab:
|
||||
selectedIndex = (selectedIndex + 1) % profileModel.length;
|
||||
event.accepted = true;
|
||||
break;
|
||||
case Qt.Key_Space:
|
||||
case Qt.Key_Return:
|
||||
case Qt.Key_Enter:
|
||||
if (selectedIndex >= 0 && selectedIndex < profileModel.length) {
|
||||
setProfile(profileModel[selectedIndex]);
|
||||
}
|
||||
event.accepted = true;
|
||||
break;
|
||||
case Qt.Key_1:
|
||||
if (profileModel.length > 0) {
|
||||
setProfile(profileModel[0]);
|
||||
}
|
||||
event.accepted = true;
|
||||
break;
|
||||
case Qt.Key_2:
|
||||
if (profileModel.length > 1) {
|
||||
setProfile(profileModel[1]);
|
||||
}
|
||||
event.accepted = true;
|
||||
break;
|
||||
case Qt.Key_3:
|
||||
if (profileModel.length > 2) {
|
||||
setProfile(profileModel[2]);
|
||||
}
|
||||
event.accepted = true;
|
||||
break;
|
||||
case Qt.Key_Escape:
|
||||
hideDialog();
|
||||
event.accepted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function setProfile(profile) {
|
||||
if (PowerProfileWatcher.applyProfile(profile)) {
|
||||
hideDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!PowerProfileWatcher.available)
|
||||
ToastService.showError(I18n.tr("power-profiles-daemon not available"));
|
||||
else
|
||||
ToastService.showError(I18n.tr("Failed to set power profile"));
|
||||
}
|
||||
|
||||
content: Component {
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.spacingL
|
||||
spacing: Theme.spacingL
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
|
||||
Column {
|
||||
width: parent.width - 40
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Power Mode")
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
color: Theme.surfaceText
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Choose a power profile")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceTextMedium
|
||||
width: parent.width
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
DankActionButton {
|
||||
iconName: "close"
|
||||
iconSize: Theme.iconSize - 4
|
||||
iconColor: Theme.surfaceText
|
||||
onClicked: root.hideDialog()
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
id: buttonsRow
|
||||
width: parent.width
|
||||
spacing: Theme.spacingM
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
|
||||
Repeater {
|
||||
model: root.profileModel
|
||||
|
||||
Rectangle {
|
||||
id: profileButton
|
||||
required property int index
|
||||
required property int modelData
|
||||
|
||||
readonly property bool isSelected: root.selectedIndex === index
|
||||
readonly property bool isActive: (typeof PowerProfiles !== "undefined") && PowerProfiles.profile === modelData
|
||||
|
||||
width: (parent.width - Theme.spacingM * (root.profileModel.length - 1)) / root.profileModel.length
|
||||
height: 120
|
||||
radius: Theme.cornerRadius
|
||||
|
||||
color: {
|
||||
if (isActive)
|
||||
return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.16);
|
||||
if (isSelected)
|
||||
return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08);
|
||||
if (mouseArea.containsMouse)
|
||||
return Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.12);
|
||||
return Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.06);
|
||||
}
|
||||
|
||||
border.color: isActive ? Theme.primary : (isSelected ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.5) : "transparent")
|
||||
border.width: (isActive || isSelected) ? 2 : 0
|
||||
|
||||
// Shortcut Key Badge on Top-Right Corner
|
||||
Rectangle {
|
||||
anchors.top: parent.top
|
||||
anchors.right: parent.right
|
||||
anchors.margins: Theme.spacingS
|
||||
width: 20
|
||||
height: 20
|
||||
radius: 4
|
||||
color: isActive ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.2) : Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.08)
|
||||
border.color: isActive ? Theme.primary : "transparent"
|
||||
border.width: isActive ? 1 : 0
|
||||
|
||||
StyledText {
|
||||
text: (index + 1).toString()
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Bold
|
||||
color: isActive ? Theme.primary : Theme.surfaceTextMedium
|
||||
anchors.centerIn: parent
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.centerIn: parent
|
||||
spacing: Theme.spacingS
|
||||
|
||||
DankIcon {
|
||||
name: Theme.getPowerProfileIcon(modelData)
|
||||
size: Theme.iconSize + 16
|
||||
color: isActive ? Theme.primary : Theme.surfaceText
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: Theme.getPowerProfileLabel(modelData)
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: isActive ? Theme.primary : Theme.surfaceText
|
||||
font.weight: Font.Medium
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: mouseArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onEntered: {
|
||||
root.selectedIndex = index;
|
||||
}
|
||||
onClicked: {
|
||||
root.setProfile(modelData);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Selected power profile description
|
||||
StyledText {
|
||||
text: (root.selectedIndex >= 0 && root.selectedIndex < root.profileModel.length) ? Theme.getPowerProfileDescription(root.profileModel[root.selectedIndex]) : ""
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: Theme.surfaceTextMedium
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
wrapMode: Text.WordWrap
|
||||
width: parent.width - Theme.spacingL * 2
|
||||
}
|
||||
|
||||
// Keyboard Shortcut Guide Footer
|
||||
Row {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
spacing: Theme.spacingXS
|
||||
opacity: 0.5
|
||||
|
||||
DankIcon {
|
||||
name: "keyboard"
|
||||
size: Theme.fontSizeSmall
|
||||
color: Theme.surfaceText
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Use keys 1-3 or arrows, Enter/Space to select")
|
||||
font.pixelSize: Theme.fontSizeSmall - 1
|
||||
color: Theme.surfaceText
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,7 @@ Row {
|
||||
WlrLayershell.namespace: "dms:control-center-widget-library"
|
||||
WlrLayershell.layer: WlrLayershell.Overlay
|
||||
WlrLayershell.exclusiveZone: -1
|
||||
WlrLayershell.keyboardFocus: visible ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None
|
||||
WlrLayershell.keyboardFocus: PopoutManager.screenshotActive ? WlrKeyboardFocus.None : (visible ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None)
|
||||
|
||||
anchors {
|
||||
top: true
|
||||
|
||||
@@ -24,14 +24,13 @@ Rectangle {
|
||||
}
|
||||
|
||||
function setProfile(profile) {
|
||||
if (typeof PowerProfiles === "undefined") {
|
||||
ToastService.showError(I18n.tr("power-profiles-daemon not available"));
|
||||
if (PowerProfileWatcher.applyProfile(profile))
|
||||
return;
|
||||
}
|
||||
PowerProfiles.profile = profile;
|
||||
if (PowerProfiles.profile !== profile) {
|
||||
|
||||
if (!PowerProfileWatcher.available)
|
||||
ToastService.showError(I18n.tr("power-profiles-daemon not available"));
|
||||
else
|
||||
ToastService.showError(I18n.tr("Failed to set power profile"));
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
@@ -193,7 +192,7 @@ Rectangle {
|
||||
}
|
||||
|
||||
DankButtonGroup {
|
||||
property var profileModel: (typeof PowerProfiles !== "undefined") ? [PowerProfile.PowerSaver, PowerProfile.Balanced].concat(PowerProfiles.hasPerformanceProfile ? [PowerProfile.Performance] : []) : [PowerProfile.PowerSaver, PowerProfile.Balanced, PowerProfile.Performance]
|
||||
property var profileModel: PowerProfileWatcher.availableProfiles
|
||||
property int currentProfileIndex: {
|
||||
if (typeof PowerProfiles === "undefined")
|
||||
return 1;
|
||||
|
||||
@@ -21,14 +21,13 @@ DankPopout {
|
||||
}
|
||||
|
||||
function setProfile(profile) {
|
||||
if (typeof PowerProfiles === "undefined") {
|
||||
ToastService.showError(I18n.tr("power-profiles-daemon not available"));
|
||||
if (PowerProfileWatcher.applyProfile(profile))
|
||||
return;
|
||||
}
|
||||
PowerProfiles.profile = profile;
|
||||
if (PowerProfiles.profile !== profile) {
|
||||
|
||||
if (!PowerProfileWatcher.available)
|
||||
ToastService.showError(I18n.tr("power-profiles-daemon not available"));
|
||||
else
|
||||
ToastService.showError(I18n.tr("Failed to set power profile"));
|
||||
}
|
||||
}
|
||||
|
||||
popupWidth: 400
|
||||
@@ -555,7 +554,7 @@ DankPopout {
|
||||
DankButtonGroup {
|
||||
id: profileButtonGroup
|
||||
|
||||
property var profileModel: (typeof PowerProfiles !== "undefined") ? [PowerProfile.PowerSaver, PowerProfile.Balanced].concat(PowerProfiles.hasPerformanceProfile ? [PowerProfile.Performance] : []) : [PowerProfile.PowerSaver, PowerProfile.Balanced, PowerProfile.Performance]
|
||||
property var profileModel: PowerProfileWatcher.availableProfiles
|
||||
property int currentProfileIndex: {
|
||||
if (typeof PowerProfiles === "undefined")
|
||||
return 1;
|
||||
|
||||
@@ -140,30 +140,24 @@ BasePill {
|
||||
log.info("Trigger! Delta: " + delta);
|
||||
|
||||
// This is after the other delta checks so it only shows on valid Y scroll
|
||||
if (typeof PowerProfiles === "undefined") {
|
||||
if (!PowerProfileWatcher.available) {
|
||||
ToastService.showError(I18n.tr("power-profiles-daemon not available"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Get list of profiles, and current index
|
||||
const profiles = [PowerProfile.PowerSaver, PowerProfile.Balanced].concat(PowerProfiles.hasPerformanceProfile ? [PowerProfile.Performance] : []);
|
||||
const profiles = PowerProfileWatcher.availableProfiles;
|
||||
var index = profiles.findIndex(profile => PowerProfiles.profile === profile);
|
||||
|
||||
// Step once based on mouse wheel direction
|
||||
if (delta > 0)
|
||||
index += 1;
|
||||
else
|
||||
index -= 1;
|
||||
|
||||
// Already at end of list, can't go further
|
||||
if (index < 0 || index >= profiles.length)
|
||||
return;
|
||||
|
||||
// Set new profile
|
||||
PowerProfiles.profile = profiles[index];
|
||||
if (PowerProfiles.profile !== profiles[index]) {
|
||||
if (!PowerProfileWatcher.applyProfile(profiles[index]))
|
||||
ToastService.showError(I18n.tr("Failed to set power profile"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ BasePill {
|
||||
property int diskUsageMode: (widgetData && widgetData.diskUsageMode !== undefined) ? widgetData.diskUsageMode : 0
|
||||
property bool isHovered: mouseArea.containsMouse
|
||||
property bool isAutoHideBar: false
|
||||
property bool minimumWidth: (widgetData && widgetData.minimumWidth !== undefined) ? widgetData.minimumWidth : true
|
||||
|
||||
property var selectedMount: {
|
||||
if (!DgopService.diskMounts || DgopService.diskMounts.length === 0) {
|
||||
@@ -69,6 +70,8 @@ BasePill {
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: SettingsData
|
||||
|
||||
function onWidgetDataChanged() {
|
||||
root.mountPath = Qt.binding(() => {
|
||||
return (root.widgetData && root.widgetData.mountPath !== undefined) ? root.widgetData.mountPath : "/";
|
||||
@@ -96,14 +99,12 @@ BasePill {
|
||||
return DgopService.diskMounts[0] || null;
|
||||
});
|
||||
}
|
||||
|
||||
target: SettingsData
|
||||
}
|
||||
|
||||
content: Component {
|
||||
Item {
|
||||
implicitWidth: root.isVerticalOrientation ? (root.widgetThickness - root.horizontalPadding * 2) : diskContent.implicitWidth
|
||||
implicitHeight: root.isVerticalOrientation ? diskColumn.implicitHeight : (root.widgetThickness - root.horizontalPadding * 2)
|
||||
implicitHeight: root.isVerticalOrientation ? diskColumn.implicitHeight : diskContent.implicitHeight
|
||||
|
||||
Column {
|
||||
id: diskColumn
|
||||
@@ -118,10 +119,12 @@ BasePill {
|
||||
if (root.diskUsagePercent > 90) {
|
||||
return Theme.tempDanger;
|
||||
}
|
||||
|
||||
if (root.diskUsagePercent > 75) {
|
||||
return Theme.tempWarning;
|
||||
}
|
||||
return Theme.surfaceText;
|
||||
|
||||
return Theme.widgetIconColor;
|
||||
}
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
}
|
||||
@@ -154,24 +157,28 @@ BasePill {
|
||||
id: diskContent
|
||||
visible: !root.isVerticalOrientation
|
||||
anchors.centerIn: parent
|
||||
spacing: 3
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
DankIcon {
|
||||
id: diskIcon
|
||||
name: "storage"
|
||||
size: Theme.barIconSize(root.barThickness, undefined, root.barConfig?.maximizeWidgetIcons, root.barConfig?.iconScale)
|
||||
color: {
|
||||
if (root.diskUsagePercent > 90) {
|
||||
return Theme.tempDanger;
|
||||
}
|
||||
|
||||
if (root.diskUsagePercent > 75) {
|
||||
return Theme.tempWarning;
|
||||
}
|
||||
return Theme.surfaceText;
|
||||
|
||||
return Theme.widgetIconColor;
|
||||
}
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
StyledText {
|
||||
id: mountText
|
||||
text: {
|
||||
if (!root.selectedMount) {
|
||||
return "--";
|
||||
@@ -182,32 +189,20 @@ BasePill {
|
||||
color: Theme.widgetTextColor
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
horizontalAlignment: Text.AlignLeft
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideNone
|
||||
wrapMode: Text.NoWrap
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: {
|
||||
if (root.diskUsagePercent === undefined || root.diskUsagePercent === null || root.diskUsagePercent === 0) {
|
||||
return "--%";
|
||||
}
|
||||
if (!root.selectedMount)
|
||||
return "--%";
|
||||
switch (root.diskUsageMode) {
|
||||
case 1:
|
||||
return root.selectedMount.size || "--";
|
||||
case 2:
|
||||
return root.selectedMount.avail || "--";
|
||||
case 3:
|
||||
return (root.selectedMount.avail || "--") + " / " + (root.selectedMount.size || "--");
|
||||
default:
|
||||
return root.diskUsagePercent.toFixed(0) + "%";
|
||||
}
|
||||
}
|
||||
font.pixelSize: Theme.barTextSize(root.barThickness, root.barConfig?.fontScale, root.barConfig?.maximizeWidgetText)
|
||||
color: Theme.widgetTextColor
|
||||
Item {
|
||||
id: textBox
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
horizontalAlignment: Text.AlignLeft
|
||||
elide: Text.ElideNone
|
||||
|
||||
implicitWidth: root.minimumWidth ? Math.max(diskBaseline.width, diskCurrent.width) : diskCurrent.width
|
||||
implicitHeight: diskText.implicitHeight
|
||||
|
||||
width: implicitWidth
|
||||
height: implicitHeight
|
||||
|
||||
StyledTextMetrics {
|
||||
id: diskBaseline
|
||||
@@ -225,7 +220,40 @@ BasePill {
|
||||
}
|
||||
}
|
||||
|
||||
width: Math.max(diskBaseline.width, paintedWidth)
|
||||
StyledTextMetrics {
|
||||
id: diskCurrent
|
||||
font.pixelSize: Theme.barTextSize(root.barThickness, root.barConfig?.fontScale, root.barConfig?.maximizeWidgetText)
|
||||
text: diskText.text
|
||||
}
|
||||
|
||||
StyledText {
|
||||
id: diskText
|
||||
text: {
|
||||
if (root.diskUsagePercent === undefined || root.diskUsagePercent === null || root.diskUsagePercent === 0) {
|
||||
return "--%";
|
||||
}
|
||||
if (!root.selectedMount)
|
||||
return "--%";
|
||||
switch (root.diskUsageMode) {
|
||||
case 1:
|
||||
return root.selectedMount.size || "--";
|
||||
case 2:
|
||||
return root.selectedMount.avail || "--";
|
||||
case 3:
|
||||
return (root.selectedMount.avail || "--") + " / " + (root.selectedMount.size || "--");
|
||||
default:
|
||||
return root.diskUsagePercent.toFixed(0) + "%";
|
||||
}
|
||||
}
|
||||
font.pixelSize: Theme.barTextSize(root.barThickness, root.barConfig?.fontScale, root.barConfig?.maximizeWidgetText)
|
||||
color: Theme.widgetTextColor
|
||||
|
||||
anchors.fill: parent
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideNone
|
||||
wrapMode: Text.NoWrap
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -981,6 +981,8 @@ BasePill {
|
||||
WlrLayershell.layer: root.barUsesOverlayLayer ? WlrLayershell.Overlay : WlrLayershell.Top
|
||||
WlrLayershell.exclusiveZone: -1
|
||||
WlrLayershell.keyboardFocus: {
|
||||
if (PopoutManager.screenshotActive)
|
||||
return WlrKeyboardFocus.None;
|
||||
if (!root.menuOpen)
|
||||
return WlrKeyboardFocus.None;
|
||||
if (CompositorService.useHyprlandFocusGrab)
|
||||
@@ -1449,6 +1451,8 @@ BasePill {
|
||||
WlrLayershell.layer: root.barUsesOverlayLayer ? WlrLayershell.Overlay : WlrLayershell.Top
|
||||
WlrLayershell.exclusiveZone: -1
|
||||
WlrLayershell.keyboardFocus: {
|
||||
if (PopoutManager.screenshotActive)
|
||||
return WlrKeyboardFocus.None;
|
||||
if (!menuRoot.showMenu)
|
||||
return WlrKeyboardFocus.None;
|
||||
if (CompositorService.useHyprlandFocusGrab)
|
||||
|
||||
@@ -25,14 +25,14 @@ DankPopout {
|
||||
property int __dropdownType: 0
|
||||
property point __dropdownAnchor: Qt.point(0, 0)
|
||||
property bool __dropdownRightEdge: false
|
||||
property var __dropdownPlayer: null
|
||||
property var __dropdownPlayers: []
|
||||
property var __dropdownPlayer: MprisController.activePlayer
|
||||
property var __dropdownPlayers: MprisController.availablePlayers
|
||||
|
||||
function __showVolumeDropdown(pos, rightEdge, player, players) {
|
||||
__dropdownAnchor = pos;
|
||||
__dropdownRightEdge = rightEdge;
|
||||
__dropdownPlayer = player;
|
||||
__dropdownPlayers = players;
|
||||
__dropdownPlayer = Qt.binding(() => MprisController.activePlayer);
|
||||
__dropdownPlayers = Qt.binding(() => MprisController.availablePlayers);
|
||||
__dropdownType = 1;
|
||||
}
|
||||
|
||||
@@ -45,8 +45,8 @@ DankPopout {
|
||||
function __showPlayersDropdown(pos, rightEdge, player, players) {
|
||||
__dropdownAnchor = pos;
|
||||
__dropdownRightEdge = rightEdge;
|
||||
__dropdownPlayer = player;
|
||||
__dropdownPlayers = players;
|
||||
__dropdownPlayer = Qt.binding(() => MprisController.activePlayer);
|
||||
__dropdownPlayers = Qt.binding(() => MprisController.availablePlayers);
|
||||
__dropdownType = 3;
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ DankPopout {
|
||||
id: __volumeCloseTimer
|
||||
interval: 400
|
||||
onTriggered: {
|
||||
if (__dropdownType === 1) {
|
||||
if (__dropdownType !== 0) {
|
||||
__hideDropdowns();
|
||||
}
|
||||
}
|
||||
@@ -230,6 +230,13 @@ DankPopout {
|
||||
return;
|
||||
}
|
||||
|
||||
if (root.currentTabIndex === 1 && mediaLoader.item?.handleKeyEvent) {
|
||||
if (mediaLoader.item.handleKeyEvent(event)) {
|
||||
event.accepted = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (root.currentTabIndex === 2 && wallpaperLoader.item?.handleKeyEvent) {
|
||||
if (wallpaperLoader.item.handleKeyEvent(event)) {
|
||||
event.accepted = true;
|
||||
@@ -394,7 +401,8 @@ DankPopout {
|
||||
root.__showPlayersDropdown(pos, rightEdge, player, players);
|
||||
}
|
||||
onHideDropdowns: root.__hideDropdowns()
|
||||
onVolumeButtonExited: root.__startCloseTimer()
|
||||
onDropdownButtonExited: root.__startCloseTimer()
|
||||
onDropdownButtonEntered: root.__stopCloseTimer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,16 +42,22 @@ Item {
|
||||
signal panelEntered
|
||||
signal panelExited
|
||||
|
||||
property int __volumeHoverCount: 0
|
||||
property int __panelHoverCount: 0
|
||||
|
||||
function volumeAreaEntered() {
|
||||
__volumeHoverCount++;
|
||||
onDropdownTypeChanged: {
|
||||
if (dropdownType === 0) {
|
||||
__panelHoverCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function panelAreaEntered() {
|
||||
__panelHoverCount++;
|
||||
panelEntered();
|
||||
}
|
||||
|
||||
function volumeAreaExited() {
|
||||
__volumeHoverCount = Math.max(0, __volumeHoverCount - 1);
|
||||
if (__volumeHoverCount === 0)
|
||||
function panelAreaExited() {
|
||||
__panelHoverCount = Math.max(0, __panelHoverCount - 1);
|
||||
if (__panelHoverCount === 0)
|
||||
panelExited();
|
||||
}
|
||||
|
||||
@@ -131,8 +137,8 @@ Item {
|
||||
anchors.fill: parent
|
||||
anchors.margins: -12
|
||||
hoverEnabled: true
|
||||
onEntered: volumeAreaEntered()
|
||||
onExited: volumeAreaExited()
|
||||
onEntered: panelAreaEntered()
|
||||
onExited: panelAreaExited()
|
||||
}
|
||||
|
||||
Item {
|
||||
@@ -190,8 +196,8 @@ Item {
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
preventStealing: true
|
||||
|
||||
onEntered: volumeAreaEntered()
|
||||
onExited: volumeAreaExited()
|
||||
onEntered: panelAreaEntered()
|
||||
onExited: panelAreaExited()
|
||||
onPressed: mouse => updateVolume(mouse)
|
||||
onPositionChanged: mouse => {
|
||||
if (pressed)
|
||||
@@ -269,6 +275,14 @@ Item {
|
||||
shadowEnabled: Theme.elevationEnabled && !BlurService.enabled
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
anchors.margins: -12
|
||||
hoverEnabled: true
|
||||
onEntered: panelAreaEntered()
|
||||
onExited: panelAreaExited()
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.spacingM
|
||||
@@ -349,7 +363,13 @@ Item {
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: modelData === AudioService.sink ? "Active" : "Available"
|
||||
text: {
|
||||
if (!modelData?.audio)
|
||||
return modelData === AudioService.sink ? I18n.tr("Active") : I18n.tr("Available");
|
||||
if (modelData.audio.muted)
|
||||
return I18n.tr("Muted", "audio status");
|
||||
return Math.round(modelData.audio.volume * 100) + "%";
|
||||
}
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
elide: Text.ElideRight
|
||||
@@ -369,6 +389,8 @@ Item {
|
||||
root.deviceSelected(modelData);
|
||||
}
|
||||
}
|
||||
onEntered: panelAreaEntered()
|
||||
onExited: panelAreaExited()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -425,6 +447,14 @@ Item {
|
||||
shadowEnabled: Theme.elevationEnabled && !BlurService.enabled
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
anchors.margins: -12
|
||||
hoverEnabled: true
|
||||
onEntered: panelAreaEntered()
|
||||
onExited: panelAreaExited()
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.spacingM
|
||||
@@ -498,15 +528,7 @@ Item {
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: {
|
||||
if (!modelData)
|
||||
return "";
|
||||
const artist = modelData.trackArtist || "";
|
||||
const isActive = modelData === activePlayer;
|
||||
if (artist.length > 0)
|
||||
return artist + (isActive ? " (Active)" : "");
|
||||
return isActive ? "Active" : "Available";
|
||||
}
|
||||
text: modelData?.trackArtist || I18n.tr("Unknown Artist")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
elide: Text.ElideRight
|
||||
@@ -526,6 +548,8 @@ Item {
|
||||
root.playerSelected(modelData);
|
||||
}
|
||||
}
|
||||
onEntered: panelAreaEntered()
|
||||
onExited: panelAreaExited()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ Item {
|
||||
LayoutMirroring.childrenInherit: true
|
||||
|
||||
property MprisPlayer activePlayer: MprisController.activePlayer
|
||||
readonly property real stableLength: MprisController.activePlayerStableLength
|
||||
property var allPlayers: MprisController.availablePlayers
|
||||
property var targetScreen: null
|
||||
property real popoutX: 0
|
||||
@@ -27,7 +28,8 @@ Item {
|
||||
signal showAudioDevicesDropdown(point pos, var screen, bool rightEdge)
|
||||
signal showPlayersDropdown(point pos, var screen, bool rightEdge, var player, var players)
|
||||
signal hideDropdowns
|
||||
signal volumeButtonExited
|
||||
signal dropdownButtonExited
|
||||
signal dropdownButtonEntered
|
||||
|
||||
property bool volumeExpanded: false
|
||||
property bool devicesExpanded: false
|
||||
@@ -39,9 +41,7 @@ Item {
|
||||
playersExpanded = false;
|
||||
}
|
||||
|
||||
DankTooltipV2 {
|
||||
id: sharedTooltip
|
||||
}
|
||||
|
||||
|
||||
readonly property bool isRightEdge: {
|
||||
if (barPosition === SettingsData.Position.Right)
|
||||
@@ -65,8 +65,7 @@ Item {
|
||||
// Derived "no players" state: always correct, no timers.
|
||||
readonly property int _playerCount: allPlayers ? allPlayers.length : 0
|
||||
readonly property bool _noneAvailable: _playerCount === 0
|
||||
readonly property bool _trulyIdle: activePlayer && activePlayer.playbackState === MprisPlaybackState.Stopped && !activePlayer.trackTitle && !activePlayer.trackArtist
|
||||
readonly property bool showNoPlayerNow: (!_switchHold) && (_noneAvailable || _trulyIdle)
|
||||
readonly property bool showNoPlayerNow: (!_switchHold) && (_noneAvailable || !activePlayer)
|
||||
|
||||
property bool _switchHold: false
|
||||
Timer {
|
||||
@@ -85,7 +84,6 @@ Item {
|
||||
isSwitching = true;
|
||||
_switchHold = true;
|
||||
_switchHoldTimer.restart();
|
||||
TrackArtService.loadArtwork(activePlayer.trackArtUrl);
|
||||
}
|
||||
|
||||
function maybeFinishSwitch() {
|
||||
@@ -96,11 +94,11 @@ Item {
|
||||
}
|
||||
|
||||
readonly property real ratio: {
|
||||
if (!activePlayer || !activePlayer.length || activePlayer.length <= 0) {
|
||||
if (!activePlayer || stableLength <= 0) {
|
||||
return 0;
|
||||
}
|
||||
const pos = (activePlayer.position || 0) % Math.max(1, activePlayer.length);
|
||||
const calculatedRatio = pos / activePlayer.length;
|
||||
const pos = (activePlayer.position || 0) % Math.max(1, stableLength);
|
||||
const calculatedRatio = pos / stableLength;
|
||||
return Math.max(0, Math.min(1, calculatedRatio));
|
||||
}
|
||||
|
||||
@@ -109,13 +107,11 @@ Item {
|
||||
|
||||
Connections {
|
||||
target: activePlayer
|
||||
ignoreUnknownSignals: true
|
||||
function onTrackTitleChanged() {
|
||||
_switchHoldTimer.restart();
|
||||
maybeFinishSwitch();
|
||||
}
|
||||
function onTrackArtUrlChanged() {
|
||||
TrackArtService.loadArtwork(activePlayer.trackArtUrl);
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
@@ -186,6 +182,102 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
function triggerVolumeDropdown() {
|
||||
if (!volumeAvailable)
|
||||
return;
|
||||
if (volumeExpanded)
|
||||
return;
|
||||
hideDropdowns();
|
||||
volumeExpanded = true;
|
||||
const buttonsOnRight = !isRightEdge;
|
||||
const btnY = volumeButton.y + volumeButton.height / 2;
|
||||
const screenX = buttonsOnRight ? (popoutX + popoutWidth) : popoutX;
|
||||
const screenY = popoutY + contentOffsetY + btnY;
|
||||
showVolumeDropdown(Qt.point(screenX, screenY), targetScreen, buttonsOnRight, activePlayer, allPlayers);
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
if (!volumeAvailable)
|
||||
return;
|
||||
SessionData.suppressOSDTemporarily();
|
||||
if (currentVolume > 0) {
|
||||
volumeButton.previousVolume = currentVolume;
|
||||
if (usePlayerVolume) {
|
||||
activePlayer.volume = 0;
|
||||
} else if (AudioService.sink?.audio) {
|
||||
AudioService.sink.audio.volume = 0;
|
||||
}
|
||||
} else {
|
||||
const restoreVolume = volumeButton.previousVolume > 0 ? volumeButton.previousVolume : 0.5;
|
||||
if (usePlayerVolume) {
|
||||
activePlayer.volume = restoreVolume;
|
||||
} else if (AudioService.sink?.audio) {
|
||||
AudioService.sink.audio.volume = restoreVolume;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyEvent(event) {
|
||||
if (!activePlayer)
|
||||
return false;
|
||||
|
||||
// 1. Number keys 0-9 to seek to 0%-90%
|
||||
if (event.key >= Qt.Key_0 && event.key <= Qt.Key_9) {
|
||||
if (activePlayer.canSeek && stableLength > 0) {
|
||||
const ratio = (event.key - Qt.Key_0) * 0.1;
|
||||
const targetPosition = ratio * stableLength;
|
||||
activePlayer.position = Math.max(0.1, Math.min(targetPosition, stableLength * 0.99));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Left / Right arrows to seek backward / forward 5s
|
||||
if (event.key === Qt.Key_Left) {
|
||||
if (activePlayer.canSeek) {
|
||||
activePlayer.position = Math.max(0.1, activePlayer.position - 5);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (event.key === Qt.Key_Right) {
|
||||
if (activePlayer.canSeek && stableLength > 0) {
|
||||
activePlayer.position = Math.max(0.1, Math.min(stableLength - 1, activePlayer.position + 5));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Up / Down arrows to adjust volume
|
||||
if (event.key === Qt.Key_Up) {
|
||||
adjustVolume(5);
|
||||
triggerVolumeDropdown();
|
||||
dropdownButtonExited();
|
||||
return true;
|
||||
}
|
||||
if (event.key === Qt.Key_Down) {
|
||||
adjustVolume(-5);
|
||||
triggerVolumeDropdown();
|
||||
dropdownButtonExited();
|
||||
return true;
|
||||
}
|
||||
|
||||
// 4. Spacebar to play/pause
|
||||
if (event.key === Qt.Key_Space) {
|
||||
if (activePlayer.canTogglePlaying) {
|
||||
activePlayer.togglePlaying();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. M key to toggle mute
|
||||
if (event.key === Qt.Key_M) {
|
||||
toggleMute();
|
||||
triggerVolumeDropdown();
|
||||
dropdownButtonExited();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
property bool isSeeking: false
|
||||
|
||||
Timer {
|
||||
@@ -198,14 +290,14 @@ Item {
|
||||
Item {
|
||||
id: bgContainer
|
||||
anchors.fill: parent
|
||||
visible: TrackArtService._bgArtSource !== ""
|
||||
visible: TrackArtService.resolvedArtUrl !== ""
|
||||
|
||||
Image {
|
||||
id: bgImage
|
||||
anchors.centerIn: parent
|
||||
width: Math.max(parent.width, parent.height) * 1.1
|
||||
height: width
|
||||
source: TrackArtService._bgArtSource
|
||||
source: TrackArtService.resolvedArtUrl
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
asynchronous: true
|
||||
cache: true
|
||||
@@ -331,7 +423,7 @@ Item {
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: activePlayer?.trackTitle || I18n.tr("Unknown Artist")
|
||||
text: activePlayer?.trackArtist || I18n.tr("Unknown Artist")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.8)
|
||||
width: parent.width
|
||||
@@ -389,7 +481,7 @@ Item {
|
||||
if (!activePlayer)
|
||||
return "0:00";
|
||||
const rawPos = Math.max(0, activePlayer.position || 0);
|
||||
const pos = activePlayer.length ? rawPos % Math.max(1, activePlayer.length) : rawPos;
|
||||
const pos = stableLength ? rawPos % Math.max(1, stableLength) : rawPos;
|
||||
const minutes = Math.floor(pos / 60);
|
||||
const seconds = Math.floor(pos % 60);
|
||||
const timeStr = minutes + ":" + (seconds < 10 ? "0" : "") + seconds;
|
||||
@@ -403,9 +495,9 @@ Item {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: {
|
||||
if (!activePlayer || !activePlayer.length)
|
||||
return "0:00";
|
||||
const dur = Math.max(0, activePlayer.length || 0);
|
||||
if (!activePlayer || stableLength <= 0)
|
||||
return "--:--";
|
||||
const dur = stableLength;
|
||||
const minutes = Math.floor(dur / 60);
|
||||
const seconds = Math.floor(dur % 60);
|
||||
return minutes + ":" + (seconds < 10 ? "0" : "") + seconds;
|
||||
@@ -647,7 +739,17 @@ Item {
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
if (playersExpanded) {
|
||||
hideDropdowns();
|
||||
if (allPlayers && allPlayers.length > 1) {
|
||||
let currentIndex = -1;
|
||||
for (let i = 0; i < allPlayers.length; i++) {
|
||||
if (allPlayers[i] === activePlayer) {
|
||||
currentIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const nextIndex = (currentIndex + 1) % allPlayers.length;
|
||||
MprisController.setActivePlayer(allPlayers[nextIndex]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
hideDropdowns();
|
||||
@@ -658,8 +760,22 @@ Item {
|
||||
const screenY = popoutY + contentOffsetY + btnY;
|
||||
showPlayersDropdown(Qt.point(screenX, screenY), targetScreen, buttonsOnRight, activePlayer, allPlayers);
|
||||
}
|
||||
onEntered: sharedTooltip.show(I18n.tr("Media Players"), playerSelectorButton, 0, 0, isRightEdge ? "right" : "left")
|
||||
onExited: sharedTooltip.hide()
|
||||
onEntered: {
|
||||
dropdownButtonEntered();
|
||||
if (playersExpanded)
|
||||
return;
|
||||
hideDropdowns();
|
||||
playersExpanded = true;
|
||||
const buttonsOnRight = !isRightEdge;
|
||||
const btnY = playerSelectorButton.y + playerSelectorButton.height / 2;
|
||||
const screenX = buttonsOnRight ? (popoutX + popoutWidth) : popoutX;
|
||||
const screenY = popoutY + contentOffsetY + btnY;
|
||||
showPlayersDropdown(Qt.point(screenX, screenY), targetScreen, buttonsOnRight, activePlayer, allPlayers);
|
||||
}
|
||||
onExited: {
|
||||
if (playersExpanded)
|
||||
dropdownButtonExited();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -691,6 +807,7 @@ Item {
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onEntered: {
|
||||
dropdownButtonEntered();
|
||||
if (volumeExpanded)
|
||||
return;
|
||||
hideDropdowns();
|
||||
@@ -703,25 +820,10 @@ Item {
|
||||
}
|
||||
onExited: {
|
||||
if (volumeExpanded)
|
||||
volumeButtonExited();
|
||||
dropdownButtonExited();
|
||||
}
|
||||
onClicked: {
|
||||
SessionData.suppressOSDTemporarily();
|
||||
if (currentVolume > 0) {
|
||||
volumeButton.previousVolume = currentVolume;
|
||||
if (usePlayerVolume) {
|
||||
activePlayer.volume = 0;
|
||||
} else if (AudioService.sink?.audio) {
|
||||
AudioService.sink.audio.volume = 0;
|
||||
}
|
||||
} else {
|
||||
const restoreVolume = volumeButton.previousVolume > 0 ? volumeButton.previousVolume : 0.5;
|
||||
if (usePlayerVolume) {
|
||||
activePlayer.volume = restoreVolume;
|
||||
} else if (AudioService.sink?.audio) {
|
||||
AudioService.sink.audio.volume = restoreVolume;
|
||||
}
|
||||
}
|
||||
toggleMute();
|
||||
}
|
||||
onWheel: wheelEvent => {
|
||||
SessionData.suppressOSDTemporarily();
|
||||
@@ -754,7 +856,7 @@ Item {
|
||||
|
||||
DankIcon {
|
||||
anchors.centerIn: parent
|
||||
name: devicesExpanded ? "expand_less" : "speaker"
|
||||
name: "speaker"
|
||||
size: 18
|
||||
color: Theme.surfaceText
|
||||
}
|
||||
@@ -766,7 +868,18 @@ Item {
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
if (devicesExpanded) {
|
||||
hideDropdowns();
|
||||
const sinks = AudioService.getAvailableSinks();
|
||||
if (sinks && sinks.length > 1) {
|
||||
let currentIndex = -1;
|
||||
for (let i = 0; i < sinks.length; i++) {
|
||||
if (sinks[i]?.name === AudioService.sink?.name) {
|
||||
currentIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const nextIndex = (currentIndex + 1) % sinks.length;
|
||||
AudioService.setSink(sinks[nextIndex]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
hideDropdowns();
|
||||
@@ -777,8 +890,22 @@ Item {
|
||||
const screenY = popoutY + contentOffsetY + btnY;
|
||||
showAudioDevicesDropdown(Qt.point(screenX, screenY), targetScreen, buttonsOnRight);
|
||||
}
|
||||
onEntered: sharedTooltip.show(I18n.tr("Output Device"), audioDevicesButton, 0, 0, isRightEdge ? "right" : "left")
|
||||
onExited: sharedTooltip.hide()
|
||||
onEntered: {
|
||||
dropdownButtonEntered();
|
||||
if (devicesExpanded)
|
||||
return;
|
||||
hideDropdowns();
|
||||
devicesExpanded = true;
|
||||
const buttonsOnRight = !isRightEdge;
|
||||
const btnY = audioDevicesButton.y + audioDevicesButton.height / 2;
|
||||
const screenX = buttonsOnRight ? (popoutX + popoutWidth) : popoutX;
|
||||
const screenY = popoutY + contentOffsetY + btnY;
|
||||
showAudioDevicesDropdown(Qt.point(screenX, screenY), targetScreen, buttonsOnRight);
|
||||
}
|
||||
onExited: {
|
||||
if (devicesExpanded)
|
||||
dropdownButtonExited();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,10 +15,11 @@ Card {
|
||||
property real displayPosition: currentPosition
|
||||
|
||||
readonly property real ratio: {
|
||||
if (!activePlayer || activePlayer.length <= 0)
|
||||
const len = MprisController.activePlayerStableLength;
|
||||
if (!activePlayer || !activePlayer.lengthSupported || len <= 0)
|
||||
return 0;
|
||||
const pos = displayPosition % Math.max(1, activePlayer.length);
|
||||
const calculatedRatio = pos / activePlayer.length;
|
||||
const pos = displayPosition % Math.max(1, len);
|
||||
const calculatedRatio = pos / len;
|
||||
return Math.max(0, Math.min(1, calculatedRatio));
|
||||
}
|
||||
|
||||
|
||||
@@ -12,16 +12,24 @@ Singleton {
|
||||
id: root
|
||||
readonly property var log: Log.scoped("GreetdSettings")
|
||||
|
||||
readonly property string configPath: {
|
||||
const greetCfgDir = Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter";
|
||||
return greetCfgDir + "/settings.json";
|
||||
readonly property string _greeterCacheDir: Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter"
|
||||
|
||||
property string configBaseDir: root._greeterCacheDir
|
||||
readonly property string configPath: root.configBaseDir ? (root.configBaseDir + "/settings.json") : ""
|
||||
readonly property string greeterWallpaperOverridePath: root.configBaseDir ? (root.configBaseDir + "/greeter_wallpaper_override.jpg") : ""
|
||||
|
||||
function setConfigBaseDir(dir) {
|
||||
const next = dir || root._greeterCacheDir;
|
||||
if (configBaseDir === next)
|
||||
return;
|
||||
configBaseDir = next;
|
||||
settingsLoaded = false;
|
||||
settingsFile.reload();
|
||||
}
|
||||
|
||||
readonly property string _greeterCacheDir: {
|
||||
const i = root.configPath.lastIndexOf("/");
|
||||
return i >= 0 ? root.configPath.substring(0, i) : "";
|
||||
function resetConfigBaseDir() {
|
||||
setConfigBaseDir(root._greeterCacheDir);
|
||||
}
|
||||
readonly property string greeterWallpaperOverridePath: root._greeterCacheDir ? (root._greeterCacheDir + "/greeter_wallpaper_override.jpg") : ""
|
||||
|
||||
property string currentThemeName: "purple"
|
||||
property bool settingsLoaded: false
|
||||
|
||||
@@ -62,6 +62,14 @@ Item {
|
||||
readonly property bool greeterPamHasU2f: greeterPamStackHasModule("pam_u2f")
|
||||
readonly property bool greeterExternalAuthAvailable: (greeterPamHasFprint && GreetdSettings.greeterEnableFprint) || (greeterPamHasU2f && GreetdSettings.greeterEnableU2f)
|
||||
readonly property bool greeterPamHasExternalAuth: greeterPamHasFprint || greeterPamHasU2f
|
||||
readonly property bool multipleUsersAvailable: GreeterUsersService.loaded && GreeterUsersService.users.length > 1
|
||||
readonly property bool showUserPicker: multipleUsersAvailable && !GreeterState.showPasswordInput && !manualUsernameEntry
|
||||
readonly property bool showAccountSwitchLink: multipleUsersAvailable && !GreeterState.showPasswordInput && !GreeterState.unlocking
|
||||
readonly property int userPickerMaxHeight: Math.min(400, Math.max(120, height * 0.35))
|
||||
property bool userListOpen: false
|
||||
property bool manualUsernameEntry: false
|
||||
property bool skipAutoSelectUser: false
|
||||
property string pickerThemeUsername: ""
|
||||
|
||||
function initWeatherService() {
|
||||
if (weatherInitialized)
|
||||
@@ -428,20 +436,87 @@ Item {
|
||||
fprintdDeviceProbe.running = true;
|
||||
}
|
||||
|
||||
function applyPickerPreviewTheme() {
|
||||
let previewUser = (pickerThemeUsername || "").trim();
|
||||
if (!previewUser && GreetdSettings.rememberLastUser)
|
||||
previewUser = (GreetdMemory.lastSuccessfulUser || "").trim();
|
||||
if (previewUser)
|
||||
GreeterUserTheme.applyForUser(previewUser);
|
||||
else
|
||||
GreeterUserTheme.applyDefault();
|
||||
}
|
||||
|
||||
function applyLastSuccessfulUser() {
|
||||
if (root.skipAutoSelectUser)
|
||||
return;
|
||||
if (!GreetdSettings.settingsLoaded || !GreetdSettings.rememberLastUser)
|
||||
return;
|
||||
const lastUser = GreetdMemory.lastSuccessfulUser;
|
||||
if (lastUser && !GreeterState.showPasswordInput && !GreeterState.username) {
|
||||
GreeterState.username = lastUser;
|
||||
GreeterState.usernameInput = lastUser;
|
||||
GreeterState.showPasswordInput = true;
|
||||
PortalService.getGreeterUserProfileImage(lastUser);
|
||||
maybeAutoStartExternalAuth();
|
||||
selectUser(lastUser, true);
|
||||
}
|
||||
}
|
||||
|
||||
function submitUsername(rawValue) {
|
||||
function enterManualUsernameEntry() {
|
||||
if (!root.multipleUsersAvailable || GreeterState.showPasswordInput)
|
||||
return;
|
||||
root.manualUsernameEntry = true;
|
||||
root.userListOpen = false;
|
||||
GreeterState.username = "";
|
||||
GreeterState.usernameInput = "";
|
||||
GreeterState.selectedUserIndex = -1;
|
||||
inputField.text = "";
|
||||
root.applyPickerPreviewTheme();
|
||||
Qt.callLater(() => inputField.forceActiveFocus());
|
||||
}
|
||||
|
||||
function returnToUserListFromManualEntry() {
|
||||
if (!root.multipleUsersAvailable)
|
||||
return;
|
||||
root.manualUsernameEntry = false;
|
||||
root.userListOpen = true;
|
||||
GreeterState.username = "";
|
||||
GreeterState.usernameInput = "";
|
||||
inputField.text = "";
|
||||
root.applyPickerPreviewTheme();
|
||||
}
|
||||
|
||||
function returnToUserPicker() {
|
||||
if (!root.multipleUsersAvailable || GreeterState.unlocking)
|
||||
return;
|
||||
root.manualUsernameEntry = false;
|
||||
root.skipAutoSelectUser = true;
|
||||
awaitingExternalAuth = false;
|
||||
pendingPasswordResponse = false;
|
||||
passwordSubmitRequested = false;
|
||||
resetPasswordSessionTransition(true);
|
||||
authTimeout.interval = defaultAuthTimeoutMs;
|
||||
authTimeout.stop();
|
||||
clearAuthFeedback();
|
||||
passwordFailureCount = 0;
|
||||
externalAuthAutoStartedForUser = "";
|
||||
if (Greetd.state !== GreetdState.Inactive)
|
||||
Greetd.cancelSession();
|
||||
const previousUser = GreeterState.username;
|
||||
GreeterState.reset();
|
||||
inputField.text = "";
|
||||
PortalService.profileImage = "";
|
||||
if (previousUser)
|
||||
root.pickerThemeUsername = previousUser;
|
||||
root.applyPickerPreviewTheme();
|
||||
root.userListOpen = true;
|
||||
}
|
||||
|
||||
function selectUser(rawValue, skipDropdownUpdate) {
|
||||
const user = (rawValue || "").trim();
|
||||
if (!user)
|
||||
return;
|
||||
root.manualUsernameEntry = false;
|
||||
root.skipAutoSelectUser = false;
|
||||
submitUsername(user, skipDropdownUpdate === true);
|
||||
}
|
||||
|
||||
function submitUsername(rawValue, skipDropdownUpdate) {
|
||||
const user = (rawValue || "").trim();
|
||||
if (!user)
|
||||
return;
|
||||
@@ -450,8 +525,15 @@ Item {
|
||||
clearAuthFeedback();
|
||||
externalAuthAutoStartedForUser = "";
|
||||
}
|
||||
root.pickerThemeUsername = user;
|
||||
GreeterState.username = user;
|
||||
GreeterState.usernameInput = user;
|
||||
GreeterState.showPasswordInput = true;
|
||||
if (!skipDropdownUpdate && typeof GreeterUsersService !== "undefined") {
|
||||
const idx = GreeterUsersService.usernames.indexOf(user);
|
||||
GreeterState.selectedUserIndex = idx;
|
||||
}
|
||||
root.userListOpen = false;
|
||||
PortalService.getGreeterUserProfileImage(user);
|
||||
GreeterState.passwordBuffer = "";
|
||||
pendingPasswordResponse = false;
|
||||
@@ -637,13 +719,44 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: GreeterUsersService
|
||||
function onLoadedChanged() {
|
||||
if (GreeterUsersService.loaded && isPrimaryScreen)
|
||||
applyPickerPreviewTheme();
|
||||
}
|
||||
function onSyncedThemePathsChanged() {
|
||||
if (!isPrimaryScreen)
|
||||
return;
|
||||
if (GreeterState.username)
|
||||
GreeterUserTheme.applyForUser(GreeterState.username);
|
||||
else if (root.showUserPicker || root.userListOpen)
|
||||
applyPickerPreviewTheme();
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: GreeterState
|
||||
function onUsernameChanged() {
|
||||
if (GreeterState.username) {
|
||||
root.pickerThemeUsername = GreeterState.username;
|
||||
GreeterUserTheme.applyForUser(GreeterState.username);
|
||||
PortalService.getGreeterUserProfileImage(GreeterState.username);
|
||||
} else if (root.showUserPicker || root.userListOpen) {
|
||||
applyPickerPreviewTheme();
|
||||
}
|
||||
}
|
||||
function onShowPasswordInputChanged() {
|
||||
if (GreeterState.showPasswordInput)
|
||||
root.userListOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
onShowUserPickerChanged: {
|
||||
if (showUserPicker && !GreeterState.username)
|
||||
applyPickerPreviewTheme();
|
||||
if (!showUserPicker)
|
||||
userListOpen = false;
|
||||
}
|
||||
|
||||
FileView {
|
||||
@@ -736,19 +849,26 @@ Item {
|
||||
anchors.fill: parent
|
||||
color: "transparent"
|
||||
|
||||
Item {
|
||||
id: clockContainer
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.bottom: parent.verticalCenter
|
||||
anchors.bottomMargin: 60
|
||||
width: parent.width
|
||||
height: clockText.implicitHeight
|
||||
Column {
|
||||
id: greeterMainColumn
|
||||
|
||||
Row {
|
||||
id: clockText
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.top: parent.top
|
||||
spacing: 0
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Theme.spacingM
|
||||
width: 380
|
||||
|
||||
Item {
|
||||
id: clockContainer
|
||||
|
||||
width: parent.width
|
||||
height: clockText.implicitHeight
|
||||
|
||||
Row {
|
||||
id: clockText
|
||||
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.top: parent.top
|
||||
spacing: 0
|
||||
|
||||
property string fullTimeStr: {
|
||||
const format = GreetdSettings.getEffectiveTimeFormat();
|
||||
@@ -853,60 +973,121 @@ Item {
|
||||
visible: clockText.ampm !== ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
id: dateText
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.top: clockContainer.bottom
|
||||
anchors.topMargin: 4
|
||||
text: {
|
||||
return systemClock.date.toLocaleDateString(I18n.locale(), GreetdSettings.getEffectiveLockDateFormat());
|
||||
}
|
||||
font.pixelSize: Theme.fontSizeXLarge
|
||||
color: "white"
|
||||
opacity: 0.9
|
||||
}
|
||||
|
||||
Item {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.top: dateText.bottom
|
||||
anchors.topMargin: Theme.spacingL
|
||||
width: 380
|
||||
height: 140
|
||||
StyledText {
|
||||
id: dateText
|
||||
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: systemClock.date.toLocaleDateString(I18n.locale(), GreetdSettings.getEffectiveLockDateFormat())
|
||||
font.pixelSize: Theme.fontSizeXLarge
|
||||
color: "white"
|
||||
opacity: 0.9
|
||||
}
|
||||
|
||||
StyledText {
|
||||
id: userPickerHint
|
||||
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
visible: root.showUserPicker && !GreeterState.showPasswordInput && !GreeterState.username && !root.userListOpen
|
||||
text: I18n.tr("Select user...", "greeter user picker placeholder")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: "white"
|
||||
opacity: 0.85
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
id: authColumn
|
||||
|
||||
width: parent.width
|
||||
spacing: Theme.spacingM
|
||||
|
||||
RowLayout {
|
||||
spacing: Theme.spacingL
|
||||
Layout.fillWidth: true
|
||||
|
||||
DankCircularImage {
|
||||
Item {
|
||||
Layout.preferredWidth: 60
|
||||
Layout.preferredHeight: 60
|
||||
imageSource: {
|
||||
if (PortalService.profileImage === "")
|
||||
return "";
|
||||
if (PortalService.profileImage.startsWith("/"))
|
||||
return encodeFileUrl(PortalService.profileImage);
|
||||
return PortalService.profileImage;
|
||||
visible: GreetdSettings.lockScreenShowProfileImage || root.multipleUsersAvailable
|
||||
|
||||
DankCircularImage {
|
||||
anchors.fill: parent
|
||||
imageSource: {
|
||||
const displayUser = GreeterState.username || root.pickerThemeUsername;
|
||||
if (displayUser) {
|
||||
const cachedPath = GreeterUsersService.profileImagePath(displayUser);
|
||||
if (cachedPath)
|
||||
return encodeFileUrl(cachedPath);
|
||||
}
|
||||
if (PortalService.profileImage === "")
|
||||
return "";
|
||||
if (PortalService.profileImage.startsWith("/"))
|
||||
return encodeFileUrl(PortalService.profileImage);
|
||||
return PortalService.profileImage;
|
||||
}
|
||||
fallbackIcon: "person"
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: width / 2
|
||||
color: "transparent"
|
||||
border.color: Theme.primary
|
||||
border.width: avatarPickerArea.containsMouse || root.userListOpen ? 2 : 0
|
||||
visible: root.multipleUsersAvailable
|
||||
Behavior on border.width {
|
||||
NumberAnimation {
|
||||
duration: Theme.shortDuration
|
||||
easing.type: Theme.standardEasing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: avatarPickerArea
|
||||
|
||||
anchors.fill: parent
|
||||
visible: root.multipleUsersAvailable
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
if (GreeterState.showPasswordInput)
|
||||
root.returnToUserPicker();
|
||||
else if (root.manualUsernameEntry)
|
||||
root.returnToUserListFromManualEntry();
|
||||
else
|
||||
root.userListOpen = !root.userListOpen;
|
||||
}
|
||||
}
|
||||
fallbackIcon: "person"
|
||||
visible: GreetdSettings.lockScreenShowProfileImage
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
property bool showPassword: false
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 60
|
||||
Layout.preferredHeight: root.showUserPicker && root.userListOpen ? Math.max(60, userPicker.implicitHeight + Theme.spacingM * 2) : 60
|
||||
|
||||
radius: Theme.cornerRadius
|
||||
color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.9)
|
||||
border.color: inputField.activeFocus ? Theme.primary : Qt.rgba(1, 1, 1, 0.3)
|
||||
border.width: inputField.activeFocus ? 2 : 1
|
||||
|
||||
GreeterUserPicker {
|
||||
id: userPicker
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: root.userListOpen ? undefined : parent.verticalCenter
|
||||
anchors.top: root.userListOpen ? parent.top : undefined
|
||||
anchors.margins: Theme.spacingM
|
||||
maxExpandedHeight: root.userPickerMaxHeight
|
||||
visible: root.showUserPicker && !GreeterState.showPasswordInput
|
||||
expanded: root.userListOpen
|
||||
onUserSelected: username => root.selectUser(username, false)
|
||||
onToggleRequested: root.userListOpen = !root.userListOpen
|
||||
}
|
||||
|
||||
DankIcon {
|
||||
id: lockIcon
|
||||
|
||||
@@ -916,6 +1097,7 @@ Item {
|
||||
name: GreeterState.showPasswordInput ? "lock" : "person"
|
||||
size: 20
|
||||
color: inputField.activeFocus ? Theme.primary : Theme.surfaceVariantText
|
||||
visible: !root.showUserPicker
|
||||
}
|
||||
|
||||
TextInput {
|
||||
@@ -941,8 +1123,9 @@ Item {
|
||||
}
|
||||
return margin;
|
||||
}
|
||||
enabled: !root.showUserPicker || GreeterState.showPasswordInput
|
||||
opacity: 0
|
||||
focus: true
|
||||
focus: !root.showUserPicker || GreeterState.showPasswordInput
|
||||
echoMode: GreeterState.showPasswordInput ? (parent.showPassword ? TextInput.Normal : TextInput.Password) : TextInput.Normal
|
||||
onTextChanged: {
|
||||
if (syncingFromState)
|
||||
@@ -1005,11 +1188,14 @@ Item {
|
||||
if (GreeterState.showPasswordInput) {
|
||||
return I18n.tr("Password...");
|
||||
}
|
||||
if (root.showUserPicker) {
|
||||
return "";
|
||||
}
|
||||
return I18n.tr("Username...");
|
||||
}
|
||||
color: (GreeterState.unlocking || (Greetd.state !== GreetdState.Inactive && !awaitingExternalAuth && !pendingPasswordResponse)) ? Theme.primary : Theme.outline
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
opacity: (GreeterState.showPasswordInput ? GreeterState.passwordBuffer.length === 0 : GreeterState.usernameInput.length === 0) ? 1 : 0
|
||||
opacity: (GreeterState.showPasswordInput ? GreeterState.passwordBuffer.length === 0 : (root.showUserPicker ? false : GreeterState.usernameInput.length === 0)) ? 1 : 0
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
@@ -1043,7 +1229,7 @@ Item {
|
||||
}
|
||||
color: Theme.surfaceText
|
||||
font.pixelSize: (GreeterState.showPasswordInput && !parent.showPassword) ? Theme.fontSizeLarge : Theme.fontSizeMedium
|
||||
opacity: (GreeterState.showPasswordInput ? GreeterState.passwordBuffer.length > 0 : GreeterState.usernameInput.length > 0) ? 1 : 0
|
||||
opacity: (GreeterState.showPasswordInput ? GreeterState.passwordBuffer.length > 0 : (root.showUserPicker ? false : GreeterState.usernameInput.length > 0)) ? 1 : 0
|
||||
clip: true
|
||||
elide: Text.ElideNone
|
||||
horizontalAlignment: implicitWidth > width ? Text.AlignRight : Text.AlignLeft
|
||||
@@ -1088,7 +1274,7 @@ Item {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
iconName: "keyboard"
|
||||
buttonSize: 32
|
||||
visible: (Greetd.state === GreetdState.Inactive || awaitingExternalAuth || pendingPasswordResponse) && !GreeterState.unlocking
|
||||
visible: (Greetd.state === GreetdState.Inactive || awaitingExternalAuth || pendingPasswordResponse) && !GreeterState.unlocking && (!root.showUserPicker || GreeterState.showPasswordInput)
|
||||
enabled: visible
|
||||
onClicked: {
|
||||
if (keyboard_controller.isKeyboardActive) {
|
||||
@@ -1107,7 +1293,7 @@ Item {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
iconName: "keyboard_return"
|
||||
buttonSize: 36
|
||||
visible: (Greetd.state === GreetdState.Inactive || awaitingExternalAuth || pendingPasswordResponse) && !GreeterState.unlocking
|
||||
visible: (Greetd.state === GreetdState.Inactive || awaitingExternalAuth || pendingPasswordResponse) && !GreeterState.unlocking && (!root.showUserPicker || GreeterState.showPasswordInput)
|
||||
enabled: true
|
||||
onClicked: {
|
||||
if (GreeterState.showPasswordInput) {
|
||||
@@ -1137,6 +1323,36 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: root.showAccountSwitchLink ? 28 : 0
|
||||
visible: root.showAccountSwitchLink
|
||||
|
||||
StyledText {
|
||||
id: accountSwitchLabel
|
||||
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: root.manualUsernameEntry ? I18n.tr("Back to user list", "greeter link to return from manual username entry to user picker") : I18n.tr("Not listed?", "greeter link to switch to manual username entry")
|
||||
color: Theme.primary
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.underline: accountSwitchMouse.containsMouse
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: accountSwitchMouse
|
||||
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
if (root.manualUsernameEntry)
|
||||
root.returnToUserListFromManualEntry();
|
||||
else
|
||||
root.enterManualUsernameEntry();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 38
|
||||
@@ -1198,13 +1414,8 @@ Item {
|
||||
StateLayer {
|
||||
stateColor: Theme.primary
|
||||
cornerRadius: parent.radius
|
||||
enabled: !GreeterState.unlocking && Greetd.state === GreetdState.Inactive && GreeterState.showPasswordInput
|
||||
onClicked: {
|
||||
GreeterState.reset();
|
||||
root.externalAuthAutoStartedForUser = "";
|
||||
inputField.text = "";
|
||||
PortalService.profileImage = "";
|
||||
}
|
||||
enabled: !GreeterState.unlocking && GreeterState.showPasswordInput
|
||||
onClicked: root.returnToUserPicker()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ Singleton {
|
||||
property var sessionExecs: []
|
||||
property var sessionPaths: []
|
||||
property int currentSessionIndex: 0
|
||||
property var availableUsers: []
|
||||
property int selectedUserIndex: -1
|
||||
|
||||
function reset() {
|
||||
showPasswordInput = false;
|
||||
@@ -26,5 +28,6 @@ Singleton {
|
||||
usernameInput = "";
|
||||
passwordBuffer = "";
|
||||
pamState = "";
|
||||
selectedUserIndex = -1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property bool expanded: false
|
||||
property int maxExpandedHeight: 400
|
||||
|
||||
signal userSelected(string username)
|
||||
signal toggleRequested()
|
||||
|
||||
readonly property int rowHeight: 52
|
||||
readonly property int collapsedBarHeight: 36
|
||||
readonly property int expandedListHeight: {
|
||||
if (!expanded)
|
||||
return 0;
|
||||
const count = GreeterUsersService.users.length;
|
||||
if (count === 0)
|
||||
return 0;
|
||||
const fullHeight = count * rowHeight + Math.max(0, count - 1) * Theme.spacingXS;
|
||||
return Math.min(maxExpandedHeight, fullHeight);
|
||||
}
|
||||
|
||||
function encodeFileUrl(path) {
|
||||
if (!path)
|
||||
return "";
|
||||
return "file://" + path.split("/").map(s => encodeURIComponent(s)).join("/");
|
||||
}
|
||||
|
||||
function profileImageSource(username) {
|
||||
const path = GreeterUsersService.profileImagePath(username);
|
||||
if (path)
|
||||
return encodeFileUrl(path);
|
||||
return "";
|
||||
}
|
||||
|
||||
implicitHeight: expanded ? expandedListHeight : collapsedBarHeight
|
||||
implicitWidth: parent ? parent.width : 320
|
||||
|
||||
RowLayout {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: expanded ? undefined : parent.verticalCenter
|
||||
height: collapsedBarHeight
|
||||
visible: !expanded && !!GreeterState.username
|
||||
spacing: Theme.spacingM
|
||||
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
text: GreeterUsersService.optionLabel(GreeterState.username)
|
||||
color: Theme.surfaceText
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
DankIcon {
|
||||
name: "expand_more"
|
||||
size: 20
|
||||
color: Theme.surfaceVariantText
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.toggleRequested()
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
height: collapsedBarHeight
|
||||
visible: !expanded && !GreeterState.username
|
||||
|
||||
DankIcon {
|
||||
anchors.centerIn: parent
|
||||
name: "expand_more"
|
||||
size: 20
|
||||
color: Theme.surfaceVariantText
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.toggleRequested()
|
||||
}
|
||||
}
|
||||
|
||||
DankListView {
|
||||
id: userListView
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
height: expandedListHeight
|
||||
visible: expanded
|
||||
clip: true
|
||||
interactive: contentHeight > height
|
||||
spacing: Theme.spacingXS
|
||||
model: GreeterUsersService.users
|
||||
|
||||
delegate: Rectangle {
|
||||
id: userRow
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
width: userListView.width
|
||||
height: root.rowHeight
|
||||
radius: Theme.cornerRadius
|
||||
color: userRowMouse.containsMouse ? Theme.surfacePressed : "transparent"
|
||||
border.color: GreeterState.username === userRow.modelData.username ? Theme.primary : "transparent"
|
||||
border.width: GreeterState.username === userRow.modelData.username ? 1 : 0
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: Theme.spacingS
|
||||
anchors.rightMargin: Theme.spacingS
|
||||
spacing: Theme.spacingM
|
||||
|
||||
Item {
|
||||
Layout.preferredWidth: 36
|
||||
Layout.preferredHeight: 36
|
||||
|
||||
DankCircularImage {
|
||||
anchors.fill: parent
|
||||
imageSource: root.profileImageSource(userRow.modelData.username)
|
||||
fallbackIcon: "person"
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
text: GreeterUsersService.optionLabel(userRow.modelData.username)
|
||||
color: Theme.surfaceText
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: userRowMouse
|
||||
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.userSelected(userRow.modelData.username)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property var log: Log.scoped("GreeterUserTheme")
|
||||
readonly property string greetCfgDir: Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter"
|
||||
|
||||
property string activeUsername: ""
|
||||
|
||||
function userCacheDir(username) {
|
||||
if (!username)
|
||||
return "";
|
||||
return greetCfgDir + "/users/" + username;
|
||||
}
|
||||
|
||||
function applyForUser(username) {
|
||||
const name = (username || "").trim();
|
||||
activeUsername = name;
|
||||
if (!name) {
|
||||
applyDefault();
|
||||
return;
|
||||
}
|
||||
const dir = userCacheDir(name);
|
||||
if (typeof GreeterUsersService !== "undefined" && GreeterUsersService.hasSyncedTheme(name)) {
|
||||
Theme.setGreeterColorsBaseDir(dir);
|
||||
SessionData.setGreeterSessionBaseDir(dir);
|
||||
GreetdSettings.setConfigBaseDir(dir);
|
||||
return;
|
||||
}
|
||||
applyDefault();
|
||||
}
|
||||
|
||||
function applyDefault() {
|
||||
activeUsername = "";
|
||||
Theme.resetGreeterColorsBaseDir();
|
||||
SessionData.resetGreeterSessionBaseDir();
|
||||
GreetdSettings.resetConfigBaseDir();
|
||||
}
|
||||
|
||||
readonly property string activeWallpaperOverridePath: {
|
||||
const base = activeUsername && typeof GreeterUsersService !== "undefined" && GreeterUsersService.hasSyncedTheme(activeUsername) ? userCacheDir(activeUsername) : greetCfgDir;
|
||||
return base ? base + "/greeter_wallpaper_override.jpg" : "";
|
||||
}
|
||||
}
|
||||
@@ -250,7 +250,17 @@ Only niri currently has a generated greeter config path managed by `dms greeter
|
||||
|
||||
The greeter can be personalized with wallpapers, themes, weather, clock formats, and more - configured exactly the same as dms.
|
||||
|
||||
**Easiest method:** Run `dms greeter sync` to automatically sync your DMS theme with the greeter.
|
||||
**Easiest method (single user):** Run `dms greeter sync` to automatically sync your DMS theme with the greeter.
|
||||
|
||||
**Multi-user systems:** One **main admin** runs full sync once to set up greetd and the shared cache (`dms greeter sync`, or `dms greeter sync --local` when developing from a checkout). **Every other account**—including other admins—should only run:
|
||||
|
||||
```bash
|
||||
dms greeter sync --profile
|
||||
```
|
||||
|
||||
Before that, an administrator must add each user to the `greeter` group in **Settings → Users** (greeter toggle) or with `sudo usermod -aG greeter <username>`. Each added user must log out and back in before `--profile` will work.
|
||||
|
||||
Per-user settings are stored under `/var/cache/dms-greeter/users/<username>/` for the login picker; the root cache remains the default fallback and is owned by whoever ran full sync.
|
||||
|
||||
**Manual method:** You can manually synchronize configurations if you want greeter settings to always mirror your shell:
|
||||
|
||||
|
||||
@@ -263,10 +263,6 @@ environment {
|
||||
DMS_RUN_GREETER "1"
|
||||
}
|
||||
|
||||
debug {
|
||||
keep-max-bpc-unchanged
|
||||
}
|
||||
|
||||
gestures {
|
||||
hot-corners {
|
||||
off
|
||||
|
||||
@@ -8,10 +8,6 @@ environment {
|
||||
|
||||
spawn-at-startup "sh" "-c" "qs -p _DMS_PATH_; niri msg action quit --skip-confirmation"
|
||||
|
||||
debug {
|
||||
keep-max-bpc-unchanged
|
||||
}
|
||||
|
||||
gestures {
|
||||
hot-corners {
|
||||
off
|
||||
|
||||
@@ -60,7 +60,7 @@ DankOSD {
|
||||
|
||||
Image {
|
||||
id: artPreloader
|
||||
source: TrackArtService._bgArtSource
|
||||
source: TrackArtService.resolvedArtUrl
|
||||
visible: false
|
||||
asynchronous: true
|
||||
cache: true
|
||||
@@ -78,7 +78,7 @@ DankOSD {
|
||||
function onLoadingChanged() {
|
||||
if (TrackArtService.loading || !root._pendingShow)
|
||||
return;
|
||||
if (!TrackArtService._bgArtSource || artPreloader.status === Image.Ready) {
|
||||
if (!TrackArtService.resolvedArtUrl || artPreloader.status === Image.Ready) {
|
||||
root._pendingShow = false;
|
||||
root.show();
|
||||
}
|
||||
@@ -116,9 +116,9 @@ DankOSD {
|
||||
root._displayAlbum = player.trackAlbum || "";
|
||||
|
||||
root.updatePlaybackIcon();
|
||||
TrackArtService.loadArtwork(player.trackArtUrl);
|
||||
const resolvedArtUrl = TrackArtService.resolvedArtUrl;
|
||||
|
||||
if (!player.trackArtUrl || player.trackArtUrl === "") {
|
||||
if (!resolvedArtUrl || resolvedArtUrl === "") {
|
||||
root.show();
|
||||
return;
|
||||
}
|
||||
@@ -126,7 +126,7 @@ DankOSD {
|
||||
root._pendingShow = true;
|
||||
return;
|
||||
}
|
||||
if (!TrackArtService._bgArtSource || artPreloader.status === Image.Ready) {
|
||||
if (!TrackArtService.resolvedArtUrl || artPreloader.status === Image.Ready) {
|
||||
root.show();
|
||||
return;
|
||||
}
|
||||
@@ -134,7 +134,10 @@ DankOSD {
|
||||
}
|
||||
|
||||
function onTrackArtUrlChanged() {
|
||||
TrackArtService.loadArtwork(player.trackArtUrl);
|
||||
handleUpdate();
|
||||
}
|
||||
function onMetadataChanged() {
|
||||
handleUpdate();
|
||||
}
|
||||
function onIsPlayingChanged() {
|
||||
handleUpdate();
|
||||
@@ -168,14 +171,14 @@ DankOSD {
|
||||
Item {
|
||||
id: bgContainer
|
||||
anchors.fill: parent
|
||||
visible: TrackArtService._bgArtSource !== ""
|
||||
visible: TrackArtService.resolvedArtUrl !== ""
|
||||
|
||||
Image {
|
||||
id: bgImage
|
||||
anchors.centerIn: parent
|
||||
width: Math.max(parent.width, parent.height)
|
||||
height: width
|
||||
source: TrackArtService._bgArtSource
|
||||
source: TrackArtService.resolvedArtUrl
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
asynchronous: true
|
||||
cache: true
|
||||
|
||||
@@ -300,6 +300,8 @@ Item {
|
||||
}
|
||||
WlrLayershell.exclusionMode: ExclusionMode.Ignore
|
||||
WlrLayershell.keyboardFocus: {
|
||||
if (PopoutManager.screenshotActive)
|
||||
return WlrKeyboardFocus.None;
|
||||
if (root.isInteracting) {
|
||||
if (CompositorService.useHyprlandFocusGrab)
|
||||
return WlrKeyboardFocus.OnDemand;
|
||||
|
||||
@@ -301,10 +301,19 @@ Item {
|
||||
clip: true
|
||||
spacing: 2
|
||||
|
||||
add: null
|
||||
remove: null
|
||||
displaced: null
|
||||
move: null
|
||||
states: [
|
||||
State {
|
||||
name: "snap"
|
||||
when: Theme.snapListModelChanges
|
||||
PropertyChanges {
|
||||
target: processListView
|
||||
add: null
|
||||
remove: null
|
||||
displaced: null
|
||||
move: null
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
model: ScriptModel {
|
||||
values: root.cachedProcesses
|
||||
|
||||
@@ -231,6 +231,8 @@ Item {
|
||||
DankActionButton {
|
||||
id: deleteGroupBtn
|
||||
iconName: "delete"
|
||||
backgroundColor: Theme.withAlpha(Theme.error, 0.15)
|
||||
iconColor: Theme.error
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: {
|
||||
SettingsData.removeDesktopWidgetGroup(groupItem.modelData.id);
|
||||
@@ -242,6 +244,7 @@ Item {
|
||||
MouseArea {
|
||||
id: groupMouseArea
|
||||
anchors.fill: parent
|
||||
z: -1
|
||||
hoverEnabled: true
|
||||
onDoubleClicked: root.editingGroupId = groupItem.modelData.id
|
||||
}
|
||||
|
||||
@@ -21,8 +21,11 @@ Singleton {
|
||||
|
||||
property var includeStatus: ({
|
||||
"exists": false,
|
||||
"included": false
|
||||
"included": false,
|
||||
"configFormat": "",
|
||||
"readOnly": false
|
||||
})
|
||||
readonly property bool readOnly: CompositorService.isHyprland && includeStatus.readOnly === true
|
||||
property bool checkingInclude: false
|
||||
property bool fixingInclude: false
|
||||
|
||||
@@ -481,6 +484,15 @@ Singleton {
|
||||
|
||||
// Write compositor config from a neutral config entry and optionally reload
|
||||
function applyConfigEntry(configEntry, configId, profileName, isManual) {
|
||||
if (CompositorService.isHyprland && readOnly) {
|
||||
if (isManual) {
|
||||
profilesLoading = false;
|
||||
manualActivation = false;
|
||||
profileError(I18n.tr("Hyprland conf mode is read-only in Settings"));
|
||||
}
|
||||
showHyprlandReadOnlyWarning();
|
||||
return;
|
||||
}
|
||||
ensureEnabledOutput(configEntry);
|
||||
// Capture the entry being applied so disabled-output settings fields can read
|
||||
// scale/position/transform back even when wlr reports no logical viewport.
|
||||
@@ -845,6 +857,8 @@ Singleton {
|
||||
Component.onCompleted: {
|
||||
outputs = buildOutputsMap();
|
||||
reloadSavedOutputs();
|
||||
if (CompositorService.isHyprland)
|
||||
checkIncludeStatus();
|
||||
}
|
||||
|
||||
function reloadSavedOutputs() {
|
||||
@@ -997,6 +1011,8 @@ Singleton {
|
||||
const id = (o.make + " " + o.model + " " + serial).trim();
|
||||
liveByIdentifier[id] = true;
|
||||
liveByIdentifier[o.make + " " + o.model] = true;
|
||||
if (CompositorService.isHyprland)
|
||||
liveByIdentifier[getHyprlandOutputIdentifier(o, name)] = true;
|
||||
}
|
||||
liveByIdentifier[name] = true;
|
||||
}
|
||||
@@ -1132,11 +1148,13 @@ Singleton {
|
||||
"scale": typeof scaleValue === "number" ? scaleValue : 1.0,
|
||||
"transform": hyprlandToTransform(transform)
|
||||
},
|
||||
"modes": modeMatch ? [{
|
||||
"width": parseInt(modeMatch[1]),
|
||||
"height": parseInt(modeMatch[2]),
|
||||
"refresh_rate": Math.round(parseFloat(modeMatch[3]) * 1000)
|
||||
}] : [],
|
||||
"modes": modeMatch ? [
|
||||
{
|
||||
"width": parseInt(modeMatch[1]),
|
||||
"height": parseInt(modeMatch[2]),
|
||||
"refresh_rate": Math.round(parseFloat(modeMatch[3]) * 1000)
|
||||
}
|
||||
] : [],
|
||||
"current_mode": modeMatch ? 0 : -1,
|
||||
"vrr_enabled": vrrMode >= 1,
|
||||
"vrr_supported": vrrMode > 0,
|
||||
@@ -1368,7 +1386,9 @@ Singleton {
|
||||
if (compositor !== "niri" && compositor !== "hyprland" && compositor !== "dwl") {
|
||||
includeStatus = {
|
||||
"exists": false,
|
||||
"included": false
|
||||
"included": false,
|
||||
"configFormat": "",
|
||||
"readOnly": false
|
||||
};
|
||||
return;
|
||||
}
|
||||
@@ -1382,7 +1402,9 @@ Singleton {
|
||||
if (exitCode !== 0) {
|
||||
includeStatus = {
|
||||
"exists": false,
|
||||
"included": false
|
||||
"included": false,
|
||||
"configFormat": "",
|
||||
"readOnly": false
|
||||
};
|
||||
return;
|
||||
}
|
||||
@@ -1391,13 +1413,24 @@ Singleton {
|
||||
} catch (e) {
|
||||
includeStatus = {
|
||||
"exists": false,
|
||||
"included": false
|
||||
"included": false,
|
||||
"configFormat": "",
|
||||
"readOnly": false
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function fixOutputsInclude() {
|
||||
if (readOnly) {
|
||||
showHyprlandReadOnlyWarning();
|
||||
return;
|
||||
}
|
||||
if (CompositorService.isHyprland && !HyprlandService.luaConfigActive) {
|
||||
showHyprlandReadOnlyWarning();
|
||||
checkIncludeStatus();
|
||||
return;
|
||||
}
|
||||
const paths = getConfigPaths();
|
||||
if (!paths)
|
||||
return;
|
||||
@@ -1414,14 +1447,34 @@ Singleton {
|
||||
});
|
||||
|
||||
Proc.runCommand("fix-outputs-include", ["sh", "-c", script], (output, exitCode) => {
|
||||
fixingInclude = false;
|
||||
if (exitCode !== 0)
|
||||
if (exitCode !== 0) {
|
||||
fixingInclude = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const liveOutputs = buildOutputsMap();
|
||||
if (CompositorService.isHyprland && Object.keys(liveOutputs).length > 0) {
|
||||
outputs = liveOutputs;
|
||||
HyprlandService.generateOutputsConfig(liveOutputs, SettingsData.hyprlandOutputSettings, success => {
|
||||
fixingInclude = false;
|
||||
if (!success)
|
||||
ToastService.showError(I18n.tr("Display setup failed"), I18n.tr("Failed to write Hyprland outputs config."), "", "display-config");
|
||||
checkIncludeStatus();
|
||||
WlrOutputService.requestState();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
fixingInclude = false;
|
||||
checkIncludeStatus();
|
||||
WlrOutputService.requestState();
|
||||
});
|
||||
}
|
||||
|
||||
function showHyprlandReadOnlyWarning() {
|
||||
ToastService.showWarning(I18n.tr("Hyprland conf mode"), I18n.tr("This install is still using hyprland.conf. Run dms setup to migrate before editing display settings."), "dms setup", "display-config");
|
||||
}
|
||||
|
||||
function buildOutputsMap() {
|
||||
const map = {};
|
||||
for (const output of wlrOutputs) {
|
||||
@@ -1510,6 +1563,10 @@ Singleton {
|
||||
NiriService.generateOutputsConfig(outputsData);
|
||||
break;
|
||||
case "hyprland":
|
||||
if (readOnly) {
|
||||
showHyprlandReadOnlyWarning();
|
||||
return false;
|
||||
}
|
||||
HyprlandService.generateOutputsConfig(outputsData, buildMergedHyprlandSettings());
|
||||
break;
|
||||
case "dwl":
|
||||
@@ -1519,6 +1576,7 @@ Singleton {
|
||||
WlrOutputService.applyOutputsConfig(outputsData, outputs);
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function normalizeOutputPositions(outputsData) {
|
||||
@@ -1666,7 +1724,7 @@ Singleton {
|
||||
|
||||
function getHyprlandOutputIdentifier(output, outputName) {
|
||||
if (SettingsData.displayNameMode === "model" && output?.make && output?.model)
|
||||
return "desc:" + output.make + " " + output.model + " " + (output?.serial || "Unknown");
|
||||
return ("desc:" + output.make + " " + output.model + " " + (output?.serial || "Unknown")).replace(/,/g, "");
|
||||
return outputName;
|
||||
}
|
||||
|
||||
@@ -1826,6 +1884,10 @@ Singleton {
|
||||
function applyChanges() {
|
||||
if (!hasPendingChanges)
|
||||
return;
|
||||
if (CompositorService.isHyprland && readOnly) {
|
||||
showHyprlandReadOnlyWarning();
|
||||
return;
|
||||
}
|
||||
const changeDescriptions = [];
|
||||
|
||||
if (formatChanged) {
|
||||
@@ -2465,6 +2527,50 @@ Singleton {
|
||||
return mode.width + "x" + mode.height + "@" + (mode.refresh_rate / 1000).toFixed(3);
|
||||
}
|
||||
|
||||
function formatScaleLabel(scale) {
|
||||
const value = Number(scale);
|
||||
if (!isFinite(value))
|
||||
return "1";
|
||||
return parseFloat(value.toFixed(2)).toString();
|
||||
}
|
||||
|
||||
function getScalePresetValues(outputName, outputData) {
|
||||
if (!CompositorService.isHyprland)
|
||||
return [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.5, 3];
|
||||
|
||||
const candidates = [0.5, 2 / 3, 0.75, 0.8, 1, 4 / 3, 1.6, 2, 2.5, 8 / 3, 3.2, 4];
|
||||
const mode = getModeForScalePresets(outputName, outputData);
|
||||
if (!mode)
|
||||
return candidates;
|
||||
|
||||
return candidates.filter(scale => scaleFitsMode(mode, scale));
|
||||
}
|
||||
|
||||
function getModeForScalePresets(outputName, outputData) {
|
||||
const pendingMode = getPendingValue(outputName, "mode");
|
||||
const modes = outputData?.modes || [];
|
||||
if (pendingMode) {
|
||||
for (const mode of modes) {
|
||||
if (formatMode(mode) === pendingMode)
|
||||
return mode;
|
||||
}
|
||||
}
|
||||
const currentMode = outputData?.current_mode;
|
||||
if (currentMode !== undefined && modes[currentMode])
|
||||
return modes[currentMode];
|
||||
return null;
|
||||
}
|
||||
|
||||
function scaleFitsMode(mode, scale) {
|
||||
const width = Number(mode?.width || 0);
|
||||
const height = Number(mode?.height || 0);
|
||||
if (width <= 0 || height <= 0 || scale <= 0)
|
||||
return false;
|
||||
const logicalWidth = width / scale;
|
||||
const logicalHeight = height / scale;
|
||||
return Math.abs(logicalWidth - Math.round(logicalWidth)) < 0.001 && Math.abs(logicalHeight - Math.round(logicalHeight)) < 0.001;
|
||||
}
|
||||
|
||||
function getTransformLabel(transform) {
|
||||
switch (transform) {
|
||||
case "Normal":
|
||||
|
||||
@@ -12,13 +12,14 @@ StyledRect {
|
||||
height: warningContent.implicitHeight + Theme.spacingL * 2
|
||||
radius: Theme.cornerRadius
|
||||
|
||||
readonly property bool showError: DisplayConfigState.includeStatus.exists && !DisplayConfigState.includeStatus.included
|
||||
readonly property bool showSetup: !DisplayConfigState.includeStatus.exists && !DisplayConfigState.includeStatus.included
|
||||
readonly property bool showLegacy: DisplayConfigState.readOnly
|
||||
readonly property bool showError: !showLegacy && DisplayConfigState.includeStatus.exists && !DisplayConfigState.includeStatus.included
|
||||
readonly property bool showSetup: !showLegacy && !DisplayConfigState.includeStatus.exists && !DisplayConfigState.includeStatus.included
|
||||
|
||||
color: (showError || showSetup) ? Theme.withAlpha(Theme.primary, 0.15) : "transparent"
|
||||
border.color: (showError || showSetup) ? Theme.withAlpha(Theme.primary, 0.3) : "transparent"
|
||||
color: (showLegacy || showError || showSetup) ? Theme.withAlpha(Theme.primary, 0.15) : "transparent"
|
||||
border.color: (showLegacy || showError || showSetup) ? Theme.withAlpha(Theme.primary, 0.3) : "transparent"
|
||||
border.width: 1
|
||||
visible: (showError || showSetup) && DisplayConfigState.hasOutputBackend && !DisplayConfigState.checkingInclude
|
||||
visible: (showLegacy || showError || showSetup) && DisplayConfigState.hasOutputBackend && !DisplayConfigState.checkingInclude
|
||||
|
||||
Column {
|
||||
id: warningContent
|
||||
@@ -44,6 +45,8 @@ StyledRect {
|
||||
|
||||
StyledText {
|
||||
text: {
|
||||
if (root.showLegacy)
|
||||
return I18n.tr("Hyprland conf mode");
|
||||
if (root.showSetup)
|
||||
return I18n.tr("First Time Setup");
|
||||
if (root.showError)
|
||||
@@ -59,6 +62,8 @@ StyledRect {
|
||||
|
||||
StyledText {
|
||||
text: {
|
||||
if (root.showLegacy)
|
||||
return I18n.tr("This install is still using hyprland.conf. Run dms setup to migrate before editing display settings.");
|
||||
if (root.showSetup)
|
||||
return I18n.tr("Click 'Setup' to create the outputs config and add include to your compositor config.");
|
||||
if (root.showError)
|
||||
@@ -75,7 +80,7 @@ StyledRect {
|
||||
|
||||
DankButton {
|
||||
id: fixButton
|
||||
visible: root.showError || root.showSetup
|
||||
visible: !root.showLegacy && (root.showError || root.showSetup)
|
||||
text: {
|
||||
if (DisplayConfigState.fixingInclude)
|
||||
return I18n.tr("Fixing...");
|
||||
|
||||
@@ -203,12 +203,40 @@ StyledRect {
|
||||
height: scaleDropdown.visible ? scaleDropdown.height : scaleInput.height
|
||||
|
||||
property bool customMode: false
|
||||
property string currentScale: {
|
||||
property real currentScaleValue: {
|
||||
const pendingScale = DisplayConfigState.getPendingValue(root.outputName, "scale");
|
||||
if (pendingScale !== undefined)
|
||||
return parseFloat(pendingScale.toFixed(2)).toString();
|
||||
const scale = root.outputData?.logical?.scale || 1.0;
|
||||
return parseFloat(scale.toFixed(2)).toString();
|
||||
return pendingScale;
|
||||
return root.outputData?.logical?.scale || 1.0;
|
||||
}
|
||||
property string currentScale: DisplayConfigState.formatScaleLabel(currentScaleValue)
|
||||
property var scaleOptionsData: {
|
||||
void (DisplayConfigState.pendingChanges);
|
||||
const customLabel = I18n.tr("Custom...");
|
||||
const values = DisplayConfigState.getScalePresetValues(root.outputName, root.outputData);
|
||||
const labels = [];
|
||||
const valueByLabel = {};
|
||||
|
||||
function addValue(value) {
|
||||
if (!isFinite(value) || value <= 0)
|
||||
return;
|
||||
const label = DisplayConfigState.formatScaleLabel(value);
|
||||
if (valueByLabel[label] !== undefined)
|
||||
return;
|
||||
valueByLabel[label] = value;
|
||||
labels.push(label);
|
||||
}
|
||||
|
||||
for (const value of values)
|
||||
addValue(value);
|
||||
addValue(scaleContainer.currentScaleValue);
|
||||
|
||||
labels.sort((a, b) => parseFloat(a) - parseFloat(b));
|
||||
labels.push(customLabel);
|
||||
return {
|
||||
"labels": labels,
|
||||
"valueByLabel": valueByLabel
|
||||
};
|
||||
}
|
||||
|
||||
DankDropdown {
|
||||
@@ -217,20 +245,7 @@ StyledRect {
|
||||
dropdownWidth: parent.width
|
||||
visible: !scaleContainer.customMode
|
||||
currentValue: scaleContainer.currentScale
|
||||
options: {
|
||||
const standard = ["0.5", "0.75", "1", "1.25", "1.5", "1.75", "2", "2.5", "3", I18n.tr("Custom...")];
|
||||
const current = scaleContainer.currentScale;
|
||||
if (standard.slice(0, -1).includes(current))
|
||||
return standard;
|
||||
const opts = [...standard.slice(0, -1), current, standard[standard.length - 1]];
|
||||
return opts.sort((a, b) => {
|
||||
if (a === I18n.tr("Custom..."))
|
||||
return 1;
|
||||
if (b === I18n.tr("Custom..."))
|
||||
return -1;
|
||||
return parseFloat(a) - parseFloat(b);
|
||||
});
|
||||
}
|
||||
options: scaleContainer.scaleOptionsData.labels
|
||||
onValueChanged: value => {
|
||||
if (value === I18n.tr("Custom...")) {
|
||||
scaleContainer.customMode = true;
|
||||
@@ -239,7 +254,8 @@ StyledRect {
|
||||
scaleInput.selectAll();
|
||||
return;
|
||||
}
|
||||
DisplayConfigState.setPendingChange(root.outputName, "scale", parseFloat(value));
|
||||
const mapped = scaleContainer.scaleOptionsData.valueByLabel[value];
|
||||
DisplayConfigState.setPendingChange(root.outputName, "scale", mapped !== undefined ? mapped : parseFloat(value));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,7 +264,7 @@ StyledRect {
|
||||
width: parent.width
|
||||
height: 40
|
||||
visible: scaleContainer.customMode
|
||||
placeholderText: "0.5 - 4.0"
|
||||
placeholderText: "0.25 - 4.0"
|
||||
|
||||
function applyValue() {
|
||||
const val = parseFloat(text);
|
||||
@@ -257,7 +273,7 @@ StyledRect {
|
||||
scaleContainer.customMode = false;
|
||||
return;
|
||||
}
|
||||
DisplayConfigState.setPendingChange(root.outputName, "scale", parseFloat(val.toFixed(2)));
|
||||
DisplayConfigState.setPendingChange(root.outputName, "scale", parseFloat(val.toFixed(6)));
|
||||
scaleContainer.customMode = false;
|
||||
}
|
||||
|
||||
|
||||
@@ -446,7 +446,7 @@ Item {
|
||||
settingKey: "greeterStatus"
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Check sync status on demand. Sync copies your theme, settings, and wallpaper configuration to the login screen. Authentication changes apply automatically.")
|
||||
text: I18n.tr("Check sync status on demand. Sync (full) is for the main admin: it copies your theme to the login screen and sets up system greeter config. On multi-user systems, add other accounts in Settings → Users, then have each of them run dms greeter sync --profile after logging out and back in—not full sync. Authentication changes apply automatically.")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
width: parent.width
|
||||
|
||||
@@ -84,6 +84,10 @@ Item {
|
||||
}
|
||||
|
||||
function startNewBind() {
|
||||
if (KeybindsService.readOnly) {
|
||||
KeybindsService.showHyprlandReadOnlyWarning();
|
||||
return;
|
||||
}
|
||||
showingNewBind = true;
|
||||
expandedKey = "";
|
||||
}
|
||||
@@ -292,7 +296,7 @@ Item {
|
||||
|
||||
StyledText {
|
||||
readonly property string bindsFile: KeybindsService.currentProvider === "niri" ? "dms/binds.kdl" : KeybindsService.currentProvider === "hyprland" ? "dms/binds-user.lua" : "dms/binds.conf"
|
||||
text: I18n.tr("Click any shortcut to edit. Changes save to %1").arg(bindsFile)
|
||||
text: KeybindsService.readOnly ? I18n.tr("Hyprland conf mode is read-only in Settings") : I18n.tr("Click any shortcut to edit. Changes save to %1").arg(bindsFile)
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
wrapMode: Text.WordWrap
|
||||
@@ -326,7 +330,7 @@ Item {
|
||||
iconSize: Theme.iconSize
|
||||
iconColor: Theme.primary
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
enabled: !keybindsTab.showingNewBind
|
||||
enabled: !keybindsTab.showingNewBind && !KeybindsService.readOnly
|
||||
opacity: enabled ? 1 : 0.5
|
||||
onClicked: keybindsTab.startNewBind()
|
||||
}
|
||||
@@ -342,14 +346,15 @@ Item {
|
||||
radius: Theme.cornerRadius
|
||||
|
||||
readonly property var status: KeybindsService.dmsStatus
|
||||
readonly property bool showError: !status.included && status.exists
|
||||
readonly property bool showWarning: status.included && status.overriddenBy > 0
|
||||
readonly property bool showSetup: !status.exists
|
||||
readonly property bool showLegacy: KeybindsService.readOnly
|
||||
readonly property bool showError: !showLegacy && !status.included && status.exists
|
||||
readonly property bool showWarning: !showLegacy && status.included && status.overriddenBy > 0
|
||||
readonly property bool showSetup: !showLegacy && !status.exists
|
||||
|
||||
color: (showError || showWarning || showSetup) ? Theme.withAlpha(Theme.primary, 0.15) : "transparent"
|
||||
border.color: (showError || showWarning || showSetup) ? Theme.withAlpha(Theme.primary, 0.3) : "transparent"
|
||||
color: (showLegacy || showError || showWarning || showSetup) ? Theme.withAlpha(Theme.primary, 0.15) : "transparent"
|
||||
border.color: (showLegacy || showError || showWarning || showSetup) ? Theme.withAlpha(Theme.primary, 0.3) : "transparent"
|
||||
border.width: 1
|
||||
visible: (showError || showWarning || showSetup) && !KeybindsService.loading
|
||||
visible: (showLegacy || showError || showWarning || showSetup) && !KeybindsService.loading
|
||||
|
||||
Column {
|
||||
id: warningSection
|
||||
@@ -375,6 +380,8 @@ Item {
|
||||
|
||||
StyledText {
|
||||
text: {
|
||||
if (warningBox.showLegacy)
|
||||
return I18n.tr("Hyprland conf mode");
|
||||
if (warningBox.showSetup)
|
||||
return I18n.tr("First Time Setup");
|
||||
if (warningBox.showError)
|
||||
@@ -391,6 +398,8 @@ Item {
|
||||
StyledText {
|
||||
readonly property string bindsFile: KeybindsService.currentProvider === "niri" ? "dms/binds.kdl" : KeybindsService.currentProvider === "hyprland" ? "dms/binds-user.lua" : "dms/binds.conf"
|
||||
text: {
|
||||
if (warningBox.showLegacy)
|
||||
return I18n.tr("This install is still using hyprland.conf. Run dms setup to migrate before editing shortcuts in Settings.");
|
||||
if (warningBox.showSetup)
|
||||
return I18n.tr("Click 'Setup' to create %1 and add include to config.").arg(bindsFile);
|
||||
if (warningBox.showError)
|
||||
@@ -411,7 +420,7 @@ Item {
|
||||
|
||||
DankButton {
|
||||
id: fixButton
|
||||
visible: warningBox.showError || warningBox.showSetup
|
||||
visible: !warningBox.showLegacy && (warningBox.showError || warningBox.showSetup)
|
||||
text: {
|
||||
if (KeybindsService.fixing)
|
||||
return I18n.tr("Fixing...");
|
||||
@@ -559,6 +568,7 @@ Item {
|
||||
desc: ""
|
||||
})
|
||||
panelWindow: keybindsTab.parentModal
|
||||
readOnly: KeybindsService.readOnly
|
||||
onSaveBind: (originalKey, newData) => keybindsTab.saveNewBind(newData)
|
||||
onCancelEdit: keybindsTab.cancelNewBind()
|
||||
}
|
||||
@@ -668,6 +678,7 @@ Item {
|
||||
bindData: modelData
|
||||
isExpanded: keybindsTab.expandedKey === modelData.action
|
||||
panelWindow: keybindsTab.parentModal
|
||||
readOnly: KeybindsService.readOnly
|
||||
onToggleExpand: keybindsTab.toggleExpanded(modelData.action)
|
||||
onSaveBind: (originalKey, newData) => {
|
||||
KeybindsService.saveBind(originalKey, newData);
|
||||
|
||||
@@ -23,8 +23,11 @@ Item {
|
||||
|
||||
property var cursorIncludeStatus: ({
|
||||
"exists": false,
|
||||
"included": false
|
||||
"included": false,
|
||||
"configFormat": "",
|
||||
"readOnly": false
|
||||
})
|
||||
readonly property bool cursorReadOnly: CompositorService.isHyprland && cursorIncludeStatus.readOnly === true
|
||||
property bool checkingCursorInclude: false
|
||||
property bool fixingCursorInclude: false
|
||||
|
||||
@@ -62,7 +65,9 @@ Item {
|
||||
if (compositor !== "niri" && compositor !== "hyprland" && compositor !== "dwl") {
|
||||
cursorIncludeStatus = {
|
||||
"exists": false,
|
||||
"included": false
|
||||
"included": false,
|
||||
"configFormat": "",
|
||||
"readOnly": false
|
||||
};
|
||||
return;
|
||||
}
|
||||
@@ -76,7 +81,9 @@ Item {
|
||||
if (exitCode !== 0) {
|
||||
cursorIncludeStatus = {
|
||||
"exists": false,
|
||||
"included": false
|
||||
"included": false,
|
||||
"configFormat": "",
|
||||
"readOnly": false
|
||||
};
|
||||
return;
|
||||
}
|
||||
@@ -85,13 +92,19 @@ Item {
|
||||
} catch (e) {
|
||||
cursorIncludeStatus = {
|
||||
"exists": false,
|
||||
"included": false
|
||||
"included": false,
|
||||
"configFormat": "",
|
||||
"readOnly": false
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function fixCursorInclude() {
|
||||
if (cursorReadOnly) {
|
||||
ToastService.showWarning(I18n.tr("Hyprland conf mode"), I18n.tr("This install is still using hyprland.conf. Run dms setup to migrate before editing cursor settings."), "dms setup", "hyprland-migration");
|
||||
return;
|
||||
}
|
||||
const paths = getCursorConfigPaths();
|
||||
if (!paths)
|
||||
return;
|
||||
|
||||
@@ -17,12 +17,14 @@ Item {
|
||||
property string pendingPassword: ""
|
||||
property string pendingConfirm: ""
|
||||
property bool pendingAdmin: false
|
||||
property bool pendingGreeter: false
|
||||
|
||||
function _resetForm() {
|
||||
pendingUsername = "";
|
||||
pendingPassword = "";
|
||||
pendingConfirm = "";
|
||||
pendingAdmin = false;
|
||||
pendingGreeter = false;
|
||||
usernameField.text = "";
|
||||
passwordField.text = "";
|
||||
confirmField.text = "";
|
||||
@@ -59,6 +61,10 @@ Item {
|
||||
id: adminToggleConfirm
|
||||
}
|
||||
|
||||
ConfirmModal {
|
||||
id: greeterToggleConfirm
|
||||
}
|
||||
|
||||
DankFlickable {
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
@@ -112,6 +118,26 @@ Item {
|
||||
height: 1
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Greeter group:")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: UsersService.greeterGroup
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
Item {
|
||||
width: Theme.spacingM
|
||||
height: 1
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: UsersService.refreshing ? I18n.tr("Refreshing…") : ""
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
@@ -120,6 +146,14 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
width: parent.width
|
||||
text: I18n.tr("Greeter group members can sync their login-screen theme with dms greeter sync --profile after logging out and back in.")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: UsersService.users
|
||||
|
||||
@@ -179,6 +213,24 @@ Item {
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
visible: userRow.modelData.isGreeter
|
||||
width: greeterChipText.implicitWidth + Theme.spacingS * 2
|
||||
height: greeterChipText.implicitHeight + Theme.spacingXS * 2
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.withAlpha(Theme.secondary, 0.15)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
StyledText {
|
||||
id: greeterChipText
|
||||
anchors.centerIn: parent
|
||||
text: I18n.tr("greeter")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.secondary
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
@@ -195,6 +247,34 @@ Item {
|
||||
spacing: Theme.spacingS
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
DankActionButton {
|
||||
id: greeterToggleBtn
|
||||
readonly property bool actionBlocked: root.operationPending
|
||||
buttonSize: 36
|
||||
iconSize: 20
|
||||
iconName: userRow.modelData.isGreeter ? "login" : "how_to_reg"
|
||||
iconColor: userRow.modelData.isGreeter ? Theme.secondary : Theme.surfaceVariantText
|
||||
opacity: actionBlocked ? 0.4 : 1.0
|
||||
tooltipText: userRow.modelData.isGreeter ? I18n.tr("Remove greeter login access") : I18n.tr("Allow greeter login access")
|
||||
tooltipSide: "left"
|
||||
onClicked: {
|
||||
if (actionBlocked)
|
||||
return;
|
||||
const enableGreeter = !userRow.modelData.isGreeter;
|
||||
greeterToggleConfirm.showWithOptions({
|
||||
title: enableGreeter ? I18n.tr("Allow greeter access?") : I18n.tr("Remove greeter access?"),
|
||||
message: enableGreeter ? I18n.tr("Add \"%1\" to the %2 group? They must log out and back in, then run dms greeter sync --profile to publish their login-screen theme.").arg(userRow.modelData.username).arg(UsersService.greeterGroup) : I18n.tr("Remove \"%1\" from the %2 group?").arg(userRow.modelData.username).arg(UsersService.greeterGroup),
|
||||
confirmText: enableGreeter ? I18n.tr("Allow") : I18n.tr("Remove"),
|
||||
confirmColor: Theme.primary,
|
||||
onConfirm: () => {
|
||||
root.operationPending = true;
|
||||
root.statusText = "";
|
||||
UsersService.setGreeterAccess(userRow.modelData.username, enableGreeter, null);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
DankActionButton {
|
||||
id: adminToggleBtn
|
||||
readonly property bool actionBlocked: root.operationPending || (userRow.isLastAdmin && userRow.modelData.isAdmin)
|
||||
@@ -380,6 +460,15 @@ Item {
|
||||
onToggled: checked => root.pendingAdmin = checked
|
||||
}
|
||||
|
||||
SettingsToggleRow {
|
||||
settingKey: "createUserGreeter"
|
||||
tags: ["user", "greeter", "login", "sync"]
|
||||
text: I18n.tr("Allow greeter login access")
|
||||
description: I18n.tr("Add the new user to the %1 group so they can run dms greeter sync --profile.").arg(UsersService.greeterGroup)
|
||||
checked: root.pendingGreeter
|
||||
onToggled: checked => root.pendingGreeter = checked
|
||||
}
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingM
|
||||
@@ -395,7 +484,7 @@ Item {
|
||||
return;
|
||||
root.operationPending = true;
|
||||
root.statusText = "";
|
||||
UsersService.createUser(root.pendingUsername, root.pendingPassword, root.pendingAdmin, null);
|
||||
UsersService.createUser(root.pendingUsername, root.pendingPassword, root.pendingAdmin, root.pendingGreeter, null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -404,7 +404,7 @@ Item {
|
||||
widgetObj.mountPath = "/";
|
||||
widgetObj.diskUsageMode = 0;
|
||||
}
|
||||
if (widgetId === "cpuUsage" || widgetId === "memUsage" || widgetId === "cpuTemp" || widgetId === "gpuTemp")
|
||||
if (widgetId === "cpuUsage" || widgetId === "memUsage" || widgetId === "cpuTemp" || widgetId === "gpuTemp" || widgetId === "diskUsage")
|
||||
widgetObj.minimumWidth = true;
|
||||
if (widgetId === "memUsage")
|
||||
widgetObj.showInGb = false;
|
||||
|
||||
@@ -320,7 +320,7 @@ Column {
|
||||
DankActionButton {
|
||||
id: minimumWidthButton
|
||||
buttonSize: 28
|
||||
visible: modelData.id === "cpuUsage" || modelData.id === "memUsage" || modelData.id === "cpuTemp" || modelData.id === "gpuTemp"
|
||||
visible: modelData.id === "cpuUsage" || modelData.id === "memUsage" || modelData.id === "cpuTemp" || modelData.id === "gpuTemp" || modelData.id === "diskUsage"
|
||||
iconName: "straighten"
|
||||
iconSize: 16
|
||||
iconColor: (modelData.minimumWidth !== undefined ? modelData.minimumWidth : true) ? Theme.primary : Theme.outline
|
||||
|
||||
@@ -19,8 +19,11 @@ Item {
|
||||
property var parentModal: null
|
||||
property var windowRulesIncludeStatus: ({
|
||||
"exists": false,
|
||||
"included": false
|
||||
"included": false,
|
||||
"configFormat": "",
|
||||
"readOnly": false
|
||||
})
|
||||
readonly property bool readOnly: CompositorService.isHyprland && windowRulesIncludeStatus.readOnly === true
|
||||
property bool checkingInclude: false
|
||||
property bool fixingInclude: false
|
||||
property var windowRules: []
|
||||
@@ -84,7 +87,9 @@ Item {
|
||||
if (result.dmsStatus) {
|
||||
windowRulesIncludeStatus = {
|
||||
"exists": result.dmsStatus.exists,
|
||||
"included": result.dmsStatus.included
|
||||
"included": result.dmsStatus.included,
|
||||
"configFormat": result.dmsStatus.configFormat ?? "",
|
||||
"readOnly": result.dmsStatus.readOnly === true
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -94,6 +99,10 @@ Item {
|
||||
}
|
||||
|
||||
function removeRule(ruleId) {
|
||||
if (readOnly) {
|
||||
showHyprlandReadOnlyWarning();
|
||||
return;
|
||||
}
|
||||
const compositor = CompositorService.compositor;
|
||||
if (compositor !== "niri" && compositor !== "hyprland")
|
||||
return;
|
||||
@@ -107,6 +116,10 @@ Item {
|
||||
}
|
||||
|
||||
function reorderRules(fromIndex, toIndex) {
|
||||
if (readOnly) {
|
||||
showHyprlandReadOnlyWarning();
|
||||
return;
|
||||
}
|
||||
if (fromIndex === toIndex)
|
||||
return;
|
||||
|
||||
@@ -131,7 +144,9 @@ Item {
|
||||
if (compositor !== "niri" && compositor !== "hyprland") {
|
||||
windowRulesIncludeStatus = {
|
||||
"exists": false,
|
||||
"included": false
|
||||
"included": false,
|
||||
"configFormat": "",
|
||||
"readOnly": false
|
||||
};
|
||||
return;
|
||||
}
|
||||
@@ -143,7 +158,9 @@ Item {
|
||||
if (exitCode !== 0) {
|
||||
windowRulesIncludeStatus = {
|
||||
"exists": false,
|
||||
"included": false
|
||||
"included": false,
|
||||
"configFormat": "",
|
||||
"readOnly": false
|
||||
};
|
||||
return;
|
||||
}
|
||||
@@ -152,13 +169,19 @@ Item {
|
||||
} catch (e) {
|
||||
windowRulesIncludeStatus = {
|
||||
"exists": false,
|
||||
"included": false
|
||||
"included": false,
|
||||
"configFormat": "",
|
||||
"readOnly": false
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function fixWindowRulesInclude() {
|
||||
if (readOnly) {
|
||||
showHyprlandReadOnlyWarning();
|
||||
return;
|
||||
}
|
||||
const paths = getWindowRulesConfigPaths();
|
||||
if (!paths)
|
||||
return;
|
||||
@@ -182,6 +205,10 @@ Item {
|
||||
}
|
||||
|
||||
function openRuleModal(window) {
|
||||
if (readOnly) {
|
||||
showHyprlandReadOnlyWarning();
|
||||
return;
|
||||
}
|
||||
if (!PopoutService.windowRuleModalLoader)
|
||||
return;
|
||||
PopoutService.windowRuleModalLoader.active = true;
|
||||
@@ -192,6 +219,10 @@ Item {
|
||||
}
|
||||
|
||||
function editRule(rule) {
|
||||
if (readOnly) {
|
||||
showHyprlandReadOnlyWarning();
|
||||
return;
|
||||
}
|
||||
if (!PopoutService.windowRuleModalLoader)
|
||||
return;
|
||||
PopoutService.windowRuleModalLoader.active = true;
|
||||
@@ -201,6 +232,10 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
function showHyprlandReadOnlyWarning() {
|
||||
ToastService.showWarning(I18n.tr("Hyprland conf mode"), I18n.tr("This install is still using hyprland.conf. Run dms setup to migrate before editing window rules in Settings."), "dms setup", "hyprland-migration");
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
if (CompositorService.isNiri || CompositorService.isHyprland) {
|
||||
checkWindowRulesIncludeStatus();
|
||||
@@ -274,6 +309,8 @@ Item {
|
||||
iconName: "add"
|
||||
iconSize: Theme.iconSize
|
||||
iconColor: Theme.primary
|
||||
enabled: !root.readOnly
|
||||
opacity: enabled ? 1 : 0.5
|
||||
onClicked: root.openRuleModal()
|
||||
}
|
||||
}
|
||||
@@ -322,13 +359,14 @@ Item {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
radius: Theme.cornerRadius
|
||||
|
||||
readonly property bool showError: root.windowRulesIncludeStatus.exists && !root.windowRulesIncludeStatus.included
|
||||
readonly property bool showSetup: !root.windowRulesIncludeStatus.exists && !root.windowRulesIncludeStatus.included
|
||||
readonly property bool showLegacy: root.readOnly
|
||||
readonly property bool showError: !showLegacy && root.windowRulesIncludeStatus.exists && !root.windowRulesIncludeStatus.included
|
||||
readonly property bool showSetup: !showLegacy && !root.windowRulesIncludeStatus.exists && !root.windowRulesIncludeStatus.included
|
||||
|
||||
color: (showError || showSetup) ? Theme.withAlpha(Theme.warning, 0.15) : "transparent"
|
||||
border.color: (showError || showSetup) ? Theme.withAlpha(Theme.warning, 0.3) : "transparent"
|
||||
color: (showLegacy || showError || showSetup) ? Theme.withAlpha(Theme.warning, 0.15) : "transparent"
|
||||
border.color: (showLegacy || showError || showSetup) ? Theme.withAlpha(Theme.warning, 0.3) : "transparent"
|
||||
border.width: 1
|
||||
visible: (showError || showSetup) && !root.checkingInclude && (CompositorService.isNiri || CompositorService.isHyprland)
|
||||
visible: (showLegacy || showError || showSetup) && !root.checkingInclude && (CompositorService.isNiri || CompositorService.isHyprland)
|
||||
|
||||
Row {
|
||||
id: warningSection
|
||||
@@ -349,7 +387,7 @@ Item {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
StyledText {
|
||||
text: warningBox.showSetup ? I18n.tr("Window Rules Not Configured") : I18n.tr("Window Rules Include Missing")
|
||||
text: warningBox.showLegacy ? I18n.tr("Hyprland conf mode") : (warningBox.showSetup ? I18n.tr("Window Rules Not Configured") : I18n.tr("Window Rules Include Missing"))
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
font.weight: Font.Medium
|
||||
color: Theme.warning
|
||||
@@ -359,7 +397,7 @@ Item {
|
||||
|
||||
StyledText {
|
||||
readonly property string rulesFile: CompositorService.isNiri ? "dms/windowrules.kdl" : "dms/windowrules.lua"
|
||||
text: warningBox.showSetup ? I18n.tr("Click 'Setup' to create %1 and add include to your compositor config.").arg(rulesFile) : I18n.tr("%1 exists but is not included. Window rules won't apply.").arg(rulesFile)
|
||||
text: warningBox.showLegacy ? I18n.tr("This install is still using hyprland.conf. Run dms setup to migrate before editing window rules in Settings.") : (warningBox.showSetup ? I18n.tr("Click 'Setup' to create %1 and add include to your compositor config.").arg(rulesFile) : I18n.tr("%1 exists but is not included. Window rules won't apply.").arg(rulesFile))
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
wrapMode: Text.WordWrap
|
||||
@@ -370,7 +408,7 @@ Item {
|
||||
|
||||
DankButton {
|
||||
id: fixButton
|
||||
visible: warningBox.showError || warningBox.showSetup
|
||||
visible: !warningBox.showLegacy && (warningBox.showError || warningBox.showSetup)
|
||||
text: root.fixingInclude ? I18n.tr("Fixing...") : (warningBox.showSetup ? I18n.tr("Setup") : I18n.tr("Fix Now"))
|
||||
backgroundColor: Theme.warning
|
||||
textColor: Theme.background
|
||||
@@ -611,6 +649,8 @@ Item {
|
||||
iconSize: 16
|
||||
backgroundColor: "transparent"
|
||||
iconColor: Theme.surfaceVariantText
|
||||
enabled: !root.readOnly
|
||||
opacity: enabled ? 1 : 0.5
|
||||
onClicked: root.editRule(ruleDelegateItem.liveRuleData)
|
||||
}
|
||||
|
||||
@@ -621,12 +661,14 @@ Item {
|
||||
iconSize: 16
|
||||
backgroundColor: "transparent"
|
||||
iconColor: deleteArea.containsMouse ? Theme.error : Theme.surfaceVariantText
|
||||
enabled: !root.readOnly
|
||||
opacity: enabled ? 1 : 0.5
|
||||
|
||||
MouseArea {
|
||||
id: deleteArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
hoverEnabled: !root.readOnly
|
||||
cursorShape: root.readOnly ? Qt.ArrowCursor : Qt.PointingHandCursor
|
||||
onClicked: root.removeRule(ruleDelegateItem.ruleIdRef)
|
||||
}
|
||||
}
|
||||
@@ -641,8 +683,8 @@ Item {
|
||||
width: 40
|
||||
height: ruleCard.height
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.SizeVerCursor
|
||||
drag.target: ruleDelegateItem.held ? ruleDelegateItem : undefined
|
||||
cursorShape: root.readOnly ? Qt.ArrowCursor : Qt.SizeVerCursor
|
||||
drag.target: !root.readOnly && ruleDelegateItem.held ? ruleDelegateItem : undefined
|
||||
drag.axis: Drag.YAxis
|
||||
preventStealing: true
|
||||
|
||||
|
||||
@@ -50,8 +50,8 @@ PanelWindow {
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
||||
color: "transparent"
|
||||
|
||||
readonly property real toastWidth: shouldBeVisible ? Math.min(900, messageText.implicitWidth + statusIcon.width + Theme.spacingM + (ToastService.hasDetails ? (expandButton.width + closeButton.width + 4) : (ToastService.currentLevel === ToastService.levelError ? closeButton.width + Theme.spacingS : 0)) + Theme.spacingL * 2 + Theme.spacingM * 2) : frozenWidth
|
||||
readonly property real toastHeight: toastContent.height + Theme.spacingL * 2
|
||||
readonly property real toastWidth: shouldBeVisible ? Theme.px(Math.min(900, messageText.implicitWidth + statusIcon.width + Theme.spacingM + (ToastService.hasDetails ? (expandButton.width + closeButton.width + 4) : (ToastService.currentLevel === ToastService.levelError ? closeButton.width + Theme.spacingS : 0)) + Theme.spacingL * 2 + Theme.spacingM * 2), dpr) : frozenWidth
|
||||
readonly property real toastHeight: Theme.px(toastContent.height + Theme.spacingL * 2, dpr)
|
||||
|
||||
anchors {
|
||||
top: true
|
||||
@@ -63,8 +63,8 @@ PanelWindow {
|
||||
top: Math.max(0, Theme.snap(toastY - shadowBuffer, dpr))
|
||||
}
|
||||
|
||||
implicitWidth: toastWidth + (shadowBuffer * 2)
|
||||
implicitHeight: toastHeight + (shadowBuffer * 2)
|
||||
implicitWidth: Theme.px(toastWidth + (shadowBuffer * 2), dpr)
|
||||
implicitHeight: Theme.px(toastHeight + (shadowBuffer * 2), dpr)
|
||||
|
||||
Rectangle {
|
||||
id: toast
|
||||
|
||||
@@ -34,6 +34,8 @@ Scope {
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.exclusiveZone: -1
|
||||
WlrLayershell.keyboardFocus: {
|
||||
if (PopoutManager.screenshotActive)
|
||||
return WlrKeyboardFocus.None;
|
||||
if (!overviewScope.overviewOpen)
|
||||
return WlrKeyboardFocus.None;
|
||||
if (CompositorService.useHyprlandFocusGrab)
|
||||
|
||||
@@ -124,6 +124,8 @@ Scope {
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.exclusiveZone: -1
|
||||
WlrLayershell.keyboardFocus: {
|
||||
if (PopoutManager.screenshotActive)
|
||||
return WlrKeyboardFocus.None;
|
||||
if (!NiriService.inOverview)
|
||||
return WlrKeyboardFocus.None;
|
||||
if (!isActiveScreen)
|
||||
|
||||
@@ -236,19 +236,16 @@ Singleton {
|
||||
readonly property bool suggestPowerSaver: false
|
||||
|
||||
readonly property var bluetoothDevices: {
|
||||
const btDevices = [];
|
||||
const bluetoothTypes = [UPowerDeviceType.BluetoothGeneric, UPowerDeviceType.Headphones, UPowerDeviceType.Headset, UPowerDeviceType.Keyboard, UPowerDeviceType.Mouse, UPowerDeviceType.Speakers];
|
||||
|
||||
for (var i = 0; i < UPower.devices.count; i++) {
|
||||
const dev = UPower.devices.get(i);
|
||||
if (dev && dev.ready && bluetoothTypes.includes(dev.type)) {
|
||||
btDevices.push({
|
||||
"name": dev.model || UPowerDeviceType.toString(dev.type),
|
||||
"percentage": Math.round(dev.percentage * 100),
|
||||
"type": dev.type
|
||||
});
|
||||
}
|
||||
}
|
||||
const btDevices = UPower.devices.values.filter(dev => dev && dev.ready && bluetoothTypes.includes(dev.type)).map(dev => {
|
||||
return {
|
||||
"name": dev.model || UPowerDeviceType.toString(dev.type),
|
||||
"percentage": Math.round(dev.percentage * 100),
|
||||
"type": dev.type
|
||||
};
|
||||
});
|
||||
|
||||
return btDevices;
|
||||
}
|
||||
|
||||
|
||||
@@ -68,15 +68,17 @@ Singleton {
|
||||
|
||||
clipboardEntries = filtered;
|
||||
unpinnedEntries = filtered.filter(e => !e.pinned);
|
||||
pinnedEntries = filtered.filter(e => e.pinned);
|
||||
totalCount = clipboardEntries.length;
|
||||
|
||||
if (unpinnedEntries.length === 0) {
|
||||
const activeCount = Math.max(unpinnedEntries.length, pinnedEntries.length);
|
||||
if (activeCount === 0) {
|
||||
keyboardNavigationActive = false;
|
||||
selectedIndex = 0;
|
||||
return;
|
||||
}
|
||||
if (selectedIndex >= unpinnedEntries.length) {
|
||||
selectedIndex = unpinnedEntries.length - 1;
|
||||
if (selectedIndex >= activeCount) {
|
||||
selectedIndex = activeCount - 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,6 +242,17 @@ Singleton {
|
||||
});
|
||||
}
|
||||
|
||||
function pasteClipboard(closeCallback) {
|
||||
if (!wtypeAvailable) {
|
||||
ToastService.showError(I18n.tr("wtype not available - install wtype for paste support"));
|
||||
return;
|
||||
}
|
||||
if (closeCallback) {
|
||||
closeCallback();
|
||||
}
|
||||
pasteTimer.start();
|
||||
}
|
||||
|
||||
function pasteEntry(entry, closeCallback) {
|
||||
if (!wtypeAvailable) {
|
||||
ToastService.showError(I18n.tr("wtype not available - install wtype for paste support"));
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Common
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property var log: Log.scoped("GreeterUsersService")
|
||||
|
||||
readonly property string greetCfgDir: Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter"
|
||||
readonly property string usersCacheDir: greetCfgDir + "/users"
|
||||
|
||||
property var users: []
|
||||
property var usernames: []
|
||||
property var profileImageMap: ({})
|
||||
property bool loaded: false
|
||||
property bool refreshing: false
|
||||
|
||||
Component.onCompleted: refresh()
|
||||
|
||||
function refresh() {
|
||||
if (refreshing)
|
||||
return;
|
||||
refreshing = true;
|
||||
_loadUsers();
|
||||
}
|
||||
|
||||
function displayName(username) {
|
||||
const u = _findUser(username);
|
||||
if (!u)
|
||||
return username || "";
|
||||
const gecos = (u.gecos || "").trim();
|
||||
return gecos.length > 0 ? gecos : username;
|
||||
}
|
||||
|
||||
function optionLabel(username) {
|
||||
const label = displayName(username);
|
||||
return label !== username ? label : username;
|
||||
}
|
||||
|
||||
function usernameFromOptionLabel(label) {
|
||||
for (let i = 0; i < users.length; i++) {
|
||||
if (root.optionLabel(users[i].username) === label)
|
||||
return users[i].username;
|
||||
}
|
||||
return label;
|
||||
}
|
||||
|
||||
function hasSyncedTheme(username) {
|
||||
if (!username)
|
||||
return false;
|
||||
return syncedThemePaths[username] === true;
|
||||
}
|
||||
|
||||
property var syncedThemePaths: ({})
|
||||
|
||||
function userCacheDir(username) {
|
||||
if (!username)
|
||||
return "";
|
||||
return usersCacheDir + "/" + username;
|
||||
}
|
||||
|
||||
function syncedSettingsPath(username) {
|
||||
const dir = userCacheDir(username);
|
||||
return dir ? dir + "/settings.json" : "";
|
||||
}
|
||||
|
||||
function _findUser(name) {
|
||||
for (let i = 0; i < users.length; i++) {
|
||||
if (users[i].username === name)
|
||||
return users[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _loadUsers() {
|
||||
Proc.runCommand("greeterUsersService-loadUsers", ["sh", "-c", "getent passwd | awk -F: '$3>=1000 && $3<60000 && $1!=\"nobody\" && $7!~/(nologin|false)$/ && $6!=\"/var/empty\" {print $1\":\"$3\":\"$5\":\"$6\":\"$7}'"], (output, exitCode) => {
|
||||
const lines = (output || "").trim().split("\n").filter(l => l.length > 0);
|
||||
const list = [];
|
||||
const names = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const parts = lines[i].split(":");
|
||||
if (parts.length < 5)
|
||||
continue;
|
||||
const username = parts[0];
|
||||
list.push({
|
||||
username,
|
||||
uid: parseInt(parts[1], 10),
|
||||
gecos: (parts[2] || "").split(",")[0],
|
||||
home: parts[3] || "",
|
||||
shell: parts[4] || ""
|
||||
});
|
||||
names.push(username);
|
||||
}
|
||||
list.sort((a, b) => a.username.localeCompare(b.username));
|
||||
names.sort((a, b) => a.localeCompare(b));
|
||||
root.users = list;
|
||||
root.usernames = names;
|
||||
root.loaded = true;
|
||||
root.refreshing = false;
|
||||
_refreshSyncedThemeFlags();
|
||||
_loadProfileIcons();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function _refreshSyncedThemeFlags() {
|
||||
if (usernames.length === 0) {
|
||||
syncedThemePaths = ({});
|
||||
return;
|
||||
}
|
||||
const checks = usernames.map(u => `[ -f "${syncedSettingsPath(u)}" ] && echo "${u}:1" || echo "${u}:0"`).join("; ");
|
||||
Proc.runCommand("greeterUsersService-syncedThemes", ["sh", "-c", checks], (output, exitCode) => {
|
||||
const map = {};
|
||||
const lines = (output || "").trim().split("\n").filter(l => l.length > 0);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const parts = lines[i].split(":");
|
||||
if (parts.length >= 2)
|
||||
map[parts[0]] = parts[1] === "1";
|
||||
}
|
||||
root.syncedThemePaths = map;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function profileImagePath(username) {
|
||||
if (!username)
|
||||
return "";
|
||||
return profileImageMap[username] || "";
|
||||
}
|
||||
|
||||
function _loadProfileIcons() {
|
||||
if (users.length === 0) {
|
||||
profileImageMap = ({});
|
||||
return;
|
||||
}
|
||||
const script = users.map(u => {
|
||||
const safeUser = u.username.replace(/'/g, "'\\''");
|
||||
const safeHome = (u.home || "").replace(/'/g, "'\\''");
|
||||
const cacheDir = usersCacheDir + "/" + u.username;
|
||||
return `( icon=""; for f in "${cacheDir}/profile.jpg" "${cacheDir}/profile.jpeg" "${cacheDir}/profile.png" "${cacheDir}/profile.webp" "/var/lib/AccountsService/icons/${safeUser}" "${safeHome}/.face" "${safeHome}/.face.icon"; do if [ -f "$f" ] && [ -r "$f" ]; then icon="$f"; break; fi; done; echo "${u.username}:$icon" )`;
|
||||
}).join("; ");
|
||||
Proc.runCommand("greeterUsersService-profileIcons", ["sh", "-c", script], (output, exitCode) => {
|
||||
const map = {};
|
||||
const lines = (output || "").trim().split("\n").filter(l => l.length > 0);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const idx = lines[i].indexOf(":");
|
||||
if (idx <= 0)
|
||||
continue;
|
||||
const user = lines[i].substring(0, idx);
|
||||
const icon = lines[i].substring(idx + 1).trim();
|
||||
map[user] = icon && icon.length > 0 ? icon : "";
|
||||
}
|
||||
for (let j = 0; j < users.length; j++) {
|
||||
const u = users[j].username;
|
||||
if (!(u in map))
|
||||
map[u] = "";
|
||||
}
|
||||
root.profileImageMap = map;
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
@@ -18,17 +18,35 @@ Singleton {
|
||||
readonly property string layoutPath: hyprDmsDir + "/layout.lua"
|
||||
readonly property string cursorPath: hyprDmsDir + "/cursor.lua"
|
||||
readonly property string windowrulesPath: hyprDmsDir + "/windowrules.lua"
|
||||
readonly property bool luaConfigActive: CompositorService.isHyprland && (Hyprland.usingLua === true || luaConfigDetected)
|
||||
|
||||
property int _lastGapValue: -1
|
||||
property bool luaConfigDetected: false
|
||||
property bool luaConfigStatusReady: false
|
||||
property bool luaConfigStatusLoading: false
|
||||
property string luaConfigFormat: ""
|
||||
|
||||
onLuaConfigActiveChanged: {
|
||||
if (luaConfigActive)
|
||||
ensureDmsLuaConfigs();
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
if (CompositorService.isHyprland) {
|
||||
Qt.callLater(generateLayoutConfig);
|
||||
ensureWindowrulesConfig();
|
||||
refreshLuaConfigStatus();
|
||||
if (luaConfigActive)
|
||||
ensureDmsLuaConfigs();
|
||||
}
|
||||
}
|
||||
|
||||
function ensureDmsLuaConfigs() {
|
||||
Qt.callLater(generateLayoutConfig);
|
||||
Qt.callLater(ensureWindowrulesConfig);
|
||||
}
|
||||
|
||||
function ensureWindowrulesConfig() {
|
||||
if (!canWriteLuaConfig("windowrules"))
|
||||
return;
|
||||
Proc.runCommand("hypr-ensure-windowrules", ["sh", "-c", `mkdir -p "${hyprDmsDir}" && [ ! -f "${windowrulesPath}" ] && touch "${windowrulesPath}" || true`], (output, exitCode) => {
|
||||
if (exitCode !== 0)
|
||||
log.warn("Failed to ensure windowrules.lua:", output);
|
||||
@@ -51,14 +69,22 @@ Singleton {
|
||||
Connections {
|
||||
target: CompositorService
|
||||
function onIsHyprlandChanged() {
|
||||
if (CompositorService.isHyprland)
|
||||
generateLayoutConfig();
|
||||
if (CompositorService.isHyprland) {
|
||||
refreshLuaConfigStatus();
|
||||
if (luaConfigActive)
|
||||
ensureDmsLuaConfigs();
|
||||
return;
|
||||
}
|
||||
luaConfigDetected = false;
|
||||
luaConfigStatusReady = false;
|
||||
luaConfigStatusLoading = false;
|
||||
luaConfigFormat = "";
|
||||
}
|
||||
}
|
||||
|
||||
function getOutputIdentifier(output, outputName) {
|
||||
if (SettingsData.displayNameMode === "model" && output.make && output.model)
|
||||
return "desc:" + output.make + " " + output.model + " " + (output.serial || "Unknown");
|
||||
return ("desc:" + output.make + " " + output.model + " " + (output.serial || "Unknown")).replace(/,/g, "");
|
||||
return outputName;
|
||||
}
|
||||
|
||||
@@ -66,6 +92,50 @@ Singleton {
|
||||
return JSON.stringify(String(str ?? ""));
|
||||
}
|
||||
|
||||
function refreshLuaConfigStatus() {
|
||||
if (!CompositorService.isHyprland) {
|
||||
luaConfigDetected = false;
|
||||
luaConfigStatusReady = false;
|
||||
luaConfigStatusLoading = false;
|
||||
luaConfigFormat = "";
|
||||
return;
|
||||
}
|
||||
if (luaConfigStatusLoading)
|
||||
return;
|
||||
|
||||
luaConfigStatusLoading = true;
|
||||
Proc.runCommand("hypr-lua-config-status", ["dms", "config", "resolve-include", "hyprland", "outputs.lua"], (output, exitCode) => {
|
||||
luaConfigStatusLoading = false;
|
||||
luaConfigStatusReady = true;
|
||||
if (exitCode !== 0) {
|
||||
luaConfigDetected = false;
|
||||
luaConfigFormat = "";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const status = JSON.parse(output.trim());
|
||||
luaConfigFormat = status.configFormat ?? "";
|
||||
luaConfigDetected = luaConfigFormat === "lua" && status.readOnly !== true;
|
||||
} catch (e) {
|
||||
luaConfigDetected = false;
|
||||
luaConfigFormat = "";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function canWriteLuaConfig(name) {
|
||||
if (luaConfigActive)
|
||||
return true;
|
||||
if (CompositorService.isHyprland && !luaConfigStatusReady && !luaConfigStatusLoading)
|
||||
refreshLuaConfigStatus();
|
||||
if (CompositorService.isHyprland && (luaConfigStatusLoading || !luaConfigStatusReady)) {
|
||||
log.debug("Deferring Hyprland", name || "config", "Lua write until config format is known");
|
||||
return false;
|
||||
}
|
||||
log.info("Skipping Hyprland", name || "config", "Lua write because the active Hyprland config is not Lua");
|
||||
return false;
|
||||
}
|
||||
|
||||
function forceFlagValue(value) {
|
||||
if (value === true)
|
||||
return 1;
|
||||
@@ -75,6 +145,11 @@ Singleton {
|
||||
}
|
||||
|
||||
function generateOutputsConfig(outputsData, hyprlandSettings, callback) {
|
||||
if (!canWriteLuaConfig("outputs")) {
|
||||
if (callback)
|
||||
callback(false);
|
||||
return;
|
||||
}
|
||||
if (!outputsData || Object.keys(outputsData).length === 0) {
|
||||
if (callback)
|
||||
callback(false);
|
||||
@@ -172,6 +247,8 @@ Singleton {
|
||||
function generateLayoutConfig() {
|
||||
if (!CompositorService.isHyprland)
|
||||
return;
|
||||
if (!canWriteLuaConfig("layout"))
|
||||
return;
|
||||
|
||||
const defaultRadius = typeof SettingsData !== "undefined" ? SettingsData.cornerRadius : 12;
|
||||
const defaultGaps = typeof SettingsData !== "undefined" ? Math.max(4, (SettingsData.barConfigs[0]?.spacing ?? 4)) : 4;
|
||||
@@ -254,6 +331,8 @@ hl.config({
|
||||
function generateCursorConfig() {
|
||||
if (!CompositorService.isHyprland)
|
||||
return;
|
||||
if (!canWriteLuaConfig("cursor"))
|
||||
return;
|
||||
|
||||
const settings = typeof SettingsData !== "undefined" ? SettingsData.cursorSettings : null;
|
||||
if (!settings) {
|
||||
@@ -326,7 +405,7 @@ hl.config({
|
||||
if (!wsId)
|
||||
return;
|
||||
const fullName = wsId + " " + newName;
|
||||
if (Hyprland.usingLua) {
|
||||
if (luaConfigActive) {
|
||||
Hyprland.dispatch(`hl.dsp.workspace.rename({ workspace = ${luaValue(wsId)}, name = ${luaString(fullName)} })`);
|
||||
} else {
|
||||
Hyprland.dispatch(`renameworkspace ${wsId} ${fullName}`);
|
||||
@@ -334,7 +413,7 @@ hl.config({
|
||||
}
|
||||
|
||||
function focusWorkspace(workspace) {
|
||||
if (Hyprland.usingLua) {
|
||||
if (luaConfigActive) {
|
||||
Hyprland.dispatch(`hl.dsp.focus({ workspace = ${luaValue(workspace)} })`);
|
||||
} else {
|
||||
Hyprland.dispatch(`workspace ${workspace}`);
|
||||
@@ -366,7 +445,7 @@ hl.config({
|
||||
if (!selector)
|
||||
return;
|
||||
|
||||
if (Hyprland.usingLua) {
|
||||
if (luaConfigActive) {
|
||||
Hyprland.dispatch(`hl.dsp.focus({ window = ${luaString(selector)} })`);
|
||||
} else {
|
||||
Hyprland.dispatch(`focuswindow ${selector}`);
|
||||
@@ -378,7 +457,7 @@ hl.config({
|
||||
if (!selector)
|
||||
return;
|
||||
|
||||
if (Hyprland.usingLua) {
|
||||
if (luaConfigActive) {
|
||||
Hyprland.dispatch(`hl.dsp.window.close(${luaString(selector)})`);
|
||||
} else {
|
||||
Hyprland.dispatch(`closewindow ${selector}`);
|
||||
@@ -390,7 +469,7 @@ hl.config({
|
||||
if (!selector)
|
||||
return;
|
||||
|
||||
if (Hyprland.usingLua) {
|
||||
if (luaConfigActive) {
|
||||
Hyprland.dispatch(`hl.dsp.window.move({ workspace = ${luaValue(workspace)}, window = ${luaString(selector)}, follow = ${follow ? "true" : "false"} })`);
|
||||
} else {
|
||||
const dispatcher = follow ? "movetoworkspace" : "movetoworkspacesilent";
|
||||
@@ -399,7 +478,7 @@ hl.config({
|
||||
}
|
||||
|
||||
function toggleSpecial(specialName) {
|
||||
if (Hyprland.usingLua) {
|
||||
if (luaConfigActive) {
|
||||
Hyprland.dispatch(`hl.dsp.workspace.toggle_special(${luaString(specialName)})`);
|
||||
} else {
|
||||
Hyprland.dispatch("togglespecialworkspace " + specialName);
|
||||
@@ -407,7 +486,7 @@ hl.config({
|
||||
}
|
||||
|
||||
function exit() {
|
||||
if (Hyprland.usingLua) {
|
||||
if (luaConfigActive) {
|
||||
Hyprland.dispatch("hl.dsp.exit()");
|
||||
} else {
|
||||
Hyprland.dispatch("exit");
|
||||
@@ -415,7 +494,7 @@ hl.config({
|
||||
}
|
||||
|
||||
function dpmsOff() {
|
||||
if (Hyprland.usingLua) {
|
||||
if (luaConfigActive) {
|
||||
Hyprland.dispatch(`hl.dsp.dpms({ action = "disable" })`);
|
||||
} else {
|
||||
Hyprland.dispatch("dpms off");
|
||||
@@ -423,7 +502,7 @@ hl.config({
|
||||
}
|
||||
|
||||
function dpmsOn() {
|
||||
if (Hyprland.usingLua) {
|
||||
if (luaConfigActive) {
|
||||
Hyprland.dispatch(`hl.dsp.dpms({ action = "enable" })`);
|
||||
} else {
|
||||
Hyprland.dispatch("dpms on");
|
||||
|
||||
@@ -52,7 +52,9 @@ Singleton {
|
||||
"bindsAfterDms": 0,
|
||||
"effective": true,
|
||||
"overriddenBy": 0,
|
||||
"statusMessage": ""
|
||||
"statusMessage": "",
|
||||
"configFormat": "",
|
||||
"readOnly": false
|
||||
})
|
||||
|
||||
property var _rawData: null
|
||||
@@ -102,6 +104,7 @@ Singleton {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
readonly property bool readOnly: currentProvider === "hyprland" && dmsStatus.readOnly === true
|
||||
readonly property var actionTypes: Actions.getActionTypes()
|
||||
readonly property var dmsActions: getDmsActions()
|
||||
|
||||
@@ -258,6 +261,10 @@ Singleton {
|
||||
function fixDmsBindsInclude() {
|
||||
if (fixing || dmsBindsIncluded || !compositorConfigDir)
|
||||
return;
|
||||
if (readOnly) {
|
||||
showHyprlandReadOnlyWarning();
|
||||
return;
|
||||
}
|
||||
fixing = true;
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const backupPath = `${mainConfigPath}.dmsbackup${timestamp}`;
|
||||
@@ -343,7 +350,9 @@ Singleton {
|
||||
"bindsAfterDms": status.bindsAfterDms ?? 0,
|
||||
"effective": status.effective ?? true,
|
||||
"overriddenBy": status.overriddenBy ?? 0,
|
||||
"statusMessage": status.statusMessage ?? ""
|
||||
"statusMessage": status.statusMessage ?? "",
|
||||
"configFormat": status.configFormat ?? "",
|
||||
"readOnly": status.readOnly === true
|
||||
};
|
||||
}
|
||||
_maybeWarnHyprlandLegacyConf();
|
||||
@@ -482,6 +491,10 @@ Singleton {
|
||||
}
|
||||
|
||||
function saveBind(originalKey, bindData) {
|
||||
if (readOnly) {
|
||||
showHyprlandReadOnlyWarning();
|
||||
return;
|
||||
}
|
||||
if (!bindData.key || !Actions.isValidAction(bindData.action))
|
||||
return;
|
||||
saving = true;
|
||||
@@ -510,13 +523,26 @@ Singleton {
|
||||
return;
|
||||
if (currentProvider !== "hyprland")
|
||||
return;
|
||||
if (readOnly) {
|
||||
_hyprlandLegacyWarnShown = true;
|
||||
showHyprlandReadOnlyWarning();
|
||||
return;
|
||||
}
|
||||
if (!dmsStatus.exists || dmsStatus.included)
|
||||
return;
|
||||
_hyprlandLegacyWarnShown = true;
|
||||
ToastService.showWarning(I18n.tr("Hyprland config still uses hyprlang"), I18n.tr("DMS Settings now writes Lua. Edits won't apply until you migrate."), "dms setup", "hyprland-migration");
|
||||
ToastService.showWarning(I18n.tr("Hyprland config include missing"), I18n.tr("DMS Settings writes Lua keybinds. Add the DMS include so edits apply."), "dms setup", "hyprland-migration");
|
||||
}
|
||||
|
||||
function showHyprlandReadOnlyWarning() {
|
||||
ToastService.showWarning(I18n.tr("Hyprland conf mode"), I18n.tr("This install is still using hyprland.conf. Run dms setup to migrate before editing shortcuts in Settings."), "dms setup", "hyprland-migration");
|
||||
}
|
||||
|
||||
function removeBind(key) {
|
||||
if (readOnly) {
|
||||
showHyprlandReadOnlyWarning();
|
||||
return;
|
||||
}
|
||||
if (!key)
|
||||
return;
|
||||
removeProcess.command = ["dms", "keybinds", "remove", currentProvider, key];
|
||||
@@ -525,6 +551,10 @@ Singleton {
|
||||
}
|
||||
|
||||
function resetBind(key) {
|
||||
if (readOnly) {
|
||||
showHyprlandReadOnlyWarning();
|
||||
return;
|
||||
}
|
||||
if (!key)
|
||||
return;
|
||||
removeProcess.command = ["dms", "keybinds", "reset", currentProvider, key];
|
||||
|
||||
@@ -7,7 +7,7 @@ import Quickshell
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property bool locationAvailable: DMSService.isConnected && (DMSService.capabilities.length === 0 || DMSService.capabilities.includes("location"))
|
||||
readonly property bool locationAvailable: DMSService.isConnected && DMSService.capabilities.includes("location")
|
||||
readonly property bool valid: latitude !== 0 || longitude !== 0
|
||||
|
||||
property var latitude: 0.0
|
||||
|
||||
@@ -11,6 +11,33 @@ Singleton {
|
||||
|
||||
readonly property list<MprisPlayer> availablePlayers: Mpris.players.values
|
||||
property MprisPlayer activePlayer: null
|
||||
property real activePlayerStableLength: 0
|
||||
|
||||
Connections {
|
||||
target: root.activePlayer
|
||||
function onTrackTitleChanged() {
|
||||
root.activePlayerStableLength = (root.activePlayer && root.activePlayer.lengthSupported && root.activePlayer.length > 1) ? root.activePlayer.length : 0;
|
||||
if (root.isIdle(root.activePlayer))
|
||||
root._resolveActivePlayer();
|
||||
}
|
||||
function onTrackArtistChanged() {
|
||||
if (root.isIdle(root.activePlayer))
|
||||
root._resolveActivePlayer();
|
||||
}
|
||||
function onLengthChanged() {
|
||||
if (root.activePlayer && root.activePlayer.lengthSupported && root.activePlayer.length > 1) {
|
||||
root.activePlayerStableLength = root.activePlayer.length;
|
||||
}
|
||||
}
|
||||
function onPlaybackStateChanged() {
|
||||
if (root.isIdle(root.activePlayer))
|
||||
root._resolveActivePlayer();
|
||||
}
|
||||
}
|
||||
|
||||
onActivePlayerChanged: {
|
||||
activePlayerStableLength = (activePlayer && activePlayer.lengthSupported && activePlayer.length > 1) ? activePlayer.length : 0;
|
||||
}
|
||||
|
||||
onAvailablePlayersChanged: _resolveActivePlayer()
|
||||
Component.onCompleted: _resolveActivePlayer()
|
||||
@@ -27,6 +54,13 @@ Singleton {
|
||||
}
|
||||
}
|
||||
|
||||
function isIdle(player: MprisPlayer): bool {
|
||||
return player
|
||||
&& player.playbackState === MprisPlaybackState.Stopped
|
||||
&& !player.trackTitle
|
||||
&& !player.trackArtist;
|
||||
}
|
||||
|
||||
function _resolveActivePlayer(): void {
|
||||
const playing = availablePlayers.find(p => p.isPlaying);
|
||||
if (playing) {
|
||||
@@ -34,17 +68,17 @@ Singleton {
|
||||
_persistIdentity(playing.identity);
|
||||
return;
|
||||
}
|
||||
if (activePlayer && availablePlayers.indexOf(activePlayer) >= 0)
|
||||
if (activePlayer && availablePlayers.indexOf(activePlayer) >= 0 && !isIdle(activePlayer))
|
||||
return;
|
||||
const savedId = SessionData.lastPlayerIdentity;
|
||||
if (savedId) {
|
||||
const match = availablePlayers.find(p => p.identity === savedId);
|
||||
if (match) {
|
||||
if (match && !isIdle(match)) {
|
||||
activePlayer = match;
|
||||
return;
|
||||
}
|
||||
}
|
||||
activePlayer = availablePlayers.find(p => p.canControl && p.canPlay) ?? null;
|
||||
activePlayer = availablePlayers.find(p => p.canControl && !isIdle(p)) ?? null;
|
||||
if (activePlayer)
|
||||
_persistIdentity(activePlayer.identity);
|
||||
}
|
||||
@@ -81,7 +115,7 @@ Singleton {
|
||||
if (!activePlayer)
|
||||
return;
|
||||
if (activePlayer.position > 8 && activePlayer.canSeek)
|
||||
activePlayer.position = 0;
|
||||
activePlayer.position = 0.1;
|
||||
else if (activePlayer.canGoPrevious)
|
||||
activePlayer.previous();
|
||||
}
|
||||
|
||||
@@ -50,6 +50,8 @@ Singleton {
|
||||
property var bluetoothPairingModal: null
|
||||
property var networkInfoModal: null
|
||||
property var windowRuleModalLoader: null
|
||||
property var powerProfileModal: null
|
||||
property var powerProfileModalLoader: null
|
||||
|
||||
property var notepadSlideouts: []
|
||||
|
||||
@@ -675,6 +677,40 @@ Singleton {
|
||||
}
|
||||
}
|
||||
|
||||
function openPowerProfileModal() {
|
||||
if (powerProfileModal) {
|
||||
powerProfileModal.openCentered();
|
||||
} else if (powerProfileModalLoader) {
|
||||
powerProfileModalLoader.active = true;
|
||||
Qt.callLater(() => powerProfileModal?.openCentered());
|
||||
}
|
||||
}
|
||||
|
||||
function closePowerProfileModal() {
|
||||
powerProfileModal?.close();
|
||||
}
|
||||
|
||||
function togglePowerProfileModal() {
|
||||
if (powerProfileModal) {
|
||||
if (powerProfileModal.shouldBeVisible) {
|
||||
powerProfileModal.close();
|
||||
} else {
|
||||
powerProfileModal.openCentered();
|
||||
}
|
||||
} else if (powerProfileModalLoader) {
|
||||
powerProfileModalLoader.active = true;
|
||||
Qt.callLater(() => {
|
||||
if (powerProfileModal) {
|
||||
if (powerProfileModal.shouldBeVisible) {
|
||||
powerProfileModal.close();
|
||||
} else {
|
||||
powerProfileModal.openCentered();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function showProcessListModal() {
|
||||
if (processListModal) {
|
||||
processListModal.show();
|
||||
|
||||
@@ -239,11 +239,23 @@ Singleton {
|
||||
});
|
||||
}
|
||||
|
||||
property string pendingGreeterProfileUser: ""
|
||||
|
||||
function getGreeterUserProfileImage(username) {
|
||||
if (!username) {
|
||||
profileImage = "";
|
||||
pendingGreeterProfileUser = "";
|
||||
return;
|
||||
}
|
||||
if (typeof GreeterUsersService !== "undefined") {
|
||||
const cachedPath = GreeterUsersService.profileImagePath(username);
|
||||
if (cachedPath) {
|
||||
profileImage = cachedPath;
|
||||
pendingGreeterProfileUser = "";
|
||||
return;
|
||||
}
|
||||
}
|
||||
pendingGreeterProfileUser = username;
|
||||
userProfileCheckProcess.command = ["bash", "-c", `uid=$(id -u ${username} 2>/dev/null) && [ -n "$uid" ] && dbus-send --system --print-reply --dest=org.freedesktop.Accounts /org/freedesktop/Accounts/User$uid org.freedesktop.DBus.Properties.Get string:org.freedesktop.Accounts.User string:IconFile 2>/dev/null | grep -oP 'string "\\K[^"]+' || echo ""`];
|
||||
userProfileCheckProcess.running = true;
|
||||
}
|
||||
@@ -261,12 +273,14 @@ Singleton {
|
||||
} else {
|
||||
root.profileImage = "";
|
||||
}
|
||||
root.pendingGreeterProfileUser = "";
|
||||
}
|
||||
}
|
||||
|
||||
onExited: exitCode => {
|
||||
if (exitCode !== 0) {
|
||||
if (exitCode !== 0 && root.pendingGreeterProfileUser !== "") {
|
||||
root.profileImage = "";
|
||||
root.pendingGreeterProfileUser = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,68 @@ Singleton {
|
||||
property int currentProfile: -1
|
||||
property int previousProfile: -1
|
||||
|
||||
readonly property bool available: typeof PowerProfiles !== "undefined"
|
||||
|
||||
readonly property var availableProfiles: {
|
||||
if (!available)
|
||||
return [PowerProfile.PowerSaver, PowerProfile.Balanced, PowerProfile.Performance];
|
||||
|
||||
return [PowerProfile.PowerSaver, PowerProfile.Balanced].concat(PowerProfiles.hasPerformanceProfile ? [PowerProfile.Performance] : []);
|
||||
}
|
||||
|
||||
signal profileChanged(int profile)
|
||||
|
||||
function profileSlug(profile: int): string {
|
||||
switch (profile) {
|
||||
case PowerProfile.PowerSaver:
|
||||
return "power-saver";
|
||||
case PowerProfile.Balanced:
|
||||
return "balanced";
|
||||
case PowerProfile.Performance:
|
||||
return "performance";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
function parseProfileSlug(slug: string): int {
|
||||
if (!slug)
|
||||
return -1;
|
||||
|
||||
const lower = slug.toLowerCase().trim();
|
||||
if (lower === "power-saver" || lower === "powersaver" || lower === "saver" || lower === "0")
|
||||
return PowerProfile.PowerSaver;
|
||||
if (lower === "balanced" || lower === "1")
|
||||
return PowerProfile.Balanced;
|
||||
if (lower === "performance" || lower === "2")
|
||||
return PowerProfile.Performance;
|
||||
return -1;
|
||||
}
|
||||
|
||||
function applyProfile(profile: int): bool {
|
||||
if (!available)
|
||||
return false;
|
||||
|
||||
if (profile === PowerProfile.Performance && !PowerProfiles.hasPerformanceProfile)
|
||||
return false;
|
||||
|
||||
if (availableProfiles.indexOf(profile) === -1)
|
||||
return false;
|
||||
|
||||
PowerProfiles.profile = profile;
|
||||
return PowerProfiles.profile === profile;
|
||||
}
|
||||
|
||||
function cycleProfile(): bool {
|
||||
if (!available)
|
||||
return false;
|
||||
|
||||
const profiles = availableProfiles;
|
||||
const index = profiles.indexOf(PowerProfiles.profile);
|
||||
const nextProfile = index === -1 ? PowerProfile.Balanced : profiles[(index + 1) % profiles.length];
|
||||
return applyProfile(nextProfile);
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: typeof PowerProfiles !== "undefined" ? PowerProfiles : null
|
||||
|
||||
|
||||
@@ -20,6 +20,18 @@ Singleton {
|
||||
property bool indexLoaded: false
|
||||
property var _translatedCache: []
|
||||
|
||||
Connections {
|
||||
target: I18n
|
||||
|
||||
function onTranslationsChanged() {
|
||||
root._refreshTranslatedCache();
|
||||
}
|
||||
|
||||
function onTranslationsLoadedChanged() {
|
||||
root._refreshTranslatedCache();
|
||||
}
|
||||
}
|
||||
|
||||
readonly property var conditionMap: ({
|
||||
"isNiri": () => CompositorService.isNiri,
|
||||
"isHyprland": () => CompositorService.isHyprland,
|
||||
@@ -143,6 +155,7 @@ Singleton {
|
||||
for (var i = 0; i < settingsIndex.length; i++) {
|
||||
var item = settingsIndex[i];
|
||||
var t = translateItem(item);
|
||||
var sourceDescription = item.description || "";
|
||||
cache.push({
|
||||
section: t.section,
|
||||
label: t.label,
|
||||
@@ -152,13 +165,58 @@ Singleton {
|
||||
icon: t.icon,
|
||||
description: t.description,
|
||||
conditionKey: t.conditionKey,
|
||||
labelLower: t.label.toLowerCase(),
|
||||
categoryLower: t.category.toLowerCase()
|
||||
labelSearch: _lowerVariants([item.label, t.label]),
|
||||
categorySearch: _lowerVariants([item.category, t.category]),
|
||||
descriptionSearch: _lowerVariants([sourceDescription, t.description])
|
||||
});
|
||||
}
|
||||
_translatedCache = cache;
|
||||
}
|
||||
|
||||
function _lowerVariants(values) {
|
||||
var out = [];
|
||||
for (var i = 0; i < values.length; i++) {
|
||||
var value = values[i];
|
||||
if (!value)
|
||||
continue;
|
||||
var lower = String(value).toLowerCase();
|
||||
if (out.indexOf(lower) === -1)
|
||||
out.push(lower);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function _bestFieldScore(fields, queryLower, exactScore, prefixScore, includesScore) {
|
||||
var score = 0;
|
||||
for (var i = 0; i < fields.length; i++) {
|
||||
var field = fields[i];
|
||||
if (field === queryLower) {
|
||||
score = Math.max(score, exactScore);
|
||||
} else if (field.startsWith(queryLower)) {
|
||||
score = Math.max(score, prefixScore);
|
||||
} else if (field.includes(queryLower)) {
|
||||
score = Math.max(score, includesScore);
|
||||
}
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
function _fieldsContainWord(fields, word) {
|
||||
for (var i = 0; i < fields.length; i++) {
|
||||
if (fields[i].includes(word))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function _refreshTranslatedCache() {
|
||||
if (!indexLoaded)
|
||||
return;
|
||||
_rebuildTranslationCache();
|
||||
if (query)
|
||||
results = _searchEntries(query, 15);
|
||||
}
|
||||
|
||||
function _searchEntries(text, maxResults) {
|
||||
if (!text)
|
||||
return [];
|
||||
@@ -174,19 +232,11 @@ Singleton {
|
||||
if (!checkCondition(entry))
|
||||
continue;
|
||||
|
||||
var labelLower = entry.labelLower;
|
||||
var categoryLower = entry.categoryLower;
|
||||
var score = 0;
|
||||
|
||||
if (labelLower === queryLower) {
|
||||
score = 10000;
|
||||
} else if (labelLower.startsWith(queryLower)) {
|
||||
score = 5000;
|
||||
} else if (labelLower.includes(queryLower)) {
|
||||
score = 1000;
|
||||
} else if (categoryLower.includes(queryLower)) {
|
||||
score = 500;
|
||||
}
|
||||
score = Math.max(score, _bestFieldScore(entry.labelSearch, queryLower, 10000, 5000, 1000));
|
||||
score = Math.max(score, _bestFieldScore(entry.categorySearch, queryLower, 500, 500, 500));
|
||||
score = Math.max(score, _bestFieldScore(entry.descriptionSearch, queryLower, 250, 250, 250));
|
||||
|
||||
if (score === 0) {
|
||||
var keywords = entry.keywords;
|
||||
@@ -205,7 +255,11 @@ Singleton {
|
||||
var allMatch = true;
|
||||
for (var w = 0; w < queryWords.length; w++) {
|
||||
var word = queryWords[w];
|
||||
if (labelLower.includes(word))
|
||||
if (_fieldsContainWord(entry.labelSearch, word))
|
||||
continue;
|
||||
if (_fieldsContainWord(entry.descriptionSearch, word))
|
||||
continue;
|
||||
if (_fieldsContainWord(entry.categorySearch, word))
|
||||
continue;
|
||||
var inKeywords = false;
|
||||
for (var k = 0; k < entry.keywords.length; k++) {
|
||||
@@ -214,7 +268,7 @@ Singleton {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!inKeywords && !categoryLower.includes(word)) {
|
||||
if (!inKeywords) {
|
||||
allMatch = false;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -10,12 +10,53 @@ Singleton {
|
||||
id: root
|
||||
|
||||
property string _lastArtUrl: ""
|
||||
property string _bgArtSource: ""
|
||||
property string resolvedArtUrl: ""
|
||||
property alias _bgArtSource: root.resolvedArtUrl
|
||||
property bool loading: false
|
||||
|
||||
function djb2Hash(str) {
|
||||
if (!str) return "";
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = ((hash << 5) + hash) + str.charCodeAt(i);
|
||||
hash = hash & 0x7FFFFFFF;
|
||||
}
|
||||
return hash.toString(16).padStart(8, '0');
|
||||
}
|
||||
|
||||
function getArtworkUrl(player) {
|
||||
if (!player) return "";
|
||||
|
||||
// 1. If native trackArtUrl is present and valid
|
||||
let artUrl = player.trackArtUrl || "";
|
||||
if (artUrl !== "") {
|
||||
return artUrl;
|
||||
}
|
||||
|
||||
// 2. Fallback to raw metadata mpris:artUrl if present
|
||||
if (player.metadata && player.metadata["mpris:artUrl"]) {
|
||||
artUrl = player.metadata["mpris:artUrl"].toString();
|
||||
if (artUrl !== "") return artUrl;
|
||||
}
|
||||
|
||||
// 3. Fallback for YouTube from xesam:url
|
||||
if (player.metadata && player.metadata["xesam:url"]) {
|
||||
const url = player.metadata["xesam:url"].toString();
|
||||
if (url.includes("youtube.com") || url.includes("youtu.be")) {
|
||||
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/;
|
||||
const match = url.match(regExp);
|
||||
if (match && match[2].length === 11) {
|
||||
return "https://img.youtube.com/vi/" + match[2] + "/hqdefault.jpg";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function loadArtwork(url) {
|
||||
if (!url || url === "") {
|
||||
_bgArtSource = "";
|
||||
resolvedArtUrl = "";
|
||||
_lastArtUrl = "";
|
||||
loading = false;
|
||||
return;
|
||||
@@ -25,25 +66,99 @@ Singleton {
|
||||
_lastArtUrl = url;
|
||||
|
||||
if (url.startsWith("http://") || url.startsWith("https://")) {
|
||||
_bgArtSource = url;
|
||||
loading = false;
|
||||
loading = true;
|
||||
resolvedArtUrl = ""; // Clear stale artwork immediately while loading
|
||||
const targetUrl = url;
|
||||
const hash = djb2Hash(url);
|
||||
const cacheDir = Paths.strip(Paths.imagecache);
|
||||
const filePath = cacheDir + "/remote_" + hash;
|
||||
const localFileUrl = "file://" + filePath;
|
||||
|
||||
// 1. First, check if the file already exists locally
|
||||
Proc.runCommand(null, ["test", "-f", filePath], (output, exitCode) => {
|
||||
if (_lastArtUrl !== targetUrl)
|
||||
return;
|
||||
|
||||
if (exitCode === 0) {
|
||||
resolvedArtUrl = localFileUrl;
|
||||
loading = false;
|
||||
} else {
|
||||
const dlCmd = "mkdir -p \"$(dirname \"$1\")\" && curl -f -s -L -o \"$1\" \"$2\" && mv \"$1\" \"$3\" || { rm -f \"$1\"; exit 1; }";
|
||||
|
||||
// 2. Check if this is a YouTube URL to do high quality 16:9 fallback
|
||||
if (targetUrl.includes("img.youtube.com/vi/")) {
|
||||
const videoId = targetUrl.split("/vi/")[1].split("/")[0];
|
||||
const maxresUrl = "https://img.youtube.com/vi/" + videoId + "/maxresdefault.jpg";
|
||||
const mqUrl = "https://img.youtube.com/vi/" + videoId + "/mqdefault.jpg";
|
||||
const tmpPath = filePath + ".tmp";
|
||||
|
||||
Proc.runCommand(null, ["sh", "-c", dlCmd, "sh", tmpPath, maxresUrl, filePath], (maxOutput, maxExitCode) => {
|
||||
if (_lastArtUrl !== targetUrl)
|
||||
return;
|
||||
|
||||
if (maxExitCode === 0) {
|
||||
resolvedArtUrl = localFileUrl;
|
||||
loading = false;
|
||||
} else {
|
||||
Proc.runCommand(null, ["sh", "-c", dlCmd, "sh", tmpPath, mqUrl, filePath], (mqOutput, mqExitCode) => {
|
||||
if (_lastArtUrl !== targetUrl)
|
||||
return;
|
||||
|
||||
if (mqExitCode === 0) {
|
||||
resolvedArtUrl = localFileUrl;
|
||||
} else {
|
||||
resolvedArtUrl = targetUrl; // Ultimate fallback
|
||||
}
|
||||
loading = false;
|
||||
}, 50, 15000);
|
||||
}
|
||||
}, 50, 15000);
|
||||
} else {
|
||||
// Standard curl download for other remote URLs (e.g. SoundCloud)
|
||||
const tmpPath = filePath + ".tmp";
|
||||
Proc.runCommand(null, ["sh", "-c", dlCmd, "sh", tmpPath, targetUrl, filePath], (dlOutput, dlExitCode) => {
|
||||
if (_lastArtUrl !== targetUrl)
|
||||
return;
|
||||
|
||||
if (dlExitCode === 0) {
|
||||
resolvedArtUrl = localFileUrl;
|
||||
} else {
|
||||
resolvedArtUrl = targetUrl; // Fallback to raw URL
|
||||
}
|
||||
loading = false;
|
||||
}, 50, 15000);
|
||||
}
|
||||
}
|
||||
}, 50, 5000);
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
resolvedArtUrl = ""; // Clear stale artwork immediately while verifying local file
|
||||
const localUrl = url;
|
||||
const filePath = url.startsWith("file://") ? url.substring(7) : url;
|
||||
Proc.runCommand("trackart", ["test", "-f", filePath], (output, exitCode) => {
|
||||
Proc.runCommand(null, ["test", "-f", filePath], (output, exitCode) => {
|
||||
if (_lastArtUrl !== localUrl)
|
||||
return;
|
||||
_bgArtSource = exitCode === 0 ? localUrl : "";
|
||||
resolvedArtUrl = exitCode === 0 ? localUrl : "";
|
||||
loading = false;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
property MprisPlayer activePlayer: MprisController.activePlayer
|
||||
|
||||
onActivePlayerChanged: {
|
||||
loadArtwork(activePlayer?.trackArtUrl ?? "");
|
||||
onActivePlayerChanged: _updateArtUrl()
|
||||
|
||||
Connections {
|
||||
target: root.activePlayer
|
||||
ignoreUnknownSignals: true
|
||||
function onTrackTitleChanged() { root._updateArtUrl(); }
|
||||
function onTrackArtUrlChanged() { root._updateArtUrl(); }
|
||||
function onMetadataChanged() { root._updateArtUrl(); }
|
||||
}
|
||||
|
||||
function _updateArtUrl() {
|
||||
const url = getArtworkUrl(activePlayer);
|
||||
loadArtwork(url);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,9 @@ Singleton {
|
||||
|
||||
property var users: []
|
||||
property string adminGroup: "wheel"
|
||||
property string greeterGroup: "greeter"
|
||||
property var adminMembers: []
|
||||
property var greeterMembers: []
|
||||
property bool refreshing: false
|
||||
|
||||
signal operationCompleted(string op, string username, bool success, string message)
|
||||
@@ -69,17 +71,35 @@ Singleton {
|
||||
Proc.runCommand("usersService-adminMembers", ["sh", "-c", "getent group " + root.adminGroup + " | awk -F: '{print $4}'"], (output, exitCode) => {
|
||||
const members = (output || "").trim().split(",").map(s => s.trim()).filter(s => s.length > 0);
|
||||
root.adminMembers = members;
|
||||
_detectGreeterGroup();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function _detectGreeterGroup() {
|
||||
Proc.runCommand("usersService-detectGreeterGroup", ["sh", "-c", "getent group greeter >/dev/null 2>&1 && echo greeter || (getent group greetd >/dev/null 2>&1 && echo greetd || (getent group _greeter >/dev/null 2>&1 && echo _greeter || echo greeter))"], (output, exitCode) => {
|
||||
root.greeterGroup = (output || "").trim() || "greeter";
|
||||
_loadGreeterMembers();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function _loadGreeterMembers() {
|
||||
Proc.runCommand("usersService-greeterMembers", ["sh", "-c", "getent group " + root.greeterGroup + " 2>/dev/null | awk -F: '{print $4}'"], (output, exitCode) => {
|
||||
const members = (output || "").trim().split(",").map(s => s.trim()).filter(s => s.length > 0);
|
||||
root.greeterMembers = members;
|
||||
_loadUsers();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function _loadUsers() {
|
||||
Proc.runCommand("usersService-loadUsers", ["sh", "-c", "getent passwd | awk -F: '$3>=1000 && $3<60000 && $1!=\"nobody\" {print $1\":\"$3\":\"$5\":\"$6\":\"$7}'"], (output, exitCode) => {
|
||||
Proc.runCommand("usersService-loadUsers", ["sh", "-c", "getent passwd | awk -F: '$3>=1000 && $3<60000 && $1!=\"nobody\" && $7!~/(nologin|false)$/ && $6!=\"/var/empty\" {print $1\":\"$3\":\"$5\":\"$6\":\"$7}'"], (output, exitCode) => {
|
||||
const lines = (output || "").trim().split("\n").filter(l => l.length > 0);
|
||||
const list = [];
|
||||
const adminSet = {};
|
||||
const greeterSet = {};
|
||||
for (let i = 0; i < root.adminMembers.length; i++)
|
||||
adminSet[root.adminMembers[i]] = true;
|
||||
for (let i = 0; i < root.greeterMembers.length; i++)
|
||||
greeterSet[root.greeterMembers[i]] = true;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const parts = lines[i].split(":");
|
||||
@@ -92,7 +112,8 @@ Singleton {
|
||||
gecos: (parts[2] || "").split(",")[0],
|
||||
home: parts[3] || "",
|
||||
shell: parts[4] || "",
|
||||
isAdmin: adminSet[username] === true
|
||||
isAdmin: adminSet[username] === true,
|
||||
isGreeter: greeterSet[username] === true
|
||||
});
|
||||
}
|
||||
list.sort((a, b) => a.username.localeCompare(b.username));
|
||||
@@ -101,7 +122,7 @@ Singleton {
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function createUser(username, password, addToAdmin, callback) {
|
||||
function createUser(username, password, addToAdmin, addToGreeter, callback) {
|
||||
if (!isValidUsername(username)) {
|
||||
_emit("create", username, false, I18n.tr("Invalid username"), callback);
|
||||
return;
|
||||
@@ -114,7 +135,7 @@ Singleton {
|
||||
_emit("create", username, false, I18n.tr("User already exists"), callback);
|
||||
return;
|
||||
}
|
||||
_runUseradd(username, password, addToAdmin === true, callback);
|
||||
_runUseradd(username, password, addToAdmin === true, addToGreeter === true, callback);
|
||||
}
|
||||
|
||||
function setPassword(username, newPassword, callback) {
|
||||
@@ -156,6 +177,55 @@ Singleton {
|
||||
_runAdminToggle(username, makeAdmin === true, callback);
|
||||
}
|
||||
|
||||
function setGreeterAccess(username, enable, callback) {
|
||||
if (!userExists(username)) {
|
||||
_emit("greeter", username, false, I18n.tr("User not found"), callback);
|
||||
return;
|
||||
}
|
||||
_runGreeterToggle(username, enable === true, callback);
|
||||
}
|
||||
|
||||
function _finishCreateUser(targetUser, addAdmin, addGreeter, outerCb) {
|
||||
function finish(success, message) {
|
||||
root._emit("create", targetUser, success, message, outerCb);
|
||||
}
|
||||
|
||||
function maybeGreeter(onDone) {
|
||||
if (addGreeter) {
|
||||
root._runGreeterToggle(targetUser, true, (greeterOk, greeterMsg) => {
|
||||
if (greeterOk)
|
||||
onDone();
|
||||
else
|
||||
finish(false, greeterMsg);
|
||||
});
|
||||
} else {
|
||||
onDone();
|
||||
}
|
||||
}
|
||||
|
||||
function createMessage() {
|
||||
if (addAdmin && addGreeter)
|
||||
return I18n.tr("User created with administrator and greeter login access");
|
||||
if (addAdmin)
|
||||
return I18n.tr("User created with administrator privileges");
|
||||
if (addGreeter)
|
||||
return I18n.tr("User created with greeter login access");
|
||||
return I18n.tr("User created");
|
||||
}
|
||||
|
||||
if (addAdmin) {
|
||||
root._runAdminToggle(targetUser, true, (adminOk, adminMsg) => {
|
||||
if (!adminOk) {
|
||||
finish(false, adminMsg);
|
||||
return;
|
||||
}
|
||||
maybeGreeter(() => finish(true, createMessage()));
|
||||
});
|
||||
} else {
|
||||
maybeGreeter(() => finish(true, createMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
function _emit(op, username, success, message, callback) {
|
||||
root.operationCompleted(op, username, success, message);
|
||||
if (typeof callback === "function") {
|
||||
@@ -174,6 +244,7 @@ Singleton {
|
||||
property string targetUser: ""
|
||||
property string targetPassword: ""
|
||||
property bool addAdmin: false
|
||||
property bool addGreeter: false
|
||||
property var cb: null
|
||||
property string capturedErr: ""
|
||||
running: false
|
||||
@@ -191,6 +262,7 @@ Singleton {
|
||||
const targetUser = useraddProc.targetUser;
|
||||
const targetPassword = useraddProc.targetPassword;
|
||||
const addAdmin = useraddProc.addAdmin;
|
||||
const addGreeter = useraddProc.addGreeter;
|
||||
const outerCb = useraddProc.cb;
|
||||
Qt.callLater(() => useraddProc.destroy());
|
||||
|
||||
@@ -199,17 +271,7 @@ Singleton {
|
||||
svc._emit("create", targetUser, false, pwMsg, outerCb);
|
||||
return;
|
||||
}
|
||||
if (addAdmin) {
|
||||
svc._runAdminToggle(targetUser, true, (adminOk, adminMsg) => {
|
||||
if (adminOk) {
|
||||
svc._emit("create", targetUser, true, I18n.tr("User created with administrator privileges"), outerCb);
|
||||
} else {
|
||||
svc._emit("create", targetUser, false, adminMsg, outerCb);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
svc._emit("create", targetUser, true, I18n.tr("User created"), outerCb);
|
||||
}
|
||||
svc._finishCreateUser(targetUser, addAdmin, addGreeter, outerCb);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -290,6 +352,36 @@ Singleton {
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: greeterToggleComp
|
||||
Process {
|
||||
id: greeterToggleProc
|
||||
property string targetUser: ""
|
||||
property bool enableGreeter: false
|
||||
property var cb: null
|
||||
property string capturedErr: ""
|
||||
running: false
|
||||
stdout: StdioCollector {}
|
||||
stderr: StdioCollector {
|
||||
onStreamFinished: greeterToggleProc.capturedErr = text || ""
|
||||
}
|
||||
onExited: exitCode => {
|
||||
const targetUser = greeterToggleProc.targetUser;
|
||||
const enableGreeter = greeterToggleProc.enableGreeter;
|
||||
const cb = greeterToggleProc.cb;
|
||||
const err = (greeterToggleProc.capturedErr || "").trim();
|
||||
Qt.callLater(() => greeterToggleProc.destroy());
|
||||
|
||||
if (exitCode !== 0) {
|
||||
root._emit("greeter", targetUser, false, err || I18n.tr("usermod failed (exit %1)").arg(exitCode), cb);
|
||||
} else {
|
||||
root.refresh();
|
||||
root._emit("greeter", targetUser, true, enableGreeter ? I18n.tr("Granted greeter login access") : I18n.tr("Removed greeter login access"), cb);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: adminToggleComp
|
||||
Process {
|
||||
@@ -320,12 +412,13 @@ Singleton {
|
||||
}
|
||||
}
|
||||
|
||||
function _runUseradd(username, password, addToAdmin, callback) {
|
||||
function _runUseradd(username, password, addToAdmin, addToGreeter, callback) {
|
||||
const proc = useraddComp.createObject(root, {
|
||||
command: ["pkexec", "useradd", "-m", "-s", "/bin/bash", username],
|
||||
targetUser: username,
|
||||
targetPassword: password,
|
||||
addAdmin: addToAdmin,
|
||||
addGreeter: addToGreeter,
|
||||
cb: callback
|
||||
});
|
||||
proc.running = true;
|
||||
@@ -361,5 +454,16 @@ Singleton {
|
||||
proc.running = true;
|
||||
}
|
||||
|
||||
function _runGreeterToggle(username, enableGreeter, callback) {
|
||||
const cmd = enableGreeter ? ["pkexec", "usermod", "-aG", root.greeterGroup, username] : ["pkexec", "gpasswd", "-d", username, root.greeterGroup];
|
||||
const proc = greeterToggleComp.createObject(root, {
|
||||
command: cmd,
|
||||
targetUser: username,
|
||||
enableGreeter: enableGreeter,
|
||||
cb: callback
|
||||
});
|
||||
proc.running = true;
|
||||
}
|
||||
|
||||
Component.onCompleted: refresh()
|
||||
}
|
||||
|
||||
@@ -8,15 +8,19 @@ Item {
|
||||
id: root
|
||||
|
||||
property MprisPlayer activePlayer
|
||||
property string artUrl: (activePlayer?.trackArtUrl) || ""
|
||||
property string artUrl: TrackArtService.resolvedArtUrl
|
||||
property string lastValidArtUrl: ""
|
||||
property alias albumArtStatus: albumArt.imageStatus
|
||||
property real albumSize: Math.min(width, height) * 0.88
|
||||
property bool showAnimation: true
|
||||
property real animationScale: 1.0
|
||||
|
||||
onActivePlayerChanged: {
|
||||
lastValidArtUrl = "";
|
||||
}
|
||||
|
||||
onArtUrlChanged: {
|
||||
if (artUrl && albumArt.status !== Image.Error) {
|
||||
if (artUrl && albumArtStatus !== Image.Error) {
|
||||
lastValidArtUrl = artUrl;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,30 @@ Item {
|
||||
dropdownMenu.close();
|
||||
}
|
||||
|
||||
function openDropdownMenu() {
|
||||
if (dropdownMenu.visible) {
|
||||
dropdownMenu.close();
|
||||
return;
|
||||
}
|
||||
if (root.options.length === 0)
|
||||
return;
|
||||
|
||||
dropdownMenu.open();
|
||||
|
||||
let currentIndex = root.options.indexOf(root.currentValue);
|
||||
listView.positionViewAtIndex(currentIndex >= 0 ? currentIndex : 0, ListView.Beginning);
|
||||
|
||||
const pos = dropdown.mapToItem(Overlay.overlay, 0, 0);
|
||||
const popupW = dropdownMenu.width;
|
||||
const popupH = dropdownMenu.height;
|
||||
const overlayH = Overlay.overlay.height;
|
||||
const goUp = root.openUpwards || pos.y + dropdown.height + popupH + 4 > overlayH;
|
||||
dropdownMenu.x = root.alignPopupRight ? pos.x + dropdown.width - popupW : pos.x - (root.popupWidthOffset / 2);
|
||||
dropdownMenu.y = goUp ? pos.y - popupH - 4 : pos.y + dropdown.height + 4;
|
||||
if (root.enableFuzzySearch)
|
||||
searchField.forceActiveFocus();
|
||||
}
|
||||
|
||||
function resetSearch() {
|
||||
searchField.text = "";
|
||||
dropdownMenu.fzfFinder = null;
|
||||
@@ -123,27 +147,7 @@ Item {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
if (dropdownMenu.visible) {
|
||||
dropdownMenu.close();
|
||||
return;
|
||||
}
|
||||
|
||||
dropdownMenu.open();
|
||||
|
||||
let currentIndex = root.options.indexOf(root.currentValue);
|
||||
listView.positionViewAtIndex(currentIndex, ListView.Beginning);
|
||||
|
||||
const pos = dropdown.mapToItem(Overlay.overlay, 0, 0);
|
||||
const popupW = dropdownMenu.width;
|
||||
const popupH = dropdownMenu.height;
|
||||
const overlayH = Overlay.overlay.height;
|
||||
const goUp = root.openUpwards || pos.y + dropdown.height + popupH + 4 > overlayH;
|
||||
dropdownMenu.x = root.alignPopupRight ? pos.x + dropdown.width - popupW : pos.x - (root.popupWidthOffset / 2);
|
||||
dropdownMenu.y = goUp ? pos.y - popupH - 4 : pos.y + dropdown.height + 4;
|
||||
if (root.enableFuzzySearch)
|
||||
searchField.forceActiveFocus();
|
||||
}
|
||||
onClicked: root.openDropdownMenu()
|
||||
}
|
||||
|
||||
Row {
|
||||
@@ -165,10 +169,10 @@ Item {
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: root.currentValue
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: Theme.surfaceText
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.currentValue !== "" ? root.currentValue : root.emptyText
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: root.currentValue !== "" ? Theme.surfaceText : Theme.outline
|
||||
width: contentRow.width - (contentRow.children[0].visible ? contentRow.children[0].width + contentRow.spacing : 0)
|
||||
elide: Text.ElideRight
|
||||
wrapMode: Text.NoWrap
|
||||
|
||||
@@ -576,9 +576,11 @@ Item {
|
||||
property real renderedAlignedY: alignedY
|
||||
property real renderedAlignedHeight: alignedHeight
|
||||
readonly property bool renderedGeometryGrowing: alignedHeight >= renderedAlignedHeight
|
||||
// Snap rendered geometry while the entrance morph runs so it doesn't ride a second animation (side-bar ramp).
|
||||
readonly property bool _settlingToOpen: fullHeightSurface && shouldBeVisible && morphAnim.running
|
||||
|
||||
Behavior on renderedAlignedY {
|
||||
enabled: root.animationsEnabled && contentWindow.visible && root.shouldBeVisible
|
||||
enabled: root.animationsEnabled && contentWindow.visible && root.shouldBeVisible && !root._settlingToOpen
|
||||
NumberAnimation {
|
||||
duration: Theme.variantDuration(root.animationDuration, root.renderedGeometryGrowing)
|
||||
easing.type: Easing.BezierSpline
|
||||
@@ -587,7 +589,7 @@ Item {
|
||||
}
|
||||
|
||||
Behavior on renderedAlignedHeight {
|
||||
enabled: root.animationsEnabled && contentWindow.visible && root.shouldBeVisible
|
||||
enabled: root.animationsEnabled && contentWindow.visible && root.shouldBeVisible && !root._settlingToOpen
|
||||
NumberAnimation {
|
||||
duration: Theme.variantDuration(root.animationDuration, root.renderedGeometryGrowing)
|
||||
easing.type: Easing.BezierSpline
|
||||
@@ -749,6 +751,8 @@ Item {
|
||||
WlrLayershell.layer: root.effectivePopoutLayer
|
||||
WlrLayershell.exclusiveZone: -1
|
||||
WlrLayershell.keyboardFocus: {
|
||||
if (PopoutManager.screenshotActive)
|
||||
return WlrKeyboardFocus.None;
|
||||
if (customKeyboardFocus !== null)
|
||||
return customKeyboardFocus;
|
||||
if (!shouldBeVisible)
|
||||
@@ -896,6 +900,7 @@ Item {
|
||||
Behavior on openProgress {
|
||||
enabled: root.animationsEnabled
|
||||
NumberAnimation {
|
||||
id: morphAnim
|
||||
duration: Theme.variantDuration(root.animationDuration, root.shouldBeVisible)
|
||||
easing.type: Easing.BezierSpline
|
||||
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
|
||||
|
||||
@@ -407,9 +407,11 @@ Item {
|
||||
property real renderedAlignedY: alignedY
|
||||
property real renderedAlignedHeight: alignedHeight
|
||||
readonly property bool renderedGeometryGrowing: alignedHeight >= renderedAlignedHeight
|
||||
// Snap rendered geometry while the entrance morph runs so it doesn't ride a second animation.
|
||||
readonly property bool _settlingToOpen: _fullHeight && shouldBeVisible && morphAnim.running
|
||||
|
||||
Behavior on renderedAlignedY {
|
||||
enabled: root.animationsEnabled && contentWindow.visible && root.shouldBeVisible
|
||||
enabled: root.animationsEnabled && contentWindow.visible && root.shouldBeVisible && !root._settlingToOpen
|
||||
NumberAnimation {
|
||||
duration: Theme.variantDuration(root.animationDuration, root.renderedGeometryGrowing)
|
||||
easing.type: Easing.BezierSpline
|
||||
@@ -418,7 +420,7 @@ Item {
|
||||
}
|
||||
|
||||
Behavior on renderedAlignedHeight {
|
||||
enabled: root.animationsEnabled && contentWindow.visible && root.shouldBeVisible
|
||||
enabled: root.animationsEnabled && contentWindow.visible && root.shouldBeVisible && !root._settlingToOpen
|
||||
NumberAnimation {
|
||||
duration: Theme.variantDuration(root.animationDuration, root.renderedGeometryGrowing)
|
||||
easing.type: Easing.BezierSpline
|
||||
@@ -620,6 +622,8 @@ Item {
|
||||
WlrLayershell.layer: root.effectivePopoutLayer
|
||||
WlrLayershell.exclusiveZone: -1
|
||||
WlrLayershell.keyboardFocus: {
|
||||
if (PopoutManager.screenshotActive)
|
||||
return WlrKeyboardFocus.None;
|
||||
if (customKeyboardFocus !== null)
|
||||
return customKeyboardFocus;
|
||||
if (!shouldBeVisible)
|
||||
@@ -729,6 +733,7 @@ Item {
|
||||
Behavior on openProgress {
|
||||
enabled: root.animationsEnabled
|
||||
NumberAnimation {
|
||||
id: morphAnim
|
||||
duration: Theme.variantDuration(root.animationDuration, root.shouldBeVisible)
|
||||
easing.type: Easing.BezierSpline
|
||||
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
|
||||
|
||||
@@ -8,12 +8,14 @@ Item {
|
||||
id: root
|
||||
|
||||
property MprisPlayer activePlayer
|
||||
readonly property real stableLength: MprisController.activePlayerStableLength
|
||||
|
||||
property real seekPreviewRatio: -1
|
||||
readonly property real playerValue: {
|
||||
if (!activePlayer || activePlayer.length <= 0)
|
||||
if (!activePlayer || stableLength <= 0)
|
||||
return 0;
|
||||
const pos = (activePlayer.position || 0) % Math.max(1, activePlayer.length);
|
||||
const calculatedRatio = pos / activePlayer.length;
|
||||
const pos = (activePlayer.position || 0) % Math.max(1, stableLength);
|
||||
const calculatedRatio = pos / stableLength;
|
||||
return Math.max(0, Math.min(1, calculatedRatio));
|
||||
}
|
||||
property real value: seekPreviewRatio >= 0 ? seekPreviewRatio : playerValue
|
||||
@@ -29,20 +31,20 @@ Item {
|
||||
}
|
||||
|
||||
function ratioForPosition(position) {
|
||||
if (!activePlayer || activePlayer.length <= 0)
|
||||
if (!activePlayer || stableLength <= 0)
|
||||
return 0;
|
||||
return clampRatio(position / activePlayer.length);
|
||||
return clampRatio(position / stableLength);
|
||||
}
|
||||
|
||||
function positionForRatio(ratio) {
|
||||
if (!activePlayer || activePlayer.length <= 0)
|
||||
if (!activePlayer || stableLength <= 0)
|
||||
return 0;
|
||||
const rawPosition = clampRatio(ratio) * activePlayer.length;
|
||||
return Math.min(rawPosition, activePlayer.length * 0.99);
|
||||
const rawPosition = clampRatio(ratio) * stableLength;
|
||||
return Math.min(rawPosition, stableLength * 0.99);
|
||||
}
|
||||
|
||||
function updatePreviewFromMouse(mouseX, width) {
|
||||
if (!activePlayer || activePlayer.length <= 0 || width <= 0)
|
||||
if (!activePlayer || stableLength <= 0 || width <= 0)
|
||||
return;
|
||||
seekPreviewRatio = clampRatio(mouseX / width);
|
||||
}
|
||||
@@ -68,7 +70,7 @@ Item {
|
||||
mouseArea.pressX = mouse.x;
|
||||
clearCommittedSeekPreview();
|
||||
holdTimer.restart();
|
||||
if (activePlayer && activePlayer.length > 0 && activePlayer.canSeek) {
|
||||
if (activePlayer && stableLength > 0 && activePlayer.canSeek) {
|
||||
updatePreviewFromMouse(mouse.x, width);
|
||||
mouseArea.pendingSeekPosition = positionForRatio(seekPreviewRatio);
|
||||
}
|
||||
@@ -78,9 +80,9 @@ Item {
|
||||
holdTimer.stop();
|
||||
isSeeking = false;
|
||||
isDraggingSeek = false;
|
||||
if (mouseArea.pendingSeekPosition >= 0 && activePlayer && activePlayer.canSeek && activePlayer.length > 0) {
|
||||
const clamped = Math.min(mouseArea.pendingSeekPosition, activePlayer.length * 0.99);
|
||||
activePlayer.position = clamped;
|
||||
if (mouseArea.pendingSeekPosition >= 0 && activePlayer && activePlayer.canSeek && stableLength > 0) {
|
||||
const clamped = Math.min(mouseArea.pendingSeekPosition, stableLength * 0.99);
|
||||
activePlayer.position = Math.max(0.1, clamped);
|
||||
mouseArea.pendingSeekPosition = -1;
|
||||
beginCommittedSeekPreview(clamped);
|
||||
} else {
|
||||
@@ -89,7 +91,7 @@ Item {
|
||||
}
|
||||
|
||||
function handleSeekPositionChanged(mouse, width, mouseArea) {
|
||||
if (mouseArea.pressed && isSeeking && activePlayer && activePlayer.length > 0 && activePlayer.canSeek) {
|
||||
if (mouseArea.pressed && isSeeking && activePlayer && stableLength > 0 && activePlayer.canSeek) {
|
||||
if (!isDraggingSeek && Math.abs(mouse.x - mouseArea.pressX) >= dragThreshold)
|
||||
isDraggingSeek = true;
|
||||
updatePreviewFromMouse(mouse.x, width);
|
||||
@@ -129,7 +131,7 @@ Item {
|
||||
|
||||
Loader {
|
||||
anchors.fill: parent
|
||||
visible: activePlayer && activePlayer.length > 0
|
||||
visible: activePlayer && stableLength > 0
|
||||
sourceComponent: SettingsData.waveProgressEnabled ? waveProgressComponent : flatProgressComponent
|
||||
z: 1
|
||||
|
||||
@@ -148,7 +150,7 @@ Item {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
enabled: activePlayer && activePlayer.canSeek && activePlayer.length > 0
|
||||
enabled: activePlayer && activePlayer.canSeek && stableLength > 0
|
||||
|
||||
property real pendingSeekPosition: -1
|
||||
property real pressX: 0
|
||||
@@ -236,7 +238,7 @@ Item {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
enabled: activePlayer && activePlayer.canSeek && activePlayer.length > 0
|
||||
enabled: activePlayer && activePlayer.canSeek && stableLength > 0
|
||||
|
||||
property real pendingSeekPosition: -1
|
||||
property real pressX: 0
|
||||
|
||||
@@ -21,6 +21,7 @@ Item {
|
||||
property var panelWindow: null
|
||||
property bool recording: false
|
||||
property bool isNew: false
|
||||
property bool readOnly: false
|
||||
property string restoreKey: ""
|
||||
|
||||
property int editingKeyIndex: -1
|
||||
@@ -160,6 +161,10 @@ Item {
|
||||
}
|
||||
|
||||
function startAddingNewKey() {
|
||||
if (readOnly) {
|
||||
KeybindsService.showHyprlandReadOnlyWarning();
|
||||
return;
|
||||
}
|
||||
addingNewKey = true;
|
||||
editingKeyIndex = -1;
|
||||
editKey = "";
|
||||
@@ -181,6 +186,8 @@ Item {
|
||||
}
|
||||
|
||||
function updateEdit(changes) {
|
||||
if (readOnly)
|
||||
return;
|
||||
if (changes.key !== undefined)
|
||||
editKey = changes.key;
|
||||
if (changes.action !== undefined)
|
||||
@@ -208,6 +215,8 @@ Item {
|
||||
}
|
||||
|
||||
function canSave() {
|
||||
if (readOnly)
|
||||
return false;
|
||||
if (!editKey)
|
||||
return false;
|
||||
if (!Actions.isValidAction(editAction))
|
||||
@@ -216,6 +225,10 @@ Item {
|
||||
}
|
||||
|
||||
function doSave() {
|
||||
if (readOnly) {
|
||||
KeybindsService.showHyprlandReadOnlyWarning();
|
||||
return;
|
||||
}
|
||||
if (!canSave())
|
||||
return;
|
||||
const origKey = addingNewKey ? "" : _originalKey;
|
||||
@@ -247,6 +260,10 @@ Item {
|
||||
}
|
||||
|
||||
function startRecording() {
|
||||
if (readOnly) {
|
||||
KeybindsService.showHyprlandReadOnlyWarning();
|
||||
return;
|
||||
}
|
||||
recording = true;
|
||||
}
|
||||
|
||||
@@ -438,6 +455,7 @@ Item {
|
||||
anchors.top: parent.top
|
||||
anchors.margins: Theme.spacingL
|
||||
spacing: Theme.spacingM
|
||||
enabled: !root.readOnly
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
@@ -554,7 +572,7 @@ Item {
|
||||
height: root._chipHeight
|
||||
radius: root._chipHeight / 4
|
||||
color: root.addingNewKey ? Theme.primary : Theme.surfaceVariant
|
||||
visible: !root.isNew
|
||||
visible: !root.isNew && !root.readOnly
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
@@ -644,6 +662,7 @@ Item {
|
||||
iconName: root.recording ? "close" : "radio_button_checked"
|
||||
iconSize: Theme.iconSizeSmall
|
||||
iconColor: root.recording ? Theme.error : Theme.primary
|
||||
enabled: !root.readOnly
|
||||
onClicked: root.recording ? root.stopRecording() : root.startRecording()
|
||||
}
|
||||
}
|
||||
@@ -746,7 +765,7 @@ Item {
|
||||
Layout.preferredHeight: root._inputHeight
|
||||
radius: Theme.cornerRadius
|
||||
color: root.addingNewKey ? Theme.primary : Theme.surfaceVariant
|
||||
visible: root.keys.length === 1 && !root.isNew
|
||||
visible: root.keys.length === 1 && !root.isNew && !root.readOnly
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
@@ -861,6 +880,8 @@ Item {
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
if (root.readOnly)
|
||||
return;
|
||||
switch (typeDelegate.modelData.id) {
|
||||
case "dms":
|
||||
root.updateEdit({
|
||||
@@ -926,6 +947,8 @@ Item {
|
||||
enableFuzzySearch: true
|
||||
maxPopupHeight: 300
|
||||
onValueChanged: value => {
|
||||
if (root.readOnly)
|
||||
return;
|
||||
const actions = KeybindsService.getDmsActions();
|
||||
for (const act of actions) {
|
||||
if (act.label === value) {
|
||||
@@ -1176,8 +1199,12 @@ Item {
|
||||
id: customToggleArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.useCustomCompositor = true
|
||||
cursorShape: root.readOnly ? Qt.ArrowCursor : Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
if (root.readOnly)
|
||||
return;
|
||||
root.useCustomCompositor = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1418,8 +1445,10 @@ Item {
|
||||
id: presetToggleArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
cursorShape: root.readOnly ? Qt.ArrowCursor : Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
if (root.readOnly)
|
||||
return;
|
||||
root.useCustomCompositor = false;
|
||||
root.updateEdit({
|
||||
"action": "close-window",
|
||||
@@ -1768,7 +1797,7 @@ Item {
|
||||
iconName: "delete"
|
||||
iconSize: Theme.iconSize - 4
|
||||
iconColor: Theme.error
|
||||
visible: root.editingKeyIndex >= 0 && root.editingKeyIndex < root.keys.length && (root.keys[root.editingKeyIndex].isDMSManaged || root.keys[root.editingKeyIndex].isOverride) && !root.isNew
|
||||
visible: root.editingKeyIndex >= 0 && root.editingKeyIndex < root.keys.length && (root.keys[root.editingKeyIndex].isDMSManaged || root.keys[root.editingKeyIndex].isOverride) && !root.isNew && !root.readOnly
|
||||
onClicked: root.removeBind(root._originalKey)
|
||||
}
|
||||
|
||||
@@ -1777,7 +1806,7 @@ Item {
|
||||
buttonHeight: root._buttonHeight
|
||||
backgroundColor: Theme.surfaceContainer
|
||||
textColor: Theme.primary
|
||||
visible: root.editingKeyIndex >= 0 && root.editingKeyIndex < root.keys.length && root.keys[root.editingKeyIndex].isOverride === true && root.keys[root.editingKeyIndex].hasDefault === true && !root.isNew
|
||||
visible: root.editingKeyIndex >= 0 && root.editingKeyIndex < root.keys.length && root.keys[root.editingKeyIndex].isOverride === true && root.keys[root.editingKeyIndex].hasDefault === true && !root.isNew && !root.readOnly
|
||||
onClicked: root.resetBind(root._originalKey)
|
||||
}
|
||||
|
||||
@@ -1786,7 +1815,7 @@ Item {
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: !root.canSave() ? I18n.tr("Set key and action to save") : (root.hasChanges ? I18n.tr("Unsaved changes") : I18n.tr("No changes"))
|
||||
text: root.readOnly ? I18n.tr("Read-only legacy config") : (!root.canSave() ? I18n.tr("Set key and action to save") : (root.hasChanges ? I18n.tr("Unsaved changes") : I18n.tr("No changes")))
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: root.hasChanges ? Theme.surfaceText : Theme.surfaceVariantText
|
||||
visible: !root.isNew
|
||||
|
||||
+1689
-873
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user