1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-06-24 03:55:23 -04:00

Compare commits

...

54 Commits

Author SHA1 Message Date
bbedward b117c80e47 settings: fix blur error message 2026-06-03 09:46:15 -04:00
purian23 d20aa3b80a feat(window-rules): view & convert external rules to DMS
- Read and convert external compositor rules into editable DMS rules
- Preserve niri multi-match rules and add match editor
- niri background-effect (blur/xray/noise/saturation) support
2026-06-03 08:59:51 -04:00
purian23 a34fda984d fix(Clipboard): stale image entry handling
- Resolved random DMS API errors & QML Warnings
2026-06-03 03:17:02 -04:00
purian23 510269dda9 fix(keybinds): show percentage amount in titles & performance improvements 2026-06-03 00:35:19 -04:00
purian23 d51b34797c refactor(Hyprland): updated the default close/kill keybinds for lua 2026-06-02 23:16:05 -04:00
arfan d2905072c0 feat(settings): Added Settings Tab Autostart App (XDG Autostart) (#2535)
* feat(Autostart): add Autostart tab and application selection popup

* fix(AutoStartTab): update systemdUserDir property to use XDG_CONFIG_HOME

* fix(AutoStartTab): update autostartDir and systemdUserDir to use StandardPaths for config home

* refactor(AutoStartTab): use FileView & FolderListModel

* refactor(AutoStartTab): implement systemd override generation for autostart applications using FileView

* feat(AutoStartTab): add systemd check to determine environment and update tray icon visibility

* feat(SettingsSidebar, AutoStartTab, DesktopService): add autostart functionality and systemd checks

* feat(AutoStartTab): add hidden property support for desktop entries and toggle functionality

* feat(AutoStartTab): add initialize autostart directory and add toast if writer failed

* add(AutoStartTab): logging for scoped log tracking

---------
2026-06-02 22:52:58 -04:00
purian23 1ee42506b6 refactor(control-center): consolidate detail section height rules 2026-06-02 15:48:47 -04:00
Guilherme Pagano 84fe2d751f fix(control-center): honor plugin ccDetailHeight instead of fixed 250px slot (#2559) 2026-06-02 15:31:55 -04:00
euletheia 5d0fc48706 Add DoNotDisturb & IdleInhibitor Indicators for the ControlCenterButton widget + Add optional icon to KeyboardLayoutName widget (#2513)
* feat(ControlCenterButton): add IdleInhibitor icon

* feat(ControlCenterButton): add DoNotDisturb icon

* fix(WidgetTabSection): adjust text width for the SystemTray Widget popup

* feat(KeyboardLayoutName): add optional icon

* refactor(KeyboardLayoutName): simplify icon visibility logic by using root.showIcon

---------
2026-06-02 14:25:35 -04:00
purian23 335c5b4ac5 feat(Greeter): add auto-login feature for startup settings
- Introduced a new cli flag:
`dms greeter sync --autologin-only`
and updated UI toggle in Greeter settings
2026-06-02 02:03:02 -04:00
bbedward 8c20f448ed control center: improve drag handling
misc: fix layer shell enum usage
2026-06-01 13:16:47 -04:00
jbwfu 0a668df138 fix(clipboard): prefer image MIME types over text fallbacks (#2551) 2026-06-01 11:44:10 -04:00
Guilherme Pagano 3e4d2b4d46 feat(control-center): add DiskUsage widget config overlay with showMountPath toggle and standardized tile sizing (#2507)
* feat(control-center): add widget config overlay with showMountPath toggle for DiskUsage

Introduces WidgetConfigOverlay and DiskUsageWidgetConfigMenu components, allowing
users to toggle mount path visibility per DiskUsage widget in edit mode

* refactor(control-center): use Theme.iconSizeLarge and Theme.fontSizeLarge for small tiles

Standardize SmallDiskUsageButton and SmallBatteryButton sizing with Theme.iconSizeLarge
and Theme.fontSizeLarge, and unify font weight to Font.Bold on both tile widgets.

* fix(control-center): adjust SmallDiskUsageButton font size based on showMountPath

* refactor(control-center): simplify DiskUsage config menu i18n strings

Remove the redundant "Disk Usage Widget" title and the toggle description
to reduce translatable strings
2026-06-01 11:35:14 -04:00
jbwfu 12e43d120e fix(clipboard): raise CLI IPC scanner limit for large entry payloads (#2550) 2026-06-01 11:01:34 -04:00
jbwfu a9845bf3cd fix(wallpaper): redraw wallpaper layers when fill mode changes (#2542) 2026-06-01 10:36:50 -04:00
Graeme Foster e51ceed175 fix(network): exclude virtual ether links and prune stale ones from networkd (#2505)
The networkd backend treated any link reporting Type=ether as a wired uplink.
Podman bridges and veth pairs report Type=ether, so they were classified as
ethernet: isWired() short-circuited on Type and never consulted looksVirtual(),
which also lacked a podman prefix.

The link map was also never pruned. Links discovered at enumeration or via
signals were kept forever, so torn-down container interfaces lingered as
routable and could win the wired-uplink slot over the real NIC -- leaving the
indicator showing WiFi while a wired connection was active and default-routed.

- isWired()/isWireless() exclude virtual interfaces before consulting Type, and
  looksVirtual() now recognises podman.
- enumerateLinks() reconciles the cached map against ListLinks via syncLinks(),
  pruning links that no longer appear so dead interfaces don't accumulate.
2026-06-01 09:45:49 -04:00
Karel "Angerion" Čeleda 304baf6f60 fix(CavaService): prevent 100% CPU EOF spin loop by using temp file (#2471)
* fix(CavaService): prevent 100% CPU EOF spin loop by using temp config file

* cava: make tmp file non-deterministic

---------

Co-authored-by: bbedward <bbedward@gmail.com>
2026-06-01 09:45:16 -04:00
purian23 6b141a9b06 refactor(Hyprland): updates to Lua syntax/dispatchers 2026-06-01 09:31:19 -04:00
purian23 0c3659a612 feat(PluginBrowser): add sorting and filtering options for plugins
- Introduced sorting and filtering by installed, default, category, name, and author
2026-05-31 23:58:13 -04:00
purian23 a44bef5796 fix(Notifications): restore long message content from overflowing
- Addtional security escape patch
- Tweak Notification Center width
2026-05-31 22:38:50 -04:00
Youseffo13 b1ac6b0ef9 Add context to font weight keys, wrap render settings, fix uptime prefix (#2529)
* fix: i18n inconsistencies

* Update UserInfoCard.qml
2026-05-31 18:10:43 -04:00
purian23 98844a3b85 fix(Clipboard): prevent security risk HTML in window titles from fetching remote URLs
- Default StyledText to PlainText; keep RichText only on notification content
2026-05-31 17:41:55 -04:00
purian23 a32b8911c7 feat(Hyprland): add touchpad gesture support via DMS default configs 2026-05-31 15:45:17 -04:00
purian23 3118e7b9c3 fix(Hyprland): correct Lua keybind writes
- Write titles as Lua description metadata
- Use hl.dispatch for custom dispatcher actions
- Preserve legacy trailing comment titles on rewrite
- Update option edits before saving keybinds
2026-05-31 15:35:13 -04:00
Miguel Saliba 2ca2bc5fb8 fix: make matugen switch pywalfox dark/light mode (#2537)
Currently, when switching dark/light theme in DMS, pywalfox's mode does not get updated. This commit adds `pywalfox light/dark` to the matugen post_hook to fix that.
2026-05-31 00:35:06 -04:00
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
180 changed files with 21748 additions and 3131 deletions
+1 -1
View File
@@ -26,4 +26,4 @@ jobs:
go-version-file: core/go.mod
- name: run pre-commit hooks
uses: j178/prek-action@v1
uses: j178/prek-action@v2
+10 -2
View File
@@ -54,8 +54,10 @@ func init() {
}
type IncludeResult struct {
Exists bool `json:"exists"`
Included bool `json:"included"`
Exists bool `json:"exists"`
Included bool `json:"included"`
ConfigFormat string `json:"configFormat,omitempty"`
ReadOnly bool `json:"readOnly,omitempty"`
}
func runResolveInclude(cmd *cobra.Command, args []string) {
@@ -106,6 +108,8 @@ func checkHyprlandInclude(filename string) (IncludeResult, error) {
mainLua := filepath.Join(configDir, "hyprland.lua")
if _, err := os.Stat(mainLua); err == nil {
result.ConfigFormat = "lua"
result.ReadOnly = false
processedLua := make(map[string]bool)
if luaconfig.RequiresTarget(mainLua, targetAbs, processedLua) {
result.Included = true
@@ -115,6 +119,10 @@ func checkHyprlandInclude(filename string) (IncludeResult, error) {
mainConf := filepath.Join(configDir, "hyprland.conf")
if _, err := os.Stat(mainConf); err == nil {
if result.ConfigFormat == "" {
result.ConfigFormat = "hyprlang"
result.ReadOnly = true
}
processed := make(map[string]bool)
if hyprlandFindIncludeHyprlang(mainConf, targetRel, processed) {
result.Included = true
+4 -1
View File
@@ -947,9 +947,12 @@ func checkSystemdServices() []checkResult {
message = fmt.Sprintf("%s, %s", dmsState.enabled, dmsState.active)
}
switch {
case dmsState.active == "failed":
status = statusError
case dmsState.active == "active":
case dmsState.enabled == "disabled":
status, message = statusWarn, "Disabled"
case dmsState.active == "failed" || dmsState.active == "inactive":
case dmsState.active == "inactive":
status = statusError
}
results = append(results, checkResult{catServices, "dms.service", status, message, "", doctorDocsURL + "#services"})
+113 -11
View File
@@ -3,6 +3,7 @@ package main
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
@@ -59,22 +60,36 @@ var greeterInstallCmd = &cobra.Command{
}
var greeterSyncCmd = &cobra.Command{
Use: "sync",
Short: "Sync DMS theme and settings with greeter",
Long: "Synchronize your current user's DMS theme, settings, and wallpaper configuration with the login greeter screen",
PreRunE: preRunPrivileged,
Use: "sync",
Short: "Sync DMS theme and settings with greeter",
Long: "Synchronize your current user's DMS theme, settings, and wallpaper configuration with the login greeter screen. Also updates a per-user cache slot at users/<username>/ for multi-account greeter theme preview.\n\nUse --profile on secondary accounts to sync only your own users/<username>/ slot without sudo or greetd changes.",
PreRunE: func(cmd *cobra.Command, args []string) error {
profile, _ := cmd.Flags().GetBool("profile")
if profile {
return nil
}
return preRunPrivileged(cmd, args)
},
Run: func(cmd *cobra.Command, args []string) {
yes, _ := cmd.Flags().GetBool("yes")
auth, _ := cmd.Flags().GetBool("auth")
local, _ := cmd.Flags().GetBool("local")
profile, _ := cmd.Flags().GetBool("profile")
autologinOnly, _ := cmd.Flags().GetBool("autologin-only")
term, _ := cmd.Flags().GetBool("terminal")
if term {
if err := syncInTerminal(yes, auth, local); err != nil {
if err := syncInTerminal(yes, auth, local, profile, autologinOnly); err != nil {
log.Fatalf("Error launching sync in terminal: %v", err)
}
return
}
if err := syncGreeter(yes, auth, local); err != nil {
if autologinOnly {
if err := syncGreeterAutoLoginOnly(yes); err != nil {
log.Fatalf("Error syncing greeter auto-login: %v", err)
}
return
}
if err := syncGreeter(yes, auth, local, profile); err != nil {
log.Fatalf("Error syncing greeter: %v", err)
}
},
@@ -85,6 +100,8 @@ func init() {
greeterSyncCmd.Flags().BoolP("terminal", "t", false, "Run sync in a new terminal (for entering sudo password); terminal auto-closes when done")
greeterSyncCmd.Flags().BoolP("auth", "a", false, "Configure PAM for fingerprint and U2F (adds both if modules exist); overrides UI toggles")
greeterSyncCmd.Flags().BoolP("local", "l", false, "Developer mode: force greetd config to use a local DMS checkout path")
greeterSyncCmd.Flags().BoolP("profile", "p", false, "Sync only your per-user greeter slot (no sudo; for secondary accounts)")
greeterSyncCmd.Flags().Bool("autologin-only", false, "Apply only greeter auto-login on startup settings to greetd (no theme or auth sync)")
}
var greeterEnableCmd = &cobra.Command{
@@ -512,8 +529,8 @@ func runCommandInTerminal(shellCmd string) error {
return fmt.Errorf("no terminal emulator found (tried: gnome-terminal, konsole, xfce4-terminal, ghostty, wezterm, alacritty, kitty, xterm)")
}
func syncInTerminal(nonInteractive bool, forceAuth bool, local bool) error {
syncFlags := make([]string, 0, 3)
func syncInTerminal(nonInteractive bool, forceAuth bool, local bool, profileOnly bool, autologinOnly bool) error {
syncFlags := make([]string, 0, 5)
if nonInteractive {
syncFlags = append(syncFlags, "--yes")
}
@@ -523,11 +540,22 @@ func syncInTerminal(nonInteractive bool, forceAuth bool, local bool) error {
if local {
syncFlags = append(syncFlags, "--local")
}
if profileOnly {
syncFlags = append(syncFlags, "--profile")
}
if autologinOnly {
syncFlags = append(syncFlags, "--autologin-only")
}
shellSyncCmd := "dms greeter sync"
if len(syncFlags) > 0 {
shellSyncCmd += " " + strings.Join(syncFlags, " ")
}
shellCmd := shellSyncCmd + `; echo; echo "Sync finished. Closing in 3 seconds..."; sleep 3`
var shellCmd string
if autologinOnly {
shellCmd = shellSyncCmd + `; echo; echo "Auto-login update finished. Closing in 3 seconds..."; sleep 3`
} else {
shellCmd = shellSyncCmd + `; echo; echo "Sync finished. Closing in 3 seconds..."; sleep 3`
}
return runCommandInTerminal(shellCmd)
}
@@ -541,7 +569,54 @@ func resolveLocalWrapperShell() (string, error) {
return "", fmt.Errorf("could not find bash or sh in PATH for local greeter wrapper")
}
func syncGreeter(nonInteractive bool, forceAuth bool, local bool) error {
func syncGreeterAutoLoginOnly(nonInteractive bool) error {
logFunc := func(msg string) {
fmt.Println(msg)
}
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err)
}
settingsPath := filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json")
cacheSettingsPath := filepath.Join(greeter.GreeterCacheDir, "settings.json")
enabled := false
for _, path := range []string{cacheSettingsPath, settingsPath} {
data, readErr := os.ReadFile(path)
if readErr != nil {
continue
}
var cfg struct {
GreeterAutoLogin bool `json:"greeterAutoLogin"`
}
if json.Unmarshal(data, &cfg) == nil {
enabled = cfg.GreeterAutoLogin
break
}
}
fmt.Println("=== Greeter Auto-Login ===")
fmt.Println()
if enabled {
fmt.Println("Enabling auto-login on startup in greetd.")
fmt.Println("After your next reboot, DMS will skip the greeter password until you sign out.")
} else {
fmt.Println("Disabling auto-login on startup in greetd.")
fmt.Println("After your next reboot, you will enter your password at the greeter again.")
}
fmt.Println()
fmt.Println("Administrator (sudo) access is required to update /etc/greetd/config.toml.")
fmt.Println()
return greeter.SyncGreeterAutoLoginOnly(logFunc, "")
}
func syncGreeter(nonInteractive bool, forceAuth bool, local bool, profileOnly bool) error {
if profileOnly {
return syncGreeterProfileOnly(nonInteractive)
}
if !nonInteractive {
fmt.Println("=== DMS Greeter Sync ===")
fmt.Println()
@@ -752,6 +827,26 @@ func syncGreeter(nonInteractive bool, forceAuth bool, local bool) error {
return nil
}
func syncGreeterProfileOnly(nonInteractive bool) error {
logFunc := func(msg string) {
fmt.Println(msg)
}
if !nonInteractive {
fmt.Println("=== DMS Greeter Profile Sync ===")
fmt.Println()
fmt.Println("Syncing your personal greeter theme slot (no system changes)...")
}
if err := greeter.SyncUserProfileCache(logFunc); err != nil {
return err
}
if !nonInteractive {
fmt.Println("\n=== Profile Sync Complete ===")
fmt.Println("\nYour theme, wallpaper, and profile photo have been synced for the login screen.")
fmt.Println("Log out to preview your greeter look when selecting your account.")
}
return nil
}
func hasDmsShellQml(dir string) bool {
info, err := os.Stat(filepath.Join(dir, "shell.qml"))
return err == nil && !info.IsDir()
@@ -837,7 +932,14 @@ func resolveLocalDMSPath() (string, error) {
}
}
return "", fmt.Errorf("could not locate a local DMS checkout from %s; run from repo root or set DMS_LOCAL_PATH=/absolute/path/to/repo", wd)
configuredCommand := readDefaultSessionCommand("/etc/greetd/config.toml")
if pathOverride := extractGreeterPathOverrideFromCommand(configuredCommand); pathOverride != "" {
if resolved, ok := resolveDMSLocalCandidate(pathOverride); ok {
return resolved, nil
}
}
return "", fmt.Errorf("could not locate a local DMS checkout from %s; run from repo root, set DMS_LOCAL_PATH=/absolute/path/to/repo, or configure greetd with -p /path/to/quickshell", wd)
}
func disableDisplayManager(dmName string) (bool, error) {
+34 -2
View File
@@ -4,7 +4,9 @@ import (
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
@@ -179,9 +181,39 @@ func getScreenshotConfig(mode screenshot.Mode) screenshot.Config {
return config
}
// setPopoutScreenshotMode toggles the shell handshake so popouts drop their keyboard grab during region select. Best-effort.
func setPopoutScreenshotMode(begin bool) {
fn := "end"
if begin {
fn = "begin"
}
cmdArgs := []string{"ipc"}
if pid, ok := getFirstDMSPID(); ok {
cmdArgs = append(cmdArgs, "--pid", strconv.Itoa(pid))
} else {
if err := findConfig(nil, nil); err != nil {
return
}
if qsHasAnyDisplay() {
cmdArgs = append(cmdArgs, "--any-display")
}
cmdArgs = append(cmdArgs, "-p", configPath)
}
cmdArgs = append(cmdArgs, "call", "screenshot", fn)
_ = exec.Command("qs", cmdArgs...).Run()
}
func runScreenshot(config screenshot.Config) {
sc := screenshot.New(config)
result, err := sc.Run()
// Region select needs the keyboard; drop popout grabs for its duration.
result, err := func() (*screenshot.CaptureResult, error) {
interactive := config.Mode == screenshot.ModeRegion || config.Mode == screenshot.ModeLastRegion
if interactive {
setPopoutScreenshotMode(true)
defer setPopoutScreenshotMode(false)
}
return screenshot.New(config).Run()
}()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
+6
View File
@@ -12,6 +12,10 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
)
// maxIPCMessageSize allows room for a 50 MB clipboard entry plus JSON/base64
// overhead in the line-delimited IPC response.
const maxIPCMessageSize = 96 * 1024 * 1024
func sendServerRequest(req models.Request) (*models.Response[any], error) {
socketPath := getServerSocketPath()
@@ -22,6 +26,7 @@ func sendServerRequest(req models.Request) (*models.Response[any], error) {
defer conn.Close()
scanner := bufio.NewScanner(conn)
scanner.Buffer(make([]byte, bufio.MaxScanTokenSize), maxIPCMessageSize)
scanner.Scan() // discard initial capabilities message
reqData, err := json.Marshal(req)
@@ -61,6 +66,7 @@ func sendServerRequestFireAndForget(req models.Request) error {
defer conn.Close()
scanner := bufio.NewScanner(conn)
scanner.Buffer(make([]byte, bufio.MaxScanTokenSize), maxIPCMessageSize)
scanner.Scan() // discard initial capabilities message
reqData, err := json.Marshal(req)
+4
View File
@@ -600,6 +600,10 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
return result, result.Error
}
CleanupStrayHyprlandConfFile(func(format string, v ...any) {
cd.log(fmt.Sprintf(format, v...))
})
result.Deployed = true
cd.log("Successfully deployed Hyprland configuration")
return result, nil
+15 -3
View File
@@ -20,13 +20,17 @@ func TestCleanupStrayHyprlandConfFile(t *testing.T) {
td := t.TempDir()
t.Setenv("HOME", td)
configDir := filepath.Join(td, ".config", "hypr")
require.NoError(t, os.MkdirAll(configDir, 0o755))
dmsDir := filepath.Join(configDir, "dms")
require.NoError(t, os.MkdirAll(dmsDir, 0o755))
confPath := filepath.Join(configDir, "hyprland.conf")
dmsConfPath := filepath.Join(dmsDir, "colors.conf")
require.NoError(t, os.WriteFile(confPath, []byte("# legacy user config\n"), 0o644))
require.NoError(t, os.WriteFile(dmsConfPath, []byte("$primary = rgba(d0bcffFF)\n"), 0o644))
CleanupStrayHyprlandConfFile(nil)
assert.FileExists(t, confPath, "must not touch hyprland.conf when user has not migrated")
assert.FileExists(t, dmsConfPath, "must not touch dms/*.conf when user has not migrated")
assert.NoDirExists(t, filepath.Join(configDir, hyprlandBackupDirName))
})
@@ -34,20 +38,25 @@ func TestCleanupStrayHyprlandConfFile(t *testing.T) {
td := t.TempDir()
t.Setenv("HOME", td)
configDir := filepath.Join(td, ".config", "hypr")
require.NoError(t, os.MkdirAll(configDir, 0o755))
dmsDir := filepath.Join(configDir, "dms")
require.NoError(t, os.MkdirAll(dmsDir, 0o755))
luaPath := filepath.Join(configDir, "hyprland.lua")
require.NoError(t, os.WriteFile(luaPath, []byte("-- dms managed\n"), 0o644))
confPath := filepath.Join(configDir, "hyprland.conf")
dmsConfPath := filepath.Join(dmsDir, "colors.conf")
require.NoError(t, os.WriteFile(confPath, []byte("# autogen\n"), 0o644))
require.NoError(t, os.WriteFile(dmsConfPath, []byte("$primary = rgba(d0bcffFF)\n"), 0o644))
CleanupStrayHyprlandConfFile(nil)
assert.NoFileExists(t, confPath)
assert.NoFileExists(t, dmsConfPath)
assert.FileExists(t, luaPath)
entries, err := os.ReadDir(filepath.Join(configDir, hyprlandBackupDirName))
require.NoError(t, err)
require.Len(t, entries, 1)
assert.FileExists(t, filepath.Join(configDir, hyprlandBackupDirName, entries[0].Name(), "hyprland.conf"))
assert.FileExists(t, filepath.Join(configDir, hyprlandBackupDirName, entries[0].Name(), "dms", "colors.conf"))
})
}
@@ -404,6 +413,7 @@ general {
dmsDir := filepath.Join(td, ".config", "hypr", "dms")
require.NoError(t, os.MkdirAll(dmsDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "binds.conf"), []byte("bind = SUPER, T, exec, foot\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "colors.conf"), []byte("$primary = rgba(d0bcffFF)\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "cursor.conf"), []byte("env = XCURSOR_SIZE,24\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(filepath.Dir(hyprPath), "hyprland.conf.backup.old"), []byte("old backup\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "binds.conf.backup.old"), []byte("old dms backup\n"), 0o644))
@@ -423,10 +433,12 @@ general {
assert.Contains(t, result.BackupPath, hyprlandBackupDirName)
assert.NoFileExists(t, hyprPath)
assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "dms", "binds.conf"))
assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "dms", "colors.conf"))
assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "dms", "cursor.conf"))
assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "hyprland.conf.backup.old"))
assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "dms", "binds.conf.backup.old"))
assert.NoFileExists(t, filepath.Join(dmsDir, "binds.conf"))
assert.NoFileExists(t, filepath.Join(dmsDir, "colors.conf"))
assert.NoFileExists(t, filepath.Join(dmsDir, "cursor.conf"))
assert.NoFileExists(t, filepath.Join(filepath.Dir(hyprPath), "hyprland.conf.backup.old"))
assert.NoFileExists(t, filepath.Join(dmsDir, "binds.conf.backup.old"))
@@ -485,7 +497,7 @@ general {
managed, err := os.ReadFile(filepath.Join(dmsDir, "binds.lua"))
require.NoError(t, err)
assert.Contains(t, string(managed), `hl.bind("SUPER + F", hl.dsp.window.fullscreen({ mode = "maximized", action = "toggle" }))`)
assert.Contains(t, string(managed), `hl.bind("SUPER + minus", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive -10% 0]]), { repeating = true })`)
assert.Contains(t, string(managed), `hl.bind("SUPER + minus", hl.dsp.window.resize({ x = -100, y = 0, relative = true }), { repeating = true })`)
user, err := os.ReadFile(filepath.Join(dmsDir, "binds-user.lua"))
require.NoError(t, err)
+9 -6
View File
@@ -38,7 +38,7 @@ hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd([[dms ipc call brightness increme
hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd([[dms ipc call brightness decrement 5 ""]]), { locked = true, repeating = true })
-- === Window Management ===
hl.bind("SUPER + Q", hl.dsp.window.kill())
hl.bind("SUPER + Q", hl.dsp.window.close())
hl.bind("SUPER + F", hl.dsp.window.fullscreen({ mode = "maximized", action = "toggle" }))
hl.bind("SUPER + SHIFT + F", hl.dsp.window.fullscreen({ mode = "fullscreen", action = "toggle" }))
hl.bind("SUPER + SHIFT + T", hl.dsp.window.float({ action = "toggle" }))
@@ -112,6 +112,9 @@ hl.bind("SUPER + mouse_up", hl.dsp.focus({ workspace = "e-1" }))
hl.bind("SUPER + CTRL + mouse_down", hl.dsp.window.move({ workspace = "e+1" }))
hl.bind("SUPER + CTRL + mouse_up", hl.dsp.window.move({ workspace = "e-1" }))
-- === Touchpad Gestures ===
hl.gesture({ fingers = 3, direction = "horizontal", action = "workspace" })
-- === Numbered Workspaces ===
hl.bind("SUPER + 1", hl.dsp.focus({ workspace = "1" }))
hl.bind("SUPER + 2", hl.dsp.focus({ workspace = "2" }))
@@ -140,7 +143,7 @@ hl.bind("SUPER + bracketright", hl.dsp.layout("preselect r"))
-- === Sizing & Layout ===
hl.bind("SUPER + R", hl.dsp.layout("togglesplit"))
hl.bind("SUPER + CTRL + F", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive exact 100% 100%]]))
hl.bind("SUPER + CTRL + F", hl.dsp.window.fullscreen({ mode = "maximized", action = "set" }))
-- === Move/resize windows with mainMod + LMB/RMB and dragging ===
hl.bind("SUPER + mouse:272", hl.dsp.window.drag(), { mouse = true, description = "Move window" })
@@ -150,10 +153,10 @@ hl.bind("SUPER + code:20", hl.dsp.window.resize({ x = -100, y = 0, relative = tr
hl.bind("SUPER + code:21", hl.dsp.window.resize({ x = 100, y = 0, relative = true }), { description = "Shrink window left" })
-- === Manual Sizing ===
hl.bind("SUPER + minus", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive -10% 0]]), { repeating = true })
hl.bind("SUPER + equal", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive 10% 0]]), { repeating = true })
hl.bind("SUPER + SHIFT + minus", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive 0 -10%]]), { repeating = true })
hl.bind("SUPER + SHIFT + equal", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive 0 10%]]), { repeating = true })
hl.bind("SUPER + minus", hl.dsp.window.resize({ x = -100, y = 0, relative = true }), { repeating = true })
hl.bind("SUPER + equal", hl.dsp.window.resize({ x = 100, y = 0, relative = true }), { repeating = true })
hl.bind("SUPER + SHIFT + minus", hl.dsp.window.resize({ x = 0, y = -100, relative = true }), { repeating = true })
hl.bind("SUPER + SHIFT + equal", hl.dsp.window.resize({ x = 0, y = 100, relative = true }), { repeating = true })
-- === Screenshots ===
hl.bind("Print", hl.dsp.exec_cmd("dms screenshot"))
@@ -13,6 +13,10 @@ hl.config({
input = {
kb_layout = "us",
numlock_by_default = true,
touchpad = {
tap_to_click = true,
natural_scroll = true,
},
},
general = {
gaps_in = 5,
+37 -14
View File
@@ -138,11 +138,9 @@ func readExistingHyprlandConfig(configDir string) (data string, sourcePath strin
return "", "", nil
}
// CleanupStrayHyprlandConfFile moves a stray ~/.config/hypr/hyprland.conf
// into .dms-backups/<timestamp>/ only when hyprland.lua also exists, which
// proves Lua is the live config and the .conf is an autogen Hyprland 0.55
// produced when launched without -c. If only hyprland.conf exists, the user
// has not migrated and we must leave their config alone.
// CleanupStrayHyprlandConfFile moves stray ~/.config/hypr/hyprland.conf and
// top-level ~/.config/hypr/dms/*.conf files into .dms-backups/<timestamp>/ only
// when hyprland.lua also exists as the live config.
func CleanupStrayHyprlandConfFile(logFn func(format string, v ...any)) {
if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") == "" {
return
@@ -156,19 +154,44 @@ func CleanupStrayHyprlandConfFile(logFn func(format string, v ...any)) {
if _, err := os.Stat(luaPath); err != nil {
return
}
var strayPaths []string
confPath := filepath.Join(configDir, "hyprland.conf")
if _, err := os.Stat(confPath); err != nil {
return
if info, err := os.Lstat(confPath); err == nil && !info.IsDir() {
strayPaths = append(strayPaths, confPath)
}
ts := time.Now().Format("2006-01-02_15-04-05")
dst := filepath.Join(configDir, hyprlandBackupDirName, ts, "hyprland.conf")
if err := moveHyprlandConfigFile(confPath, dst); err != nil {
if logFn != nil {
logFn("Could not move stray hyprland.conf: %v", err)
dmsConfPaths, err := filepath.Glob(filepath.Join(configDir, "dms", "*.conf"))
if err == nil {
for _, p := range dmsConfPaths {
if info, err := os.Lstat(p); err == nil && !info.IsDir() {
strayPaths = append(strayPaths, p)
}
}
}
if len(strayPaths) == 0 {
return
}
if logFn != nil {
logFn("Moved stray hyprland.conf to %s", dst)
ts := time.Now().Format("2006-01-02_15-04-05")
moved := 0
for _, src := range strayPaths {
rel, err := filepath.Rel(configDir, src)
if err != nil {
rel = filepath.Base(src)
}
dst := filepath.Join(configDir, hyprlandBackupDirName, ts, rel)
if err := moveHyprlandConfigFile(src, dst); err != nil {
if logFn != nil {
logFn("Could not move stray Hyprland conf file %s: %v", src, err)
}
continue
}
moved++
if logFn != nil {
logFn("Moved stray Hyprland conf file to %s", dst)
}
}
if moved > 0 && logFn != nil {
logFn("Moved %d stray Hyprland conf file(s) out of the active Lua config tree", moved)
}
}
+443 -19
View File
@@ -9,6 +9,7 @@ import (
"fmt"
"os"
"os/exec"
"os/user"
"path/filepath"
"strings"
"time"
@@ -191,6 +192,421 @@ func upsertDefaultSession(configContent, greeterUser, command string) string {
return strings.Join(out, "\n")
}
func removeTomlSection(configContent, sectionName string) string {
lines := strings.Split(configContent, "\n")
var out []string
inSection := false
for _, line := range lines {
if section, ok := parseTomlSection(line); ok {
inSection = section == sectionName
if inSection {
continue
}
out = append(out, line)
continue
}
if inSection {
continue
}
out = append(out, line)
}
result := strings.TrimRight(strings.Join(out, "\n"), "\n")
if result != "" {
result += "\n"
}
return result
}
func stripDesktopExecCodes(execLine string) string {
fields := strings.Fields(execLine)
cleaned := make([]string, 0, len(fields))
for _, field := range fields {
if strings.HasPrefix(field, "%") {
continue
}
cleaned = append(cleaned, field)
}
return strings.Join(cleaned, " ")
}
func formatInitialSessionCommand(sessionExec string) string {
execLine := strings.TrimSpace(stripDesktopExecCodes(sessionExec))
if execLine == "" {
return `command = ""`
}
escaped := strings.ReplaceAll(execLine, `'`, `'\''`)
inner := fmt.Sprintf("env XDG_SESSION_TYPE=wayland sh -c 'exec %s'", escaped)
tomlEscaped := strings.ReplaceAll(inner, `\`, `\\`)
tomlEscaped = strings.ReplaceAll(tomlEscaped, `"`, `\"`)
return fmt.Sprintf(`command = "%s"`, tomlEscaped)
}
func upsertInitialSession(configContent, loginUser, sessionExec string, enabled bool) string {
if !enabled {
return removeTomlSection(configContent, "initial_session")
}
commandLine := formatInitialSessionCommand(sessionExec)
lines := strings.Split(configContent, "\n")
var out []string
inInitialSession := false
foundInitialSession := false
initialSessionUserSet := false
initialSessionCommandSet := false
appendInitialSessionFields := func() {
if !initialSessionUserSet {
out = append(out, fmt.Sprintf(`user = "%s"`, loginUser))
}
if !initialSessionCommandSet {
out = append(out, commandLine)
}
}
for _, line := range lines {
if section, ok := parseTomlSection(line); ok {
if inInitialSession {
appendInitialSessionFields()
}
inInitialSession = section == "initial_session"
if inInitialSession {
foundInitialSession = true
initialSessionUserSet = false
initialSessionCommandSet = false
}
out = append(out, line)
continue
}
if inInitialSession {
trimmed := stripTomlComment(line)
if strings.HasPrefix(trimmed, "user =") || strings.HasPrefix(trimmed, "user=") {
out = append(out, fmt.Sprintf(`user = "%s"`, loginUser))
initialSessionUserSet = true
continue
}
if strings.HasPrefix(trimmed, "command =") || strings.HasPrefix(trimmed, "command=") {
if !initialSessionCommandSet {
out = append(out, commandLine)
initialSessionCommandSet = true
}
continue
}
}
out = append(out, line)
}
if inInitialSession {
appendInitialSessionFields()
}
if !foundInitialSession {
if len(out) > 0 && strings.TrimSpace(out[len(out)-1]) != "" {
out = append(out, "")
}
out = append(out, "[initial_session]")
out = append(out, fmt.Sprintf(`user = "%s"`, loginUser))
out = append(out, commandLine)
}
return strings.Join(out, "\n")
}
type greeterAutoLoginConfig struct {
GreeterAutoLogin bool `json:"greeterAutoLogin"`
GreeterRememberLastUser bool `json:"greeterRememberLastUser"`
GreeterRememberLastSession bool `json:"greeterRememberLastSession"`
}
type greeterAutoLoginMemory struct {
LastSuccessfulUser string `json:"lastSuccessfulUser"`
LastSessionID string `json:"lastSessionId"`
LastSessionExec string `json:"lastSessionExec"`
AutoLoginEnabled bool `json:"autoLoginEnabled"`
}
func readGreeterAutoLoginConfig(settingsPath string) (greeterAutoLoginConfig, error) {
cfg := greeterAutoLoginConfig{
GreeterRememberLastUser: true,
GreeterRememberLastSession: true,
}
data, err := os.ReadFile(settingsPath)
if err != nil {
if os.IsNotExist(err) {
return cfg, nil
}
return cfg, err
}
if err := json.Unmarshal(data, &cfg); err != nil {
return cfg, fmt.Errorf("failed to parse settings at %s: %w", settingsPath, err)
}
return cfg, nil
}
func readGreeterAutoLoginMemory(memoryPath string) (greeterAutoLoginMemory, error) {
var mem greeterAutoLoginMemory
data, err := os.ReadFile(memoryPath)
if err != nil {
if os.IsNotExist(err) {
return mem, nil
}
return mem, err
}
if err := json.Unmarshal(data, &mem); err != nil {
return mem, fmt.Errorf("failed to parse greeter memory at %s: %w", memoryPath, err)
}
return mem, nil
}
func execFromDesktopFile(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
for line := range strings.SplitSeq(string(data), "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "Exec=") {
return strings.TrimSpace(trimmed[len("Exec="):]), nil
}
}
return "", fmt.Errorf("no Exec= line found in %s", path)
}
func resolveGreeterAutoLoginState(cacheDir, homeDir string) (enabled bool, loginUser string, sessionExec string, err error) {
settingsPath := filepath.Join(cacheDir, "settings.json")
if _, statErr := os.Stat(settingsPath); statErr != nil {
settingsPath = filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json")
}
cfg, err := readGreeterAutoLoginConfig(settingsPath)
if err != nil {
return false, "", "", err
}
memoryPath := filepath.Join(cacheDir, ".local/state/memory.json")
mem, err := readGreeterAutoLoginMemory(memoryPath)
if err != nil {
return false, "", "", err
}
enabled = cfg.GreeterAutoLogin
if !enabled {
return false, "", "", nil
}
if !cfg.GreeterRememberLastUser || !cfg.GreeterRememberLastSession {
return true, "", "", nil
}
loginUser = mem.LastSuccessfulUser
if loginUser == "" {
current, userErr := user.Current()
if userErr != nil {
return true, "", "", userErr
}
loginUser = current.Username
}
sessionExec = mem.LastSessionExec
if sessionExec == "" && mem.LastSessionID != "" {
sessionExec, err = execFromDesktopFile(mem.LastSessionID)
if err != nil {
sessionExec = ""
}
}
return true, loginUser, sessionExec, nil
}
func writeGreetdConfig(configPath, content string, logFunc func(string), sudoPassword, successMsg string) error {
if err := backupFileIfExists(sudoPassword, configPath, ".backup"); err != nil {
return fmt.Errorf("failed to backup greetd config: %w", err)
}
tmpFile, err := os.CreateTemp("", "greetd-config-*.toml")
if err != nil {
return fmt.Errorf("failed to create temp greetd config: %w", err)
}
defer os.Remove(tmpFile.Name())
if _, err := tmpFile.WriteString(content); err != nil {
_ = tmpFile.Close()
return fmt.Errorf("failed to write temp greetd config: %w", err)
}
if err := tmpFile.Close(); err != nil {
return fmt.Errorf("failed to close temp greetd config: %w", err)
}
if err := privesc.Run(context.Background(), sudoPassword, "mkdir", "-p", "/etc/greetd"); err != nil {
return fmt.Errorf("failed to create /etc/greetd: %w", err)
}
if err := privesc.Run(context.Background(), sudoPassword, "install", "-o", "root", "-g", "root", "-m", "0644", tmpFile.Name(), configPath); err != nil {
return fmt.Errorf("failed to install greetd config: %w", err)
}
if logFunc != nil && successMsg != "" {
logFunc(successMsg)
}
return nil
}
func clearGreeterAutoLoginMemory(memoryPath, sudoPassword string) error {
data, err := readGreeterMemoryFile(memoryPath, sudoPassword)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if len(strings.TrimSpace(string(data))) == 0 {
return nil
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return fmt.Errorf("failed to parse greeter memory at %s: %w", memoryPath, err)
}
if _, ok := raw["autoLoginEnabled"]; !ok {
return nil
}
delete(raw, "autoLoginEnabled")
encoded, err := json.MarshalIndent(raw, "", " ")
if err != nil {
return err
}
if len(encoded) == 0 || string(encoded) == "null" {
encoded = []byte("{}")
}
encoded = append(encoded, '\n')
if err := os.WriteFile(memoryPath, encoded, 0o644); err == nil {
return nil
} else if !os.IsPermission(err) {
return err
}
tmpFile, err := os.CreateTemp("", "greeter-memory-*.json")
if err != nil {
return fmt.Errorf("failed to create temp greeter memory file: %w", err)
}
defer os.Remove(tmpFile.Name())
if _, err := tmpFile.Write(encoded); err != nil {
_ = tmpFile.Close()
return fmt.Errorf("failed to write temp greeter memory file: %w", err)
}
if err := tmpFile.Close(); err != nil {
return fmt.Errorf("failed to close temp greeter memory file: %w", err)
}
greeterUser := DetectGreeterUser()
greeterGroup := DetectGreeterGroup()
owner := greeterUser + ":" + greeterGroup
if err := privesc.Run(context.Background(), sudoPassword, "install", "-o", greeterUser, "-g", greeterGroup, "-m", "0664", tmpFile.Name(), memoryPath); err != nil {
if fallbackErr := privesc.Run(context.Background(), sudoPassword, "install", "-o", "root", "-g", greeterGroup, "-m", "0664", tmpFile.Name(), memoryPath); fallbackErr != nil {
return fmt.Errorf("failed to install greeter memory file (preferred %s: %w; fallback root:%s: %v)", owner, err, greeterGroup, fallbackErr)
}
}
return nil
}
func readGreeterMemoryFile(memoryPath, sudoPassword string) ([]byte, error) {
data, err := os.ReadFile(memoryPath)
if err == nil || !os.IsPermission(err) {
return data, err
}
tmpFile, err := os.CreateTemp("", "greeter-memory-read-*")
if err != nil {
return nil, fmt.Errorf("failed to create temp file for greeter memory read: %w", err)
}
tmpPath := tmpFile.Name()
_ = tmpFile.Close()
defer os.Remove(tmpPath)
if err := privesc.Run(context.Background(), sudoPassword, "cp", "-f", memoryPath, tmpPath); err != nil {
return nil, fmt.Errorf("failed to read greeter memory at %s: %w", memoryPath, err)
}
return os.ReadFile(tmpPath)
}
func SyncGreetdAutoLogin(cacheDir, homeDir string, logFunc func(string), sudoPassword string) error {
enabled, loginUser, sessionExec, err := resolveGreeterAutoLoginState(cacheDir, homeDir)
if err != nil {
return err
}
configPath := "/etc/greetd/config.toml"
configContent := ""
if data, readErr := os.ReadFile(configPath); readErr == nil {
configContent = string(data)
} else if !os.IsNotExist(readErr) {
return fmt.Errorf("failed to read greetd config: %w", readErr)
}
if !enabled {
memoryPath := filepath.Join(cacheDir, ".local/state/memory.json")
if err := clearGreeterAutoLoginMemory(memoryPath, sudoPassword); err != nil && logFunc != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to clear greeter auto-login memory flag: %v", err))
}
newConfig := upsertInitialSession(configContent, "", "", false)
if newConfig == configContent {
if logFunc != nil {
logFunc("✓ Greeter auto-login disabled")
}
return nil
}
return writeGreetdConfig(configPath, newConfig, logFunc, sudoPassword, "✓ Disabled greeter auto-login")
}
if loginUser == "" || sessionExec == "" {
if logFunc != nil {
logFunc("⚠ Greeter auto-login is enabled but user or session is not configured yet. Log in manually once, then run sync.")
}
newConfig := upsertInitialSession(configContent, "", "", false)
if newConfig != configContent {
return writeGreetdConfig(configPath, newConfig, nil, sudoPassword, "")
}
return nil
}
newConfig := upsertInitialSession(configContent, loginUser, sessionExec, true)
if newConfig == configContent {
if logFunc != nil {
logFunc(fmt.Sprintf("✓ Greeter auto-login already configured for %s", loginUser))
}
memoryPath := filepath.Join(cacheDir, ".local/state/memory.json")
_ = clearGreeterAutoLoginMemory(memoryPath, sudoPassword)
return nil
}
if err := writeGreetdConfig(configPath, newConfig, logFunc, sudoPassword, fmt.Sprintf("✓ Configured greeter auto-login for %s", loginUser)); err != nil {
return err
}
memoryPath := filepath.Join(cacheDir, ".local/state/memory.json")
if err := clearGreeterAutoLoginMemory(memoryPath, sudoPassword); err != nil && logFunc != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to clear greeter auto-login memory flag: %v", err))
}
return nil
}
func SyncGreeterAutoLoginOnly(logFunc func(string), sudoPassword string) error {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err)
}
return SyncGreetdAutoLogin(GreeterCacheDir, homeDir, logFunc, sudoPassword)
}
func DetectGreeterUser() string {
passwdData, err := os.ReadFile("/etc/passwd")
if err == nil {
@@ -572,6 +988,7 @@ func EnsureGreeterCacheDir(logFunc func(string), sudoPassword string) error {
}
runtimeDirs := []string{
filepath.Join(cacheDir, "users"),
filepath.Join(cacheDir, ".local"),
filepath.Join(cacheDir, ".local", "state"),
filepath.Join(cacheDir, ".local", "share"),
@@ -1255,6 +1672,20 @@ func SyncDMSConfigs(dmsPath, compositor string, logFunc func(string), sudoPasswo
return fmt.Errorf("greeter wallpaper override sync failed: %w", err)
}
currentUser, err := user.Current()
if err != nil {
return fmt.Errorf("failed to resolve syncing user for per-user greeter cache: %w", err)
}
if err := syncUserGreeterCacheSlot(homeDir, cacheDir, currentUser.Username, state, logFunc, userSlotSyncOpts{
sudoPassword: sudoPassword,
}); err != nil {
return fmt.Errorf("per-user greeter cache sync failed: %w", err)
}
if err := SyncGreetdAutoLogin(cacheDir, homeDir, logFunc, sudoPassword); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: greeter auto-login sync failed: %v", err))
}
if strings.ToLower(compositor) != "niri" {
return nil
}
@@ -1719,29 +2150,22 @@ vt = 1
commandLine := fmt.Sprintf(`command = "%s"`, commandValue)
newConfig := upsertDefaultSession(configContent, greeterUser, commandLine)
tmpFile, err := os.CreateTemp("", "greetd-config-*.toml")
if err != nil {
return fmt.Errorf("failed to create temp greetd config: %w", err)
}
defer os.Remove(tmpFile.Name())
if _, err := tmpFile.WriteString(newConfig); err != nil {
_ = tmpFile.Close()
return fmt.Errorf("failed to write temp greetd config: %w", err)
}
if err := tmpFile.Close(); err != nil {
return fmt.Errorf("failed to close temp greetd config: %w", err)
homeDir, homeErr := os.UserHomeDir()
if homeErr == nil {
enabled, loginUser, sessionExec, resolveErr := resolveGreeterAutoLoginState(GreeterCacheDir, homeDir)
if resolveErr != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to resolve greeter auto-login state: %v", resolveErr))
} else if enabled && loginUser != "" && sessionExec != "" {
newConfig = upsertInitialSession(newConfig, loginUser, sessionExec, true)
} else {
newConfig = upsertInitialSession(newConfig, "", "", false)
}
}
if err := privesc.Run(context.Background(), sudoPassword, "mkdir", "-p", "/etc/greetd"); err != nil {
return fmt.Errorf("failed to create /etc/greetd: %w", err)
if err := writeGreetdConfig(configPath, newConfig, logFunc, sudoPassword, fmt.Sprintf("✓ Updated greetd configuration (user: %s, command: %s)", greeterUser, commandValue)); err != nil {
return err
}
if err := privesc.Run(context.Background(), sudoPassword, "install", "-o", "root", "-g", "root", "-m", "0644", tmpFile.Name(), configPath); err != nil {
return fmt.Errorf("failed to install greetd config: %w", err)
}
logFunc(fmt.Sprintf("✓ Updated greetd configuration (user: %s, command: %s)", greeterUser, commandValue))
return nil
}
+145
View File
@@ -3,6 +3,7 @@ package greeter
import (
"os"
"path/filepath"
"strings"
"testing"
)
@@ -96,3 +97,147 @@ func TestResolveGreeterThemeSyncState(t *testing.T) {
})
}
}
func TestUpsertInitialSession(t *testing.T) {
t.Parallel()
baseConfig := `[terminal]
vt = 1
[default_session]
user = "greeter"
command = "/usr/bin/dms-greeter --command niri"
`
t.Run("inserts initial session", func(t *testing.T) {
t.Parallel()
got := upsertInitialSession(baseConfig, "alice", "niri", true)
if !strings.Contains(got, "[initial_session]") {
t.Fatalf("expected [initial_session] section, got:\n%s", got)
}
if !strings.Contains(got, `user = "alice"`) {
t.Fatalf("expected alice user in initial session, got:\n%s", got)
}
if !strings.Contains(got, `env XDG_SESSION_TYPE=wayland sh -c 'exec niri'`) {
t.Fatalf("expected wrapped session command, got:\n%s", got)
}
})
t.Run("updates existing initial session", func(t *testing.T) {
t.Parallel()
existing := baseConfig + `
[initial_session]
user = "bob"
command = "old-command"
`
got := upsertInitialSession(existing, "alice", "Hyprland", true)
if strings.Contains(got, `user = "bob"`) {
t.Fatalf("expected bob to be replaced, got:\n%s", got)
}
if !strings.Contains(got, `exec Hyprland`) {
t.Fatalf("expected Hyprland command, got:\n%s", got)
}
})
t.Run("removes initial session when disabled", func(t *testing.T) {
t.Parallel()
existing := baseConfig + `
[initial_session]
user = "alice"
command = "niri"
`
got := upsertInitialSession(existing, "", "", false)
if strings.Contains(got, "[initial_session]") {
t.Fatalf("expected initial session removed, got:\n%s", got)
}
if !strings.Contains(got, "[default_session]") {
t.Fatalf("expected default session preserved, got:\n%s", got)
}
})
}
func TestStripDesktopExecCodes(t *testing.T) {
t.Parallel()
got := stripDesktopExecCodes("niri --session %f")
want := "niri --session"
if got != want {
t.Fatalf("stripDesktopExecCodes = %q, want %q", got, want)
}
}
func TestResolveGreeterAutoLoginState(t *testing.T) {
t.Parallel()
cacheDir := t.TempDir()
homeDir := t.TempDir()
writeTestFile(t, filepath.Join(cacheDir, "settings.json"), `{
"greeterAutoLogin": true,
"greeterRememberLastUser": true,
"greeterRememberLastSession": true
}`)
writeTestFile(t, filepath.Join(cacheDir, ".local/state/memory.json"), `{
"lastSuccessfulUser": "alice",
"lastSessionExec": "niri"
}`)
enabled, loginUser, sessionExec, err := resolveGreeterAutoLoginState(cacheDir, homeDir)
if err != nil {
t.Fatalf("resolveGreeterAutoLoginState returned error: %v", err)
}
if !enabled || loginUser != "alice" || sessionExec != "niri" {
t.Fatalf("got enabled=%v user=%q exec=%q", enabled, loginUser, sessionExec)
}
}
func TestResolveGreeterAutoLoginStateIgnoresMemoryFlag(t *testing.T) {
t.Parallel()
cacheDir := t.TempDir()
homeDir := t.TempDir()
writeTestFile(t, filepath.Join(cacheDir, "settings.json"), `{
"greeterAutoLogin": false,
"greeterRememberLastUser": true,
"greeterRememberLastSession": true
}`)
writeTestFile(t, filepath.Join(cacheDir, ".local/state/memory.json"), `{
"autoLoginEnabled": true,
"lastSuccessfulUser": "alice",
"lastSessionExec": "niri"
}`)
enabled, loginUser, sessionExec, err := resolveGreeterAutoLoginState(cacheDir, homeDir)
if err != nil {
t.Fatalf("resolveGreeterAutoLoginState returned error: %v", err)
}
if enabled || loginUser != "" || sessionExec != "" {
t.Fatalf("expected disabled with empty user/exec, got enabled=%v user=%q exec=%q", enabled, loginUser, sessionExec)
}
}
func TestClearGreeterAutoLoginMemory(t *testing.T) {
t.Parallel()
memoryPath := filepath.Join(t.TempDir(), "memory.json")
writeTestFile(t, memoryPath, `{
"autoLoginEnabled": true,
"lastSuccessfulUser": "alice"
}`)
if err := clearGreeterAutoLoginMemory(memoryPath, ""); err != nil {
t.Fatalf("clearGreeterAutoLoginMemory returned error: %v", err)
}
data, err := os.ReadFile(memoryPath)
if err != nil {
t.Fatalf("failed to read memory file: %v", err)
}
if strings.Contains(string(data), "autoLoginEnabled") {
t.Fatalf("expected autoLoginEnabled removed, got: %s", string(data))
}
if !strings.Contains(string(data), "lastSuccessfulUser") {
t.Fatalf("expected other memory fields preserved, got: %s", string(data))
}
}
+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"])
}
}
+684 -29
View File
@@ -68,6 +68,8 @@ func (h *HyprlandProvider) GetCheatSheet() (*keybinds.CheatSheet, error) {
Effective: result.DMSStatus.Effective,
OverriddenBy: result.DMSStatus.OverriddenBy,
StatusMessage: result.DMSStatus.StatusMessage,
ConfigFormat: result.DMSStatus.ConfigFormat,
ReadOnly: result.DMSStatus.ReadOnly,
}
}
@@ -219,6 +221,9 @@ func (h *HyprlandProvider) validateAction(action string) error {
}
func (h *HyprlandProvider) SetBind(key, action, description string, options map[string]any) error {
if err := h.ensureWritableConfig(); err != nil {
return err
}
if err := h.validateAction(action); err != nil {
return err
}
@@ -242,9 +247,10 @@ func (h *HyprlandProvider) SetBind(key, action, description string, options map[
}
}
normalizedKey := strings.ToLower(key)
canonicalKey := canonicalHyprlandOverrideKey(key)
normalizedKey := hyprlandOverrideMapKey(canonicalKey)
existingBinds[normalizedKey] = &hyprlandOverrideBind{
Key: key,
Key: canonicalKey,
Action: action,
Description: description,
Flags: flags,
@@ -255,21 +261,28 @@ func (h *HyprlandProvider) SetBind(key, action, description string, options map[
}
func (h *HyprlandProvider) RemoveBind(key string) error {
if err := h.ensureWritableConfig(); err != nil {
return err
}
existingBinds, err := h.loadOverrideBinds()
if err != nil {
return nil
}
normalizedKey := strings.ToLower(key)
existingBinds[normalizedKey] = &hyprlandOverrideBind{Key: key, Unbind: true}
canonicalKey := canonicalHyprlandOverrideKey(key)
normalizedKey := hyprlandOverrideMapKey(canonicalKey)
existingBinds[normalizedKey] = &hyprlandOverrideBind{Key: canonicalKey, Unbind: true}
return h.writeOverrideBinds(existingBinds)
}
func (h *HyprlandProvider) ResetBind(key string) error {
if err := h.ensureWritableConfig(); err != nil {
return err
}
existingBinds, err := h.loadOverrideBinds()
if err != nil {
return nil
}
normalizedKey := strings.ToLower(key)
normalizedKey := hyprlandOverrideMapKey(key)
delete(existingBinds, normalizedKey)
return h.writeOverrideBinds(existingBinds)
}
@@ -284,10 +297,46 @@ type hyprlandOverrideBind struct {
Unbind bool
}
func (h *HyprlandProvider) ensureWritableConfig() error {
if h.isLegacyConfigReadOnly() {
return fmt.Errorf("hyprland legacy conf configs are read-only; run dms setup to migrate to Lua before editing keybinds")
}
return nil
}
func (h *HyprlandProvider) isLegacyConfigReadOnly() bool {
expanded, err := utils.ExpandPath(h.configPath)
if err != nil {
expanded = h.configPath
}
luaPath := filepath.Join(expanded, "hyprland.lua")
if st, err := os.Stat(luaPath); err == nil && st.Mode().IsRegular() {
return false
}
confPath := filepath.Join(expanded, "hyprland.conf")
if st, err := os.Stat(confPath); err == nil && st.Mode().IsRegular() {
return true
}
return false
}
func (h *HyprlandProvider) loadOverrideBinds() (map[string]*hyprlandOverrideBind, error) {
return readLuaOrHyprlangOverride(h.GetOverridePath())
}
func canonicalHyprlandOverrideKey(key string) string {
trimmed := strings.TrimSpace(key)
normalized := luaKeyComboToInternalKey(trimmed)
if normalized == "" {
return trimmed
}
return normalized
}
func hyprlandOverrideMapKey(key string) string {
return strings.ToLower(canonicalHyprlandOverrideKey(key))
}
func (h *HyprlandProvider) getBindSortPriority(action string) int {
switch {
case strings.HasPrefix(action, "exec") && strings.Contains(action, "dms"):
@@ -368,24 +417,629 @@ func normalizeLuaBindKeyPart(part string) string {
return part
}
type luaField struct {
name string
value string
}
func luaDispatcherTableCall(funcName string, fields ...luaField) string {
parts := make([]string, 0, len(fields))
for _, field := range fields {
if field.name == "" || field.value == "" {
continue
}
parts = append(parts, field.name+" = "+field.value)
}
return fmt.Sprintf(`%s({ %s })`, funcName, strings.Join(parts, ", "))
}
func luaStringField(name, value string) luaField {
return luaField{name: name, value: strconv.Quote(strings.TrimSpace(value))}
}
func luaBoolField(name string, value bool) luaField {
if value {
return luaField{name: name, value: "true"}
}
return luaField{name: name, value: "false"}
}
func luaNumberOrStringField(name, value string) luaField {
value = strings.TrimSpace(value)
if isBareLuaNumber(value) {
return luaField{name: name, value: value}
}
return luaStringField(name, value)
}
func isBareLuaNumber(value string) bool {
if value == "" || strings.HasPrefix(value, "+") {
return false
}
if value[0] == '-' {
value = value[1:]
}
if value == "" {
return false
}
digitsBeforeDot := 0
i := 0
for i < len(value) && value[i] >= '0' && value[i] <= '9' {
digitsBeforeDot++
i++
}
digitsAfterDot := 0
if i < len(value) && value[i] == '.' {
i++
for i < len(value) && value[i] >= '0' && value[i] <= '9' {
digitsAfterDot++
i++
}
}
return i == len(value) && (digitsBeforeDot > 0 || digitsAfterDot > 0)
}
func splitHyprlandAction(action string) (dispatcher, params string) {
action = strings.TrimSpace(action)
if action == "" {
return "", ""
}
idx := strings.IndexFunc(action, func(r rune) bool {
return r == ' ' || r == '\t' || r == '\r' || r == '\n'
})
if idx < 0 {
return strings.ToLower(action), ""
}
return strings.ToLower(strings.TrimSpace(action[:idx])), strings.TrimSpace(action[idx+1:])
}
func isKnownHyprlandDispatcher(dispatcher string) bool {
switch dispatcher {
case "exec", "execr", "spawn",
"killactive", "forcekillactive", "closewindow", "killwindow",
"signal", "signalwindow", "togglefloating", "setfloating", "settiled",
"workspace", "renameworkspace", "fullscreen", "fullscreenstate", "fakefullscreen",
"movetoworkspace", "movetoworkspacesilent", "pseudo", "movefocus",
"movewindow", "swapwindow", "centerwindow", "togglegroup", "changegroupactive",
"movegroupwindow", "focusmonitor", "movecursortocorner", "movecursor",
"workspaceopt", "exit", "movecurrentworkspacetomonitor", "focusworkspaceoncurrentmonitor",
"moveworkspacetomonitor", "togglespecialworkspace", "forcerendererreload",
"resizeactive", "moveactive", "cyclenext", "focuswindowbyclass", "focuswindow",
"tagwindow", "toggleswallow", "submap", "pass", "sendshortcut", "sendkeystate",
"layoutmsg", "splitratio", "dpms", "movewindowpixel", "resizewindowpixel",
"swapnext", "swapactiveworkspaces", "pin", "mouse", "bringactivetotop",
"alterzorder", "focusurgentorlast", "focuscurrentorlast", "lockgroups",
"lockactivegroup", "moveintogroup", "moveoutofgroup", "movewindoworgroup",
"moveintoorcreategroup", "setignoregrouplock", "denywindowfromgroup", "event",
"global", "setprop", "forceidle":
return true
default:
return false
}
}
func firstParam(params string) (head, rest string) {
params = strings.TrimSpace(params)
if params == "" {
return "", ""
}
fields := strings.Fields(params)
if len(fields) == 0 {
return "", ""
}
head = fields[0]
rest = strings.TrimSpace(strings.TrimPrefix(params, head))
return head, rest
}
func xyParams(params string) (x, y string, relative bool, ok bool) {
fields := strings.Fields(params)
if len(fields) > 0 && strings.EqualFold(fields[0], "exact") {
relative = false
fields = fields[1:]
} else {
relative = true
}
if len(fields) < 2 {
return "", "", relative, false
}
return fields[0], fields[1], relative, true
}
func dispatcherWorkspaceMove(params string, follow *bool) string {
workspace, window := firstParam(params)
if workspace == "" {
return ""
}
fields := []luaField{luaStringField("workspace", workspace)}
if follow != nil {
fields = append(fields, luaBoolField("follow", *follow))
}
if window != "" {
fields = append(fields, luaStringField("window", window))
}
return luaDispatcherTableCall("hl.dsp.window.move", fields...)
}
func dispatcherActiveMoveResize(funcName, params string) string {
x, y, relative, ok := xyParams(params)
if !ok {
return ""
}
if !isBareLuaNumber(x) || !isBareLuaNumber(y) {
return ""
}
return luaDispatcherTableCall(funcName,
luaNumberOrStringField("x", x),
luaNumberOrStringField("y", y),
luaBoolField("relative", relative),
)
}
func dispatcherWindowMoveResize(funcName, params string) string {
geometry, window := splitCommaParams(params)
x, y, relative, ok := xyParams(geometry)
if !ok {
return ""
}
if !isBareLuaNumber(x) || !isBareLuaNumber(y) {
return ""
}
fields := []luaField{
luaNumberOrStringField("x", x),
luaNumberOrStringField("y", y),
luaBoolField("relative", relative),
}
if window != "" {
fields = append(fields, luaStringField("window", window))
}
return luaDispatcherTableCall(funcName, fields...)
}
func splitCommaParams(params string) (left, right string) {
left = strings.TrimSpace(params)
if idx := strings.Index(left, ","); idx >= 0 {
right = strings.TrimSpace(left[idx+1:])
left = strings.TrimSpace(left[:idx])
}
return left, right
}
func luaHyprctlDispatchFunction(action string) string {
return fmt.Sprintf(`function() hl.exec_cmd(%s) end`, strconv.Quote("hyprctl dispatch "+strings.TrimSpace(action)))
}
func luaToggleActionValue(params string) string {
switch strings.ToLower(strings.TrimSpace(params)) {
case "on", "enable", "enabled", "set", "lock":
return "on"
case "off", "disable", "disabled", "unset", "unlock":
return "off"
default:
return "toggle"
}
}
func dispatcherToggleTableCall(funcName, params string) string {
return luaDispatcherTableCall(funcName, luaStringField("action", luaToggleActionValue(params)))
}
func dispatcherCycleNext(params string) string {
params = strings.TrimSpace(strings.ToLower(params))
if params == "" {
return `hl.dsp.window.cycle_next()`
}
fields := []luaField{}
for _, field := range strings.Fields(params) {
switch field {
case "prev", "previous", "b":
fields = append(fields, luaBoolField("next", false))
case "next", "f":
fields = append(fields, luaBoolField("next", true))
case "tiled":
fields = append(fields, luaBoolField("tiled", true))
case "floating":
fields = append(fields, luaBoolField("floating", true))
}
}
if len(fields) == 0 {
return ""
}
return luaDispatcherTableCall("hl.dsp.window.cycle_next", fields...)
}
func dispatcherSwapNext(params string) string {
switch strings.ToLower(strings.TrimSpace(params)) {
case "prev", "previous", "b":
return `hl.dsp.window.swap({ prev = true })`
default:
return `hl.dsp.window.swap({ next = true })`
}
}
func dispatcherGroupActive(params string) string {
switch strings.ToLower(strings.TrimSpace(params)) {
case "f", "next", "forward":
return `hl.dsp.group.next()`
case "b", "prev", "previous", "backward":
return `hl.dsp.group.prev()`
}
if isBareLuaNumber(params) {
return luaDispatcherTableCall("hl.dsp.group.active", luaNumberOrStringField("index", params))
}
return ""
}
func dispatcherMoveGroupWindow(params string) string {
switch strings.ToLower(strings.TrimSpace(params)) {
case "b", "prev", "previous", "backward":
return `hl.dsp.group.move_window({ forward = false })`
default:
return `hl.dsp.group.move_window({ forward = true })`
}
}
func dispatcherCursorMove(params string) string {
x, y, _, ok := xyParams(params)
if !ok || !isBareLuaNumber(x) || !isBareLuaNumber(y) {
return ""
}
return luaDispatcherTableCall("hl.dsp.cursor.move", luaNumberOrStringField("x", x), luaNumberOrStringField("y", y))
}
func dispatcherSignal(params string) string {
signal, window := firstParam(params)
if signal == "" || !isBareLuaNumber(signal) {
return ""
}
fields := []luaField{luaNumberOrStringField("signal", signal)}
if window != "" {
fields = append(fields, luaStringField("window", window))
}
return luaDispatcherTableCall("hl.dsp.window.signal", fields...)
}
func dispatcherSignalWindow(params string) string {
window, rest := firstParam(params)
signal, _ := firstParam(rest)
if signal == "" || !isBareLuaNumber(signal) {
return ""
}
fields := []luaField{luaNumberOrStringField("signal", signal)}
if window != "" {
fields = append(fields, luaStringField("window", window))
}
return luaDispatcherTableCall("hl.dsp.window.signal", fields...)
}
func dispatcherTagWindow(params string) string {
tag, window := firstParam(params)
if tag == "" {
return ""
}
fields := []luaField{luaStringField("tag", tag)}
if window != "" {
fields = append(fields, luaStringField("window", window))
}
return luaDispatcherTableCall("hl.dsp.window.tag", fields...)
}
func luaActionStringFromKnownHyprlandAction(action string) (string, bool) {
dispatcher, params := splitHyprlandAction(action)
switch dispatcher {
case "spawn", "exec":
return fmt.Sprintf(`hl.dsp.exec_cmd(%s)`, strconv.Quote(params)), true
case "execr":
return fmt.Sprintf(`hl.dsp.exec_raw(%s)`, strconv.Quote(params)), true
case "killactive":
return `hl.dsp.window.close()`, true
case "forcekillactive":
return `hl.dsp.window.kill()`, true
case "closewindow":
if params == "" {
return `hl.dsp.window.close()`, true
}
return luaDispatcherTableCall("hl.dsp.window.close", luaStringField("window", params)), true
case "killwindow":
if params == "" {
return `hl.dsp.window.kill()`, true
}
return luaDispatcherTableCall("hl.dsp.window.kill", luaStringField("window", params)), true
case "togglefloating":
return dispatcherToggleTableCall("hl.dsp.window.float", "toggle"), true
case "setfloating":
return dispatcherToggleTableCall("hl.dsp.window.float", "on"), true
case "settiled":
return dispatcherToggleTableCall("hl.dsp.window.float", "off"), true
case "fullscreen":
mode := strings.TrimSpace(params)
switch mode {
case "", "0":
return `hl.dsp.window.fullscreen({ mode = "fullscreen", action = "toggle" })`, true
case "1":
return `hl.dsp.window.fullscreen({ mode = "maximized", action = "toggle" })`, true
}
return luaHyprctlDispatchFunction(action), true
case "fullscreenstate":
internal, rest := firstParam(params)
client, _ := firstParam(rest)
if internal != "" && client != "" {
return luaDispatcherTableCall("hl.dsp.window.fullscreen_state",
luaNumberOrStringField("internal", internal),
luaNumberOrStringField("client", client),
), true
}
case "fakefullscreen":
return luaHyprctlDispatchFunction(action), true
case "pin":
if params == "" {
return `hl.dsp.window.pin()`, true
}
return dispatcherToggleTableCall("hl.dsp.window.pin", params), true
case "pseudo":
return dispatcherToggleTableCall("hl.dsp.window.pseudo", params), true
case "centerwindow":
return `hl.dsp.window.center()`, true
case "resizewindow":
return `hl.dsp.window.resize()`, true
case "movewindow":
if params == "" {
return `hl.dsp.window.drag()`, true
}
if monitor, ok := strings.CutPrefix(params, "mon:"); ok {
return luaDispatcherTableCall("hl.dsp.window.move", luaStringField("monitor", monitor)), true
}
return luaDispatcherTableCall("hl.dsp.window.move", luaStringField("direction", params)), true
case "swapwindow":
if params == "" {
return "", false
}
return luaDispatcherTableCall("hl.dsp.window.swap", luaStringField("direction", params)), true
case "swapnext":
return dispatcherSwapNext(params), true
case "resizeactive":
if expr := dispatcherActiveMoveResize("hl.dsp.window.resize", params); expr != "" {
return expr, true
}
return luaHyprctlDispatchFunction(action), true
case "moveactive":
if expr := dispatcherActiveMoveResize("hl.dsp.window.move", params); expr != "" {
return expr, true
}
return luaHyprctlDispatchFunction(action), true
case "resizewindowpixel":
if expr := dispatcherWindowMoveResize("hl.dsp.window.resize", params); expr != "" {
return expr, true
}
return luaHyprctlDispatchFunction(action), true
case "movewindowpixel":
if expr := dispatcherWindowMoveResize("hl.dsp.window.move", params); expr != "" {
return expr, true
}
return luaHyprctlDispatchFunction(action), true
case "workspace":
if params == "" {
return "", false
}
return luaDispatcherTableCall("hl.dsp.focus", luaStringField("workspace", params)), true
case "focusworkspaceoncurrentmonitor":
if params == "" {
return "", false
}
return luaDispatcherTableCall("hl.dsp.focus", luaStringField("workspace", params), luaBoolField("on_current_monitor", true)), true
case "movetoworkspace":
if expr := dispatcherWorkspaceMove(params, nil); expr != "" {
return expr, true
}
case "movetoworkspacesilent":
follow := false
if expr := dispatcherWorkspaceMove(params, &follow); expr != "" {
return expr, true
}
case "togglespecialworkspace":
if params == "" {
return `hl.dsp.workspace.toggle_special()`, true
}
return fmt.Sprintf(`hl.dsp.workspace.toggle_special(%s)`, strconv.Quote(params)), true
case "renameworkspace":
workspace, name := firstParam(params)
if workspace != "" {
fields := []luaField{luaStringField("workspace", workspace)}
if name != "" {
fields = append(fields, luaStringField("name", name))
}
return luaDispatcherTableCall("hl.dsp.workspace.rename", fields...), true
}
case "movecurrentworkspacetomonitor":
if params != "" {
return luaDispatcherTableCall("hl.dsp.workspace.move", luaStringField("monitor", params)), true
}
case "moveworkspacetomonitor":
workspace, monitor := firstParam(params)
if workspace != "" && monitor != "" {
return luaDispatcherTableCall("hl.dsp.workspace.move", luaStringField("workspace", workspace), luaStringField("monitor", monitor)), true
}
case "workspaceopt":
return luaHyprctlDispatchFunction(action), true
case "swapactiveworkspaces":
monitor1, rest := firstParam(params)
monitor2, _ := firstParam(rest)
if monitor1 != "" && monitor2 != "" {
return luaDispatcherTableCall("hl.dsp.workspace.swap_monitors", luaStringField("monitor1", monitor1), luaStringField("monitor2", monitor2)), true
}
case "movefocus":
if params != "" {
return luaDispatcherTableCall("hl.dsp.focus", luaStringField("direction", params)), true
}
case "focusmonitor":
if params != "" {
return luaDispatcherTableCall("hl.dsp.focus", luaStringField("monitor", params)), true
}
case "focuswindow":
if params != "" {
return luaDispatcherTableCall("hl.dsp.focus", luaStringField("window", params)), true
}
case "focuswindowbyclass":
if params != "" {
return luaDispatcherTableCall("hl.dsp.focus", luaStringField("window", "class:"+params)), true
}
case "focuscurrentorlast":
return `hl.dsp.focus({ last = true })`, true
case "focusurgentorlast":
return `hl.dsp.focus({ urgent_or_last = true })`, true
case "cyclenext":
if expr := dispatcherCycleNext(params); expr != "" {
return expr, true
}
return luaHyprctlDispatchFunction(action), true
case "layoutmsg":
if params != "" {
return fmt.Sprintf(`hl.dsp.layout(%s)`, strconv.Quote(params)), true
}
case "splitratio":
return luaHyprctlDispatchFunction(action), true
case "alterzorder":
mode, window := firstParam(params)
if mode != "" {
fields := []luaField{luaStringField("mode", mode)}
if window != "" {
fields = append(fields, luaStringField("window", window))
}
return luaDispatcherTableCall("hl.dsp.window.alter_zorder", fields...), true
}
case "setprop":
window, rest := firstParam(params)
prop, value := firstParam(rest)
if window != "" && prop != "" && value != "" {
return luaDispatcherTableCall("hl.dsp.window.set_prop",
luaStringField("window", window),
luaStringField("prop", prop),
luaStringField("value", value),
), true
}
case "bringactivetotop":
return `hl.dsp.window.bring_to_top()`, true
case "toggleswallow":
return `hl.dsp.window.toggle_swallow()`, true
case "signal":
if expr := dispatcherSignal(params); expr != "" {
return expr, true
}
case "signalwindow":
if expr := dispatcherSignalWindow(params); expr != "" {
return expr, true
}
case "tagwindow":
if expr := dispatcherTagWindow(params); expr != "" {
return expr, true
}
case "dpms":
dpmsAction := strings.TrimSpace(params)
switch dpmsAction {
case "on":
dpmsAction = "enable"
case "off":
dpmsAction = "disable"
}
if dpmsAction == "" {
return `hl.dsp.dpms({})`, true
}
return luaDispatcherTableCall("hl.dsp.dpms", luaStringField("action", dpmsAction)), true
case "exit":
return `hl.dsp.exit()`, true
case "submap":
return fmt.Sprintf(`hl.dsp.submap(%s)`, strconv.Quote(params)), true
case "global":
return fmt.Sprintf(`hl.dsp.global(%s)`, strconv.Quote(params)), true
case "event":
return fmt.Sprintf(`hl.dsp.event(%s)`, strconv.Quote(params)), true
case "pass":
if params == "" {
return `hl.dsp.pass({})`, true
}
return luaDispatcherTableCall("hl.dsp.pass", luaStringField("window", params)), true
case "sendshortcut":
mod, rest := firstParam(params)
key, window := firstParam(rest)
if mod != "" && key != "" {
fields := []luaField{luaStringField("mods", mod), luaStringField("key", key)}
if window != "" {
fields = append(fields, luaStringField("window", window))
}
return luaDispatcherTableCall("hl.dsp.send_shortcut", fields...), true
}
case "sendkeystate":
mod, rest := firstParam(params)
key, rest := firstParam(rest)
state, window := firstParam(rest)
if mod != "" && key != "" && state != "" {
fields := []luaField{luaStringField("mods", mod), luaStringField("key", key), luaStringField("state", state)}
if window != "" {
fields = append(fields, luaStringField("window", window))
}
return luaDispatcherTableCall("hl.dsp.send_key_state", fields...), true
}
case "movecursortocorner":
if params != "" && isBareLuaNumber(params) {
return luaDispatcherTableCall("hl.dsp.cursor.move_to_corner", luaNumberOrStringField("corner", params)), true
}
case "movecursor":
if expr := dispatcherCursorMove(params); expr != "" {
return expr, true
}
case "togglegroup":
return `hl.dsp.group.toggle()`, true
case "changegroupactive":
if expr := dispatcherGroupActive(params); expr != "" {
return expr, true
}
return luaHyprctlDispatchFunction(action), true
case "movegroupwindow":
return dispatcherMoveGroupWindow(params), true
case "moveintogroup":
if params != "" {
return luaDispatcherTableCall("hl.dsp.window.move", luaStringField("into_group", params)), true
}
case "moveintoorcreategroup":
if params != "" {
return luaDispatcherTableCall("hl.dsp.window.move", luaStringField("into_or_create_group", params)), true
}
case "moveoutofgroup":
if params != "" {
return luaDispatcherTableCall("hl.dsp.window.move", luaStringField("out_of_group", params)), true
}
return luaDispatcherTableCall("hl.dsp.window.move", luaBoolField("out_of_group", true)), true
case "movewindoworgroup":
if params != "" {
return luaDispatcherTableCall("hl.dsp.window.move", luaStringField("direction", params), luaBoolField("group_aware", true)), true
}
case "lockgroups":
return dispatcherToggleTableCall("hl.dsp.group.lock", params), true
case "lockactivegroup":
return dispatcherToggleTableCall("hl.dsp.group.lock_active", params), true
case "denywindowfromgroup":
return dispatcherToggleTableCall("hl.dsp.window.deny_from_group", params), true
case "setignoregrouplock":
return luaHyprctlDispatchFunction(action), true
case "forcerendererreload":
return `hl.dsp.force_renderer_reload()`, true
case "forceidle":
if params != "" && isBareLuaNumber(params) {
return fmt.Sprintf(`hl.dsp.force_idle(%s)`, params), true
}
}
if isKnownHyprlandDispatcher(dispatcher) {
return luaHyprctlDispatchFunction(action), true
}
return "", false
}
func luaActionStringFromHyprlangAction(action string) string {
action = strings.TrimSpace(action)
if strings.HasPrefix(action, "spawn ") {
return fmt.Sprintf(`hl.dsp.exec_cmd(%s)`, strconv.Quote(strings.TrimSpace(strings.TrimPrefix(action, "spawn "))))
}
if strings.HasPrefix(action, "exec ") {
return fmt.Sprintf(`hl.dsp.exec_cmd(%s)`, strconv.Quote(strings.TrimPrefix(action, "exec ")))
}
switch action {
case "killactive":
return `hl.dsp.window.kill()`
case "togglefloating":
return `hl.dsp.window.float({ action = "toggle" })`
case "exit":
return `hl.dsp.exit()`
default:
return fmt.Sprintf(`hl.dsp.exec_cmd(%s)`, strconv.Quote("hyprctl dispatch "+action))
if expr, ok := luaActionStringFromKnownHyprlandAction(action); ok {
return expr
}
return action
}
func luaExprToInternalAction(expr string) string {
@@ -407,7 +1061,7 @@ func luaBindOptions(bind *hyprlandOverrideBind) []string {
if strings.Contains(bind.Flags, "e") {
opts = append(opts, "repeating = true")
}
if bind.Description != "" && strings.Contains(bind.Flags, "d") {
if bind.Description != "" {
opts = append(opts, fmt.Sprintf("description = %s", strconv.Quote(bind.Description)))
}
return opts
@@ -427,11 +1081,7 @@ func writeLuaBindLine(sb *strings.Builder, bind *hyprlandOverrideBind) {
if len(opts) > 0 {
fmt.Fprintf(sb, `hl.bind("%s", %s, { %s })`, key, expr, strings.Join(opts, ", "))
} else {
if bind.Description != "" {
fmt.Fprintf(sb, `hl.bind("%s", %s) -- %s`, key, expr, bind.Description)
} else {
fmt.Fprintf(sb, `hl.bind("%s", %s)`, key, expr)
}
fmt.Fprintf(sb, `hl.bind("%s", %s)`, key, expr)
}
sb.WriteByte('\n')
}
@@ -450,6 +1100,9 @@ func parseLuaBindOverrideLine(line string) (*hyprlandOverrideBind, bool) {
action := luaExprToInternalAction(actionExpr)
flags := luaBindOptFlags(optSuffix)
description := luaBindOptDescription(optSuffix)
if description == "" {
description = luaLineTrailingComment(line)
}
return &hyprlandOverrideBind{
Key: internalKey,
Action: action,
@@ -498,11 +1151,12 @@ func readLuaOrHyprlangOverride(path string) (map[string]*hyprlandOverrideBind, e
continue
}
if key, ok := parseLuaUnbindLine(line); ok {
pendingUnbinds[strings.ToLower(key)] = key
pendingUnbinds[hyprlandOverrideMapKey(key)] = canonicalHyprlandOverrideKey(key)
continue
}
if kb, ok := parseLuaBindOverrideLine(line); ok {
normalizedKey := strings.ToLower(kb.Key)
kb.Key = canonicalHyprlandOverrideKey(kb.Key)
normalizedKey := hyprlandOverrideMapKey(kb.Key)
binds[normalizedKey] = kb
delete(pendingUnbinds, normalizedKey)
continue
@@ -520,7 +1174,8 @@ func readLuaOrHyprlangOverride(path string) (map[string]*hyprlandOverrideBind, e
action = kb.Dispatcher + " " + kb.Params
}
flags := kb.Flags
normalizedKey := strings.ToLower(keyStr)
keyStr = canonicalHyprlandOverrideKey(keyStr)
normalizedKey := hyprlandOverrideMapKey(keyStr)
binds[normalizedKey] = &hyprlandOverrideBind{
Key: keyStr,
Action: action,
@@ -54,6 +54,8 @@ type HyprlandParser struct {
dmsProcessed bool
removedKeys map[string]bool // bare hl.unbind targets (negative overrides)
defaultDMSKeys map[string]bool // keys present in dms/binds.{lua,conf}
configFormat string
readOnly bool
}
func NewHyprlandParser(configDir string) *HyprlandParser {
@@ -310,6 +312,8 @@ type HyprlandDMSStatus struct {
Effective bool
OverriddenBy int
StatusMessage string
ConfigFormat string
ReadOnly bool
}
func (p *HyprlandParser) buildDMSStatus() *HyprlandDMSStatus {
@@ -319,6 +323,8 @@ func (p *HyprlandParser) buildDMSStatus() *HyprlandDMSStatus {
IncludePosition: p.dmsIncludePos,
TotalIncludes: p.includeCount,
BindsAfterDMS: p.bindsAfterDMS,
ConfigFormat: p.configFormat,
ReadOnly: p.readOnly,
}
switch {
@@ -398,6 +404,13 @@ func (p *HyprlandParser) ParseWithDMS() (*HyprlandSection, error) {
if err != nil {
return nil, err
}
if strings.EqualFold(filepath.Ext(mainConfig), ".lua") {
p.configFormat = "lua"
p.readOnly = false
} else {
p.configFormat = "hyprlang"
p.readOnly = true
}
section, err := p.parseFileWithSource(mainConfig, "")
if err != nil {
return nil, err
@@ -869,23 +882,20 @@ func parseLuaStringLiteral(line string, i int) (value string, next int, ok bool)
return "", i, false
}
// parseLuaFirstArgExpr parses a single Lua expression starting at i, stopping when parentheses
// opened from the first '(' are balanced (handles nested () and {} and double-quoted strings).
// parseLuaFirstArgExpr parses a single Lua expression starting at i, stopping at
// the next top-level comma. It handles nested calls/tables and inline functions.
func parseLuaFirstArgExpr(line string, start int) (expr string, next int, ok bool) {
start = skipLuaWS(line, start)
if start >= len(line) {
return "", start, false
}
// Find first '(' of the call (e.g. hl.dsp.exec_cmd(...)
firstParen := strings.IndexByte(line[start:], '(')
if firstParen < 0 {
return "", start, false
}
i := start + firstParen
depth := 0
i := start
parenDepth := 0
braceDepth := 0
bracketDepth := 0
functionDepth := 0
inStr := byte(0)
esc := false
exprStart := start
for ; i < len(line); i++ {
c := line[i]
if inStr != 0 {
@@ -902,19 +912,66 @@ func parseLuaFirstArgExpr(line string, start int) (expr string, next int, ok boo
}
continue
}
if c == '[' && i+1 < len(line) && line[i+1] == '[' {
if end := strings.Index(line[i+2:], "]]"); end >= 0 {
i += end + 3
continue
}
return "", start, false
}
if luaWordAt(line, i, "function") {
functionDepth++
i += len("function") - 1
continue
}
if luaWordAt(line, i, "end") && functionDepth > 0 {
functionDepth--
i += len("end") - 1
continue
}
switch c {
case '"', '\'':
inStr = c
case '(':
depth++
parenDepth++
case ')':
depth--
if depth == 0 {
return strings.TrimSpace(line[exprStart : i+1]), i + 1, true
if parenDepth > 0 {
parenDepth--
}
case '{':
braceDepth++
case '}':
if braceDepth > 0 {
braceDepth--
}
case '[':
bracketDepth++
case ']':
if bracketDepth > 0 {
bracketDepth--
}
case ',':
if parenDepth == 0 && braceDepth == 0 && bracketDepth == 0 && functionDepth == 0 {
return strings.TrimSpace(line[start:i]), i, true
}
}
}
return "", start, false
expr = strings.TrimSpace(line[start:i])
return expr, i, expr != ""
}
func luaWordAt(line string, idx int, word string) bool {
if idx < 0 || idx+len(word) > len(line) || line[idx:idx+len(word)] != word {
return false
}
before := idx == 0 || !isLuaIdentByte(line[idx-1])
afterIdx := idx + len(word)
after := afterIdx >= len(line) || !isLuaIdentByte(line[afterIdx])
return before && after
}
func isLuaIdentByte(c byte) bool {
return c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')
}
// parseLuaBindInvocation parses one hl.bind("KEY", expr [, opts]) on a single line.
@@ -993,19 +1050,39 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) {
if arg != "" {
if u, err := strconv.Unquote(arg); err == nil {
if strings.HasPrefix(u, "hyprctl dispatch ") {
rest := strings.TrimSpace(strings.TrimPrefix(u, "hyprctl dispatch "))
parts := strings.SplitN(rest, " ", 2)
if len(parts) == 1 {
return parts[0], ""
}
return parts[0], parts[1]
return splitDispatchCommand(strings.TrimSpace(strings.TrimPrefix(u, "hyprctl dispatch ")))
}
return "exec", u
}
}
return "exec", strings.TrimSpace(strings.TrimPrefix(expr, "hl.dsp.exec_cmd"))
case strings.Contains(expr, "hl.dsp.window.kill()"):
case strings.HasPrefix(expr, "hl.dsp.exec_raw("):
return "execr", luaCallStringArgValue(expr, "hl.dsp.exec_raw")
case strings.HasPrefix(expr, "hl.dispatch("):
if arg := luaCallStringArgValue(expr, "hl.dispatch"); arg != "" {
return splitDispatchCommand(arg)
}
return "", ""
case strings.Contains(expr, "hl.exec_cmd("):
if arg := luaEmbeddedCallStringArgValue(expr, "hl.exec_cmd"); strings.HasPrefix(arg, "hyprctl dispatch ") {
return splitDispatchCommand(strings.TrimSpace(strings.TrimPrefix(arg, "hyprctl dispatch ")))
}
case strings.HasPrefix(expr, "hl.dsp.window.close("):
if window := luaTableStringField(expr, "window"); window != "" {
return "closewindow", window
}
if arg := luaCallStringArgValue(expr, "hl.dsp.window.close"); arg != "" {
return "closewindow", arg
}
return "killactive", ""
case strings.HasPrefix(expr, "hl.dsp.window.kill("):
if window := luaTableStringField(expr, "window"); window != "" {
return "killwindow", window
}
if arg := luaCallStringArgValue(expr, "hl.dsp.window.kill"); arg != "" {
return "killwindow", arg
}
return "forcekillactive", ""
case strings.HasPrefix(expr, "hl.dsp.window.fullscreen("):
switch luaTableStringField(expr, "mode") {
case "maximized", "maximize":
@@ -1014,10 +1091,55 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) {
return "fullscreen", "0"
}
return "fullscreen", luaTableStringField(expr, "mode")
case strings.HasPrefix(expr, "hl.dsp.window.fullscreen_state("):
internal := luaStringValue(luaTableScalarField(expr, "internal"))
client := luaStringValue(luaTableScalarField(expr, "client"))
return joinDispatcherParams("fullscreenstate", internal, client)
case strings.HasPrefix(expr, "hl.dsp.window.float("):
return "togglefloating", ""
switch luaToggleActionToLegacy(luaTableStringField(expr, "action")) {
case "on":
return "setfloating", ""
case "off":
return "settiled", ""
default:
return "togglefloating", ""
}
case strings.HasPrefix(expr, "hl.dsp.window.pseudo("):
action := luaToggleActionToLegacy(luaTableStringField(expr, "action"))
if action == "" || action == "toggle" {
return "pseudo", ""
}
return "pseudo", action
case strings.HasPrefix(expr, "hl.dsp.window.pin("):
if action := luaToggleActionToLegacy(luaTableStringField(expr, "action")); action != "" && action != "toggle" {
return "pin", action
}
return "pin", ""
case strings.Contains(expr, "hl.dsp.window.center()"):
return "centerwindow", ""
case strings.Contains(expr, "hl.dsp.window.bring_to_top()"):
return "bringactivetotop", ""
case strings.Contains(expr, "hl.dsp.window.toggle_swallow()"):
return "toggleswallow", ""
case strings.Contains(expr, "hl.dsp.group.toggle()"):
return "togglegroup", ""
case strings.Contains(expr, "hl.dsp.group.next()"):
return "changegroupactive", "f"
case strings.Contains(expr, "hl.dsp.group.prev()"):
return "changegroupactive", "b"
case strings.HasPrefix(expr, "hl.dsp.group.active("):
return "changegroupactive", luaStringValue(luaTableScalarField(expr, "index"))
case strings.HasPrefix(expr, "hl.dsp.group.move_window("):
if forward, ok := luaTableBoolField(expr, "forward"); ok && !forward {
return "movegroupwindow", "b"
}
return "movegroupwindow", "f"
case strings.HasPrefix(expr, "hl.dsp.group.lock_active("):
return "lockactivegroup", luaToggleActionToLockArg(luaTableStringField(expr, "action"))
case strings.HasPrefix(expr, "hl.dsp.group.lock("):
return "lockgroups", luaToggleActionToLockArg(luaTableStringField(expr, "action"))
case strings.HasPrefix(expr, "hl.dsp.window.deny_from_group("):
return "denywindowfromgroup", luaToggleActionToLegacy(luaTableStringField(expr, "action"))
case strings.HasPrefix(expr, "hl.dsp.focus("):
switch {
case luaTableStringField(expr, "direction") != "":
@@ -1025,18 +1147,58 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) {
case luaTableStringField(expr, "monitor") != "":
return "focusmonitor", luaTableStringField(expr, "monitor")
case luaTableStringField(expr, "workspace") != "":
if luaTableBoolFieldValue(expr, "on_current_monitor") {
return "focusworkspaceoncurrentmonitor", luaTableStringField(expr, "workspace")
}
return "workspace", luaTableStringField(expr, "workspace")
case luaTableStringField(expr, "window") != "":
return "focuswindow", luaTableStringField(expr, "window")
case luaTableBoolFieldValue(expr, "urgent_or_last"):
return "focusurgentorlast", ""
case luaTableBoolFieldValue(expr, "last"):
return "focuscurrentorlast", ""
}
case strings.HasPrefix(expr, "hl.dsp.window.move("):
switch {
case luaTableScalarField(expr, "x") != "" || luaTableScalarField(expr, "y") != "":
x := luaStringValue(luaTableScalarField(expr, "x"))
y := luaStringValue(luaTableScalarField(expr, "y"))
if x == "" {
x = "0"
}
if y == "" {
y = "0"
}
prefix := ""
if raw, ok := luaTableBoolField(expr, "relative"); ok && !raw {
prefix = "exact "
}
params := prefix + x + " " + y
if window := luaTableStringField(expr, "window"); window != "" {
return "movewindowpixel", params + "," + window
}
return "moveactive", params
case luaTableStringField(expr, "into_group") != "":
return "moveintogroup", luaTableStringField(expr, "into_group")
case luaTableStringField(expr, "into_or_create_group") != "":
return "moveintoorcreategroup", luaTableStringField(expr, "into_or_create_group")
case luaTableBoolFieldValue(expr, "out_of_group"):
return "moveoutofgroup", ""
case luaTableStringField(expr, "out_of_group") != "":
return "moveoutofgroup", luaTableStringField(expr, "out_of_group")
case luaTableStringField(expr, "direction") != "":
if luaTableBoolFieldValue(expr, "group_aware") {
return "movewindoworgroup", luaTableStringField(expr, "direction")
}
return "movewindow", luaTableStringField(expr, "direction")
case luaTableStringField(expr, "monitor") != "":
return "movewindow", "mon:" + luaTableStringField(expr, "monitor")
case luaTableStringField(expr, "workspace") != "":
return "movetoworkspace", luaTableStringField(expr, "workspace")
action := "movetoworkspace"
if follow, ok := luaTableBoolField(expr, "follow"); ok && !follow {
action = "movetoworkspacesilent"
}
return joinDispatcherParams(action, luaTableStringField(expr, "workspace"), luaTableStringField(expr, "window"))
}
case expr == "hl.dsp.window.drag()":
return "movewindow", ""
@@ -1052,25 +1214,184 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) {
if y == "" {
y = "0"
}
return "resizeactive", x + " " + y
}
case strings.HasPrefix(expr, "hl.dsp.layout("):
arg := extractLuaCallStringArg(expr, "hl.dsp.layout")
if arg != "" {
if u, err := strconv.Unquote(arg); err == nil {
return "layoutmsg", u
prefix := ""
if relative, ok := luaTableBoolField(expr, "relative"); ok && !relative {
prefix = "exact "
}
params := prefix + x + " " + y
if window := luaTableStringField(expr, "window"); window != "" {
return "resizewindowpixel", params + "," + window
}
return "resizeactive", params
}
case strings.HasPrefix(expr, "hl.dsp.window.swap("):
switch {
case luaTableBoolFieldValue(expr, "next"):
return "swapnext", ""
case luaTableBoolFieldValue(expr, "prev"):
return "swapnext", "prev"
}
return "swapwindow", luaTableStringField(expr, "direction")
case strings.HasPrefix(expr, "hl.dsp.window.cycle_next("):
parts := []string{}
if next, ok := luaTableBoolField(expr, "next"); ok && !next {
parts = append(parts, "prev")
}
if luaTableBoolFieldValue(expr, "tiled") {
parts = append(parts, "tiled")
}
if luaTableBoolFieldValue(expr, "floating") {
parts = append(parts, "floating")
}
return "cyclenext", strings.Join(parts, " ")
case strings.HasPrefix(expr, "hl.dsp.window.signal("):
signal := luaStringValue(luaTableScalarField(expr, "signal"))
window := luaTableStringField(expr, "window")
if window != "" {
return joinDispatcherParams("signalwindow", window, signal)
}
return "signal", signal
case strings.HasPrefix(expr, "hl.dsp.window.tag("):
return joinDispatcherParams("tagwindow", luaTableStringField(expr, "tag"), luaTableStringField(expr, "window"))
case strings.HasPrefix(expr, "hl.dsp.window.alter_zorder("):
mode := luaTableStringField(expr, "mode")
if mode == "" {
mode = luaTableStringField(expr, "zheight")
}
return joinDispatcherParams("alterzorder", mode, luaTableStringField(expr, "window"))
case strings.HasPrefix(expr, "hl.dsp.window.set_prop("):
prop := luaTableStringField(expr, "prop")
if prop == "" {
prop = luaTableStringField(expr, "property")
}
return joinDispatcherParams("setprop", luaTableStringField(expr, "window"), prop, luaTableStringField(expr, "value"))
case strings.HasPrefix(expr, "hl.dsp.workspace.rename("):
return joinDispatcherParams("renameworkspace", luaTableStringField(expr, "workspace"), luaTableStringField(expr, "name"))
case strings.HasPrefix(expr, "hl.dsp.workspace.move("):
workspace := luaTableStringField(expr, "workspace")
monitor := luaTableStringField(expr, "monitor")
if workspace != "" {
return joinDispatcherParams("moveworkspacetomonitor", workspace, monitor)
}
return "movecurrentworkspacetomonitor", monitor
case strings.HasPrefix(expr, "hl.dsp.workspace.swap_monitors("):
return joinDispatcherParams("swapactiveworkspaces", luaTableStringField(expr, "monitor1"), luaTableStringField(expr, "monitor2"))
case strings.HasPrefix(expr, "hl.dsp.workspace.toggle_special("):
return "togglespecialworkspace", luaCallStringArgValue(expr, "hl.dsp.workspace.toggle_special")
case strings.HasPrefix(expr, "hl.dsp.layout("):
if arg := luaCallStringArgValue(expr, "hl.dsp.layout"); arg != "" {
return "layoutmsg", arg
}
case strings.HasPrefix(expr, "hl.dsp.dpms("):
if action := luaTableStringField(expr, "action"); action != "" {
switch action {
case "enable":
return "dpms", "on"
case "disable":
return "dpms", "off"
}
return "dpms", action
}
return "dpms", ""
case strings.HasPrefix(expr, "hl.dsp.submap("):
return "submap", luaCallStringArgValue(expr, "hl.dsp.submap")
case strings.HasPrefix(expr, "hl.dsp.global("):
return "global", luaCallStringArgValue(expr, "hl.dsp.global")
case strings.HasPrefix(expr, "hl.dsp.event("):
return "event", luaCallStringArgValue(expr, "hl.dsp.event")
case strings.HasPrefix(expr, "hl.dsp.pass("):
if window := luaTableStringField(expr, "window"); window != "" {
return "pass", window
}
return "pass", luaCallStringArgValue(expr, "hl.dsp.pass")
case strings.HasPrefix(expr, "hl.dsp.send_shortcut("):
return joinDispatcherParams("sendshortcut", luaTableModsField(expr), luaTableStringField(expr, "key"), luaTableStringField(expr, "window"))
case strings.HasPrefix(expr, "hl.dsp.send_key_state("):
return joinDispatcherParams("sendkeystate", luaTableModsField(expr), luaTableStringField(expr, "key"), luaTableStringField(expr, "state"), luaTableStringField(expr, "window"))
case strings.HasPrefix(expr, "hl.dsp.cursor.move_to_corner("):
return "movecursortocorner", luaStringValue(luaTableScalarField(expr, "corner"))
case strings.HasPrefix(expr, "hl.dsp.cursor.move("):
return joinDispatcherParams("movecursor", luaStringValue(luaTableScalarField(expr, "x")), luaStringValue(luaTableScalarField(expr, "y")))
case strings.Contains(expr, "hl.dsp.force_renderer_reload()"):
return "forcerendererreload", ""
case strings.HasPrefix(expr, "hl.dsp.force_idle("):
return "forceidle", luaCallScalarArgValue(expr, "hl.dsp.force_idle")
case strings.Contains(expr, "hl.dsp.exit()"):
return "exit", ""
default:
return "exec", "hyprctl dispatch lua:" + expr
return expr, ""
}
return expr, ""
}
func splitDispatchCommand(command string) (dispatcher, params string) {
command = strings.TrimSpace(command)
if command == "" {
return "", ""
}
parts := strings.SplitN(command, " ", 2)
if len(parts) == 1 {
return parts[0], ""
}
return parts[0], strings.TrimSpace(parts[1])
}
func joinDispatcherParams(dispatcher string, values ...string) (string, string) {
parts := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
parts = append(parts, value)
}
}
return dispatcher, strings.Join(parts, " ")
}
func luaEmbeddedCallStringArgValue(expr, funcName string) string {
idx := strings.Index(expr, funcName+"(")
if idx < 0 {
return ""
}
return luaCallStringArgValue(expr[idx:], funcName)
}
func luaCallScalarArgValue(callExpr, funcName string) string {
callExpr = strings.TrimSpace(callExpr)
prefix := funcName + "("
if !strings.HasPrefix(callExpr, prefix) {
return ""
}
inner := strings.TrimSpace(callExpr[len(prefix):])
if inner == "" {
return ""
}
if s := luaCallStringArgValue(callExpr, funcName); s != "" {
return s
}
re := regexp.MustCompile(`^-?\d+(?:\.\d+)?`)
return re.FindString(inner)
}
func luaToggleActionToLegacy(action string) string {
switch strings.ToLower(strings.TrimSpace(action)) {
case "on", "enable", "enabled", "set", "lock":
return "on"
case "off", "disable", "disabled", "unset", "unlock":
return "off"
default:
return "toggle"
}
}
func luaToggleActionToLockArg(action string) string {
switch luaToggleActionToLegacy(action) {
case "on":
return "lock"
case "off":
return "unlock"
default:
return "toggle"
}
return "exec", "hyprctl dispatch lua:" + expr
}
func extractLuaCallStringArg(callExpr, funcName string) string {
@@ -1100,10 +1421,46 @@ func extractLuaCallStringArg(callExpr, funcName string) string {
return ""
}
func luaCallStringArgValue(callExpr, funcName string) string {
arg := extractLuaCallStringArg(callExpr, funcName)
if arg == "" {
return ""
}
u, err := strconv.Unquote(arg)
if err != nil {
return ""
}
return u
}
func luaTableStringField(expr, field string) string {
return luaStringValue(luaTableScalarField(expr, field))
}
func luaTableModsField(expr string) string {
if mods := luaTableStringField(expr, "mods"); mods != "" {
return mods
}
return luaTableStringField(expr, "mod")
}
func luaTableBoolFieldValue(expr, field string) bool {
value, ok := luaTableBoolField(expr, field)
return ok && value
}
func luaTableBoolField(expr, field string) (bool, bool) {
raw := strings.ToLower(luaTableScalarField(expr, field))
switch raw {
case "true":
return true, true
case "false":
return false, true
default:
return false, false
}
}
func luaTableScalarField(expr, field string) string {
re := regexp.MustCompile(`(?s)\b` + regexp.QuoteMeta(field) + `\s*=\s*("(?:\\.|[^"])*"|'(?:\\.|[^'])*'|\[\[.*?\]\]|-?\d+(?:\.\d+)?|true|false)`)
m := re.FindStringSubmatch(expr)
@@ -1136,8 +1493,38 @@ func luaStringValue(raw string) string {
}
func luaLineTrailingComment(line string) string {
if idx := strings.Index(line, "--"); idx >= 0 {
return strings.TrimSpace(line[idx+2:])
inString := byte(0)
escaped := false
for i := 0; i < len(line)-1; i++ {
c := line[i]
if inString != 0 {
if escaped {
escaped = false
continue
}
if c == '\\' && inString == '"' {
escaped = true
continue
}
if c == inString {
inString = 0
}
continue
}
if c == '"' || c == '\'' {
inString = c
continue
}
if c == '[' && line[i+1] == '[' {
if end := strings.Index(line[i+2:], "]]"); end >= 0 {
i += end + 3
continue
}
return ""
}
if c == '-' && line[i+1] == '-' {
return strings.TrimSpace(line[i+2:])
}
}
return ""
}
@@ -70,12 +70,37 @@ func TestHyprlandLuaBindRoundTripHelpers(t *testing.T) {
wantParams string
}{
{`hl.dsp.exec_cmd([[dms ipc call brightness increment 5 ""]])`, "exec", `dms ipc call brightness increment 5 ""`},
{`hl.dsp.exec_cmd([[hyprctl dispatch workspace 1]])`, "workspace", "1"},
{`hl.dispatch("workspace 2")`, "workspace", "2"},
{`hl.dispatch([[customdispatcher arg one]])`, "customdispatcher", "arg one"},
{`hl.dsp.window.fullscreen({ mode = "maximized", action = "toggle" })`, "fullscreen", "1"},
{`hl.dsp.window.float({ action = "on" })`, "setfloating", ""},
{`hl.dsp.window.close()`, "killactive", ""},
{`hl.dsp.window.kill()`, "forcekillactive", ""},
{`hl.dsp.window.close({ window = "class:^(kitty)$" })`, "closewindow", "class:^(kitty)$"},
{`hl.dsp.focus({ workspace = "e+1" })`, "workspace", "e+1"},
{`hl.dsp.focus({ workspace = "2", on_current_monitor = true })`, "focusworkspaceoncurrentmonitor", "2"},
{`hl.dsp.window.move({ monitor = "l" })`, "movewindow", "mon:l"},
{`hl.dsp.window.resize({ x = "-10%", y = 0, relative = true })`, "resizeactive", "-10% 0"},
{`hl.dsp.window.move({ direction = "r", group_aware = true })`, "movewindoworgroup", "r"},
{`hl.dsp.window.move({ into_group = "l" })`, "moveintogroup", "l"},
{`hl.dsp.window.move({ out_of_group = true })`, "moveoutofgroup", ""},
{`hl.dsp.window.move({ workspace = "special:magic", follow = false })`, "movetoworkspacesilent", "special:magic"},
{`hl.dsp.window.resize({ x = -100, y = 0, relative = true })`, "resizeactive", "-100 0"},
{`hl.dsp.window.resize({ x = 1280, y = 720, relative = false })`, "resizeactive", "exact 1280 720"},
{`hl.dsp.window.resize({ x = 100, y = 50, relative = true, window = "class:^(app)$" })`, "resizewindowpixel", "100 50,class:^(app)$"},
{`hl.dsp.window.cycle_next({ next = false, tiled = true })`, "cyclenext", "prev tiled"},
{`hl.dsp.group.next()`, "changegroupactive", "f"},
{`hl.dsp.group.prev()`, "changegroupactive", "b"},
{`hl.dsp.group.active({ index = 2 })`, "changegroupactive", "2"},
{`hl.dsp.group.move_window({ forward = false })`, "movegroupwindow", "b"},
{`hl.dsp.group.lock({ action = "on" })`, "lockgroups", "lock"},
{`hl.dsp.group.lock_active({ action = "off" })`, "lockactivegroup", "unlock"},
{`hl.dsp.window.deny_from_group({ action = "toggle" })`, "denywindowfromgroup", "toggle"},
{`function() hl.exec_cmd("hyprctl dispatch splitratio +0.1") end`, "splitratio", "+0.1"},
{`hl.dsp.layout("togglesplit")`, "layoutmsg", "togglesplit"},
{`hl.dsp.dpms({ action = "toggle" })`, "dpms", "toggle"},
{`hl.dsp.workspace.rename({ workspace = "1", name = "work" })`, "renameworkspace", "1 work"},
{`hl.dsp.no_op()`, "hl.dsp.no_op()", ""},
}
for _, tt := range tests {
@@ -113,12 +138,132 @@ func TestWriteLuaBindLineMapsSpawnActionForHyprland(t *testing.T) {
})
want := `hl.unbind("SUPER + N")
hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle")) -- Notepad: Toggle`
hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"), { description = "Notepad: Toggle" })`
if got := strings.TrimSpace(sb.String()); got != want {
t.Fatalf("writeLuaBindLine() = %q, want %q", got, want)
}
}
func TestWriteLuaBindLineLeavesCustomLuaDispatcherRaw(t *testing.T) {
var sb strings.Builder
writeLuaBindLine(&sb, &hyprlandOverrideBind{
Key: "Super+u",
Action: "hl.dsp.no_op()",
Description: "Custom Lua",
})
want := `hl.unbind("SUPER + U")
hl.bind("SUPER + U", hl.dsp.no_op(), { description = "Custom Lua" })`
if got := strings.TrimSpace(sb.String()); got != want {
t.Fatalf("writeLuaBindLine() = %q, want %q", got, want)
}
}
func TestLuaActionStringFromHyprlangActionUsesNativeDispatchers(t *testing.T) {
tests := []struct {
action string
want string
}{
{"killactive", `hl.dsp.window.close()`},
{"forcekillactive", `hl.dsp.window.kill()`},
{"workspace 1", `hl.dsp.focus({ workspace = "1" })`},
{"movetoworkspace 2", `hl.dsp.window.move({ workspace = "2" })`},
{"movetoworkspacesilent special:magic", `hl.dsp.window.move({ workspace = "special:magic", follow = false })`},
{"focusmonitor DP-1", `hl.dsp.focus({ monitor = "DP-1" })`},
{"resizeactive exact 1280 720", `hl.dsp.window.resize({ x = 1280, y = 720, relative = false })`},
{"dpms toggle", `hl.dsp.dpms({ action = "toggle" })`},
{"renameworkspace 1 work", `hl.dsp.workspace.rename({ workspace = "1", name = "work" })`},
{"changegroupactive f", `hl.dsp.group.next()`},
{"changegroupactive b", `hl.dsp.group.prev()`},
{"changegroupactive 2", `hl.dsp.group.active({ index = 2 })`},
{"moveintogroup l", `hl.dsp.window.move({ into_group = "l" })`},
{"moveoutofgroup", `hl.dsp.window.move({ out_of_group = true })`},
{"movewindoworgroup r", `hl.dsp.window.move({ direction = "r", group_aware = true })`},
{"movegroupwindow b", `hl.dsp.group.move_window({ forward = false })`},
{"lockgroups lock", `hl.dsp.group.lock({ action = "on" })`},
{"lockactivegroup unlock", `hl.dsp.group.lock_active({ action = "off" })`},
{"denywindowfromgroup toggle", `hl.dsp.window.deny_from_group({ action = "toggle" })`},
{"cyclenext prev", `hl.dsp.window.cycle_next({ next = false })`},
{"setfloating", `hl.dsp.window.float({ action = "on" })`},
{"settiled", `hl.dsp.window.float({ action = "off" })`},
{"bringactivetotop", `hl.dsp.window.bring_to_top()`},
{"toggleswallow", `hl.dsp.window.toggle_swallow()`},
{"forceidle 300", `hl.dsp.force_idle(300)`},
}
for _, tt := range tests {
t.Run(tt.action, func(t *testing.T) {
got := luaActionStringFromHyprlangAction(tt.action)
if got != tt.want {
t.Fatalf("luaActionStringFromHyprlangAction(%q) = %q, want %q", tt.action, got, tt.want)
}
if strings.Contains(got, "hyprctl dispatch") {
t.Fatalf("expected native Lua dispatcher, got legacy dispatch wrapper: %q", got)
}
})
}
}
func TestLuaActionStringFallsBackForUnsupportedResizePercentages(t *testing.T) {
got := luaActionStringFromHyprlangAction("resizeactive exact 100% 100%")
want := `function() hl.exec_cmd("hyprctl dispatch resizeactive exact 100% 100%") end`
if got != want {
t.Fatalf("luaActionStringFromHyprlangAction() = %q, want %q", got, want)
}
}
func TestParseLuaBindLineHandlesFunctionDispatcherFallback(t *testing.T) {
line := `hl.bind("SUPER + R", function() hl.exec_cmd("hyprctl dispatch resizeactive exact 100% 100%") end, { description = "Unsupported Resize" })`
got, ok := parseLuaBindOverrideLine(line)
if !ok {
t.Fatalf("expected line to parse")
}
if got.Action != "resizeactive exact 100% 100%" {
t.Fatalf("Action = %q, want resizeactive exact 100%% 100%%", got.Action)
}
if got.Description != "Unsupported Resize" {
t.Fatalf("Description = %q, want Unsupported Resize", got.Description)
}
}
func TestLuaActionStringLeavesCustomLuaDispatcherRaw(t *testing.T) {
got := luaActionStringFromHyprlangAction("hl.dsp.no_op()")
want := `hl.dsp.no_op()`
if got != want {
t.Fatalf("luaActionStringFromHyprlangAction() = %q, want %q", got, want)
}
if strings.Contains(got, "hl.dispatch") || strings.Contains(got, "hyprctl dispatch") {
t.Fatalf("expected custom Lua dispatcher expression to stay raw, got %q", got)
}
}
func TestReadLuaOverrideMigratesTrailingCommentToDescription(t *testing.T) {
tmpDir := t.TempDir()
overridePath := filepath.Join(tmpDir, "binds-user.lua")
contents := `hl.unbind("SUPER + N")
hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle")) -- Notepad: Toggle
hl.bind("SUPER + H", hl.dsp.exec_cmd("app --help"))
`
if err := os.WriteFile(overridePath, []byte(contents), 0o644); err != nil {
t.Fatal(err)
}
binds, err := readLuaOrHyprlangOverride(overridePath)
if err != nil {
t.Fatal(err)
}
got := binds["super+n"]
if got == nil {
t.Fatalf("expected SUPER+N override, got %#v", binds)
}
if got.Description != "Notepad: Toggle" {
t.Fatalf("expected trailing comment to be preserved as description, got %q", got.Description)
}
if got := binds["super+h"]; got == nil || got.Description != "" {
t.Fatalf("expected -- inside a Lua string to stay out of the description, got %#v", got)
}
}
func TestHyprlandLuaBindsUserOverridesDefaults(t *testing.T) {
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
@@ -283,6 +428,64 @@ func TestHyprlandRemoveBindWritesNegativeOverrideForDefault(t *testing.T) {
}
}
func TestHyprlandSetBindLeavesConfOnlyInstallReadOnly(t *testing.T) {
tmpDir := t.TempDir()
if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.conf"), []byte("bind = SUPER, T, exec, kitty\n"), 0o644); err != nil {
t.Fatal(err)
}
provider := NewHyprlandProvider(tmpDir)
err := provider.SetBind("SUPER+N", "workspace 1", "Workspace 1", nil)
if err == nil {
t.Fatal("expected SetBind to reject conf-only Hyprland config")
}
if !strings.Contains(err.Error(), "read-only") {
t.Fatalf("expected read-only error, got %v", err)
}
if _, err := os.Stat(filepath.Join(tmpDir, "dms", "binds-user.lua")); !os.IsNotExist(err) {
t.Fatalf("expected no Lua override to be created for conf-only config, stat err=%v", err)
}
}
func TestHyprlandSetBindUpdatesSpacedLuaOverrideWithoutDuplicates(t *testing.T) {
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
t.Fatal(err)
}
override := `-- DMS user keybind overrides
hl.unbind("SUPER + SHIFT + S")
hl.bind("SUPER + 1", hl.dsp.exec_cmd("hyprctl dispatch workspace 1"))
`
if err := os.WriteFile(filepath.Join(dmsDir, "binds-user.lua"), []byte(override), 0o644); err != nil {
t.Fatal(err)
}
provider := NewHyprlandProvider(tmpDir)
if err := provider.SetBind("SUPER + 1", "workspace 1", "", nil); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(dmsDir, "binds-user.lua"))
if err != nil {
t.Fatal(err)
}
got := string(data)
if strings.Count(got, `hl.unbind("SUPER + 1")`) != 1 {
t.Fatalf("expected one SUPER+1 unbind, got:\n%s", got)
}
if strings.Count(got, `hl.bind("SUPER + 1", hl.dsp.focus({ workspace = "1" }))`) != 1 {
t.Fatalf("expected one native SUPER+1 bind, got:\n%s", got)
}
if strings.Contains(got, "hyprctl dispatch workspace 1") {
t.Fatalf("expected old hyprctl workspace dispatcher to be replaced, got:\n%s", got)
}
if !strings.Contains(got, `hl.unbind("SUPER + SHIFT + S")`) {
t.Fatalf("expected unrelated override to be preserved, got:\n%s", got)
}
}
func TestHyprlandRemoveBindReplacesExistingOverrideWithNegativeOverride(t *testing.T) {
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
+2
View File
@@ -25,6 +25,8 @@ type DMSBindsStatus struct {
Effective bool `json:"effective"`
OverriddenBy int `json:"overriddenBy"`
StatusMessage string `json:"statusMessage"`
ConfigFormat string `json:"configFormat,omitempty"`
ReadOnly bool `json:"readOnly,omitempty"`
}
type CheatSheet struct {
@@ -2,6 +2,7 @@ package clipboard
import (
"encoding/json"
"errors"
"fmt"
"net"
@@ -73,6 +74,10 @@ func handleGetEntry(conn net.Conn, req models.Request, m *Manager) {
entry, err := m.GetEntry(uint64(id))
if err != nil {
if errors.Is(err, errEntryNotFound) {
models.Respond[any](conn, req.ID, nil)
return
}
models.RespondError(conn, req.ID, err.Error())
return
}
+25 -6
View File
@@ -3,6 +3,7 @@ package clipboard
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"image"
_ "image/gif"
@@ -34,6 +35,8 @@ import (
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
)
var errEntryNotFound = errors.New("entry not found")
// These mime types won't be stored in history
var sensitiveMimeTypes = []string{
"x-kde-passwordManagerHint",
@@ -572,16 +575,16 @@ func (m *Manager) hasSensitiveMimeType(mimes []string) bool {
func (m *Manager) selectMimeType(mimes []string) string {
preferredTypes := []string{
"text/uri-list",
"text/plain;charset=utf-8",
"text/plain",
"UTF8_STRING",
"STRING",
"TEXT",
"image/png",
"image/jpeg",
"image/gif",
"image/bmp",
"image/tiff",
"text/plain;charset=utf-8",
"text/plain",
"UTF8_STRING",
"STRING",
"TEXT",
}
for _, pref := range preferredTypes {
@@ -764,9 +767,25 @@ func stateEqual(a, b *State) bool {
if len(a.History) != len(b.History) {
return false
}
for i := range a.History {
if !entryStateEqual(a.History[i], b.History[i]) {
return false
}
}
return true
}
func entryStateEqual(a, b Entry) bool {
return a.ID == b.ID &&
a.Hash == b.Hash &&
a.Pinned == b.Pinned &&
a.IsImage == b.IsImage &&
a.MimeType == b.MimeType &&
a.Preview == b.Preview &&
a.Size == b.Size &&
a.Timestamp.Equal(b.Timestamp)
}
func (m *Manager) GetHistory() []Entry {
if m.db == nil {
return nil
@@ -854,7 +873,7 @@ func (m *Manager) GetEntry(id uint64) (*Entry, error) {
return nil, err
}
if !found {
return nil, fmt.Errorf("entry not found")
return nil, errEntryNotFound
}
return &entry, nil
+141 -2
View File
@@ -1,17 +1,52 @@
package clipboard
import (
"bytes"
"encoding/json"
"net"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
mocks_wlcontext "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/wlcontext"
)
type clipboardTestConn struct {
net.Conn
writeBuf *bytes.Buffer
}
func newClipboardTestConn() *clipboardTestConn {
return &clipboardTestConn{writeBuf: &bytes.Buffer{}}
}
func (c *clipboardTestConn) Write(b []byte) (int, error) {
return c.writeBuf.Write(b)
}
func newTestManagerWithDB(t *testing.T) *Manager {
t.Helper()
db, err := openDB(filepath.Join(t.TempDir(), "clipboard.db"))
require.NoError(t, err)
t.Cleanup(func() {
db.Close()
})
return &Manager{
config: DefaultConfig(),
db: db,
}
}
func TestEncodeDecodeEntry_Roundtrip(t *testing.T) {
original := Entry{
ID: 12345,
@@ -131,11 +166,113 @@ func TestStateEqual_HistoryLengthDiffers(t *testing.T) {
}
func TestStateEqual_BothEqual(t *testing.T) {
a := &State{Enabled: true, History: []Entry{{ID: 1}, {ID: 2}}}
b := &State{Enabled: true, History: []Entry{{ID: 3}, {ID: 4}}}
ts := time.Now().Truncate(time.Second)
entry := Entry{
ID: 1,
Hash: 100,
MimeType: "image/png",
Preview: "[[ image 1 KiB png 32x32 ]]",
Size: 1024,
Timestamp: ts,
IsImage: true,
Pinned: true,
}
a := &State{Enabled: true, History: []Entry{entry}}
b := &State{Enabled: true, History: []Entry{entry}}
assert.True(t, stateEqual(a, b))
}
func TestStateEqual_SameLengthDifferentIDs(t *testing.T) {
ts := time.Now().Truncate(time.Second)
a := &State{Enabled: true, History: []Entry{{ID: 1, Hash: 100, Timestamp: ts}}}
b := &State{Enabled: true, History: []Entry{{ID: 2, Hash: 100, Timestamp: ts}}}
assert.False(t, stateEqual(a, b))
}
func TestStateEqual_MetadataDiffers(t *testing.T) {
ts := time.Now().Truncate(time.Second)
base := Entry{
ID: 1,
Hash: 100,
MimeType: "image/png",
Preview: "[[ image 1 KiB png 32x32 ]]",
Size: 1024,
Timestamp: ts,
IsImage: true,
Pinned: false,
}
tests := []struct {
name string
mutate func(*Entry)
}{
{name: "hash", mutate: func(e *Entry) { e.Hash = 101 }},
{name: "pinned", mutate: func(e *Entry) { e.Pinned = true }},
{name: "is image", mutate: func(e *Entry) { e.IsImage = false }},
{name: "mime type", mutate: func(e *Entry) { e.MimeType = "image/jpeg" }},
{name: "preview", mutate: func(e *Entry) { e.Preview = "[[ image 2 KiB jpeg 64x64 ]]" }},
{name: "size", mutate: func(e *Entry) { e.Size = 2048 }},
{name: "timestamp", mutate: func(e *Entry) { e.Timestamp = ts.Add(time.Second) }},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
changed := base
tt.mutate(&changed)
a := &State{Enabled: true, History: []Entry{base}}
b := &State{Enabled: true, History: []Entry{changed}}
assert.False(t, stateEqual(a, b))
})
}
}
func TestHandleGetEntry_ReturnsExistingEntry(t *testing.T) {
m := newTestManagerWithDB(t)
err := m.storeEntry(Entry{
Data: []byte("hello world"),
MimeType: "text/plain;charset=utf-8",
Preview: "hello world",
Size: len("hello world"),
Timestamp: time.Now().Truncate(time.Second),
IsImage: false,
})
require.NoError(t, err)
history := m.GetHistory()
require.Len(t, history, 1)
conn := newClipboardTestConn()
handleGetEntry(conn, models.Request{
ID: 1,
Params: map[string]any{"id": float64(history[0].ID)},
}, m)
var resp models.Response[Entry]
require.NoError(t, json.NewDecoder(conn.writeBuf).Decode(&resp))
assert.Empty(t, resp.Error)
require.NotNil(t, resp.Result)
assert.Equal(t, history[0].ID, resp.Result.ID)
assert.Equal(t, []byte("hello world"), resp.Result.Data)
}
func TestHandleGetEntry_MissingIDReturnsNullResult(t *testing.T) {
m := newTestManagerWithDB(t)
conn := newClipboardTestConn()
handleGetEntry(conn, models.Request{
ID: 1,
Params: map[string]any{"id": float64(999)},
}, m)
var resp models.Response[any]
require.NoError(t, json.NewDecoder(conn.writeBuf).Decode(&resp))
assert.Empty(t, resp.Error)
assert.Nil(t, resp.Result)
}
func TestManager_ConcurrentSubscriberAccess(t *testing.T) {
m := &Manager{
subscribers: make(map[string]chan State),
@@ -410,6 +547,8 @@ func TestSelectMimeType(t *testing.T) {
{[]string{"text/plain;charset=utf-8", "text/html"}, "text/plain;charset=utf-8"},
{[]string{"text/html", "text/plain"}, "text/plain"},
{[]string{"text/html", "image/png"}, "image/png"},
{[]string{"image/png", "text/plain"}, "image/png"},
{[]string{"text/plain", "image/png"}, "image/png"},
{[]string{"image/png", "image/jpeg"}, "image/png"},
{[]string{"image/png"}, "image/png"},
{[]string{"application/octet-stream"}, "application/octet-stream"},
@@ -27,16 +27,19 @@ type linkInfo struct {
}
func (l *linkInfo) isWired() bool {
if looksVirtual(l.name) {
return false
}
if l.linkType != "" {
return l.linkType == "ether"
}
if looksVirtual(l.name) || strings.HasPrefix(l.name, "wlan") || strings.HasPrefix(l.name, "wlp") {
return false
}
return true
return !strings.HasPrefix(l.name, "wlan") && !strings.HasPrefix(l.name, "wlp")
}
func (l *linkInfo) isWireless() bool {
if looksVirtual(l.name) {
return false
}
if l.linkType != "" {
return l.linkType == "wlan"
}
@@ -45,7 +48,7 @@ func (l *linkInfo) isWireless() bool {
func looksVirtual(name string) bool {
virtualPrefixes := []string{
"lo", "docker", "veth", "virbr", "br-", "vnet", "tun", "tap",
"lo", "docker", "podman", "veth", "virbr", "br-", "vnet", "tun", "tap",
"vboxnet", "vmnet", "kube", "cni", "flannel", "cali",
}
for _, prefix := range virtualPrefixes {
@@ -110,6 +113,12 @@ func (b *SystemdNetworkdBackend) Close() {
}
}
type enumeratedLink struct {
ifindex int32
name string
path dbus.ObjectPath
}
func (b *SystemdNetworkdBackend) enumerateLinks() error {
obj := b.conn.Object(networkdBusName, b.managerPath)
@@ -123,25 +132,48 @@ func (b *SystemdNetworkdBackend) enumerateLinks() error {
return fmt.Errorf("ListLinks: %w", err)
}
fresh := make([]enumeratedLink, len(links))
for i, l := range links {
fresh[i] = enumeratedLink{ifindex: l.Ifindex, name: l.Name, path: l.Path}
}
b.linksMutex.Lock()
defer b.linksMutex.Unlock()
b.syncLinks(fresh)
for _, l := range links {
if existing, ok := b.links[l.Name]; ok && existing.path == l.Path {
existing.ifindex = l.Ifindex
return nil
}
// syncLinks reconciles the cached link map against the freshly enumerated set:
// it adds links not seen before (querying their Type once), refreshes the
// ifindex of survivors, and prunes links that no longer appear. Pruning is what
// keeps torn-down container interfaces (podman bridges, veth pairs) from
// lingering as routable and being mistaken for the wired uplink.
// Callers must hold linksMutex.
func (b *SystemdNetworkdBackend) syncLinks(fresh []enumeratedLink) {
present := make(map[string]bool, len(fresh))
for _, l := range fresh {
present[l.name] = true
if existing, ok := b.links[l.name]; ok && existing.path == l.path {
existing.ifindex = l.ifindex
continue
}
info := &linkInfo{
ifindex: l.Ifindex,
name: l.Name,
path: l.Path,
linkType: b.fetchLinkType(l.Path),
ifindex: l.ifindex,
name: l.name,
path: l.path,
linkType: b.fetchLinkType(l.path),
}
b.links[l.Name] = info
log.Debugf("networkd: enumerated link %s (ifindex=%d, path=%s, type=%q)", l.Name, l.Ifindex, l.Path, info.linkType)
b.links[l.name] = info
log.Debugf("networkd: enumerated link %s (ifindex=%d, path=%s, type=%q)", l.name, l.ifindex, l.path, info.linkType)
}
return nil
for name := range b.links {
if !present[name] {
log.Debugf("networkd: pruned stale link %s", name)
delete(b.links, name)
}
}
}
// fetchLinkType queries networkd's Describe method and extracts the link Type
@@ -160,6 +160,12 @@ func TestLinkInfo_Classify(t *testing.T) {
{"loopback type", "lo", "loopback", false, false},
{"none type (tun overlay)", "nebula.homelab", "none", false, false},
{"none type (wireguard)", "wg0", "none", false, false},
// Virtual interfaces report Type=ether but must never be mistaken for
// the wired uplink — stale podman/veth links would otherwise poison
// ethernet detection.
{"veth ether excluded", "veth1234", "ether", false, false},
{"podman bridge ether excluded", "podman3", "ether", false, false},
{"docker bridge ether excluded", "docker0", "ether", false, false},
// Fallback path: linkType unavailable, name-prefix heuristic applies.
{"fallback enp wired", "enp141s0", "", true, false},
{"fallback wlan wireless", "wlan0", "", false, true},
@@ -205,8 +211,46 @@ func TestParseDescribeType(t *testing.T) {
}
}
func TestSyncLinks_PrunesRemovedLinks(t *testing.T) {
// Stale container interfaces (torn-down podman bridges, veth pairs) must
// not linger in the link map after they disappear from ListLinks — kept as
// routable, they stole the wired-uplink slot from the real ethernet NIC.
backend, _ := NewSystemdNetworkdBackend()
backend.links = map[string]*linkInfo{
"eno1": {ifindex: 2, name: "eno1", path: "/org/freedesktop/network1/link/_32", linkType: "ether", opState: "routable"},
"podman3": {ifindex: 9, name: "podman3", path: "/org/freedesktop/network1/link/_39", linkType: "ether", opState: "routable"},
"veth0": {ifindex: 10, name: "veth0", path: "/org/freedesktop/network1/link/_310", linkType: "ether", opState: "routable"},
}
backend.syncLinks([]enumeratedLink{
{ifindex: 2, name: "eno1", path: "/org/freedesktop/network1/link/_32"},
})
assert.Len(t, backend.links, 1)
assert.Contains(t, backend.links, "eno1")
assert.NotContains(t, backend.links, "podman3")
assert.NotContains(t, backend.links, "veth0")
}
func TestSyncLinks_RefreshesSurvivingLink(t *testing.T) {
// A link that survives keeps its cached Type — Describe is only queried for
// newly seen links — while picking up a refreshed ifindex.
backend, _ := NewSystemdNetworkdBackend()
backend.links = map[string]*linkInfo{
"eno1": {ifindex: 2, name: "eno1", path: "/org/freedesktop/network1/link/_32", linkType: "ether"},
}
backend.syncLinks([]enumeratedLink{
{ifindex: 7, name: "eno1", path: "/org/freedesktop/network1/link/_32"},
})
assert.Len(t, backend.links, 1)
assert.Equal(t, int32(7), backend.links["eno1"].ifindex)
assert.Equal(t, "ether", backend.links["eno1"].linkType)
}
func TestLooksVirtual(t *testing.T) {
virtual := []string{"lo", "docker0", "veth123", "virbr0", "br-abc", "vnet0", "tun0", "tap0", "vboxnet0", "vmnet1", "kube-ipvs0", "cni0", "flannel.1", "cali-abc"}
virtual := []string{"lo", "docker0", "veth123", "virbr0", "br-abc", "vnet0", "tun0", "tap0", "vboxnet0", "vmnet1", "kube-ipvs0", "cni0", "flannel.1", "cali-abc", "podman0", "podman3"}
for _, n := range virtual {
assert.True(t, looksVirtual(n), "%s should look virtual", n)
}
+1
View File
@@ -418,6 +418,7 @@ func handleConnection(conn net.Conn) {
conn.Write(capsData)
conn.Write([]byte("\n"))
scanner := bufio.NewScanner(conn)
scanner.Buffer(make([]byte, bufio.MaxScanTokenSize), 64*1024*1024) // grow up to 64 MB for large clipboard payloads
for scanner.Scan() {
line := scanner.Bytes()
@@ -44,6 +44,8 @@ type HyprlandRulesParser struct {
dmsIncludePos int
rulesAfterDMS int
dmsProcessed bool
configFormat string
readOnly bool
requireLineInMain int // hyprland.lua line (1-based) where require("dms.windowrules") occurs; else -1
primaryHyprLua string // absolute path to ~/.config/hypr/hyprland.lua when that is the main config
@@ -82,10 +84,15 @@ func (p *HyprlandRulesParser) Parse() ([]HyprlandWindowRule, error) {
}
if strings.EqualFold(filepath.Ext(mainConfig), ".lua") {
p.configFormat = "lua"
p.readOnly = false
p.probeRequireWindowrulesLine(mainConfig)
if ap, err := filepath.Abs(mainConfig); err == nil {
p.primaryHyprLua = ap
}
} else {
p.configFormat = "hyprlang"
p.readOnly = true
}
if err := p.parseFile(mainConfig); err != nil {
@@ -300,6 +307,8 @@ func (p *HyprlandRulesParser) buildDMSStatus() *windowrules.DMSRulesStatus {
IncludePosition: p.dmsIncludePos,
TotalIncludes: p.includeCount,
RulesAfterDMS: p.rulesAfterDMS,
ConfigFormat: p.configFormat,
ReadOnly: p.readOnly,
}
switch {
@@ -451,6 +460,9 @@ func (p *HyprlandWritableProvider) GetRuleSet() (*windowrules.RuleSet, error) {
}
func (p *HyprlandWritableProvider) SetRule(rule windowrules.WindowRule) error {
if err := p.ensureWritableConfig(); err != nil {
return err
}
rules, err := p.LoadDMSRules()
if err != nil {
rules = []windowrules.WindowRule{}
@@ -472,6 +484,9 @@ func (p *HyprlandWritableProvider) SetRule(rule windowrules.WindowRule) error {
}
func (p *HyprlandWritableProvider) RemoveRule(id string) error {
if err := p.ensureWritableConfig(); err != nil {
return err
}
rules, err := p.LoadDMSRules()
if err != nil {
return err
@@ -488,6 +503,9 @@ func (p *HyprlandWritableProvider) RemoveRule(id string) error {
}
func (p *HyprlandWritableProvider) ReorderRules(ids []string) error {
if err := p.ensureWritableConfig(); err != nil {
return err
}
rules, err := p.LoadDMSRules()
if err != nil {
return err
@@ -513,6 +531,29 @@ func (p *HyprlandWritableProvider) ReorderRules(ids []string) error {
return p.writeDMSRules(newRules)
}
func (p *HyprlandWritableProvider) ensureWritableConfig() error {
if p.isLegacyConfigReadOnly() {
return fmt.Errorf("hyprland legacy conf configs are read-only; run dms setup to migrate to Lua before editing window rules")
}
return nil
}
func (p *HyprlandWritableProvider) isLegacyConfigReadOnly() bool {
expanded, err := utils.ExpandPath(p.configDir)
if err != nil {
expanded = p.configDir
}
luaPath := filepath.Join(expanded, "hyprland.lua")
if st, err := os.Stat(luaPath); err == nil && st.Mode().IsRegular() {
return false
}
confPath := filepath.Join(expanded, "hyprland.conf")
if st, err := os.Stat(confPath); err == nil && st.Mode().IsRegular() {
return true
}
return false
}
var dmsRuleCommentRegex = regexp.MustCompile(`^#\s*DMS-RULE:\s*id=([^,]+),\s*name=(.*)$`)
var dmsRuleLuaHDRRegex = regexp.MustCompile(`^\s*--\s*DMS-RULE:\s*id=([^,]+),\s*name=(.*)$`)
@@ -188,6 +188,27 @@ func TestHyprlandSetAndLoadDMSRules(t *testing.T) {
}
}
func TestHyprlandSetRuleLeavesConfOnlyInstallReadOnly(t *testing.T) {
tmpDir := t.TempDir()
if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.conf"), []byte("windowrulev2 = float, class:^(kitty)$\n"), 0o644); err != nil {
t.Fatal(err)
}
provider := NewHyprlandWritableProvider(tmpDir)
rule := newTestWindowRule("test_id", "Test Rule", "^(firefox)$")
rule.Actions.OpenFloating = boolPtr(true)
err := provider.SetRule(rule)
if err == nil {
t.Fatal("expected SetRule to reject conf-only Hyprland config")
}
if !strings.Contains(err.Error(), "read-only") {
t.Fatalf("expected read-only error, got %v", err)
}
if _, err := os.Stat(filepath.Join(tmpDir, "dms", "windowrules.lua")); !os.IsNotExist(err) {
t.Fatalf("expected no Lua windowrules file to be created for conf-only config, stat err=%v", err)
}
}
func TestHyprlandRemoveRule(t *testing.T) {
tmpDir := t.TempDir()
provider := NewHyprlandWritableProvider(tmpDir)
@@ -14,6 +14,18 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules"
)
type NiriMatch struct {
AppID string
Title string
IsFloating *bool
IsActive *bool
IsFocused *bool
IsActiveInColumn *bool
IsWindowCastTarget *bool
IsUrgent *bool
AtStartup *bool
}
type NiriWindowRule struct {
MatchAppID string
MatchTitle string
@@ -24,6 +36,7 @@ type NiriWindowRule struct {
MatchIsWindowCastTarget *bool
MatchIsUrgent *bool
MatchAtStartup *bool
Matches []NiriMatch
Opacity *float64
OpenFloating *bool
OpenMaximized *bool
@@ -50,6 +63,10 @@ type NiriWindowRule struct {
FocusRingOff *bool
BorderOff *bool
DrawBorderWithBg *bool
BgBlur *bool
BgXray *bool
BgNoise *float64
BgSaturation *float64
Source string
}
@@ -191,7 +208,7 @@ func (p *NiriRulesParser) parseWindowRuleNode(node *document.Node) {
switch childName {
case "match":
p.parseMatchNode(child, &rule)
rule.Matches = append(rule.Matches, p.parseMatchNode(child))
case "opacity":
if len(child.Arguments) > 0 {
val := child.Arguments[0].ResolvedValue()
@@ -297,9 +314,24 @@ func (p *NiriRulesParser) parseWindowRuleNode(node *document.Node) {
case "draw-border-with-background":
b := p.parseBoolArg(child)
rule.DrawBorderWithBg = &b
case "background-effect":
p.parseBackgroundEffectNode(child, &rule)
}
}
if len(rule.Matches) > 0 {
first := rule.Matches[0]
rule.MatchAppID = first.AppID
rule.MatchTitle = first.Title
rule.MatchIsFloating = first.IsFloating
rule.MatchIsActive = first.IsActive
rule.MatchIsFocused = first.IsFocused
rule.MatchIsActiveInColumn = first.IsActiveInColumn
rule.MatchIsWindowCastTarget = first.IsWindowCastTarget
rule.MatchIsUrgent = first.IsUrgent
rule.MatchAtStartup = first.AtStartup
}
p.rules = append(p.rules, rule)
}
@@ -326,45 +358,47 @@ func (p *NiriRulesParser) parseSizeNode(node *document.Node) string {
return ""
}
func (p *NiriRulesParser) parseMatchNode(node *document.Node, rule *NiriWindowRule) {
func (p *NiriRulesParser) parseMatchNode(node *document.Node) NiriMatch {
m := NiriMatch{}
if node.Properties == nil {
return
return m
}
if val, ok := node.Properties.Get("app-id"); ok {
rule.MatchAppID = val.ValueString()
m.AppID = val.ValueString()
}
if val, ok := node.Properties.Get("title"); ok {
rule.MatchTitle = val.ValueString()
m.Title = val.ValueString()
}
if val, ok := node.Properties.Get("is-floating"); ok {
b := val.ValueString() == "true"
rule.MatchIsFloating = &b
m.IsFloating = &b
}
if val, ok := node.Properties.Get("is-active"); ok {
b := val.ValueString() == "true"
rule.MatchIsActive = &b
m.IsActive = &b
}
if val, ok := node.Properties.Get("is-focused"); ok {
b := val.ValueString() == "true"
rule.MatchIsFocused = &b
m.IsFocused = &b
}
if val, ok := node.Properties.Get("is-active-in-column"); ok {
b := val.ValueString() == "true"
rule.MatchIsActiveInColumn = &b
m.IsActiveInColumn = &b
}
if val, ok := node.Properties.Get("is-window-cast-target"); ok {
b := val.ValueString() == "true"
rule.MatchIsWindowCastTarget = &b
m.IsWindowCastTarget = &b
}
if val, ok := node.Properties.Get("is-urgent"); ok {
b := val.ValueString() == "true"
rule.MatchIsUrgent = &b
m.IsUrgent = &b
}
if val, ok := node.Properties.Get("at-startup"); ok {
b := val.ValueString() == "true"
rule.MatchAtStartup = &b
m.AtStartup = &b
}
return m
}
func (p *NiriRulesParser) parseBorderNode(node *document.Node, rule *NiriWindowRule) {
@@ -385,6 +419,45 @@ func (p *NiriRulesParser) parseBorderNode(node *document.Node, rule *NiriWindowR
}
}
func (p *NiriRulesParser) parseBackgroundEffectNode(node *document.Node, rule *NiriWindowRule) {
if node.Children == nil {
return
}
for _, child := range node.Children {
switch child.Name.String() {
case "blur":
b := p.parseBoolArg(child)
rule.BgBlur = &b
case "xray":
b := p.parseBoolArg(child)
rule.BgXray = &b
case "noise":
if f, ok := p.parseFloatArg(child); ok {
rule.BgNoise = &f
}
case "saturation":
if f, ok := p.parseFloatArg(child); ok {
rule.BgSaturation = &f
}
}
}
}
func (p *NiriRulesParser) parseFloatArg(node *document.Node) (float64, bool) {
if len(node.Arguments) == 0 {
return 0, false
}
val := node.Arguments[0].ResolvedValue()
switch v := val.(type) {
case float64:
return v, true
case int64:
return float64(v), true
}
return 0, false
}
func (p *NiriRulesParser) parseFocusRingNode(node *document.Node, rule *NiriWindowRule) {
if node.Children == nil {
return
@@ -461,6 +534,27 @@ func ParseNiriWindowRules(configDir string) (*NiriRulesParseResult, error) {
}, nil
}
func convertNiriMatches(matches []NiriMatch) []windowrules.MatchCriteria {
if len(matches) == 0 {
return nil
}
result := make([]windowrules.MatchCriteria, 0, len(matches))
for _, m := range matches {
result = append(result, windowrules.MatchCriteria{
AppID: m.AppID,
Title: m.Title,
IsFloating: m.IsFloating,
IsActive: m.IsActive,
IsFocused: m.IsFocused,
IsActiveInColumn: m.IsActiveInColumn,
IsWindowCastTarget: m.IsWindowCastTarget,
IsUrgent: m.IsUrgent,
AtStartup: m.AtStartup,
})
}
return result
}
func ConvertNiriRulesToWindowRules(niriRules []NiriWindowRule) []windowrules.WindowRule {
result := make([]windowrules.WindowRule, 0, len(niriRules))
for i, nr := range niriRules {
@@ -479,6 +573,7 @@ func ConvertNiriRulesToWindowRules(niriRules []NiriWindowRule) []windowrules.Win
IsUrgent: nr.MatchIsUrgent,
AtStartup: nr.MatchAtStartup,
},
Matches: convertNiriMatches(nr.Matches),
Actions: windowrules.Actions{
Opacity: nr.Opacity,
OpenFloating: nr.OpenFloating,
@@ -506,6 +601,10 @@ func ConvertNiriRulesToWindowRules(niriRules []NiriWindowRule) []windowrules.Win
FocusRingOff: nr.FocusRingOff,
BorderOff: nr.BorderOff,
DrawBorderWithBg: nr.DrawBorderWithBg,
BackgroundBlur: nr.BgBlur,
BackgroundXray: nr.BgXray,
BackgroundNoise: nr.BgNoise,
BackgroundSaturation: nr.BgSaturation,
},
}
result = append(result, wr)
@@ -684,6 +783,7 @@ func (p *NiriWritableProvider) LoadDMSRules() ([]windowrules.WindowRule, error)
IsUrgent: nr.MatchIsUrgent,
AtStartup: nr.MatchAtStartup,
},
Matches: convertNiriMatches(nr.Matches),
Actions: windowrules.Actions{
Opacity: nr.Opacity,
OpenFloating: nr.OpenFloating,
@@ -711,6 +811,10 @@ func (p *NiriWritableProvider) LoadDMSRules() ([]windowrules.WindowRule, error)
FocusRingOff: nr.FocusRingOff,
BorderOff: nr.BorderOff,
DrawBorderWithBg: nr.DrawBorderWithBg,
BackgroundBlur: nr.BgBlur,
BackgroundXray: nr.BgXray,
BackgroundNoise: nr.BgNoise,
BackgroundSaturation: nr.BgSaturation,
},
}
@@ -740,44 +844,54 @@ func (p *NiriWritableProvider) writeDMSRules(rules []windowrules.WindowRule) err
return os.WriteFile(rulesPath, []byte(strings.Join(lines, "\n")), 0644)
}
func formatNiriMatchLine(m windowrules.MatchCriteria) (string, bool) {
var matchProps []string
if m.AppID != "" {
matchProps = append(matchProps, fmt.Sprintf("app-id=%q", m.AppID))
}
if m.Title != "" {
matchProps = append(matchProps, fmt.Sprintf("title=%q", m.Title))
}
if m.IsFloating != nil {
matchProps = append(matchProps, fmt.Sprintf("is-floating=%t", *m.IsFloating))
}
if m.IsActive != nil {
matchProps = append(matchProps, fmt.Sprintf("is-active=%t", *m.IsActive))
}
if m.IsFocused != nil {
matchProps = append(matchProps, fmt.Sprintf("is-focused=%t", *m.IsFocused))
}
if m.IsActiveInColumn != nil {
matchProps = append(matchProps, fmt.Sprintf("is-active-in-column=%t", *m.IsActiveInColumn))
}
if m.IsWindowCastTarget != nil {
matchProps = append(matchProps, fmt.Sprintf("is-window-cast-target=%t", *m.IsWindowCastTarget))
}
if m.IsUrgent != nil {
matchProps = append(matchProps, fmt.Sprintf("is-urgent=%t", *m.IsUrgent))
}
if m.AtStartup != nil {
matchProps = append(matchProps, fmt.Sprintf("at-startup=%t", *m.AtStartup))
}
if len(matchProps) == 0 {
return "", false
}
return " match " + strings.Join(matchProps, " "), true
}
func (p *NiriWritableProvider) formatRule(rule windowrules.WindowRule) string {
var lines []string
lines = append(lines, fmt.Sprintf("// @id=%s @name=%s", rule.ID, rule.Name))
lines = append(lines, "window-rule {")
m := rule.MatchCriteria
if m.AppID != "" || m.Title != "" || m.IsFloating != nil || m.IsActive != nil ||
m.IsFocused != nil || m.IsActiveInColumn != nil || m.IsWindowCastTarget != nil ||
m.IsUrgent != nil || m.AtStartup != nil {
var matchProps []string
if m.AppID != "" {
matchProps = append(matchProps, fmt.Sprintf("app-id=%q", m.AppID))
matches := rule.Matches
if len(matches) == 0 {
matches = []windowrules.MatchCriteria{rule.MatchCriteria}
}
for _, m := range matches {
if line, ok := formatNiriMatchLine(m); ok {
lines = append(lines, line)
}
if m.Title != "" {
matchProps = append(matchProps, fmt.Sprintf("title=%q", m.Title))
}
if m.IsFloating != nil {
matchProps = append(matchProps, fmt.Sprintf("is-floating=%t", *m.IsFloating))
}
if m.IsActive != nil {
matchProps = append(matchProps, fmt.Sprintf("is-active=%t", *m.IsActive))
}
if m.IsFocused != nil {
matchProps = append(matchProps, fmt.Sprintf("is-focused=%t", *m.IsFocused))
}
if m.IsActiveInColumn != nil {
matchProps = append(matchProps, fmt.Sprintf("is-active-in-column=%t", *m.IsActiveInColumn))
}
if m.IsWindowCastTarget != nil {
matchProps = append(matchProps, fmt.Sprintf("is-window-cast-target=%t", *m.IsWindowCastTarget))
}
if m.IsUrgent != nil {
matchProps = append(matchProps, fmt.Sprintf("is-urgent=%t", *m.IsUrgent))
}
if m.AtStartup != nil {
matchProps = append(matchProps, fmt.Sprintf("at-startup=%t", *m.AtStartup))
}
lines = append(lines, " match "+strings.Join(matchProps, " "))
}
a := rule.Actions
@@ -858,10 +972,31 @@ func (p *NiriWritableProvider) formatRule(rule windowrules.WindowRule) string {
lines = append(lines, fmt.Sprintf(" draw-border-with-background %t", *a.DrawBorderWithBg))
}
if a.BackgroundBlur != nil || a.BackgroundXray != nil || a.BackgroundNoise != nil || a.BackgroundSaturation != nil {
lines = append(lines, " background-effect {")
if a.BackgroundBlur != nil {
lines = append(lines, fmt.Sprintf(" blur %t", *a.BackgroundBlur))
}
if a.BackgroundXray != nil {
lines = append(lines, fmt.Sprintf(" xray %t", *a.BackgroundXray))
}
if a.BackgroundNoise != nil {
lines = append(lines, fmt.Sprintf(" noise %s", formatFloat(*a.BackgroundNoise)))
}
if a.BackgroundSaturation != nil {
lines = append(lines, fmt.Sprintf(" saturation %s", formatFloat(*a.BackgroundSaturation)))
}
lines = append(lines, " }")
}
lines = append(lines, "}")
return strings.Join(lines, "\n")
}
func formatFloat(f float64) string {
return strconv.FormatFloat(f, 'f', -1, 64)
}
func formatSizeProperty(name, value string) string {
parts := strings.SplitN(value, " ", 2)
if len(parts) == 2 {
+13 -6
View File
@@ -43,6 +43,10 @@ type Actions struct {
FocusRingOff *bool `json:"focusRingOff,omitempty"`
BorderOff *bool `json:"borderOff,omitempty"`
DrawBorderWithBg *bool `json:"drawBorderWithBackground,omitempty"`
BackgroundBlur *bool `json:"backgroundBlur,omitempty"`
BackgroundXray *bool `json:"backgroundXray,omitempty"`
BackgroundNoise *float64 `json:"backgroundNoise,omitempty"`
BackgroundSaturation *float64 `json:"backgroundSaturation,omitempty"`
Size string `json:"size,omitempty"`
Move string `json:"move,omitempty"`
Monitor string `json:"monitor,omitempty"`
@@ -62,12 +66,13 @@ type Actions struct {
}
type WindowRule struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
Enabled bool `json:"enabled"`
MatchCriteria MatchCriteria `json:"matchCriteria"`
Actions Actions `json:"actions"`
Source string `json:"source,omitempty"`
ID string `json:"id"`
Name string `json:"name,omitempty"`
Enabled bool `json:"enabled"`
MatchCriteria MatchCriteria `json:"matchCriteria"`
Matches []MatchCriteria `json:"matches,omitempty"`
Actions Actions `json:"actions"`
Source string `json:"source,omitempty"`
}
type DMSRulesStatus struct {
@@ -79,6 +84,8 @@ type DMSRulesStatus struct {
Effective bool `json:"effective"`
OverriddenBy int `json:"overriddenBy"`
StatusMessage string `json:"statusMessage"`
ConfigFormat string `json:"configFormat,omitempty"`
ReadOnly bool `json:"readOnly,omitempty"`
}
type RuleSet struct {
+7
View File
@@ -48,6 +48,13 @@ fragments.
keyboard shortcuts in `dms/binds-user.lua`, or use the Keyboard Shortcuts page in
DMS Settings.
Stock configs include a 3-finger horizontal touchpad gesture for workspace
switching (`hl.gesture` in `dms/binds.lua`) and basic touchpad settings
(`tap_to_click`, `natural_scroll` in `hyprland.lua`). To customize or disable
gestures, add your own `hl.gesture(...)` lines to `dms/binds-user.lua`, or unset
a stock gesture with `action = "unset"` matching the original fingers,
direction, and modifiers.
Most other existing non-empty Lua fragments are preserved.
## Legacy Config Migration
+63
View File
@@ -282,6 +282,53 @@ dms ipc call inhibit toggle
dms ipc call inhibit enable
```
## Target: `powerprofile`
Power profile control via `power-profiles-daemon`. Changes stay in sync with DMS UI and trigger the power profile OSD when enabled.
Requires `power-profiles-daemon` to be installed and running. Works on all compositors.
### Functions
**`open`**
- Show the power profile picker modal
- Returns: Success confirmation or error if daemon unavailable
**`close`**
- Close the power profile picker modal
- Returns: Success confirmation
**`toggle`**
- Toggle power profile picker modal visibility
- Returns: Success confirmation or error if daemon unavailable
**`list`**
- List available profile slugs, one per line
- Returns: `power-saver`, `balanced`, and `performance` when supported
**`status`**
- Get the currently active profile slug
- Returns: `power-saver`, `balanced`, `performance`, or error if daemon unavailable
**`set <profile>`**
- Set the active power profile
- Parameters: Profile slug or alias — `power-saver` (`powersaver`, `saver`, `0`), `balanced` (`1`), `performance` (`2`)
- Returns: Success confirmation or error if profile unknown, unsupported, or write failed
**`cycle`**
- Cycle to the next available profile in order: power-saver → balanced → performance → power-saver
- Returns: Success confirmation or error if daemon unavailable or write failed
### Examples
```bash
dms ipc call powerprofile status
dms ipc call powerprofile list
dms ipc call powerprofile cycle
dms ipc call powerprofile set balanced
dms ipc call powerprofile set performance
dms ipc call powerprofile toggle
```
## Target: `wallpaper`
Wallpaper management and retrieval with support for per-monitor configurations.
@@ -543,6 +590,18 @@ Power menu modal control for system power actions.
- `close` - Hide power menu modal
- `toggle` - Toggle power menu modal visibility
### Target: `powerprofile`
Power profile picker modal and profile control via `power-profiles-daemon`.
**Functions:**
- `open` - Show power profile picker modal
- `close` - Hide power profile picker modal
- `toggle` - Toggle power profile picker modal visibility
- `list` - List available profile slugs
- `status` - Get current profile slug
- `set <profile>` - Set profile by slug or alias (`power-saver`, `balanced`, `performance`)
- `cycle` - Cycle to the next available profile
### Target: `control-center`
Control Center popout containing network, bluetooth, audio, power, and other quick settings.
@@ -673,6 +732,10 @@ dms ipc call processlist toggle
# Show power menu
dms ipc call powermenu toggle
# Cycle or set power profile (requires power-profiles-daemon)
dms ipc call powerprofile cycle
dms ipc call powerprofile toggle
# Open notepad
dms ipc call notepad toggle
+10
View File
@@ -57,9 +57,15 @@ const KEY_MAP = {
16842802: "XF86Eject",
16842791: "XF86Calculator",
16842806: "XF86Explorer",
16777360: "XF86HomePage",
16842794: "XF86HomePage",
16777362: "XF86Search",
16777426: "XF86Search",
16777376: "XF86Mail",
16777427: "XF86Mail",
16777377: "XF86AudioMedia",
16777419: "XF86Calculator",
16777429: "XF86Explorer",
16777442: "XF86Launch0",
16777443: "XF86Launch1",
33: "1",
@@ -129,6 +135,10 @@ function xkbKeyFromQtKey(qk) {
return String.fromCharCode(qk);
if (qk >= 16777264 && qk <= 16777298)
return "F" + (qk - 16777264 + 1);
if (qk >= 16777378 && qk <= 16777387)
return "XF86Launch" + (qk - 16777378);
if (qk >= 16777388 && qk <= 16777393)
return "XF86Launch" + String.fromCharCode(65 + qk - 16777388);
return KEY_MAP[qk] || "";
}
+24
View File
@@ -770,6 +770,26 @@ const DMS_ACTION_ARGS = {
}
};
const DMS_AMOUNT_LABELS = {
"audio increment": "Volume Up",
"audio decrement": "Volume Down",
"mpris increment": "Player Volume Up",
"mpris decrement": "Player Volume Down",
"brightness increment": "Brightness Up",
"brightness decrement": "Brightness Down"
};
function getDmsAmountLabel(action) {
var parsed = parseDmsActionArgs(action);
var label = DMS_AMOUNT_LABELS[parsed.base];
if (!label)
return null;
var amount = parsed.args?.amount;
if (amount === undefined || amount === null || amount === "")
return label;
return label + " (" + amount + "%)";
}
function getActionTypes() {
return ACTION_TYPES;
}
@@ -844,6 +864,10 @@ function getActionLabel(action, compositor) {
if (!action)
return "";
var amountLabel = getDmsAmountLabel(action);
if (amountLabel)
return amountLabel;
var dmsAct = findDmsAction(action);
if (dmsAct)
return dmsAct.label;
+91
View File
@@ -0,0 +1,91 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Wayland
import qs.Services
Singleton {
id: root
readonly property var log: Log.scoped("LayerShell")
function _toLayer(name) {
switch (name) {
case "background":
return WlrLayer.Background;
case "bottom":
return WlrLayer.Bottom;
case "top":
return WlrLayer.Top;
case "overlay":
return WlrLayer.Overlay;
}
return undefined;
}
function _toName(layer) {
switch (layer) {
case WlrLayer.Background:
return "background";
case WlrLayer.Bottom:
return "bottom";
case WlrLayer.Top:
return "top";
case WlrLayer.Overlay:
return "overlay";
}
return "top";
}
// Resolve a WlrLayer from a DMS_*_LAYER env override.
// name: env var to read, e.g. "DMS_OSD_LAYER"
// fallback: WlrLayer used when the var is unset or unrecognized
// opts (optional):
// allow: array of honored layer names; recognized names outside it
// are treated as invalid
// invalidLayer: WlrLayer used for a recognized-but-disallowed value
// (default: fallback)
// label: context for the diagnostic, e.g. "OSDs"; omit to stay silent
// error: log at error level instead of warn
function fromEnv(name, fallback, opts) {
const value = Quickshell.env(name);
if (!value)
return fallback;
const requested = _toLayer(value);
if (requested === undefined)
return fallback;
const allow = opts?.allow;
if (!allow || allow.indexOf(value) !== -1)
return requested;
const invalid = opts?.invalidLayer ?? fallback;
if (opts?.label) {
const msg = `'${value}' layer is not valid for ${opts.label}. Defaulting to '${_toName(invalid)}' layer.`;
if (opts?.error)
log.error(msg);
else
log.warn(msg);
}
return invalid;
}
// For call sites that only need "is the override the overlay layer?".
// Honors "overlay" (true) and bottom/background/top (false); anything else
// returns `fallback`.
function envUsesOverlay(name, fallback) {
switch (Quickshell.env(name)) {
case "overlay":
return true;
case "bottom":
case "background":
case "top":
return false;
default:
return fallback;
}
}
}
+1
View File
@@ -13,6 +13,7 @@ Singleton {
property var currentModalsByScreen: ({})
function openModal(modal) {
PopoutManager.screenshotActive = false;
const screenName = modal.effectiveScreen?.name ?? "unknown";
currentModalsByScreen[screenName] = modal;
modalChanged();
+5
View File
@@ -10,6 +10,9 @@ Singleton {
property var currentPopoutsByScreen: ({})
property var currentPopoutTriggers: ({})
// Set by the screenshot IPC handshake (dms screenshot region select); cleared by end() or any popout/modal open.
property bool screenshotActive: false
signal popoutOpening
signal popoutChanged
@@ -47,6 +50,7 @@ Singleton {
function showPopout(popout) {
if (!popout || !popout.screen)
return;
screenshotActive = false;
popoutOpening();
const screenName = popout.screen.name;
@@ -97,6 +101,7 @@ Singleton {
function requestPopout(popout, tabIndex, triggerSource) {
if (!popout || !popout.screen)
return;
screenshotActive = false;
const screenName = popout.screen.name;
const currentPopout = currentPopoutsByScreen[screenName];
const triggerId = triggerSource !== undefined ? triggerSource : tabIndex;
+34 -4
View File
@@ -154,6 +154,8 @@ Singleton {
property var trayItemOrder: []
property var recentColors: []
property bool showThirdPartyPlugins: false
property bool pluginBrowserInstalledFirst: false
property string pluginBrowserSortMode: "default"
property string launchPrefix: ""
property string lastBrightnessDevice: ""
property var brightnessExponentialDevices: ({})
@@ -964,6 +966,20 @@ Singleton {
saveSettings();
}
function setPluginBrowserInstalledFirst(enabled) {
pluginBrowserInstalledFirst = enabled;
saveSettings();
}
function setPluginBrowserSortMode(mode) {
if (mode === "type" || mode === "contributor")
mode = "author";
if (mode !== "default" && mode !== "name" && mode !== "author" && mode !== "category")
mode = "default";
pluginBrowserSortMode = mode;
saveSettings();
}
function setLaunchPrefix(prefix) {
launchPrefix = prefix;
saveSettings();
@@ -1353,13 +1369,27 @@ Singleton {
}
}
readonly property string _greeterCacheDir: Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter"
property string greeterSessionBaseDir: root._greeterCacheDir
function setGreeterSessionBaseDir(dir) {
const next = dir || root._greeterCacheDir;
if (greeterSessionBaseDir === next)
return;
greeterSessionBaseDir = next;
if (isGreeterMode)
greeterSessionFile.reload();
}
function resetGreeterSessionBaseDir() {
setGreeterSessionBaseDir(root._greeterCacheDir);
}
FileView {
id: greeterSessionFile
path: {
const greetCfgDir = Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter";
return greetCfgDir + "/session.json";
}
path: root.greeterSessionBaseDir ? (root.greeterSessionBaseDir + "/session.json") : ""
preload: isGreeterMode
blockLoading: false
blockWrites: true
+15 -1
View File
@@ -315,6 +315,8 @@ Singleton {
property bool controlCenterShowBatteryIcon: false
property bool controlCenterShowPrinterIcon: false
property bool controlCenterShowScreenSharingIcon: true
property bool controlCenterShowIdleInhibitorIcon: false
property bool controlCenterShowDoNotDisturbIcon: false
property bool showPrivacyButton: true
property bool privacyShowMicIcon: false
property bool privacyShowCameraIcon: false
@@ -405,6 +407,7 @@ Singleton {
property int appsDockEnlargePercentage: 125
property int appsDockIconSizePercentage: 100
property bool keyboardLayoutNameCompactMode: false
property bool keyboardLayoutNameShowIcon: false
property bool runningAppsCurrentWorkspace: true
property bool runningAppsGroupByApp: false
property bool runningAppsCurrentMonitor: false
@@ -414,6 +417,7 @@ Singleton {
property string lockDateFormat: ""
property bool greeterRememberLastSession: true
property bool greeterRememberLastUser: true
property bool greeterAutoLogin: false
property bool greeterEnableFprint: false
property bool greeterEnableU2f: false
property string greeterWallpaperPath: ""
@@ -1333,6 +1337,15 @@ Singleton {
});
}
function scheduleGreeterAutoLoginSync() {
if (isGreeterMode)
return;
Qt.callLater(() => {
Processes.settingsRoot = root;
Processes.scheduleGreeterAutoLoginSync();
});
}
readonly property var _hooks: ({
"applyStoredTheme": applyStoredTheme,
"regenSystemThemes": regenSystemThemes,
@@ -1340,7 +1353,8 @@ Singleton {
"applyStoredIconTheme": applyStoredIconTheme,
"updateBarConfigs": updateBarConfigs,
"updateCompositorCursor": updateCompositorCursor,
"scheduleAuthApply": scheduleAuthApply
"scheduleAuthApply": scheduleAuthApply,
"scheduleGreeterAutoLoginSync": scheduleGreeterAutoLoginSync
})
function set(key, value) {
+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 shortDuration: (typeof SettingsData !== "undefined" && SettingsData.animationSpeed === SettingsData.AnimationSpeed.Custom) ? SettingsData.customAnimationDuration : currentDurations.short
readonly property bool snapListModelChanges: shortDuration <= 0
readonly property int mediumDuration: (typeof SettingsData !== "undefined" && SettingsData.animationSpeed === SettingsData.AnimationSpeed.Custom) ? SettingsData.customAnimationDuration : currentDurations.medium
readonly property int longDuration: (typeof SettingsData !== "undefined" && SettingsData.animationSpeed === SettingsData.AnimationSpeed.Custom) ? SettingsData.customAnimationDuration : currentDurations.long
readonly property int extraLongDuration: (typeof SettingsData !== "undefined" && SettingsData.animationSpeed === SettingsData.AnimationSpeed.Custom) ? SettingsData.customAnimationDuration : currentDurations.extraLong
@@ -2079,12 +2080,29 @@ Singleton {
}
}
readonly property string _greeterCacheDir: Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter"
property string greeterColorsBaseDir: root._greeterCacheDir
function setGreeterColorsBaseDir(dir) {
const next = dir || root._greeterCacheDir;
if (greeterColorsBaseDir === next)
return;
greeterColorsBaseDir = next;
if (typeof SessionData !== "undefined" && SessionData.isGreeterMode)
dynamicColorsFileView.reload();
}
function resetGreeterColorsBaseDir() {
setGreeterColorsBaseDir(root._greeterCacheDir);
}
FileView {
id: dynamicColorsFileView
path: {
const greetCfgDir = Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter";
const colorsPath = SessionData.isGreeterMode ? greetCfgDir + "/colors.json" : stateDir + "/dms-colors.json";
return colorsPath;
if (SessionData.isGreeterMode)
return root.greeterColorsBaseDir ? (root.greeterColorsBaseDir + "/colors.json") : "";
return stateDir + "/dms-colors.json";
}
blockLoading: false
watchChanges: !SessionData.isGreeterMode
+165
View File
@@ -12,6 +12,35 @@ Singleton {
property var settingsRoot: null
onSettingsRootChanged: {
if (settingsRoot && !settingsRoot.isGreeterMode)
consumeGreeterAutoLoginPendingSync();
}
readonly property string greeterAutoLoginPendingSyncPath: (Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter") + "/.local/state/auto-login-sync-pending"
function consumeGreeterAutoLoginPendingSync() {
if (!settingsRoot || settingsRoot.isGreeterMode)
return;
greeterAutoLoginPendingCheckProcess.running = true;
}
property var greeterAutoLoginPendingCheckProcess: Process {
command: ["sh", "-c", "if [ -f " + JSON.stringify(root.greeterAutoLoginPendingSyncPath) + " ]; then rm -f " + JSON.stringify(root.greeterAutoLoginPendingSyncPath) + "; echo pending; fi"]
running: false
stdout: StdioCollector {
onStreamFinished: {
if ((text || "").trim() !== "pending" || !root.settingsRoot)
return;
if (!root.settingsRoot.greeterAutoLogin)
root.settingsRoot.set("greeterAutoLogin", true);
else
root.scheduleGreeterAutoLoginSync();
}
}
}
property string greetdPamText: ""
property string systemAuthPamText: ""
property string commonAuthPamText: ""
@@ -296,6 +325,66 @@ Singleton {
authApplyDebounce.restart();
}
// --- Greeter auto-login sync pipeline ---
property bool greeterAutoLoginSyncRunning: false
property bool greeterAutoLoginSyncQueued: false
property bool greeterAutoLoginSyncRerunRequested: false
property string greeterAutoLoginSyncStdout: ""
property string greeterAutoLoginSyncStderr: ""
property string greeterAutoLoginSyncTerminalFallbackStderr: ""
function scheduleGreeterAutoLoginSync() {
if (!settingsRoot || settingsRoot.isGreeterMode)
return;
greeterAutoLoginSyncQueued = true;
if (greeterAutoLoginSyncRunning) {
greeterAutoLoginSyncRerunRequested = true;
return;
}
greeterAutoLoginSyncDebounce.restart();
}
function beginGreeterAutoLoginSync() {
if (!greeterAutoLoginSyncQueued || greeterAutoLoginSyncRunning || !settingsRoot || settingsRoot.isGreeterMode)
return;
greeterAutoLoginSyncQueued = false;
greeterAutoLoginSyncRerunRequested = false;
greeterAutoLoginSyncStdout = "";
greeterAutoLoginSyncStderr = "";
greeterAutoLoginSyncTerminalFallbackStderr = "";
greeterAutoLoginSyncRunning = true;
greeterAutoLoginSyncSudoProbeProcess.running = true;
}
function launchGreeterAutoLoginSyncTerminalFallback(details) {
ToastService.showWarning(I18n.tr("Opening terminal to update greetd"), I18n.tr("DMS needs administrator access. The terminal closes automatically when done.") + (details ? "\n\n" + details : ""), "dms greeter sync --autologin-only", "greeter-autologin-sync");
greeterAutoLoginSyncTerminalFallbackStderr = "";
greeterAutoLoginSyncTerminalFallbackProcess.running = true;
}
function greeterAutoLoginSyncSuccessToast(details) {
const enabling = settingsRoot && settingsRoot.greeterAutoLogin;
// Clear the sticky in-progress toast, then confirm with an auto-dismissing toast.
ToastService.dismissCategory("greeter-autologin-sync");
if (enabling) {
ToastService.showWarning(I18n.tr("Auto-login enabled"), I18n.tr("You'll skip the greeter password after the next reboot. The lock screen and signing out still require your password.") + (details ? "\n\n" + details : ""));
} else {
ToastService.showInfo(I18n.tr("Auto-login disabled"), I18n.tr("You'll enter your password at the greeter after the next reboot.") + (details ? "\n\n" + details : ""));
}
}
function finishGreeterAutoLoginSync() {
const shouldRerun = greeterAutoLoginSyncQueued || greeterAutoLoginSyncRerunRequested;
greeterAutoLoginSyncRunning = false;
greeterAutoLoginSyncRerunRequested = false;
if (shouldRerun)
greeterAutoLoginSyncDebounce.restart();
}
// --- PAM parsing helpers ---
function stripPamComment(line) {
@@ -433,6 +522,82 @@ Singleton {
onTriggered: root.beginAuthApply()
}
Timer {
id: greeterAutoLoginSyncDebounce
interval: 300
repeat: false
onTriggered: root.beginGreeterAutoLoginSync()
}
property var greeterAutoLoginSyncProcess: Process {
command: ["dms", "greeter", "sync", "--yes", "--autologin-only"]
running: false
stdout: StdioCollector {
onStreamFinished: root.greeterAutoLoginSyncStdout = text || ""
}
stderr: StdioCollector {
onStreamFinished: root.greeterAutoLoginSyncStderr = text || ""
}
onExited: exitCode => {
const out = (root.greeterAutoLoginSyncStdout || "").trim();
const err = (root.greeterAutoLoginSyncStderr || "").trim();
if (exitCode === 0) {
let details = out;
if (err !== "")
details = details !== "" ? details + "\n\nstderr:\n" + err : "stderr:\n" + err;
root.greeterAutoLoginSyncSuccessToast(details);
root.finishGreeterAutoLoginSync();
return;
}
let details = "";
if (out !== "")
details = out;
if (err !== "")
details = details !== "" ? details + "\n\nstderr:\n" + err : "stderr:\n" + err;
root.launchGreeterAutoLoginSyncTerminalFallback(details);
}
}
property var greeterAutoLoginSyncSudoProbeProcess: Process {
command: ["sudo", "-n", "true"]
running: false
onExited: exitCode => {
const enabling = root.settingsRoot && root.settingsRoot.greeterAutoLogin;
if (exitCode === 0) {
ToastService.showWarning(enabling ? I18n.tr("Applying auto-login on startup…") : I18n.tr("Disabling auto-login on startup…"), "", "dms greeter sync --autologin-only", "greeter-autologin-sync");
root.greeterAutoLoginSyncProcess.running = true;
return;
}
root.launchGreeterAutoLoginSyncTerminalFallback();
}
}
property var greeterAutoLoginSyncTerminalFallbackProcess: Process {
command: ["dms", "greeter", "sync", "--terminal", "--yes", "--autologin-only"]
running: false
stderr: StdioCollector {
onStreamFinished: root.greeterAutoLoginSyncTerminalFallbackStderr = text || ""
}
onExited: exitCode => {
if (exitCode === 0) {
root.greeterAutoLoginSyncSuccessToast("");
} else {
let details = (root.greeterAutoLoginSyncTerminalFallbackStderr || "").trim();
ToastService.showError(I18n.tr("Couldn't open a terminal for the auto-login update.") + " (exit " + exitCode + ")", details, "dms greeter sync --autologin-only", "greeter-autologin-sync");
}
root.finishGreeterAutoLoginSync();
}
}
property var authApplyProcess: Process {
command: ["dms", "auth", "sync", "--yes"]
running: false
@@ -56,6 +56,8 @@ var SPEC = {
trayItemOrder: { def: [] },
recentColors: { def: [] },
showThirdPartyPlugins: { def: false },
pluginBrowserInstalledFirst: { def: false },
pluginBrowserSortMode: { def: "default" },
launchPrefix: { def: "" },
lastBrightnessDevice: { def: "" },
@@ -104,6 +104,8 @@ var SPEC = {
controlCenterShowBatteryIcon: { def: false },
controlCenterShowPrinterIcon: { def: false },
controlCenterShowScreenSharingIcon: { def: true },
controlCenterShowIdleInhibitorIcon: { def: false },
controlCenterShowDoNotDisturbIcon: { def: false },
showPrivacyButton: { def: true },
privacyShowMicIcon: { def: false },
@@ -165,6 +167,7 @@ var SPEC = {
appsDockEnlargePercentage: { def: 125 },
appsDockIconSizePercentage: { def: 100 },
keyboardLayoutNameCompactMode: { def: false },
keyboardLayoutNameShowIcon: { def: false},
runningAppsCurrentWorkspace: { def: true },
runningAppsGroupByApp: { def: false },
runningAppsCurrentMonitor: { def: false },
@@ -182,6 +185,7 @@ var SPEC = {
lockDateFormat: { def: "" },
greeterRememberLastSession: { def: true },
greeterRememberLastUser: { def: true },
greeterAutoLogin: { def: false, onChange: "scheduleGreeterAutoLoginSync" },
greeterEnableFprint: { def: false, onChange: "scheduleAuthApply" },
greeterEnableU2f: { def: false, onChange: "scheduleAuthApply" },
greeterWallpaperPath: { def: "" },
+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 {
powerMenuModalLoader: powerMenuModalLoader
processListModalLoader: processListModalLoader
+85
View File
@@ -3,6 +3,7 @@ import Quickshell.Io
import Quickshell.Hyprland
import Quickshell.Wayland
import Quickshell.Services.SystemTray
import Quickshell.Services.UPower
import qs.Common
import qs.Services
import qs.Modules.Settings.DisplayConfig
@@ -161,6 +162,21 @@ Item {
target: "control-center"
}
IpcHandler {
// Screenshot region-select handshake
function begin(): string {
PopoutManager.screenshotActive = true;
return "SCREENSHOT_MODE_ON";
}
function end(): string {
PopoutManager.screenshotActive = false;
return "SCREENSHOT_MODE_OFF";
}
target: "screenshot"
}
IpcHandler {
function resolveTabIndex(tab: string): int {
switch ((tab || "").toLowerCase()) {
@@ -1875,4 +1891,73 @@ Item {
target: "tray"
}
IpcHandler {
function open(): string {
if (!PowerProfileWatcher.available)
return "ERROR: power-profiles-daemon not available";
PopoutService.openPowerProfileModal();
return "POWERPROFILE_OPEN_SUCCESS";
}
function close(): string {
PopoutService.closePowerProfileModal();
return "POWERPROFILE_CLOSE_SUCCESS";
}
function toggle(): string {
if (!PowerProfileWatcher.available)
return "ERROR: power-profiles-daemon not available";
PopoutService.togglePowerProfileModal();
return "POWERPROFILE_TOGGLE_SUCCESS";
}
function list(): string {
if (!PowerProfileWatcher.available)
return "ERROR: power-profiles-daemon not available";
return PowerProfileWatcher.availableProfiles.map(profile => PowerProfileWatcher.profileSlug(profile)).join("\n");
}
function status(): string {
if (!PowerProfileWatcher.available)
return "ERROR: power-profiles-daemon not available";
return PowerProfileWatcher.profileSlug(PowerProfiles.profile);
}
function set(profile: string): string {
if (!PowerProfileWatcher.available)
return "ERROR: power-profiles-daemon not available";
if (!profile)
return "ERROR: No profile specified";
const parsed = PowerProfileWatcher.parseProfileSlug(profile);
if (parsed === -1)
return "ERROR: Unknown power profile. Supported options: power-saver, balanced, performance";
if (parsed === PowerProfile.Performance && !PowerProfiles.hasPerformanceProfile)
return "ERROR: Performance profile not supported by hardware";
if (!PowerProfileWatcher.applyProfile(parsed))
return "ERROR: Failed to set power profile";
return "POWERPROFILE_SET_SUCCESS";
}
function cycle(): string {
if (!PowerProfileWatcher.available)
return "ERROR: power-profiles-daemon not available";
if (!PowerProfileWatcher.cycleProfile())
return "ERROR: Failed to set power profile";
return "POWERPROFILE_CYCLE_SUCCESS";
}
target: "powerprofile"
}
}
@@ -26,7 +26,8 @@ Item {
ClipboardHeader {
id: header
width: parent.width
totalCount: modal.totalCount
recentsCount: modal.unpinnedEntries.length
savedCount: modal.pinnedEntries.length
showKeyboardHints: modal.showKeyboardHints
activeTab: modal.activeTab
pinnedCount: modal.pinnedCount
@@ -65,15 +66,6 @@ Item {
forceActiveFocus();
});
}
Connections {
target: modal
function onOpened() {
Qt.callLater(function () {
searchField.forceActiveFocus();
});
}
}
}
}
@@ -108,6 +100,20 @@ Item {
pressDelay: 0
flickableDirection: Flickable.VerticalFlick
states: [
State {
name: "snap"
when: Theme.snapListModelChanges
PropertyChanges {
target: clipboardListView
add: null
remove: null
displaced: null
move: null
}
}
]
function ensureVisible(index) {
if (index < 0 || index >= count) {
return;
@@ -145,6 +151,7 @@ Item {
onDeleteRequested: clipboardContent.modal.deleteEntry(modelData)
onPinRequested: clipboardContent.modal.pinEntry(modelData)
onUnpinRequested: clipboardContent.modal.unpinEntry(modelData)
onEditRequested: clipboardContent.modal.editEntry(modelData)
}
}
@@ -167,6 +174,20 @@ Item {
pressDelay: 0
flickableDirection: Flickable.VerticalFlick
states: [
State {
name: "snap"
when: Theme.snapListModelChanges
PropertyChanges {
target: savedListView
add: null
remove: null
displaced: null
move: null
}
}
]
function ensureVisible(index) {
if (index < 0 || index >= count) {
return;
@@ -204,6 +225,7 @@ Item {
onDeleteRequested: clipboardContent.modal.deletePinnedEntry(modelData)
onPinRequested: clipboardContent.modal.pinEntry(modelData)
onUnpinRequested: clipboardContent.modal.unpinEntry(modelData)
onEditRequested: clipboardContent.modal.editEntry(modelData)
}
}
@@ -0,0 +1,530 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls
import qs.Common
import qs.Services
import qs.Widgets
Item {
id: root
required property var modal
property var keyController: null
property var entry: null
property string editorText: ""
function decodeEntryData(data) {
if (!data) {
return "";
}
if (typeof data !== "string") {
return String(data);
}
const sanitized = data.replace(/\s+/g, "");
if (sanitized.length === 0) {
return "";
}
try {
const decoded = Qt.atob(sanitized);
if (!decoded) {
return data;
}
let binary = "";
if (typeof decoded === "string") {
// Pre-6.11 Qt.atob returns a binary string directly
binary = decoded;
} else {
// Qt 6.11+ Qt.atob returns an ArrayBuffer convert to avoid O(n²) concat/stack limits
const bytes = new Uint8Array(decoded);
const chunkSize = 8192;
const chunks = [];
for (let i = 0; i < bytes.length; i += chunkSize) {
chunks.push(String.fromCharCode.apply(null, bytes.subarray(i, i + chunkSize)));
}
binary = chunks.join("");
}
if (!binary) {
return data;
}
try {
return decodeURIComponent(escape(binary));
} catch (e) {
return binary;
}
} catch (e) {
return data;
}
}
function setEntry(newEntry) {
entry = newEntry;
editorText = newEntry?.text ?? newEntry?.preview ?? "";
if (editField) {
editField.text = editorText;
}
Qt.callLater(function () {
if (editField) {
editField.forceActiveFocus();
editField.cursorPosition = editField.text.length;
}
});
if (!newEntry || newEntry.isImage) {
return;
}
const requestedId = newEntry.id;
DMSService.sendRequest("clipboard.getEntry", {
"id": requestedId
}, function (response) {
if (response.error) {
return;
}
if (!root.entry || root.entry.id !== requestedId) {
return;
}
if (!response.result) {
ClipboardService.refresh();
return;
}
const result = response.result;
let fullText = "";
if (result?.data) {
fullText = root.decodeEntryData(result.data);
} else {
fullText = result?.preview ?? "";
}
if (!fullText || fullText.length === 0) {
return;
}
root.editorText = fullText;
if (editField) {
if (fullText.length > 50000) {
Qt.callLater(function () {
if (editField) {
editField.text = fullText;
editField.cursorPosition = fullText.length;
}
});
} else {
editField.text = fullText;
editField.cursorPosition = fullText.length;
}
}
});
}
function saveEntry(action) {
const saveAction = action ?? "history";
DMSService.sendRequest("clipboard.copy", {
"text": root.editorText
}, function (response) {
if (response.error) {
ToastService.showError(I18n.tr("Failed to update clipboard"));
return;
}
if (saveAction === "history") {
modal.mode = "history";
Qt.callLater(function () {
ClipboardService.reset();
ClipboardService.refresh();
if (keyController) {
keyController.reset();
}
});
return;
}
if (saveAction === "close") {
modal.hide();
return;
}
if (saveAction === "paste") {
ClipboardService.pasteClipboard(modal.hide);
}
});
}
function positionSaveMenu() {
saveMenu.width = Math.max(saveMenuColumn.implicitWidth + saveMenu.padding * 2, saveButton.width);
const pos = saveButton.mapToItem(Overlay.overlay, 0, 0);
const popupW = saveMenu.width;
const popupH = saveMenu.height;
const overlayW = Overlay.overlay.width;
const overlayH = Overlay.overlay.height;
let x = pos.x + (saveButton.width - popupW) / 2;
let y = pos.y + saveButton.height + 4;
if (y + popupH > overlayH) {
y = pos.y - popupH - 4;
}
x = Math.max(8, Math.min(x, overlayW - popupW - 8));
y = Math.max(8, y);
saveMenu.x = x;
saveMenu.y = y;
}
function toggleSaveMenu() {
if (saveMenu.visible) {
saveMenu.close();
return;
}
saveMenu.open();
positionSaveMenu();
Qt.callLater(positionSaveMenu);
}
Shortcut {
sequences: ["Escape"]
enabled: modal.mode === "editor"
onActivated: modal.mode = "history"
}
Column {
anchors.fill: parent
anchors.margins: Theme.spacingM
spacing: Theme.spacingM
Item {
id: editorHeader
width: parent.width
height: ClipboardConstants.headerHeight
DankActionButton {
iconName: "arrow_back"
iconSize: Theme.iconSize - 4
iconColor: Theme.surfaceText
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
onClicked: modal.mode = "history"
}
StyledText {
text: I18n.tr("Edit Clipboard")
font.pixelSize: Theme.fontSizeLarge
color: Theme.surfaceText
font.weight: Font.Medium
anchors.centerIn: parent
}
DankActionButton {
iconName: "close"
iconSize: Theme.iconSize - 4
iconColor: Theme.surfaceText
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
onClicked: modal.mode = "history"
}
}
StyledRect {
id: editFieldContainer
width: parent.width
height: Math.max(Theme.fontSizeMedium * 8, parent.height - editorHeader.height - editorActions.height - Theme.spacingM * 2)
radius: Theme.cornerRadius
color: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
border.color: editField.activeFocus ? Theme.primary : Theme.outlineMedium
border.width: editField.activeFocus ? 2 : 1
clip: true
DankIcon {
id: editIcon
name: "edit"
size: Theme.iconSize
color: editField.activeFocus ? Theme.primary : Theme.surfaceVariantText
anchors.left: parent.left
anchors.leftMargin: Theme.spacingM
anchors.top: parent.top
anchors.topMargin: Theme.spacingM
}
DankFlickable {
id: editScroll
anchors.left: editIcon.right
anchors.leftMargin: Theme.spacingS
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.rightMargin: Theme.spacingM
anchors.topMargin: Theme.spacingS
anchors.bottomMargin: Theme.spacingS
clip: true
contentWidth: width
contentHeight: editField.height
TextEdit {
id: editField
width: editScroll.width
height: Math.max(editScroll.height, contentHeight)
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
wrapMode: TextEdit.Wrap
selectByMouse: true
onTextChanged: root.editorText = text
Keys.onPressed: function (event) {
const hasCtrl = (event.modifiers & Qt.ControlModifier) !== 0;
const hasShift = (event.modifiers & Qt.ShiftModifier) !== 0;
if (hasCtrl && event.key === Qt.Key_S) {
root.saveEntry(hasShift ? "close" : "history");
event.accepted = true;
return;
}
if (hasCtrl && hasShift && event.key === Qt.Key_V) {
root.saveEntry("paste");
event.accepted = true;
return;
}
}
}
}
StyledText {
text: I18n.tr("Edit clipboard text")
font.pixelSize: Theme.fontSizeMedium
color: Theme.outlineButton
anchors.left: editScroll.left
anchors.right: editScroll.right
anchors.top: editScroll.top
anchors.bottom: editScroll.bottom
visible: editField.text.length === 0 && !editField.activeFocus
wrapMode: Text.WordWrap
}
}
Row {
id: editorActions
width: parent.width
spacing: Theme.spacingS
Item {
id: buttonSpacer
width: Math.max(0, parent.width - cancelButton.width - saveButton.width - Theme.spacingS)
height: 1
}
DankButton {
id: cancelButton
text: I18n.tr("Cancel")
backgroundColor: Theme.surfaceContainerHigh
textColor: Theme.surfaceText
onClicked: modal.mode = "history"
}
Item {
id: saveButton
readonly property int buttonHeight: cancelButton.buttonHeight
readonly property int arrowWidth: Theme.iconSizeLarge
width: cancelButton.width
height: buttonHeight
Rectangle {
anchors.fill: parent
radius: Theme.cornerRadius
color: Theme.primary
}
Item {
id: saveMainArea
anchors.left: parent.left
anchors.right: saveArrowArea.left
anchors.top: parent.top
anchors.bottom: parent.bottom
}
StyledText {
text: I18n.tr("Save")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.onPrimary
anchors.centerIn: saveMainArea
}
Item {
id: saveArrowArea
width: saveButton.arrowWidth
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
}
Rectangle {
width: 1
height: parent.height - cancelButton.horizontalPadding
color: Theme.withAlpha(Theme.onPrimary, 0.2)
anchors.right: saveArrowArea.left
anchors.verticalCenter: parent.verticalCenter
}
DankIcon {
name: saveMenu.visible ? "expand_less" : "expand_more"
size: Theme.iconSizeSmall
color: Theme.onPrimary
anchors.centerIn: saveArrowArea
}
StateLayer {
z: 1
anchors.fill: saveMainArea
stateColor: Theme.onPrimary
onClicked: root.saveEntry("history")
}
StateLayer {
z: 1
anchors.fill: saveArrowArea
stateColor: Theme.onPrimary
onClicked: root.toggleSaveMenu()
}
}
}
Popup {
id: saveMenu
parent: Overlay.overlay
padding: Theme.spacingM
modal: true
dim: false
focus: true
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
background: StyledRect {
radius: Theme.cornerRadius
color: Theme.surfaceContainer
border.color: Theme.outlineMedium
border.width: 1
}
contentItem: Column {
id: saveMenuColumn
spacing: Theme.spacingXS
StyledRect {
implicitWidth: saveMenuRow.implicitWidth + Theme.spacingS * 2
implicitHeight: saveMenuRow.implicitHeight + Theme.spacingS * 2
radius: Theme.cornerRadius
color: saveMenuSaveArea.containsMouse ? Theme.surfaceVariant : "transparent"
Row {
id: saveMenuRow
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingS
DankIcon {
name: "save"
size: Theme.iconSizeSmall
color: Theme.surfaceText
}
StyledText {
text: I18n.tr("Save")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
}
}
MouseArea {
id: saveMenuSaveArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
saveMenu.close();
root.saveEntry("history");
}
}
}
StyledRect {
implicitWidth: saveMenuCloseRow.implicitWidth + Theme.spacingS * 2
implicitHeight: saveMenuCloseRow.implicitHeight + Theme.spacingS * 2
radius: Theme.cornerRadius
color: saveMenuCloseArea.containsMouse ? Theme.surfaceVariant : "transparent"
Row {
id: saveMenuCloseRow
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingS
DankIcon {
name: "close"
size: Theme.iconSizeSmall
color: Theme.surfaceText
}
StyledText {
text: I18n.tr("Save and close")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
}
}
MouseArea {
id: saveMenuCloseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
saveMenu.close();
root.saveEntry("close");
}
}
}
StyledRect {
implicitWidth: saveMenuPasteRow.implicitWidth + Theme.spacingS * 2
implicitHeight: saveMenuPasteRow.implicitHeight + Theme.spacingS * 2
radius: Theme.cornerRadius
color: saveMenuPasteArea.containsMouse ? Theme.surfaceVariant : "transparent"
opacity: modal.wtypeAvailable ? 1 : 0.5
Row {
id: saveMenuPasteRow
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingS
DankIcon {
name: "content_paste"
size: Theme.iconSizeSmall
color: Theme.surfaceText
}
StyledText {
text: I18n.tr("Save and paste")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
}
}
MouseArea {
id: saveMenuPasteArea
anchors.fill: parent
hoverEnabled: true
enabled: modal.wtypeAvailable
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: {
saveMenu.close();
root.saveEntry("paste");
}
}
}
}
}
}
}
+19 -2
View File
@@ -17,6 +17,7 @@ Rectangle {
signal deleteRequested
signal pinRequested
signal unpinRequested
signal editRequested
readonly property string entryType: modal ? modal.getEntryType(entry) : "text"
readonly property string entryPreview: modal ? modal.getEntryPreview(entry) : ""
@@ -70,6 +71,19 @@ Rectangle {
onClicked: entry.pinned ? unpinRequested() : pinRequested()
}
DankActionButton {
iconName: "edit"
iconSize: Theme.iconSize - 6
iconColor: Theme.surfaceText
onClicked: {
if (entryType === "image") {
return;
}
editRequested();
}
}
DankActionButton {
iconName: "close"
iconSize: Theme.iconSize - 6
@@ -142,8 +156,11 @@ Rectangle {
MouseArea {
id: mouseArea
anchors.fill: parent
anchors.rightMargin: 80
anchors.left: parent.left
anchors.right: actionButtons.left
anchors.rightMargin: Theme.spacingS
anchors.top: parent.top
anchors.bottom: parent.bottom
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onPressed: mouse => {
@@ -6,7 +6,8 @@ import qs.Modals.Clipboard
Item {
id: header
property int totalCount: 0
property int recentsCount: 0
property int savedCount: 0
property bool showKeyboardHints: false
property string activeTab: "recents"
property int pinnedCount: 0
@@ -31,7 +32,7 @@ Item {
}
StyledText {
text: I18n.tr("Clipboard History") + ` (${totalCount})`
text: (header.activeTab === "saved" ? I18n.tr("Clipboard Saved") : I18n.tr("Clipboard History")) + ` (${header.activeTab === "saved" ? header.savedCount : header.recentsCount})`
font.pixelSize: Theme.fontSizeLarge
color: Theme.surfaceText
font.weight: Font.Medium
@@ -48,6 +49,7 @@ Item {
iconName: "push_pin"
iconSize: Theme.iconSize - 4
iconColor: header.activeTab === "saved" ? Theme.primary : Theme.surfaceText
backgroundColor: header.activeTab === "saved" ? Theme.primarySelected : "transparent"
visible: header.pinnedCount > 0
tooltipText: header.activeTab === "saved" ? I18n.tr("Recent") : I18n.tr("Saved")
onClicked: tabChanged(header.activeTab === "saved" ? "recents" : "saved")
@@ -0,0 +1,210 @@
pragma ComponentBehavior: Bound
import QtQuick
import qs.Common
import qs.Services
FocusScope {
id: root
property var clearConfirmDialog: null
property string activeTab: "recents"
property bool showKeyboardHints: false
property int activeImageLoads: 0
readonly property int maxConcurrentLoads: 3
property string mode: "history"
property string searchText: ClipboardService.searchText
readonly property bool clipboardAvailable: ClipboardService.clipboardAvailable
readonly property bool wtypeAvailable: ClipboardService.wtypeAvailable
readonly property int totalCount: ClipboardService.totalCount
readonly property var clipboardEntries: ClipboardService.clipboardEntries
readonly property var pinnedEntries: ClipboardService.pinnedEntries
readonly property int pinnedCount: ClipboardService.pinnedCount
readonly property var unpinnedEntries: ClipboardService.unpinnedEntries
readonly property int selectedIndex: ClipboardService.selectedIndex
readonly property bool keyboardNavigationActive: ClipboardService.keyboardNavigationActive
readonly property var modalFocusScope: root
property alias searchField: historyContent.searchField
property alias editorView: editorView
property alias keyboardController: keyboardController
signal closeRequested
signal instantCloseRequested
onActiveTabChanged: {
ClipboardService.selectedIndex = 0;
ClipboardService.keyboardNavigationActive = false;
}
onSearchTextChanged: ClipboardService.searchText = searchText
function hide() {
closeRequested();
}
function pasteSelected() {
ClipboardService.pasteSelected(() => root.instantCloseRequested());
}
function copyEntry(entry) {
ClipboardService.copyEntry(entry, () => root.closeRequested());
}
function deleteEntry(entry) {
ClipboardService.deleteEntry(entry);
}
function deletePinnedEntry(entry) {
ClipboardService.deletePinnedEntry(entry, clearConfirmDialog);
}
function pinEntry(entry) {
ClipboardService.pinEntry(entry);
}
function unpinEntry(entry) {
ClipboardService.unpinEntry(entry);
}
function clearAll() {
ClipboardService.clearAll();
}
function getEntryPreview(entry) {
return ClipboardService.getEntryPreview(entry);
}
function getEntryType(entry) {
return ClipboardService.getEntryType(entry);
}
function updateFilteredModel() {
ClipboardService.updateFilteredModel();
}
function refreshClipboard() {
ClipboardService.refresh();
}
function editEntry(entry) {
if (!entry || entry.isImage) {
return;
}
editorView.setEntry(entry);
mode = "editor";
}
function resetState() {
activeImageLoads = 0;
mode = "history";
ClipboardService.reset();
keyboardController.reset();
}
focus: true
Keys.onPressed: function (event) {
keyboardController.handleKey(event);
}
ClipboardKeyboardController {
id: keyboardController
modal: root
}
Item {
id: historyView
anchors.fill: parent
opacity: 1
scale: 1
visible: opacity > 0.01
enabled: root.mode === "history"
ClipboardContent {
id: historyContent
anchors.fill: parent
modal: root
clearConfirmDialog: root.clearConfirmDialog
}
}
ClipboardEditor {
id: editorView
anchors.fill: parent
opacity: 0
scale: 0.98
visible: opacity > 0.01
enabled: root.mode === "editor"
focus: root.mode === "editor"
modal: root
keyController: keyboardController
}
states: [
State {
name: "history"
when: root.mode === "history"
PropertyChanges {
target: historyView
opacity: 1
scale: 1
}
PropertyChanges {
target: editorView
opacity: 0
scale: 0.98
}
},
State {
name: "editor"
when: root.mode === "editor"
PropertyChanges {
target: historyView
opacity: 0
scale: 0.98
}
PropertyChanges {
target: editorView
opacity: 1
scale: 1
}
}
]
transitions: [
Transition {
from: "history"
to: "editor"
ParallelAnimation {
NumberAnimation {
property: "opacity"
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
NumberAnimation {
property: "scale"
duration: Theme.shortDuration
easing.type: Theme.emphasizedEasing
}
}
},
Transition {
from: "editor"
to: "history"
ParallelAnimation {
NumberAnimation {
property: "opacity"
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
NumberAnimation {
property: "scale"
duration: Theme.shortDuration
easing.type: Theme.emphasizedEasing
}
}
}
]
}
@@ -17,61 +17,28 @@ DankModal {
active: clipboardHistoryModal.useHyprlandFocusGrab && clipboardHistoryModal.shouldHaveFocus
}
property string activeTab: "recents"
onActiveTabChanged: {
ClipboardService.selectedIndex = 0;
ClipboardService.keyboardNavigationActive = false;
}
property bool showKeyboardHints: false
property Component clipboardContent
property int activeImageLoads: 0
readonly property int maxConcurrentLoads: 3
readonly property bool clipboardAvailable: ClipboardService.clipboardAvailable
readonly property bool wtypeAvailable: ClipboardService.wtypeAvailable
readonly property int totalCount: ClipboardService.totalCount
readonly property var clipboardEntries: ClipboardService.clipboardEntries
readonly property var pinnedEntries: ClipboardService.pinnedEntries
readonly property int pinnedCount: ClipboardService.pinnedCount
readonly property var unpinnedEntries: ClipboardService.unpinnedEntries
readonly property int selectedIndex: ClipboardService.selectedIndex
readonly property bool keyboardNavigationActive: ClipboardService.keyboardNavigationActive
property string searchText: ClipboardService.searchText
onSearchTextChanged: ClipboardService.searchText = searchText
Ref {
service: ClipboardService
}
function updateFilteredModel() {
ClipboardService.updateFilteredModel();
}
function pasteSelected() {
ClipboardService.pasteSelected(instantClose);
}
function toggle() {
if (shouldBeVisible) {
hide();
} else {
show();
return;
}
show();
}
function show() {
open();
activeImageLoads = 0;
shouldHaveFocus = true;
ClipboardService.reset();
keyboardController.reset();
Qt.callLater(function () {
if (clipboardAvailable) {
if (contentLoader.item) {
contentLoader.item.resetState();
}
if (clipboardHistoryModal.clipboardAvailable) {
if (Theme.isConnectedEffect) {
Qt.callLater(() => {
if (clipboardHistoryModal.shouldBeVisible)
if (clipboardHistoryModal.shouldBeVisible) {
ClipboardService.refresh();
}
});
} else {
ClipboardService.refresh();
@@ -89,46 +56,12 @@ DankModal {
}
onDialogClosed: {
activeImageLoads = 0;
ClipboardService.reset();
keyboardController.reset();
if (contentLoader.item) {
contentLoader.item.resetState();
}
}
function refreshClipboard() {
ClipboardService.refresh();
}
function copyEntry(entry) {
ClipboardService.copyEntry(entry, hide);
}
function deleteEntry(entry) {
ClipboardService.deleteEntry(entry);
}
function deletePinnedEntry(entry) {
ClipboardService.deletePinnedEntry(entry, clearConfirmDialog);
}
function pinEntry(entry) {
ClipboardService.pinEntry(entry);
}
function unpinEntry(entry) {
ClipboardService.unpinEntry(entry);
}
function clearAll() {
ClipboardService.clearAll();
}
function getEntryPreview(entry) {
return ClipboardService.getEntryPreview(entry);
}
function getEntryType(entry) {
return ClipboardService.getEntryType(entry);
}
readonly property bool clipboardAvailable: ClipboardService.clipboardAvailable
visible: false
modalWidth: ClipboardConstants.modalWidth
@@ -138,15 +71,11 @@ DankModal {
borderColor: Theme.outlineMedium
borderWidth: 1
enableShadow: true
closeOnEscapeKey: (contentLoader.item?.mode ?? "history") !== "editor"
onBackgroundClicked: hide()
modalFocusScope.Keys.onPressed: function (event) {
keyboardController.handleKey(event);
}
content: clipboardContent
ClipboardKeyboardController {
id: keyboardController
modal: clipboardHistoryModal
Ref {
service: ClipboardService
}
ConfirmModal {
@@ -171,12 +100,11 @@ DankModal {
}
}
property var confirmDialog: clearConfirmDialog
clipboardContent: Component {
ClipboardContent {
modal: clipboardHistoryModal
clearConfirmDialog: clipboardHistoryModal.confirmDialog
content: Component {
ClipboardHistoryContent {
clearConfirmDialog: clearConfirmDialog
onCloseRequested: clipboardHistoryModal.hide()
onInstantCloseRequested: clipboardHistoryModal.instantClose()
}
}
}
@@ -15,47 +15,20 @@ DankPopout {
property var parentWidget: null
property var triggerScreen: null
property string activeTab: "recents"
property bool showKeyboardHints: false
property int activeImageLoads: 0
readonly property int maxConcurrentLoads: 3
readonly property bool clipboardAvailable: ClipboardService.clipboardAvailable
readonly property bool wtypeAvailable: ClipboardService.wtypeAvailable
readonly property int totalCount: ClipboardService.totalCount
readonly property var clipboardEntries: ClipboardService.clipboardEntries
readonly property var pinnedEntries: ClipboardService.pinnedEntries
readonly property int pinnedCount: ClipboardService.pinnedCount
readonly property var unpinnedEntries: ClipboardService.unpinnedEntries
readonly property int selectedIndex: ClipboardService.selectedIndex
readonly property bool keyboardNavigationActive: ClipboardService.keyboardNavigationActive
property string searchText: ClipboardService.searchText
onSearchTextChanged: ClipboardService.searchText = searchText
readonly property var confirmDialog: clearConfirmDialog
readonly property var modalFocusScope: contentLoader.item ?? null
Ref {
service: ClipboardService
}
function updateFilteredModel() {
ClipboardService.updateFilteredModel();
}
function pasteSelected() {
ClipboardService.pasteSelected(instantClose);
}
function instantClose() {
close();
}
function show() {
open();
activeImageLoads = 0;
ClipboardService.reset();
keyboardController.reset();
Qt.callLater(function () {
if (contentLoader.item) {
contentLoader.item.activeTab = activeTab;
contentLoader.item.resetState();
}
if (contentLoader.item?.searchField) {
contentLoader.item.searchField.text = "";
contentLoader.item.searchField.forceActiveFocus();
@@ -65,47 +38,12 @@ DankPopout {
function hide() {
close();
activeImageLoads = 0;
ClipboardService.reset();
keyboardController.reset();
}
function refreshClipboard() {
ClipboardService.refresh();
}
function copyEntry(entry) {
ClipboardService.copyEntry(entry, hide);
}
function deleteEntry(entry) {
ClipboardService.deleteEntry(entry);
}
function deletePinnedEntry(entry) {
ClipboardService.deletePinnedEntry(entry, clearConfirmDialog);
}
function pinEntry(entry) {
ClipboardService.pinEntry(entry);
}
function unpinEntry(entry) {
ClipboardService.unpinEntry(entry);
}
function clearAll() {
ClipboardService.clearAll();
}
function getEntryPreview(entry) {
return ClipboardService.getEntryPreview(entry);
}
function getEntryType(entry) {
return ClipboardService.getEntryType(entry);
}
popupWidth: ClipboardConstants.popoutWidth
popupHeight: ClipboardConstants.popoutHeight
triggerWidth: 55
@@ -117,20 +55,25 @@ DankPopout {
onBackgroundClicked: hide()
onShouldBeVisibleChanged: {
if (!shouldBeVisible)
if (!shouldBeVisible) {
return;
}
if (clipboardAvailable) {
if (Theme.isConnectedEffect) {
Qt.callLater(() => {
if (root.shouldBeVisible)
if (root.shouldBeVisible) {
ClipboardService.refresh();
}
});
} else {
ClipboardService.refresh();
}
}
keyboardController.reset();
Qt.callLater(function () {
if (contentLoader.item) {
contentLoader.item.activeTab = activeTab;
contentLoader.item.resetState();
}
if (contentLoader.item?.searchField) {
contentLoader.item.searchField.text = "";
contentLoader.item.searchField.forceActiveFocus();
@@ -139,14 +82,13 @@ DankPopout {
}
onPopoutClosed: {
activeImageLoads = 0;
ClipboardService.reset();
keyboardController.reset();
if (contentLoader.item) {
contentLoader.item.resetState();
}
}
ClipboardKeyboardController {
id: keyboardController
modal: root
Ref {
service: ClipboardService
}
ConfirmModal {
@@ -155,48 +97,20 @@ DankPopout {
confirmButtonColor: Theme.primary
}
property var confirmDialog: clearConfirmDialog
content: Component {
FocusScope {
id: contentFocusScope
ClipboardHistoryContent {
LayoutMirroring.enabled: I18n.isRtl
LayoutMirroring.childrenInherit: true
focus: true
property alias searchField: clipboardContentItem.searchField
Keys.onPressed: function (event) {
keyboardController.handleKey(event);
}
clearConfirmDialog: clearConfirmDialog
onCloseRequested: root.hide()
onInstantCloseRequested: root.close()
Component.onCompleted: {
if (root.shouldBeVisible)
activeTab = root.activeTab;
if (root.shouldBeVisible) {
forceActiveFocus();
}
Connections {
target: root
function onShouldBeVisibleChanged() {
if (root.shouldBeVisible) {
Qt.callLater(() => contentFocusScope.forceActiveFocus());
}
}
function onOpened() {
Qt.callLater(() => {
if (clipboardContentItem.searchField) {
clipboardContentItem.searchField.forceActiveFocus();
}
});
}
}
ClipboardContent {
id: clipboardContentItem
modal: root
clearConfirmDialog: root.confirmDialog
}
}
}
@@ -66,7 +66,24 @@ QtObject {
}
}
function editSelected() {
const entries = modal.activeTab === "saved" ? ClipboardService.pinnedEntries : ClipboardService.unpinnedEntries;
if (!entries || entries.length === 0) {
return;
}
const index = ClipboardService.selectedIndex >= 0 && ClipboardService.selectedIndex < entries.length ? ClipboardService.selectedIndex : 0;
modal.editEntry(entries[index]);
}
function handleKey(event) {
if (modal.mode === "editor") {
if (event.key === Qt.Key_Escape) {
modal.mode = "history";
event.accepted = true;
}
return;
}
switch (event.key) {
case Qt.Key_Escape:
if (ClipboardService.keyboardNavigationActive) {
@@ -152,6 +169,10 @@ QtObject {
event.accepted = true;
}
return;
case Qt.Key_E:
editSelected();
event.accepted = true;
return;
}
}
@@ -10,7 +10,7 @@ Rectangle {
readonly property string hintsText: {
if (!wtypeAvailable)
return I18n.tr("Ctrl+Tab: Switch Tab • Ctrl+S: Pin/Unpin • Shift+Del: Clear All • Esc: Close");
return enterToPaste ? I18n.tr("Ctrl+Tab: Switch Tab • Ctrl+S: Pin/Unpin • Shift+Enter: Copy • Shift+Del: Clear All • Esc: Close", "Keyboard hints when enter-to-paste is enabled") : I18n.tr("Ctrl+Tab: Switch Tab • Ctrl+S: Pin/Unpin • Shift+Enter: Paste • Shift+Del: Clear All • Esc: Close");
return enterToPaste ? I18n.tr("Ctrl+Tab: Switch Tabs • Ctrl+S: Pin/Unpin • Shift+Enter: Copy • Shift+Del: Clear All • F10: Help • Esc: Close", "Keyboard hints when enter-to-paste is enabled") : I18n.tr("Ctrl+Tab: Switch Tabs • Ctrl+S: Pin/Unpin • Shift+Enter: Paste • Shift+Del: Clear All • F10: Help • Esc: Close");
}
height: ClipboardConstants.keyboardHintsHeight
@@ -22,13 +22,17 @@ Rectangle {
z: 100
Column {
width: parent.width - Theme.spacingL * 2
anchors.centerIn: parent
spacing: 2
StyledText {
text: keyboardHints.enterToPaste ? I18n.tr("↑/↓: Navigate • Enter: Paste • Del: Delete • F10: Help", "Keyboard hints when enter-to-paste is enabled") : I18n.tr("↑/↓: Navigate • Enter/Ctrl+C: Copy • Del: Delete • F10: Help")
text: keyboardHints.enterToPaste ? I18n.tr("↑/↓: Navigate • Enter: Paste • Ctrl+C: Copy • Del: Delete • Ctrl+E: Edit • Ctrl+S: Pin/Unpin • F10: Help", "Keyboard hints when enter-to-paste is enabled") : I18n.tr("↑/↓: Navigate • Enter/Ctrl+C: Copy • Del: Delete • Ctrl+E: Edit • Ctrl+S: Pin/Unpin • F10: Help")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
width: parent.width
wrapMode: Text.WordWrap
horizontalAlignment: Text.AlignHCenter
anchors.horizontalCenter: parent.horizontalCenter
}
@@ -36,6 +40,9 @@ Rectangle {
text: keyboardHints.hintsText
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
width: parent.width
wrapMode: Text.WordWrap
horizontalAlignment: Text.AlignHCenter
anchors.horizontalCenter: parent.horizontalCenter
}
}
@@ -13,6 +13,7 @@ Item {
required property var modal
required property var listView
required property int itemIndex
property bool disposed: false
Image {
id: thumbnailImage
@@ -20,6 +21,13 @@ Item {
property bool isVisible: false
property string cachedImageData: ""
property bool loadQueued: false
property bool activeLoad: false
property bool completed: false
property int loadGeneration: 0
property var activeEntryId: null
property var activeRequest: null
property var currentEntryId: entry && entry.id !== undefined ? entry.id : null
property string currentEntryType: entryType
anchors.fill: parent
source: cachedImageData ? `data:image/png;base64,${cachedImageData}` : ""
@@ -31,29 +39,119 @@ Item {
sourceSize.width: 128
sourceSize.height: 128
onCurrentEntryIdChanged: {
if (thumbnailImage.completed) {
thumbnailImage.resetForEntry();
}
}
onCurrentEntryTypeChanged: {
if (thumbnailImage.completed) {
thumbnailImage.resetForEntry();
}
}
function hasValidEntryId() {
return entry && entry.id !== undefined && entry.id !== null;
}
function releaseActiveLoad() {
if (!thumbnailImage.activeLoad) {
return;
}
thumbnailImage.activeLoad = false;
if (modal && modal.activeImageLoads > 0) {
modal.activeImageLoads--;
}
}
function finishLoad(request) {
thumbnailImage.loadQueued = false;
thumbnailImage.activeEntryId = null;
if (!request || thumbnailImage.activeRequest === request) {
thumbnailImage.activeRequest = null;
}
thumbnailImage.releaseActiveLoad();
}
function cancelLoad() {
if (thumbnailImage.activeRequest) {
thumbnailImage.activeRequest.cancelled = true;
thumbnailImage.activeRequest = null;
}
retryTimer.stop();
visibilityTimer.stop();
thumbnailImage.loadQueued = false;
thumbnailImage.activeEntryId = null;
thumbnailImage.releaseActiveLoad();
}
function resetForEntry() {
thumbnailImage.loadGeneration++;
thumbnailImage.cachedImageData = "";
thumbnailImage.isVisible = false;
thumbnailImage.cancelLoad();
Qt.callLater(function () {
if (thumbnail.disposed) {
return;
}
thumbnailImage.checkVisibility();
});
}
function startLoad() {
if (!modal) {
thumbnailImage.loadQueued = false;
return;
}
modal.activeImageLoads++;
thumbnailImage.activeLoad = true;
thumbnailImage.loadImage();
}
function tryLoadImage() {
if (thumbnailImage.loadQueued || entryType !== "image" || thumbnailImage.cachedImageData) {
if (thumbnail.disposed || thumbnailImage.loadQueued || entryType !== "image" || thumbnailImage.cachedImageData || !thumbnailImage.hasValidEntryId()) {
return;
}
thumbnailImage.loadQueued = true;
if (modal.activeImageLoads < modal.maxConcurrentLoads) {
modal.activeImageLoads++;
thumbnailImage.loadImage();
if (modal && modal.activeImageLoads < modal.maxConcurrentLoads) {
thumbnailImage.startLoad();
} else {
retryTimer.restart();
}
}
function loadImage() {
if (!thumbnailImage.hasValidEntryId()) {
thumbnailImage.finishLoad();
return;
}
const requestedId = entry.id;
const generation = thumbnailImage.loadGeneration;
const request = {
"cancelled": false
};
thumbnailImage.activeEntryId = requestedId;
thumbnailImage.activeRequest = request;
DMSService.sendRequest("clipboard.getEntry", {
"id": entry.id
"id": requestedId
}, function (response) {
thumbnailImage.loadQueued = false;
if (modal.activeImageLoads > 0) {
modal.activeImageLoads--;
if (request.cancelled) {
return;
}
if (thumbnail.disposed || generation !== thumbnailImage.loadGeneration || thumbnailImage.activeRequest !== request || thumbnailImage.activeEntryId !== requestedId) {
return;
}
thumbnailImage.finishLoad(request);
if (!entry || entry.id !== requestedId || entryType !== "image") {
return;
}
if (response.error) {
log.warn("Failed to load image:", entry.id);
log.warn("Failed to load image:", requestedId);
return;
}
if (!response.result) {
ClipboardService.refresh();
return;
}
const data = response.result?.data;
@@ -70,9 +168,8 @@ Item {
if (!thumbnailImage.loadQueued) {
return;
}
if (modal.activeImageLoads < modal.maxConcurrentLoads) {
modal.activeImageLoads++;
thumbnailImage.loadImage();
if (modal && modal.activeImageLoads < modal.maxConcurrentLoads) {
thumbnailImage.startLoad();
} else {
retryTimer.restart();
}
@@ -80,7 +177,8 @@ Item {
}
Component.onCompleted: {
if (entryType !== "image" || listView.height <= 0) {
thumbnailImage.completed = true;
if (entryType !== "image" || listView.height <= 0 || !thumbnailImage.hasValidEntryId()) {
return;
}
@@ -94,6 +192,11 @@ Item {
}
}
Component.onDestruction: {
thumbnail.disposed = true;
thumbnailImage.cancelLoad();
}
Timer {
id: visibilityTimer
interval: 100
@@ -101,7 +204,7 @@ Item {
}
function checkVisibility() {
if (entryType !== "image" || listView.height <= 0 || isVisible) {
if (thumbnail.disposed || entryType !== "image" || listView.height <= 0 || isVisible || !thumbnailImage.hasValidEntryId()) {
return;
}
const itemY = itemIndex * (ClipboardConstants.itemHeight + listView.spacing);
@@ -497,22 +497,12 @@ Item {
}
WlrLayershell.namespace: root.layerNamespace
WlrLayershell.layer: {
if (root.useOverlayLayer)
return WlrLayershell.Overlay;
switch (Quickshell.env("DMS_MODAL_LAYER")) {
case "bottom":
log.error("'bottom' layer is not valid for modals. Defaulting to 'top' layer.");
return WlrLayershell.Top;
case "background":
log.error("'background' layer is not valid for modals. Defaulting to 'top' layer.");
return WlrLayershell.Top;
case "overlay":
return WlrLayershell.Overlay;
default:
return WlrLayershell.Top;
}
}
WlrLayershell.layer: root.useOverlayLayer ? WlrLayer.Overlay : LayerShell.fromEnv("DMS_MODAL_LAYER", WlrLayer.Top, {
"allow": ["top", "overlay"],
"invalidLayer": WlrLayer.Top,
"label": "modals",
"error": true
})
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: {
if (customKeyboardFocus !== null)
@@ -251,22 +251,12 @@ Item {
}
WlrLayershell.namespace: root.layerNamespace
WlrLayershell.layer: {
if (root.useOverlayLayer)
return WlrLayershell.Overlay;
switch (Quickshell.env("DMS_MODAL_LAYER")) {
case "bottom":
log.error("'bottom' layer is not valid for modals. Defaulting to 'top' layer.");
return WlrLayershell.Top;
case "background":
log.error("'background' layer is not valid for modals. Defaulting to 'top' layer.");
return WlrLayershell.Top;
case "overlay":
return WlrLayershell.Overlay;
default:
return WlrLayershell.Top;
}
}
WlrLayershell.layer: root.useOverlayLayer ? WlrLayer.Overlay : LayerShell.fromEnv("DMS_MODAL_LAYER", WlrLayer.Top, {
"allow": ["top", "overlay"],
"invalidLayer": WlrLayer.Top,
"label": "modals",
"error": true
})
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: {
if (customKeyboardFocus !== null)
@@ -57,7 +57,11 @@ Rectangle {
return;
if (response.error)
return;
const result = response.result ?? {};
if (!response.result) {
ClipboardService.refresh();
return;
}
const result = response.result;
const mimeType = (result.mimeType ?? entry?.mimeType ?? "").toString();
const data = (result.data ?? "").toString();
if (data.length === 0 || !resolvedSourceUrl(data, mimeType))
@@ -1721,11 +1721,15 @@ Item {
return "";
var idx = text.toLowerCase().indexOf(lowerQuery);
if (idx === -1)
return text;
return _escapeRichText(text);
var before = text.substring(0, idx);
var match = text.substring(idx, idx + queryLen);
var after = text.substring(idx + queryLen);
return '<span style="color:' + baseColor + '">' + before + '</span><span style="color:' + highlightColor + '; font-weight:600">' + match + '</span><span style="color:' + baseColor + '">' + after + '</span>';
return '<span style="color:' + baseColor + '">' + _escapeRichText(before) + '</span><span style="color:' + highlightColor + '; font-weight:600">' + _escapeRichText(match) + '</span><span style="color:' + baseColor + '">' + _escapeRichText(after) + '</span>';
}
function _escapeRichText(text) {
return String(text).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
}
function getCurrentSectionViewMode() {
@@ -42,20 +42,12 @@ Item {
readonly property real screenHeight: effectiveScreen?.height ?? 1080
readonly property real dpr: effectiveScreen ? CompositorService.getScreenScale(effectiveScreen) : 1
readonly property bool usesOverlayLayer: SettingsData.launcherUseOverlayLayer || triggerUsesOverlayLayer
readonly property var effectiveLauncherLayer: {
switch (Quickshell.env("DMS_MODAL_LAYER")) {
case "bottom":
log.error("'bottom' layer is not valid for modals. Defaulting to 'top' layer.");
return WlrLayershell.Top;
case "background":
log.error("'background' layer is not valid for modals. Defaulting to 'top' layer.");
return WlrLayershell.Top;
case "overlay":
return WlrLayershell.Overlay;
default:
return root.usesOverlayLayer ? WlrLayershell.Overlay : WlrLayershell.Top;
}
}
readonly property var effectiveLauncherLayer: LayerShell.fromEnv("DMS_MODAL_LAYER", root.usesOverlayLayer ? WlrLayer.Overlay : WlrLayer.Top, {
"allow": ["top", "overlay"],
"invalidLayer": WlrLayer.Top,
"label": "modals",
"error": true
})
readonly property int baseWidth: {
switch (SettingsData.dankLauncherV2Size) {
@@ -689,7 +681,7 @@ Item {
WlrLayershell.namespace: "dms:spotlight"
WlrLayershell.layer: root.effectiveLauncherLayer
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: keyboardActive ? (root.useHyprlandFocusGrab ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) : WlrKeyboardFocus.None
WlrLayershell.keyboardFocus: PopoutManager.screenshotActive ? WlrKeyboardFocus.None : (keyboardActive ? (root.useHyprlandFocusGrab ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) : WlrKeyboardFocus.None)
anchors {
left: true
@@ -32,20 +32,12 @@ Item {
readonly property real dpr: effectiveScreen ? CompositorService.getScreenScale(effectiveScreen) : 1
readonly property bool useBackgroundDarken: !SettingsData.frameEnabled && SettingsData.modalDarkenBackground
readonly property bool usesOverlayLayer: useBackgroundDarken || SettingsData.launcherUseOverlayLayer || triggerUsesOverlayLayer
readonly property var effectiveLauncherLayer: {
switch (Quickshell.env("DMS_MODAL_LAYER")) {
case "bottom":
log.error("'bottom' layer is not valid for modals. Defaulting to 'top' layer.");
return WlrLayershell.Top;
case "background":
log.error("'background' layer is not valid for modals. Defaulting to 'top' layer.");
return WlrLayershell.Top;
case "overlay":
return WlrLayershell.Overlay;
default:
return root.usesOverlayLayer ? WlrLayershell.Overlay : WlrLayershell.Top;
}
}
readonly property var effectiveLauncherLayer: LayerShell.fromEnv("DMS_MODAL_LAYER", root.usesOverlayLayer ? WlrLayer.Overlay : WlrLayer.Top, {
"allow": ["top", "overlay"],
"invalidLayer": WlrLayer.Top,
"label": "modals",
"error": true
})
readonly property int _openDuration: 50
readonly property int _closeDuration: 40
@@ -345,7 +337,7 @@ Item {
WlrLayershell.namespace: "dms:spotlight"
WlrLayershell.layer: root.effectiveLauncherLayer
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: keyboardActive ? (root.useHyprlandFocusGrab ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) : WlrKeyboardFocus.None
WlrLayershell.keyboardFocus: PopoutManager.screenshotActive ? WlrKeyboardFocus.None : (keyboardActive ? (root.useHyprlandFocusGrab ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) : WlrKeyboardFocus.None)
anchors {
top: true
@@ -81,20 +81,12 @@ Item {
readonly property color backgroundColor: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
readonly property bool useBackgroundDarken: !SettingsData.frameEnabled && SettingsData.modalDarkenBackground
readonly property bool usesOverlayLayer: useBackgroundDarken || SettingsData.launcherUseOverlayLayer || triggerUsesOverlayLayer
readonly property var effectiveLauncherLayer: {
switch (Quickshell.env("DMS_MODAL_LAYER")) {
case "bottom":
log.error("'bottom' layer is not valid for modals. Defaulting to 'top' layer.");
return WlrLayershell.Top;
case "background":
log.error("'background' layer is not valid for modals. Defaulting to 'top' layer.");
return WlrLayershell.Top;
case "overlay":
return WlrLayershell.Overlay;
default:
return root.usesOverlayLayer ? WlrLayershell.Overlay : WlrLayershell.Top;
}
}
readonly property var effectiveLauncherLayer: LayerShell.fromEnv("DMS_MODAL_LAYER", root.usesOverlayLayer ? WlrLayer.Overlay : WlrLayer.Top, {
"allow": ["top", "overlay"],
"invalidLayer": WlrLayer.Top,
"label": "modals",
"error": true
})
readonly property real cornerRadius: Theme.cornerRadius
readonly property color borderColor: {
if (!SettingsData.dankLauncherV2BorderEnabled)
@@ -381,7 +373,7 @@ Item {
WlrLayershell.namespace: "dms:spotlight"
WlrLayershell.layer: root.effectiveLauncherLayer
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: keyboardActive ? (root.useHyprlandFocusGrab ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) : WlrKeyboardFocus.None
WlrLayershell.keyboardFocus: PopoutManager.screenshotActive ? WlrKeyboardFocus.None : (keyboardActive ? (root.useHyprlandFocusGrab ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) : WlrKeyboardFocus.None)
anchors {
top: true
@@ -446,7 +446,7 @@ Item {
WlrLayershell.namespace: "dms:launcher-context-menu"
WlrLayershell.layer: WlrLayershell.Overlay
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: root.renderActive ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None
WlrLayershell.keyboardFocus: PopoutManager.screenshotActive ? WlrKeyboardFocus.None : (root.renderActive ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None)
anchors {
top: true
+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
}
}
}
}
}
}
@@ -570,5 +570,22 @@ FocusScope {
Qt.callLater(() => item.forceActiveFocus());
}
}
Loader {
id: autoStartLoader
anchors.fill: parent
active: root.currentIndex === 36
visible: active
focus: active
sourceComponent: AutoStartTab {
parentModal: root.parentModal
}
onActiveChanged: {
if (active && item)
Qt.callLater(() => item.forceActiveFocus());
}
}
}
}
@@ -245,6 +245,13 @@ Rectangle {
"icon": "app_registration",
"tabIndex": 19,
"hyprlandNiriOnly": true
},
{
"id": "autostart",
"text": I18n.tr("Autostart Apps"),
"icon": "line_start",
"tabIndex": 36,
"autostartOnly": true
}
]
},
@@ -369,6 +376,8 @@ Rectangle {
return false;
if (item.updaterOnly && !SystemUpdateService.sysupdateAvailable)
return false;
if (item.autostartOnly && !DesktopService.autostartAvailable)
return false;
return true;
}
+441 -9
View File
@@ -20,6 +20,10 @@ FloatingWindow {
readonly property int inputFieldHeight: Theme.fontSizeMedium + Theme.spacingL * 2
readonly property int sectionSpacing: Theme.spacingL
ListModel {
id: extraMatchModel
}
objectName: "windowRuleModal"
title: isEditMode ? I18n.tr("Edit Window Rule") : I18n.tr("Create Window Rule")
minimumSize: Qt.size(500, 600)
@@ -31,6 +35,18 @@ FloatingWindow {
nameInput.text = "";
appIdInput.text = "";
titleInput.text = "";
extraMatchModel.clear();
condFloating.triState = 0;
condActive.triState = 0;
condFocused.triState = 0;
condActiveInColumn.triState = 0;
condCastTarget.triState = 0;
condUrgent.triState = 0;
condAtStartup.triState = 0;
condXwayland.triState = 0;
condFullscreen.triState = 0;
condPinned.triState = 0;
condInitialised.triState = 0;
opacityEnabled.checked = false;
opacitySlider.value = 100;
floatingToggle.checked = false;
@@ -52,6 +68,12 @@ FloatingWindow {
clipToGeometryToggle.checked = false;
tiledStateToggle.checked = false;
drawBorderBgToggle.checked = false;
blurCond.triState = 0;
xrayCond.triState = 0;
noiseEnabled.checked = false;
noiseSlider.value = 5;
saturationEnabled.checked = false;
saturationSlider.value = 100;
minWidthInput.text = "";
maxWidthInput.text = "";
minHeightInput.text = "";
@@ -84,18 +106,39 @@ FloatingWindow {
Qt.callLater(() => nameInput.forceActiveFocus());
}
function showEdit(rule) {
if (!rule) {
show();
return;
}
editingRule = rule;
resetForm();
function triFromBool(v) {
if (v === true)
return 1;
if (v === false)
return 2;
return 0;
}
function populateForm(rule) {
nameInput.text = rule.name || "";
const match = rule.matchCriteria || {};
const matchList = (rule.matches && rule.matches.length > 0) ? rule.matches : [rule.matchCriteria || {}];
const match = matchList[0] || {};
appIdInput.text = match.appId || "";
titleInput.text = match.title || "";
extraMatchModel.clear();
for (let i = 1; i < matchList.length; i++) {
extraMatchModel.append({
"rowAppId": matchList[i].appId || "",
"rowTitle": matchList[i].title || ""
});
}
condFloating.triState = triFromBool(match.isFloating);
condActive.triState = triFromBool(match.isActive);
condFocused.triState = triFromBool(match.isFocused);
condActiveInColumn.triState = triFromBool(match.isActiveInColumn);
condCastTarget.triState = triFromBool(match.isWindowCastTarget);
condUrgent.triState = triFromBool(match.isUrgent);
condAtStartup.triState = triFromBool(match.atStartup);
condXwayland.triState = triFromBool(match.xwayland);
condFullscreen.triState = triFromBool(match.fullscreen);
condPinned.triState = triFromBool(match.pinned);
condInitialised.triState = triFromBool(match.initialised);
const actions = rule.actions || {};
const hasOpacity = actions.opacity !== undefined && actions.opacity !== null;
@@ -131,6 +174,15 @@ FloatingWindow {
drawBorderBgToggle.checked = actions.drawBorderWithBackground || false;
xrayCond.triState = triFromBool(actions.backgroundXray);
blurCond.triState = triFromBool(actions.backgroundBlur);
const hasNoise = actions.backgroundNoise !== undefined && actions.backgroundNoise !== null;
noiseEnabled.checked = hasNoise;
noiseSlider.value = hasNoise ? Math.round(actions.backgroundNoise * 100) : 5;
const hasSaturation = actions.backgroundSaturation !== undefined && actions.backgroundSaturation !== null;
saturationEnabled.checked = hasSaturation;
saturationSlider.value = hasSaturation ? Math.round(actions.backgroundSaturation * 100) : 100;
minWidthInput.text = actions.minWidth !== undefined ? String(actions.minWidth) : "";
maxWidthInput.text = actions.maxWidth !== undefined ? String(actions.maxWidth) : "";
minHeightInput.text = actions.minHeight !== undefined ? String(actions.minHeight) : "";
@@ -150,7 +202,28 @@ FloatingWindow {
moveInput.text = actions.move || "";
monitorInput.text = actions.monitor || "";
hyprWorkspaceInput.text = actions.workspace || "";
}
function showEdit(rule) {
if (!rule) {
show();
return;
}
editingRule = rule;
resetForm();
populateForm(rule);
visible = true;
Qt.callLater(() => nameInput.forceActiveFocus());
}
function showCopy(rule) {
if (!rule) {
show();
return;
}
editingRule = null;
resetForm();
populateForm(rule);
visible = true;
Qt.callLater(() => nameInput.forceActiveFocus());
}
@@ -161,6 +234,13 @@ FloatingWindow {
targetWindow = null;
}
function applyCond(obj, key, triState) {
if (triState === 1)
obj[key] = true;
else if (triState === 2)
obj[key] = false;
}
function submitAndClose() {
const matchCriteria = {};
if (appIdInput.text.trim())
@@ -168,6 +248,38 @@ FloatingWindow {
if (titleInput.text.trim())
matchCriteria.title = titleInput.text.trim();
applyCond(matchCriteria, "isFloating", condFloating.triState);
if (isNiri) {
applyCond(matchCriteria, "isActive", condActive.triState);
applyCond(matchCriteria, "isFocused", condFocused.triState);
applyCond(matchCriteria, "isActiveInColumn", condActiveInColumn.triState);
applyCond(matchCriteria, "isWindowCastTarget", condCastTarget.triState);
applyCond(matchCriteria, "isUrgent", condUrgent.triState);
applyCond(matchCriteria, "atStartup", condAtStartup.triState);
}
if (isHyprland) {
applyCond(matchCriteria, "xwayland", condXwayland.triState);
applyCond(matchCriteria, "fullscreen", condFullscreen.triState);
applyCond(matchCriteria, "pinned", condPinned.triState);
applyCond(matchCriteria, "initialised", condInitialised.triState);
}
const matches = [];
if (Object.keys(matchCriteria).length > 0)
matches.push(matchCriteria);
if (isNiri) {
for (let i = 0; i < extraMatchModel.count; i++) {
const row = extraMatchModel.get(i);
const m = {};
if ((row.rowAppId || "").trim())
m.appId = row.rowAppId.trim();
if ((row.rowTitle || "").trim())
m.title = row.rowTitle.trim();
if (Object.keys(m).length > 0)
matches.push(m);
}
}
const actions = {};
if (opacityEnabled.checked)
@@ -206,6 +318,14 @@ FloatingWindow {
actions.tiledState = true;
if (drawBorderBgToggle.checked && isNiri)
actions.drawBorderWithBackground = true;
if (isNiri) {
applyCond(actions, "backgroundBlur", blurCond.triState);
applyCond(actions, "backgroundXray", xrayCond.triState);
}
if (noiseEnabled.checked && isNiri)
actions.backgroundNoise = noiseSlider.value / 100;
if (saturationEnabled.checked && isNiri)
actions.backgroundSaturation = saturationSlider.value / 100;
const minW = parseInt(minWidthInput.text);
const maxW = parseInt(maxWidthInput.text);
@@ -260,6 +380,8 @@ FloatingWindow {
actions: actions,
enabled: true
};
if (isNiri && extraMatchModel.count > 0)
ruleData.matches = matches;
submitting = true;
@@ -369,6 +491,61 @@ FloatingWindow {
border.width: hasFocus ? 2 : 1
}
// Tri-state toggle: 0 = unset (Inherit/Any), 1 = true, 2 = false
component MatchCond: Rectangle {
id: mc
property string label: ""
property int triState: 0
property string unsetLabel: I18n.tr("Any")
property bool readOnly: false
readonly property var stateText: [mc.unsetLabel, "true", "false"]
readonly property var stateColor: [Theme.surfaceVariantText, Theme.primary, Theme.error]
width: condRow.implicitWidth + Theme.spacingM * 2
height: root.inputFieldHeight
radius: Theme.cornerRadius
color: Theme.surfaceHover
border.width: 1
border.color: mc.triState === 0 ? Theme.outlineStrong : mc.stateColor[mc.triState]
opacity: mc.readOnly ? 0.4 : 1
Row {
id: condRow
anchors.centerIn: parent
spacing: Theme.spacingXS
StyledText {
text: mc.label
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
Rectangle {
width: stateBadge.implicitWidth + Theme.spacingS * 2
height: 18
radius: 9
color: Theme.withAlpha(mc.stateColor[mc.triState], 0.15)
anchors.verticalCenter: parent.verticalCenter
StyledText {
id: stateBadge
anchors.centerIn: parent
text: mc.stateText[mc.triState]
font.pixelSize: Theme.fontSizeSmall - 2
color: mc.stateColor[mc.triState]
}
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
enabled: root.visible && !mc.readOnly
onClicked: mc.triState = (mc.triState + 1) % 3
}
}
FocusScope {
anchors.fill: parent
focus: true
@@ -514,6 +691,176 @@ FloatingWindow {
}
}
StyledText {
width: parent.width
visible: root.isNiri
text: I18n.tr("The rule applies to any window matching one of these.")
font.pixelSize: Theme.fontSizeSmall - 1
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
}
Repeater {
model: extraMatchModel
delegate: Row {
width: parent.width
spacing: Theme.spacingS
InputField {
width: (parent.width - removeMatchBtn.width - Theme.spacingS * 2) / 2
hasFocus: extraAppId.activeFocus
DankTextField {
id: extraAppId
anchors.fill: parent
font.pixelSize: Theme.fontSizeSmall
textColor: Theme.surfaceText
placeholderText: root.isNiri ? I18n.tr("App ID regex") : I18n.tr("Class regex")
backgroundColor: "transparent"
enabled: root.visible
text: rowAppId
onTextEdited: extraMatchModel.setProperty(index, "rowAppId", text)
}
}
InputField {
width: (parent.width - removeMatchBtn.width - Theme.spacingS * 2) / 2
hasFocus: extraTitle.activeFocus
DankTextField {
id: extraTitle
anchors.fill: parent
font.pixelSize: Theme.fontSizeSmall
textColor: Theme.surfaceText
placeholderText: I18n.tr("Title regex (optional)")
backgroundColor: "transparent"
enabled: root.visible
text: rowTitle
onTextEdited: extraMatchModel.setProperty(index, "rowTitle", text)
}
}
DankActionButton {
id: removeMatchBtn
width: root.inputFieldHeight
height: root.inputFieldHeight
circular: false
iconName: "close"
iconSize: 16
iconColor: Theme.surfaceVariantText
tooltipText: I18n.tr("Remove match")
tooltipSide: "left"
onClicked: extraMatchModel.remove(index)
}
}
}
Item {
width: parent.width
height: root.inputFieldHeight
visible: root.isNiri
Row {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingS
DankIcon {
name: "add"
size: 18
color: Theme.primary
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: I18n.tr("Add match")
font.pixelSize: Theme.fontSizeSmall
color: Theme.primary
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: extraMatchModel.append({
"rowAppId": "",
"rowTitle": ""
})
}
}
SectionHeader {
title: I18n.tr("Match Conditions")
}
StyledText {
width: parent.width
text: I18n.tr("Optional state-based conditions applied to the first match.")
font.pixelSize: Theme.fontSizeSmall - 1
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
}
Flow {
width: parent.width
spacing: Theme.spacingS
MatchCond {
id: condFloating
label: I18n.tr("Floating")
}
MatchCond {
id: condActive
label: I18n.tr("Active")
visible: isNiri
}
MatchCond {
id: condFocused
label: I18n.tr("Focused")
visible: isNiri
}
MatchCond {
id: condActiveInColumn
label: I18n.tr("Active in Column")
visible: isNiri
}
MatchCond {
id: condCastTarget
label: I18n.tr("Cast Target")
visible: isNiri
}
MatchCond {
id: condUrgent
label: I18n.tr("Urgent")
visible: isNiri
}
MatchCond {
id: condAtStartup
label: I18n.tr("At Startup")
visible: isNiri
}
MatchCond {
id: condXwayland
label: I18n.tr("XWayland")
visible: isHyprland
}
MatchCond {
id: condFullscreen
label: I18n.tr("Fullscreen")
visible: isHyprland
}
MatchCond {
id: condPinned
label: I18n.tr("Pinned")
visible: isHyprland
}
MatchCond {
id: condInitialised
label: I18n.tr("Initialised")
visible: isHyprland
}
}
SectionHeader {
title: I18n.tr("Window Opening")
}
@@ -682,6 +1029,7 @@ FloatingWindow {
DankSlider {
id: opacitySlider
wheelEnabled: false
width: parent.width - 100
minimum: 10
maximum: 100
@@ -710,7 +1058,7 @@ FloatingWindow {
}
CheckboxRow {
id: drawBorderBgToggle
label: I18n.tr("Border with BG")
label: I18n.tr("Border with Background")
}
}
@@ -777,6 +1125,7 @@ FloatingWindow {
DankSlider {
id: scrollFactorSlider
wheelEnabled: false
width: parent.width - 120
minimum: 10
maximum: 200
@@ -798,6 +1147,7 @@ FloatingWindow {
DankSlider {
id: cornerRadiusSlider
wheelEnabled: false
width: parent.width - 130
minimum: 0
maximum: 24
@@ -807,6 +1157,88 @@ FloatingWindow {
}
}
SectionHeader {
title: I18n.tr("Background Effect")
visible: isNiri
}
StyledText {
width: parent.width
visible: isNiri
text: I18n.tr("Xray blurs only the wallpaper (efficient) and is the default when Blur is on. Set Xray to Off for regular full blur of everything beneath the window (more expensive).")
font.pixelSize: Theme.fontSizeSmall - 1
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
}
Flow {
width: parent.width
spacing: Theme.spacingS
visible: isNiri
MatchCond {
id: blurCond
label: I18n.tr("Blur")
unsetLabel: I18n.tr("Inherit")
onTriStateChanged: {
if (triState === 2)
xrayCond.triState = 0;
}
}
MatchCond {
id: xrayCond
label: I18n.tr("X-Ray")
unsetLabel: I18n.tr("Inherit")
readOnly: blurCond.triState === 2
}
}
Row {
width: parent.width
spacing: Theme.spacingM
visible: isNiri
CheckboxRow {
id: noiseEnabled
label: I18n.tr("Noise")
anchors.verticalCenter: parent.verticalCenter
}
DankSlider {
id: noiseSlider
wheelEnabled: false
width: parent.width - 130
minimum: 0
maximum: 100
value: 5
enabled: noiseEnabled.checked
opacity: enabled ? 1 : 0.4
}
}
Row {
width: parent.width
spacing: Theme.spacingM
visible: isNiri
CheckboxRow {
id: saturationEnabled
label: I18n.tr("Saturation")
anchors.verticalCenter: parent.verticalCenter
}
DankSlider {
id: saturationSlider
wheelEnabled: false
width: parent.width - 130
minimum: 0
maximum: 200
value: 100
enabled: saturationEnabled.checked
opacity: enabled ? 1 : 0.4
}
}
SectionHeader {
title: I18n.tr("Size Constraints")
}
@@ -126,6 +126,14 @@ Variants {
}
}
Connections {
target: SettingsData
function onWallpaperFillModeChanged() {
root._renderSettling = true;
renderSettleTimer.restart();
}
}
Timer {
id: renderSettleTimer
interval: 1000
@@ -15,25 +15,6 @@ Item {
property var pluginDetailInstance: null
property var widgetModel: null
property var collapseCallback: null
property real maxAvailableHeight: 9999
function getDetailHeight(section) {
switch (true) {
case section === "wifi":
case section === "bluetooth":
case section === "builtin_vpn":
case section === "builtin_tailscale":
return Math.min(350, maxAvailableHeight);
case section.startsWith("brightnessSlider_"):
return Math.min(400, maxAvailableHeight);
case section.startsWith("plugin_"):
if (pluginDetailInstance?.ccDetailHeight)
return Math.min(pluginDetailInstance.ccDetailHeight, maxAvailableHeight);
return Math.min(250, maxAvailableHeight);
default:
return Math.min(250, maxAvailableHeight);
}
}
Loader {
id: pluginDetailLoader
@@ -0,0 +1,38 @@
import QtQuick
import qs.Common
import qs.Widgets
Rectangle {
id: root
property var widgetData: null
signal showMountPathChanged(bool show)
width: 260
height: menuColumn.implicitHeight + Theme.spacingS * 2
radius: Theme.cornerRadius
color: Theme.surfaceContainer
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.16)
border.width: 1
MouseArea {
anchors.fill: parent
}
Column {
id: menuColumn
anchors.fill: parent
anchors.margins: Theme.spacingS
spacing: 2
DankToggle {
width: parent.width
text: I18n.tr("Show mount path", "toggle in control center disk usage widget to turn mount path display on or off")
checked: root.widgetData?.showMountPath !== false
onToggled: newChecked => {
root.showMountPathChanged(newChecked);
}
}
}
}
@@ -3,6 +3,7 @@ import qs.Common
import qs.Services
import qs.Modules.ControlCenter.Widgets
import qs.Modules.ControlCenter.Components
import "../utils/detailHeight.js" as DetailHeightUtils
import "../utils/layout.js" as LayoutUtils
Column {
@@ -25,6 +26,7 @@ Column {
signal moveWidget(int fromIndex, int toIndex)
signal toggleWidgetSize(int index)
signal collapseRequested
signal configRequested(int index, var widgetData, var anchor)
function requestCollapse() {
collapseRequested();
@@ -37,6 +39,7 @@ Column {
property real currentRowWidth: 0
property int expandedRowIndex: -1
property var colorPickerModal: null
property var activePluginDetailInstance: null
readonly property real _maxDetailHeight: {
const rows = layoutResult.rows;
@@ -53,6 +56,8 @@ Column {
}
readonly property real targetImplicitHeight: {
if (editMode)
return editModeGrid.implicitHeight;
const rows = layoutResult.rows;
let totalHeight = 0;
for (let i = 0; i < rows.length; i++) {
@@ -70,15 +75,7 @@ Column {
}
function detailHeightForSection(section) {
if (!section)
return 0;
if (section === "wifi" || section === "bluetooth" || section === "builtin_vpn")
return Math.min(350, _maxDetailHeight);
if (section.startsWith("brightnessSlider_"))
return Math.min(400, _maxDetailHeight);
if (section.startsWith("plugin_"))
return Math.min(250, _maxDetailHeight);
return Math.min(250, _maxDetailHeight);
return DetailHeightUtils.detailHeightForSection(section, _maxDetailHeight, activePluginDetailInstance);
}
function calculateRowsAndWidgets() {
@@ -105,8 +102,40 @@ Column {
item.z = 1000;
}
function componentForWidget(widgetData) {
const id = widgetData.id || "";
const widgetWidth = widgetData.width || 50;
if (id.startsWith("builtin_"))
return builtinPluginWidgetComponent;
if (id.startsWith("plugin_"))
return pluginWidgetComponent;
switch (id) {
case "wifi":
case "bluetooth":
case "audioOutput":
case "audioInput":
return compoundPillComponent;
case "volumeSlider":
return audioSliderComponent;
case "brightnessSlider":
return brightnessSliderComponent;
case "inputVolumeSlider":
return inputAudioSliderComponent;
case "battery":
return widgetWidth <= 25 ? smallBatteryComponent : batteryPillComponent;
case "diskUsage":
return widgetWidth <= 25 ? smallDiskUsageComponent : diskUsagePillComponent;
case "colorPicker":
return colorPickerPillComponent;
case "doNotDisturb":
return widgetWidth <= 25 ? smallToggleComponent : dndPillComponent;
default:
return widgetWidth <= 25 ? smallToggleComponent : toggleButtonComponent;
}
}
Repeater {
model: root.layoutResult.rows
model: root.editMode ? [] : root.layoutResult.rows
Column {
width: root.width
@@ -173,36 +202,12 @@ Column {
return id === "volumeSlider" || id === "brightnessSlider" || id === "inputVolumeSlider";
}
widgetComponent: {
const id = modelData.id || "";
if (id.startsWith("builtin_")) {
return builtinPluginWidgetComponent;
} else if (id.startsWith("plugin_")) {
return pluginWidgetComponent;
} else if (id === "wifi" || id === "bluetooth" || id === "audioOutput" || id === "audioInput") {
return compoundPillComponent;
} else if (id === "volumeSlider") {
return audioSliderComponent;
} else if (id === "brightnessSlider") {
return brightnessSliderComponent;
} else if (id === "inputVolumeSlider") {
return inputAudioSliderComponent;
} else if (id === "battery") {
return widgetWidth <= 25 ? smallBatteryComponent : batteryPillComponent;
} else if (id === "diskUsage") {
return widgetWidth <= 25 ? smallDiskUsageComponent : diskUsagePillComponent;
} else if (id === "colorPicker") {
return colorPickerPillComponent;
} else if (id === "doNotDisturb") {
return widgetWidth <= 25 ? smallToggleComponent : dndPillComponent;
} else {
return widgetWidth <= 25 ? smallToggleComponent : toggleButtonComponent;
}
}
widgetComponent: root.componentForWidget(modelData)
onWidgetMoved: (fromIndex, toIndex) => root.moveWidget(fromIndex, toIndex)
onRemoveWidget: index => root.removeWidget(index)
onToggleWidgetSize: index => root.toggleWidgetSize(index)
onConfigRequested: (idx, data, anchor) => root.configRequested(idx, data, anchor)
}
}
}
@@ -210,7 +215,6 @@ Column {
DetailHost {
id: detailHost
width: parent.width
maxAvailableHeight: root._maxDetailHeight
height: active ? (root.detailHeightForSection(root.expandedSection) + Theme.spacingS) : 0
clip: true
property string retainedSection: ""
@@ -247,7 +251,19 @@ Column {
retainedWidgetData = root.expandedWidgetData;
}
onActiveChanged: retainActiveDetail()
function syncActivePluginDetail() {
if (active) {
root.activePluginDetailInstance = pluginDetailInstance;
} else if (root.activePluginDetailInstance === pluginDetailInstance) {
root.activePluginDetailInstance = null;
}
}
onActiveChanged: {
retainActiveDetail();
syncActivePluginDetail();
}
onPluginDetailInstanceChanged: syncActivePluginDetail()
onHeightChanged: {
if (!active && height <= 0.5) {
retainedSection = "";
@@ -277,6 +293,18 @@ Column {
}
}
EditModeGrid {
id: editModeGrid
width: root.width
visible: root.editMode
active: root.editMode
model: root.model
componentProvider: root
onRemoveWidget: index => root.removeWidget(index)
onToggleWidgetSize: index => root.toggleWidgetSize(index)
onConfigRequested: (idx, data, anchor) => root.configRequested(idx, data, anchor)
}
Component {
id: errorPillComponent
ErrorPill {
@@ -869,6 +897,7 @@ Column {
mountPath: widgetData.mountPath || "/"
instanceId: widgetData.instanceId || ""
showMountPath: widgetData.showMountPath !== undefined ? widgetData.showMountPath : true
onExpandClicked: {
if (!root.editMode) {
@@ -888,6 +917,7 @@ Column {
mountPath: widgetData.mountPath || "/"
instanceId: widgetData.instanceId || ""
showMountPath: widgetData.showMountPath !== undefined ? widgetData.showMountPath : true
onClicked: {
if (!root.editMode) {
@@ -21,6 +21,7 @@ Item {
signal widgetMoved(int fromIndex, int toIndex)
signal removeWidget(int index)
signal toggleWidgetSize(int index)
signal configRequested(int index, var widgetData, var anchor)
width: {
const widgetWidth = widgetData?.width || 50;
@@ -236,6 +237,7 @@ Item {
}
Rectangle {
id: removeButton
width: 16
height: 16
radius: 8
@@ -278,6 +280,34 @@ Item {
}
}
readonly property bool hasConfigMenu: widgetData?.id === "diskUsage"
Rectangle {
id: configButton
width: 16
height: 16
radius: 8
color: Theme.primary
anchors.top: removeButton.top
anchors.right: removeButton.left
anchors.rightMargin: 4
visible: editMode && root.hasConfigMenu
z: 10
DankIcon {
anchors.centerIn: parent
name: "settings"
size: 12
color: Theme.primaryText
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.configRequested(root.widgetIndex, root.widgetData, configButton)
}
}
Rectangle {
id: dragHandle
width: 16
@@ -50,7 +50,7 @@ Row {
WlrLayershell.namespace: "dms:control-center-widget-library"
WlrLayershell.layer: WlrLayershell.Overlay
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: visible ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None
WlrLayershell.keyboardFocus: PopoutManager.screenshotActive ? WlrKeyboardFocus.None : (visible ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None)
anchors {
top: true
@@ -0,0 +1,110 @@
import QtQuick
import qs.Common
import qs.Services
import qs.Modules.ControlCenter.Components
import "../utils/layout.js" as LayoutUtils
Item {
id: root
property var model: null
property var componentProvider: null
property bool active: true
signal removeWidget(int index)
signal toggleWidgetSize(int index)
signal configRequested(int index, var widgetData, var anchor)
property var sourceWidgets: SettingsData.controlCenterWidgets || []
property var visualOrder: []
property int draggingSourceIndex: -1
property var dragStartOrder: []
readonly property real rowSpacing: Theme.spacingL
readonly property real sliderCellHeight: 48
readonly property real normalCellHeight: 60
readonly property var slotLayout: LayoutUtils.computeSlots(sourceWidgets, visualOrder, width, Theme.spacingS, rowSpacing, sliderCellHeight, normalCellHeight)
implicitHeight: slotLayout.totalHeight
function rebuildOrder() {
const n = (sourceWidgets || []).length;
const arr = [];
for (var i = 0; i < n; i++)
arr.push(i);
visualOrder = arr;
}
onSourceWidgetsChanged: rebuildOrder()
Component.onCompleted: rebuildOrder()
function beginDrag(sourceIndex) {
draggingSourceIndex = sourceIndex;
dragStartOrder = visualOrder.slice();
}
function sameOrder(a, b) {
if (a.length !== b.length)
return false;
for (var i = 0; i < a.length; i++) {
if (a[i] !== b[i])
return false;
}
return true;
}
function updateDragTarget(centerX, centerY) {
if (draggingSourceIndex < 0)
return;
const p = LayoutUtils.slotContainingPoint(slotLayout.slots, visualOrder, centerX, centerY);
if (p < 0)
return;
const arr = visualOrder.slice();
const d = arr.indexOf(draggingSourceIndex);
if (d < 0 || d === p)
return;
arr.splice(d, 1);
arr.splice(p, 0, draggingSourceIndex);
visualOrder = arr;
}
function endDrag() {
if (draggingSourceIndex < 0)
return;
draggingSourceIndex = -1;
if (!sameOrder(visualOrder, dragStartOrder))
commit();
}
function commit() {
const widgets = sourceWidgets || [];
const arr = visualOrder.map(i => widgets[i]);
if (root.model)
root.model.reorderWidgets(arr);
}
Repeater {
model: root.active ? root.sourceWidgets : []
EditModeWidgetDelegate {
required property int index
required property var modelData
grid: root
sourceIndex: index
widgetData: modelData
isSlider: LayoutUtils.isSliderWidget(modelData.id || "")
widgetComponent: root.componentProvider ? root.componentProvider.componentForWidget(modelData) : null
slotX: root.slotLayout.slots[index] ? root.slotLayout.slots[index].x : 0
slotY: root.slotLayout.slots[index] ? root.slotLayout.slots[index].y : 0
cellW: root.slotLayout.slots[index] ? root.slotLayout.slots[index].w : root.width
cellH: root.slotLayout.slots[index] ? root.slotLayout.slots[index].h : root.normalCellHeight
onRemoveWidget: idx => root.removeWidget(idx)
onToggleWidgetSize: idx => root.toggleWidgetSize(idx)
onConfigRequested: (idx, data, anchor) => root.configRequested(idx, data, anchor)
}
}
}
@@ -0,0 +1,242 @@
import QtQuick
import qs.Common
import qs.Services
import qs.Widgets
Item {
id: root
property var grid: null
property int sourceIndex: -1
property var widgetData: null
property Component widgetComponent: null
property bool isSlider: false
property real slotX: 0
property real slotY: 0
property real cellW: 100
property real cellH: 60
property bool dragging: !!grid && grid.draggingSourceIndex === sourceIndex
signal removeWidget(int index)
signal toggleWidgetSize(int index)
signal configRequested(int index, var widgetData, var anchor)
width: cellW
height: cellH
z: dragging ? 10000 : 1
Binding {
target: root
property: "x"
value: root.slotX
when: !root.dragging
restoreMode: Binding.RestoreNone
}
Binding {
target: root
property: "y"
value: root.slotY
when: !root.dragging
restoreMode: Binding.RestoreNone
}
onXChanged: {
if (dragging && grid)
grid.updateDragTarget(x + width / 2, y + height / 2);
}
onYChanged: {
if (dragging && grid)
grid.updateDragTarget(x + width / 2, y + height / 2);
}
Behavior on x {
enabled: !root.dragging
NumberAnimation {
duration: Theme.expressiveDurations.normal
easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.expressiveEffects
}
}
Behavior on y {
enabled: !root.dragging
NumberAnimation {
duration: Theme.expressiveDurations.normal
easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.expressiveEffects
}
}
Rectangle {
id: dragIndicator
anchors.fill: parent
color: "transparent"
border.color: Theme.primary
border.width: root.dragging ? 2 : 0
radius: Theme.cornerRadius
opacity: root.dragging ? 0.8 : 1.0
z: root.dragging ? 10000 : 1
Behavior on border.width {
NumberAnimation {
duration: 150
}
}
Behavior on opacity {
NumberAnimation {
duration: 150
}
}
}
Loader {
id: widgetLoader
anchors.fill: parent
sourceComponent: root.widgetComponent
property var widgetData: root.widgetData
property int widgetIndex: root.sourceIndex
property int globalWidgetIndex: root.sourceIndex
property int widgetWidth: root.widgetData?.width || 50
MouseArea {
id: editModeBlocker
anchors.fill: parent
enabled: true
acceptedButtons: Qt.AllButtons
onPressed: function (mouse) {
mouse.accepted = true;
}
onWheel: function (wheel) {
wheel.accepted = true;
}
z: 100
}
}
MouseArea {
id: dragArea
anchors.fill: parent
cursorShape: Qt.OpenHandCursor
drag.target: root
drag.axis: Drag.XAndYAxis
drag.smoothed: false
onPressed: function (mouse) {
cursorShape = Qt.ClosedHandCursor;
if (root.grid)
root.grid.beginDrag(root.sourceIndex);
}
onReleased: function (mouse) {
cursorShape = Qt.OpenHandCursor;
if (root.grid)
root.grid.endDrag();
}
}
Rectangle {
id: removeButton
width: 16
height: 16
radius: 8
color: Theme.error
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: -4
z: 10
DankIcon {
anchors.centerIn: parent
name: "close"
size: 12
color: Theme.primaryText
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.removeWidget(root.sourceIndex)
}
}
SizeControls {
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.margins: -6
z: 10
currentSize: root.widgetData?.width || 50
isSlider: root.isSlider
widgetIndex: root.sourceIndex
onSizeChanged: newSize => {
var widgets = SettingsData.controlCenterWidgets.slice();
if (root.sourceIndex >= 0 && root.sourceIndex < widgets.length) {
widgets[root.sourceIndex].width = newSize;
SettingsData.set("controlCenterWidgets", widgets);
}
}
}
readonly property bool hasConfigMenu: widgetData?.id === "diskUsage"
Rectangle {
id: configButton
width: 16
height: 16
radius: 8
color: Theme.primary
anchors.top: removeButton.top
anchors.right: removeButton.left
anchors.rightMargin: 4
visible: root.hasConfigMenu
z: 10
DankIcon {
anchors.centerIn: parent
name: "settings"
size: 12
color: Theme.primaryText
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.configRequested(root.sourceIndex, root.widgetData, configButton)
}
}
Rectangle {
id: dragHandle
width: 16
height: 12
radius: 2
color: Theme.primary
anchors.top: parent.top
anchors.left: parent.left
anchors.margins: 4
z: 15
opacity: root.dragging ? 1.0 : 0.7
DankIcon {
anchors.centerIn: parent
name: "drag_indicator"
size: 10
color: Theme.primaryText
}
Behavior on opacity {
NumberAnimation {
duration: 150
}
}
}
Rectangle {
anchors.fill: parent
color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08)
radius: Theme.cornerRadius
border.color: "transparent"
border.width: 0
z: -1
}
}
@@ -60,7 +60,7 @@ Rectangle {
}
Typography {
text: DgopService.uptime || I18n.tr("Unknown")
text: DgopService.uptime ? I18n.tr("up") + " " + DgopService.uptime.slice(3) : I18n.tr("Unknown")
style: Typography.Style.Caption
color: Theme.surfaceVariantText
}
@@ -0,0 +1,77 @@
import QtQuick
import qs.Common
import qs.Services
Item {
id: root
property int widgetIndex: -1
property real anchorX: 0
property real anchorY: 0
property real anchorWidth: 0
property real anchorHeight: 0
readonly property var widgetData: {
if (widgetIndex < 0)
return null;
const widgets = SettingsData.controlCenterWidgets || [];
return widgets[widgetIndex] || null;
}
visible: widgetIndex >= 0
z: 10000
function open(index, data, anchorItem) {
const pos = anchorItem.mapToItem(root, 0, 0);
anchorX = pos.x;
anchorY = pos.y;
anchorWidth = anchorItem.width;
anchorHeight = anchorItem.height;
widgetIndex = index;
}
function close() {
widgetIndex = -1;
}
function persistShowMountPath(show) {
const widgets = (SettingsData.controlCenterWidgets || []).slice();
if (root.widgetIndex < 0 || root.widgetIndex >= widgets.length)
return;
const updated = Object.assign({}, widgets[root.widgetIndex]);
updated.showMountPath = show;
widgets[root.widgetIndex] = updated;
SettingsData.set("controlCenterWidgets", widgets);
}
MouseArea {
anchors.fill: parent
enabled: root.visible
onClicked: root.close()
}
DiskUsageWidgetConfigMenu {
id: diskMenu
visible: root.visible && root.widgetData?.id === "diskUsage"
widgetData: root.widgetData
x: {
let nx = root.anchorX + root.anchorWidth - width;
const maxX = root.width - width - Theme.spacingS;
const minX = Theme.spacingS;
if (nx < minX)
nx = minX;
if (nx > maxX)
nx = maxX;
return nx;
}
y: {
let ny = root.anchorY - height - Theme.spacingS;
if (ny < Theme.spacingS)
ny = root.anchorY + root.anchorHeight + Theme.spacingS;
return ny;
}
onShowMountPathChanged: show => root.persistShowMountPath(show)
}
}
@@ -273,6 +273,7 @@ DankPopout {
onMoveWidget: (fromIndex, toIndex) => widgetModel.moveWidget(fromIndex, toIndex)
onToggleWidgetSize: index => widgetModel.toggleWidgetSize(index)
onCollapseRequested: root.collapseAll()
onConfigRequested: (idx, data, anchor) => widgetConfigOverlay.open(idx, data, anchor)
}
EditControls {
@@ -303,6 +304,11 @@ DankPopout {
anchors.fill: parent
z: 10000
}
WidgetConfigOverlay {
id: widgetConfigOverlay
anchors.fill: parent
}
}
}
@@ -24,14 +24,13 @@ Rectangle {
}
function setProfile(profile) {
if (typeof PowerProfiles === "undefined") {
ToastService.showError(I18n.tr("power-profiles-daemon not available"));
if (PowerProfileWatcher.applyProfile(profile))
return;
}
PowerProfiles.profile = profile;
if (PowerProfiles.profile !== profile) {
if (!PowerProfileWatcher.available)
ToastService.showError(I18n.tr("power-profiles-daemon not available"));
else
ToastService.showError(I18n.tr("Failed to set power profile"));
}
}
Column {
@@ -193,7 +192,7 @@ Rectangle {
}
DankButtonGroup {
property var profileModel: (typeof PowerProfiles !== "undefined") ? [PowerProfile.PowerSaver, PowerProfile.Balanced].concat(PowerProfiles.hasPerformanceProfile ? [PowerProfile.Performance] : []) : [PowerProfile.PowerSaver, PowerProfile.Balanced, PowerProfile.Performance]
property var profileModel: PowerProfileWatcher.availableProfiles
property int currentProfileIndex: {
if (typeof PowerProfiles === "undefined")
return 1;
@@ -352,6 +352,10 @@ QtObject {
WidgetUtils.moveWidget(fromIndex, toIndex);
}
function reorderWidgets(newOrder) {
WidgetUtils.reorderWidgets(newOrder);
}
function resetToDefault() {
WidgetUtils.resetToDefault();
}
@@ -8,6 +8,7 @@ CompoundPill {
property string mountPath: "/"
property string instanceId: ""
property bool showMountPath: true
iconName: "storage"
@@ -37,6 +38,9 @@ CompoundPill {
if (!selectedMount) {
return I18n.tr("No disk data");
}
if (!showMountPath) {
return I18n.tr("Disk Usage");
}
return selectedMount.mount;
}
@@ -64,7 +64,7 @@ Rectangle {
DankIcon {
name: BatteryService.getBatteryIcon()
size: parent.parent.width * 0.25
size: Theme.iconSizeLarge
color: {
if (BatteryService.isLowBattery && !BatteryService.isCharging) {
return Theme.error;
@@ -76,8 +76,8 @@ Rectangle {
StyledText {
text: BatteryService.batteryAvailable ? `${BatteryService.batteryLevel}%` : ""
font.pixelSize: parent.parent.width * 0.15
font.weight: Font.Medium
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.Bold
color: {
if (BatteryService.isLowBattery && !BatteryService.isCharging) {
return Theme.error;
@@ -11,6 +11,7 @@ Rectangle {
property string mountPath: "/"
property string instanceId: ""
property bool showMountPath: true
property var selectedMount: {
if (!DgopService.diskMounts || DgopService.diskMounts.length === 0)
@@ -67,7 +68,7 @@ Rectangle {
DankIcon {
anchors.verticalCenter: parent.verticalCenter
name: "storage"
size: Theme.iconSizeSmall
size: Theme.iconSizeLarge
color: {
if (root.usagePercent > 90)
return Theme.error;
@@ -82,6 +83,7 @@ Rectangle {
spacing: 0
StyledText {
visible: root.showMountPath
text: root.selectedMount?.mount || root.mountPath
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
@@ -92,7 +94,7 @@ Rectangle {
StyledText {
text: `${root.usagePercent.toFixed(0)}%`
font.pixelSize: Theme.fontSizeSmall
font.pixelSize: root.showMountPath ? Theme.fontSizeSmall : Theme.fontSizeLarge
font.weight: Font.Bold
color: {
if (root.usagePercent > 90)
@@ -0,0 +1,14 @@
function detailHeightForSection(section, maxHeight, pluginInstance) {
if (!section)
return 0;
if (section === "wifi" || section === "bluetooth"
|| section === "builtin_vpn" || section === "builtin_tailscale")
return Math.min(350, maxHeight);
if (section.startsWith("brightnessSlider_"))
return Math.min(400, maxHeight);
if (section.startsWith("plugin_")) {
const h = pluginInstance ? pluginInstance.ccDetailHeight : 0;
return Math.min(h > 0 ? h : 250, maxHeight);
}
return Math.min(250, maxHeight);
}
@@ -1,3 +1,71 @@
function spanWidthFor(baseWidth, widgetWidth, spacing) {
const w = widgetWidth || 50
if (w <= 25)
return (baseWidth - spacing * 3) / 4
if (w <= 50)
return (baseWidth - spacing) / 2
if (w <= 75)
return (baseWidth - spacing * 2) * 0.75
return baseWidth
}
function isSliderWidget(id) {
return id === "volumeSlider" || id === "brightnessSlider" || id === "inputVolumeSlider"
}
function computeSlots(widgets, order, baseWidth, spacing, rowSpacing, sliderHeight, normalHeight) {
const slots = []
let x = 0
let y = 0
let rowRight = 0
let rowMaxH = 0
let countInRow = 0
for (let p = 0; p < order.length; p++) {
const sourceIndex = order[p]
const widget = widgets[sourceIndex]
if (!widget)
continue
const itemW = spanWidthFor(baseWidth, widget.width, spacing)
const itemH = isSliderWidget(widget.id || "") ? sliderHeight : normalHeight
if (countInRow > 0 && (rowRight + spacing + itemW > baseWidth + 0.5)) {
y += rowMaxH + rowSpacing
rowRight = 0
rowMaxH = 0
countInRow = 0
}
x = countInRow === 0 ? 0 : rowRight + spacing
slots[sourceIndex] = {
"x": x,
"y": y,
"w": itemW,
"h": itemH
}
rowRight = x + itemW
rowMaxH = Math.max(rowMaxH, itemH)
countInRow++
}
return {
"slots": slots,
"totalHeight": y + rowMaxH
}
}
function slotContainingPoint(slots, order, px, py) {
for (let p = 0; p < order.length; p++) {
const s = slots[order[p]]
if (!s)
continue
if (px >= s.x && px < s.x + s.w && py >= s.y && py < s.y + s.h)
return p
}
return -1
}
function calculateRowsAndWidgets(controlCenterColumn, expandedSection, expandedWidgetIndex) {
var rows = []
var currentRow = []
@@ -13,6 +13,7 @@ function addWidget(widgetId) {
if (widgetId === "diskUsage") {
widget.instanceId = generateUniqueId()
widget.mountPath = "/"
widget.showMountPath = true
}
if (widgetId === "brightnessSlider") {
+1 -14
View File
@@ -110,20 +110,7 @@ PanelWindow {
readonly property bool usesOverlayLayer: CompositorService.framePeerSurfacesUseOverlayForScreen(barWindow.screen) || (barConfig?.useOverlayLayer ?? false)
readonly property var dBarLayer: {
switch (Quickshell.env("DMS_DANKBAR_LAYER")) {
case "bottom":
return WlrLayer.Bottom;
case "overlay":
return WlrLayer.Overlay;
case "background":
return WlrLayer.Background;
case "top":
return WlrLayer.Top;
default:
return barWindow.usesOverlayLayer ? WlrLayer.Overlay : WlrLayer.Top;
}
}
readonly property var dBarLayer: LayerShell.fromEnv("DMS_DANKBAR_LAYER", barWindow.usesOverlayLayer ? WlrLayer.Overlay : WlrLayer.Top)
property var blurRegion: null
property var _blurWidgetItems: []
@@ -21,14 +21,13 @@ DankPopout {
}
function setProfile(profile) {
if (typeof PowerProfiles === "undefined") {
ToastService.showError(I18n.tr("power-profiles-daemon not available"));
if (PowerProfileWatcher.applyProfile(profile))
return;
}
PowerProfiles.profile = profile;
if (PowerProfiles.profile !== profile) {
if (!PowerProfileWatcher.available)
ToastService.showError(I18n.tr("power-profiles-daemon not available"));
else
ToastService.showError(I18n.tr("Failed to set power profile"));
}
}
popupWidth: 400
@@ -555,7 +554,7 @@ DankPopout {
DankButtonGroup {
id: profileButtonGroup
property var profileModel: (typeof PowerProfiles !== "undefined") ? [PowerProfile.PowerSaver, PowerProfile.Balanced].concat(PowerProfiles.hasPerformanceProfile ? [PowerProfile.Performance] : []) : [PowerProfile.PowerSaver, PowerProfile.Balanced, PowerProfile.Performance]
property var profileModel: PowerProfileWatcher.availableProfiles
property int currentProfileIndex: {
if (typeof PowerProfiles === "undefined")
return 1;
@@ -140,30 +140,24 @@ BasePill {
log.info("Trigger! Delta: " + delta);
// This is after the other delta checks so it only shows on valid Y scroll
if (typeof PowerProfiles === "undefined") {
if (!PowerProfileWatcher.available) {
ToastService.showError(I18n.tr("power-profiles-daemon not available"));
return;
}
// Get list of profiles, and current index
const profiles = [PowerProfile.PowerSaver, PowerProfile.Balanced].concat(PowerProfiles.hasPerformanceProfile ? [PowerProfile.Performance] : []);
const profiles = PowerProfileWatcher.availableProfiles;
var index = profiles.findIndex(profile => PowerProfiles.profile === profile);
// Step once based on mouse wheel direction
if (delta > 0)
index += 1;
else
index -= 1;
// Already at end of list, can't go further
if (index < 0 || index >= profiles.length)
return;
// Set new profile
PowerProfiles.profile = profiles[index];
if (PowerProfiles.profile !== profiles[index]) {
if (!PowerProfileWatcher.applyProfile(profiles[index]))
ToastService.showError(I18n.tr("Failed to set power profile"));
}
}
}
}
@@ -26,6 +26,8 @@ BasePill {
property bool showBatteryIcon: widgetData?.showBatteryIcon !== undefined ? widgetData.showBatteryIcon : SettingsData.controlCenterShowBatteryIcon
property bool showPrinterIcon: widgetData?.showPrinterIcon !== undefined ? widgetData.showPrinterIcon : SettingsData.controlCenterShowPrinterIcon
property bool showScreenSharingIcon: widgetData?.showScreenSharingIcon !== undefined ? widgetData.showScreenSharingIcon : SettingsData.controlCenterShowScreenSharingIcon
property bool showIdleInhibitorIcon: widgetData?.showIdleInhibitorIcon !== undefined ? widgetData.showIdleInhibitorIcon : SettingsData.controlCenterShowIdleInhibitorIcon
property bool showDoNotDisturbIcon: widgetData?.showDoNotDisturbIcon !== undefined ? widgetData.showDoNotDisturbIcon : SettingsData.controlCenterShowDoNotDisturbIcon
property real touchpadThreshold: 100
property real micAccumulator: 0
property real volumeAccumulator: 0
@@ -40,7 +42,7 @@ BasePill {
property var _vBrightness: null
property var _vMic: null
property var _interactionDelegates: []
readonly property var defaultControlCenterGroupOrder: ["network", "vpn", "bluetooth", "audio", "microphone", "brightness", "battery", "printer", "screenSharing"]
readonly property var defaultControlCenterGroupOrder: ["network", "vpn", "bluetooth", "audio", "microphone", "brightness", "battery", "printer", "screenSharing", "idleInhibitor", "doNotDisturb"]
readonly property var effectiveControlCenterGroupOrder: getEffectiveControlCenterGroupOrder()
readonly property var controlCenterRenderModel: getControlCenterRenderModel()
@@ -353,6 +355,10 @@ BasePill {
return root.showBatteryIcon && BatteryService.batteryAvailable;
case "printer":
return root.showPrinterIcon && CupsService.cupsAvailable && root.hasPrintJobs();
case "idleInhibitor":
return root.showIdleInhibitorIcon && SessionService.idleInhibited;
case "doNotDisturb":
return root.showDoNotDisturbIcon && SessionData.doNotDisturb;
default:
return false;
}
@@ -512,6 +518,10 @@ BasePill {
return Theme.getBatteryIcon(BatteryService.batteryLevel, BatteryService.isCharging, BatteryService.batteryAvailable);
case "printer":
return "print";
case "idleInhibitor":
return "motion_sensor_active";
case "doNotDisturb":
return "do_not_disturb_on";
default:
return "settings";
}
@@ -531,6 +541,10 @@ BasePill {
return root.getBatteryIconColor();
case "printer":
return Theme.primary;
case "idleInhibitor":
return Theme.primary;
case "doNotDisturb":
return Theme.primary;
default:
return Theme.widgetIconColor;
}
@@ -689,6 +703,10 @@ BasePill {
return Theme.getBatteryIcon(BatteryService.batteryLevel, BatteryService.isCharging, BatteryService.batteryAvailable);
case "printer":
return "print";
case "idleInhibitor":
return "motion_sensor_active";
case "doNotDisturb":
return "do_not_disturb_on";
default:
return "settings";
}
@@ -708,6 +726,10 @@ BasePill {
return root.getBatteryIconColor();
case "printer":
return Theme.primary;
case "idleInhibitor":
return Theme.primary;
case "doNotDisturb":
return Theme.primary;
default:
return Theme.widgetIconColor;
}
@@ -10,8 +10,10 @@ BasePill {
property var widgetData: null
property string mountPath: (widgetData && widgetData.mountPath !== undefined) ? widgetData.mountPath : "/"
property int diskUsageMode: (widgetData && widgetData.diskUsageMode !== undefined) ? widgetData.diskUsageMode : 0
property bool showMountPath: (widgetData && widgetData.showMountPath !== undefined) ? widgetData.showMountPath : true
property bool isHovered: mouseArea.containsMouse
property bool isAutoHideBar: false
property bool minimumWidth: (widgetData && widgetData.minimumWidth !== undefined) ? widgetData.minimumWidth : true
property var selectedMount: {
if (!DgopService.diskMounts || DgopService.diskMounts.length === 0) {
@@ -69,6 +71,8 @@ BasePill {
}
Connections {
target: SettingsData
function onWidgetDataChanged() {
root.mountPath = Qt.binding(() => {
return (root.widgetData && root.widgetData.mountPath !== undefined) ? root.widgetData.mountPath : "/";
@@ -96,14 +100,12 @@ BasePill {
return DgopService.diskMounts[0] || null;
});
}
target: SettingsData
}
content: Component {
Item {
implicitWidth: root.isVerticalOrientation ? (root.widgetThickness - root.horizontalPadding * 2) : diskContent.implicitWidth
implicitHeight: root.isVerticalOrientation ? diskColumn.implicitHeight : (root.widgetThickness - root.horizontalPadding * 2)
implicitHeight: root.isVerticalOrientation ? diskColumn.implicitHeight : diskContent.implicitHeight
Column {
id: diskColumn
@@ -118,10 +120,12 @@ BasePill {
if (root.diskUsagePercent > 90) {
return Theme.tempDanger;
}
if (root.diskUsagePercent > 75) {
return Theme.tempWarning;
}
return Theme.surfaceText;
return Theme.widgetIconColor;
}
anchors.horizontalCenter: parent.horizontalCenter
}
@@ -154,24 +158,29 @@ BasePill {
id: diskContent
visible: !root.isVerticalOrientation
anchors.centerIn: parent
spacing: 3
spacing: Theme.spacingXS
DankIcon {
id: diskIcon
name: "storage"
size: Theme.barIconSize(root.barThickness, undefined, root.barConfig?.maximizeWidgetIcons, root.barConfig?.iconScale)
color: {
if (root.diskUsagePercent > 90) {
return Theme.tempDanger;
}
if (root.diskUsagePercent > 75) {
return Theme.tempWarning;
}
return Theme.surfaceText;
return Theme.widgetIconColor;
}
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
id: mountText
visible: root.showMountPath
text: {
if (!root.selectedMount) {
return "--";
@@ -182,32 +191,20 @@ BasePill {
color: Theme.widgetTextColor
anchors.verticalCenter: parent.verticalCenter
horizontalAlignment: Text.AlignLeft
verticalAlignment: Text.AlignVCenter
elide: Text.ElideNone
wrapMode: Text.NoWrap
}
StyledText {
text: {
if (root.diskUsagePercent === undefined || root.diskUsagePercent === null || root.diskUsagePercent === 0) {
return "--%";
}
if (!root.selectedMount)
return "--%";
switch (root.diskUsageMode) {
case 1:
return root.selectedMount.size || "--";
case 2:
return root.selectedMount.avail || "--";
case 3:
return (root.selectedMount.avail || "--") + " / " + (root.selectedMount.size || "--");
default:
return root.diskUsagePercent.toFixed(0) + "%";
}
}
font.pixelSize: Theme.barTextSize(root.barThickness, root.barConfig?.fontScale, root.barConfig?.maximizeWidgetText)
color: Theme.widgetTextColor
Item {
id: textBox
anchors.verticalCenter: parent.verticalCenter
horizontalAlignment: Text.AlignLeft
elide: Text.ElideNone
implicitWidth: root.minimumWidth ? Math.max(diskBaseline.width, diskCurrent.width) : diskCurrent.width
implicitHeight: diskText.implicitHeight
width: implicitWidth
height: implicitHeight
StyledTextMetrics {
id: diskBaseline
@@ -225,7 +222,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
}
}
}
}
@@ -11,6 +11,7 @@ BasePill {
property var widgetData: null
property bool compactMode: widgetData?.keyboardLayoutNameCompactMode !== undefined ? widgetData.keyboardLayoutNameCompactMode : SettingsData.keyboardLayoutNameCompactMode
property bool showIcon: widgetData?.keyboardLayoutNameShowIcon !== undefined ? widgetData.keyboardLayoutNameShowIcon : SettingsData.keyboardLayoutNameShowIcon
readonly property var langCodes: ({
"afrikaans": "af",
"albanian": "sq",
@@ -134,6 +135,7 @@ BasePill {
size: Theme.barIconSize(root.barThickness, undefined, root.barConfig?.maximizeWidgetIcons, root.barConfig?.iconScale)
color: Theme.widgetTextColor
anchors.horizontalCenter: parent.horizontalCenter
visible: root.showIcon
}
StyledText {
@@ -156,6 +158,14 @@ BasePill {
anchors.centerIn: parent
spacing: Theme.spacingS
DankIcon {
name: "keyboard"
size: Theme.barIconSize(root.barThickness, undefined, root.barConfig?.maximizeWidgetIcons, root.barConfig?.iconScale)
color: Theme.widgetTextColor
anchors.verticalCenter: parent.verticalCenter
visible: root.showIcon
}
StyledText {
text: {
if (!root.currentLayout)
@@ -981,6 +981,8 @@ BasePill {
WlrLayershell.layer: root.barUsesOverlayLayer ? WlrLayershell.Overlay : WlrLayershell.Top
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: {
if (PopoutManager.screenshotActive)
return WlrKeyboardFocus.None;
if (!root.menuOpen)
return WlrKeyboardFocus.None;
if (CompositorService.useHyprlandFocusGrab)
@@ -1449,6 +1451,8 @@ BasePill {
WlrLayershell.layer: root.barUsesOverlayLayer ? WlrLayershell.Overlay : WlrLayershell.Top
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: {
if (PopoutManager.screenshotActive)
return WlrKeyboardFocus.None;
if (!menuRoot.showMenu)
return WlrKeyboardFocus.None;
if (CompositorService.useHyprlandFocusGrab)
+16 -8
View File
@@ -25,14 +25,14 @@ DankPopout {
property int __dropdownType: 0
property point __dropdownAnchor: Qt.point(0, 0)
property bool __dropdownRightEdge: false
property var __dropdownPlayer: null
property var __dropdownPlayers: []
property var __dropdownPlayer: MprisController.activePlayer
property var __dropdownPlayers: MprisController.availablePlayers
function __showVolumeDropdown(pos, rightEdge, player, players) {
__dropdownAnchor = pos;
__dropdownRightEdge = rightEdge;
__dropdownPlayer = player;
__dropdownPlayers = players;
__dropdownPlayer = Qt.binding(() => MprisController.activePlayer);
__dropdownPlayers = Qt.binding(() => MprisController.availablePlayers);
__dropdownType = 1;
}
@@ -45,8 +45,8 @@ DankPopout {
function __showPlayersDropdown(pos, rightEdge, player, players) {
__dropdownAnchor = pos;
__dropdownRightEdge = rightEdge;
__dropdownPlayer = player;
__dropdownPlayers = players;
__dropdownPlayer = Qt.binding(() => MprisController.activePlayer);
__dropdownPlayers = Qt.binding(() => MprisController.availablePlayers);
__dropdownType = 3;
}
@@ -69,7 +69,7 @@ DankPopout {
id: __volumeCloseTimer
interval: 400
onTriggered: {
if (__dropdownType === 1) {
if (__dropdownType !== 0) {
__hideDropdowns();
}
}
@@ -230,6 +230,13 @@ DankPopout {
return;
}
if (root.currentTabIndex === 1 && mediaLoader.item?.handleKeyEvent) {
if (mediaLoader.item.handleKeyEvent(event)) {
event.accepted = true;
return;
}
}
if (root.currentTabIndex === 2 && wallpaperLoader.item?.handleKeyEvent) {
if (wallpaperLoader.item.handleKeyEvent(event)) {
event.accepted = true;
@@ -394,7 +401,8 @@ DankPopout {
root.__showPlayersDropdown(pos, rightEdge, player, players);
}
onHideDropdowns: root.__hideDropdowns()
onVolumeButtonExited: root.__startCloseTimer()
onDropdownButtonExited: root.__startCloseTimer()
onDropdownButtonEntered: root.__stopCloseTimer()
}
}
}
@@ -42,16 +42,22 @@ Item {
signal panelEntered
signal panelExited
property int __volumeHoverCount: 0
property int __panelHoverCount: 0
function volumeAreaEntered() {
__volumeHoverCount++;
onDropdownTypeChanged: {
if (dropdownType === 0) {
__panelHoverCount = 0;
}
}
function panelAreaEntered() {
__panelHoverCount++;
panelEntered();
}
function volumeAreaExited() {
__volumeHoverCount = Math.max(0, __volumeHoverCount - 1);
if (__volumeHoverCount === 0)
function panelAreaExited() {
__panelHoverCount = Math.max(0, __panelHoverCount - 1);
if (__panelHoverCount === 0)
panelExited();
}
@@ -131,8 +137,8 @@ Item {
anchors.fill: parent
anchors.margins: -12
hoverEnabled: true
onEntered: volumeAreaEntered()
onExited: volumeAreaExited()
onEntered: panelAreaEntered()
onExited: panelAreaExited()
}
Item {
@@ -190,8 +196,8 @@ Item {
cursorShape: Qt.PointingHandCursor
preventStealing: true
onEntered: volumeAreaEntered()
onExited: volumeAreaExited()
onEntered: panelAreaEntered()
onExited: panelAreaExited()
onPressed: mouse => updateVolume(mouse)
onPositionChanged: mouse => {
if (pressed)
@@ -269,6 +275,14 @@ Item {
shadowEnabled: Theme.elevationEnabled && !BlurService.enabled
}
MouseArea {
anchors.fill: parent
anchors.margins: -12
hoverEnabled: true
onEntered: panelAreaEntered()
onExited: panelAreaExited()
}
Column {
anchors.fill: parent
anchors.margins: Theme.spacingM
@@ -349,7 +363,13 @@ Item {
}
StyledText {
text: modelData === AudioService.sink ? "Active" : "Available"
text: {
if (!modelData?.audio)
return modelData === AudioService.sink ? I18n.tr("Active") : I18n.tr("Available");
if (modelData.audio.muted)
return I18n.tr("Muted", "audio status");
return Math.round(modelData.audio.volume * 100) + "%";
}
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
elide: Text.ElideRight
@@ -369,6 +389,8 @@ Item {
root.deviceSelected(modelData);
}
}
onEntered: panelAreaEntered()
onExited: panelAreaExited()
}
}
}
@@ -425,6 +447,14 @@ Item {
shadowEnabled: Theme.elevationEnabled && !BlurService.enabled
}
MouseArea {
anchors.fill: parent
anchors.margins: -12
hoverEnabled: true
onEntered: panelAreaEntered()
onExited: panelAreaExited()
}
Column {
anchors.fill: parent
anchors.margins: Theme.spacingM
@@ -498,15 +528,7 @@ Item {
}
StyledText {
text: {
if (!modelData)
return "";
const artist = modelData.trackArtist || "";
const isActive = modelData === activePlayer;
if (artist.length > 0)
return artist + (isActive ? " (Active)" : "");
return isActive ? "Active" : "Available";
}
text: modelData?.trackArtist || I18n.tr("Unknown Artist")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
elide: Text.ElideRight
@@ -526,6 +548,8 @@ Item {
root.playerSelected(modelData);
}
}
onEntered: panelAreaEntered()
onExited: panelAreaExited()
}
}
}
+171 -44
View File
@@ -13,6 +13,7 @@ Item {
LayoutMirroring.childrenInherit: true
property MprisPlayer activePlayer: MprisController.activePlayer
readonly property real stableLength: MprisController.activePlayerStableLength
property var allPlayers: MprisController.availablePlayers
property var targetScreen: null
property real popoutX: 0
@@ -27,7 +28,8 @@ Item {
signal showAudioDevicesDropdown(point pos, var screen, bool rightEdge)
signal showPlayersDropdown(point pos, var screen, bool rightEdge, var player, var players)
signal hideDropdowns
signal volumeButtonExited
signal dropdownButtonExited
signal dropdownButtonEntered
property bool volumeExpanded: false
property bool devicesExpanded: false
@@ -39,9 +41,7 @@ Item {
playersExpanded = false;
}
DankTooltipV2 {
id: sharedTooltip
}
readonly property bool isRightEdge: {
if (barPosition === SettingsData.Position.Right)
@@ -65,8 +65,7 @@ Item {
// Derived "no players" state: always correct, no timers.
readonly property int _playerCount: allPlayers ? allPlayers.length : 0
readonly property bool _noneAvailable: _playerCount === 0
readonly property bool _trulyIdle: activePlayer && activePlayer.playbackState === MprisPlaybackState.Stopped && !activePlayer.trackTitle && !activePlayer.trackArtist
readonly property bool showNoPlayerNow: (!_switchHold) && (_noneAvailable || _trulyIdle)
readonly property bool showNoPlayerNow: (!_switchHold) && (_noneAvailable || !activePlayer)
property bool _switchHold: false
Timer {
@@ -85,7 +84,6 @@ Item {
isSwitching = true;
_switchHold = true;
_switchHoldTimer.restart();
TrackArtService.loadArtwork(activePlayer.trackArtUrl);
}
function maybeFinishSwitch() {
@@ -96,11 +94,11 @@ Item {
}
readonly property real ratio: {
if (!activePlayer || !activePlayer.length || activePlayer.length <= 0) {
if (!activePlayer || stableLength <= 0) {
return 0;
}
const pos = (activePlayer.position || 0) % Math.max(1, activePlayer.length);
const calculatedRatio = pos / activePlayer.length;
const pos = (activePlayer.position || 0) % Math.max(1, stableLength);
const calculatedRatio = pos / stableLength;
return Math.max(0, Math.min(1, calculatedRatio));
}
@@ -109,13 +107,11 @@ Item {
Connections {
target: activePlayer
ignoreUnknownSignals: true
function onTrackTitleChanged() {
_switchHoldTimer.restart();
maybeFinishSwitch();
}
function onTrackArtUrlChanged() {
TrackArtService.loadArtwork(activePlayer.trackArtUrl);
}
}
Connections {
@@ -186,6 +182,102 @@ Item {
}
}
function triggerVolumeDropdown() {
if (!volumeAvailable)
return;
if (volumeExpanded)
return;
hideDropdowns();
volumeExpanded = true;
const buttonsOnRight = !isRightEdge;
const btnY = volumeButton.y + volumeButton.height / 2;
const screenX = buttonsOnRight ? (popoutX + popoutWidth) : popoutX;
const screenY = popoutY + contentOffsetY + btnY;
showVolumeDropdown(Qt.point(screenX, screenY), targetScreen, buttonsOnRight, activePlayer, allPlayers);
}
function toggleMute() {
if (!volumeAvailable)
return;
SessionData.suppressOSDTemporarily();
if (currentVolume > 0) {
volumeButton.previousVolume = currentVolume;
if (usePlayerVolume) {
activePlayer.volume = 0;
} else if (AudioService.sink?.audio) {
AudioService.sink.audio.volume = 0;
}
} else {
const restoreVolume = volumeButton.previousVolume > 0 ? volumeButton.previousVolume : 0.5;
if (usePlayerVolume) {
activePlayer.volume = restoreVolume;
} else if (AudioService.sink?.audio) {
AudioService.sink.audio.volume = restoreVolume;
}
}
}
function handleKeyEvent(event) {
if (!activePlayer)
return false;
// 1. Number keys 0-9 to seek to 0%-90%
if (event.key >= Qt.Key_0 && event.key <= Qt.Key_9) {
if (activePlayer.canSeek && stableLength > 0) {
const ratio = (event.key - Qt.Key_0) * 0.1;
const targetPosition = ratio * stableLength;
activePlayer.position = Math.max(0.1, Math.min(targetPosition, stableLength * 0.99));
return true;
}
}
// 2. Left / Right arrows to seek backward / forward 5s
if (event.key === Qt.Key_Left) {
if (activePlayer.canSeek) {
activePlayer.position = Math.max(0.1, activePlayer.position - 5);
return true;
}
}
if (event.key === Qt.Key_Right) {
if (activePlayer.canSeek && stableLength > 0) {
activePlayer.position = Math.max(0.1, Math.min(stableLength - 1, activePlayer.position + 5));
return true;
}
}
// 3. Up / Down arrows to adjust volume
if (event.key === Qt.Key_Up) {
adjustVolume(5);
triggerVolumeDropdown();
dropdownButtonExited();
return true;
}
if (event.key === Qt.Key_Down) {
adjustVolume(-5);
triggerVolumeDropdown();
dropdownButtonExited();
return true;
}
// 4. Spacebar to play/pause
if (event.key === Qt.Key_Space) {
if (activePlayer.canTogglePlaying) {
activePlayer.togglePlaying();
return true;
}
}
// 5. M key to toggle mute
if (event.key === Qt.Key_M) {
toggleMute();
triggerVolumeDropdown();
dropdownButtonExited();
return true;
}
return false;
}
property bool isSeeking: false
Timer {
@@ -198,14 +290,14 @@ Item {
Item {
id: bgContainer
anchors.fill: parent
visible: TrackArtService._bgArtSource !== ""
visible: TrackArtService.resolvedArtUrl !== ""
Image {
id: bgImage
anchors.centerIn: parent
width: Math.max(parent.width, parent.height) * 1.1
height: width
source: TrackArtService._bgArtSource
source: TrackArtService.resolvedArtUrl
fillMode: Image.PreserveAspectCrop
asynchronous: true
cache: true
@@ -331,7 +423,7 @@ Item {
}
StyledText {
text: activePlayer?.trackTitle || I18n.tr("Unknown Artist")
text: activePlayer?.trackArtist || I18n.tr("Unknown Artist")
font.pixelSize: Theme.fontSizeMedium
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.8)
width: parent.width
@@ -389,7 +481,7 @@ Item {
if (!activePlayer)
return "0:00";
const rawPos = Math.max(0, activePlayer.position || 0);
const pos = activePlayer.length ? rawPos % Math.max(1, activePlayer.length) : rawPos;
const pos = stableLength ? rawPos % Math.max(1, stableLength) : rawPos;
const minutes = Math.floor(pos / 60);
const seconds = Math.floor(pos % 60);
const timeStr = minutes + ":" + (seconds < 10 ? "0" : "") + seconds;
@@ -403,9 +495,9 @@ Item {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: {
if (!activePlayer || !activePlayer.length)
return "0:00";
const dur = Math.max(0, activePlayer.length || 0);
if (!activePlayer || stableLength <= 0)
return "--:--";
const dur = stableLength;
const minutes = Math.floor(dur / 60);
const seconds = Math.floor(dur % 60);
return minutes + ":" + (seconds < 10 ? "0" : "") + seconds;
@@ -647,7 +739,17 @@ Item {
cursorShape: Qt.PointingHandCursor
onClicked: {
if (playersExpanded) {
hideDropdowns();
if (allPlayers && allPlayers.length > 1) {
let currentIndex = -1;
for (let i = 0; i < allPlayers.length; i++) {
if (allPlayers[i] === activePlayer) {
currentIndex = i;
break;
}
}
const nextIndex = (currentIndex + 1) % allPlayers.length;
MprisController.setActivePlayer(allPlayers[nextIndex]);
}
return;
}
hideDropdowns();
@@ -658,8 +760,22 @@ Item {
const screenY = popoutY + contentOffsetY + btnY;
showPlayersDropdown(Qt.point(screenX, screenY), targetScreen, buttonsOnRight, activePlayer, allPlayers);
}
onEntered: sharedTooltip.show(I18n.tr("Media Players"), playerSelectorButton, 0, 0, isRightEdge ? "right" : "left")
onExited: sharedTooltip.hide()
onEntered: {
dropdownButtonEntered();
if (playersExpanded)
return;
hideDropdowns();
playersExpanded = true;
const buttonsOnRight = !isRightEdge;
const btnY = playerSelectorButton.y + playerSelectorButton.height / 2;
const screenX = buttonsOnRight ? (popoutX + popoutWidth) : popoutX;
const screenY = popoutY + contentOffsetY + btnY;
showPlayersDropdown(Qt.point(screenX, screenY), targetScreen, buttonsOnRight, activePlayer, allPlayers);
}
onExited: {
if (playersExpanded)
dropdownButtonExited();
}
}
}
@@ -691,6 +807,7 @@ Item {
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onEntered: {
dropdownButtonEntered();
if (volumeExpanded)
return;
hideDropdowns();
@@ -703,25 +820,10 @@ Item {
}
onExited: {
if (volumeExpanded)
volumeButtonExited();
dropdownButtonExited();
}
onClicked: {
SessionData.suppressOSDTemporarily();
if (currentVolume > 0) {
volumeButton.previousVolume = currentVolume;
if (usePlayerVolume) {
activePlayer.volume = 0;
} else if (AudioService.sink?.audio) {
AudioService.sink.audio.volume = 0;
}
} else {
const restoreVolume = volumeButton.previousVolume > 0 ? volumeButton.previousVolume : 0.5;
if (usePlayerVolume) {
activePlayer.volume = restoreVolume;
} else if (AudioService.sink?.audio) {
AudioService.sink.audio.volume = restoreVolume;
}
}
toggleMute();
}
onWheel: wheelEvent => {
SessionData.suppressOSDTemporarily();
@@ -754,7 +856,7 @@ Item {
DankIcon {
anchors.centerIn: parent
name: devicesExpanded ? "expand_less" : "speaker"
name: "speaker"
size: 18
color: Theme.surfaceText
}
@@ -766,7 +868,18 @@ Item {
cursorShape: Qt.PointingHandCursor
onClicked: {
if (devicesExpanded) {
hideDropdowns();
const sinks = AudioService.getAvailableSinks();
if (sinks && sinks.length > 1) {
let currentIndex = -1;
for (let i = 0; i < sinks.length; i++) {
if (sinks[i]?.name === AudioService.sink?.name) {
currentIndex = i;
break;
}
}
const nextIndex = (currentIndex + 1) % sinks.length;
AudioService.setSink(sinks[nextIndex]);
}
return;
}
hideDropdowns();
@@ -777,8 +890,22 @@ Item {
const screenY = popoutY + contentOffsetY + btnY;
showAudioDevicesDropdown(Qt.point(screenX, screenY), targetScreen, buttonsOnRight);
}
onEntered: sharedTooltip.show(I18n.tr("Output Device"), audioDevicesButton, 0, 0, isRightEdge ? "right" : "left")
onExited: sharedTooltip.hide()
onEntered: {
dropdownButtonEntered();
if (devicesExpanded)
return;
hideDropdowns();
devicesExpanded = true;
const buttonsOnRight = !isRightEdge;
const btnY = audioDevicesButton.y + audioDevicesButton.height / 2;
const screenX = buttonsOnRight ? (popoutX + popoutWidth) : popoutX;
const screenY = popoutY + contentOffsetY + btnY;
showAudioDevicesDropdown(Qt.point(screenX, screenY), targetScreen, buttonsOnRight);
}
onExited: {
if (devicesExpanded)
dropdownButtonExited();
}
}
}
}
@@ -15,10 +15,11 @@ Card {
property real displayPosition: currentPosition
readonly property real ratio: {
if (!activePlayer || activePlayer.length <= 0)
const len = MprisController.activePlayerStableLength;
if (!activePlayer || !activePlayer.lengthSupported || len <= 0)
return 0;
const pos = displayPosition % Math.max(1, activePlayer.length);
const calculatedRatio = pos / activePlayer.length;
const pos = displayPosition % Math.max(1, len);
const calculatedRatio = pos / len;
return Math.max(0, Math.min(1, calculatedRatio));
}
@@ -99,7 +99,9 @@ Card {
}
StyledText {
text: DgopService.shortUptime || I18n.tr("up")
text: DgopService.shortUptime
? I18n.tr("up") + DgopService.shortUptime.slice(2)
: I18n.tr("up")
font.pixelSize: Theme.fontSizeSmall
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
anchors.verticalCenter: parent.verticalCenter
+20 -1
View File
@@ -18,6 +18,7 @@ Singleton {
readonly property bool rememberLastUser: GreetdEnv.readBoolOverride(Quickshell.env, ["DMS_GREET_REMEMBER_LAST_USER", "DMS_SAVE_USERNAME"], true)
property string lastSessionId: ""
property string lastSessionExec: ""
property string lastSuccessfulUser: ""
property bool memoryReady: false
property bool isLightMode: false
@@ -54,6 +55,7 @@ Singleton {
return;
const memory = JSON.parse(content);
lastSessionId = rememberLastSession ? (memory.lastSessionId || "") : "";
lastSessionExec = rememberLastSession ? (memory.lastSessionExec || "") : "";
lastSuccessfulUser = rememberLastUser ? (memory.lastSuccessfulUser || "") : "";
if (!rememberLastSession || !rememberLastUser)
saveMemory();
@@ -66,6 +68,8 @@ Singleton {
let memory = {};
if (rememberLastSession && lastSessionId)
memory.lastSessionId = lastSessionId;
if (rememberLastSession && lastSessionExec)
memory.lastSessionExec = lastSessionExec;
if (rememberLastUser && lastSuccessfulUser)
memory.lastSuccessfulUser = lastSuccessfulUser;
memoryFileView.setText(JSON.stringify(memory, null, 2));
@@ -73,13 +77,28 @@ Singleton {
function setLastSessionId(id) {
if (!rememberLastSession) {
if (lastSessionId !== "") {
if (lastSessionId !== "" || lastSessionExec !== "") {
lastSessionId = "";
lastSessionExec = "";
saveMemory();
}
return;
}
lastSessionId = id || "";
if (!lastSessionId)
lastSessionExec = "";
saveMemory();
}
function setLastSessionExec(exec) {
if (!rememberLastSession) {
if (lastSessionExec !== "") {
lastSessionExec = "";
saveMemory();
}
return;
}
lastSessionExec = exec || "";
saveMemory();
}
+19 -7
View File
@@ -12,16 +12,24 @@ Singleton {
id: root
readonly property var log: Log.scoped("GreetdSettings")
readonly property string configPath: {
const greetCfgDir = Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter";
return greetCfgDir + "/settings.json";
readonly property string _greeterCacheDir: Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter"
property string configBaseDir: root._greeterCacheDir
readonly property string configPath: root.configBaseDir ? (root.configBaseDir + "/settings.json") : ""
readonly property string greeterWallpaperOverridePath: root.configBaseDir ? (root.configBaseDir + "/greeter_wallpaper_override.jpg") : ""
function setConfigBaseDir(dir) {
const next = dir || root._greeterCacheDir;
if (configBaseDir === next)
return;
configBaseDir = next;
settingsLoaded = false;
settingsFile.reload();
}
readonly property string _greeterCacheDir: {
const i = root.configPath.lastIndexOf("/");
return i >= 0 ? root.configPath.substring(0, i) : "";
function resetConfigBaseDir() {
setConfigBaseDir(root._greeterCacheDir);
}
readonly property string greeterWallpaperOverridePath: root._greeterCacheDir ? (root._greeterCacheDir + "/greeter_wallpaper_override.jpg") : ""
property string currentThemeName: "purple"
property bool settingsLoaded: false
@@ -59,6 +67,7 @@ Singleton {
property bool lockScreenShowProfileImage: true
property bool rememberLastSession: true
property bool rememberLastUser: true
property bool greeterAutoLogin: false
property bool greeterEnableFprint: false
property bool greeterEnableU2f: false
property string greeterWallpaperPath: ""
@@ -124,6 +133,9 @@ Singleton {
} else {
rememberLastUser = settings.greeterRememberLastUser !== undefined ? settings.greeterRememberLastUser : settings.rememberLastUser !== undefined ? settings.rememberLastUser : true;
}
if (configBaseDir === root._greeterCacheDir) {
greeterAutoLogin = settings.greeterAutoLogin !== undefined ? settings.greeterAutoLogin : false;
}
greeterEnableFprint = settings.greeterEnableFprint !== undefined ? settings.greeterEnableFprint : false;
greeterEnableU2f = settings.greeterEnableU2f !== undefined ? settings.greeterEnableU2f : false;
greeterWallpaperPath = settings.greeterWallpaperPath !== undefined ? settings.greeterWallpaperPath : "";
+521 -197
View File
@@ -57,11 +57,20 @@ Item {
property int maxPasswordSessionTransitionRetries: 2
property bool fprintdProbeComplete: false
property bool fprintdHasDevice: false
property bool autoLoginOnSuccess: false
// Falls back to PAM-only detection until the fprintd D-Bus probe completes.
readonly property bool greeterPamHasFprint: greeterPamStackHasModule("pam_fprintd") && (!fprintdProbeComplete || fprintdHasDevice)
readonly property bool greeterPamHasU2f: greeterPamStackHasModule("pam_u2f")
readonly property bool greeterExternalAuthAvailable: (greeterPamHasFprint && GreetdSettings.greeterEnableFprint) || (greeterPamHasU2f && GreetdSettings.greeterEnableU2f)
readonly property bool greeterPamHasExternalAuth: greeterPamHasFprint || greeterPamHasU2f
readonly property bool multipleUsersAvailable: GreeterUsersService.loaded && GreeterUsersService.users.length > 1
readonly property bool showUserPicker: multipleUsersAvailable && !GreeterState.showPasswordInput && !manualUsernameEntry
readonly property bool showAccountSwitchLink: multipleUsersAvailable && !GreeterState.showPasswordInput && !GreeterState.unlocking
readonly property int userPickerMaxHeight: Math.min(400, Math.max(120, height * 0.35))
property bool userListOpen: false
property bool manualUsernameEntry: false
property bool skipAutoSelectUser: false
property string pickerThemeUsername: ""
function initWeatherService() {
if (weatherInitialized)
@@ -428,20 +437,87 @@ Item {
fprintdDeviceProbe.running = true;
}
function applyPickerPreviewTheme() {
let previewUser = (pickerThemeUsername || "").trim();
if (!previewUser && GreetdSettings.rememberLastUser)
previewUser = (GreetdMemory.lastSuccessfulUser || "").trim();
if (previewUser)
GreeterUserTheme.applyForUser(previewUser);
else
GreeterUserTheme.applyDefault();
}
function applyLastSuccessfulUser() {
if (root.skipAutoSelectUser)
return;
if (!GreetdSettings.settingsLoaded || !GreetdSettings.rememberLastUser)
return;
const lastUser = GreetdMemory.lastSuccessfulUser;
if (lastUser && !GreeterState.showPasswordInput && !GreeterState.username) {
GreeterState.username = lastUser;
GreeterState.usernameInput = lastUser;
GreeterState.showPasswordInput = true;
PortalService.getGreeterUserProfileImage(lastUser);
maybeAutoStartExternalAuth();
selectUser(lastUser, true);
}
}
function submitUsername(rawValue) {
function enterManualUsernameEntry() {
if (!root.multipleUsersAvailable || GreeterState.showPasswordInput)
return;
root.manualUsernameEntry = true;
root.userListOpen = false;
GreeterState.username = "";
GreeterState.usernameInput = "";
GreeterState.selectedUserIndex = -1;
inputField.text = "";
root.applyPickerPreviewTheme();
Qt.callLater(() => inputField.forceActiveFocus());
}
function returnToUserListFromManualEntry() {
if (!root.multipleUsersAvailable)
return;
root.manualUsernameEntry = false;
root.userListOpen = true;
GreeterState.username = "";
GreeterState.usernameInput = "";
inputField.text = "";
root.applyPickerPreviewTheme();
}
function returnToUserPicker() {
if (!root.multipleUsersAvailable || GreeterState.unlocking)
return;
root.manualUsernameEntry = false;
root.skipAutoSelectUser = true;
awaitingExternalAuth = false;
pendingPasswordResponse = false;
passwordSubmitRequested = false;
resetPasswordSessionTransition(true);
authTimeout.interval = defaultAuthTimeoutMs;
authTimeout.stop();
clearAuthFeedback();
passwordFailureCount = 0;
externalAuthAutoStartedForUser = "";
if (Greetd.state !== GreetdState.Inactive)
Greetd.cancelSession();
const previousUser = GreeterState.username;
GreeterState.reset();
inputField.text = "";
PortalService.profileImage = "";
if (previousUser)
root.pickerThemeUsername = previousUser;
root.applyPickerPreviewTheme();
root.userListOpen = true;
}
function selectUser(rawValue, skipDropdownUpdate) {
const user = (rawValue || "").trim();
if (!user)
return;
root.manualUsernameEntry = false;
root.skipAutoSelectUser = false;
submitUsername(user, skipDropdownUpdate === true);
}
function submitUsername(rawValue, skipDropdownUpdate) {
const user = (rawValue || "").trim();
if (!user)
return;
@@ -449,9 +525,17 @@ Item {
passwordFailureCount = 0;
clearAuthFeedback();
externalAuthAutoStartedForUser = "";
root.autoLoginOnSuccess = false;
}
root.pickerThemeUsername = user;
GreeterState.username = user;
GreeterState.usernameInput = user;
GreeterState.showPasswordInput = true;
if (!skipDropdownUpdate && typeof GreeterUsersService !== "undefined") {
const idx = GreeterUsersService.usernames.indexOf(user);
GreeterState.selectedUserIndex = idx;
}
root.userListOpen = false;
PortalService.getGreeterUserProfileImage(user);
GreeterState.passwordBuffer = "";
pendingPasswordResponse = false;
@@ -564,6 +648,12 @@ Item {
}
}
Process {
id: greeterAutoLoginPendingProcess
command: ["sh", "-c", "mkdir -p $(dirname " + JSON.stringify((Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter") + "/.local/state/auto-login-sync-pending") + ") && touch " + JSON.stringify((Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter") + "/.local/state/auto-login-sync-pending")]
running: false
}
Process {
id: hyprlandLayoutProcess
running: false
@@ -637,13 +727,44 @@ Item {
}
}
Connections {
target: GreeterUsersService
function onLoadedChanged() {
if (GreeterUsersService.loaded && isPrimaryScreen)
applyPickerPreviewTheme();
}
function onSyncedThemePathsChanged() {
if (!isPrimaryScreen)
return;
if (GreeterState.username)
GreeterUserTheme.applyForUser(GreeterState.username);
else if (root.showUserPicker || root.userListOpen)
applyPickerPreviewTheme();
}
}
Connections {
target: GreeterState
function onUsernameChanged() {
if (GreeterState.username) {
root.pickerThemeUsername = GreeterState.username;
GreeterUserTheme.applyForUser(GreeterState.username);
PortalService.getGreeterUserProfileImage(GreeterState.username);
} else if (root.showUserPicker || root.userListOpen) {
applyPickerPreviewTheme();
}
}
function onShowPasswordInputChanged() {
if (GreeterState.showPasswordInput)
root.userListOpen = false;
}
}
onShowUserPickerChanged: {
if (showUserPicker && !GreeterState.username)
applyPickerPreviewTheme();
if (!showUserPicker)
userListOpen = false;
}
FileView {
@@ -736,177 +857,245 @@ Item {
anchors.fill: parent
color: "transparent"
Item {
id: clockContainer
Column {
id: greeterMainColumn
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.verticalCenter
anchors.bottomMargin: 60
width: parent.width
height: clockText.implicitHeight
Row {
id: clockText
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
spacing: 0
property string fullTimeStr: {
const format = GreetdSettings.getEffectiveTimeFormat();
return systemClock.date.toLocaleTimeString(I18n.locale(), format);
}
property var timeParts: fullTimeStr.split(':')
property string hours: timeParts[0] || ""
property string minutes: timeParts[1] || ""
property string secondsWithAmPm: timeParts.length > 2 ? timeParts[2] : ""
property string seconds: secondsWithAmPm.replace(/\s*(AM|PM|am|pm)$/i, '')
property string ampm: {
const match = fullTimeStr.match(/\s*(AM|PM|am|pm)$/i);
return match ? match[0].trim() : "";
}
property bool hasSeconds: timeParts.length > 2
StyledText {
width: 75
text: clockText.hours.length > 1 ? clockText.hours[0] : ""
font.pixelSize: 120
font.weight: Font.Light
color: "white"
horizontalAlignment: Text.AlignHCenter
}
StyledText {
width: 75
text: clockText.hours.length > 1 ? clockText.hours[1] : clockText.hours.length > 0 ? clockText.hours[0] : ""
font.pixelSize: 120
font.weight: Font.Light
color: "white"
horizontalAlignment: Text.AlignHCenter
}
StyledText {
text: ":"
font.pixelSize: 120
font.weight: Font.Light
color: "white"
}
StyledText {
width: 75
text: clockText.minutes.length > 0 ? clockText.minutes[0] : ""
font.pixelSize: 120
font.weight: Font.Light
color: "white"
horizontalAlignment: Text.AlignHCenter
}
StyledText {
width: 75
text: clockText.minutes.length > 1 ? clockText.minutes[1] : ""
font.pixelSize: 120
font.weight: Font.Light
color: "white"
horizontalAlignment: Text.AlignHCenter
}
StyledText {
text: clockText.hasSeconds ? ":" : ""
font.pixelSize: 120
font.weight: Font.Light
color: "white"
visible: clockText.hasSeconds
}
StyledText {
width: 75
text: clockText.hasSeconds && clockText.seconds.length > 0 ? clockText.seconds[0] : ""
font.pixelSize: 120
font.weight: Font.Light
color: "white"
horizontalAlignment: Text.AlignHCenter
visible: clockText.hasSeconds
}
StyledText {
width: 75
text: clockText.hasSeconds && clockText.seconds.length > 1 ? clockText.seconds[1] : ""
font.pixelSize: 120
font.weight: Font.Light
color: "white"
horizontalAlignment: Text.AlignHCenter
visible: clockText.hasSeconds
}
StyledText {
width: 20
text: " "
font.pixelSize: 120
font.weight: Font.Light
color: "white"
visible: clockText.ampm !== ""
}
StyledText {
text: clockText.ampm
font.pixelSize: 120
font.weight: Font.Light
color: "white"
visible: clockText.ampm !== ""
}
}
}
StyledText {
id: dateText
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: clockContainer.bottom
anchors.topMargin: 4
text: {
return systemClock.date.toLocaleDateString(I18n.locale(), GreetdSettings.getEffectiveLockDateFormat());
}
font.pixelSize: Theme.fontSizeXLarge
color: "white"
opacity: 0.9
}
Item {
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: dateText.bottom
anchors.topMargin: Theme.spacingL
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingM
width: 380
height: 140
Item {
id: clockContainer
width: parent.width
height: clockText.implicitHeight
Row {
id: clockText
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
spacing: 0
property string fullTimeStr: {
const format = GreetdSettings.getEffectiveTimeFormat();
return systemClock.date.toLocaleTimeString(I18n.locale(), format);
}
property var timeParts: fullTimeStr.split(':')
property string hours: timeParts[0] || ""
property string minutes: timeParts[1] || ""
property string secondsWithAmPm: timeParts.length > 2 ? timeParts[2] : ""
property string seconds: secondsWithAmPm.replace(/\s*(AM|PM|am|pm)$/i, '')
property string ampm: {
const match = fullTimeStr.match(/\s*(AM|PM|am|pm)$/i);
return match ? match[0].trim() : "";
}
property bool hasSeconds: timeParts.length > 2
StyledText {
width: 75
text: clockText.hours.length > 1 ? clockText.hours[0] : ""
font.pixelSize: 120
font.weight: Font.Light
color: "white"
horizontalAlignment: Text.AlignHCenter
}
StyledText {
width: 75
text: clockText.hours.length > 1 ? clockText.hours[1] : clockText.hours.length > 0 ? clockText.hours[0] : ""
font.pixelSize: 120
font.weight: Font.Light
color: "white"
horizontalAlignment: Text.AlignHCenter
}
StyledText {
text: ":"
font.pixelSize: 120
font.weight: Font.Light
color: "white"
}
StyledText {
width: 75
text: clockText.minutes.length > 0 ? clockText.minutes[0] : ""
font.pixelSize: 120
font.weight: Font.Light
color: "white"
horizontalAlignment: Text.AlignHCenter
}
StyledText {
width: 75
text: clockText.minutes.length > 1 ? clockText.minutes[1] : ""
font.pixelSize: 120
font.weight: Font.Light
color: "white"
horizontalAlignment: Text.AlignHCenter
}
StyledText {
text: clockText.hasSeconds ? ":" : ""
font.pixelSize: 120
font.weight: Font.Light
color: "white"
visible: clockText.hasSeconds
}
StyledText {
width: 75
text: clockText.hasSeconds && clockText.seconds.length > 0 ? clockText.seconds[0] : ""
font.pixelSize: 120
font.weight: Font.Light
color: "white"
horizontalAlignment: Text.AlignHCenter
visible: clockText.hasSeconds
}
StyledText {
width: 75
text: clockText.hasSeconds && clockText.seconds.length > 1 ? clockText.seconds[1] : ""
font.pixelSize: 120
font.weight: Font.Light
color: "white"
horizontalAlignment: Text.AlignHCenter
visible: clockText.hasSeconds
}
StyledText {
width: 20
text: " "
font.pixelSize: 120
font.weight: Font.Light
color: "white"
visible: clockText.ampm !== ""
}
StyledText {
text: clockText.ampm
font.pixelSize: 120
font.weight: Font.Light
color: "white"
visible: clockText.ampm !== ""
}
}
}
StyledText {
id: dateText
anchors.horizontalCenter: parent.horizontalCenter
text: systemClock.date.toLocaleDateString(I18n.locale(), GreetdSettings.getEffectiveLockDateFormat())
font.pixelSize: Theme.fontSizeXLarge
color: "white"
opacity: 0.9
}
StyledText {
id: userPickerHint
anchors.horizontalCenter: parent.horizontalCenter
visible: root.showUserPicker && !GreeterState.showPasswordInput && !GreeterState.username && !root.userListOpen
text: I18n.tr("Select user...", "greeter user picker placeholder")
font.pixelSize: Theme.fontSizeMedium
color: "white"
opacity: 0.85
}
ColumnLayout {
anchors.fill: parent
id: authColumn
width: parent.width
spacing: Theme.spacingM
RowLayout {
spacing: Theme.spacingL
Layout.fillWidth: true
DankCircularImage {
Item {
Layout.preferredWidth: 60
Layout.preferredHeight: 60
imageSource: {
if (PortalService.profileImage === "")
return "";
if (PortalService.profileImage.startsWith("/"))
return encodeFileUrl(PortalService.profileImage);
return PortalService.profileImage;
visible: GreetdSettings.lockScreenShowProfileImage || root.multipleUsersAvailable
DankCircularImage {
anchors.fill: parent
imageSource: {
const displayUser = GreeterState.username || root.pickerThemeUsername;
if (displayUser) {
const cachedPath = GreeterUsersService.profileImagePath(displayUser);
if (cachedPath)
return encodeFileUrl(cachedPath);
}
if (PortalService.profileImage === "")
return "";
if (PortalService.profileImage.startsWith("/"))
return encodeFileUrl(PortalService.profileImage);
return PortalService.profileImage;
}
fallbackIcon: "person"
}
Rectangle {
anchors.fill: parent
radius: width / 2
color: "transparent"
border.color: Theme.primary
border.width: avatarPickerArea.containsMouse || root.userListOpen ? 2 : 0
visible: root.multipleUsersAvailable
Behavior on border.width {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
MouseArea {
id: avatarPickerArea
anchors.fill: parent
visible: root.multipleUsersAvailable
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (GreeterState.showPasswordInput)
root.returnToUserPicker();
else if (root.manualUsernameEntry)
root.returnToUserListFromManualEntry();
else
root.userListOpen = !root.userListOpen;
}
}
fallbackIcon: "person"
visible: GreetdSettings.lockScreenShowProfileImage
}
Rectangle {
property bool showPassword: false
Layout.fillWidth: true
Layout.preferredHeight: 60
Layout.preferredHeight: root.showUserPicker && root.userListOpen ? Math.max(60, userPicker.implicitHeight + Theme.spacingM * 2) : 60
radius: Theme.cornerRadius
color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.9)
border.color: inputField.activeFocus ? Theme.primary : Qt.rgba(1, 1, 1, 0.3)
border.width: inputField.activeFocus ? 2 : 1
GreeterUserPicker {
id: userPicker
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: root.userListOpen ? undefined : parent.verticalCenter
anchors.top: root.userListOpen ? parent.top : undefined
anchors.margins: Theme.spacingM
maxExpandedHeight: root.userPickerMaxHeight
visible: root.showUserPicker && !GreeterState.showPasswordInput
expanded: root.userListOpen
onUserSelected: username => root.selectUser(username, false)
onToggleRequested: root.userListOpen = !root.userListOpen
}
DankIcon {
id: lockIcon
@@ -916,6 +1105,7 @@ Item {
name: GreeterState.showPasswordInput ? "lock" : "person"
size: 20
color: inputField.activeFocus ? Theme.primary : Theme.surfaceVariantText
visible: !root.showUserPicker
}
TextInput {
@@ -941,8 +1131,9 @@ Item {
}
return margin;
}
enabled: !root.showUserPicker || GreeterState.showPasswordInput
opacity: 0
focus: true
focus: !root.showUserPicker || GreeterState.showPasswordInput
echoMode: GreeterState.showPasswordInput ? (parent.showPassword ? TextInput.Normal : TextInput.Password) : TextInput.Normal
onTextChanged: {
if (syncingFromState)
@@ -1005,11 +1196,14 @@ Item {
if (GreeterState.showPasswordInput) {
return I18n.tr("Password...");
}
if (root.showUserPicker) {
return "";
}
return I18n.tr("Username...");
}
color: (GreeterState.unlocking || (Greetd.state !== GreetdState.Inactive && !awaitingExternalAuth && !pendingPasswordResponse)) ? Theme.primary : Theme.outline
font.pixelSize: Theme.fontSizeMedium
opacity: (GreeterState.showPasswordInput ? GreeterState.passwordBuffer.length === 0 : GreeterState.usernameInput.length === 0) ? 1 : 0
opacity: (GreeterState.showPasswordInput ? GreeterState.passwordBuffer.length === 0 : (root.showUserPicker ? false : GreeterState.usernameInput.length === 0)) ? 1 : 0
Behavior on opacity {
NumberAnimation {
@@ -1043,7 +1237,7 @@ Item {
}
color: Theme.surfaceText
font.pixelSize: (GreeterState.showPasswordInput && !parent.showPassword) ? Theme.fontSizeLarge : Theme.fontSizeMedium
opacity: (GreeterState.showPasswordInput ? GreeterState.passwordBuffer.length > 0 : GreeterState.usernameInput.length > 0) ? 1 : 0
opacity: (GreeterState.showPasswordInput ? GreeterState.passwordBuffer.length > 0 : (root.showUserPicker ? false : GreeterState.usernameInput.length > 0)) ? 1 : 0
clip: true
elide: Text.ElideNone
horizontalAlignment: implicitWidth > width ? Text.AlignRight : Text.AlignLeft
@@ -1088,7 +1282,7 @@ Item {
anchors.verticalCenter: parent.verticalCenter
iconName: "keyboard"
buttonSize: 32
visible: (Greetd.state === GreetdState.Inactive || awaitingExternalAuth || pendingPasswordResponse) && !GreeterState.unlocking
visible: (Greetd.state === GreetdState.Inactive || awaitingExternalAuth || pendingPasswordResponse) && !GreeterState.unlocking && (!root.showUserPicker || GreeterState.showPasswordInput)
enabled: visible
onClicked: {
if (keyboard_controller.isKeyboardActive) {
@@ -1107,7 +1301,7 @@ Item {
anchors.verticalCenter: parent.verticalCenter
iconName: "keyboard_return"
buttonSize: 36
visible: (Greetd.state === GreetdState.Inactive || awaitingExternalAuth || pendingPasswordResponse) && !GreeterState.unlocking
visible: (Greetd.state === GreetdState.Inactive || awaitingExternalAuth || pendingPasswordResponse) && !GreeterState.unlocking && (!root.showUserPicker || GreeterState.showPasswordInput)
enabled: true
onClicked: {
if (GreeterState.showPasswordInput) {
@@ -1137,9 +1331,39 @@ Item {
}
}
Item {
Layout.fillWidth: true
Layout.preferredHeight: root.showAccountSwitchLink ? 28 : 0
visible: root.showAccountSwitchLink
StyledText {
id: accountSwitchLabel
anchors.horizontalCenter: parent.horizontalCenter
text: root.manualUsernameEntry ? I18n.tr("Back to user list", "greeter link to return from manual username entry to user picker") : I18n.tr("Not listed?", "greeter link to switch to manual username entry")
color: Theme.primary
font.pixelSize: Theme.fontSizeSmall
font.underline: accountSwitchMouse.containsMouse
}
MouseArea {
id: accountSwitchMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (root.manualUsernameEntry)
root.returnToUserListFromManualEntry();
else
root.enterManualUsernameEntry();
}
}
}
StyledText {
Layout.fillWidth: true
Layout.preferredHeight: 38
Layout.preferredHeight: root.authFeedbackMessage !== "" ? 38 : 0
Layout.topMargin: -Theme.spacingS
Layout.bottomMargin: -Theme.spacingS
text: root.authFeedbackMessage
@@ -1158,52 +1382,149 @@ Item {
}
}
Rectangle {
Layout.alignment: Qt.AlignHCenter
Layout.topMargin: 0
Layout.preferredWidth: switchUserRow.width + Theme.spacingL * 2
Layout.preferredHeight: 40
radius: Theme.cornerRadius
color: Theme.surfaceContainer
opacity: GreeterState.showPasswordInput ? 1 : 0
enabled: GreeterState.showPasswordInput
// Password-screen actions: Switch User + Auto-login toggle as one compact chip row
Item {
id: passwordActions
Behavior on opacity {
NumberAnimation {
duration: Theme.mediumDuration
easing.type: Theme.standardEasing
}
}
readonly property bool autoLoginAvailable: GreetdSettings.rememberLastUser && GreetdSettings.rememberLastSession
Layout.fillWidth: true
Layout.topMargin: Theme.spacingXS
Layout.preferredHeight: visible ? 32 : 0
visible: GreeterState.showPasswordInput && !GreeterState.unlocking && (root.multipleUsersAvailable || autoLoginAvailable)
Row {
id: switchUserRow
anchors.centerIn: parent
spacing: Theme.spacingS
DankIcon {
name: "people"
size: Theme.iconSize - 4
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
Rectangle {
id: switchUserChip
visible: root.multipleUsersAvailable
height: 32
width: switchUserContent.implicitWidth + Theme.spacingM * 2
radius: height / 2
color: Theme.withAlpha(Theme.surfaceVariant, 0.65)
Rectangle {
anchors.fill: parent
radius: parent.radius
color: (switchUserMouse.containsMouse || switchUserMouse.pressed) ? Theme.surfaceTextHover : "transparent"
Behavior on color {
ColorAnimation {
duration: Theme.shorterDuration
easing.type: Theme.standardEasing
}
}
}
DankRipple {
id: switchUserRipple
cornerRadius: switchUserChip.radius
rippleColor: Theme.surfaceVariantText
}
Row {
id: switchUserContent
anchors.centerIn: parent
spacing: Theme.spacingXS
DankIcon {
name: "people"
size: 16
color: Theme.surfaceVariantText
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: I18n.tr("Switch User")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: switchUserMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onPressed: mouse => switchUserRipple.trigger(mouse.x, mouse.y)
onClicked: root.returnToUserPicker()
}
}
StyledText {
text: I18n.tr("Switch User")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
}
Rectangle {
id: autoLoginChip
StateLayer {
stateColor: Theme.primary
cornerRadius: parent.radius
enabled: !GreeterState.unlocking && Greetd.state === GreetdState.Inactive && GreeterState.showPasswordInput
onClicked: {
GreeterState.reset();
root.externalAuthAutoStartedForUser = "";
inputField.text = "";
PortalService.profileImage = "";
visible: passwordActions.autoLoginAvailable
height: 32
width: autoLoginContent.implicitWidth + Theme.spacingM * 2
radius: height / 2
color: root.autoLoginOnSuccess ? Theme.withAlpha(Theme.primary, 0.85) : Theme.withAlpha(Theme.surfaceVariant, 0.65)
Behavior on color {
ColorAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
Rectangle {
anchors.fill: parent
radius: parent.radius
color: {
if (autoLoginMouse.pressed)
return root.autoLoginOnSuccess ? Theme.primaryPressed : Theme.surfaceTextHover;
if (autoLoginMouse.containsMouse)
return root.autoLoginOnSuccess ? Theme.primaryHover : Theme.surfaceTextHover;
return "transparent";
}
Behavior on color {
ColorAnimation {
duration: Theme.shorterDuration
easing.type: Theme.standardEasing
}
}
}
DankRipple {
id: autoLoginRipple
cornerRadius: autoLoginChip.radius
rippleColor: root.autoLoginOnSuccess ? Theme.primaryText : Theme.surfaceVariantText
}
Row {
id: autoLoginContent
anchors.centerIn: parent
spacing: Theme.spacingXS
DankIcon {
name: root.autoLoginOnSuccess ? "check" : "login"
size: 16
color: root.autoLoginOnSuccess ? Theme.primaryText : Theme.surfaceVariantText
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: I18n.tr("Auto-login")
font.pixelSize: Theme.fontSizeSmall
font.weight: root.autoLoginOnSuccess ? Font.Medium : Font.Normal
color: root.autoLoginOnSuccess ? Theme.primaryText : Theme.surfaceVariantText
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: autoLoginMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.autoLoginOnSuccess = !root.autoLoginOnSuccess
onPressed: mouse => autoLoginRipple.trigger(mouse.x, mouse.y)
}
}
}
}
@@ -1773,7 +2094,8 @@ Item {
launchTimeout.restart();
if (GreetdSettings.rememberLastSession) {
GreetdMemory.setLastSessionId(sessionPath);
} else if (GreetdMemory.lastSessionId) {
GreetdMemory.setLastSessionExec(sessionCmd);
} else if (GreetdMemory.lastSessionId || GreetdMemory.lastSessionExec) {
GreetdMemory.setLastSessionId("");
}
if (GreetdSettings.rememberLastUser) {
@@ -1781,6 +2103,8 @@ Item {
} else if (GreetdMemory.lastSuccessfulUser) {
GreetdMemory.setLastSuccessfulUser("");
}
if (root.autoLoginOnSuccess)
greeterAutoLoginPendingProcess.running = true;
pendingLaunchCommand = sessionCmd;
pendingLaunchEnv = ["XDG_SESSION_TYPE=wayland"];
memoryFlushTimer.restart();

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