1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-06-23 11:35:25 -04:00

Compare commits

...

29 Commits

Author SHA1 Message Date
purian23 4bfb08f6ef fix(Hyprland): Lua config for display setup writes
- Check display include status on startup from legacy to lua
2026-05-31 00:11:30 -04:00
purian23 0689339780 feat(Hyprland): add fractional scaling display presets
- Show Hyprland scale presets that fit the active mode
- Preserve current dms setup values
2026-05-31 00:10:22 -04:00
purian23 a265625851 refactor(Hyprland): Update Lua migration and keybind writes
- emit native hl.dsp.* dispatchers for generated Lua keybinds
- keep legacy hyprland.conf installs read-only but preserved until dms setup migration
2026-05-30 23:07:06 -04:00
Body 389fffaf64 feat(Clipboard-Bar-Hist): Add search/filter to saved clipboard entries & animation states (#2464)
* Fix gaps and overlaps when filtering clipboard history

* feat(Clipboard-Bar-Hist): Add search/filter to saved clipboard entries as well. Change title on toggle between recent/saved.

* keep Pinned/Saved icon highlighted when selected

* add back filter animations

* Implement snap state for list views based on animation settings

---------

Co-authored-by: purian23 <purian23@gmail.com>
2026-05-30 15:10:58 -04:00
purian23 b7daf3f64a feat(ipc): add powerprofile status & shared profile helpers
- Follow-up to PR #2515
2026-05-30 14:57:01 -04:00
Huỳnh Thiện Lộc 461da22b08 feat(ipc): add native powerprofile target for power profiles management (#2515)
* feat: add native powerprofile IPC target for power profiles management

* feat: show centered PowerProfileModal with 3 square buttons for powerprofile IPC toggle

* style: enhance PowerProfileModal size, icons, description, and keyboard hints

* feat: add Space key binding to select highlighted power profile
2026-05-30 14:51:19 -04:00
jbwfu 2b661e241d fix(settings): support localized settings search (#2521) 2026-05-30 01:55:46 -04:00
Kyunghyun Park d7df3800c2 keybinds: add missing XF86 key mappings (#2500) 2026-05-30 01:31:58 -04:00
Bogdan f2961f9b6a feat(DiskUsage): updated dynamic width options for DiskUsage widget (#2517)
* feat(WidgetsTabSection.qml): added dynamic width and static padding for DiskUsage

* feat(DiskUsage.qml):added functionality for dynamic width and static padding, also changed spacing to work like in cpu and ram monitor. Now they look complete and same

* fix(DiskUsage): restore display modes & formatting

---------

Co-authored-by: purian23 <purian23@gmail.com>
2026-05-30 01:02:15 -04:00
purian23 f2d5ee4692 fix(animation): adjust the Popout/Control Center motion 2026-05-29 22:04:46 -04:00
purian23 7c2d5ce15e fix(Screenshot): allow region capture over shell overlays 2026-05-29 17:03:19 -04:00
Lucas 5ceb908b8b greeter: remove keep-max-bpc-unchanged option (#2528) 2026-05-29 15:14:41 -04:00
Paul d819865853 fix: Display Configurator in Hyprland (#2506)
* fix: display configurator

* fix: replace
2026-05-28 17:26:14 -04:00
jbwfu 38176ab543 fix(settings): make desktop widget group delete button clickable (#2512) 2026-05-28 12:19:02 -04:00
purian23 53936d7034 Revert "fix(IconTheme): apply stored icon theme at startup (#2511)"
This reverts commit aafc2ea4d7.
2026-05-28 11:42:07 -04:00
lingdianshiren aafc2ea4d7 fix(IconTheme): apply stored icon theme at startup (#2511)
Add applyStoredIconTheme() calls alongside existing applyStoredTheme()
calls in loadSettings(), onLoaded, and onLoadFailed, ensuring the stored
icon theme is synced to GTK/Qt/Cosmic configs on every startup and reload.

The applyStoredIconTheme() function and its _hooks entry already existed
in the codebase but were never invoked during initial settings loading.

Co-authored-by: lingdiansr <2077258365@qq.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 11:18:44 -04:00
Connor Welsh 8a4be4936a Hide MPRIS players that have nothing to play (#2509)
* fix(Mpris): exclude idle players from active-player selection

Add MprisController.isIdle() (player Stopped with empty title and
artist). _resolveActivePlayer excludes idle players from every
selection path, and re-resolves when the active player itself goes
idle. Existing triggers (availablePlayers change, isPlaying becoming
true) do not fire for a player merely stopping.

The fallback now gates on !isIdle(p) instead of p.canPlay. canPlay
describes whether Play() would succeed; !isIdle describes whether
there is anything to surface. canControl unchanged.

When no eligible player remains, activePlayer becomes null and
consumers that gate on it unload (the bar media widget via
WidgetHost; the dash via the companion fix).

* fix(DankDash): show no-player state when active player resolves to null

showNoPlayerNow gated on _noneAvailable (player count === 0) or
activePlayer being idle. Neither covers activePlayer being null
while players remain registered, which is now possible (an
always-on player sitting stopped with empty metadata).

Key showNoPlayerNow on !activePlayer; drop the unreachable
_trulyIdle.

* add(MprisController): add track artist change handling to active player resolution

---------
2026-05-28 00:19:34 -04:00
purian23 af097d0f33 feat(Greeter): Enhance login experience & manual username fallback support 2026-05-27 22:31:54 -04:00
Ethan Todd 44867e7b43 fix: awk in new greeter (#2508) 2026-05-27 22:02:37 -04:00
purian23 a366bf3ca0 fix(ClipboardEditor): Support legacy QT 6.xx decoding & large clipboard data 2026-05-26 16:38:32 -04:00
Huỳnh Thiện Lộc 89f86be00a feat: unify media controls dropdown interactions, hover behavior and cycle controls (#2470)
* feat: unify media controls dropdown interactions, hover behavior and cycle controls

- Implement hover-to-show and hover-to-hide for all media control dropdowns.
- Make clicking the Output Devices and Media Players buttons cycle through items when expanded.
- Always display the 'speaker' icon for Output Devices to maintain visual consistency.
- Bind dropdown player properties dynamically to fix list stale rendering states.

* fix(DankDash): use trackArtist property for artist label in MediaPlayerTab

* fix(DankDash): simplify active player label for consistency with output devices

* feat(DankDash): display volume levels for audio output devices in dropdown

* fix(DankDash): display Unknown Artist when artist is empty in player list

* feat(DankDash): add keyboard shortcuts for seeking, track cycling and playback control in Media popout

* feat(DankDash): change Up/Down arrow keys to adjust volume in Media popout

* feat(DankDash): auto-open volume dropdown overlay when using Up/Down shortcuts

* feat(DankDash): add Key M shortcut to toggle mute in Media popout

* fix(mpris): clamp minimum seek position to 0.1s to prevent browser player reset

* fix(mpris): cache stable length to prevent browser transient reset issues

* fix(mpris): persist activePlayerStableLength in MprisController singleton

* fix(mpris): resolve browser player album art with raw metadata and YouTube url fallbacks

* fix(mpris): resolve browser player album art with local caching and 16:9 youtube fallbacks

* style(mpris): trim trailing whitespace in TrackArtService

* fix(mpris): address code review feedback on remote caching, stale artwork, and hover state

* fix: secure curl commands and prevent premature dropdown overlays closing on button re-hover
2026-05-26 13:44:51 -04:00
bbedward 12a744e985 clipboard: fix editing in popout 2026-05-26 11:49:14 -04:00
Guilherme Pagano 54f272ba1e fix(toast): align dimensions to whole pixels to avoid blurry rendering (#2494)
The toast Rectangle uses `layer.enabled: true`, which renders to a
texture before compositing. With fractional implicit/content sizes
(derived from text and icon metrics), the cached texture was being
sampled with sub-pixel interpolation and the toast looked blurry
under fractional-scale-aware compositors (e.g., niri).

Wrap toastWidth/toastHeight and implicitWidth/implicitHeight with
Theme.px(value, dpr), matching the alignment NotificationPopup.qml
already applies to its surface.
2026-05-26 11:23:10 -04:00
Cong Luan Tran 60b64f22c6 fix(BatteryService): Make bluetoothBattery detection actually work (#2486) 2026-05-26 11:22:39 -04:00
Niltempus 97666dc73d Wait for location capability before requesting state (#2476) 2026-05-26 11:16:42 -04:00
bbedward 6c6756936b i18n: sync 2026-05-26 11:09:06 -04:00
purian23 91f8ca4efe ci: upgrade prek-action to v2 2026-05-26 09:06:26 -04:00
purian23 045ac59a44 feat(Clipboard): Clipboard Editor PR Revived (#2492)
* feat(clipboard): Add editing capability to clipboard entries
* Add split save menu for clipboard editor
* Add clipboard editor shortcuts and hints
* Show full clipboard text in editor
* feat(Clipboard): Revive ClipboardEditor PR

- Original PR #1916 by @nabaco
* fix(clipboard): restore Save button targets in editor

---------

Co-authored-by: Nachum Barcohen <38861757+nabaco@users.noreply.github.com>
2026-05-25 23:25:57 -04:00
purian23 078180fe42 feat(Greeter): improved multi-user UI and per-user theme sync
- Introduce multi-account greeter login with per-user theme previews
- Add `dms greeter sync --profile` for secondary users with or without sudo
- Add Manage greeter group membership from Settings UI → Users Tab
2026-05-25 22:41:23 -04:00
115 changed files with 15773 additions and 2476 deletions
+1 -1
View File
@@ -26,4 +26,4 @@ jobs:
go-version-file: core/go.mod go-version-file: core/go.mod
- name: run pre-commit hooks - name: run pre-commit hooks
uses: j178/prek-action@v1 uses: j178/prek-action@v2
+10 -2
View File
@@ -54,8 +54,10 @@ func init() {
} }
type IncludeResult struct { type IncludeResult struct {
Exists bool `json:"exists"` Exists bool `json:"exists"`
Included bool `json:"included"` Included bool `json:"included"`
ConfigFormat string `json:"configFormat,omitempty"`
ReadOnly bool `json:"readOnly,omitempty"`
} }
func runResolveInclude(cmd *cobra.Command, args []string) { func runResolveInclude(cmd *cobra.Command, args []string) {
@@ -106,6 +108,8 @@ func checkHyprlandInclude(filename string) (IncludeResult, error) {
mainLua := filepath.Join(configDir, "hyprland.lua") mainLua := filepath.Join(configDir, "hyprland.lua")
if _, err := os.Stat(mainLua); err == nil { if _, err := os.Stat(mainLua); err == nil {
result.ConfigFormat = "lua"
result.ReadOnly = false
processedLua := make(map[string]bool) processedLua := make(map[string]bool)
if luaconfig.RequiresTarget(mainLua, targetAbs, processedLua) { if luaconfig.RequiresTarget(mainLua, targetAbs, processedLua) {
result.Included = true result.Included = true
@@ -115,6 +119,10 @@ func checkHyprlandInclude(filename string) (IncludeResult, error) {
mainConf := filepath.Join(configDir, "hyprland.conf") mainConf := filepath.Join(configDir, "hyprland.conf")
if _, err := os.Stat(mainConf); err == nil { if _, err := os.Stat(mainConf); err == nil {
if result.ConfigFormat == "" {
result.ConfigFormat = "hyprlang"
result.ReadOnly = true
}
processed := make(map[string]bool) processed := make(map[string]bool)
if hyprlandFindIncludeHyprlang(mainConf, targetRel, processed) { if hyprlandFindIncludeHyprlang(mainConf, targetRel, processed) {
result.Included = true result.Included = true
+4 -1
View File
@@ -947,9 +947,12 @@ func checkSystemdServices() []checkResult {
message = fmt.Sprintf("%s, %s", dmsState.enabled, dmsState.active) message = fmt.Sprintf("%s, %s", dmsState.enabled, dmsState.active)
} }
switch { switch {
case dmsState.active == "failed":
status = statusError
case dmsState.active == "active":
case dmsState.enabled == "disabled": case dmsState.enabled == "disabled":
status, message = statusWarn, "Disabled" status, message = statusWarn, "Disabled"
case dmsState.active == "failed" || dmsState.active == "inactive": case dmsState.active == "inactive":
status = statusError status = statusError
} }
results = append(results, checkResult{catServices, "dms.service", status, message, "", doctorDocsURL + "#services"}) results = append(results, checkResult{catServices, "dms.service", status, message, "", doctorDocsURL + "#services"})
+52 -10
View File
@@ -59,22 +59,29 @@ var greeterInstallCmd = &cobra.Command{
} }
var greeterSyncCmd = &cobra.Command{ var greeterSyncCmd = &cobra.Command{
Use: "sync", Use: "sync",
Short: "Sync DMS theme and settings with greeter", 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", 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: preRunPrivileged, 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) { Run: func(cmd *cobra.Command, args []string) {
yes, _ := cmd.Flags().GetBool("yes") yes, _ := cmd.Flags().GetBool("yes")
auth, _ := cmd.Flags().GetBool("auth") auth, _ := cmd.Flags().GetBool("auth")
local, _ := cmd.Flags().GetBool("local") local, _ := cmd.Flags().GetBool("local")
profile, _ := cmd.Flags().GetBool("profile")
term, _ := cmd.Flags().GetBool("terminal") term, _ := cmd.Flags().GetBool("terminal")
if term { 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) log.Fatalf("Error launching sync in terminal: %v", err)
} }
return 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) 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("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("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("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{ 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)") 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 { func syncInTerminal(nonInteractive bool, forceAuth bool, local bool, profileOnly bool) error {
syncFlags := make([]string, 0, 3) syncFlags := make([]string, 0, 4)
if nonInteractive { if nonInteractive {
syncFlags = append(syncFlags, "--yes") syncFlags = append(syncFlags, "--yes")
} }
@@ -523,6 +531,9 @@ func syncInTerminal(nonInteractive bool, forceAuth bool, local bool) error {
if local { if local {
syncFlags = append(syncFlags, "--local") syncFlags = append(syncFlags, "--local")
} }
if profileOnly {
syncFlags = append(syncFlags, "--profile")
}
shellSyncCmd := "dms greeter sync" shellSyncCmd := "dms greeter sync"
if len(syncFlags) > 0 { if len(syncFlags) > 0 {
shellSyncCmd += " " + strings.Join(syncFlags, " ") 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") 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 { if !nonInteractive {
fmt.Println("=== DMS Greeter Sync ===") fmt.Println("=== DMS Greeter Sync ===")
fmt.Println() fmt.Println()
@@ -752,6 +767,26 @@ func syncGreeter(nonInteractive bool, forceAuth bool, local bool) error {
return nil 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 { func hasDmsShellQml(dir string) bool {
info, err := os.Stat(filepath.Join(dir, "shell.qml")) info, err := os.Stat(filepath.Join(dir, "shell.qml"))
return err == nil && !info.IsDir() 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) { func disableDisplayManager(dmName string) (bool, error) {
+34 -2
View File
@@ -4,7 +4,9 @@ import (
"bytes" "bytes"
"fmt" "fmt"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard" "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
@@ -179,9 +181,39 @@ func getScreenshotConfig(mode screenshot.Mode) screenshot.Config {
return 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) { func runScreenshot(config screenshot.Config) {
sc := screenshot.New(config) // Region select needs the keyboard; drop popout grabs for its duration.
result, err := sc.Run() 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 { if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err) fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1) os.Exit(1)
+4
View File
@@ -600,6 +600,10 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
return result, result.Error return result, result.Error
} }
CleanupStrayHyprlandConfFile(func(format string, v ...any) {
cd.log(fmt.Sprintf(format, v...))
})
result.Deployed = true result.Deployed = true
cd.log("Successfully deployed Hyprland configuration") cd.log("Successfully deployed Hyprland configuration")
return result, nil return result, nil
+15 -3
View File
@@ -20,13 +20,17 @@ func TestCleanupStrayHyprlandConfFile(t *testing.T) {
td := t.TempDir() td := t.TempDir()
t.Setenv("HOME", td) t.Setenv("HOME", td)
configDir := filepath.Join(td, ".config", "hypr") 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") 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(confPath, []byte("# legacy user config\n"), 0o644))
require.NoError(t, os.WriteFile(dmsConfPath, []byte("$primary = rgba(d0bcffFF)\n"), 0o644))
CleanupStrayHyprlandConfFile(nil) CleanupStrayHyprlandConfFile(nil)
assert.FileExists(t, confPath, "must not touch hyprland.conf when user has not migrated") 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)) assert.NoDirExists(t, filepath.Join(configDir, hyprlandBackupDirName))
}) })
@@ -34,20 +38,25 @@ func TestCleanupStrayHyprlandConfFile(t *testing.T) {
td := t.TempDir() td := t.TempDir()
t.Setenv("HOME", td) t.Setenv("HOME", td)
configDir := filepath.Join(td, ".config", "hypr") 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") luaPath := filepath.Join(configDir, "hyprland.lua")
require.NoError(t, os.WriteFile(luaPath, []byte("-- dms managed\n"), 0o644)) require.NoError(t, os.WriteFile(luaPath, []byte("-- dms managed\n"), 0o644))
confPath := filepath.Join(configDir, "hyprland.conf") 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(confPath, []byte("# autogen\n"), 0o644))
require.NoError(t, os.WriteFile(dmsConfPath, []byte("$primary = rgba(d0bcffFF)\n"), 0o644))
CleanupStrayHyprlandConfFile(nil) CleanupStrayHyprlandConfFile(nil)
assert.NoFileExists(t, confPath) assert.NoFileExists(t, confPath)
assert.NoFileExists(t, dmsConfPath)
assert.FileExists(t, luaPath) assert.FileExists(t, luaPath)
entries, err := os.ReadDir(filepath.Join(configDir, hyprlandBackupDirName)) entries, err := os.ReadDir(filepath.Join(configDir, hyprlandBackupDirName))
require.NoError(t, err) require.NoError(t, err)
require.Len(t, entries, 1) 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(), "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") dmsDir := filepath.Join(td, ".config", "hypr", "dms")
require.NoError(t, os.MkdirAll(dmsDir, 0o755)) 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, "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(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(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)) 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.Contains(t, result.BackupPath, hyprlandBackupDirName)
assert.NoFileExists(t, hyprPath) 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", "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), "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), "hyprland.conf.backup.old"))
assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "dms", "binds.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, "binds.conf"))
assert.NoFileExists(t, filepath.Join(dmsDir, "colors.conf"))
assert.NoFileExists(t, filepath.Join(dmsDir, "cursor.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(filepath.Dir(hyprPath), "hyprland.conf.backup.old"))
assert.NoFileExists(t, filepath.Join(dmsDir, "binds.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")) managed, err := os.ReadFile(filepath.Join(dmsDir, "binds.lua"))
require.NoError(t, err) 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 + 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")) user, err := os.ReadFile(filepath.Join(dmsDir, "binds-user.lua"))
require.NoError(t, err) require.NoError(t, err)
+5 -5
View File
@@ -140,7 +140,7 @@ hl.bind("SUPER + bracketright", hl.dsp.layout("preselect r"))
-- === Sizing & Layout === -- === Sizing & Layout ===
hl.bind("SUPER + R", hl.dsp.layout("togglesplit")) 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 === -- === Move/resize windows with mainMod + LMB/RMB and dragging ===
hl.bind("SUPER + mouse:272", hl.dsp.window.drag(), { mouse = true, description = "Move window" }) 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" }) hl.bind("SUPER + code:21", hl.dsp.window.resize({ x = 100, y = 0, relative = true }), { description = "Shrink window left" })
-- === Manual Sizing === -- === Manual Sizing ===
hl.bind("SUPER + minus", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive -10% 0]]), { repeating = true }) hl.bind("SUPER + minus", hl.dsp.window.resize({ x = -100, y = 0, relative = true }), { repeating = true })
hl.bind("SUPER + equal", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive 10% 0]]), { 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.exec_cmd([[hyprctl dispatch resizeactive 0 -10%]]), { 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.exec_cmd([[hyprctl dispatch resizeactive 0 10%]]), { repeating = true }) hl.bind("SUPER + SHIFT + equal", hl.dsp.window.resize({ x = 0, y = 100, relative = true }), { repeating = true })
-- === Screenshots === -- === Screenshots ===
hl.bind("Print", hl.dsp.exec_cmd("dms screenshot")) hl.bind("Print", hl.dsp.exec_cmd("dms screenshot"))
+37 -14
View File
@@ -138,11 +138,9 @@ func readExistingHyprlandConfig(configDir string) (data string, sourcePath strin
return "", "", nil return "", "", nil
} }
// CleanupStrayHyprlandConfFile moves a stray ~/.config/hypr/hyprland.conf // CleanupStrayHyprlandConfFile moves stray ~/.config/hypr/hyprland.conf and
// into .dms-backups/<timestamp>/ only when hyprland.lua also exists, which // top-level ~/.config/hypr/dms/*.conf files into .dms-backups/<timestamp>/ only
// proves Lua is the live config and the .conf is an autogen Hyprland 0.55 // when hyprland.lua also exists as the live config.
// produced when launched without -c. If only hyprland.conf exists, the user
// has not migrated and we must leave their config alone.
func CleanupStrayHyprlandConfFile(logFn func(format string, v ...any)) { func CleanupStrayHyprlandConfFile(logFn func(format string, v ...any)) {
if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") == "" { if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") == "" {
return return
@@ -156,19 +154,44 @@ func CleanupStrayHyprlandConfFile(logFn func(format string, v ...any)) {
if _, err := os.Stat(luaPath); err != nil { if _, err := os.Stat(luaPath); err != nil {
return return
} }
var strayPaths []string
confPath := filepath.Join(configDir, "hyprland.conf") confPath := filepath.Join(configDir, "hyprland.conf")
if _, err := os.Stat(confPath); err != nil { if info, err := os.Lstat(confPath); err == nil && !info.IsDir() {
return strayPaths = append(strayPaths, confPath)
} }
ts := time.Now().Format("2006-01-02_15-04-05") dmsConfPaths, err := filepath.Glob(filepath.Join(configDir, "dms", "*.conf"))
dst := filepath.Join(configDir, hyprlandBackupDirName, ts, "hyprland.conf") if err == nil {
if err := moveHyprlandConfigFile(confPath, dst); err != nil { for _, p := range dmsConfPaths {
if logFn != nil { if info, err := os.Lstat(p); err == nil && !info.IsDir() {
logFn("Could not move stray hyprland.conf: %v", err) strayPaths = append(strayPaths, p)
}
} }
}
if len(strayPaths) == 0 {
return 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)
} }
} }
+12
View File
@@ -9,6 +9,7 @@ import (
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"os/user"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
@@ -572,6 +573,7 @@ func EnsureGreeterCacheDir(logFunc func(string), sudoPassword string) error {
} }
runtimeDirs := []string{ runtimeDirs := []string{
filepath.Join(cacheDir, "users"),
filepath.Join(cacheDir, ".local"), filepath.Join(cacheDir, ".local"),
filepath.Join(cacheDir, ".local", "state"), filepath.Join(cacheDir, ".local", "state"),
filepath.Join(cacheDir, ".local", "share"), 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) 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" { if strings.ToLower(compositor) != "niri" {
return nil return nil
} }
+548
View File
@@ -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"])
}
}
+404 -23
View File
@@ -68,6 +68,8 @@ func (h *HyprlandProvider) GetCheatSheet() (*keybinds.CheatSheet, error) {
Effective: result.DMSStatus.Effective, Effective: result.DMSStatus.Effective,
OverriddenBy: result.DMSStatus.OverriddenBy, OverriddenBy: result.DMSStatus.OverriddenBy,
StatusMessage: result.DMSStatus.StatusMessage, 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 { 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 { if err := h.validateAction(action); err != nil {
return err 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{ existingBinds[normalizedKey] = &hyprlandOverrideBind{
Key: key, Key: canonicalKey,
Action: action, Action: action,
Description: description, Description: description,
Flags: flags, Flags: flags,
@@ -255,21 +261,28 @@ func (h *HyprlandProvider) SetBind(key, action, description string, options map[
} }
func (h *HyprlandProvider) RemoveBind(key string) error { func (h *HyprlandProvider) RemoveBind(key string) error {
if err := h.ensureWritableConfig(); err != nil {
return err
}
existingBinds, err := h.loadOverrideBinds() existingBinds, err := h.loadOverrideBinds()
if err != nil { if err != nil {
return nil return nil
} }
normalizedKey := strings.ToLower(key) canonicalKey := canonicalHyprlandOverrideKey(key)
existingBinds[normalizedKey] = &hyprlandOverrideBind{Key: key, Unbind: true} normalizedKey := hyprlandOverrideMapKey(canonicalKey)
existingBinds[normalizedKey] = &hyprlandOverrideBind{Key: canonicalKey, Unbind: true}
return h.writeOverrideBinds(existingBinds) return h.writeOverrideBinds(existingBinds)
} }
func (h *HyprlandProvider) ResetBind(key string) error { func (h *HyprlandProvider) ResetBind(key string) error {
if err := h.ensureWritableConfig(); err != nil {
return err
}
existingBinds, err := h.loadOverrideBinds() existingBinds, err := h.loadOverrideBinds()
if err != nil { if err != nil {
return nil return nil
} }
normalizedKey := strings.ToLower(key) normalizedKey := hyprlandOverrideMapKey(key)
delete(existingBinds, normalizedKey) delete(existingBinds, normalizedKey)
return h.writeOverrideBinds(existingBinds) return h.writeOverrideBinds(existingBinds)
} }
@@ -284,10 +297,46 @@ type hyprlandOverrideBind struct {
Unbind bool 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) { func (h *HyprlandProvider) loadOverrideBinds() (map[string]*hyprlandOverrideBind, error) {
return readLuaOrHyprlangOverride(h.GetOverridePath()) 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 { func (h *HyprlandProvider) getBindSortPriority(action string) int {
switch { switch {
case strings.HasPrefix(action, "exec") && strings.Contains(action, "dms"): case strings.HasPrefix(action, "exec") && strings.Contains(action, "dms"):
@@ -368,24 +417,354 @@ func normalizeLuaBindKeyPart(part string) string {
return part 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 { func luaActionStringFromHyprlangAction(action string) string {
action = strings.TrimSpace(action) action = strings.TrimSpace(action)
if strings.HasPrefix(action, "spawn ") { if expr, ok := luaActionStringFromKnownHyprlandAction(action); ok {
return fmt.Sprintf(`hl.dsp.exec_cmd(%s)`, strconv.Quote(strings.TrimSpace(strings.TrimPrefix(action, "spawn ")))) return expr
}
if strings.HasPrefix(action, "exec ") {
return fmt.Sprintf(`hl.dsp.exec_cmd(%s)`, strconv.Quote(strings.TrimPrefix(action, "exec ")))
}
switch action {
case "killactive":
return `hl.dsp.window.kill()`
case "togglefloating":
return `hl.dsp.window.float({ action = "toggle" })`
case "exit":
return `hl.dsp.exit()`
default:
return fmt.Sprintf(`hl.dsp.exec_cmd(%s)`, strconv.Quote("hyprctl dispatch "+action))
} }
return fmt.Sprintf(`hl.dsp.exec_cmd(%s)`, strconv.Quote("hyprctl dispatch "+action))
} }
func luaExprToInternalAction(expr string) string { func luaExprToInternalAction(expr string) string {
@@ -498,11 +877,12 @@ func readLuaOrHyprlangOverride(path string) (map[string]*hyprlandOverrideBind, e
continue continue
} }
if key, ok := parseLuaUnbindLine(line); ok { if key, ok := parseLuaUnbindLine(line); ok {
pendingUnbinds[strings.ToLower(key)] = key pendingUnbinds[hyprlandOverrideMapKey(key)] = canonicalHyprlandOverrideKey(key)
continue continue
} }
if kb, ok := parseLuaBindOverrideLine(line); ok { if kb, ok := parseLuaBindOverrideLine(line); ok {
normalizedKey := strings.ToLower(kb.Key) kb.Key = canonicalHyprlandOverrideKey(kb.Key)
normalizedKey := hyprlandOverrideMapKey(kb.Key)
binds[normalizedKey] = kb binds[normalizedKey] = kb
delete(pendingUnbinds, normalizedKey) delete(pendingUnbinds, normalizedKey)
continue continue
@@ -520,7 +900,8 @@ func readLuaOrHyprlangOverride(path string) (map[string]*hyprlandOverrideBind, e
action = kb.Dispatcher + " " + kb.Params action = kb.Dispatcher + " " + kb.Params
} }
flags := kb.Flags flags := kb.Flags
normalizedKey := strings.ToLower(keyStr) keyStr = canonicalHyprlandOverrideKey(keyStr)
normalizedKey := hyprlandOverrideMapKey(keyStr)
binds[normalizedKey] = &hyprlandOverrideBind{ binds[normalizedKey] = &hyprlandOverrideBind{
Key: keyStr, Key: keyStr,
Action: action, Action: action,
@@ -54,6 +54,8 @@ type HyprlandParser struct {
dmsProcessed bool dmsProcessed bool
removedKeys map[string]bool // bare hl.unbind targets (negative overrides) removedKeys map[string]bool // bare hl.unbind targets (negative overrides)
defaultDMSKeys map[string]bool // keys present in dms/binds.{lua,conf} defaultDMSKeys map[string]bool // keys present in dms/binds.{lua,conf}
configFormat string
readOnly bool
} }
func NewHyprlandParser(configDir string) *HyprlandParser { func NewHyprlandParser(configDir string) *HyprlandParser {
@@ -310,6 +312,8 @@ type HyprlandDMSStatus struct {
Effective bool Effective bool
OverriddenBy int OverriddenBy int
StatusMessage string StatusMessage string
ConfigFormat string
ReadOnly bool
} }
func (p *HyprlandParser) buildDMSStatus() *HyprlandDMSStatus { func (p *HyprlandParser) buildDMSStatus() *HyprlandDMSStatus {
@@ -319,6 +323,8 @@ func (p *HyprlandParser) buildDMSStatus() *HyprlandDMSStatus {
IncludePosition: p.dmsIncludePos, IncludePosition: p.dmsIncludePos,
TotalIncludes: p.includeCount, TotalIncludes: p.includeCount,
BindsAfterDMS: p.bindsAfterDMS, BindsAfterDMS: p.bindsAfterDMS,
ConfigFormat: p.configFormat,
ReadOnly: p.readOnly,
} }
switch { switch {
@@ -398,6 +404,13 @@ func (p *HyprlandParser) ParseWithDMS() (*HyprlandSection, error) {
if err != nil { if err != nil {
return nil, err 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, "") section, err := p.parseFileWithSource(mainConfig, "")
if err != nil { if err != nil {
return nil, err 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")) 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", "" return "killactive", ""
case strings.HasPrefix(expr, "hl.dsp.window.fullscreen("): case strings.HasPrefix(expr, "hl.dsp.window.fullscreen("):
switch luaTableStringField(expr, "mode") { switch luaTableStringField(expr, "mode") {
@@ -1014,8 +1038,26 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) {
return "fullscreen", "0" return "fullscreen", "0"
} }
return "fullscreen", luaTableStringField(expr, "mode") 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("): 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()"): case strings.Contains(expr, "hl.dsp.group.toggle()"):
return "togglegroup", "" return "togglegroup", ""
case strings.HasPrefix(expr, "hl.dsp.focus("): case strings.HasPrefix(expr, "hl.dsp.focus("):
@@ -1025,18 +1067,43 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) {
case luaTableStringField(expr, "monitor") != "": case luaTableStringField(expr, "monitor") != "":
return "focusmonitor", luaTableStringField(expr, "monitor") return "focusmonitor", luaTableStringField(expr, "monitor")
case luaTableStringField(expr, "workspace") != "": case luaTableStringField(expr, "workspace") != "":
if luaTableBoolFieldValue(expr, "on_current_monitor") {
return "focusworkspaceoncurrentmonitor", luaTableStringField(expr, "workspace")
}
return "workspace", luaTableStringField(expr, "workspace") return "workspace", luaTableStringField(expr, "workspace")
case luaTableStringField(expr, "window") != "": case luaTableStringField(expr, "window") != "":
return "focuswindow", 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("): case strings.HasPrefix(expr, "hl.dsp.window.move("):
switch { 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") != "": case luaTableStringField(expr, "direction") != "":
return "movewindow", luaTableStringField(expr, "direction") return "movewindow", luaTableStringField(expr, "direction")
case luaTableStringField(expr, "monitor") != "": case luaTableStringField(expr, "monitor") != "":
return "movewindow", "mon:" + luaTableStringField(expr, "monitor") return "movewindow", "mon:" + luaTableStringField(expr, "monitor")
case luaTableStringField(expr, "workspace") != "": 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()": case expr == "hl.dsp.window.drag()":
return "movewindow", "" return "movewindow", ""
@@ -1052,19 +1119,69 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) {
if y == "" { if y == "" {
y = "0" y = "0"
} }
return "resizeactive", x + " " + y prefix := ""
} if relative, ok := luaTableBoolField(expr, "relative"); ok && !relative {
case strings.HasPrefix(expr, "hl.dsp.layout("): prefix = "exact "
arg := extractLuaCallStringArg(expr, "hl.dsp.layout")
if arg != "" {
if u, err := strconv.Unquote(arg); err == nil {
return "layoutmsg", u
} }
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("): case strings.HasPrefix(expr, "hl.dsp.dpms("):
if action := luaTableStringField(expr, "action"); action != "" { if action := luaTableStringField(expr, "action"); action != "" {
switch action {
case "enable":
return "dpms", "on"
case "disable":
return "dpms", "off"
}
return "dpms", action 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()"): case strings.Contains(expr, "hl.dsp.exit()"):
return "exit", "" return "exit", ""
default: default:
@@ -1073,6 +1190,17 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) {
return "exec", "hyprctl dispatch lua:" + expr 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 { func extractLuaCallStringArg(callExpr, funcName string) string {
callExpr = strings.TrimSpace(callExpr) callExpr = strings.TrimSpace(callExpr)
prefix := funcName + "(" prefix := funcName + "("
@@ -1100,10 +1228,46 @@ func extractLuaCallStringArg(callExpr, funcName string) string {
return "" 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 { func luaTableStringField(expr, field string) string {
return luaStringValue(luaTableScalarField(expr, field)) 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 { func luaTableScalarField(expr, field string) string {
re := regexp.MustCompile(`(?s)\b` + regexp.QuoteMeta(field) + `\s*=\s*("(?:\\.|[^"])*"|'(?:\\.|[^'])*'|\[\[.*?\]\]|-?\d+(?:\.\d+)?|true|false)`) re := regexp.MustCompile(`(?s)\b` + regexp.QuoteMeta(field) + `\s*=\s*("(?:\\.|[^"])*"|'(?:\\.|[^'])*'|\[\[.*?\]\]|-?\d+(?:\.\d+)?|true|false)`)
m := re.FindStringSubmatch(expr) m := re.FindStringSubmatch(expr)
@@ -70,12 +70,17 @@ func TestHyprlandLuaBindRoundTripHelpers(t *testing.T) {
wantParams string wantParams string
}{ }{
{`hl.dsp.exec_cmd([[dms ipc call brightness increment 5 ""]])`, "exec", `dms ipc call brightness increment 5 ""`}, {`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.window.fullscreen({ mode = "maximized", action = "toggle" })`, "fullscreen", "1"},
{`hl.dsp.focus({ workspace = "e+1" })`, "workspace", "e+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.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.layout("togglesplit")`, "layoutmsg", "togglesplit"},
{`hl.dsp.dpms({ action = "toggle" })`, "dpms", "toggle"}, {`hl.dsp.dpms({ action = "toggle" })`, "dpms", "toggle"},
{`hl.dsp.workspace.rename({ workspace = "1", name = "work" })`, "renameworkspace", "1 work"},
} }
for _, tt := range tests { 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) { func TestHyprlandLuaBindsUserOverridesDefaults(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms") 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) { func TestHyprlandRemoveBindReplacesExistingOverrideWithNegativeOverride(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms") dmsDir := filepath.Join(tmpDir, "dms")
+2
View File
@@ -25,6 +25,8 @@ type DMSBindsStatus struct {
Effective bool `json:"effective"` Effective bool `json:"effective"`
OverriddenBy int `json:"overriddenBy"` OverriddenBy int `json:"overriddenBy"`
StatusMessage string `json:"statusMessage"` StatusMessage string `json:"statusMessage"`
ConfigFormat string `json:"configFormat,omitempty"`
ReadOnly bool `json:"readOnly,omitempty"`
} }
type CheatSheet struct { type CheatSheet struct {
+1
View File
@@ -418,6 +418,7 @@ func handleConnection(conn net.Conn) {
conn.Write(capsData) conn.Write(capsData)
conn.Write([]byte("\n")) conn.Write([]byte("\n"))
scanner := bufio.NewScanner(conn) 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() { for scanner.Scan() {
line := scanner.Bytes() line := scanner.Bytes()
@@ -44,6 +44,8 @@ type HyprlandRulesParser struct {
dmsIncludePos int dmsIncludePos int
rulesAfterDMS int rulesAfterDMS int
dmsProcessed bool dmsProcessed bool
configFormat string
readOnly bool
requireLineInMain int // hyprland.lua line (1-based) where require("dms.windowrules") occurs; else -1 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 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") { if strings.EqualFold(filepath.Ext(mainConfig), ".lua") {
p.configFormat = "lua"
p.readOnly = false
p.probeRequireWindowrulesLine(mainConfig) p.probeRequireWindowrulesLine(mainConfig)
if ap, err := filepath.Abs(mainConfig); err == nil { if ap, err := filepath.Abs(mainConfig); err == nil {
p.primaryHyprLua = ap p.primaryHyprLua = ap
} }
} else {
p.configFormat = "hyprlang"
p.readOnly = true
} }
if err := p.parseFile(mainConfig); err != nil { if err := p.parseFile(mainConfig); err != nil {
@@ -300,6 +307,8 @@ func (p *HyprlandRulesParser) buildDMSStatus() *windowrules.DMSRulesStatus {
IncludePosition: p.dmsIncludePos, IncludePosition: p.dmsIncludePos,
TotalIncludes: p.includeCount, TotalIncludes: p.includeCount,
RulesAfterDMS: p.rulesAfterDMS, RulesAfterDMS: p.rulesAfterDMS,
ConfigFormat: p.configFormat,
ReadOnly: p.readOnly,
} }
switch { switch {
@@ -451,6 +460,9 @@ func (p *HyprlandWritableProvider) GetRuleSet() (*windowrules.RuleSet, error) {
} }
func (p *HyprlandWritableProvider) SetRule(rule windowrules.WindowRule) error { func (p *HyprlandWritableProvider) SetRule(rule windowrules.WindowRule) error {
if err := p.ensureWritableConfig(); err != nil {
return err
}
rules, err := p.LoadDMSRules() rules, err := p.LoadDMSRules()
if err != nil { if err != nil {
rules = []windowrules.WindowRule{} rules = []windowrules.WindowRule{}
@@ -472,6 +484,9 @@ func (p *HyprlandWritableProvider) SetRule(rule windowrules.WindowRule) error {
} }
func (p *HyprlandWritableProvider) RemoveRule(id string) error { func (p *HyprlandWritableProvider) RemoveRule(id string) error {
if err := p.ensureWritableConfig(); err != nil {
return err
}
rules, err := p.LoadDMSRules() rules, err := p.LoadDMSRules()
if err != nil { if err != nil {
return err return err
@@ -488,6 +503,9 @@ func (p *HyprlandWritableProvider) RemoveRule(id string) error {
} }
func (p *HyprlandWritableProvider) ReorderRules(ids []string) error { func (p *HyprlandWritableProvider) ReorderRules(ids []string) error {
if err := p.ensureWritableConfig(); err != nil {
return err
}
rules, err := p.LoadDMSRules() rules, err := p.LoadDMSRules()
if err != nil { if err != nil {
return err return err
@@ -513,6 +531,29 @@ func (p *HyprlandWritableProvider) ReorderRules(ids []string) error {
return p.writeDMSRules(newRules) 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 dmsRuleCommentRegex = regexp.MustCompile(`^#\s*DMS-RULE:\s*id=([^,]+),\s*name=(.*)$`)
var dmsRuleLuaHDRRegex = regexp.MustCompile(`^\s*--\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) { func TestHyprlandRemoveRule(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
provider := NewHyprlandWritableProvider(tmpDir) provider := NewHyprlandWritableProvider(tmpDir)
+2
View File
@@ -79,6 +79,8 @@ type DMSRulesStatus struct {
Effective bool `json:"effective"` Effective bool `json:"effective"`
OverriddenBy int `json:"overriddenBy"` OverriddenBy int `json:"overriddenBy"`
StatusMessage string `json:"statusMessage"` StatusMessage string `json:"statusMessage"`
ConfigFormat string `json:"configFormat,omitempty"`
ReadOnly bool `json:"readOnly,omitempty"`
} }
type RuleSet struct { type RuleSet struct {
+63
View File
@@ -282,6 +282,53 @@ dms ipc call inhibit toggle
dms ipc call inhibit enable 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` ## Target: `wallpaper`
Wallpaper management and retrieval with support for per-monitor configurations. 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 - `close` - Hide power menu modal
- `toggle` - Toggle power menu modal visibility - `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` ### Target: `control-center`
Control Center popout containing network, bluetooth, audio, power, and other quick settings. Control Center popout containing network, bluetooth, audio, power, and other quick settings.
@@ -673,6 +732,10 @@ dms ipc call processlist toggle
# Show power menu # Show power menu
dms ipc call powermenu toggle 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 # Open notepad
dms ipc call notepad toggle dms ipc call notepad toggle
+10
View File
@@ -57,9 +57,15 @@ const KEY_MAP = {
16842802: "XF86Eject", 16842802: "XF86Eject",
16842791: "XF86Calculator", 16842791: "XF86Calculator",
16842806: "XF86Explorer", 16842806: "XF86Explorer",
16777360: "XF86HomePage",
16842794: "XF86HomePage", 16842794: "XF86HomePage",
16777362: "XF86Search",
16777426: "XF86Search", 16777426: "XF86Search",
16777376: "XF86Mail",
16777427: "XF86Mail", 16777427: "XF86Mail",
16777377: "XF86AudioMedia",
16777419: "XF86Calculator",
16777429: "XF86Explorer",
16777442: "XF86Launch0", 16777442: "XF86Launch0",
16777443: "XF86Launch1", 16777443: "XF86Launch1",
33: "1", 33: "1",
@@ -129,6 +135,10 @@ function xkbKeyFromQtKey(qk) {
return String.fromCharCode(qk); return String.fromCharCode(qk);
if (qk >= 16777264 && qk <= 16777298) if (qk >= 16777264 && qk <= 16777298)
return "F" + (qk - 16777264 + 1); 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] || ""; return KEY_MAP[qk] || "";
} }
+1
View File
@@ -13,6 +13,7 @@ Singleton {
property var currentModalsByScreen: ({}) property var currentModalsByScreen: ({})
function openModal(modal) { function openModal(modal) {
PopoutManager.screenshotActive = false;
const screenName = modal.effectiveScreen?.name ?? "unknown"; const screenName = modal.effectiveScreen?.name ?? "unknown";
currentModalsByScreen[screenName] = modal; currentModalsByScreen[screenName] = modal;
modalChanged(); modalChanged();
+5
View File
@@ -10,6 +10,9 @@ Singleton {
property var currentPopoutsByScreen: ({}) property var currentPopoutsByScreen: ({})
property var currentPopoutTriggers: ({}) 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 popoutOpening
signal popoutChanged signal popoutChanged
@@ -47,6 +50,7 @@ Singleton {
function showPopout(popout) { function showPopout(popout) {
if (!popout || !popout.screen) if (!popout || !popout.screen)
return; return;
screenshotActive = false;
popoutOpening(); popoutOpening();
const screenName = popout.screen.name; const screenName = popout.screen.name;
@@ -97,6 +101,7 @@ Singleton {
function requestPopout(popout, tabIndex, triggerSource) { function requestPopout(popout, tabIndex, triggerSource) {
if (!popout || !popout.screen) if (!popout || !popout.screen)
return; return;
screenshotActive = false;
const screenName = popout.screen.name; const screenName = popout.screen.name;
const currentPopout = currentPopoutsByScreen[screenName]; const currentPopout = currentPopoutsByScreen[screenName];
const triggerId = triggerSource !== undefined ? triggerSource : tabIndex; const triggerId = triggerSource !== undefined ? triggerSource : tabIndex;
+18 -4
View File
@@ -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 { FileView {
id: greeterSessionFile id: greeterSessionFile
path: { path: root.greeterSessionBaseDir ? (root.greeterSessionBaseDir + "/session.json") : ""
const greetCfgDir = Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter";
return greetCfgDir + "/session.json";
}
preload: isGreeterMode preload: isGreeterMode
blockLoading: false blockLoading: false
blockWrites: true blockWrites: true
+21 -3
View File
@@ -970,6 +970,7 @@ Singleton {
readonly property int shorterDuration: (typeof SettingsData !== "undefined" && SettingsData.animationSpeed === SettingsData.AnimationSpeed.Custom) ? SettingsData.customAnimationDuration : currentDurations.shorter 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 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 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 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 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 { FileView {
id: dynamicColorsFileView id: dynamicColorsFileView
path: { path: {
const greetCfgDir = Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter"; if (SessionData.isGreeterMode)
const colorsPath = SessionData.isGreeterMode ? greetCfgDir + "/colors.json" : stateDir + "/dms-colors.json"; return root.greeterColorsBaseDir ? (root.greeterColorsBaseDir + "/colors.json") : "";
return colorsPath; return stateDir + "/dms-colors.json";
} }
blockLoading: false blockLoading: false
watchChanges: !SessionData.isGreeterMode watchChanges: !SessionData.isGreeterMode
+18
View File
@@ -1185,6 +1185,24 @@ Item {
} }
} }
LazyLoader {
id: powerProfileModalLoader
active: false
PowerProfileModal {
id: powerProfileModal
Component.onCompleted: {
PopoutService.powerProfileModal = powerProfileModal;
}
}
Component.onCompleted: {
PopoutService.powerProfileModalLoader = powerProfileModalLoader;
}
}
DMSShellIPC { DMSShellIPC {
powerMenuModalLoader: powerMenuModalLoader powerMenuModalLoader: powerMenuModalLoader
processListModalLoader: processListModalLoader processListModalLoader: processListModalLoader
+85
View File
@@ -3,6 +3,7 @@ import Quickshell.Io
import Quickshell.Hyprland import Quickshell.Hyprland
import Quickshell.Wayland import Quickshell.Wayland
import Quickshell.Services.SystemTray import Quickshell.Services.SystemTray
import Quickshell.Services.UPower
import qs.Common import qs.Common
import qs.Services import qs.Services
import qs.Modules.Settings.DisplayConfig import qs.Modules.Settings.DisplayConfig
@@ -161,6 +162,21 @@ Item {
target: "control-center" 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 { IpcHandler {
function resolveTabIndex(tab: string): int { function resolveTabIndex(tab: string): int {
switch ((tab || "").toLowerCase()) { switch ((tab || "").toLowerCase()) {
@@ -1875,4 +1891,73 @@ Item {
target: "tray" 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 { ClipboardHeader {
id: header id: header
width: parent.width width: parent.width
totalCount: modal.totalCount recentsCount: modal.unpinnedEntries.length
savedCount: modal.pinnedEntries.length
showKeyboardHints: modal.showKeyboardHints showKeyboardHints: modal.showKeyboardHints
activeTab: modal.activeTab activeTab: modal.activeTab
pinnedCount: modal.pinnedCount pinnedCount: modal.pinnedCount
@@ -65,15 +66,6 @@ Item {
forceActiveFocus(); forceActiveFocus();
}); });
} }
Connections {
target: modal
function onOpened() {
Qt.callLater(function () {
searchField.forceActiveFocus();
});
}
}
} }
} }
@@ -108,6 +100,20 @@ Item {
pressDelay: 0 pressDelay: 0
flickableDirection: Flickable.VerticalFlick flickableDirection: Flickable.VerticalFlick
states: [
State {
name: "snap"
when: Theme.snapListModelChanges
PropertyChanges {
target: clipboardListView
add: null
remove: null
displaced: null
move: null
}
}
]
function ensureVisible(index) { function ensureVisible(index) {
if (index < 0 || index >= count) { if (index < 0 || index >= count) {
return; return;
@@ -145,6 +151,7 @@ Item {
onDeleteRequested: clipboardContent.modal.deleteEntry(modelData) onDeleteRequested: clipboardContent.modal.deleteEntry(modelData)
onPinRequested: clipboardContent.modal.pinEntry(modelData) onPinRequested: clipboardContent.modal.pinEntry(modelData)
onUnpinRequested: clipboardContent.modal.unpinEntry(modelData) onUnpinRequested: clipboardContent.modal.unpinEntry(modelData)
onEditRequested: clipboardContent.modal.editEntry(modelData)
} }
} }
@@ -167,6 +174,20 @@ Item {
pressDelay: 0 pressDelay: 0
flickableDirection: Flickable.VerticalFlick flickableDirection: Flickable.VerticalFlick
states: [
State {
name: "snap"
when: Theme.snapListModelChanges
PropertyChanges {
target: savedListView
add: null
remove: null
displaced: null
move: null
}
}
]
function ensureVisible(index) { function ensureVisible(index) {
if (index < 0 || index >= count) { if (index < 0 || index >= count) {
return; return;
@@ -204,6 +225,7 @@ Item {
onDeleteRequested: clipboardContent.modal.deletePinnedEntry(modelData) onDeleteRequested: clipboardContent.modal.deletePinnedEntry(modelData)
onPinRequested: clipboardContent.modal.pinEntry(modelData) onPinRequested: clipboardContent.modal.pinEntry(modelData)
onUnpinRequested: clipboardContent.modal.unpinEntry(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");
}
}
}
}
}
}
}
+19 -2
View File
@@ -17,6 +17,7 @@ Rectangle {
signal deleteRequested signal deleteRequested
signal pinRequested signal pinRequested
signal unpinRequested signal unpinRequested
signal editRequested
readonly property string entryType: modal ? modal.getEntryType(entry) : "text" readonly property string entryType: modal ? modal.getEntryType(entry) : "text"
readonly property string entryPreview: modal ? modal.getEntryPreview(entry) : "" readonly property string entryPreview: modal ? modal.getEntryPreview(entry) : ""
@@ -70,6 +71,19 @@ Rectangle {
onClicked: entry.pinned ? unpinRequested() : pinRequested() onClicked: entry.pinned ? unpinRequested() : pinRequested()
} }
DankActionButton {
iconName: "edit"
iconSize: Theme.iconSize - 6
iconColor: Theme.surfaceText
onClicked: {
if (entryType === "image") {
return;
}
editRequested();
}
}
DankActionButton { DankActionButton {
iconName: "close" iconName: "close"
iconSize: Theme.iconSize - 6 iconSize: Theme.iconSize - 6
@@ -142,8 +156,11 @@ Rectangle {
MouseArea { MouseArea {
id: mouseArea id: mouseArea
anchors.fill: parent anchors.left: parent.left
anchors.rightMargin: 80 anchors.right: actionButtons.left
anchors.rightMargin: Theme.spacingS
anchors.top: parent.top
anchors.bottom: parent.bottom
hoverEnabled: true hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onPressed: mouse => { onPressed: mouse => {
@@ -6,7 +6,8 @@ import qs.Modals.Clipboard
Item { Item {
id: header id: header
property int totalCount: 0 property int recentsCount: 0
property int savedCount: 0
property bool showKeyboardHints: false property bool showKeyboardHints: false
property string activeTab: "recents" property string activeTab: "recents"
property int pinnedCount: 0 property int pinnedCount: 0
@@ -31,7 +32,7 @@ Item {
} }
StyledText { 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 font.pixelSize: Theme.fontSizeLarge
color: Theme.surfaceText color: Theme.surfaceText
font.weight: Font.Medium font.weight: Font.Medium
@@ -48,6 +49,7 @@ Item {
iconName: "push_pin" iconName: "push_pin"
iconSize: Theme.iconSize - 4 iconSize: Theme.iconSize - 4
iconColor: header.activeTab === "saved" ? Theme.primary : Theme.surfaceText iconColor: header.activeTab === "saved" ? Theme.primary : Theme.surfaceText
backgroundColor: header.activeTab === "saved" ? Theme.primarySelected : "transparent"
visible: header.pinnedCount > 0 visible: header.pinnedCount > 0
tooltipText: header.activeTab === "saved" ? I18n.tr("Recent") : I18n.tr("Saved") tooltipText: header.activeTab === "saved" ? I18n.tr("Recent") : I18n.tr("Saved")
onClicked: tabChanged(header.activeTab === "saved" ? "recents" : "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 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() { function toggle() {
if (shouldBeVisible) { if (shouldBeVisible) {
hide(); hide();
} else { return;
show();
} }
show();
} }
function show() { function show() {
open(); open();
activeImageLoads = 0;
shouldHaveFocus = true; shouldHaveFocus = true;
ClipboardService.reset();
keyboardController.reset();
Qt.callLater(function () { Qt.callLater(function () {
if (clipboardAvailable) { if (contentLoader.item) {
contentLoader.item.resetState();
}
if (clipboardHistoryModal.clipboardAvailable) {
if (Theme.isConnectedEffect) { if (Theme.isConnectedEffect) {
Qt.callLater(() => { Qt.callLater(() => {
if (clipboardHistoryModal.shouldBeVisible) if (clipboardHistoryModal.shouldBeVisible) {
ClipboardService.refresh(); ClipboardService.refresh();
}
}); });
} else { } else {
ClipboardService.refresh(); ClipboardService.refresh();
@@ -89,46 +56,12 @@ DankModal {
} }
onDialogClosed: { onDialogClosed: {
activeImageLoads = 0; if (contentLoader.item) {
ClipboardService.reset(); contentLoader.item.resetState();
keyboardController.reset(); }
} }
function refreshClipboard() { readonly property bool clipboardAvailable: ClipboardService.clipboardAvailable
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);
}
visible: false visible: false
modalWidth: ClipboardConstants.modalWidth modalWidth: ClipboardConstants.modalWidth
@@ -138,15 +71,11 @@ DankModal {
borderColor: Theme.outlineMedium borderColor: Theme.outlineMedium
borderWidth: 1 borderWidth: 1
enableShadow: true enableShadow: true
closeOnEscapeKey: (contentLoader.item?.mode ?? "history") !== "editor"
onBackgroundClicked: hide() onBackgroundClicked: hide()
modalFocusScope.Keys.onPressed: function (event) {
keyboardController.handleKey(event);
}
content: clipboardContent
ClipboardKeyboardController { Ref {
id: keyboardController service: ClipboardService
modal: clipboardHistoryModal
} }
ConfirmModal { ConfirmModal {
@@ -171,12 +100,11 @@ DankModal {
} }
} }
property var confirmDialog: clearConfirmDialog content: Component {
ClipboardHistoryContent {
clipboardContent: Component { clearConfirmDialog: clearConfirmDialog
ClipboardContent { onCloseRequested: clipboardHistoryModal.hide()
modal: clipboardHistoryModal onInstantCloseRequested: clipboardHistoryModal.instantClose()
clearConfirmDialog: clipboardHistoryModal.confirmDialog
} }
} }
} }
@@ -15,47 +15,20 @@ DankPopout {
property var parentWidget: null property var parentWidget: null
property var triggerScreen: null property var triggerScreen: null
property string activeTab: "recents" 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 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 int pinnedCount: ClipboardService.pinnedCount
readonly property var unpinnedEntries: ClipboardService.unpinnedEntries readonly property var confirmDialog: clearConfirmDialog
readonly property int selectedIndex: ClipboardService.selectedIndex
readonly property bool keyboardNavigationActive: ClipboardService.keyboardNavigationActive
property string searchText: ClipboardService.searchText
onSearchTextChanged: ClipboardService.searchText = searchText
readonly property var modalFocusScope: contentLoader.item ?? null readonly property var modalFocusScope: contentLoader.item ?? null
Ref {
service: ClipboardService
}
function updateFilteredModel() {
ClipboardService.updateFilteredModel();
}
function pasteSelected() {
ClipboardService.pasteSelected(instantClose);
}
function instantClose() {
close();
}
function show() { function show() {
open(); open();
activeImageLoads = 0;
ClipboardService.reset();
keyboardController.reset();
Qt.callLater(function () { Qt.callLater(function () {
if (contentLoader.item) {
contentLoader.item.activeTab = activeTab;
contentLoader.item.resetState();
}
if (contentLoader.item?.searchField) { if (contentLoader.item?.searchField) {
contentLoader.item.searchField.text = ""; contentLoader.item.searchField.text = "";
contentLoader.item.searchField.forceActiveFocus(); contentLoader.item.searchField.forceActiveFocus();
@@ -65,47 +38,12 @@ DankPopout {
function hide() { function hide() {
close(); 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() { function clearAll() {
ClipboardService.clearAll(); ClipboardService.clearAll();
} }
function getEntryPreview(entry) {
return ClipboardService.getEntryPreview(entry);
}
function getEntryType(entry) {
return ClipboardService.getEntryType(entry);
}
popupWidth: ClipboardConstants.popoutWidth popupWidth: ClipboardConstants.popoutWidth
popupHeight: ClipboardConstants.popoutHeight popupHeight: ClipboardConstants.popoutHeight
triggerWidth: 55 triggerWidth: 55
@@ -117,20 +55,25 @@ DankPopout {
onBackgroundClicked: hide() onBackgroundClicked: hide()
onShouldBeVisibleChanged: { onShouldBeVisibleChanged: {
if (!shouldBeVisible) if (!shouldBeVisible) {
return; return;
}
if (clipboardAvailable) { if (clipboardAvailable) {
if (Theme.isConnectedEffect) { if (Theme.isConnectedEffect) {
Qt.callLater(() => { Qt.callLater(() => {
if (root.shouldBeVisible) if (root.shouldBeVisible) {
ClipboardService.refresh(); ClipboardService.refresh();
}
}); });
} else { } else {
ClipboardService.refresh(); ClipboardService.refresh();
} }
} }
keyboardController.reset();
Qt.callLater(function () { Qt.callLater(function () {
if (contentLoader.item) {
contentLoader.item.activeTab = activeTab;
contentLoader.item.resetState();
}
if (contentLoader.item?.searchField) { if (contentLoader.item?.searchField) {
contentLoader.item.searchField.text = ""; contentLoader.item.searchField.text = "";
contentLoader.item.searchField.forceActiveFocus(); contentLoader.item.searchField.forceActiveFocus();
@@ -139,14 +82,13 @@ DankPopout {
} }
onPopoutClosed: { onPopoutClosed: {
activeImageLoads = 0; if (contentLoader.item) {
ClipboardService.reset(); contentLoader.item.resetState();
keyboardController.reset(); }
} }
ClipboardKeyboardController { Ref {
id: keyboardController service: ClipboardService
modal: root
} }
ConfirmModal { ConfirmModal {
@@ -155,48 +97,20 @@ DankPopout {
confirmButtonColor: Theme.primary confirmButtonColor: Theme.primary
} }
property var confirmDialog: clearConfirmDialog
content: Component { content: Component {
FocusScope { ClipboardHistoryContent {
id: contentFocusScope
LayoutMirroring.enabled: I18n.isRtl LayoutMirroring.enabled: I18n.isRtl
LayoutMirroring.childrenInherit: true LayoutMirroring.childrenInherit: true
focus: true clearConfirmDialog: clearConfirmDialog
onCloseRequested: root.hide()
property alias searchField: clipboardContentItem.searchField onInstantCloseRequested: root.close()
Keys.onPressed: function (event) {
keyboardController.handleKey(event);
}
Component.onCompleted: { Component.onCompleted: {
if (root.shouldBeVisible) activeTab = root.activeTab;
if (root.shouldBeVisible) {
forceActiveFocus(); 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) { function handleKey(event) {
if (modal.mode === "editor") {
if (event.key === Qt.Key_Escape) {
modal.mode = "history";
event.accepted = true;
}
return;
}
switch (event.key) { switch (event.key) {
case Qt.Key_Escape: case Qt.Key_Escape:
if (ClipboardService.keyboardNavigationActive) { if (ClipboardService.keyboardNavigationActive) {
@@ -152,6 +169,10 @@ QtObject {
event.accepted = true; event.accepted = true;
} }
return; return;
case Qt.Key_E:
editSelected();
event.accepted = true;
return;
} }
} }
@@ -10,7 +10,7 @@ Rectangle {
readonly property string hintsText: { readonly property string hintsText: {
if (!wtypeAvailable) if (!wtypeAvailable)
return I18n.tr("Ctrl+Tab: Switch Tab • Ctrl+S: Pin/Unpin • Shift+Del: Clear All • Esc: Close"); 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 height: ClipboardConstants.keyboardHintsHeight
@@ -22,13 +22,17 @@ Rectangle {
z: 100 z: 100
Column { Column {
width: parent.width - Theme.spacingL * 2
anchors.centerIn: parent anchors.centerIn: parent
spacing: 2 spacing: 2
StyledText { 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 font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText color: Theme.surfaceText
width: parent.width
wrapMode: Text.WordWrap
horizontalAlignment: Text.AlignHCenter
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
} }
@@ -36,6 +40,9 @@ Rectangle {
text: keyboardHints.hintsText text: keyboardHints.hintsText
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText color: Theme.surfaceText
width: parent.width
wrapMode: Text.WordWrap
horizontalAlignment: Text.AlignHCenter
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
} }
} }
@@ -689,7 +689,7 @@ Item {
WlrLayershell.namespace: "dms:spotlight" WlrLayershell.namespace: "dms:spotlight"
WlrLayershell.layer: root.effectiveLauncherLayer WlrLayershell.layer: root.effectiveLauncherLayer
WlrLayershell.exclusiveZone: -1 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 { anchors {
left: true left: true
@@ -345,7 +345,7 @@ Item {
WlrLayershell.namespace: "dms:spotlight" WlrLayershell.namespace: "dms:spotlight"
WlrLayershell.layer: root.effectiveLauncherLayer WlrLayershell.layer: root.effectiveLauncherLayer
WlrLayershell.exclusiveZone: -1 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 { anchors {
top: true top: true
@@ -381,7 +381,7 @@ Item {
WlrLayershell.namespace: "dms:spotlight" WlrLayershell.namespace: "dms:spotlight"
WlrLayershell.layer: root.effectiveLauncherLayer WlrLayershell.layer: root.effectiveLauncherLayer
WlrLayershell.exclusiveZone: -1 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 { anchors {
top: true top: true
@@ -446,7 +446,7 @@ Item {
WlrLayershell.namespace: "dms:launcher-context-menu" WlrLayershell.namespace: "dms:launcher-context-menu"
WlrLayershell.layer: WlrLayershell.Overlay WlrLayershell.layer: WlrLayershell.Overlay
WlrLayershell.exclusiveZone: -1 WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: root.renderActive ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None WlrLayershell.keyboardFocus: PopoutManager.screenshotActive ? WlrKeyboardFocus.None : (root.renderActive ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None)
anchors { anchors {
top: true top: true
+277
View File
@@ -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.namespace: "dms:control-center-widget-library"
WlrLayershell.layer: WlrLayershell.Overlay WlrLayershell.layer: WlrLayershell.Overlay
WlrLayershell.exclusiveZone: -1 WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: visible ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None WlrLayershell.keyboardFocus: PopoutManager.screenshotActive ? WlrKeyboardFocus.None : (visible ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None)
anchors { anchors {
top: true top: true
@@ -24,14 +24,13 @@ Rectangle {
} }
function setProfile(profile) { function setProfile(profile) {
if (typeof PowerProfiles === "undefined") { if (PowerProfileWatcher.applyProfile(profile))
ToastService.showError(I18n.tr("power-profiles-daemon not available"));
return; return;
}
PowerProfiles.profile = profile; if (!PowerProfileWatcher.available)
if (PowerProfiles.profile !== profile) { ToastService.showError(I18n.tr("power-profiles-daemon not available"));
else
ToastService.showError(I18n.tr("Failed to set power profile")); ToastService.showError(I18n.tr("Failed to set power profile"));
}
} }
Column { Column {
@@ -193,7 +192,7 @@ Rectangle {
} }
DankButtonGroup { 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: { property int currentProfileIndex: {
if (typeof PowerProfiles === "undefined") if (typeof PowerProfiles === "undefined")
return 1; return 1;
@@ -21,14 +21,13 @@ DankPopout {
} }
function setProfile(profile) { function setProfile(profile) {
if (typeof PowerProfiles === "undefined") { if (PowerProfileWatcher.applyProfile(profile))
ToastService.showError(I18n.tr("power-profiles-daemon not available"));
return; return;
}
PowerProfiles.profile = profile; if (!PowerProfileWatcher.available)
if (PowerProfiles.profile !== profile) { ToastService.showError(I18n.tr("power-profiles-daemon not available"));
else
ToastService.showError(I18n.tr("Failed to set power profile")); ToastService.showError(I18n.tr("Failed to set power profile"));
}
} }
popupWidth: 400 popupWidth: 400
@@ -555,7 +554,7 @@ DankPopout {
DankButtonGroup { DankButtonGroup {
id: profileButtonGroup 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: { property int currentProfileIndex: {
if (typeof PowerProfiles === "undefined") if (typeof PowerProfiles === "undefined")
return 1; return 1;
@@ -140,30 +140,24 @@ BasePill {
log.info("Trigger! Delta: " + delta); log.info("Trigger! Delta: " + delta);
// This is after the other delta checks so it only shows on valid Y scroll // 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")); ToastService.showError(I18n.tr("power-profiles-daemon not available"));
return; return;
} }
// Get list of profiles, and current index const profiles = PowerProfileWatcher.availableProfiles;
const profiles = [PowerProfile.PowerSaver, PowerProfile.Balanced].concat(PowerProfiles.hasPerformanceProfile ? [PowerProfile.Performance] : []);
var index = profiles.findIndex(profile => PowerProfiles.profile === profile); var index = profiles.findIndex(profile => PowerProfiles.profile === profile);
// Step once based on mouse wheel direction
if (delta > 0) if (delta > 0)
index += 1; index += 1;
else else
index -= 1; index -= 1;
// Already at end of list, can't go further
if (index < 0 || index >= profiles.length) if (index < 0 || index >= profiles.length)
return; return;
// Set new profile if (!PowerProfileWatcher.applyProfile(profiles[index]))
PowerProfiles.profile = profiles[index];
if (PowerProfiles.profile !== profiles[index]) {
ToastService.showError(I18n.tr("Failed to set power profile")); 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 int diskUsageMode: (widgetData && widgetData.diskUsageMode !== undefined) ? widgetData.diskUsageMode : 0
property bool isHovered: mouseArea.containsMouse property bool isHovered: mouseArea.containsMouse
property bool isAutoHideBar: false property bool isAutoHideBar: false
property bool minimumWidth: (widgetData && widgetData.minimumWidth !== undefined) ? widgetData.minimumWidth : true
property var selectedMount: { property var selectedMount: {
if (!DgopService.diskMounts || DgopService.diskMounts.length === 0) { if (!DgopService.diskMounts || DgopService.diskMounts.length === 0) {
@@ -69,6 +70,8 @@ BasePill {
} }
Connections { Connections {
target: SettingsData
function onWidgetDataChanged() { function onWidgetDataChanged() {
root.mountPath = Qt.binding(() => { root.mountPath = Qt.binding(() => {
return (root.widgetData && root.widgetData.mountPath !== undefined) ? root.widgetData.mountPath : "/"; return (root.widgetData && root.widgetData.mountPath !== undefined) ? root.widgetData.mountPath : "/";
@@ -96,14 +99,12 @@ BasePill {
return DgopService.diskMounts[0] || null; return DgopService.diskMounts[0] || null;
}); });
} }
target: SettingsData
} }
content: Component { content: Component {
Item { Item {
implicitWidth: root.isVerticalOrientation ? (root.widgetThickness - root.horizontalPadding * 2) : diskContent.implicitWidth 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 { Column {
id: diskColumn id: diskColumn
@@ -118,10 +119,12 @@ BasePill {
if (root.diskUsagePercent > 90) { if (root.diskUsagePercent > 90) {
return Theme.tempDanger; return Theme.tempDanger;
} }
if (root.diskUsagePercent > 75) { if (root.diskUsagePercent > 75) {
return Theme.tempWarning; return Theme.tempWarning;
} }
return Theme.surfaceText;
return Theme.widgetIconColor;
} }
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
} }
@@ -154,24 +157,28 @@ BasePill {
id: diskContent id: diskContent
visible: !root.isVerticalOrientation visible: !root.isVerticalOrientation
anchors.centerIn: parent anchors.centerIn: parent
spacing: 3 spacing: Theme.spacingXS
DankIcon { DankIcon {
id: diskIcon
name: "storage" name: "storage"
size: Theme.barIconSize(root.barThickness, undefined, root.barConfig?.maximizeWidgetIcons, root.barConfig?.iconScale) size: Theme.barIconSize(root.barThickness, undefined, root.barConfig?.maximizeWidgetIcons, root.barConfig?.iconScale)
color: { color: {
if (root.diskUsagePercent > 90) { if (root.diskUsagePercent > 90) {
return Theme.tempDanger; return Theme.tempDanger;
} }
if (root.diskUsagePercent > 75) { if (root.diskUsagePercent > 75) {
return Theme.tempWarning; return Theme.tempWarning;
} }
return Theme.surfaceText;
return Theme.widgetIconColor;
} }
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
} }
StyledText { StyledText {
id: mountText
text: { text: {
if (!root.selectedMount) { if (!root.selectedMount) {
return "--"; return "--";
@@ -182,32 +189,20 @@ BasePill {
color: Theme.widgetTextColor color: Theme.widgetTextColor
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
horizontalAlignment: Text.AlignLeft horizontalAlignment: Text.AlignLeft
verticalAlignment: Text.AlignVCenter
elide: Text.ElideNone elide: Text.ElideNone
wrapMode: Text.NoWrap
} }
StyledText { Item {
text: { id: textBox
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.verticalCenter: parent.verticalCenter 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 { StyledTextMetrics {
id: diskBaseline 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.layer: root.barUsesOverlayLayer ? WlrLayershell.Overlay : WlrLayershell.Top
WlrLayershell.exclusiveZone: -1 WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: { WlrLayershell.keyboardFocus: {
if (PopoutManager.screenshotActive)
return WlrKeyboardFocus.None;
if (!root.menuOpen) if (!root.menuOpen)
return WlrKeyboardFocus.None; return WlrKeyboardFocus.None;
if (CompositorService.useHyprlandFocusGrab) if (CompositorService.useHyprlandFocusGrab)
@@ -1449,6 +1451,8 @@ BasePill {
WlrLayershell.layer: root.barUsesOverlayLayer ? WlrLayershell.Overlay : WlrLayershell.Top WlrLayershell.layer: root.barUsesOverlayLayer ? WlrLayershell.Overlay : WlrLayershell.Top
WlrLayershell.exclusiveZone: -1 WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: { WlrLayershell.keyboardFocus: {
if (PopoutManager.screenshotActive)
return WlrKeyboardFocus.None;
if (!menuRoot.showMenu) if (!menuRoot.showMenu)
return WlrKeyboardFocus.None; return WlrKeyboardFocus.None;
if (CompositorService.useHyprlandFocusGrab) if (CompositorService.useHyprlandFocusGrab)
+16 -8
View File
@@ -25,14 +25,14 @@ DankPopout {
property int __dropdownType: 0 property int __dropdownType: 0
property point __dropdownAnchor: Qt.point(0, 0) property point __dropdownAnchor: Qt.point(0, 0)
property bool __dropdownRightEdge: false property bool __dropdownRightEdge: false
property var __dropdownPlayer: null property var __dropdownPlayer: MprisController.activePlayer
property var __dropdownPlayers: [] property var __dropdownPlayers: MprisController.availablePlayers
function __showVolumeDropdown(pos, rightEdge, player, players) { function __showVolumeDropdown(pos, rightEdge, player, players) {
__dropdownAnchor = pos; __dropdownAnchor = pos;
__dropdownRightEdge = rightEdge; __dropdownRightEdge = rightEdge;
__dropdownPlayer = player; __dropdownPlayer = Qt.binding(() => MprisController.activePlayer);
__dropdownPlayers = players; __dropdownPlayers = Qt.binding(() => MprisController.availablePlayers);
__dropdownType = 1; __dropdownType = 1;
} }
@@ -45,8 +45,8 @@ DankPopout {
function __showPlayersDropdown(pos, rightEdge, player, players) { function __showPlayersDropdown(pos, rightEdge, player, players) {
__dropdownAnchor = pos; __dropdownAnchor = pos;
__dropdownRightEdge = rightEdge; __dropdownRightEdge = rightEdge;
__dropdownPlayer = player; __dropdownPlayer = Qt.binding(() => MprisController.activePlayer);
__dropdownPlayers = players; __dropdownPlayers = Qt.binding(() => MprisController.availablePlayers);
__dropdownType = 3; __dropdownType = 3;
} }
@@ -69,7 +69,7 @@ DankPopout {
id: __volumeCloseTimer id: __volumeCloseTimer
interval: 400 interval: 400
onTriggered: { onTriggered: {
if (__dropdownType === 1) { if (__dropdownType !== 0) {
__hideDropdowns(); __hideDropdowns();
} }
} }
@@ -230,6 +230,13 @@ DankPopout {
return; 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 (root.currentTabIndex === 2 && wallpaperLoader.item?.handleKeyEvent) {
if (wallpaperLoader.item.handleKeyEvent(event)) { if (wallpaperLoader.item.handleKeyEvent(event)) {
event.accepted = true; event.accepted = true;
@@ -394,7 +401,8 @@ DankPopout {
root.__showPlayersDropdown(pos, rightEdge, player, players); root.__showPlayersDropdown(pos, rightEdge, player, players);
} }
onHideDropdowns: root.__hideDropdowns() onHideDropdowns: root.__hideDropdowns()
onVolumeButtonExited: root.__startCloseTimer() onDropdownButtonExited: root.__startCloseTimer()
onDropdownButtonEntered: root.__stopCloseTimer()
} }
} }
} }
@@ -42,16 +42,22 @@ Item {
signal panelEntered signal panelEntered
signal panelExited signal panelExited
property int __volumeHoverCount: 0 property int __panelHoverCount: 0
function volumeAreaEntered() { onDropdownTypeChanged: {
__volumeHoverCount++; if (dropdownType === 0) {
__panelHoverCount = 0;
}
}
function panelAreaEntered() {
__panelHoverCount++;
panelEntered(); panelEntered();
} }
function volumeAreaExited() { function panelAreaExited() {
__volumeHoverCount = Math.max(0, __volumeHoverCount - 1); __panelHoverCount = Math.max(0, __panelHoverCount - 1);
if (__volumeHoverCount === 0) if (__panelHoverCount === 0)
panelExited(); panelExited();
} }
@@ -131,8 +137,8 @@ Item {
anchors.fill: parent anchors.fill: parent
anchors.margins: -12 anchors.margins: -12
hoverEnabled: true hoverEnabled: true
onEntered: volumeAreaEntered() onEntered: panelAreaEntered()
onExited: volumeAreaExited() onExited: panelAreaExited()
} }
Item { Item {
@@ -190,8 +196,8 @@ Item {
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
preventStealing: true preventStealing: true
onEntered: volumeAreaEntered() onEntered: panelAreaEntered()
onExited: volumeAreaExited() onExited: panelAreaExited()
onPressed: mouse => updateVolume(mouse) onPressed: mouse => updateVolume(mouse)
onPositionChanged: mouse => { onPositionChanged: mouse => {
if (pressed) if (pressed)
@@ -269,6 +275,14 @@ Item {
shadowEnabled: Theme.elevationEnabled && !BlurService.enabled shadowEnabled: Theme.elevationEnabled && !BlurService.enabled
} }
MouseArea {
anchors.fill: parent
anchors.margins: -12
hoverEnabled: true
onEntered: panelAreaEntered()
onExited: panelAreaExited()
}
Column { Column {
anchors.fill: parent anchors.fill: parent
anchors.margins: Theme.spacingM anchors.margins: Theme.spacingM
@@ -349,7 +363,13 @@ Item {
} }
StyledText { 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 font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText color: Theme.surfaceVariantText
elide: Text.ElideRight elide: Text.ElideRight
@@ -369,6 +389,8 @@ Item {
root.deviceSelected(modelData); root.deviceSelected(modelData);
} }
} }
onEntered: panelAreaEntered()
onExited: panelAreaExited()
} }
} }
} }
@@ -425,6 +447,14 @@ Item {
shadowEnabled: Theme.elevationEnabled && !BlurService.enabled shadowEnabled: Theme.elevationEnabled && !BlurService.enabled
} }
MouseArea {
anchors.fill: parent
anchors.margins: -12
hoverEnabled: true
onEntered: panelAreaEntered()
onExited: panelAreaExited()
}
Column { Column {
anchors.fill: parent anchors.fill: parent
anchors.margins: Theme.spacingM anchors.margins: Theme.spacingM
@@ -498,15 +528,7 @@ Item {
} }
StyledText { StyledText {
text: { text: modelData?.trackArtist || I18n.tr("Unknown Artist")
if (!modelData)
return "";
const artist = modelData.trackArtist || "";
const isActive = modelData === activePlayer;
if (artist.length > 0)
return artist + (isActive ? " (Active)" : "");
return isActive ? "Active" : "Available";
}
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText color: Theme.surfaceVariantText
elide: Text.ElideRight elide: Text.ElideRight
@@ -526,6 +548,8 @@ Item {
root.playerSelected(modelData); root.playerSelected(modelData);
} }
} }
onEntered: panelAreaEntered()
onExited: panelAreaExited()
} }
} }
} }
+171 -44
View File
@@ -13,6 +13,7 @@ Item {
LayoutMirroring.childrenInherit: true LayoutMirroring.childrenInherit: true
property MprisPlayer activePlayer: MprisController.activePlayer property MprisPlayer activePlayer: MprisController.activePlayer
readonly property real stableLength: MprisController.activePlayerStableLength
property var allPlayers: MprisController.availablePlayers property var allPlayers: MprisController.availablePlayers
property var targetScreen: null property var targetScreen: null
property real popoutX: 0 property real popoutX: 0
@@ -27,7 +28,8 @@ Item {
signal showAudioDevicesDropdown(point pos, var screen, bool rightEdge) signal showAudioDevicesDropdown(point pos, var screen, bool rightEdge)
signal showPlayersDropdown(point pos, var screen, bool rightEdge, var player, var players) signal showPlayersDropdown(point pos, var screen, bool rightEdge, var player, var players)
signal hideDropdowns signal hideDropdowns
signal volumeButtonExited signal dropdownButtonExited
signal dropdownButtonEntered
property bool volumeExpanded: false property bool volumeExpanded: false
property bool devicesExpanded: false property bool devicesExpanded: false
@@ -39,9 +41,7 @@ Item {
playersExpanded = false; playersExpanded = false;
} }
DankTooltipV2 {
id: sharedTooltip
}
readonly property bool isRightEdge: { readonly property bool isRightEdge: {
if (barPosition === SettingsData.Position.Right) if (barPosition === SettingsData.Position.Right)
@@ -65,8 +65,7 @@ Item {
// Derived "no players" state: always correct, no timers. // Derived "no players" state: always correct, no timers.
readonly property int _playerCount: allPlayers ? allPlayers.length : 0 readonly property int _playerCount: allPlayers ? allPlayers.length : 0
readonly property bool _noneAvailable: _playerCount === 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 || !activePlayer)
readonly property bool showNoPlayerNow: (!_switchHold) && (_noneAvailable || _trulyIdle)
property bool _switchHold: false property bool _switchHold: false
Timer { Timer {
@@ -85,7 +84,6 @@ Item {
isSwitching = true; isSwitching = true;
_switchHold = true; _switchHold = true;
_switchHoldTimer.restart(); _switchHoldTimer.restart();
TrackArtService.loadArtwork(activePlayer.trackArtUrl);
} }
function maybeFinishSwitch() { function maybeFinishSwitch() {
@@ -96,11 +94,11 @@ Item {
} }
readonly property real ratio: { readonly property real ratio: {
if (!activePlayer || !activePlayer.length || activePlayer.length <= 0) { if (!activePlayer || stableLength <= 0) {
return 0; return 0;
} }
const pos = (activePlayer.position || 0) % Math.max(1, activePlayer.length); const pos = (activePlayer.position || 0) % Math.max(1, stableLength);
const calculatedRatio = pos / activePlayer.length; const calculatedRatio = pos / stableLength;
return Math.max(0, Math.min(1, calculatedRatio)); return Math.max(0, Math.min(1, calculatedRatio));
} }
@@ -109,13 +107,11 @@ Item {
Connections { Connections {
target: activePlayer target: activePlayer
ignoreUnknownSignals: true
function onTrackTitleChanged() { function onTrackTitleChanged() {
_switchHoldTimer.restart(); _switchHoldTimer.restart();
maybeFinishSwitch(); maybeFinishSwitch();
} }
function onTrackArtUrlChanged() {
TrackArtService.loadArtwork(activePlayer.trackArtUrl);
}
} }
Connections { 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 property bool isSeeking: false
Timer { Timer {
@@ -198,14 +290,14 @@ Item {
Item { Item {
id: bgContainer id: bgContainer
anchors.fill: parent anchors.fill: parent
visible: TrackArtService._bgArtSource !== "" visible: TrackArtService.resolvedArtUrl !== ""
Image { Image {
id: bgImage id: bgImage
anchors.centerIn: parent anchors.centerIn: parent
width: Math.max(parent.width, parent.height) * 1.1 width: Math.max(parent.width, parent.height) * 1.1
height: width height: width
source: TrackArtService._bgArtSource source: TrackArtService.resolvedArtUrl
fillMode: Image.PreserveAspectCrop fillMode: Image.PreserveAspectCrop
asynchronous: true asynchronous: true
cache: true cache: true
@@ -331,7 +423,7 @@ Item {
} }
StyledText { StyledText {
text: activePlayer?.trackTitle || I18n.tr("Unknown Artist") text: activePlayer?.trackArtist || I18n.tr("Unknown Artist")
font.pixelSize: Theme.fontSizeMedium font.pixelSize: Theme.fontSizeMedium
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.8) color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.8)
width: parent.width width: parent.width
@@ -389,7 +481,7 @@ Item {
if (!activePlayer) if (!activePlayer)
return "0:00"; return "0:00";
const rawPos = Math.max(0, activePlayer.position || 0); 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 minutes = Math.floor(pos / 60);
const seconds = Math.floor(pos % 60); const seconds = Math.floor(pos % 60);
const timeStr = minutes + ":" + (seconds < 10 ? "0" : "") + seconds; const timeStr = minutes + ":" + (seconds < 10 ? "0" : "") + seconds;
@@ -403,9 +495,9 @@ Item {
anchors.right: parent.right anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: { text: {
if (!activePlayer || !activePlayer.length) if (!activePlayer || stableLength <= 0)
return "0:00"; return "--:--";
const dur = Math.max(0, activePlayer.length || 0); const dur = stableLength;
const minutes = Math.floor(dur / 60); const minutes = Math.floor(dur / 60);
const seconds = Math.floor(dur % 60); const seconds = Math.floor(dur % 60);
return minutes + ":" + (seconds < 10 ? "0" : "") + seconds; return minutes + ":" + (seconds < 10 ? "0" : "") + seconds;
@@ -647,7 +739,17 @@ Item {
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onClicked: { onClicked: {
if (playersExpanded) { 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; return;
} }
hideDropdowns(); hideDropdowns();
@@ -658,8 +760,22 @@ Item {
const screenY = popoutY + contentOffsetY + btnY; const screenY = popoutY + contentOffsetY + btnY;
showPlayersDropdown(Qt.point(screenX, screenY), targetScreen, buttonsOnRight, activePlayer, allPlayers); showPlayersDropdown(Qt.point(screenX, screenY), targetScreen, buttonsOnRight, activePlayer, allPlayers);
} }
onEntered: sharedTooltip.show(I18n.tr("Media Players"), playerSelectorButton, 0, 0, isRightEdge ? "right" : "left") onEntered: {
onExited: sharedTooltip.hide() 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 hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onEntered: { onEntered: {
dropdownButtonEntered();
if (volumeExpanded) if (volumeExpanded)
return; return;
hideDropdowns(); hideDropdowns();
@@ -703,25 +820,10 @@ Item {
} }
onExited: { onExited: {
if (volumeExpanded) if (volumeExpanded)
volumeButtonExited(); dropdownButtonExited();
} }
onClicked: { onClicked: {
SessionData.suppressOSDTemporarily(); toggleMute();
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;
}
}
} }
onWheel: wheelEvent => { onWheel: wheelEvent => {
SessionData.suppressOSDTemporarily(); SessionData.suppressOSDTemporarily();
@@ -754,7 +856,7 @@ Item {
DankIcon { DankIcon {
anchors.centerIn: parent anchors.centerIn: parent
name: devicesExpanded ? "expand_less" : "speaker" name: "speaker"
size: 18 size: 18
color: Theme.surfaceText color: Theme.surfaceText
} }
@@ -766,7 +868,18 @@ Item {
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onClicked: { onClicked: {
if (devicesExpanded) { 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; return;
} }
hideDropdowns(); hideDropdowns();
@@ -777,8 +890,22 @@ Item {
const screenY = popoutY + contentOffsetY + btnY; const screenY = popoutY + contentOffsetY + btnY;
showAudioDevicesDropdown(Qt.point(screenX, screenY), targetScreen, buttonsOnRight); showAudioDevicesDropdown(Qt.point(screenX, screenY), targetScreen, buttonsOnRight);
} }
onEntered: sharedTooltip.show(I18n.tr("Output Device"), audioDevicesButton, 0, 0, isRightEdge ? "right" : "left") onEntered: {
onExited: sharedTooltip.hide() 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 property real displayPosition: currentPosition
readonly property real ratio: { readonly property real ratio: {
if (!activePlayer || activePlayer.length <= 0) const len = MprisController.activePlayerStableLength;
if (!activePlayer || !activePlayer.lengthSupported || len <= 0)
return 0; return 0;
const pos = displayPosition % Math.max(1, activePlayer.length); const pos = displayPosition % Math.max(1, len);
const calculatedRatio = pos / activePlayer.length; const calculatedRatio = pos / len;
return Math.max(0, Math.min(1, calculatedRatio)); return Math.max(0, Math.min(1, calculatedRatio));
} }
+15 -7
View File
@@ -12,16 +12,24 @@ Singleton {
id: root id: root
readonly property var log: Log.scoped("GreetdSettings") readonly property var log: Log.scoped("GreetdSettings")
readonly property string configPath: { readonly property string _greeterCacheDir: Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter"
const greetCfgDir = Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter";
return greetCfgDir + "/settings.json"; 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: { function resetConfigBaseDir() {
const i = root.configPath.lastIndexOf("/"); setConfigBaseDir(root._greeterCacheDir);
return i >= 0 ? root.configPath.substring(0, i) : "";
} }
readonly property string greeterWallpaperOverridePath: root._greeterCacheDir ? (root._greeterCacheDir + "/greeter_wallpaper_override.jpg") : ""
property string currentThemeName: "purple" property string currentThemeName: "purple"
property bool settingsLoaded: false property bool settingsLoaded: false
+271 -60
View File
@@ -62,6 +62,14 @@ Item {
readonly property bool greeterPamHasU2f: greeterPamStackHasModule("pam_u2f") readonly property bool greeterPamHasU2f: greeterPamStackHasModule("pam_u2f")
readonly property bool greeterExternalAuthAvailable: (greeterPamHasFprint && GreetdSettings.greeterEnableFprint) || (greeterPamHasU2f && GreetdSettings.greeterEnableU2f) readonly property bool greeterExternalAuthAvailable: (greeterPamHasFprint && GreetdSettings.greeterEnableFprint) || (greeterPamHasU2f && GreetdSettings.greeterEnableU2f)
readonly property bool greeterPamHasExternalAuth: greeterPamHasFprint || greeterPamHasU2f 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() { function initWeatherService() {
if (weatherInitialized) if (weatherInitialized)
@@ -428,20 +436,87 @@ Item {
fprintdDeviceProbe.running = true; 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() { function applyLastSuccessfulUser() {
if (root.skipAutoSelectUser)
return;
if (!GreetdSettings.settingsLoaded || !GreetdSettings.rememberLastUser) if (!GreetdSettings.settingsLoaded || !GreetdSettings.rememberLastUser)
return; return;
const lastUser = GreetdMemory.lastSuccessfulUser; const lastUser = GreetdMemory.lastSuccessfulUser;
if (lastUser && !GreeterState.showPasswordInput && !GreeterState.username) { if (lastUser && !GreeterState.showPasswordInput && !GreeterState.username) {
GreeterState.username = lastUser; selectUser(lastUser, true);
GreeterState.usernameInput = lastUser;
GreeterState.showPasswordInput = true;
PortalService.getGreeterUserProfileImage(lastUser);
maybeAutoStartExternalAuth();
} }
} }
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(); const user = (rawValue || "").trim();
if (!user) if (!user)
return; return;
@@ -450,8 +525,15 @@ Item {
clearAuthFeedback(); clearAuthFeedback();
externalAuthAutoStartedForUser = ""; externalAuthAutoStartedForUser = "";
} }
root.pickerThemeUsername = user;
GreeterState.username = user; GreeterState.username = user;
GreeterState.usernameInput = user;
GreeterState.showPasswordInput = true; GreeterState.showPasswordInput = true;
if (!skipDropdownUpdate && typeof GreeterUsersService !== "undefined") {
const idx = GreeterUsersService.usernames.indexOf(user);
GreeterState.selectedUserIndex = idx;
}
root.userListOpen = false;
PortalService.getGreeterUserProfileImage(user); PortalService.getGreeterUserProfileImage(user);
GreeterState.passwordBuffer = ""; GreeterState.passwordBuffer = "";
pendingPasswordResponse = false; 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 { Connections {
target: GreeterState target: GreeterState
function onUsernameChanged() { function onUsernameChanged() {
if (GreeterState.username) { if (GreeterState.username) {
root.pickerThemeUsername = GreeterState.username;
GreeterUserTheme.applyForUser(GreeterState.username);
PortalService.getGreeterUserProfileImage(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 { FileView {
@@ -736,19 +849,26 @@ Item {
anchors.fill: parent anchors.fill: parent
color: "transparent" color: "transparent"
Item { Column {
id: clockContainer id: greeterMainColumn
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.verticalCenter
anchors.bottomMargin: 60
width: parent.width
height: clockText.implicitHeight
Row { anchors.horizontalCenter: parent.horizontalCenter
id: clockText anchors.verticalCenter: parent.verticalCenter
anchors.horizontalCenter: parent.horizontalCenter spacing: Theme.spacingM
anchors.top: parent.top width: 380
spacing: 0
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: { property string fullTimeStr: {
const format = GreetdSettings.getEffectiveTimeFormat(); const format = GreetdSettings.getEffectiveTimeFormat();
@@ -853,60 +973,121 @@ Item {
visible: clockText.ampm !== "" 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 { StyledText {
anchors.horizontalCenter: parent.horizontalCenter id: dateText
anchors.top: dateText.bottom
anchors.topMargin: Theme.spacingL anchors.horizontalCenter: parent.horizontalCenter
width: 380 text: systemClock.date.toLocaleDateString(I18n.locale(), GreetdSettings.getEffectiveLockDateFormat())
height: 140 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 { ColumnLayout {
anchors.fill: parent id: authColumn
width: parent.width
spacing: Theme.spacingM spacing: Theme.spacingM
RowLayout { RowLayout {
spacing: Theme.spacingL spacing: Theme.spacingL
Layout.fillWidth: true Layout.fillWidth: true
DankCircularImage { Item {
Layout.preferredWidth: 60 Layout.preferredWidth: 60
Layout.preferredHeight: 60 Layout.preferredHeight: 60
imageSource: { visible: GreetdSettings.lockScreenShowProfileImage || root.multipleUsersAvailable
if (PortalService.profileImage === "")
return ""; DankCircularImage {
if (PortalService.profileImage.startsWith("/")) anchors.fill: parent
return encodeFileUrl(PortalService.profileImage); imageSource: {
return PortalService.profileImage; 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 { Rectangle {
property bool showPassword: false property bool showPassword: false
Layout.fillWidth: true Layout.fillWidth: true
Layout.preferredHeight: 60 Layout.preferredHeight: root.showUserPicker && root.userListOpen ? Math.max(60, userPicker.implicitHeight + Theme.spacingM * 2) : 60
radius: Theme.cornerRadius radius: Theme.cornerRadius
color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.9) 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.color: inputField.activeFocus ? Theme.primary : Qt.rgba(1, 1, 1, 0.3)
border.width: inputField.activeFocus ? 2 : 1 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 { DankIcon {
id: lockIcon id: lockIcon
@@ -916,6 +1097,7 @@ Item {
name: GreeterState.showPasswordInput ? "lock" : "person" name: GreeterState.showPasswordInput ? "lock" : "person"
size: 20 size: 20
color: inputField.activeFocus ? Theme.primary : Theme.surfaceVariantText color: inputField.activeFocus ? Theme.primary : Theme.surfaceVariantText
visible: !root.showUserPicker
} }
TextInput { TextInput {
@@ -941,8 +1123,9 @@ Item {
} }
return margin; return margin;
} }
enabled: !root.showUserPicker || GreeterState.showPasswordInput
opacity: 0 opacity: 0
focus: true focus: !root.showUserPicker || GreeterState.showPasswordInput
echoMode: GreeterState.showPasswordInput ? (parent.showPassword ? TextInput.Normal : TextInput.Password) : TextInput.Normal echoMode: GreeterState.showPasswordInput ? (parent.showPassword ? TextInput.Normal : TextInput.Password) : TextInput.Normal
onTextChanged: { onTextChanged: {
if (syncingFromState) if (syncingFromState)
@@ -1005,11 +1188,14 @@ Item {
if (GreeterState.showPasswordInput) { if (GreeterState.showPasswordInput) {
return I18n.tr("Password..."); return I18n.tr("Password...");
} }
if (root.showUserPicker) {
return "";
}
return I18n.tr("Username..."); return I18n.tr("Username...");
} }
color: (GreeterState.unlocking || (Greetd.state !== GreetdState.Inactive && !awaitingExternalAuth && !pendingPasswordResponse)) ? Theme.primary : Theme.outline color: (GreeterState.unlocking || (Greetd.state !== GreetdState.Inactive && !awaitingExternalAuth && !pendingPasswordResponse)) ? Theme.primary : Theme.outline
font.pixelSize: Theme.fontSizeMedium 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 { Behavior on opacity {
NumberAnimation { NumberAnimation {
@@ -1043,7 +1229,7 @@ Item {
} }
color: Theme.surfaceText color: Theme.surfaceText
font.pixelSize: (GreeterState.showPasswordInput && !parent.showPassword) ? Theme.fontSizeLarge : Theme.fontSizeMedium 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 clip: true
elide: Text.ElideNone elide: Text.ElideNone
horizontalAlignment: implicitWidth > width ? Text.AlignRight : Text.AlignLeft horizontalAlignment: implicitWidth > width ? Text.AlignRight : Text.AlignLeft
@@ -1088,7 +1274,7 @@ Item {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
iconName: "keyboard" iconName: "keyboard"
buttonSize: 32 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 enabled: visible
onClicked: { onClicked: {
if (keyboard_controller.isKeyboardActive) { if (keyboard_controller.isKeyboardActive) {
@@ -1107,7 +1293,7 @@ Item {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
iconName: "keyboard_return" iconName: "keyboard_return"
buttonSize: 36 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 enabled: true
onClicked: { onClicked: {
if (GreeterState.showPasswordInput) { 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 { StyledText {
Layout.fillWidth: true Layout.fillWidth: true
Layout.preferredHeight: 38 Layout.preferredHeight: 38
@@ -1198,13 +1414,8 @@ Item {
StateLayer { StateLayer {
stateColor: Theme.primary stateColor: Theme.primary
cornerRadius: parent.radius cornerRadius: parent.radius
enabled: !GreeterState.unlocking && Greetd.state === GreetdState.Inactive && GreeterState.showPasswordInput enabled: !GreeterState.unlocking && GreeterState.showPasswordInput
onClicked: { onClicked: root.returnToUserPicker()
GreeterState.reset();
root.externalAuthAutoStartedForUser = "";
inputField.text = "";
PortalService.profileImage = "";
}
} }
} }
} }
@@ -19,6 +19,8 @@ Singleton {
property var sessionExecs: [] property var sessionExecs: []
property var sessionPaths: [] property var sessionPaths: []
property int currentSessionIndex: 0 property int currentSessionIndex: 0
property var availableUsers: []
property int selectedUserIndex: -1
function reset() { function reset() {
showPasswordInput = false; showPasswordInput = false;
@@ -26,5 +28,6 @@ Singleton {
usernameInput = ""; usernameInput = "";
passwordBuffer = ""; passwordBuffer = "";
pamState = ""; 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" : "";
}
}
+11 -1
View File
@@ -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. 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: **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" DMS_RUN_GREETER "1"
} }
debug {
keep-max-bpc-unchanged
}
gestures { gestures {
hot-corners { hot-corners {
off off
@@ -8,10 +8,6 @@ environment {
spawn-at-startup "sh" "-c" "qs -p _DMS_PATH_; niri msg action quit --skip-confirmation" spawn-at-startup "sh" "-c" "qs -p _DMS_PATH_; niri msg action quit --skip-confirmation"
debug {
keep-max-bpc-unchanged
}
gestures { gestures {
hot-corners { hot-corners {
off off
+11 -8
View File
@@ -60,7 +60,7 @@ DankOSD {
Image { Image {
id: artPreloader id: artPreloader
source: TrackArtService._bgArtSource source: TrackArtService.resolvedArtUrl
visible: false visible: false
asynchronous: true asynchronous: true
cache: true cache: true
@@ -78,7 +78,7 @@ DankOSD {
function onLoadingChanged() { function onLoadingChanged() {
if (TrackArtService.loading || !root._pendingShow) if (TrackArtService.loading || !root._pendingShow)
return; return;
if (!TrackArtService._bgArtSource || artPreloader.status === Image.Ready) { if (!TrackArtService.resolvedArtUrl || artPreloader.status === Image.Ready) {
root._pendingShow = false; root._pendingShow = false;
root.show(); root.show();
} }
@@ -116,9 +116,9 @@ DankOSD {
root._displayAlbum = player.trackAlbum || ""; root._displayAlbum = player.trackAlbum || "";
root.updatePlaybackIcon(); root.updatePlaybackIcon();
TrackArtService.loadArtwork(player.trackArtUrl); const resolvedArtUrl = TrackArtService.resolvedArtUrl;
if (!player.trackArtUrl || player.trackArtUrl === "") { if (!resolvedArtUrl || resolvedArtUrl === "") {
root.show(); root.show();
return; return;
} }
@@ -126,7 +126,7 @@ DankOSD {
root._pendingShow = true; root._pendingShow = true;
return; return;
} }
if (!TrackArtService._bgArtSource || artPreloader.status === Image.Ready) { if (!TrackArtService.resolvedArtUrl || artPreloader.status === Image.Ready) {
root.show(); root.show();
return; return;
} }
@@ -134,7 +134,10 @@ DankOSD {
} }
function onTrackArtUrlChanged() { function onTrackArtUrlChanged() {
TrackArtService.loadArtwork(player.trackArtUrl); handleUpdate();
}
function onMetadataChanged() {
handleUpdate();
} }
function onIsPlayingChanged() { function onIsPlayingChanged() {
handleUpdate(); handleUpdate();
@@ -168,14 +171,14 @@ DankOSD {
Item { Item {
id: bgContainer id: bgContainer
anchors.fill: parent anchors.fill: parent
visible: TrackArtService._bgArtSource !== "" visible: TrackArtService.resolvedArtUrl !== ""
Image { Image {
id: bgImage id: bgImage
anchors.centerIn: parent anchors.centerIn: parent
width: Math.max(parent.width, parent.height) width: Math.max(parent.width, parent.height)
height: width height: width
source: TrackArtService._bgArtSource source: TrackArtService.resolvedArtUrl
fillMode: Image.PreserveAspectCrop fillMode: Image.PreserveAspectCrop
asynchronous: true asynchronous: true
cache: true cache: true
@@ -300,6 +300,8 @@ Item {
} }
WlrLayershell.exclusionMode: ExclusionMode.Ignore WlrLayershell.exclusionMode: ExclusionMode.Ignore
WlrLayershell.keyboardFocus: { WlrLayershell.keyboardFocus: {
if (PopoutManager.screenshotActive)
return WlrKeyboardFocus.None;
if (root.isInteracting) { if (root.isInteracting) {
if (CompositorService.useHyprlandFocusGrab) if (CompositorService.useHyprlandFocusGrab)
return WlrKeyboardFocus.OnDemand; return WlrKeyboardFocus.OnDemand;
@@ -301,10 +301,19 @@ Item {
clip: true clip: true
spacing: 2 spacing: 2
add: null states: [
remove: null State {
displaced: null name: "snap"
move: null when: Theme.snapListModelChanges
PropertyChanges {
target: processListView
add: null
remove: null
displaced: null
move: null
}
}
]
model: ScriptModel { model: ScriptModel {
values: root.cachedProcesses values: root.cachedProcesses
@@ -231,6 +231,8 @@ Item {
DankActionButton { DankActionButton {
id: deleteGroupBtn id: deleteGroupBtn
iconName: "delete" iconName: "delete"
backgroundColor: Theme.withAlpha(Theme.error, 0.15)
iconColor: Theme.error
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
onClicked: { onClicked: {
SettingsData.removeDesktopWidgetGroup(groupItem.modelData.id); SettingsData.removeDesktopWidgetGroup(groupItem.modelData.id);
@@ -242,6 +244,7 @@ Item {
MouseArea { MouseArea {
id: groupMouseArea id: groupMouseArea
anchors.fill: parent anchors.fill: parent
z: -1
hoverEnabled: true hoverEnabled: true
onDoubleClicked: root.editingGroupId = groupItem.modelData.id onDoubleClicked: root.editingGroupId = groupItem.modelData.id
} }
@@ -21,8 +21,11 @@ Singleton {
property var includeStatus: ({ property var includeStatus: ({
"exists": false, "exists": false,
"included": false "included": false,
"configFormat": "",
"readOnly": false
}) })
readonly property bool readOnly: CompositorService.isHyprland && includeStatus.readOnly === true
property bool checkingInclude: false property bool checkingInclude: false
property bool fixingInclude: false property bool fixingInclude: false
@@ -481,6 +484,15 @@ Singleton {
// Write compositor config from a neutral config entry and optionally reload // Write compositor config from a neutral config entry and optionally reload
function applyConfigEntry(configEntry, configId, profileName, isManual) { 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); ensureEnabledOutput(configEntry);
// Capture the entry being applied so disabled-output settings fields can read // Capture the entry being applied so disabled-output settings fields can read
// scale/position/transform back even when wlr reports no logical viewport. // scale/position/transform back even when wlr reports no logical viewport.
@@ -845,6 +857,8 @@ Singleton {
Component.onCompleted: { Component.onCompleted: {
outputs = buildOutputsMap(); outputs = buildOutputsMap();
reloadSavedOutputs(); reloadSavedOutputs();
if (CompositorService.isHyprland)
checkIncludeStatus();
} }
function reloadSavedOutputs() { function reloadSavedOutputs() {
@@ -997,6 +1011,8 @@ Singleton {
const id = (o.make + " " + o.model + " " + serial).trim(); const id = (o.make + " " + o.model + " " + serial).trim();
liveByIdentifier[id] = true; liveByIdentifier[id] = true;
liveByIdentifier[o.make + " " + o.model] = true; liveByIdentifier[o.make + " " + o.model] = true;
if (CompositorService.isHyprland)
liveByIdentifier[getHyprlandOutputIdentifier(o, name)] = true;
} }
liveByIdentifier[name] = true; liveByIdentifier[name] = true;
} }
@@ -1132,11 +1148,13 @@ Singleton {
"scale": typeof scaleValue === "number" ? scaleValue : 1.0, "scale": typeof scaleValue === "number" ? scaleValue : 1.0,
"transform": hyprlandToTransform(transform) "transform": hyprlandToTransform(transform)
}, },
"modes": modeMatch ? [{ "modes": modeMatch ? [
"width": parseInt(modeMatch[1]), {
"height": parseInt(modeMatch[2]), "width": parseInt(modeMatch[1]),
"refresh_rate": Math.round(parseFloat(modeMatch[3]) * 1000) "height": parseInt(modeMatch[2]),
}] : [], "refresh_rate": Math.round(parseFloat(modeMatch[3]) * 1000)
}
] : [],
"current_mode": modeMatch ? 0 : -1, "current_mode": modeMatch ? 0 : -1,
"vrr_enabled": vrrMode >= 1, "vrr_enabled": vrrMode >= 1,
"vrr_supported": vrrMode > 0, "vrr_supported": vrrMode > 0,
@@ -1368,7 +1386,9 @@ Singleton {
if (compositor !== "niri" && compositor !== "hyprland" && compositor !== "dwl") { if (compositor !== "niri" && compositor !== "hyprland" && compositor !== "dwl") {
includeStatus = { includeStatus = {
"exists": false, "exists": false,
"included": false "included": false,
"configFormat": "",
"readOnly": false
}; };
return; return;
} }
@@ -1382,7 +1402,9 @@ Singleton {
if (exitCode !== 0) { if (exitCode !== 0) {
includeStatus = { includeStatus = {
"exists": false, "exists": false,
"included": false "included": false,
"configFormat": "",
"readOnly": false
}; };
return; return;
} }
@@ -1391,13 +1413,24 @@ Singleton {
} catch (e) { } catch (e) {
includeStatus = { includeStatus = {
"exists": false, "exists": false,
"included": false "included": false,
"configFormat": "",
"readOnly": false
}; };
} }
}); });
} }
function fixOutputsInclude() { function fixOutputsInclude() {
if (readOnly) {
showHyprlandReadOnlyWarning();
return;
}
if (CompositorService.isHyprland && !HyprlandService.luaConfigActive) {
showHyprlandReadOnlyWarning();
checkIncludeStatus();
return;
}
const paths = getConfigPaths(); const paths = getConfigPaths();
if (!paths) if (!paths)
return; return;
@@ -1414,14 +1447,34 @@ Singleton {
}); });
Proc.runCommand("fix-outputs-include", ["sh", "-c", script], (output, exitCode) => { Proc.runCommand("fix-outputs-include", ["sh", "-c", script], (output, exitCode) => {
fixingInclude = false; if (exitCode !== 0) {
if (exitCode !== 0) fixingInclude = false;
return; 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(); checkIncludeStatus();
WlrOutputService.requestState(); 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() { function buildOutputsMap() {
const map = {}; const map = {};
for (const output of wlrOutputs) { for (const output of wlrOutputs) {
@@ -1510,6 +1563,10 @@ Singleton {
NiriService.generateOutputsConfig(outputsData); NiriService.generateOutputsConfig(outputsData);
break; break;
case "hyprland": case "hyprland":
if (readOnly) {
showHyprlandReadOnlyWarning();
return false;
}
HyprlandService.generateOutputsConfig(outputsData, buildMergedHyprlandSettings()); HyprlandService.generateOutputsConfig(outputsData, buildMergedHyprlandSettings());
break; break;
case "dwl": case "dwl":
@@ -1519,6 +1576,7 @@ Singleton {
WlrOutputService.applyOutputsConfig(outputsData, outputs); WlrOutputService.applyOutputsConfig(outputsData, outputs);
break; break;
} }
return true;
} }
function normalizeOutputPositions(outputsData) { function normalizeOutputPositions(outputsData) {
@@ -1666,7 +1724,7 @@ Singleton {
function getHyprlandOutputIdentifier(output, outputName) { function getHyprlandOutputIdentifier(output, outputName) {
if (SettingsData.displayNameMode === "model" && output?.make && output?.model) 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; return outputName;
} }
@@ -1826,6 +1884,10 @@ Singleton {
function applyChanges() { function applyChanges() {
if (!hasPendingChanges) if (!hasPendingChanges)
return; return;
if (CompositorService.isHyprland && readOnly) {
showHyprlandReadOnlyWarning();
return;
}
const changeDescriptions = []; const changeDescriptions = [];
if (formatChanged) { if (formatChanged) {
@@ -2465,6 +2527,50 @@ Singleton {
return mode.width + "x" + mode.height + "@" + (mode.refresh_rate / 1000).toFixed(3); 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) { function getTransformLabel(transform) {
switch (transform) { switch (transform) {
case "Normal": case "Normal":
@@ -12,13 +12,14 @@ StyledRect {
height: warningContent.implicitHeight + Theme.spacingL * 2 height: warningContent.implicitHeight + Theme.spacingL * 2
radius: Theme.cornerRadius radius: Theme.cornerRadius
readonly property bool showError: DisplayConfigState.includeStatus.exists && !DisplayConfigState.includeStatus.included readonly property bool showLegacy: DisplayConfigState.readOnly
readonly property bool showSetup: !DisplayConfigState.includeStatus.exists && !DisplayConfigState.includeStatus.included 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" color: (showLegacy || showError || showSetup) ? Theme.withAlpha(Theme.primary, 0.15) : "transparent"
border.color: (showError || showSetup) ? Theme.withAlpha(Theme.primary, 0.3) : "transparent" border.color: (showLegacy || showError || showSetup) ? Theme.withAlpha(Theme.primary, 0.3) : "transparent"
border.width: 1 border.width: 1
visible: (showError || showSetup) && DisplayConfigState.hasOutputBackend && !DisplayConfigState.checkingInclude visible: (showLegacy || showError || showSetup) && DisplayConfigState.hasOutputBackend && !DisplayConfigState.checkingInclude
Column { Column {
id: warningContent id: warningContent
@@ -44,6 +45,8 @@ StyledRect {
StyledText { StyledText {
text: { text: {
if (root.showLegacy)
return I18n.tr("Hyprland conf mode");
if (root.showSetup) if (root.showSetup)
return I18n.tr("First Time Setup"); return I18n.tr("First Time Setup");
if (root.showError) if (root.showError)
@@ -59,6 +62,8 @@ StyledRect {
StyledText { StyledText {
text: { 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) if (root.showSetup)
return I18n.tr("Click 'Setup' to create the outputs config and add include to your compositor config."); return I18n.tr("Click 'Setup' to create the outputs config and add include to your compositor config.");
if (root.showError) if (root.showError)
@@ -75,7 +80,7 @@ StyledRect {
DankButton { DankButton {
id: fixButton id: fixButton
visible: root.showError || root.showSetup visible: !root.showLegacy && (root.showError || root.showSetup)
text: { text: {
if (DisplayConfigState.fixingInclude) if (DisplayConfigState.fixingInclude)
return I18n.tr("Fixing..."); return I18n.tr("Fixing...");
@@ -203,12 +203,40 @@ StyledRect {
height: scaleDropdown.visible ? scaleDropdown.height : scaleInput.height height: scaleDropdown.visible ? scaleDropdown.height : scaleInput.height
property bool customMode: false property bool customMode: false
property string currentScale: { property real currentScaleValue: {
const pendingScale = DisplayConfigState.getPendingValue(root.outputName, "scale"); const pendingScale = DisplayConfigState.getPendingValue(root.outputName, "scale");
if (pendingScale !== undefined) if (pendingScale !== undefined)
return parseFloat(pendingScale.toFixed(2)).toString(); return pendingScale;
const scale = root.outputData?.logical?.scale || 1.0; return root.outputData?.logical?.scale || 1.0;
return parseFloat(scale.toFixed(2)).toString(); }
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 { DankDropdown {
@@ -217,20 +245,7 @@ StyledRect {
dropdownWidth: parent.width dropdownWidth: parent.width
visible: !scaleContainer.customMode visible: !scaleContainer.customMode
currentValue: scaleContainer.currentScale currentValue: scaleContainer.currentScale
options: { options: scaleContainer.scaleOptionsData.labels
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);
});
}
onValueChanged: value => { onValueChanged: value => {
if (value === I18n.tr("Custom...")) { if (value === I18n.tr("Custom...")) {
scaleContainer.customMode = true; scaleContainer.customMode = true;
@@ -239,7 +254,8 @@ StyledRect {
scaleInput.selectAll(); scaleInput.selectAll();
return; 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 width: parent.width
height: 40 height: 40
visible: scaleContainer.customMode visible: scaleContainer.customMode
placeholderText: "0.5 - 4.0" placeholderText: "0.25 - 4.0"
function applyValue() { function applyValue() {
const val = parseFloat(text); const val = parseFloat(text);
@@ -257,7 +273,7 @@ StyledRect {
scaleContainer.customMode = false; scaleContainer.customMode = false;
return; return;
} }
DisplayConfigState.setPendingChange(root.outputName, "scale", parseFloat(val.toFixed(2))); DisplayConfigState.setPendingChange(root.outputName, "scale", parseFloat(val.toFixed(6)));
scaleContainer.customMode = false; scaleContainer.customMode = false;
} }
+1 -1
View File
@@ -446,7 +446,7 @@ Item {
settingKey: "greeterStatus" settingKey: "greeterStatus"
StyledText { 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 font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText color: Theme.surfaceVariantText
width: parent.width width: parent.width
+20 -9
View File
@@ -84,6 +84,10 @@ Item {
} }
function startNewBind() { function startNewBind() {
if (KeybindsService.readOnly) {
KeybindsService.showHyprlandReadOnlyWarning();
return;
}
showingNewBind = true; showingNewBind = true;
expandedKey = ""; expandedKey = "";
} }
@@ -292,7 +296,7 @@ Item {
StyledText { StyledText {
readonly property string bindsFile: KeybindsService.currentProvider === "niri" ? "dms/binds.kdl" : KeybindsService.currentProvider === "hyprland" ? "dms/binds-user.lua" : "dms/binds.conf" 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 font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText color: Theme.surfaceVariantText
wrapMode: Text.WordWrap wrapMode: Text.WordWrap
@@ -326,7 +330,7 @@ Item {
iconSize: Theme.iconSize iconSize: Theme.iconSize
iconColor: Theme.primary iconColor: Theme.primary
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
enabled: !keybindsTab.showingNewBind enabled: !keybindsTab.showingNewBind && !KeybindsService.readOnly
opacity: enabled ? 1 : 0.5 opacity: enabled ? 1 : 0.5
onClicked: keybindsTab.startNewBind() onClicked: keybindsTab.startNewBind()
} }
@@ -342,14 +346,15 @@ Item {
radius: Theme.cornerRadius radius: Theme.cornerRadius
readonly property var status: KeybindsService.dmsStatus readonly property var status: KeybindsService.dmsStatus
readonly property bool showError: !status.included && status.exists readonly property bool showLegacy: KeybindsService.readOnly
readonly property bool showWarning: status.included && status.overriddenBy > 0 readonly property bool showError: !showLegacy && !status.included && status.exists
readonly property bool showSetup: !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" color: (showLegacy || showError || showWarning || showSetup) ? Theme.withAlpha(Theme.primary, 0.15) : "transparent"
border.color: (showError || showWarning || showSetup) ? Theme.withAlpha(Theme.primary, 0.3) : "transparent" border.color: (showLegacy || showError || showWarning || showSetup) ? Theme.withAlpha(Theme.primary, 0.3) : "transparent"
border.width: 1 border.width: 1
visible: (showError || showWarning || showSetup) && !KeybindsService.loading visible: (showLegacy || showError || showWarning || showSetup) && !KeybindsService.loading
Column { Column {
id: warningSection id: warningSection
@@ -375,6 +380,8 @@ Item {
StyledText { StyledText {
text: { text: {
if (warningBox.showLegacy)
return I18n.tr("Hyprland conf mode");
if (warningBox.showSetup) if (warningBox.showSetup)
return I18n.tr("First Time Setup"); return I18n.tr("First Time Setup");
if (warningBox.showError) if (warningBox.showError)
@@ -391,6 +398,8 @@ Item {
StyledText { StyledText {
readonly property string bindsFile: KeybindsService.currentProvider === "niri" ? "dms/binds.kdl" : KeybindsService.currentProvider === "hyprland" ? "dms/binds-user.lua" : "dms/binds.conf" readonly property string bindsFile: KeybindsService.currentProvider === "niri" ? "dms/binds.kdl" : KeybindsService.currentProvider === "hyprland" ? "dms/binds-user.lua" : "dms/binds.conf"
text: { 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) if (warningBox.showSetup)
return I18n.tr("Click 'Setup' to create %1 and add include to config.").arg(bindsFile); return I18n.tr("Click 'Setup' to create %1 and add include to config.").arg(bindsFile);
if (warningBox.showError) if (warningBox.showError)
@@ -411,7 +420,7 @@ Item {
DankButton { DankButton {
id: fixButton id: fixButton
visible: warningBox.showError || warningBox.showSetup visible: !warningBox.showLegacy && (warningBox.showError || warningBox.showSetup)
text: { text: {
if (KeybindsService.fixing) if (KeybindsService.fixing)
return I18n.tr("Fixing..."); return I18n.tr("Fixing...");
@@ -559,6 +568,7 @@ Item {
desc: "" desc: ""
}) })
panelWindow: keybindsTab.parentModal panelWindow: keybindsTab.parentModal
readOnly: KeybindsService.readOnly
onSaveBind: (originalKey, newData) => keybindsTab.saveNewBind(newData) onSaveBind: (originalKey, newData) => keybindsTab.saveNewBind(newData)
onCancelEdit: keybindsTab.cancelNewBind() onCancelEdit: keybindsTab.cancelNewBind()
} }
@@ -668,6 +678,7 @@ Item {
bindData: modelData bindData: modelData
isExpanded: keybindsTab.expandedKey === modelData.action isExpanded: keybindsTab.expandedKey === modelData.action
panelWindow: keybindsTab.parentModal panelWindow: keybindsTab.parentModal
readOnly: KeybindsService.readOnly
onToggleExpand: keybindsTab.toggleExpanded(modelData.action) onToggleExpand: keybindsTab.toggleExpanded(modelData.action)
onSaveBind: (originalKey, newData) => { onSaveBind: (originalKey, newData) => {
KeybindsService.saveBind(originalKey, newData); KeybindsService.saveBind(originalKey, newData);
+17 -4
View File
@@ -23,8 +23,11 @@ Item {
property var cursorIncludeStatus: ({ property var cursorIncludeStatus: ({
"exists": false, "exists": false,
"included": false "included": false,
"configFormat": "",
"readOnly": false
}) })
readonly property bool cursorReadOnly: CompositorService.isHyprland && cursorIncludeStatus.readOnly === true
property bool checkingCursorInclude: false property bool checkingCursorInclude: false
property bool fixingCursorInclude: false property bool fixingCursorInclude: false
@@ -62,7 +65,9 @@ Item {
if (compositor !== "niri" && compositor !== "hyprland" && compositor !== "dwl") { if (compositor !== "niri" && compositor !== "hyprland" && compositor !== "dwl") {
cursorIncludeStatus = { cursorIncludeStatus = {
"exists": false, "exists": false,
"included": false "included": false,
"configFormat": "",
"readOnly": false
}; };
return; return;
} }
@@ -76,7 +81,9 @@ Item {
if (exitCode !== 0) { if (exitCode !== 0) {
cursorIncludeStatus = { cursorIncludeStatus = {
"exists": false, "exists": false,
"included": false "included": false,
"configFormat": "",
"readOnly": false
}; };
return; return;
} }
@@ -85,13 +92,19 @@ Item {
} catch (e) { } catch (e) {
cursorIncludeStatus = { cursorIncludeStatus = {
"exists": false, "exists": false,
"included": false "included": false,
"configFormat": "",
"readOnly": false
}; };
} }
}); });
} }
function fixCursorInclude() { 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(); const paths = getCursorConfigPaths();
if (!paths) if (!paths)
return; return;
+90 -1
View File
@@ -17,12 +17,14 @@ Item {
property string pendingPassword: "" property string pendingPassword: ""
property string pendingConfirm: "" property string pendingConfirm: ""
property bool pendingAdmin: false property bool pendingAdmin: false
property bool pendingGreeter: false
function _resetForm() { function _resetForm() {
pendingUsername = ""; pendingUsername = "";
pendingPassword = ""; pendingPassword = "";
pendingConfirm = ""; pendingConfirm = "";
pendingAdmin = false; pendingAdmin = false;
pendingGreeter = false;
usernameField.text = ""; usernameField.text = "";
passwordField.text = ""; passwordField.text = "";
confirmField.text = ""; confirmField.text = "";
@@ -59,6 +61,10 @@ Item {
id: adminToggleConfirm id: adminToggleConfirm
} }
ConfirmModal {
id: greeterToggleConfirm
}
DankFlickable { DankFlickable {
anchors.fill: parent anchors.fill: parent
clip: true clip: true
@@ -112,6 +118,26 @@ Item {
height: 1 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 { StyledText {
text: UsersService.refreshing ? I18n.tr("Refreshing…") : "" text: UsersService.refreshing ? I18n.tr("Refreshing…") : ""
font.pixelSize: Theme.fontSizeSmall 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 { Repeater {
model: UsersService.users model: UsersService.users
@@ -179,6 +213,24 @@ Item {
font.weight: Font.Medium 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 { StyledText {
@@ -195,6 +247,34 @@ Item {
spacing: Theme.spacingS spacing: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter 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 { DankActionButton {
id: adminToggleBtn id: adminToggleBtn
readonly property bool actionBlocked: root.operationPending || (userRow.isLastAdmin && userRow.modelData.isAdmin) readonly property bool actionBlocked: root.operationPending || (userRow.isLastAdmin && userRow.modelData.isAdmin)
@@ -380,6 +460,15 @@ Item {
onToggled: checked => root.pendingAdmin = checked 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 { Row {
width: parent.width width: parent.width
spacing: Theme.spacingM spacing: Theme.spacingM
@@ -395,7 +484,7 @@ Item {
return; return;
root.operationPending = true; root.operationPending = true;
root.statusText = ""; root.statusText = "";
UsersService.createUser(root.pendingUsername, root.pendingPassword, root.pendingAdmin, null); UsersService.createUser(root.pendingUsername, root.pendingPassword, root.pendingAdmin, root.pendingGreeter, null);
} }
} }
+1 -1
View File
@@ -404,7 +404,7 @@ Item {
widgetObj.mountPath = "/"; widgetObj.mountPath = "/";
widgetObj.diskUsageMode = 0; 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; widgetObj.minimumWidth = true;
if (widgetId === "memUsage") if (widgetId === "memUsage")
widgetObj.showInGb = false; widgetObj.showInGb = false;
@@ -320,7 +320,7 @@ Column {
DankActionButton { DankActionButton {
id: minimumWidthButton id: minimumWidthButton
buttonSize: 28 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" iconName: "straighten"
iconSize: 16 iconSize: 16
iconColor: (modelData.minimumWidth !== undefined ? modelData.minimumWidth : true) ? Theme.primary : Theme.outline iconColor: (modelData.minimumWidth !== undefined ? modelData.minimumWidth : true) ? Theme.primary : Theme.outline
+59 -17
View File
@@ -19,8 +19,11 @@ Item {
property var parentModal: null property var parentModal: null
property var windowRulesIncludeStatus: ({ property var windowRulesIncludeStatus: ({
"exists": false, "exists": false,
"included": false "included": false,
"configFormat": "",
"readOnly": false
}) })
readonly property bool readOnly: CompositorService.isHyprland && windowRulesIncludeStatus.readOnly === true
property bool checkingInclude: false property bool checkingInclude: false
property bool fixingInclude: false property bool fixingInclude: false
property var windowRules: [] property var windowRules: []
@@ -84,7 +87,9 @@ Item {
if (result.dmsStatus) { if (result.dmsStatus) {
windowRulesIncludeStatus = { windowRulesIncludeStatus = {
"exists": result.dmsStatus.exists, "exists": result.dmsStatus.exists,
"included": result.dmsStatus.included "included": result.dmsStatus.included,
"configFormat": result.dmsStatus.configFormat ?? "",
"readOnly": result.dmsStatus.readOnly === true
}; };
} }
} catch (e) { } catch (e) {
@@ -94,6 +99,10 @@ Item {
} }
function removeRule(ruleId) { function removeRule(ruleId) {
if (readOnly) {
showHyprlandReadOnlyWarning();
return;
}
const compositor = CompositorService.compositor; const compositor = CompositorService.compositor;
if (compositor !== "niri" && compositor !== "hyprland") if (compositor !== "niri" && compositor !== "hyprland")
return; return;
@@ -107,6 +116,10 @@ Item {
} }
function reorderRules(fromIndex, toIndex) { function reorderRules(fromIndex, toIndex) {
if (readOnly) {
showHyprlandReadOnlyWarning();
return;
}
if (fromIndex === toIndex) if (fromIndex === toIndex)
return; return;
@@ -131,7 +144,9 @@ Item {
if (compositor !== "niri" && compositor !== "hyprland") { if (compositor !== "niri" && compositor !== "hyprland") {
windowRulesIncludeStatus = { windowRulesIncludeStatus = {
"exists": false, "exists": false,
"included": false "included": false,
"configFormat": "",
"readOnly": false
}; };
return; return;
} }
@@ -143,7 +158,9 @@ Item {
if (exitCode !== 0) { if (exitCode !== 0) {
windowRulesIncludeStatus = { windowRulesIncludeStatus = {
"exists": false, "exists": false,
"included": false "included": false,
"configFormat": "",
"readOnly": false
}; };
return; return;
} }
@@ -152,13 +169,19 @@ Item {
} catch (e) { } catch (e) {
windowRulesIncludeStatus = { windowRulesIncludeStatus = {
"exists": false, "exists": false,
"included": false "included": false,
"configFormat": "",
"readOnly": false
}; };
} }
}); });
} }
function fixWindowRulesInclude() { function fixWindowRulesInclude() {
if (readOnly) {
showHyprlandReadOnlyWarning();
return;
}
const paths = getWindowRulesConfigPaths(); const paths = getWindowRulesConfigPaths();
if (!paths) if (!paths)
return; return;
@@ -182,6 +205,10 @@ Item {
} }
function openRuleModal(window) { function openRuleModal(window) {
if (readOnly) {
showHyprlandReadOnlyWarning();
return;
}
if (!PopoutService.windowRuleModalLoader) if (!PopoutService.windowRuleModalLoader)
return; return;
PopoutService.windowRuleModalLoader.active = true; PopoutService.windowRuleModalLoader.active = true;
@@ -192,6 +219,10 @@ Item {
} }
function editRule(rule) { function editRule(rule) {
if (readOnly) {
showHyprlandReadOnlyWarning();
return;
}
if (!PopoutService.windowRuleModalLoader) if (!PopoutService.windowRuleModalLoader)
return; return;
PopoutService.windowRuleModalLoader.active = true; 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: { Component.onCompleted: {
if (CompositorService.isNiri || CompositorService.isHyprland) { if (CompositorService.isNiri || CompositorService.isHyprland) {
checkWindowRulesIncludeStatus(); checkWindowRulesIncludeStatus();
@@ -274,6 +309,8 @@ Item {
iconName: "add" iconName: "add"
iconSize: Theme.iconSize iconSize: Theme.iconSize
iconColor: Theme.primary iconColor: Theme.primary
enabled: !root.readOnly
opacity: enabled ? 1 : 0.5
onClicked: root.openRuleModal() onClicked: root.openRuleModal()
} }
} }
@@ -322,13 +359,14 @@ Item {
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
radius: Theme.cornerRadius radius: Theme.cornerRadius
readonly property bool showError: root.windowRulesIncludeStatus.exists && !root.windowRulesIncludeStatus.included readonly property bool showLegacy: root.readOnly
readonly property bool showSetup: !root.windowRulesIncludeStatus.exists && !root.windowRulesIncludeStatus.included 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" color: (showLegacy || showError || showSetup) ? Theme.withAlpha(Theme.warning, 0.15) : "transparent"
border.color: (showError || showSetup) ? Theme.withAlpha(Theme.warning, 0.3) : "transparent" border.color: (showLegacy || showError || showSetup) ? Theme.withAlpha(Theme.warning, 0.3) : "transparent"
border.width: 1 border.width: 1
visible: (showError || showSetup) && !root.checkingInclude && (CompositorService.isNiri || CompositorService.isHyprland) visible: (showLegacy || showError || showSetup) && !root.checkingInclude && (CompositorService.isNiri || CompositorService.isHyprland)
Row { Row {
id: warningSection id: warningSection
@@ -349,7 +387,7 @@ Item {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
StyledText { 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.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium font.weight: Font.Medium
color: Theme.warning color: Theme.warning
@@ -359,7 +397,7 @@ Item {
StyledText { StyledText {
readonly property string rulesFile: CompositorService.isNiri ? "dms/windowrules.kdl" : "dms/windowrules.lua" 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 font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText color: Theme.surfaceVariantText
wrapMode: Text.WordWrap wrapMode: Text.WordWrap
@@ -370,7 +408,7 @@ Item {
DankButton { DankButton {
id: fixButton 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")) text: root.fixingInclude ? I18n.tr("Fixing...") : (warningBox.showSetup ? I18n.tr("Setup") : I18n.tr("Fix Now"))
backgroundColor: Theme.warning backgroundColor: Theme.warning
textColor: Theme.background textColor: Theme.background
@@ -611,6 +649,8 @@ Item {
iconSize: 16 iconSize: 16
backgroundColor: "transparent" backgroundColor: "transparent"
iconColor: Theme.surfaceVariantText iconColor: Theme.surfaceVariantText
enabled: !root.readOnly
opacity: enabled ? 1 : 0.5
onClicked: root.editRule(ruleDelegateItem.liveRuleData) onClicked: root.editRule(ruleDelegateItem.liveRuleData)
} }
@@ -621,12 +661,14 @@ Item {
iconSize: 16 iconSize: 16
backgroundColor: "transparent" backgroundColor: "transparent"
iconColor: deleteArea.containsMouse ? Theme.error : Theme.surfaceVariantText iconColor: deleteArea.containsMouse ? Theme.error : Theme.surfaceVariantText
enabled: !root.readOnly
opacity: enabled ? 1 : 0.5
MouseArea { MouseArea {
id: deleteArea id: deleteArea
anchors.fill: parent anchors.fill: parent
hoverEnabled: true hoverEnabled: !root.readOnly
cursorShape: Qt.PointingHandCursor cursorShape: root.readOnly ? Qt.ArrowCursor : Qt.PointingHandCursor
onClicked: root.removeRule(ruleDelegateItem.ruleIdRef) onClicked: root.removeRule(ruleDelegateItem.ruleIdRef)
} }
} }
@@ -641,8 +683,8 @@ Item {
width: 40 width: 40
height: ruleCard.height height: ruleCard.height
hoverEnabled: true hoverEnabled: true
cursorShape: Qt.SizeVerCursor cursorShape: root.readOnly ? Qt.ArrowCursor : Qt.SizeVerCursor
drag.target: ruleDelegateItem.held ? ruleDelegateItem : undefined drag.target: !root.readOnly && ruleDelegateItem.held ? ruleDelegateItem : undefined
drag.axis: Drag.YAxis drag.axis: Drag.YAxis
preventStealing: true preventStealing: true
+4 -4
View File
@@ -50,8 +50,8 @@ PanelWindow {
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
color: "transparent" 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 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: toastContent.height + Theme.spacingL * 2 readonly property real toastHeight: Theme.px(toastContent.height + Theme.spacingL * 2, dpr)
anchors { anchors {
top: true top: true
@@ -63,8 +63,8 @@ PanelWindow {
top: Math.max(0, Theme.snap(toastY - shadowBuffer, dpr)) top: Math.max(0, Theme.snap(toastY - shadowBuffer, dpr))
} }
implicitWidth: toastWidth + (shadowBuffer * 2) implicitWidth: Theme.px(toastWidth + (shadowBuffer * 2), dpr)
implicitHeight: toastHeight + (shadowBuffer * 2) implicitHeight: Theme.px(toastHeight + (shadowBuffer * 2), dpr)
Rectangle { Rectangle {
id: toast id: toast
@@ -34,6 +34,8 @@ Scope {
WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.exclusiveZone: -1 WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: { WlrLayershell.keyboardFocus: {
if (PopoutManager.screenshotActive)
return WlrKeyboardFocus.None;
if (!overviewScope.overviewOpen) if (!overviewScope.overviewOpen)
return WlrKeyboardFocus.None; return WlrKeyboardFocus.None;
if (CompositorService.useHyprlandFocusGrab) if (CompositorService.useHyprlandFocusGrab)
@@ -124,6 +124,8 @@ Scope {
WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.exclusiveZone: -1 WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: { WlrLayershell.keyboardFocus: {
if (PopoutManager.screenshotActive)
return WlrKeyboardFocus.None;
if (!NiriService.inOverview) if (!NiriService.inOverview)
return WlrKeyboardFocus.None; return WlrKeyboardFocus.None;
if (!isActiveScreen) if (!isActiveScreen)
+8 -11
View File
@@ -236,19 +236,16 @@ Singleton {
readonly property bool suggestPowerSaver: false readonly property bool suggestPowerSaver: false
readonly property var bluetoothDevices: { readonly property var bluetoothDevices: {
const btDevices = [];
const bluetoothTypes = [UPowerDeviceType.BluetoothGeneric, UPowerDeviceType.Headphones, UPowerDeviceType.Headset, UPowerDeviceType.Keyboard, UPowerDeviceType.Mouse, UPowerDeviceType.Speakers]; const bluetoothTypes = [UPowerDeviceType.BluetoothGeneric, UPowerDeviceType.Headphones, UPowerDeviceType.Headset, UPowerDeviceType.Keyboard, UPowerDeviceType.Mouse, UPowerDeviceType.Speakers];
for (var i = 0; i < UPower.devices.count; i++) { const btDevices = UPower.devices.values.filter(dev => dev && dev.ready && bluetoothTypes.includes(dev.type)).map(dev => {
const dev = UPower.devices.get(i); return {
if (dev && dev.ready && bluetoothTypes.includes(dev.type)) { "name": dev.model || UPowerDeviceType.toString(dev.type),
btDevices.push({ "percentage": Math.round(dev.percentage * 100),
"name": dev.model || UPowerDeviceType.toString(dev.type), "type": dev.type
"percentage": Math.round(dev.percentage * 100), };
"type": dev.type });
});
}
}
return btDevices; return btDevices;
} }
+16 -3
View File
@@ -68,15 +68,17 @@ Singleton {
clipboardEntries = filtered; clipboardEntries = filtered;
unpinnedEntries = filtered.filter(e => !e.pinned); unpinnedEntries = filtered.filter(e => !e.pinned);
pinnedEntries = filtered.filter(e => e.pinned);
totalCount = clipboardEntries.length; totalCount = clipboardEntries.length;
if (unpinnedEntries.length === 0) { const activeCount = Math.max(unpinnedEntries.length, pinnedEntries.length);
if (activeCount === 0) {
keyboardNavigationActive = false; keyboardNavigationActive = false;
selectedIndex = 0; selectedIndex = 0;
return; return;
} }
if (selectedIndex >= unpinnedEntries.length) { if (selectedIndex >= activeCount) {
selectedIndex = unpinnedEntries.length - 1; 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) { function pasteEntry(entry, closeCallback) {
if (!wtypeAvailable) { if (!wtypeAvailable) {
ToastService.showError(I18n.tr("wtype not available - install wtype for paste support")); ToastService.showError(I18n.tr("wtype not available - install wtype for paste support"));
+163
View File
@@ -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);
}
}
+93 -14
View File
@@ -18,17 +18,35 @@ Singleton {
readonly property string layoutPath: hyprDmsDir + "/layout.lua" readonly property string layoutPath: hyprDmsDir + "/layout.lua"
readonly property string cursorPath: hyprDmsDir + "/cursor.lua" readonly property string cursorPath: hyprDmsDir + "/cursor.lua"
readonly property string windowrulesPath: hyprDmsDir + "/windowrules.lua" readonly property string windowrulesPath: hyprDmsDir + "/windowrules.lua"
readonly property bool luaConfigActive: CompositorService.isHyprland && (Hyprland.usingLua === true || luaConfigDetected)
property int _lastGapValue: -1 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: { Component.onCompleted: {
if (CompositorService.isHyprland) { if (CompositorService.isHyprland) {
Qt.callLater(generateLayoutConfig); refreshLuaConfigStatus();
ensureWindowrulesConfig(); if (luaConfigActive)
ensureDmsLuaConfigs();
} }
} }
function ensureDmsLuaConfigs() {
Qt.callLater(generateLayoutConfig);
Qt.callLater(ensureWindowrulesConfig);
}
function ensureWindowrulesConfig() { function ensureWindowrulesConfig() {
if (!canWriteLuaConfig("windowrules"))
return;
Proc.runCommand("hypr-ensure-windowrules", ["sh", "-c", `mkdir -p "${hyprDmsDir}" && [ ! -f "${windowrulesPath}" ] && touch "${windowrulesPath}" || true`], (output, exitCode) => { Proc.runCommand("hypr-ensure-windowrules", ["sh", "-c", `mkdir -p "${hyprDmsDir}" && [ ! -f "${windowrulesPath}" ] && touch "${windowrulesPath}" || true`], (output, exitCode) => {
if (exitCode !== 0) if (exitCode !== 0)
log.warn("Failed to ensure windowrules.lua:", output); log.warn("Failed to ensure windowrules.lua:", output);
@@ -51,14 +69,22 @@ Singleton {
Connections { Connections {
target: CompositorService target: CompositorService
function onIsHyprlandChanged() { function onIsHyprlandChanged() {
if (CompositorService.isHyprland) if (CompositorService.isHyprland) {
generateLayoutConfig(); refreshLuaConfigStatus();
if (luaConfigActive)
ensureDmsLuaConfigs();
return;
}
luaConfigDetected = false;
luaConfigStatusReady = false;
luaConfigStatusLoading = false;
luaConfigFormat = "";
} }
} }
function getOutputIdentifier(output, outputName) { function getOutputIdentifier(output, outputName) {
if (SettingsData.displayNameMode === "model" && output.make && output.model) 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; return outputName;
} }
@@ -66,6 +92,50 @@ Singleton {
return JSON.stringify(String(str ?? "")); 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) { function forceFlagValue(value) {
if (value === true) if (value === true)
return 1; return 1;
@@ -75,6 +145,11 @@ Singleton {
} }
function generateOutputsConfig(outputsData, hyprlandSettings, callback) { function generateOutputsConfig(outputsData, hyprlandSettings, callback) {
if (!canWriteLuaConfig("outputs")) {
if (callback)
callback(false);
return;
}
if (!outputsData || Object.keys(outputsData).length === 0) { if (!outputsData || Object.keys(outputsData).length === 0) {
if (callback) if (callback)
callback(false); callback(false);
@@ -172,6 +247,8 @@ Singleton {
function generateLayoutConfig() { function generateLayoutConfig() {
if (!CompositorService.isHyprland) if (!CompositorService.isHyprland)
return; return;
if (!canWriteLuaConfig("layout"))
return;
const defaultRadius = typeof SettingsData !== "undefined" ? SettingsData.cornerRadius : 12; const defaultRadius = typeof SettingsData !== "undefined" ? SettingsData.cornerRadius : 12;
const defaultGaps = typeof SettingsData !== "undefined" ? Math.max(4, (SettingsData.barConfigs[0]?.spacing ?? 4)) : 4; const defaultGaps = typeof SettingsData !== "undefined" ? Math.max(4, (SettingsData.barConfigs[0]?.spacing ?? 4)) : 4;
@@ -254,6 +331,8 @@ hl.config({
function generateCursorConfig() { function generateCursorConfig() {
if (!CompositorService.isHyprland) if (!CompositorService.isHyprland)
return; return;
if (!canWriteLuaConfig("cursor"))
return;
const settings = typeof SettingsData !== "undefined" ? SettingsData.cursorSettings : null; const settings = typeof SettingsData !== "undefined" ? SettingsData.cursorSettings : null;
if (!settings) { if (!settings) {
@@ -326,7 +405,7 @@ hl.config({
if (!wsId) if (!wsId)
return; return;
const fullName = wsId + " " + newName; const fullName = wsId + " " + newName;
if (Hyprland.usingLua) { if (luaConfigActive) {
Hyprland.dispatch(`hl.dsp.workspace.rename({ workspace = ${luaValue(wsId)}, name = ${luaString(fullName)} })`); Hyprland.dispatch(`hl.dsp.workspace.rename({ workspace = ${luaValue(wsId)}, name = ${luaString(fullName)} })`);
} else { } else {
Hyprland.dispatch(`renameworkspace ${wsId} ${fullName}`); Hyprland.dispatch(`renameworkspace ${wsId} ${fullName}`);
@@ -334,7 +413,7 @@ hl.config({
} }
function focusWorkspace(workspace) { function focusWorkspace(workspace) {
if (Hyprland.usingLua) { if (luaConfigActive) {
Hyprland.dispatch(`hl.dsp.focus({ workspace = ${luaValue(workspace)} })`); Hyprland.dispatch(`hl.dsp.focus({ workspace = ${luaValue(workspace)} })`);
} else { } else {
Hyprland.dispatch(`workspace ${workspace}`); Hyprland.dispatch(`workspace ${workspace}`);
@@ -366,7 +445,7 @@ hl.config({
if (!selector) if (!selector)
return; return;
if (Hyprland.usingLua) { if (luaConfigActive) {
Hyprland.dispatch(`hl.dsp.focus({ window = ${luaString(selector)} })`); Hyprland.dispatch(`hl.dsp.focus({ window = ${luaString(selector)} })`);
} else { } else {
Hyprland.dispatch(`focuswindow ${selector}`); Hyprland.dispatch(`focuswindow ${selector}`);
@@ -378,7 +457,7 @@ hl.config({
if (!selector) if (!selector)
return; return;
if (Hyprland.usingLua) { if (luaConfigActive) {
Hyprland.dispatch(`hl.dsp.window.close(${luaString(selector)})`); Hyprland.dispatch(`hl.dsp.window.close(${luaString(selector)})`);
} else { } else {
Hyprland.dispatch(`closewindow ${selector}`); Hyprland.dispatch(`closewindow ${selector}`);
@@ -390,7 +469,7 @@ hl.config({
if (!selector) if (!selector)
return; return;
if (Hyprland.usingLua) { if (luaConfigActive) {
Hyprland.dispatch(`hl.dsp.window.move({ workspace = ${luaValue(workspace)}, window = ${luaString(selector)}, follow = ${follow ? "true" : "false"} })`); Hyprland.dispatch(`hl.dsp.window.move({ workspace = ${luaValue(workspace)}, window = ${luaString(selector)}, follow = ${follow ? "true" : "false"} })`);
} else { } else {
const dispatcher = follow ? "movetoworkspace" : "movetoworkspacesilent"; const dispatcher = follow ? "movetoworkspace" : "movetoworkspacesilent";
@@ -399,7 +478,7 @@ hl.config({
} }
function toggleSpecial(specialName) { function toggleSpecial(specialName) {
if (Hyprland.usingLua) { if (luaConfigActive) {
Hyprland.dispatch(`hl.dsp.workspace.toggle_special(${luaString(specialName)})`); Hyprland.dispatch(`hl.dsp.workspace.toggle_special(${luaString(specialName)})`);
} else { } else {
Hyprland.dispatch("togglespecialworkspace " + specialName); Hyprland.dispatch("togglespecialworkspace " + specialName);
@@ -407,7 +486,7 @@ hl.config({
} }
function exit() { function exit() {
if (Hyprland.usingLua) { if (luaConfigActive) {
Hyprland.dispatch("hl.dsp.exit()"); Hyprland.dispatch("hl.dsp.exit()");
} else { } else {
Hyprland.dispatch("exit"); Hyprland.dispatch("exit");
@@ -415,7 +494,7 @@ hl.config({
} }
function dpmsOff() { function dpmsOff() {
if (Hyprland.usingLua) { if (luaConfigActive) {
Hyprland.dispatch(`hl.dsp.dpms({ action = "disable" })`); Hyprland.dispatch(`hl.dsp.dpms({ action = "disable" })`);
} else { } else {
Hyprland.dispatch("dpms off"); Hyprland.dispatch("dpms off");
@@ -423,7 +502,7 @@ hl.config({
} }
function dpmsOn() { function dpmsOn() {
if (Hyprland.usingLua) { if (luaConfigActive) {
Hyprland.dispatch(`hl.dsp.dpms({ action = "enable" })`); Hyprland.dispatch(`hl.dsp.dpms({ action = "enable" })`);
} else { } else {
Hyprland.dispatch("dpms on"); Hyprland.dispatch("dpms on");
+33 -3
View File
@@ -52,7 +52,9 @@ Singleton {
"bindsAfterDms": 0, "bindsAfterDms": 0,
"effective": true, "effective": true,
"overriddenBy": 0, "overriddenBy": 0,
"statusMessage": "" "statusMessage": "",
"configFormat": "",
"readOnly": false
}) })
property var _rawData: null property var _rawData: null
@@ -102,6 +104,7 @@ Singleton {
return ""; return "";
} }
} }
readonly property bool readOnly: currentProvider === "hyprland" && dmsStatus.readOnly === true
readonly property var actionTypes: Actions.getActionTypes() readonly property var actionTypes: Actions.getActionTypes()
readonly property var dmsActions: getDmsActions() readonly property var dmsActions: getDmsActions()
@@ -258,6 +261,10 @@ Singleton {
function fixDmsBindsInclude() { function fixDmsBindsInclude() {
if (fixing || dmsBindsIncluded || !compositorConfigDir) if (fixing || dmsBindsIncluded || !compositorConfigDir)
return; return;
if (readOnly) {
showHyprlandReadOnlyWarning();
return;
}
fixing = true; fixing = true;
const timestamp = Math.floor(Date.now() / 1000); const timestamp = Math.floor(Date.now() / 1000);
const backupPath = `${mainConfigPath}.dmsbackup${timestamp}`; const backupPath = `${mainConfigPath}.dmsbackup${timestamp}`;
@@ -343,7 +350,9 @@ Singleton {
"bindsAfterDms": status.bindsAfterDms ?? 0, "bindsAfterDms": status.bindsAfterDms ?? 0,
"effective": status.effective ?? true, "effective": status.effective ?? true,
"overriddenBy": status.overriddenBy ?? 0, "overriddenBy": status.overriddenBy ?? 0,
"statusMessage": status.statusMessage ?? "" "statusMessage": status.statusMessage ?? "",
"configFormat": status.configFormat ?? "",
"readOnly": status.readOnly === true
}; };
} }
_maybeWarnHyprlandLegacyConf(); _maybeWarnHyprlandLegacyConf();
@@ -482,6 +491,10 @@ Singleton {
} }
function saveBind(originalKey, bindData) { function saveBind(originalKey, bindData) {
if (readOnly) {
showHyprlandReadOnlyWarning();
return;
}
if (!bindData.key || !Actions.isValidAction(bindData.action)) if (!bindData.key || !Actions.isValidAction(bindData.action))
return; return;
saving = true; saving = true;
@@ -510,13 +523,26 @@ Singleton {
return; return;
if (currentProvider !== "hyprland") if (currentProvider !== "hyprland")
return; return;
if (readOnly) {
_hyprlandLegacyWarnShown = true;
showHyprlandReadOnlyWarning();
return;
}
if (!dmsStatus.exists || dmsStatus.included) if (!dmsStatus.exists || dmsStatus.included)
return; return;
_hyprlandLegacyWarnShown = true; _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) { function removeBind(key) {
if (readOnly) {
showHyprlandReadOnlyWarning();
return;
}
if (!key) if (!key)
return; return;
removeProcess.command = ["dms", "keybinds", "remove", currentProvider, key]; removeProcess.command = ["dms", "keybinds", "remove", currentProvider, key];
@@ -525,6 +551,10 @@ Singleton {
} }
function resetBind(key) { function resetBind(key) {
if (readOnly) {
showHyprlandReadOnlyWarning();
return;
}
if (!key) if (!key)
return; return;
removeProcess.command = ["dms", "keybinds", "reset", currentProvider, key]; removeProcess.command = ["dms", "keybinds", "reset", currentProvider, key];
+1 -1
View File
@@ -7,7 +7,7 @@ import Quickshell
Singleton { Singleton {
id: root 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 readonly property bool valid: latitude !== 0 || longitude !== 0
property var latitude: 0.0 property var latitude: 0.0
+38 -4
View File
@@ -11,6 +11,33 @@ Singleton {
readonly property list<MprisPlayer> availablePlayers: Mpris.players.values readonly property list<MprisPlayer> availablePlayers: Mpris.players.values
property MprisPlayer activePlayer: null 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() onAvailablePlayersChanged: _resolveActivePlayer()
Component.onCompleted: _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 { function _resolveActivePlayer(): void {
const playing = availablePlayers.find(p => p.isPlaying); const playing = availablePlayers.find(p => p.isPlaying);
if (playing) { if (playing) {
@@ -34,17 +68,17 @@ Singleton {
_persistIdentity(playing.identity); _persistIdentity(playing.identity);
return; return;
} }
if (activePlayer && availablePlayers.indexOf(activePlayer) >= 0) if (activePlayer && availablePlayers.indexOf(activePlayer) >= 0 && !isIdle(activePlayer))
return; return;
const savedId = SessionData.lastPlayerIdentity; const savedId = SessionData.lastPlayerIdentity;
if (savedId) { if (savedId) {
const match = availablePlayers.find(p => p.identity === savedId); const match = availablePlayers.find(p => p.identity === savedId);
if (match) { if (match && !isIdle(match)) {
activePlayer = match; activePlayer = match;
return; return;
} }
} }
activePlayer = availablePlayers.find(p => p.canControl && p.canPlay) ?? null; activePlayer = availablePlayers.find(p => p.canControl && !isIdle(p)) ?? null;
if (activePlayer) if (activePlayer)
_persistIdentity(activePlayer.identity); _persistIdentity(activePlayer.identity);
} }
@@ -81,7 +115,7 @@ Singleton {
if (!activePlayer) if (!activePlayer)
return; return;
if (activePlayer.position > 8 && activePlayer.canSeek) if (activePlayer.position > 8 && activePlayer.canSeek)
activePlayer.position = 0; activePlayer.position = 0.1;
else if (activePlayer.canGoPrevious) else if (activePlayer.canGoPrevious)
activePlayer.previous(); activePlayer.previous();
} }
+36
View File
@@ -50,6 +50,8 @@ Singleton {
property var bluetoothPairingModal: null property var bluetoothPairingModal: null
property var networkInfoModal: null property var networkInfoModal: null
property var windowRuleModalLoader: null property var windowRuleModalLoader: null
property var powerProfileModal: null
property var powerProfileModalLoader: null
property var notepadSlideouts: [] 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() { function showProcessListModal() {
if (processListModal) { if (processListModal) {
processListModal.show(); processListModal.show();
+15 -1
View File
@@ -239,11 +239,23 @@ Singleton {
}); });
} }
property string pendingGreeterProfileUser: ""
function getGreeterUserProfileImage(username) { function getGreeterUserProfileImage(username) {
if (!username) { if (!username) {
profileImage = ""; profileImage = "";
pendingGreeterProfileUser = "";
return; 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.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; userProfileCheckProcess.running = true;
} }
@@ -261,12 +273,14 @@ Singleton {
} else { } else {
root.profileImage = ""; root.profileImage = "";
} }
root.pendingGreeterProfileUser = "";
} }
} }
onExited: exitCode => { onExited: exitCode => {
if (exitCode !== 0) { if (exitCode !== 0 && root.pendingGreeterProfileUser !== "") {
root.profileImage = ""; root.profileImage = "";
root.pendingGreeterProfileUser = "";
} }
} }
} }
@@ -11,8 +11,68 @@ Singleton {
property int currentProfile: -1 property int currentProfile: -1
property int previousProfile: -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) 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 { Connections {
target: typeof PowerProfiles !== "undefined" ? PowerProfiles : null target: typeof PowerProfiles !== "undefined" ? PowerProfiles : null
+69 -15
View File
@@ -20,6 +20,18 @@ Singleton {
property bool indexLoaded: false property bool indexLoaded: false
property var _translatedCache: [] property var _translatedCache: []
Connections {
target: I18n
function onTranslationsChanged() {
root._refreshTranslatedCache();
}
function onTranslationsLoadedChanged() {
root._refreshTranslatedCache();
}
}
readonly property var conditionMap: ({ readonly property var conditionMap: ({
"isNiri": () => CompositorService.isNiri, "isNiri": () => CompositorService.isNiri,
"isHyprland": () => CompositorService.isHyprland, "isHyprland": () => CompositorService.isHyprland,
@@ -143,6 +155,7 @@ Singleton {
for (var i = 0; i < settingsIndex.length; i++) { for (var i = 0; i < settingsIndex.length; i++) {
var item = settingsIndex[i]; var item = settingsIndex[i];
var t = translateItem(item); var t = translateItem(item);
var sourceDescription = item.description || "";
cache.push({ cache.push({
section: t.section, section: t.section,
label: t.label, label: t.label,
@@ -152,13 +165,58 @@ Singleton {
icon: t.icon, icon: t.icon,
description: t.description, description: t.description,
conditionKey: t.conditionKey, conditionKey: t.conditionKey,
labelLower: t.label.toLowerCase(), labelSearch: _lowerVariants([item.label, t.label]),
categoryLower: t.category.toLowerCase() categorySearch: _lowerVariants([item.category, t.category]),
descriptionSearch: _lowerVariants([sourceDescription, t.description])
}); });
} }
_translatedCache = cache; _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) { function _searchEntries(text, maxResults) {
if (!text) if (!text)
return []; return [];
@@ -174,19 +232,11 @@ Singleton {
if (!checkCondition(entry)) if (!checkCondition(entry))
continue; continue;
var labelLower = entry.labelLower;
var categoryLower = entry.categoryLower;
var score = 0; var score = 0;
if (labelLower === queryLower) { score = Math.max(score, _bestFieldScore(entry.labelSearch, queryLower, 10000, 5000, 1000));
score = 10000; score = Math.max(score, _bestFieldScore(entry.categorySearch, queryLower, 500, 500, 500));
} else if (labelLower.startsWith(queryLower)) { score = Math.max(score, _bestFieldScore(entry.descriptionSearch, queryLower, 250, 250, 250));
score = 5000;
} else if (labelLower.includes(queryLower)) {
score = 1000;
} else if (categoryLower.includes(queryLower)) {
score = 500;
}
if (score === 0) { if (score === 0) {
var keywords = entry.keywords; var keywords = entry.keywords;
@@ -205,7 +255,11 @@ Singleton {
var allMatch = true; var allMatch = true;
for (var w = 0; w < queryWords.length; w++) { for (var w = 0; w < queryWords.length; w++) {
var word = queryWords[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; continue;
var inKeywords = false; var inKeywords = false;
for (var k = 0; k < entry.keywords.length; k++) { for (var k = 0; k < entry.keywords.length; k++) {
@@ -214,7 +268,7 @@ Singleton {
break; break;
} }
} }
if (!inKeywords && !categoryLower.includes(word)) { if (!inKeywords) {
allMatch = false; allMatch = false;
break; break;
} }
+123 -8
View File
@@ -10,12 +10,53 @@ Singleton {
id: root id: root
property string _lastArtUrl: "" property string _lastArtUrl: ""
property string _bgArtSource: "" property string resolvedArtUrl: ""
property alias _bgArtSource: root.resolvedArtUrl
property bool loading: false 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) { function loadArtwork(url) {
if (!url || url === "") { if (!url || url === "") {
_bgArtSource = ""; resolvedArtUrl = "";
_lastArtUrl = ""; _lastArtUrl = "";
loading = false; loading = false;
return; return;
@@ -25,25 +66,99 @@ Singleton {
_lastArtUrl = url; _lastArtUrl = url;
if (url.startsWith("http://") || url.startsWith("https://")) { if (url.startsWith("http://") || url.startsWith("https://")) {
_bgArtSource = url; loading = true;
loading = false; 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; return;
} }
loading = true; loading = true;
resolvedArtUrl = ""; // Clear stale artwork immediately while verifying local file
const localUrl = url; const localUrl = url;
const filePath = url.startsWith("file://") ? url.substring(7) : 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) if (_lastArtUrl !== localUrl)
return; return;
_bgArtSource = exitCode === 0 ? localUrl : ""; resolvedArtUrl = exitCode === 0 ? localUrl : "";
loading = false; loading = false;
}, 200); }, 200);
} }
property MprisPlayer activePlayer: MprisController.activePlayer property MprisPlayer activePlayer: MprisController.activePlayer
onActivePlayerChanged: { onActivePlayerChanged: _updateArtUrl()
loadArtwork(activePlayer?.trackArtUrl ?? "");
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);
} }
} }
+120 -16
View File
@@ -12,7 +12,9 @@ Singleton {
property var users: [] property var users: []
property string adminGroup: "wheel" property string adminGroup: "wheel"
property string greeterGroup: "greeter"
property var adminMembers: [] property var adminMembers: []
property var greeterMembers: []
property bool refreshing: false property bool refreshing: false
signal operationCompleted(string op, string username, bool success, string message) 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) => { 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); const members = (output || "").trim().split(",").map(s => s.trim()).filter(s => s.length > 0);
root.adminMembers = members; 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(); _loadUsers();
}, 0); }, 0);
} }
function _loadUsers() { 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 lines = (output || "").trim().split("\n").filter(l => l.length > 0);
const list = []; const list = [];
const adminSet = {}; const adminSet = {};
const greeterSet = {};
for (let i = 0; i < root.adminMembers.length; i++) for (let i = 0; i < root.adminMembers.length; i++)
adminSet[root.adminMembers[i]] = true; 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++) { for (let i = 0; i < lines.length; i++) {
const parts = lines[i].split(":"); const parts = lines[i].split(":");
@@ -92,7 +112,8 @@ Singleton {
gecos: (parts[2] || "").split(",")[0], gecos: (parts[2] || "").split(",")[0],
home: parts[3] || "", home: parts[3] || "",
shell: parts[4] || "", shell: parts[4] || "",
isAdmin: adminSet[username] === true isAdmin: adminSet[username] === true,
isGreeter: greeterSet[username] === true
}); });
} }
list.sort((a, b) => a.username.localeCompare(b.username)); list.sort((a, b) => a.username.localeCompare(b.username));
@@ -101,7 +122,7 @@ Singleton {
}, 0); }, 0);
} }
function createUser(username, password, addToAdmin, callback) { function createUser(username, password, addToAdmin, addToGreeter, callback) {
if (!isValidUsername(username)) { if (!isValidUsername(username)) {
_emit("create", username, false, I18n.tr("Invalid username"), callback); _emit("create", username, false, I18n.tr("Invalid username"), callback);
return; return;
@@ -114,7 +135,7 @@ Singleton {
_emit("create", username, false, I18n.tr("User already exists"), callback); _emit("create", username, false, I18n.tr("User already exists"), callback);
return; return;
} }
_runUseradd(username, password, addToAdmin === true, callback); _runUseradd(username, password, addToAdmin === true, addToGreeter === true, callback);
} }
function setPassword(username, newPassword, callback) { function setPassword(username, newPassword, callback) {
@@ -156,6 +177,55 @@ Singleton {
_runAdminToggle(username, makeAdmin === true, callback); _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) { function _emit(op, username, success, message, callback) {
root.operationCompleted(op, username, success, message); root.operationCompleted(op, username, success, message);
if (typeof callback === "function") { if (typeof callback === "function") {
@@ -174,6 +244,7 @@ Singleton {
property string targetUser: "" property string targetUser: ""
property string targetPassword: "" property string targetPassword: ""
property bool addAdmin: false property bool addAdmin: false
property bool addGreeter: false
property var cb: null property var cb: null
property string capturedErr: "" property string capturedErr: ""
running: false running: false
@@ -191,6 +262,7 @@ Singleton {
const targetUser = useraddProc.targetUser; const targetUser = useraddProc.targetUser;
const targetPassword = useraddProc.targetPassword; const targetPassword = useraddProc.targetPassword;
const addAdmin = useraddProc.addAdmin; const addAdmin = useraddProc.addAdmin;
const addGreeter = useraddProc.addGreeter;
const outerCb = useraddProc.cb; const outerCb = useraddProc.cb;
Qt.callLater(() => useraddProc.destroy()); Qt.callLater(() => useraddProc.destroy());
@@ -199,17 +271,7 @@ Singleton {
svc._emit("create", targetUser, false, pwMsg, outerCb); svc._emit("create", targetUser, false, pwMsg, outerCb);
return; return;
} }
if (addAdmin) { svc._finishCreateUser(targetUser, addAdmin, addGreeter, outerCb);
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);
}
}); });
} }
} }
@@ -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 { Component {
id: adminToggleComp id: adminToggleComp
Process { Process {
@@ -320,12 +412,13 @@ Singleton {
} }
} }
function _runUseradd(username, password, addToAdmin, callback) { function _runUseradd(username, password, addToAdmin, addToGreeter, callback) {
const proc = useraddComp.createObject(root, { const proc = useraddComp.createObject(root, {
command: ["pkexec", "useradd", "-m", "-s", "/bin/bash", username], command: ["pkexec", "useradd", "-m", "-s", "/bin/bash", username],
targetUser: username, targetUser: username,
targetPassword: password, targetPassword: password,
addAdmin: addToAdmin, addAdmin: addToAdmin,
addGreeter: addToGreeter,
cb: callback cb: callback
}); });
proc.running = true; proc.running = true;
@@ -361,5 +454,16 @@ Singleton {
proc.running = true; 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() Component.onCompleted: refresh()
} }
+6 -2
View File
@@ -8,15 +8,19 @@ Item {
id: root id: root
property MprisPlayer activePlayer property MprisPlayer activePlayer
property string artUrl: (activePlayer?.trackArtUrl) || "" property string artUrl: TrackArtService.resolvedArtUrl
property string lastValidArtUrl: "" property string lastValidArtUrl: ""
property alias albumArtStatus: albumArt.imageStatus property alias albumArtStatus: albumArt.imageStatus
property real albumSize: Math.min(width, height) * 0.88 property real albumSize: Math.min(width, height) * 0.88
property bool showAnimation: true property bool showAnimation: true
property real animationScale: 1.0 property real animationScale: 1.0
onActivePlayerChanged: {
lastValidArtUrl = "";
}
onArtUrlChanged: { onArtUrlChanged: {
if (artUrl && albumArt.status !== Image.Error) { if (artUrl && albumArtStatus !== Image.Error) {
lastValidArtUrl = artUrl; lastValidArtUrl = artUrl;
} }
} }
+28 -24
View File
@@ -58,6 +58,30 @@ Item {
dropdownMenu.close(); 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() { function resetSearch() {
searchField.text = ""; searchField.text = "";
dropdownMenu.fzfFinder = null; dropdownMenu.fzfFinder = null;
@@ -123,27 +147,7 @@ Item {
anchors.fill: parent anchors.fill: parent
hoverEnabled: true hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onClicked: { onClicked: root.openDropdownMenu()
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();
}
} }
Row { Row {
@@ -165,10 +169,10 @@ Item {
} }
StyledText { StyledText {
text: root.currentValue
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter 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) width: contentRow.width - (contentRow.children[0].visible ? contentRow.children[0].width + contentRow.spacing : 0)
elide: Text.ElideRight elide: Text.ElideRight
wrapMode: Text.NoWrap wrapMode: Text.NoWrap
+7 -2
View File
@@ -576,9 +576,11 @@ Item {
property real renderedAlignedY: alignedY property real renderedAlignedY: alignedY
property real renderedAlignedHeight: alignedHeight property real renderedAlignedHeight: alignedHeight
readonly property bool renderedGeometryGrowing: alignedHeight >= renderedAlignedHeight 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 { Behavior on renderedAlignedY {
enabled: root.animationsEnabled && contentWindow.visible && root.shouldBeVisible enabled: root.animationsEnabled && contentWindow.visible && root.shouldBeVisible && !root._settlingToOpen
NumberAnimation { NumberAnimation {
duration: Theme.variantDuration(root.animationDuration, root.renderedGeometryGrowing) duration: Theme.variantDuration(root.animationDuration, root.renderedGeometryGrowing)
easing.type: Easing.BezierSpline easing.type: Easing.BezierSpline
@@ -587,7 +589,7 @@ Item {
} }
Behavior on renderedAlignedHeight { Behavior on renderedAlignedHeight {
enabled: root.animationsEnabled && contentWindow.visible && root.shouldBeVisible enabled: root.animationsEnabled && contentWindow.visible && root.shouldBeVisible && !root._settlingToOpen
NumberAnimation { NumberAnimation {
duration: Theme.variantDuration(root.animationDuration, root.renderedGeometryGrowing) duration: Theme.variantDuration(root.animationDuration, root.renderedGeometryGrowing)
easing.type: Easing.BezierSpline easing.type: Easing.BezierSpline
@@ -749,6 +751,8 @@ Item {
WlrLayershell.layer: root.effectivePopoutLayer WlrLayershell.layer: root.effectivePopoutLayer
WlrLayershell.exclusiveZone: -1 WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: { WlrLayershell.keyboardFocus: {
if (PopoutManager.screenshotActive)
return WlrKeyboardFocus.None;
if (customKeyboardFocus !== null) if (customKeyboardFocus !== null)
return customKeyboardFocus; return customKeyboardFocus;
if (!shouldBeVisible) if (!shouldBeVisible)
@@ -896,6 +900,7 @@ Item {
Behavior on openProgress { Behavior on openProgress {
enabled: root.animationsEnabled enabled: root.animationsEnabled
NumberAnimation { NumberAnimation {
id: morphAnim
duration: Theme.variantDuration(root.animationDuration, root.shouldBeVisible) duration: Theme.variantDuration(root.animationDuration, root.shouldBeVisible)
easing.type: Easing.BezierSpline easing.type: Easing.BezierSpline
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
+7 -2
View File
@@ -407,9 +407,11 @@ Item {
property real renderedAlignedY: alignedY property real renderedAlignedY: alignedY
property real renderedAlignedHeight: alignedHeight property real renderedAlignedHeight: alignedHeight
readonly property bool renderedGeometryGrowing: alignedHeight >= renderedAlignedHeight 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 { Behavior on renderedAlignedY {
enabled: root.animationsEnabled && contentWindow.visible && root.shouldBeVisible enabled: root.animationsEnabled && contentWindow.visible && root.shouldBeVisible && !root._settlingToOpen
NumberAnimation { NumberAnimation {
duration: Theme.variantDuration(root.animationDuration, root.renderedGeometryGrowing) duration: Theme.variantDuration(root.animationDuration, root.renderedGeometryGrowing)
easing.type: Easing.BezierSpline easing.type: Easing.BezierSpline
@@ -418,7 +420,7 @@ Item {
} }
Behavior on renderedAlignedHeight { Behavior on renderedAlignedHeight {
enabled: root.animationsEnabled && contentWindow.visible && root.shouldBeVisible enabled: root.animationsEnabled && contentWindow.visible && root.shouldBeVisible && !root._settlingToOpen
NumberAnimation { NumberAnimation {
duration: Theme.variantDuration(root.animationDuration, root.renderedGeometryGrowing) duration: Theme.variantDuration(root.animationDuration, root.renderedGeometryGrowing)
easing.type: Easing.BezierSpline easing.type: Easing.BezierSpline
@@ -620,6 +622,8 @@ Item {
WlrLayershell.layer: root.effectivePopoutLayer WlrLayershell.layer: root.effectivePopoutLayer
WlrLayershell.exclusiveZone: -1 WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: { WlrLayershell.keyboardFocus: {
if (PopoutManager.screenshotActive)
return WlrKeyboardFocus.None;
if (customKeyboardFocus !== null) if (customKeyboardFocus !== null)
return customKeyboardFocus; return customKeyboardFocus;
if (!shouldBeVisible) if (!shouldBeVisible)
@@ -729,6 +733,7 @@ Item {
Behavior on openProgress { Behavior on openProgress {
enabled: root.animationsEnabled enabled: root.animationsEnabled
NumberAnimation { NumberAnimation {
id: morphAnim
duration: Theme.variantDuration(root.animationDuration, root.shouldBeVisible) duration: Theme.variantDuration(root.animationDuration, root.shouldBeVisible)
easing.type: Easing.BezierSpline easing.type: Easing.BezierSpline
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
+19 -17
View File
@@ -8,12 +8,14 @@ Item {
id: root id: root
property MprisPlayer activePlayer property MprisPlayer activePlayer
readonly property real stableLength: MprisController.activePlayerStableLength
property real seekPreviewRatio: -1 property real seekPreviewRatio: -1
readonly property real playerValue: { readonly property real playerValue: {
if (!activePlayer || activePlayer.length <= 0) if (!activePlayer || stableLength <= 0)
return 0; return 0;
const pos = (activePlayer.position || 0) % Math.max(1, activePlayer.length); const pos = (activePlayer.position || 0) % Math.max(1, stableLength);
const calculatedRatio = pos / activePlayer.length; const calculatedRatio = pos / stableLength;
return Math.max(0, Math.min(1, calculatedRatio)); return Math.max(0, Math.min(1, calculatedRatio));
} }
property real value: seekPreviewRatio >= 0 ? seekPreviewRatio : playerValue property real value: seekPreviewRatio >= 0 ? seekPreviewRatio : playerValue
@@ -29,20 +31,20 @@ Item {
} }
function ratioForPosition(position) { function ratioForPosition(position) {
if (!activePlayer || activePlayer.length <= 0) if (!activePlayer || stableLength <= 0)
return 0; return 0;
return clampRatio(position / activePlayer.length); return clampRatio(position / stableLength);
} }
function positionForRatio(ratio) { function positionForRatio(ratio) {
if (!activePlayer || activePlayer.length <= 0) if (!activePlayer || stableLength <= 0)
return 0; return 0;
const rawPosition = clampRatio(ratio) * activePlayer.length; const rawPosition = clampRatio(ratio) * stableLength;
return Math.min(rawPosition, activePlayer.length * 0.99); return Math.min(rawPosition, stableLength * 0.99);
} }
function updatePreviewFromMouse(mouseX, width) { function updatePreviewFromMouse(mouseX, width) {
if (!activePlayer || activePlayer.length <= 0 || width <= 0) if (!activePlayer || stableLength <= 0 || width <= 0)
return; return;
seekPreviewRatio = clampRatio(mouseX / width); seekPreviewRatio = clampRatio(mouseX / width);
} }
@@ -68,7 +70,7 @@ Item {
mouseArea.pressX = mouse.x; mouseArea.pressX = mouse.x;
clearCommittedSeekPreview(); clearCommittedSeekPreview();
holdTimer.restart(); holdTimer.restart();
if (activePlayer && activePlayer.length > 0 && activePlayer.canSeek) { if (activePlayer && stableLength > 0 && activePlayer.canSeek) {
updatePreviewFromMouse(mouse.x, width); updatePreviewFromMouse(mouse.x, width);
mouseArea.pendingSeekPosition = positionForRatio(seekPreviewRatio); mouseArea.pendingSeekPosition = positionForRatio(seekPreviewRatio);
} }
@@ -78,9 +80,9 @@ Item {
holdTimer.stop(); holdTimer.stop();
isSeeking = false; isSeeking = false;
isDraggingSeek = false; isDraggingSeek = false;
if (mouseArea.pendingSeekPosition >= 0 && activePlayer && activePlayer.canSeek && activePlayer.length > 0) { if (mouseArea.pendingSeekPosition >= 0 && activePlayer && activePlayer.canSeek && stableLength > 0) {
const clamped = Math.min(mouseArea.pendingSeekPosition, activePlayer.length * 0.99); const clamped = Math.min(mouseArea.pendingSeekPosition, stableLength * 0.99);
activePlayer.position = clamped; activePlayer.position = Math.max(0.1, clamped);
mouseArea.pendingSeekPosition = -1; mouseArea.pendingSeekPosition = -1;
beginCommittedSeekPreview(clamped); beginCommittedSeekPreview(clamped);
} else { } else {
@@ -89,7 +91,7 @@ Item {
} }
function handleSeekPositionChanged(mouse, width, mouseArea) { 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) if (!isDraggingSeek && Math.abs(mouse.x - mouseArea.pressX) >= dragThreshold)
isDraggingSeek = true; isDraggingSeek = true;
updatePreviewFromMouse(mouse.x, width); updatePreviewFromMouse(mouse.x, width);
@@ -129,7 +131,7 @@ Item {
Loader { Loader {
anchors.fill: parent anchors.fill: parent
visible: activePlayer && activePlayer.length > 0 visible: activePlayer && stableLength > 0
sourceComponent: SettingsData.waveProgressEnabled ? waveProgressComponent : flatProgressComponent sourceComponent: SettingsData.waveProgressEnabled ? waveProgressComponent : flatProgressComponent
z: 1 z: 1
@@ -148,7 +150,7 @@ Item {
anchors.fill: parent anchors.fill: parent
hoverEnabled: true hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
enabled: activePlayer && activePlayer.canSeek && activePlayer.length > 0 enabled: activePlayer && activePlayer.canSeek && stableLength > 0
property real pendingSeekPosition: -1 property real pendingSeekPosition: -1
property real pressX: 0 property real pressX: 0
@@ -236,7 +238,7 @@ Item {
anchors.fill: parent anchors.fill: parent
hoverEnabled: true hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
enabled: activePlayer && activePlayer.canSeek && activePlayer.length > 0 enabled: activePlayer && activePlayer.canSeek && stableLength > 0
property real pendingSeekPosition: -1 property real pendingSeekPosition: -1
property real pressX: 0 property real pressX: 0
+37 -8
View File
@@ -21,6 +21,7 @@ Item {
property var panelWindow: null property var panelWindow: null
property bool recording: false property bool recording: false
property bool isNew: false property bool isNew: false
property bool readOnly: false
property string restoreKey: "" property string restoreKey: ""
property int editingKeyIndex: -1 property int editingKeyIndex: -1
@@ -160,6 +161,10 @@ Item {
} }
function startAddingNewKey() { function startAddingNewKey() {
if (readOnly) {
KeybindsService.showHyprlandReadOnlyWarning();
return;
}
addingNewKey = true; addingNewKey = true;
editingKeyIndex = -1; editingKeyIndex = -1;
editKey = ""; editKey = "";
@@ -181,6 +186,8 @@ Item {
} }
function updateEdit(changes) { function updateEdit(changes) {
if (readOnly)
return;
if (changes.key !== undefined) if (changes.key !== undefined)
editKey = changes.key; editKey = changes.key;
if (changes.action !== undefined) if (changes.action !== undefined)
@@ -208,6 +215,8 @@ Item {
} }
function canSave() { function canSave() {
if (readOnly)
return false;
if (!editKey) if (!editKey)
return false; return false;
if (!Actions.isValidAction(editAction)) if (!Actions.isValidAction(editAction))
@@ -216,6 +225,10 @@ Item {
} }
function doSave() { function doSave() {
if (readOnly) {
KeybindsService.showHyprlandReadOnlyWarning();
return;
}
if (!canSave()) if (!canSave())
return; return;
const origKey = addingNewKey ? "" : _originalKey; const origKey = addingNewKey ? "" : _originalKey;
@@ -247,6 +260,10 @@ Item {
} }
function startRecording() { function startRecording() {
if (readOnly) {
KeybindsService.showHyprlandReadOnlyWarning();
return;
}
recording = true; recording = true;
} }
@@ -438,6 +455,7 @@ Item {
anchors.top: parent.top anchors.top: parent.top
anchors.margins: Theme.spacingL anchors.margins: Theme.spacingL
spacing: Theme.spacingM spacing: Theme.spacingM
enabled: !root.readOnly
Rectangle { Rectangle {
Layout.fillWidth: true Layout.fillWidth: true
@@ -554,7 +572,7 @@ Item {
height: root._chipHeight height: root._chipHeight
radius: root._chipHeight / 4 radius: root._chipHeight / 4
color: root.addingNewKey ? Theme.primary : Theme.surfaceVariant color: root.addingNewKey ? Theme.primary : Theme.surfaceVariant
visible: !root.isNew visible: !root.isNew && !root.readOnly
Rectangle { Rectangle {
anchors.fill: parent anchors.fill: parent
@@ -644,6 +662,7 @@ Item {
iconName: root.recording ? "close" : "radio_button_checked" iconName: root.recording ? "close" : "radio_button_checked"
iconSize: Theme.iconSizeSmall iconSize: Theme.iconSizeSmall
iconColor: root.recording ? Theme.error : Theme.primary iconColor: root.recording ? Theme.error : Theme.primary
enabled: !root.readOnly
onClicked: root.recording ? root.stopRecording() : root.startRecording() onClicked: root.recording ? root.stopRecording() : root.startRecording()
} }
} }
@@ -746,7 +765,7 @@ Item {
Layout.preferredHeight: root._inputHeight Layout.preferredHeight: root._inputHeight
radius: Theme.cornerRadius radius: Theme.cornerRadius
color: root.addingNewKey ? Theme.primary : Theme.surfaceVariant color: root.addingNewKey ? Theme.primary : Theme.surfaceVariant
visible: root.keys.length === 1 && !root.isNew visible: root.keys.length === 1 && !root.isNew && !root.readOnly
Rectangle { Rectangle {
anchors.fill: parent anchors.fill: parent
@@ -861,6 +880,8 @@ Item {
hoverEnabled: true hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onClicked: { onClicked: {
if (root.readOnly)
return;
switch (typeDelegate.modelData.id) { switch (typeDelegate.modelData.id) {
case "dms": case "dms":
root.updateEdit({ root.updateEdit({
@@ -926,6 +947,8 @@ Item {
enableFuzzySearch: true enableFuzzySearch: true
maxPopupHeight: 300 maxPopupHeight: 300
onValueChanged: value => { onValueChanged: value => {
if (root.readOnly)
return;
const actions = KeybindsService.getDmsActions(); const actions = KeybindsService.getDmsActions();
for (const act of actions) { for (const act of actions) {
if (act.label === value) { if (act.label === value) {
@@ -1176,8 +1199,12 @@ Item {
id: customToggleArea id: customToggleArea
anchors.fill: parent anchors.fill: parent
hoverEnabled: true hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: root.readOnly ? Qt.ArrowCursor : Qt.PointingHandCursor
onClicked: root.useCustomCompositor = true onClicked: {
if (root.readOnly)
return;
root.useCustomCompositor = true;
}
} }
} }
} }
@@ -1418,8 +1445,10 @@ Item {
id: presetToggleArea id: presetToggleArea
anchors.fill: parent anchors.fill: parent
hoverEnabled: true hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: root.readOnly ? Qt.ArrowCursor : Qt.PointingHandCursor
onClicked: { onClicked: {
if (root.readOnly)
return;
root.useCustomCompositor = false; root.useCustomCompositor = false;
root.updateEdit({ root.updateEdit({
"action": "close-window", "action": "close-window",
@@ -1768,7 +1797,7 @@ Item {
iconName: "delete" iconName: "delete"
iconSize: Theme.iconSize - 4 iconSize: Theme.iconSize - 4
iconColor: Theme.error 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) onClicked: root.removeBind(root._originalKey)
} }
@@ -1777,7 +1806,7 @@ Item {
buttonHeight: root._buttonHeight buttonHeight: root._buttonHeight
backgroundColor: Theme.surfaceContainer backgroundColor: Theme.surfaceContainer
textColor: Theme.primary 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) onClicked: root.resetBind(root._originalKey)
} }
@@ -1786,7 +1815,7 @@ Item {
} }
StyledText { 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 font.pixelSize: Theme.fontSizeSmall
color: root.hasChanges ? Theme.surfaceText : Theme.surfaceVariantText color: root.hasChanges ? Theme.surfaceText : Theme.surfaceVariantText
visible: !root.isNew visible: !root.isNew
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