1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-06-20 18:15:24 -04:00

Compare commits

...

32 Commits

Author SHA1 Message Date
purian23 aed731efb0 fix(clipboard): restore Save button targets in editor 2026-05-25 23:19:42 -04:00
purian23 cf0632c077 feat(Clipboard): Revive ClipboardEditor PR
- Original PR #1916 by @nabaco
2026-05-24 23:28:21 -04:00
Nachum Barcohen e92da4a15f Show full clipboard text in editor 2026-05-24 22:34:24 -04:00
Nachum Barcohen 8abdff3220 Add clipboard editor shortcuts and hints 2026-05-24 22:34:24 -04:00
Nachum Barcohen 584d57a8de Add split save menu for clipboard editor 2026-05-24 22:34:05 -04:00
Nachum Barcohen afb5e59c29 feat(clipboard): Add editing capability to clipboard entries 2026-05-24 22:34:05 -04:00
purian23 d9525908f1 refactor(Notifications): further support for duplicate notification logic
- New setting to stack or suppress identical alerts (on by default)
Closes #2334
2026-05-24 22:22:34 -04:00
Lucas 6093c37b41 settings: add descriptions for DankBar menu (#2490) 2026-05-24 18:56:42 -04:00
purian23 bb05cbb6c5 feat(sessions): implement local user session switching functionality
- Core user is logged in tty1 while user two is in tty3, you can now seamlessly switch bewteen them
New IPC options:
- `dms ipc call sessions list`
- `dms switch-user [target]`
- New Powermenu switch users option
2026-05-24 18:33:38 -04:00
purian23 4d4af8f549 feat(Users): add user management UI in DMS Settings 2026-05-24 18:15:41 -04:00
Feng Yu 0b55fbcb15 fix(DankBar): Resolve tray freeze and wallpaper loss after DPMS resume (#2457)
Fixes #2354

Root cause (tray freeze): In clickThrough mode, the PanelWindow mask uses
sectionRect() with mapToItem() to compute input regions. After DPMS resume,
the PanelWindow is recreated with width=0, and mapToItem() returns wrong
positions. The right section's implicitWidth doesn't change after creation
(fixed-size tray icons), so the mask binding is never re-evaluated when the
compositor sets the actual screen width. Adding barWindow.width as a binding
dependency ensures the mask recalculates on resize.

Root cause (wallpaper loss): Wallpaper PanelWindows are recreated by Variants
during screen reconnection before the compositor finishes output initialization.
The wallpaper Image renders at 0x0 dimensions, resulting in a black screen.

Changes:
- DankBarWindow: add barWindow.width dependency to clickThrough mask bindings
- DMSShell: add surface recovery mechanism (screen reconnect + session resume)
  with progressive 2-pass timer (800ms + 2800ms) that recreates bar, Frame,
  wallpaper, and dock surfaces after the compositor is ready
- WlrOutputService: re-request output state on session resume
2026-05-22 09:05:41 -04:00
Domen Kožar 7476a220b5 feat: Blink WiFi/Bluetooth icons while connecting (#2448)
Pulses the WiFi and Bluetooth status icons while a connection is in
progress (lock screen, DankBar control center button, control center
compound pill). The pulse is implemented as a reusable Widgets/DankBlink
component, and the wifi-connecting condition is centralized as
NetworkService.isWifiConnecting.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:03:25 -04:00
Huỳnh Thiện Lộc aaff1ab61e feat: implement interactive microphone volume OSD and IPC controls (#2406)
* feat: implement interactive microphone volume OSD and persistence

Addresses #2388

* refactor: reduce scope to interactive microphone OSD and IPC controls only
2026-05-22 09:00:12 -04:00
Cloud 39622eb62a fix(lock): avoid U2F PAM polling in OR mode (#2459) 2026-05-22 08:59:11 -04:00
Lucas eea039f575 feat(Launcher/Spotlight): improve context keyboard navigation and mode persistence (#2467)
* feat(Spotlight): fix submenu keyboard navigation

* feat(Launcher/Spotlight): disable persisting last mode when changed by triggers

* feat(Launcher/Spotlight): add option to disable last mode being persisted

* fix(Launcher/Spotlight): fix context menu keys navigation

* fix(NiriOverviewOverlay): fix context menu keys navigation and position
2026-05-22 08:53:45 -04:00
purian23 ef5de19f6b distros(dms-greeter): Add sysusers.d immutable distro support
- Closes #1975
2026-05-21 23:21:44 -04:00
bbedward f0c31bd7b3 launcher: add /d /f file search prefixes. Fix prefix not always
triggering
2026-05-21 21:10:30 -04:00
Domen Kožar 7ddd0ca90d fix(Network): Bucket WiFi signal for stable list order (#2449)
Sort networks by signal bucket of 25 with SSID as tiebreaker so minor
RSSI fluctuations between scans no longer reshuffle the list while the
user is selecting a network.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 13:04:31 -04:00
bbedward b84e5abc4a core: remove niri parse test 2026-05-21 10:33:12 -04:00
purian23 fb9ec8e721 feat: (Launcher/Spotlight): Updated w/New Settings & QOL features
- New Spotlight toggle to show/hide chips, off by default
- Updated blur effects on all launcher inputs and footers
- Fixed previous queries resurfacing
- Upated Spotlight keyboard navigation
- Added functionality to show and shortcut to keybinds from the Launcher tab
2026-05-21 01:05:56 -04:00
purian23 078c9b4890 refactor(niri): Normalize key bindings to be case insensitive within DMS 2026-05-21 00:38:31 -04:00
purian23 37c98220a9 refactor(Spotlight): Use Spotlight alongside OG Launcher
- Update to add DMS Action keys in Keyboard Shortcuts
- Defaulted in niri/hyprland includes file as `Alt+Space`
- New (IPC): `dms ipc call spotlight-bar toggle`
- Slight UI update to follow user radius
2026-05-20 17:21:03 -04:00
Klesh Wong fc07611b3b fix(osd): ensure OSD appears on all monitors after resume (#2453)
Some monitors, especially cheaper models, are slow to power on after sleep and may not be present in Quickshell.screens
when onScreensChanged is triggered. This change adds a 3-second delay before updating currentOSDsByScreen to ensure all
screens are detected, mitigating the issue of OSDs not appearing on certain monitors after resume.

Co-authored-by: Klesh Wong <kleshwong@gmail.com>
2026-05-20 11:58:13 -04:00
Graeme Foster a923308c09 networkd: classify links by Type instead of name prefix (#2447)
* networkd: classify links by Type instead of name prefix

The systemd-networkd backend decided wifi-vs-ethernet by checking
whether the interface name started with "wlan" or "wlp". Anything
else (that was not on a small virtual-prefix denylist) was treated
as wired ethernet. That misclassified two common cases:

* Nebula tunnels (kernel name like "nebula.homelab", Type=none,
  Kind=tun) showed up as a wired ethernet device — DMS rendered an
  "Ethernet connected" indicator whenever the overlay was up, even
  with no physical NIC plugged in.
* Renamed wifi interfaces (e.g. systemd link files that rename
  wlan0 to a friendlier name like "wifi") were also miscategorised
  as ethernet, because they no longer matched wlan*/wlp*.

networkd already publishes the real link kind in the JSON returned
by the per-link Describe method ("ether", "wlan", "loopback",
"none"). Fetch it during enumerateLinks, cache it on linkInfo, and
classify against that. The old prefix logic is kept as a fallback
for the case where Describe ever fails to populate Type.

The package-level looksVirtual() helper replaces the unexported
isVirtualInterface method so the new classification helpers and
their tests can use it without needing a live backend.

Tests cover both the Type-based and fallback paths, including the
Nebula-shaped Type=none/tun case that motivated this change.

* networkd: cache linkType across signal ticks and unit-test Describe fallback

enumerateLinks runs on every PropertiesChanged signal under
/org/freedesktop/network1, which fires on carrier flap, DHCP renew, and
each address change. The previous version rebuilt every linkInfo from
scratch on each tick, including a synchronous Describe D-Bus round-trip
per link, despite the link Type being fixed at netlink creation. Preserve
existing entries when the D-Bus path matches, refreshing only ifindex,
and only call fetchLinkType on a genuinely new entry. A link torn down
and re-created at a different path still triggers a refetch.

Extract parseDescribeType from fetchLinkType so the JSON failure path —
malformed payload, missing Type field, wrong type for Type — can be
exercised without a live D-Bus connection. The classifier's fallback to
name-prefix heuristics already had coverage; this locks in that the seam
between Describe and the classifier surfaces an empty string on every
failure mode rather than misclassifying a link.
2026-05-20 11:43:50 -04:00
Lichie 0990b43a43 feat(FocusedWindow): Improve content width calculation and add size options (#2444)
* use RowLayout in focusedapp widget for better width calculation

* Add context menu with additional size options for focused app widget
2026-05-20 11:43:14 -04:00
purian23 548c2305fb refactor(Settings): Rename fullscreen properties to overlay layer for consistency
- Updated property names from `showOverFullscreen` to `useOverlayLayer` across various components.
- Fixed Darken Modal Overlay in Standalone mode
2026-05-19 22:03:33 -04:00
purian23 4634763840 refactor(fullscreen): Refine fullscreen layering and frame overlay behavior
- Replaced fullscreen hide/reveal toggles with Show Over Fullscreen layer toggles
- Added Launcher opt to Show Over Fullscreen setting
- Kept fullscreen stacking compositor-owned via top/overlay layer choices
- Fixed Hyrland Special Workspaces
- Updated DMS Advanced Configuration docs
2026-05-19 18:42:45 -04:00
bbedward cdc1102092 popout: fix opening popouts across monitors
cc/brightness: fix delegate bindings and pinning
2026-05-19 11:23:03 -04:00
purian23 4845299cc2 fix(Spotlight): Update the new clipboard/settings merge w/cache & debouced refresh 2026-05-19 01:39:16 -04:00
purian23 81a1bb1cd7 fix(Hyprland): Respect legacy conf configs before migrating to lua 2026-05-18 16:51:25 -04:00
sima 4528552610 Fix Hyprland Lua dispatch helpers (#2443) 2026-05-18 13:34:49 -04:00
purian23 0b55bf5dac feat(Hyprland): Introduce Lua support for Hyprland configurations
- Note: We do not convert your existing conf configs to lua. This update only reflects DMS defaults state
- Updated README.md to reflect changes
- Updated Keyboard shortcut support
2026-05-18 13:06:58 -04:00
154 changed files with 9760 additions and 2256 deletions
+3
View File
@@ -6,6 +6,7 @@ import (
"regexp"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/plugins"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server"
@@ -37,6 +38,7 @@ var runCmd = &cobra.Command{
}
}
log.ApplyEnvOverrides()
config.CleanupStrayHyprlandConfFile(log.Infof)
if daemon {
runShellDaemon(session)
} else {
@@ -539,5 +541,6 @@ func getCommonCommands() []*cobra.Command {
blurCmd,
trashCmd,
systemCmd,
switchUserCmd,
}
}
+41 -8
View File
@@ -8,6 +8,7 @@ import (
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/luaconfig"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/spf13/cobra"
)
@@ -27,7 +28,21 @@ var resolveIncludeCmd = &cobra.Command{
case 0:
return []string{"hyprland", "niri", "mangowc"}, cobra.ShellCompDirectiveNoFileComp
case 1:
return []string{"cursor.kdl", "cursor.conf", "outputs.kdl", "outputs.conf", "binds.kdl", "binds.conf"}, cobra.ShellCompDirectiveNoFileComp
return []string{
"binds.lua",
"binds-user.lua",
"colors.lua",
"layout.lua",
"outputs.lua",
"cursor.lua",
"windowrules.lua",
"cursor.kdl",
"outputs.kdl",
"binds.kdl",
"cursor.conf",
"outputs.conf",
"binds.conf",
}, cobra.ShellCompDirectiveNoFileComp
}
return nil, cobra.ShellCompDirectiveNoFileComp
},
@@ -82,17 +97,35 @@ func checkHyprlandInclude(filename string) (IncludeResult, error) {
result.Exists = true
}
mainConfig := filepath.Join(configDir, "hyprland.conf")
if _, err := os.Stat(mainConfig); os.IsNotExist(err) {
return result, nil
targetAbs, err := filepath.Abs(targetPath)
if err != nil {
return result, err
}
targetRel := filepath.ToSlash(filepath.Join("dms", filename))
mainLua := filepath.Join(configDir, "hyprland.lua")
if _, err := os.Stat(mainLua); err == nil {
processedLua := make(map[string]bool)
if luaconfig.RequiresTarget(mainLua, targetAbs, processedLua) {
result.Included = true
return result, nil
}
}
mainConf := filepath.Join(configDir, "hyprland.conf")
if _, err := os.Stat(mainConf); err == nil {
processed := make(map[string]bool)
if hyprlandFindIncludeHyprlang(mainConf, targetRel, processed) {
result.Included = true
return result, nil
}
}
processed := make(map[string]bool)
result.Included = hyprlandFindInclude(mainConfig, "dms/"+filename, processed)
return result, nil
}
func hyprlandFindInclude(filePath, target string, processed map[string]bool) bool {
func hyprlandFindIncludeHyprlang(filePath, target string, processed map[string]bool) bool {
absPath, err := filepath.Abs(filePath)
if err != nil {
return false
@@ -141,7 +174,7 @@ func hyprlandFindInclude(filePath, target string, processed map[string]bool) boo
continue
}
if hyprlandFindInclude(expanded, target, processed) {
if hyprlandFindIncludeHyprlang(expanded, target, processed) {
return true
}
}
+27 -2
View File
@@ -51,12 +51,20 @@ var keybindsSetCmd = &cobra.Command{
var keybindsRemoveCmd = &cobra.Command{
Use: "remove <provider> <key>",
Short: "Remove a keybind override",
Long: "Remove a keybind override from the specified provider",
Short: "Remove a keybind",
Long: "Remove a keybind. For Hyprland this writes a negative override to dms/binds-user.lua so the key stays unbound across DMS updates. For other providers it deletes the entry from the managed file.",
Args: cobra.ExactArgs(2),
Run: runKeybindsRemove,
}
var keybindsResetCmd = &cobra.Command{
Use: "reset <provider> <key>",
Short: "Reset a keybind override to its DMS default",
Long: "Drop the user override for the given key so the DMS default re-applies. For providers without a separate default file (Niri, MangoWC) this is equivalent to remove.",
Args: cobra.ExactArgs(2),
Run: runKeybindsReset,
}
func init() {
keybindsListCmd.Flags().BoolP("json", "j", false, "Output as JSON")
keybindsShowCmd.Flags().String("path", "", "Override config path for the provider")
@@ -72,6 +80,7 @@ func init() {
keybindsCmd.AddCommand(keybindsShowCmd)
keybindsCmd.AddCommand(keybindsSetCmd)
keybindsCmd.AddCommand(keybindsRemoveCmd)
keybindsCmd.AddCommand(keybindsResetCmd)
keybinds.SetJSONProviderFactory(func(filePath string) (keybinds.Provider, error) {
return providers.NewJSONFileProvider(filePath)
@@ -263,3 +272,19 @@ func runKeybindsRemove(_ *cobra.Command, args []string) {
}, "", " ")
fmt.Fprintln(os.Stdout, string(output))
}
func runKeybindsReset(_ *cobra.Command, args []string) {
providerName, key := args[0], args[1]
writable := getWritableProvider(providerName)
if err := writable.ResetBind(key); err != nil {
log.Fatalf("Error resetting keybind: %v", err)
}
output, _ := json.MarshalIndent(map[string]any{
"success": true,
"key": key,
"reset": true,
}, "", " ")
fmt.Fprintln(os.Stdout, string(output))
}
+187
View File
@@ -0,0 +1,187 @@
package main
import (
"bufio"
"fmt"
"os"
"os/exec"
"sort"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/spf13/cobra"
)
var switchUserCmd = &cobra.Command{
Use: "switch-user [target]",
Short: "Switch to another active session on this seat",
Long: `Switch the active VT to another running session.
With no target, prints the list of switchable sessions. Pass a username or a
numeric session ID to switch directly. Requires the target to already be a
running session on the same seat (use the greeter for a fresh login).`,
Args: cobra.MaximumNArgs(1),
Run: runSwitchUser,
}
type sessionInfo struct {
ID string
Name string
Seat string
TTY string
Type string
Class string
Active bool
State string
Current bool
}
func runSwitchUser(cmd *cobra.Command, args []string) {
currentID := os.Getenv("XDG_SESSION_ID")
sessions, err := listSessions(currentID)
if err != nil {
log.Fatalf("%v", err)
}
switchable := make([]sessionInfo, 0, len(sessions))
for _, s := range sessions {
if s.Class != "user" || s.State == "closing" || s.Current {
continue
}
switchable = append(switchable, s)
}
if len(args) == 0 {
if len(switchable) == 0 {
fmt.Println("No other active sessions on this seat.")
return
}
printSessions(switchable)
return
}
target := args[0]
picked, err := pickSession(switchable, target)
if err != nil {
fmt.Fprintln(os.Stderr, err)
if len(switchable) == 0 {
fmt.Fprintln(os.Stderr, "No other active sessions on this seat. Only already-running sessions can be switched to.")
} else {
fmt.Fprintln(os.Stderr, "\nSwitchable sessions:")
printSessions(switchable)
}
os.Exit(1)
}
if err := activateSession(picked.ID); err != nil {
log.Fatalf("loginctl activate %s: %v", picked.ID, err)
}
}
func listSessions(currentID string) ([]sessionInfo, error) {
listOut, err := exec.Command("loginctl", "list-sessions", "--no-legend").Output()
if err != nil {
return nil, fmt.Errorf("loginctl list-sessions: %w", err)
}
var ids []string
scanner := bufio.NewScanner(strings.NewReader(string(listOut)))
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) == 0 {
continue
}
ids = append(ids, fields[0])
}
out := make([]sessionInfo, 0, len(ids))
for _, id := range ids {
s, err := showSession(id)
if err != nil {
continue
}
s.Current = currentID != "" && s.ID == currentID
out = append(out, s)
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].Name != out[j].Name {
return out[i].Name < out[j].Name
}
return out[i].ID < out[j].ID
})
return out, nil
}
func showSession(id string) (sessionInfo, error) {
out, err := exec.Command("loginctl", "show-session", id,
"-p", "Id", "-p", "Name", "-p", "Seat", "-p", "TTY",
"-p", "Type", "-p", "Class", "-p", "Active", "-p", "State").Output()
if err != nil {
return sessionInfo{}, err
}
fields := map[string]string{}
for _, line := range strings.Split(string(out), "\n") {
idx := strings.IndexByte(line, '=')
if idx <= 0 {
continue
}
fields[line[:idx]] = line[idx+1:]
}
if fields["Id"] == "" {
return sessionInfo{}, fmt.Errorf("session %s: no Id", id)
}
return sessionInfo{
ID: fields["Id"],
Name: fields["Name"],
Seat: fields["Seat"],
TTY: fields["TTY"],
Type: fields["Type"],
Class: fields["Class"],
Active: fields["Active"] == "yes",
State: fields["State"],
}, nil
}
func pickSession(sessions []sessionInfo, target string) (sessionInfo, error) {
for _, s := range sessions {
if s.ID == target {
return s, nil
}
}
matches := make([]sessionInfo, 0, 2)
for _, s := range sessions {
if s.Name == target {
matches = append(matches, s)
}
}
if len(matches) == 1 {
return matches[0], nil
}
if len(matches) > 1 {
ids := make([]string, len(matches))
for i, m := range matches {
ids[i] = m.ID
}
return sessionInfo{}, fmt.Errorf("%s has multiple active sessions (%s); pass a session ID instead", target, strings.Join(ids, ", "))
}
return sessionInfo{}, fmt.Errorf("no switchable session matches %q", target)
}
func activateSession(id string) error {
return exec.Command("loginctl", "activate", id).Run()
}
func printSessions(sessions []sessionInfo) {
fmt.Printf("%-6s %-12s %-8s %-8s %-8s\n", "ID", "USER", "TYPE", "SEAT", "TTY")
for _, s := range sessions {
tty := s.TTY
if tty == "" {
tty = "-"
}
seat := s.Seat
if seat == "" {
seat = "-"
}
fmt.Printf("%-6s %-12s %-8s %-8s %-8s\n", s.ID, s.Name, s.Type, seat, tty)
}
}
+23 -17
View File
@@ -109,25 +109,25 @@ type dmsConfigSpec struct {
var dmsConfigSpecs = map[string]dmsConfigSpec{
"binds": {
niriFile: "binds.kdl",
hyprFile: "binds.conf",
hyprFile: "binds.lua",
niriContent: func(t string) string {
return strings.ReplaceAll(config.NiriBindsConfig, "{{TERMINAL_COMMAND}}", t)
},
hyprContent: func(t string) string {
return strings.ReplaceAll(config.HyprBindsConfig, "{{TERMINAL_COMMAND}}", t)
return strings.ReplaceAll(config.DMSBindsLuaConfig, "{{TERMINAL_COMMAND}}", t)
},
},
"layout": {
niriFile: "layout.kdl",
hyprFile: "layout.conf",
hyprFile: "layout.lua",
niriContent: func(_ string) string { return config.NiriLayoutConfig },
hyprContent: func(_ string) string { return config.HyprLayoutConfig },
hyprContent: func(_ string) string { return config.DMSLayoutLuaConfig },
},
"colors": {
niriFile: "colors.kdl",
hyprFile: "colors.conf",
hyprFile: "colors.lua",
niriContent: func(_ string) string { return config.NiriColorsConfig },
hyprContent: func(_ string) string { return config.HyprColorsConfig },
hyprContent: func(_ string) string { return config.DMSColorsLuaConfig },
},
"alttab": {
niriFile: "alttab.kdl",
@@ -135,21 +135,21 @@ var dmsConfigSpecs = map[string]dmsConfigSpec{
},
"outputs": {
niriFile: "outputs.kdl",
hyprFile: "outputs.conf",
hyprFile: "outputs.lua",
niriContent: func(_ string) string { return "" },
hyprContent: func(_ string) string { return "" },
hyprContent: func(_ string) string { return config.DMSOutputsLuaConfig },
},
"cursor": {
niriFile: "cursor.kdl",
hyprFile: "cursor.conf",
hyprFile: "cursor.lua",
niriContent: func(_ string) string { return "" },
hyprContent: func(_ string) string { return "" },
hyprContent: func(_ string) string { return config.DMSCursorLuaConfig },
},
"windowrules": {
niriFile: "windowrules.kdl",
hyprFile: "windowrules.conf",
hyprFile: "windowrules.lua",
niriContent: func(_ string) string { return "" },
hyprContent: func(_ string) string { return "" },
hyprContent: func(_ string) string { return config.DMSWindowRulesLuaConfig },
},
}
@@ -438,16 +438,22 @@ func checkExistingConfigs(wm deps.WindowManager, wmSelected bool, terminal deps.
willBackup := false
if wmSelected {
var configPath string
var configPaths []string
switch wm {
case deps.WindowManagerNiri:
configPath = filepath.Join(homeDir, ".config", "niri", "config.kdl")
configPaths = []string{filepath.Join(homeDir, ".config", "niri", "config.kdl")}
case deps.WindowManagerHyprland:
configPath = filepath.Join(homeDir, ".config", "hypr", "hyprland.conf")
configPaths = []string{
filepath.Join(homeDir, ".config", "hypr", "hyprland.lua"),
filepath.Join(homeDir, ".config", "hypr", "hyprland.conf"),
}
}
if _, err := os.Stat(configPath); err == nil {
willBackup = true
for _, configPath := range configPaths {
if _, err := os.Stat(configPath); err == nil {
willBackup = true
break
}
}
}
+8 -10
View File
@@ -26,7 +26,7 @@ var windowrulesListCmd = &cobra.Command{
Args: cobra.MaximumNArgs(1),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 {
return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
}
return nil, cobra.ShellCompDirectiveNoFileComp
},
@@ -40,8 +40,7 @@ var windowrulesAddCmd = &cobra.Command{
Args: cobra.ExactArgs(2),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 {
// ! disabled hyprland return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
}
return nil, cobra.ShellCompDirectiveNoFileComp
},
@@ -55,7 +54,7 @@ var windowrulesUpdateCmd = &cobra.Command{
Args: cobra.ExactArgs(3),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 {
return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
}
return nil, cobra.ShellCompDirectiveNoFileComp
},
@@ -69,7 +68,7 @@ var windowrulesRemoveCmd = &cobra.Command{
Args: cobra.ExactArgs(2),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 {
return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
}
return nil, cobra.ShellCompDirectiveNoFileComp
},
@@ -83,7 +82,7 @@ var windowrulesReorderCmd = &cobra.Command{
Args: cobra.ExactArgs(2),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 {
return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
}
return nil, cobra.ShellCompDirectiveNoFileComp
},
@@ -118,9 +117,9 @@ func getCompositor(args []string) string {
if os.Getenv("NIRI_SOCKET") != "" {
return "niri"
}
// if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "" {
// return "hyprland"
// }
if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "" {
return "hyprland"
}
return ""
}
@@ -183,7 +182,6 @@ func runWindowrulesList(cmd *cobra.Command, args []string) {
result.DMSStatus = parseResult.DMSStatus
case "hyprland":
log.Fatalf("Hyprland support is currently disabled.") // ! disabled hyprland
configDir, err := utils.ExpandPath("$HOME/.config/hypr")
if err != nil {
log.Fatalf("Failed to expand hyprland config path: %v", err)
+179 -110
View File
@@ -12,6 +12,8 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
)
const hyprlandBackupDirName = ".dms-backups"
type ConfigDeployer struct {
logChan chan<- string
}
@@ -63,12 +65,23 @@ func (cd *ConfigDeployer) deployConfigurationsInternal(ctx context.Context, wm d
var results []DeploymentResult
// Primary config file paths used to detect fresh installs.
configPrimaryPaths := map[string]string{
"Niri": filepath.Join(os.Getenv("HOME"), ".config", "niri", "config.kdl"),
"Hyprland": filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf"),
"Ghostty": filepath.Join(os.Getenv("HOME"), ".config", "ghostty", "config"),
"Kitty": filepath.Join(os.Getenv("HOME"), ".config", "kitty", "kitty.conf"),
"Alacritty": filepath.Join(os.Getenv("HOME"), ".config", "alacritty", "alacritty.toml"),
configPrimaryPaths := map[string][]string{
"Niri": {
filepath.Join(os.Getenv("HOME"), ".config", "niri", "config.kdl"),
},
"Hyprland": {
filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.lua"),
filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf"),
},
"Ghostty": {
filepath.Join(os.Getenv("HOME"), ".config", "ghostty", "config"),
},
"Kitty": {
filepath.Join(os.Getenv("HOME"), ".config", "kitty", "kitty.conf"),
},
"Alacritty": {
filepath.Join(os.Getenv("HOME"), ".config", "alacritty", "alacritty.toml"),
},
}
shouldReplaceConfig := func(configType string) bool {
@@ -81,8 +94,15 @@ func (cd *ConfigDeployer) deployConfigurationsInternal(ctx context.Context, wm d
}
// Config is explicitly set to "don't replace" — but still deploy
// if the config file doesn't exist yet (fresh install scenario).
if primaryPath, ok := configPrimaryPaths[configType]; ok {
if _, err := os.Stat(primaryPath); os.IsNotExist(err) {
if primaryPaths, ok := configPrimaryPaths[configType]; ok {
exists := false
for _, primaryPath := range primaryPaths {
if _, err := os.Stat(primaryPath); err == nil {
exists = true
break
}
}
if !exists {
return true
}
}
@@ -495,7 +515,7 @@ func (cd *ConfigDeployer) mergeNiriOutputSections(newConfig, existingConfig, dms
func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystemd bool) (DeploymentResult, error) {
result := DeploymentResult{
ConfigType: "Hyprland",
Path: filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf"),
Path: filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.lua"),
}
configDir := filepath.Dir(result.Path)
@@ -510,20 +530,20 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
return result, result.Error
}
timestamp := time.Now().Format("2006-01-02_15-04-05")
backupDir := filepath.Join(configDir, hyprlandBackupDirName, timestamp)
var existingConfig string
if _, err := os.Stat(result.Path); err == nil {
cd.log("Found existing Hyprland configuration")
existingData, existingPath, err := readExistingHyprlandConfig(configDir)
if err != nil {
result.Error = err
return result, result.Error
}
if existingData != "" {
existingConfig = existingData
cd.log(fmt.Sprintf("Found existing Hyprland configuration at %s", existingPath))
existingData, err := os.ReadFile(result.Path)
if err != nil {
result.Error = fmt.Errorf("failed to read existing config: %w", err)
return result, result.Error
}
existingConfig = string(existingData)
timestamp := time.Now().Format("2006-01-02_15-04-05")
result.BackupPath = result.Path + ".backup." + timestamp
if err := os.WriteFile(result.BackupPath, existingData, 0o644); err != nil {
result.BackupPath = filepath.Join(backupDir, filepath.Base(existingPath))
if err := backupHyprlandConfigFile(existingPath, result.BackupPath, []byte(existingData), strings.EqualFold(filepath.Ext(existingPath), ".conf")); err != nil {
result.Error = fmt.Errorf("failed to create backup: %w", err)
return result, result.Error
}
@@ -542,10 +562,10 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
terminalCommand = "ghostty"
}
newConfig := strings.ReplaceAll(HyprlandConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
newConfig := strings.ReplaceAll(HyprlandLuaConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
if !useSystemd {
newConfig = cd.transformHyprlandConfigForNonSystemd(newConfig, terminalCommand)
newConfig = transformHyprlandLuaForNonSystemd(newConfig, terminalCommand)
}
if existingConfig != "" {
@@ -563,6 +583,18 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
return result, result.Error
}
movedLegacy, err := backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir)
if err != nil {
result.Error = fmt.Errorf("failed to back up legacy hyprlang configs: %w", err)
return result, result.Error
}
if movedLegacy > 0 {
if result.BackupPath == "" {
result.BackupPath = backupDir
}
cd.log(fmt.Sprintf("Moved %d legacy hyprlang config(s) to %s", movedLegacy, backupDir))
}
if err := cd.deployHyprlandDmsConfigs(dmsDir, terminalCommand); err != nil {
result.Error = fmt.Errorf("failed to deploy dms configs: %w", err)
return result, result.Error
@@ -573,29 +605,118 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
return result, nil
}
func backupHyprlandConfigFile(src, dst string, data []byte, removeSource bool) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
if err := os.WriteFile(dst, data, 0o644); err != nil {
return err
}
if removeSource {
if err := os.Remove(src); err != nil && !os.IsNotExist(err) {
return err
}
}
return nil
}
func backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir string) (int, error) {
legacyPaths := []string{filepath.Join(configDir, "hyprland.conf")}
dmsConfPaths, err := filepath.Glob(filepath.Join(dmsDir, "*.conf"))
if err != nil {
return 0, err
}
legacyPaths = append(legacyPaths, dmsConfPaths...)
backupPaths, err := adjacentHyprlandBackupFiles(configDir, dmsDir)
if err != nil {
return 0, err
}
legacyPaths = append(legacyPaths, backupPaths...)
moved := 0
for _, src := range legacyPaths {
info, err := os.Lstat(src)
if os.IsNotExist(err) {
continue
}
if err != nil {
return moved, err
}
if info.IsDir() {
continue
}
rel, err := filepath.Rel(configDir, src)
if err != nil {
rel = filepath.Base(src)
}
dst := filepath.Join(backupDir, rel)
if err := moveHyprlandConfigFile(src, dst); err != nil {
return moved, err
}
moved++
}
return moved, nil
}
func moveHyprlandConfigFile(src, dst string) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
return os.Rename(src, dst)
}
func adjacentHyprlandBackupFiles(configDir, dmsDir string) ([]string, error) {
var paths []string
patterns := []string{
filepath.Join(configDir, "hyprland.conf.backup.*"),
filepath.Join(configDir, "hyprland.lua.backup.*"),
filepath.Join(dmsDir, "*.conf.backup.*"),
filepath.Join(dmsDir, "*.lua.backup.*"),
}
for _, pattern := range patterns {
matches, err := filepath.Glob(pattern)
if err != nil {
return nil, err
}
paths = append(paths, matches...)
}
return paths, nil
}
func (cd *ConfigDeployer) deployHyprlandDmsConfigs(dmsDir string, terminalCommand string) error {
configs := []struct {
name string
content string
name string
content string
overwrite bool
}{
{"colors.conf", HyprColorsConfig},
{"layout.conf", HyprLayoutConfig},
{"binds.conf", strings.ReplaceAll(HyprBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)},
{"outputs.conf", ""},
{"cursor.conf", ""},
{"windowrules.conf", ""},
{name: "colors.lua", content: DMSColorsLuaConfig},
{name: "layout.lua", content: DMSLayoutLuaConfig},
{name: "binds.lua", content: strings.ReplaceAll(DMSBindsLuaConfig, "{{TERMINAL_COMMAND}}", terminalCommand), overwrite: true},
{name: "binds-user.lua", content: DMSBindsUserLuaConfig},
{name: "outputs.lua", content: DMSOutputsLuaConfig},
{name: "cursor.lua", content: DMSCursorLuaConfig},
{name: "windowrules.lua", content: DMSWindowRulesLuaConfig},
}
for _, cfg := range configs {
path := filepath.Join(dmsDir, cfg.name)
// Skip if file already exists and is not empty to preserve user modifications
existed := false
if info, err := os.Stat(path); err == nil && info.Size() > 0 {
existed = true
}
if existed && !cfg.overwrite {
cd.log(fmt.Sprintf("Skipping %s (already exists)", cfg.name))
continue
}
if err := os.WriteFile(path, []byte(cfg.content), 0o644); err != nil {
return fmt.Errorf("failed to write %s: %w", cfg.name, err)
}
if existed {
cd.log(fmt.Sprintf("Updated %s", cfg.name))
continue
}
cd.log(fmt.Sprintf("Deployed %s", cfg.name))
}
@@ -603,94 +724,42 @@ func (cd *ConfigDeployer) deployHyprlandDmsConfigs(dmsDir string, terminalComman
}
func (cd *ConfigDeployer) mergeHyprlandMonitorSections(newConfig, existingConfig, dmsDir string) (string, error) {
monitorRegex := regexp.MustCompile(`(?m)^#?\s*monitor\s*=.*$`)
existingMonitors := monitorRegex.FindAllString(existingConfig, -1)
if len(existingMonitors) == 0 {
_ = newConfig
lines := extractHyprlangMonitorLines(existingConfig)
if len(lines) == 0 {
return newConfig, nil
}
outputsPath := filepath.Join(dmsDir, "outputs.conf")
if _, err := os.Stat(outputsPath); err != nil {
var outputsContent strings.Builder
for _, monitor := range existingMonitors {
outputsContent.WriteString(monitor)
outputsContent.WriteString("\n")
}
if err := os.WriteFile(outputsPath, []byte(outputsContent.String()), 0o644); err != nil {
cd.log(fmt.Sprintf("Warning: Failed to migrate monitors to %s: %v", outputsPath, err))
} else {
cd.log("Migrated monitor sections to dms/outputs.conf")
}
outputsPath := filepath.Join(dmsDir, "outputs.lua")
if info, err := os.Stat(outputsPath); err == nil && info.Size() > 0 {
cd.log("Skipping monitor migration: dms/outputs.lua already exists")
return newConfig, nil
}
exampleMonitorRegex := regexp.MustCompile(`(?m)^# monitor = eDP-2.*$`)
mergedConfig := exampleMonitorRegex.ReplaceAllString(newConfig, "")
monitorHeaderRegex := regexp.MustCompile(`(?m)^# MONITOR CONFIG\n# ==================$`)
headerMatch := monitorHeaderRegex.FindStringIndex(mergedConfig)
if headerMatch == nil {
return "", fmt.Errorf("could not find MONITOR CONFIG section")
}
insertPos := headerMatch[1] + 1
var builder strings.Builder
builder.WriteString(mergedConfig[:insertPos])
builder.WriteString("# Monitors from existing configuration\n")
for _, monitor := range existingMonitors {
builder.WriteString(monitor)
builder.WriteString("\n")
}
builder.WriteString(mergedConfig[insertPos:])
return builder.String(), nil
}
func (cd *ConfigDeployer) transformHyprlandConfigForNonSystemd(config, terminalCommand string) string {
lines := strings.Split(config, "\n")
var result []string
startupSectionFound := false
var b strings.Builder
b.WriteString("-- Migrated from existing hyprlang monitor lines\n\n")
ok := 0
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "exec-once = dbus-update-activation-environment") {
lua, err := hyprlangMonitorLineToLua(line)
if err != nil {
cd.log(fmt.Sprintf("Warning: could not migrate monitor line %q: %v", line, err))
continue
}
if strings.HasPrefix(trimmed, "exec-once = systemctl --user start") {
startupSectionFound = true
result = append(result, "exec-once = dms run")
result = append(result, "env = QT_QPA_PLATFORM,wayland;xcb")
result = append(result, "env = ELECTRON_OZONE_PLATFORM_HINT,auto")
result = append(result, "env = QT_QPA_PLATFORMTHEME,gtk3")
result = append(result, "env = QT_QPA_PLATFORMTHEME_QT6,gtk3")
result = append(result, fmt.Sprintf("env = TERMINAL,%s", terminalCommand))
continue
}
result = append(result, line)
b.WriteString(lua)
b.WriteByte('\n')
ok++
}
if !startupSectionFound {
for i, line := range result {
if strings.Contains(line, "STARTUP APPS") {
insertLines := []string{
"exec-once = dms run",
"env = QT_QPA_PLATFORM,wayland;xcb",
"env = ELECTRON_OZONE_PLATFORM_HINT,auto",
"env = QT_QPA_PLATFORMTHEME,gtk3",
"env = QT_QPA_PLATFORMTHEME_QT6,gtk3",
fmt.Sprintf("env = TERMINAL,%s", terminalCommand),
}
result = append(result[:i+2], append(insertLines, result[i+2:]...)...)
break
}
}
if ok == 0 {
return newConfig, nil
}
return strings.Join(result, "\n")
b.WriteByte('\n')
b.WriteString("-- Default fallback\n")
b.WriteString("hl.monitor({ output = \"\", mode = \"preferred\", position = \"auto\", scale = \"auto\" })\n")
if err := os.WriteFile(outputsPath, []byte(b.String()), 0o644); err != nil {
return newConfig, err
}
cd.log("Migrated monitor sections to dms/outputs.lua")
return newConfig, nil
}
func (cd *ConfigDeployer) transformNiriConfigForNonSystemd(config, terminalCommand string) string {
+208 -132
View File
@@ -11,6 +11,46 @@ import (
"github.com/stretchr/testify/require"
)
func TestCleanupStrayHyprlandConfFile(t *testing.T) {
if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") == "" {
t.Setenv("HYPRLAND_INSTANCE_SIGNATURE", "test-signature")
}
t.Run("leaves conf alone when no hyprland.lua present", func(t *testing.T) {
td := t.TempDir()
t.Setenv("HOME", td)
configDir := filepath.Join(td, ".config", "hypr")
require.NoError(t, os.MkdirAll(configDir, 0o755))
confPath := filepath.Join(configDir, "hyprland.conf")
require.NoError(t, os.WriteFile(confPath, []byte("# legacy user config\n"), 0o644))
CleanupStrayHyprlandConfFile(nil)
assert.FileExists(t, confPath, "must not touch hyprland.conf when user has not migrated")
assert.NoDirExists(t, filepath.Join(configDir, hyprlandBackupDirName))
})
t.Run("moves stray conf into backup when hyprland.lua exists", func(t *testing.T) {
td := t.TempDir()
t.Setenv("HOME", td)
configDir := filepath.Join(td, ".config", "hypr")
require.NoError(t, os.MkdirAll(configDir, 0o755))
luaPath := filepath.Join(configDir, "hyprland.lua")
require.NoError(t, os.WriteFile(luaPath, []byte("-- dms managed\n"), 0o644))
confPath := filepath.Join(configDir, "hyprland.conf")
require.NoError(t, os.WriteFile(confPath, []byte("# autogen\n"), 0o644))
CleanupStrayHyprlandConfFile(nil)
assert.NoFileExists(t, confPath)
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"))
})
}
func TestMergeNiriOutputSections(t *testing.T) {
cd := &ConfigDeployer{}
@@ -259,130 +299,56 @@ func getGhosttyPath() string {
func TestMergeHyprlandMonitorSections(t *testing.T) {
cd := &ConfigDeployer{}
tests := []struct {
name string
newConfig string
existingConfig string
wantError bool
wantContains []string
wantNotContains []string
}{
{
name: "no existing monitors",
newConfig: `# ==================
# MONITOR CONFIG
# ==================
# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1
t.Run("no monitors in existing", func(t *testing.T) {
tmp := t.TempDir()
out, err := cd.mergeHyprlandMonitorSections(`hl.config({})`, `input { kb_layout = us }`, tmp)
require.NoError(t, err)
assert.Equal(t, `hl.config({})`, out)
_, e := os.Stat(filepath.Join(tmp, "outputs.lua"))
assert.True(t, os.IsNotExist(e))
})
# ==================
# ENVIRONMENT VARS
# ==================
env = XDG_CURRENT_DESKTOP,niri`,
existingConfig: `# Some other config
input {
kb_layout = us
}`,
wantError: false,
wantContains: []string{"MONITOR CONFIG", "ENVIRONMENT VARS"},
},
{
name: "merge single monitor",
newConfig: `# ==================
# MONITOR CONFIG
# ==================
# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1
# ==================
# ENVIRONMENT VARS
# ==================`,
existingConfig: `# My config
monitor = DP-1, 1920x1080@144, 0x0, 1
input {
kb_layout = us
}`,
wantError: false,
wantContains: []string{
"MONITOR CONFIG",
"monitor = DP-1, 1920x1080@144, 0x0, 1",
"Monitors from existing configuration",
},
wantNotContains: []string{
"monitor = eDP-2", // Example monitor should be removed
},
},
{
name: "merge multiple monitors",
newConfig: `# ==================
# MONITOR CONFIG
# ==================
# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1
# ==================
# ENVIRONMENT VARS
# ==================`,
existingConfig: `monitor = DP-1, 1920x1080@144, 0x0, 1
t.Run("writes outputs lua from hyprlang monitors", func(t *testing.T) {
tmp := t.TempDir()
existing := `monitor = DP-1, 1920x1080@144, 0x0, 1
# monitor = HDMI-A-1, 1920x1080@60, 1920x0, 1
monitor = eDP-1, 2560x1440@165, auto, 1.25`,
wantError: false,
wantContains: []string{
"monitor = DP-1",
"# monitor = HDMI-A-1", // Commented monitor preserved
"monitor = eDP-1",
"Monitors from existing configuration",
},
wantNotContains: []string{
"monitor = eDP-2", // Example monitor should be removed
},
},
{
name: "preserve commented monitors",
newConfig: `# ==================
# MONITOR CONFIG
# ==================
# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1
monitor = eDP-1, 2560x1440@165, auto, 1.25`
out, err := cd.mergeHyprlandMonitorSections(`return`, existing, tmp)
require.NoError(t, err)
assert.Equal(t, `return`, out)
b, err := os.ReadFile(filepath.Join(tmp, "outputs.lua"))
require.NoError(t, err)
s := string(b)
assert.Contains(t, s, "hl.monitor")
assert.Contains(t, s, "DP-1")
assert.Contains(t, s, "HDMI-A-1")
assert.Contains(t, s, "eDP-1")
assert.Contains(t, s, "preferred") // fallback rule at end
})
# ==================`,
existingConfig: `# monitor = DP-1, 1920x1080@144, 0x0, 1
# monitor = HDMI-A-1, 1920x1080@60, 1920x0, 1`,
wantError: false,
wantContains: []string{
"# monitor = DP-1",
"# monitor = HDMI-A-1",
"Monitors from existing configuration",
},
},
{
name: "no monitor config section",
newConfig: `# Some config without monitor section
input {
kb_layout = us
}`,
existingConfig: `monitor = DP-1, 1920x1080@144, 0x0, 1`,
wantError: true,
},
}
t.Run("skips when outputs lua already exists", func(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "outputs.lua")
require.NoError(t, os.WriteFile(path, []byte("-- keep\n"), 0o644))
_, err := cd.mergeHyprlandMonitorSections(`x`, `monitor = DP-1, 1920x1080@144, 0x0, 1`, tmp)
require.NoError(t, err)
b, err := os.ReadFile(path)
require.NoError(t, err)
assert.Equal(t, "-- keep\n", string(b))
})
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()
result, err := cd.mergeHyprlandMonitorSections(tt.newConfig, tt.existingConfig, tmpDir)
func TestHyprlangMonitorLineToLuaPreservesOptions(t *testing.T) {
got, err := hyprlangMonitorLineToLua(`monitor = DP-1, 1920x1080@144, 0x0, 1, transform, 1, vrr, 2, bitdepth, 10, cm, hdr, sdrbrightness, 1.2, sdrsaturation, 0.98`)
require.NoError(t, err)
if tt.wantError {
assert.Error(t, err)
return
}
require.NoError(t, err)
for _, want := range tt.wantContains {
assert.Contains(t, result, want, "merged config should contain: %s", want)
}
for _, notWant := range tt.wantNotContains {
assert.NotContains(t, result, notWant, "merged config should NOT contain: %s", notWant)
}
})
}
assert.Contains(t, got, `output = "DP-1"`)
assert.Contains(t, got, `transform = 1`)
assert.Contains(t, got, `vrr = 2`)
assert.Contains(t, got, `bitdepth = 10`)
assert.Contains(t, got, `cm = "hdr"`)
assert.Contains(t, got, `sdrbrightness = 1.2`)
assert.Contains(t, got, `sdrsaturation = 0.98`)
}
func TestHyprlandConfigDeployment(t *testing.T) {
@@ -398,6 +364,10 @@ func TestHyprlandConfigDeployment(t *testing.T) {
cd := NewConfigDeployer(logChan)
t.Run("deploy hyprland config to empty directory", func(t *testing.T) {
td, err := os.MkdirTemp("", "dankinstall-hyprland-empty")
require.NoError(t, err)
defer os.RemoveAll(td)
os.Setenv("HOME", td)
result, err := cd.deployHyprlandConfig(deps.TerminalGhostty, true)
require.NoError(t, err)
@@ -408,12 +378,16 @@ func TestHyprlandConfigDeployment(t *testing.T) {
content, err := os.ReadFile(result.Path)
require.NoError(t, err)
assert.Contains(t, string(content), "# MONITOR CONFIG")
assert.Contains(t, string(content), "source = ./dms/binds.conf")
assert.Contains(t, string(content), "exec-once = ")
assert.Contains(t, string(content), `require("dms.binds")`)
assert.Contains(t, string(content), "DMS_STARTUP_BEGIN")
assert.Contains(t, string(content), "hl.config(")
})
t.Run("deploy hyprland config with existing monitors", func(t *testing.T) {
td, err := os.MkdirTemp("", "dankinstall-hyprland-merge")
require.NoError(t, err)
defer os.RemoveAll(td)
os.Setenv("HOME", td)
existingContent := `# My existing Hyprland config
monitor = DP-1, 1920x1080@144, 0x0, 1
monitor = HDMI-A-1, 3840x2160@60, 1920x0, 1.5
@@ -422,11 +396,17 @@ general {
gaps_in = 10
}
`
hyprPath := filepath.Join(tempDir, ".config", "hypr", "hyprland.conf")
err := os.MkdirAll(filepath.Dir(hyprPath), 0o755)
hyprPath := filepath.Join(td, ".config", "hypr", "hyprland.conf")
err = os.MkdirAll(filepath.Dir(hyprPath), 0o755)
require.NoError(t, err)
err = os.WriteFile(hyprPath, []byte(existingContent), 0o644)
require.NoError(t, err)
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, "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))
result, err := cd.deployHyprlandConfig(deps.TerminalKitty, true)
require.NoError(t, err)
@@ -440,13 +420,76 @@ general {
backupContent, err := os.ReadFile(result.BackupPath)
require.NoError(t, err)
assert.Equal(t, existingContent, string(backupContent))
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", "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, "cursor.conf"))
assert.NoFileExists(t, filepath.Join(filepath.Dir(hyprPath), "hyprland.conf.backup.old"))
assert.NoFileExists(t, filepath.Join(dmsDir, "binds.conf.backup.old"))
newContent, err := os.ReadFile(result.Path)
require.NoError(t, err)
assert.Contains(t, string(newContent), "monitor = DP-1, 1920x1080@144")
assert.Contains(t, string(newContent), "monitor = HDMI-A-1, 3840x2160@60")
assert.Contains(t, string(newContent), "source = ./dms/binds.conf")
assert.NotContains(t, string(newContent), "monitor = eDP-2")
assert.Contains(t, string(newContent), `require("dms.binds")`)
outputsPath := filepath.Join(td, ".config", "hypr", "dms", "outputs.lua")
outBytes, err := os.ReadFile(outputsPath)
require.NoError(t, err)
outs := string(outBytes)
assert.Contains(t, outs, `hl.monitor`)
assert.Contains(t, outs, "DP-1")
assert.Contains(t, outs, "HDMI-A-1")
})
t.Run("deploy hyprland config removes root legacy symlink when lua exists", func(t *testing.T) {
td, err := os.MkdirTemp("", "dankinstall-hyprland-lua-conf-symlink")
require.NoError(t, err)
defer os.RemoveAll(td)
os.Setenv("HOME", td)
configDir := filepath.Join(td, ".config", "hypr")
require.NoError(t, os.MkdirAll(configDir, 0o755))
luaPath := filepath.Join(configDir, "hyprland.lua")
confPath := filepath.Join(configDir, "hyprland.conf")
require.NoError(t, os.WriteFile(luaPath, []byte(`require("dms.binds")`+"\n"), 0o644))
require.NoError(t, os.Symlink(filepath.Join(configDir, "missing-legacy.conf"), confPath))
result, err := cd.deployHyprlandConfig(deps.TerminalKitty, true)
require.NoError(t, err)
assert.Equal(t, luaPath, result.Path)
_, err = os.Lstat(confPath)
assert.True(t, os.IsNotExist(err), "root hyprland.conf symlink should be moved out of the live config directory")
_, err = os.Lstat(filepath.Join(filepath.Dir(result.BackupPath), "hyprland.conf"))
assert.NoError(t, err)
})
t.Run("deploy hyprland config refreshes managed binds but preserves user binds", func(t *testing.T) {
td, err := os.MkdirTemp("", "dankinstall-hyprland-refresh-binds")
require.NoError(t, err)
defer os.RemoveAll(td)
os.Setenv("HOME", td)
dmsDir := filepath.Join(td, ".config", "hypr", "dms")
require.NoError(t, os.MkdirAll(dmsDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "binds.lua"), []byte("-- stale managed binds\n"), 0o644))
userBinds := "-- custom user binds\n"
require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "binds-user.lua"), []byte(userBinds), 0o644))
_, err = cd.deployHyprlandConfig(deps.TerminalKitty, true)
require.NoError(t, err)
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 })`)
user, err := os.ReadFile(filepath.Join(dmsDir, "binds-user.lua"))
require.NoError(t, err)
assert.Equal(t, userBinds, string(user))
})
}
@@ -459,10 +502,10 @@ func TestNiriConfigStructure(t *testing.T) {
}
func TestHyprlandConfigStructure(t *testing.T) {
assert.Contains(t, HyprlandConfig, "# MONITOR CONFIG")
assert.Contains(t, HyprlandConfig, "# STARTUP APPS")
assert.Contains(t, HyprlandConfig, "# INPUT CONFIG")
assert.Contains(t, HyprlandConfig, "source = ./dms/binds.conf")
assert.Contains(t, HyprlandLuaConfig, `require("dms.binds")`)
assert.Contains(t, HyprlandLuaConfig, "DMS_STARTUP_BEGIN")
assert.Contains(t, HyprlandLuaConfig, "hl.config(")
assert.Contains(t, HyprlandLuaConfig, "input =")
}
func TestGhosttyConfigStructure(t *testing.T) {
@@ -789,4 +832,37 @@ func TestShouldReplaceConfigDeployIfMissing(t *testing.T) {
}
assert.True(t, foundGhostty, "expected Ghostty config to be deployed when replaceConfigs is true")
})
t.Run("hyprland legacy config exists skips when replace false", func(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dankinstall-hyprland-legacy-skip-test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
originalHome := os.Getenv("HOME")
os.Setenv("HOME", tempDir)
defer os.Setenv("HOME", originalHome)
hyprConf := filepath.Join(tempDir, ".config", "hypr", "hyprland.conf")
require.NoError(t, os.MkdirAll(filepath.Dir(hyprConf), 0o755))
require.NoError(t, os.WriteFile(hyprConf, []byte("monitor = , preferred, auto, 1\n"), 0o644))
logChan := make(chan string, 100)
cd := NewConfigDeployer(logChan)
results, err := cd.deployConfigurationsInternal(
context.Background(),
deps.WindowManagerHyprland,
deps.TerminalGhostty,
nil,
allFalse,
nil,
true,
)
require.NoError(t, err)
for _, r := range results {
if r.ConfigType == "Hyprland" && r.Deployed {
t.Fatalf("expected Hyprland deployment to be skipped when legacy config exists and replace=false")
}
}
})
}
@@ -0,0 +1 @@
-- Optional per-user keybind overrides (managed by DMS). Loaded after default binds.
@@ -1,165 +0,0 @@
# === Application Launchers ===
bind = SUPER, T, exec, {{TERMINAL_COMMAND}}
bind = SUPER, space, exec, dms ipc call spotlight toggle
bind = SUPER, V, exec, dms ipc call clipboard toggle
bind = SUPER, M, exec, dms ipc call processlist focusOrToggle
bind = SUPER, comma, exec, dms ipc call settings focusOrToggle
bind = SUPER, N, exec, dms ipc call notifications toggle
bind = SUPER SHIFT, N, exec, dms ipc call notepad toggle
bind = SUPER, Y, exec, dms ipc call dankdash wallpaper
bind = SUPER, TAB, exec, dms ipc call hypr toggleOverview
bind = SUPER, X, exec, dms ipc call powermenu toggle
# === Cheat sheet
bind = SUPER SHIFT, Slash, exec, dms ipc call keybinds toggle hyprland
# === Security ===
bind = SUPER ALT, L, exec, dms ipc call lock lock
bind = SUPER SHIFT, E, exit
bind = CTRL ALT, Delete, exec, dms ipc call processlist focusOrToggle
# === Audio Controls ===
bindel = , XF86AudioRaiseVolume, exec, dms ipc call audio increment 3
bindel = , XF86AudioLowerVolume, exec, dms ipc call audio decrement 3
bindl = , XF86AudioMute, exec, dms ipc call audio mute
bindl = , XF86AudioMicMute, exec, dms ipc call audio micmute
bindl = , XF86AudioPause, exec, dms ipc call mpris playPause
bindl = , XF86AudioPlay, exec, dms ipc call mpris playPause
bindl = , XF86AudioPrev, exec, dms ipc call mpris previous
bindl = , XF86AudioNext, exec, dms ipc call mpris next
bindel = CTRL, XF86AudioRaiseVolume, exec, dms ipc call mpris increment 3
bindel = CTRL, XF86AudioLowerVolume, exec, dms ipc call mpris decrement 3
# === Brightness Controls ===
bindel = , XF86MonBrightnessUp, exec, dms ipc call brightness increment 5 ""
bindel = , XF86MonBrightnessDown, exec, dms ipc call brightness decrement 5 ""
# === Window Management ===
bind = SUPER, Q, killactive
bind = SUPER, F, fullscreen, 1
bind = SUPER SHIFT, F, fullscreen, 0
bind = SUPER SHIFT, T, togglefloating
bind = SUPER, W, togglegroup
bind = SUPER SHIFT, W, exec, dms ipc call window-rules toggle
# === Focus Navigation ===
bind = SUPER, left, movefocus, l
bind = SUPER, down, movefocus, d
bind = SUPER, up, movefocus, u
bind = SUPER, right, movefocus, r
bind = SUPER, H, movefocus, l
bind = SUPER, J, movefocus, d
bind = SUPER, K, movefocus, u
bind = SUPER, L, movefocus, r
# === Window Movement ===
bind = SUPER SHIFT, left, movewindow, l
bind = SUPER SHIFT, down, movewindow, d
bind = SUPER SHIFT, up, movewindow, u
bind = SUPER SHIFT, right, movewindow, r
bind = SUPER SHIFT, H, movewindow, l
bind = SUPER SHIFT, J, movewindow, d
bind = SUPER SHIFT, K, movewindow, u
bind = SUPER SHIFT, L, movewindow, r
# === Column Navigation ===
bind = SUPER, Home, focuswindow, first
bind = SUPER, End, focuswindow, last
# === Monitor Navigation ===
bind = SUPER CTRL, left, focusmonitor, l
bind = SUPER CTRL, right, focusmonitor, r
bind = SUPER CTRL, H, focusmonitor, l
bind = SUPER CTRL, J, focusmonitor, d
bind = SUPER CTRL, K, focusmonitor, u
bind = SUPER CTRL, L, focusmonitor, r
# === Move to Monitor ===
bind = SUPER SHIFT CTRL, left, movewindow, mon:l
bind = SUPER SHIFT CTRL, down, movewindow, mon:d
bind = SUPER SHIFT CTRL, up, movewindow, mon:u
bind = SUPER SHIFT CTRL, right, movewindow, mon:r
bind = SUPER SHIFT CTRL, H, movewindow, mon:l
bind = SUPER SHIFT CTRL, J, movewindow, mon:d
bind = SUPER SHIFT CTRL, K, movewindow, mon:u
bind = SUPER SHIFT CTRL, L, movewindow, mon:r
# === Workspace Navigation ===
bind = SUPER, Page_Down, workspace, e+1
bind = SUPER, Page_Up, workspace, e-1
bind = SUPER, U, workspace, e+1
bind = SUPER, I, workspace, e-1
bind = SUPER CTRL, down, movetoworkspace, e+1
bind = SUPER CTRL, up, movetoworkspace, e-1
bind = SUPER CTRL, U, movetoworkspace, e+1
bind = SUPER CTRL, I, movetoworkspace, e-1
# === Workspace Management ===
bind = CTRL SHIFT, R, exec, dms ipc call workspace-rename open
# === Move Workspaces ===
bind = SUPER SHIFT, Page_Down, movetoworkspace, e+1
bind = SUPER SHIFT, Page_Up, movetoworkspace, e-1
bind = SUPER SHIFT, U, movetoworkspace, e+1
bind = SUPER SHIFT, I, movetoworkspace, e-1
# === Mouse Wheel Navigation ===
bind = SUPER, mouse_down, workspace, e+1
bind = SUPER, mouse_up, workspace, e-1
bind = SUPER CTRL, mouse_down, movetoworkspace, e+1
bind = SUPER CTRL, mouse_up, movetoworkspace, e-1
# === Numbered Workspaces ===
bind = SUPER, 1, workspace, 1
bind = SUPER, 2, workspace, 2
bind = SUPER, 3, workspace, 3
bind = SUPER, 4, workspace, 4
bind = SUPER, 5, workspace, 5
bind = SUPER, 6, workspace, 6
bind = SUPER, 7, workspace, 7
bind = SUPER, 8, workspace, 8
bind = SUPER, 9, workspace, 9
# === Move to Numbered Workspaces ===
bind = SUPER SHIFT, 1, movetoworkspace, 1
bind = SUPER SHIFT, 2, movetoworkspace, 2
bind = SUPER SHIFT, 3, movetoworkspace, 3
bind = SUPER SHIFT, 4, movetoworkspace, 4
bind = SUPER SHIFT, 5, movetoworkspace, 5
bind = SUPER SHIFT, 6, movetoworkspace, 6
bind = SUPER SHIFT, 7, movetoworkspace, 7
bind = SUPER SHIFT, 8, movetoworkspace, 8
bind = SUPER SHIFT, 9, movetoworkspace, 9
# === Column Management ===
bind = SUPER, bracketleft, layoutmsg, preselect l
bind = SUPER, bracketright, layoutmsg, preselect r
# === Sizing & Layout ===
bind = SUPER, R, layoutmsg, togglesplit
bind = SUPER CTRL, F, resizeactive, exact 100% 100%
# === Move/resize windows with mainMod + LMB/RMB and dragging ===
bindmd = SUPER, mouse:272, Move window, movewindow
bindmd = SUPER, mouse:273, Resize window, resizewindow
# === Move/resize windows with mainMod + LMB/RMB and dragging ===
bindd = SUPER, code:20, Expand window left, resizeactive, -100 0
bindd = SUPER, code:21, Shrink window left, resizeactive, 100 0
# === Manual Sizing ===
binde = SUPER, minus, resizeactive, -10% 0
binde = SUPER, equal, resizeactive, 10% 0
binde = SUPER SHIFT, minus, resizeactive, 0 -10%
binde = SUPER SHIFT, equal, resizeactive, 0 10%
# === Screenshots ===
bind = , Print, exec, dms screenshot
bind = CTRL, Print, exec, dms screenshot full
bind = ALT, Print, exec, dms screenshot window
# === Display Profiles ===
bind = SUPER, P, exec, dms ipc outputs cycleProfile
# === System Controls ===
bind = SUPER SHIFT, P, dpms, toggle
@@ -0,0 +1,167 @@
-- DMS default keybinds (Hyprland 0.55+ Lua)
-- === Application Launchers ===
hl.bind("SUPER + T", hl.dsp.exec_cmd("{{TERMINAL_COMMAND}}"))
hl.bind("SUPER + space", hl.dsp.exec_cmd("dms ipc call spotlight toggle"))
hl.bind("ALT + space", hl.dsp.exec_cmd("dms ipc call spotlight-bar toggle"))
hl.bind("SUPER + V", hl.dsp.exec_cmd("dms ipc call clipboard toggle"))
hl.bind("SUPER + M", hl.dsp.exec_cmd("dms ipc call processlist focusOrToggle"))
hl.bind("SUPER + comma", hl.dsp.exec_cmd("dms ipc call settings focusOrToggle"))
hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notifications toggle"))
hl.bind("SUPER + SHIFT + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"))
hl.bind("SUPER + Y", hl.dsp.exec_cmd("dms ipc call dankdash wallpaper"))
hl.bind("SUPER + TAB", hl.dsp.exec_cmd("dms ipc call hypr toggleOverview"))
hl.bind("SUPER + X", hl.dsp.exec_cmd("dms ipc call powermenu toggle"))
-- === Cheat sheet
hl.bind("SUPER + SHIFT + Slash", hl.dsp.exec_cmd("dms ipc call keybinds toggle hyprland"))
-- === Security ===
hl.bind("SUPER + ALT + L", hl.dsp.exec_cmd("dms ipc call lock lock"))
hl.bind("SUPER + SHIFT + E", hl.dsp.exit())
hl.bind("CTRL + ALT + Delete", hl.dsp.exec_cmd("dms ipc call processlist focusOrToggle"))
-- === Audio Controls ===
hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("dms ipc call audio increment 3"), { locked = true, repeating = true })
hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("dms ipc call audio decrement 3"), { locked = true, repeating = true })
hl.bind("XF86AudioMute", hl.dsp.exec_cmd("dms ipc call audio mute"), { locked = true })
hl.bind("XF86AudioMicMute", hl.dsp.exec_cmd("dms ipc call audio micmute"), { locked = true })
hl.bind("XF86AudioPause", hl.dsp.exec_cmd("dms ipc call mpris playPause"), { locked = true })
hl.bind("XF86AudioPlay", hl.dsp.exec_cmd("dms ipc call mpris playPause"), { locked = true })
hl.bind("XF86AudioPrev", hl.dsp.exec_cmd("dms ipc call mpris previous"), { locked = true })
hl.bind("XF86AudioNext", hl.dsp.exec_cmd("dms ipc call mpris next"), { locked = true })
hl.bind("CTRL + XF86AudioRaiseVolume", hl.dsp.exec_cmd("dms ipc call mpris increment 3"), { locked = true, repeating = true })
hl.bind("CTRL + XF86AudioLowerVolume", hl.dsp.exec_cmd("dms ipc call mpris decrement 3"), { locked = true, repeating = true })
-- === Brightness Controls ===
hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd([[dms ipc call brightness increment 5 ""]]), { locked = true, repeating = true })
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 + 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" }))
hl.bind("SUPER + W", hl.dsp.group.toggle())
hl.bind("SUPER + SHIFT + W", hl.dsp.exec_cmd("dms ipc call window-rules toggle"))
-- === Focus Navigation ===
hl.bind("SUPER + left", hl.dsp.focus({ direction = "l" }))
hl.bind("SUPER + down", hl.dsp.focus({ direction = "d" }))
hl.bind("SUPER + up", hl.dsp.focus({ direction = "u" }))
hl.bind("SUPER + right", hl.dsp.focus({ direction = "r" }))
hl.bind("SUPER + H", hl.dsp.focus({ direction = "l" }))
hl.bind("SUPER + J", hl.dsp.focus({ direction = "d" }))
hl.bind("SUPER + K", hl.dsp.focus({ direction = "u" }))
hl.bind("SUPER + L", hl.dsp.focus({ direction = "r" }))
-- === Window Movement ===
hl.bind("SUPER + SHIFT + left", hl.dsp.window.move({ direction = "l" }))
hl.bind("SUPER + SHIFT + down", hl.dsp.window.move({ direction = "d" }))
hl.bind("SUPER + SHIFT + up", hl.dsp.window.move({ direction = "u" }))
hl.bind("SUPER + SHIFT + right", hl.dsp.window.move({ direction = "r" }))
hl.bind("SUPER + SHIFT + H", hl.dsp.window.move({ direction = "l" }))
hl.bind("SUPER + SHIFT + J", hl.dsp.window.move({ direction = "d" }))
hl.bind("SUPER + SHIFT + K", hl.dsp.window.move({ direction = "u" }))
hl.bind("SUPER + SHIFT + L", hl.dsp.window.move({ direction = "r" }))
-- === Column Navigation ===
hl.bind("SUPER + Home", hl.dsp.focus({ window = "first" }))
hl.bind("SUPER + End", hl.dsp.focus({ window = "last" }))
-- === Monitor Navigation ===
hl.bind("SUPER + CTRL + left", hl.dsp.focus({ monitor = "l" }))
hl.bind("SUPER + CTRL + right", hl.dsp.focus({ monitor = "r" }))
hl.bind("SUPER + CTRL + H", hl.dsp.focus({ monitor = "l" }))
hl.bind("SUPER + CTRL + J", hl.dsp.focus({ monitor = "d" }))
hl.bind("SUPER + CTRL + K", hl.dsp.focus({ monitor = "u" }))
hl.bind("SUPER + CTRL + L", hl.dsp.focus({ monitor = "r" }))
-- === Move to Monitor ===
hl.bind("SUPER + SHIFT + CTRL + left", hl.dsp.window.move({ monitor = "l" }))
hl.bind("SUPER + SHIFT + CTRL + down", hl.dsp.window.move({ monitor = "d" }))
hl.bind("SUPER + SHIFT + CTRL + up", hl.dsp.window.move({ monitor = "u" }))
hl.bind("SUPER + SHIFT + CTRL + right", hl.dsp.window.move({ monitor = "r" }))
hl.bind("SUPER + SHIFT + CTRL + H", hl.dsp.window.move({ monitor = "l" }))
hl.bind("SUPER + SHIFT + CTRL + J", hl.dsp.window.move({ monitor = "d" }))
hl.bind("SUPER + SHIFT + CTRL + K", hl.dsp.window.move({ monitor = "u" }))
hl.bind("SUPER + SHIFT + CTRL + L", hl.dsp.window.move({ monitor = "r" }))
-- === Workspace Navigation ===
hl.bind("SUPER + Page_Down", hl.dsp.focus({ workspace = "e+1" }))
hl.bind("SUPER + Page_Up", hl.dsp.focus({ workspace = "e-1" }))
hl.bind("SUPER + U", hl.dsp.focus({ workspace = "e+1" }))
hl.bind("SUPER + I", hl.dsp.focus({ workspace = "e-1" }))
hl.bind("SUPER + CTRL + down", hl.dsp.window.move({ workspace = "e+1" }))
hl.bind("SUPER + CTRL + up", hl.dsp.window.move({ workspace = "e-1" }))
hl.bind("SUPER + CTRL + U", hl.dsp.window.move({ workspace = "e+1" }))
hl.bind("SUPER + CTRL + I", hl.dsp.window.move({ workspace = "e-1" }))
-- === Workspace Management ===
hl.bind("CTRL + SHIFT + R", hl.dsp.exec_cmd("dms ipc call workspace-rename open"))
-- === Move Workspaces ===
hl.bind("SUPER + SHIFT + Page_Down", hl.dsp.window.move({ workspace = "e+1" }))
hl.bind("SUPER + SHIFT + Page_Up", hl.dsp.window.move({ workspace = "e-1" }))
hl.bind("SUPER + SHIFT + U", hl.dsp.window.move({ workspace = "e+1" }))
hl.bind("SUPER + SHIFT + I", hl.dsp.window.move({ workspace = "e-1" }))
-- === Mouse Wheel Navigation ===
hl.bind("SUPER + mouse_down", hl.dsp.focus({ workspace = "e+1" }))
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" }))
-- === Numbered Workspaces ===
hl.bind("SUPER + 1", hl.dsp.focus({ workspace = "1" }))
hl.bind("SUPER + 2", hl.dsp.focus({ workspace = "2" }))
hl.bind("SUPER + 3", hl.dsp.focus({ workspace = "3" }))
hl.bind("SUPER + 4", hl.dsp.focus({ workspace = "4" }))
hl.bind("SUPER + 5", hl.dsp.focus({ workspace = "5" }))
hl.bind("SUPER + 6", hl.dsp.focus({ workspace = "6" }))
hl.bind("SUPER + 7", hl.dsp.focus({ workspace = "7" }))
hl.bind("SUPER + 8", hl.dsp.focus({ workspace = "8" }))
hl.bind("SUPER + 9", hl.dsp.focus({ workspace = "9" }))
-- === Move to Numbered Workspaces ===
hl.bind("SUPER + SHIFT + 1", hl.dsp.window.move({ workspace = "1" }))
hl.bind("SUPER + SHIFT + 2", hl.dsp.window.move({ workspace = "2" }))
hl.bind("SUPER + SHIFT + 3", hl.dsp.window.move({ workspace = "3" }))
hl.bind("SUPER + SHIFT + 4", hl.dsp.window.move({ workspace = "4" }))
hl.bind("SUPER + SHIFT + 5", hl.dsp.window.move({ workspace = "5" }))
hl.bind("SUPER + SHIFT + 6", hl.dsp.window.move({ workspace = "6" }))
hl.bind("SUPER + SHIFT + 7", hl.dsp.window.move({ workspace = "7" }))
hl.bind("SUPER + SHIFT + 8", hl.dsp.window.move({ workspace = "8" }))
hl.bind("SUPER + SHIFT + 9", hl.dsp.window.move({ workspace = "9" }))
-- === Column Management ===
hl.bind("SUPER + bracketleft", hl.dsp.layout("preselect l"))
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%]]))
-- === Move/resize windows with mainMod + LMB/RMB and dragging ===
hl.bind("SUPER + mouse:272", hl.dsp.window.drag(), { mouse = true, description = "Move window" })
hl.bind("SUPER + mouse:273", hl.dsp.window.resize(), { mouse = true, description = "Resize window" })
hl.bind("SUPER + code:20", hl.dsp.window.resize({ x = -100, y = 0, relative = true }), { description = "Expand window left" })
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 })
-- === Screenshots ===
hl.bind("Print", hl.dsp.exec_cmd("dms screenshot"))
hl.bind("CTRL + Print", hl.dsp.exec_cmd("dms screenshot full"))
hl.bind("ALT + Print", hl.dsp.exec_cmd("dms screenshot window"))
-- === Display Profiles ===
hl.bind("SUPER + P", hl.dsp.exec_cmd("dms ipc outputs cycleProfile"))
-- === System Controls ===
hl.bind("SUPER + SHIFT + P", hl.dsp.dpms({ action = "toggle" }))
@@ -1,25 +0,0 @@
# ! Auto-generated file. Do not edit directly.
# Remove source = ./dms/colors.conf from your config to override.
$primary = rgb(d0bcff)
$outline = rgb(948f99)
$error = rgb(f2b8b5)
general {
col.active_border = $primary
col.inactive_border = $outline
}
group {
col.border_active = $primary
col.border_inactive = $outline
col.border_locked_active = $error
col.border_locked_inactive = $outline
groupbar {
col.active = $primary
col.inactive = $outline
col.locked_active = $error
col.locked_inactive = $outline
}
}
@@ -0,0 +1,27 @@
-- ! Auto-generated file. Do not edit directly.
-- Regenerate via DMS theme tools or remove require("dms.colors") from hyprland.lua to override.
hl.config({
general = {
col = {
active_border = "rgb(d0bcff)",
inactive_border = "rgb(948f99)",
},
},
group = {
col = {
border_active = "rgb(d0bcff)",
border_inactive = "rgb(948f99)",
border_locked_active = "rgb(f2b8b5)",
border_locked_inactive = "rgb(948f99)",
},
groupbar = {
col = {
active = "rgb(d0bcff)",
inactive = "rgb(948f99)",
locked_active = "rgb(f2b8b5)",
locked_inactive = "rgb(948f99)",
},
},
},
})
@@ -0,0 +1 @@
-- Cursor theme overrides. Deploy writes ~/.config/hypr/dms/cursor.lua
@@ -1,11 +0,0 @@
# Auto-generated by DMS - do not edit manually
general {
gaps_in = 4
gaps_out = 4
border_size = 2
}
decoration {
rounding = 12
}
@@ -0,0 +1,12 @@
-- Auto-generated by DMS — do not edit manually
hl.config({
general = {
gaps_in = 4,
gaps_out = 4,
border_size = 2,
},
decoration = {
rounding = 12,
},
})
@@ -0,0 +1,3 @@
-- Per-output monitor rules — embedded sibling of the legacy outputs.conf fragment. Deploy writes ~/.config/hypr/dms/outputs.lua
hl.monitor({ output = "", mode = "preferred", position = "auto", scale = "auto" })
@@ -0,0 +1 @@
-- Window rules. Deploy writes ~/.config/hypr/dms/windowrules.lua
-117
View File
@@ -1,117 +0,0 @@
# Hyprland Configuration
# https://wiki.hypr.land/Configuring/
# ==================
# MONITOR CONFIG
# ==================
# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1
monitor = , preferred,auto,auto
# ==================
# STARTUP APPS
# ==================
exec-once = dbus-update-activation-environment --systemd --all
exec-once = systemctl --user start hyprland-session.target
# ==================
# INPUT CONFIG
# ==================
input {
kb_layout = us
numlock_by_default = true
}
# ==================
# GENERAL LAYOUT
# ==================
general {
gaps_in = 5
gaps_out = 5
border_size = 2
layout = dwindle
}
# ==================
# DECORATION
# ==================
decoration {
rounding = 12
active_opacity = 1.0
inactive_opacity = 1.0
shadow {
enabled = true
range = 30
render_power = 5
offset = 0 5
color = rgba(00000070)
}
}
# ==================
# ANIMATIONS
# ==================
animations {
enabled = true
animation = windowsIn, 1, 3, default
animation = windowsOut, 1, 3, default
animation = workspaces, 1, 5, default
animation = windowsMove, 1, 4, default
animation = fade, 1, 3, default
animation = border, 1, 3, default
}
# ==================
# LAYOUTS
# ==================
dwindle {
preserve_split = true
}
master {
mfact = 0.5
}
# ==================
# MISC
# ==================
misc {
disable_hyprland_logo = true
disable_splash_rendering = true
}
# ==================
# WINDOW RULES
# ==================
windowrule = tile on, match:class ^(org\.wezfurlong\.wezterm)$
windowrule = rounding 12, match:class ^(org\.gnome\.)
windowrule = tile on, match:class ^(gnome-control-center)$
windowrule = tile on, match:class ^(pavucontrol)$
windowrule = tile on, match:class ^(nm-connection-editor)$
windowrule = float on, match:class ^(org\.gnome\.Calculator)$
windowrule = float on, match:class ^(gnome-calculator)$
windowrule = float on, match:class ^(galculator)$
windowrule = float on, match:class ^(blueman-manager)$
windowrule = float on, match:class ^(org\.gnome\.Nautilus)$
windowrule = float on, match:class ^(xdg-desktop-portal)$
windowrule = no_initial_focus on, match:class ^(steam)$, match:title ^(notificationtoasts)
windowrule = pin on, match:class ^(steam)$, match:title ^(notificationtoasts)
windowrule = float on, match:class ^(firefox)$, match:title ^(Picture-in-Picture)$
windowrule = float on, match:class ^(zoom)$
layerrule = no_anim on, match:namespace ^(quickshell)$
layerrule = no_anim on, match:namespace ^dms:.*
source = ./dms/colors.conf
source = ./dms/outputs.conf
source = ./dms/layout.conf
source = ./dms/cursor.conf
source = ./dms/binds.conf
@@ -0,0 +1,84 @@
-- Hyprland configuration (Lua) — https://wiki.hypr.land/Configuring/Start/
hl.config({ autogenerated = false })
-- DMS_STARTUP_BEGIN
hl.on("hyprland.start", function()
hl.exec_cmd("dbus-update-activation-environment --systemd --all")
hl.exec_cmd("systemctl --user start hyprland-session.target")
end)
-- DMS_STARTUP_END
hl.config({
input = {
kb_layout = "us",
numlock_by_default = true,
},
general = {
gaps_in = 5,
gaps_out = 5,
border_size = 2,
layout = "dwindle",
},
decoration = {
rounding = 12,
active_opacity = 1.0,
inactive_opacity = 1.0,
shadow = {
enabled = true,
range = 30,
render_power = 5,
offset = "0 5",
color = "rgba(00000070)",
},
},
misc = {
disable_hyprland_logo = true,
disable_splash_rendering = true,
},
dwindle = {
preserve_split = true,
},
master = {
mfact = 0.5,
},
})
hl.animation({ leaf = "windowsIn", enabled = true, speed = 3, bezier = "default" })
hl.animation({ leaf = "windowsOut", enabled = true, speed = 3, bezier = "default" })
hl.animation({ leaf = "workspaces", enabled = true, speed = 5, bezier = "default" })
hl.animation({ leaf = "windowsMove", enabled = true, speed = 4, bezier = "default" })
hl.animation({ leaf = "fade", enabled = true, speed = 3, bezier = "default" })
hl.animation({ leaf = "border", enabled = true, speed = 3, bezier = "default" })
hl.window_rule({ match = { class = "^(org\\.wezfurlong\\.wezterm)$" }, tile = true })
hl.window_rule({ match = { class = "^(org\\.gnome\\.)" }, rounding = 12 })
hl.window_rule({ match = { class = "^(gnome-control-center)$" }, tile = true })
hl.window_rule({ match = { class = "^(pavucontrol)$" }, tile = true })
hl.window_rule({ match = { class = "^(nm-connection-editor)$" }, tile = true })
hl.window_rule({ match = { class = "^(org\\.gnome\\.Calculator)$" }, float = true })
hl.window_rule({ match = { class = "^(gnome-calculator)$" }, float = true })
hl.window_rule({ match = { class = "^(galculator)$" }, float = true })
hl.window_rule({ match = { class = "^(blueman-manager)$" }, float = true })
hl.window_rule({ match = { class = "^(org\\.gnome\\.Nautilus)$" }, float = true })
hl.window_rule({ match = { class = "^(xdg-desktop-portal)$" }, float = true })
hl.window_rule({
match = { class = "^(steam)$", title = "^(notificationtoasts)" },
no_initial_focus = true,
pin = true,
})
hl.window_rule({
match = { class = "^(firefox)$", title = "^(Picture-in-Picture)$" },
float = true,
})
hl.window_rule({ match = { class = "^(zoom)$" }, float = true })
hl.layer_rule({ match = { namespace = "^(quickshell)$" }, no_anim = true })
hl.layer_rule({ match = { namespace = "^dms:.*" }, no_anim = true })
require("dms.colors")
require("dms.outputs")
require("dms.layout")
require("dms.cursor")
require("dms.binds")
require("dms.binds-user")
require("dms.windowrules")
@@ -9,6 +9,9 @@ binds {
Mod+Space hotkey-overlay-title="Application Launcher" {
spawn "dms" "ipc" "call" "spotlight" "toggle";
}
Alt+Space hotkey-overlay-title="Spotlight Bar" {
spawn "dms" "ipc" "call" "spotlight-bar" "toggle";
}
Mod+V hotkey-overlay-title="Clipboard Manager" {
spawn "dms" "ipc" "call" "clipboard" "toggle";
}
+20 -8
View File
@@ -2,14 +2,26 @@ package config
import _ "embed"
//go:embed embedded/hyprland.conf
var HyprlandConfig string
//go:embed embedded/hyprland.lua
var HyprlandLuaConfig string
//go:embed embedded/hypr-colors.conf
var HyprColorsConfig string
//go:embed embedded/hypr-colors.lua
var DMSColorsLuaConfig string
//go:embed embedded/hypr-layout.conf
var HyprLayoutConfig string
//go:embed embedded/hypr-layout.lua
var DMSLayoutLuaConfig string
//go:embed embedded/hypr-binds.conf
var HyprBindsConfig string
//go:embed embedded/hypr-binds.lua
var DMSBindsLuaConfig string
//go:embed embedded/hypr-outputs.lua
var DMSOutputsLuaConfig string
//go:embed embedded/hypr-cursor.lua
var DMSCursorLuaConfig string
//go:embed embedded/hypr-windowrules.lua
var DMSWindowRulesLuaConfig string
//go:embed embedded/hypr-binds-user.lua
var DMSBindsUserLuaConfig string
+174
View File
@@ -0,0 +1,174 @@
package config
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
)
const (
hyprlandStartupBegin = "-- DMS_STARTUP_BEGIN"
hyprlandStartupEnd = "-- DMS_STARTUP_END"
)
func extractHyprlangMonitorLines(hyprlang string) []string {
re := regexp.MustCompile(`(?m)^\s*#?\s*monitor\s*=.*$`)
return re.FindAllString(hyprlang, -1)
}
func hyprlangMonitorLineToLua(line string) (string, error) {
re := regexp.MustCompile(`(?i)^\s*#?\s*monitor\s*=\s*(.*)\s*$`)
m := re.FindStringSubmatch(line)
if m == nil {
return "", fmt.Errorf("not a monitor line")
}
rest := strings.TrimSpace(m[1])
parts := strings.Split(rest, ",")
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
if len(parts) < 4 {
if len(parts) == 2 && strings.EqualFold(parts[1], "disable") {
return fmt.Sprintf(`hl.monitor({ output = %s, disabled = true })`, strconv.Quote(parts[0])), nil
}
return "", fmt.Errorf("expected at least 4 comma-separated fields")
}
out := parts[0]
mode := parts[1]
pos := parts[2]
scaleStr := parts[3]
scaleField := formatMonitorScaleLua(scaleStr)
fields := []string{
fmt.Sprintf("output = %s", strconv.Quote(out)),
fmt.Sprintf("mode = %s", strconv.Quote(mode)),
fmt.Sprintf("position = %s", strconv.Quote(pos)),
scaleField,
}
for i := 4; i < len(parts); i += 2 {
key := strings.ToLower(strings.TrimSpace(parts[i]))
if key == "" {
continue
}
if i+1 >= len(parts) {
fields = append(fields, fmt.Sprintf("%s = true", hyprlangMonitorOptionToLuaKey(key)))
continue
}
val := strings.TrimSpace(parts[i+1])
if converted, ok := formatMonitorOptionLua(key, val); ok {
fields = append(fields, converted)
}
}
return fmt.Sprintf(`hl.monitor({ %s })`, strings.Join(fields, ", ")), nil
}
func formatMonitorScaleLua(scaleStr string) string {
if scaleStr == "auto" {
return `scale = "auto"`
}
if f, err := strconv.ParseFloat(scaleStr, 64); err == nil {
return fmt.Sprintf(`scale = %g`, f)
}
return fmt.Sprintf(`scale = %s`, strconv.Quote(scaleStr))
}
func hyprlangMonitorOptionToLuaKey(key string) string {
switch strings.ToLower(strings.TrimSpace(key)) {
case "10bit":
return "bitdepth"
default:
return strings.ReplaceAll(strings.ToLower(strings.TrimSpace(key)), "-", "_")
}
}
func formatMonitorOptionLua(key, val string) (string, bool) {
luaKey := hyprlangMonitorOptionToLuaKey(key)
switch luaKey {
case "transform", "vrr", "bitdepth", "supports_wide_color", "supports_hdr", "sdr_max_luminance", "max_luminance", "max_avg_luminance":
if _, err := strconv.Atoi(val); err == nil {
return fmt.Sprintf("%s = %s", luaKey, val), true
}
case "sdrbrightness", "sdrsaturation", "sdr_min_luminance", "min_luminance":
if _, err := strconv.ParseFloat(val, 64); err == nil {
return fmt.Sprintf("%s = %s", luaKey, val), true
}
case "cm", "sdr_eotf", "icc", "mirror":
return fmt.Sprintf("%s = %s", luaKey, strconv.Quote(val)), true
}
return "", false
}
func transformHyprlandLuaForNonSystemd(config, terminalCommand string) string {
start := strings.Index(config, hyprlandStartupBegin)
end := strings.Index(config, hyprlandStartupEnd)
if start == -1 || end == -1 || end <= start {
return config
}
endClose := end + len(hyprlandStartupEnd)
replacement := hyprlandStartupBegin + "\n" +
`hl.env("QT_QPA_PLATFORM", "wayland;xcb")` + "\n" +
`hl.env("ELECTRON_OZONE_PLATFORM_HINT", "auto")` + "\n" +
`hl.env("QT_QPA_PLATFORMTHEME", "gtk3")` + "\n" +
`hl.env("QT_QPA_PLATFORMTHEME_QT6", "gtk3")` + "\n" +
fmt.Sprintf(`hl.env("TERMINAL", %s)`, strconv.Quote(terminalCommand)) + "\n\n" +
`hl.on("hyprland.start", function()` + "\n" +
` hl.exec_cmd("dms run")` + "\n" +
`end)` + "\n" +
hyprlandStartupEnd
return config[:start] + replacement + config[endClose:]
}
func readExistingHyprlandConfig(configDir string) (data string, sourcePath string, err error) {
luaPath := filepath.Join(configDir, "hyprland.lua")
if b, e := os.ReadFile(luaPath); e == nil {
return string(b), luaPath, nil
} else if !os.IsNotExist(e) {
return "", "", e
}
confPath := filepath.Join(configDir, "hyprland.conf")
if b, e := os.ReadFile(confPath); e == nil {
return string(b), confPath, nil
} else if !os.IsNotExist(e) {
return "", "", e
}
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.
func CleanupStrayHyprlandConfFile(logFn func(format string, v ...any)) {
if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") == "" {
return
}
home := os.Getenv("HOME")
if home == "" {
return
}
configDir := filepath.Join(home, ".config", "hypr")
luaPath := filepath.Join(configDir, "hyprland.lua")
if _, err := os.Stat(luaPath); err != nil {
return
}
confPath := filepath.Join(configDir, "hyprland.conf")
if _, err := os.Stat(confPath); err != nil {
return
}
ts := time.Now().Format("2006-01-02_15-04-05")
dst := filepath.Join(configDir, hyprlandBackupDirName, ts, "hyprland.conf")
if err := moveHyprlandConfigFile(confPath, dst); err != nil {
if logFn != nil {
logFn("Could not move stray hyprland.conf: %v", err)
}
return
}
if logFn != nil {
logFn("Moved stray hyprland.conf to %s", dst)
}
}
+214 -175
View File
@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
@@ -48,7 +49,7 @@ func (h *HyprlandProvider) GetCheatSheet() (*keybinds.CheatSheet, error) {
h.parsed = true
categorizedBinds := make(map[string][]keybinds.Keybind)
h.convertSection(result.Section, "", categorizedBinds, result.ConflictingConfigs)
h.convertSection(result.Section, "", categorizedBinds, result.ConflictingConfigs, result.DefaultDMSKeys)
sheet := &keybinds.CheatSheet{
Title: "Hyprland Keybinds",
@@ -88,7 +89,7 @@ func (h *HyprlandProvider) HasDMSBindsIncluded() bool {
return h.dmsBindsIncluded
}
func (h *HyprlandProvider) convertSection(section *HyprlandSection, subcategory string, categorizedBinds map[string][]keybinds.Keybind, conflicts map[string]*HyprlandKeyBinding) {
func (h *HyprlandProvider) convertSection(section *HyprlandSection, subcategory string, categorizedBinds map[string][]keybinds.Keybind, conflicts map[string]*HyprlandKeyBinding, defaultKeys map[string]bool) {
currentSubcat := subcategory
if section.Name != "" {
currentSubcat = section.Name
@@ -96,12 +97,12 @@ func (h *HyprlandProvider) convertSection(section *HyprlandSection, subcategory
for _, kb := range section.Keybinds {
category := h.categorizeByDispatcher(kb.Dispatcher)
bind := h.convertKeybind(&kb, currentSubcat, conflicts)
bind := h.convertKeybind(&kb, currentSubcat, conflicts, defaultKeys)
categorizedBinds[category] = append(categorizedBinds[category], bind)
}
for _, child := range section.Children {
h.convertSection(&child, currentSubcat, categorizedBinds, conflicts)
h.convertSection(&child, currentSubcat, categorizedBinds, conflicts, defaultKeys)
}
}
@@ -133,7 +134,7 @@ func (h *HyprlandProvider) categorizeByDispatcher(dispatcher string) string {
}
}
func (h *HyprlandProvider) convertKeybind(kb *HyprlandKeyBinding, subcategory string, conflicts map[string]*HyprlandKeyBinding) keybinds.Keybind {
func (h *HyprlandProvider) convertKeybind(kb *HyprlandKeyBinding, subcategory string, conflicts map[string]*HyprlandKeyBinding, defaultKeys map[string]bool) keybinds.Keybind {
keyStr := h.formatKey(kb)
rawAction := h.formatRawAction(kb.Dispatcher, kb.Params)
desc := kb.Comment
@@ -143,8 +144,15 @@ func (h *HyprlandProvider) convertKeybind(kb *HyprlandKeyBinding, subcategory st
}
source := "config"
if strings.Contains(kb.Source, "dms/binds.conf") {
if isDMSBindsUserOverridePath(kb.Source) {
source = "dms"
} else if isDMSBindsPrimarySourcePath(kb.Source) {
source = "dms-default"
}
hasDefault := false
if source == "dms" && defaultKeys != nil {
hasDefault = defaultKeys[strings.ToLower(keyStr)]
}
bind := keybinds.Keybind{
@@ -154,9 +162,10 @@ func (h *HyprlandProvider) convertKeybind(kb *HyprlandKeyBinding, subcategory st
Subcategory: subcategory,
Source: source,
Flags: kb.Flags,
HasDefault: hasDefault,
}
if source == "dms" && conflicts != nil {
if (source == "dms" || source == "dms-default") && conflicts != nil {
normalizedKey := strings.ToLower(keyStr)
if conflictKb, ok := conflicts[normalizedKey]; ok {
bind.Conflict = &keybinds.Keybind{
@@ -188,9 +197,9 @@ func (h *HyprlandProvider) formatKey(kb *HyprlandKeyBinding) string {
func (h *HyprlandProvider) GetOverridePath() string {
expanded, err := utils.ExpandPath(h.configPath)
if err != nil {
return filepath.Join(h.configPath, "dms", "binds.conf")
return filepath.Join(h.configPath, "dms", "binds-user.lua")
}
return filepath.Join(expanded, "dms", "binds.conf")
return filepath.Join(expanded, "dms", "binds-user.lua")
}
func (h *HyprlandProvider) validateAction(action string) error {
@@ -250,7 +259,16 @@ func (h *HyprlandProvider) RemoveBind(key string) error {
if err != nil {
return nil
}
normalizedKey := strings.ToLower(key)
existingBinds[normalizedKey] = &hyprlandOverrideBind{Key: key, Unbind: true}
return h.writeOverrideBinds(existingBinds)
}
func (h *HyprlandProvider) ResetBind(key string) error {
existingBinds, err := h.loadOverrideBinds()
if err != nil {
return nil
}
normalizedKey := strings.ToLower(key)
delete(existingBinds, normalizedKey)
return h.writeOverrideBinds(existingBinds)
@@ -262,116 +280,12 @@ type hyprlandOverrideBind struct {
Description string
Flags string // Bind flags: l=locked, r=release, e=repeat, n=non-consuming, m=mouse, t=transparent, i=ignore-mods, s=separate, d=description, o=long-press
Options map[string]any
// Unbind: negative override (hl.unbind only, no rebind).
Unbind bool
}
func (h *HyprlandProvider) loadOverrideBinds() (map[string]*hyprlandOverrideBind, error) {
overridePath := h.GetOverridePath()
binds := make(map[string]*hyprlandOverrideBind)
data, err := os.ReadFile(overridePath)
if os.IsNotExist(err) {
return binds, nil
}
if err != nil {
return nil, err
}
lines := strings.Split(string(data), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if !strings.HasPrefix(line, "bind") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) < 2 {
continue
}
// Extract flags from bind type
bindType := strings.TrimSpace(parts[0])
flags := extractBindFlags(bindType)
hasDescFlag := strings.Contains(flags, "d")
content := strings.TrimSpace(parts[1])
commentParts := strings.SplitN(content, "#", 2)
bindContent := strings.TrimSpace(commentParts[0])
var comment string
if len(commentParts) > 1 {
comment = strings.TrimSpace(commentParts[1])
}
// For bindd, format is: mods, key, description, dispatcher, params
var minFields, descIndex, dispatcherIndex int
if hasDescFlag {
minFields = 4
descIndex = 2
dispatcherIndex = 3
} else {
minFields = 3
dispatcherIndex = 2
}
fields := strings.SplitN(bindContent, ",", minFields+2)
if len(fields) < minFields {
continue
}
mods := strings.TrimSpace(fields[0])
keyName := strings.TrimSpace(fields[1])
var dispatcher, params string
if hasDescFlag {
if comment == "" {
comment = strings.TrimSpace(fields[descIndex])
}
dispatcher = strings.TrimSpace(fields[dispatcherIndex])
if len(fields) > dispatcherIndex+1 {
paramParts := fields[dispatcherIndex+1:]
params = strings.TrimSpace(strings.Join(paramParts, ","))
}
} else {
dispatcher = strings.TrimSpace(fields[dispatcherIndex])
if len(fields) > dispatcherIndex+1 {
paramParts := fields[dispatcherIndex+1:]
params = strings.TrimSpace(strings.Join(paramParts, ","))
}
}
keyStr := h.buildKeyString(mods, keyName)
normalizedKey := strings.ToLower(keyStr)
action := dispatcher
if params != "" {
action = dispatcher + " " + params
}
binds[normalizedKey] = &hyprlandOverrideBind{
Key: keyStr,
Action: action,
Description: comment,
Flags: flags,
}
}
return binds, nil
}
func (h *HyprlandProvider) buildKeyString(mods, key string) string {
if mods == "" {
return key
}
modList := strings.FieldsFunc(mods, func(r rune) bool {
return r == '+' || r == ' '
})
parts := append(modList, key)
return strings.Join(parts, "+")
return readLuaOrHyprlangOverride(h.GetOverridePath())
}
func (h *HyprlandProvider) getBindSortPriority(action string) int {
@@ -420,78 +334,203 @@ func (h *HyprlandProvider) generateBindsContent(binds map[string]*hyprlandOverri
})
var sb strings.Builder
sb.WriteString("-- DMS user keybind overrides (edit via Control Center or dms; do not remove this header)\n\n")
for _, bind := range bindList {
h.writeBindLine(&sb, bind)
writeLuaBindLine(&sb, bind)
}
return sb.String()
}
func (h *HyprlandProvider) writeBindLine(sb *strings.Builder, bind *hyprlandOverrideBind) {
mods, key := h.parseKeyString(bind.Key)
dispatcher, params := h.parseAction(bind.Action)
// Write bind type with flags (e.g., "bind", "binde", "bindel")
sb.WriteString("bind")
if bind.Flags != "" {
sb.WriteString(bind.Flags)
func formatLuaBindKey(internalKey string) string {
internalKey = strings.TrimSpace(internalKey)
parts := strings.Split(internalKey, "+")
for i := range parts {
parts[i] = normalizeLuaBindKeyPart(strings.TrimSpace(parts[i]))
}
sb.WriteString(" = ")
sb.WriteString(mods)
sb.WriteString(", ")
sb.WriteString(key)
sb.WriteString(", ")
// For bindd (description flag), include description before dispatcher
if strings.Contains(bind.Flags, "d") && bind.Description != "" {
sb.WriteString(bind.Description)
sb.WriteString(", ")
}
sb.WriteString(dispatcher)
if params != "" {
sb.WriteString(", ")
sb.WriteString(params)
}
// Only add comment if not using bindd (which has inline description)
if bind.Description != "" && !strings.Contains(bind.Flags, "d") {
sb.WriteString(" # ")
sb.WriteString(bind.Description)
}
sb.WriteString("\n")
return strings.Join(parts, " + ")
}
func (h *HyprlandProvider) parseKeyString(keyStr string) (mods, key string) {
parts := strings.Split(keyStr, "+")
switch len(parts) {
case 0:
return "", keyStr
case 1:
return "", parts[0]
func normalizeLuaBindKeyPart(part string) string {
switch strings.ToLower(part) {
case "super", "mod4", "mainmod":
return "SUPER"
case "ctrl", "control":
return "CTRL"
case "shift":
return "SHIFT"
case "alt", "mod1":
return "ALT"
}
if len(part) == 1 {
return strings.ToUpper(part)
}
return part
}
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 strings.Join(parts[:len(parts)-1], " "), parts[len(parts)-1]
return fmt.Sprintf(`hl.dsp.exec_cmd(%s)`, strconv.Quote("hyprctl dispatch "+action))
}
}
func (h *HyprlandProvider) parseAction(action string) (dispatcher, params string) {
parts := strings.SplitN(action, " ", 2)
switch len(parts) {
case 0:
return action, ""
case 1:
dispatcher = parts[0]
default:
dispatcher = parts[0]
params = parts[1]
func luaExprToInternalAction(expr string) string {
d, p := luaExprToDispatcherParams(expr)
if d == "exec" && p != "" && !strings.HasPrefix(p, "hyprctl dispatch lua:") {
return "exec " + p
}
// Convert internal spawn format to Hyprland's exec
if dispatcher == "spawn" {
dispatcher = "exec"
if p != "" {
return d + " " + p
}
return dispatcher, params
return d
}
func luaBindOptions(bind *hyprlandOverrideBind) []string {
var opts []string
if strings.Contains(bind.Flags, "l") {
opts = append(opts, "locked = true")
}
if strings.Contains(bind.Flags, "e") {
opts = append(opts, "repeating = true")
}
if bind.Description != "" && strings.Contains(bind.Flags, "d") {
opts = append(opts, fmt.Sprintf("description = %s", strconv.Quote(bind.Description)))
}
return opts
}
func writeLuaBindLine(sb *strings.Builder, bind *hyprlandOverrideBind) {
key := formatLuaBindKey(bind.Key)
if bind.Unbind {
fmt.Fprintf(sb, `hl.unbind("%s")`, key)
sb.WriteByte('\n')
return
}
expr := luaActionStringFromHyprlangAction(bind.Action)
opts := luaBindOptions(bind)
fmt.Fprintf(sb, `hl.unbind("%s")`, key)
sb.WriteByte('\n')
if len(opts) > 0 {
fmt.Fprintf(sb, `hl.bind("%s", %s, { %s })`, key, expr, strings.Join(opts, ", "))
} else {
if bind.Description != "" {
fmt.Fprintf(sb, `hl.bind("%s", %s) -- %s`, key, expr, bind.Description)
} else {
fmt.Fprintf(sb, `hl.bind("%s", %s)`, key, expr)
}
}
sb.WriteByte('\n')
}
func parseLuaBindOverrideLine(line string) (*hyprlandOverrideBind, bool) {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "--") {
return nil, false
}
kbc, actionExpr, optSuffix, ok := parseLuaBindInvocation(line)
if !ok {
return nil, false
}
internalKey := luaKeyComboToInternalKey(kbc)
action := luaExprToInternalAction(actionExpr)
flags := luaBindOptFlags(optSuffix)
description := luaBindOptDescription(optSuffix)
return &hyprlandOverrideBind{
Key: internalKey,
Action: action,
Description: description,
Flags: flags,
}, true
}
func parseLuaUnbindLine(line string) (string, bool) {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "hl.unbind") {
return "", false
}
rest := strings.TrimSpace(line[len("hl.unbind"):])
if !strings.HasPrefix(rest, "(") {
return "", false
}
rest = rest[1:]
combo, _, ok := parseLuaStringLiteral(rest, 0)
if !ok {
return "", false
}
return luaKeyComboToInternalKey(combo), true
}
func luaKeyComboToInternalKey(combo string) string {
parts := strings.Fields(strings.ReplaceAll(strings.ReplaceAll(combo, "+", " "), " ", " "))
return strings.Join(parts, "+")
}
func readLuaOrHyprlangOverride(path string) (map[string]*hyprlandOverrideBind, error) {
binds := make(map[string]*hyprlandOverrideBind)
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return binds, nil
}
if err != nil {
return nil, err
}
lines := strings.Split(string(data), "\n")
parser := NewHyprlandParser("")
pendingUnbinds := make(map[string]string)
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "--") {
continue
}
if key, ok := parseLuaUnbindLine(line); ok {
pendingUnbinds[strings.ToLower(key)] = key
continue
}
if kb, ok := parseLuaBindOverrideLine(line); ok {
normalizedKey := strings.ToLower(kb.Key)
binds[normalizedKey] = kb
delete(pendingUnbinds, normalizedKey)
continue
}
if !strings.HasPrefix(line, "bind") {
continue
}
kb := parser.parseBindLine(line)
if kb == nil {
continue
}
keyStr := parser.formatBindKey(kb)
action := kb.Dispatcher
if kb.Params != "" {
action = kb.Dispatcher + " " + kb.Params
}
flags := kb.Flags
normalizedKey := strings.ToLower(keyStr)
binds[normalizedKey] = &hyprlandOverrideBind{
Key: keyStr,
Action: action,
Description: kb.Comment,
Flags: flags,
}
delete(pendingUnbinds, normalizedKey)
}
for normKey, origKey := range pendingUnbinds {
binds[normKey] = &hyprlandOverrideBind{Key: origKey, Unbind: true}
}
return binds, nil
}
@@ -4,8 +4,10 @@ import (
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/luaconfig"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
@@ -50,6 +52,8 @@ type HyprlandParser struct {
bindOrder []string
processedFiles map[string]bool
dmsProcessed bool
removedKeys map[string]bool // bare hl.unbind targets (negative overrides)
defaultDMSKeys map[string]bool // keys present in dms/binds.{lua,conf}
}
func NewHyprlandParser(configDir string) *HyprlandParser {
@@ -64,6 +68,8 @@ func NewHyprlandParser(configDir string) *HyprlandParser {
bindMap: make(map[string]*HyprlandKeyBinding),
bindOrder: []string{},
processedFiles: make(map[string]bool),
removedKeys: make(map[string]bool),
defaultDMSKeys: make(map[string]bool),
}
}
@@ -292,6 +298,7 @@ type HyprlandParseResult struct {
DMSBindsIncluded bool
DMSStatus *HyprlandDMSStatus
ConflictingConfigs map[string]*HyprlandKeyBinding
DefaultDMSKeys map[string]bool // keys with a DMS default in binds.{lua,conf}
}
type HyprlandDMSStatus struct {
@@ -317,10 +324,10 @@ func (p *HyprlandParser) buildDMSStatus() *HyprlandDMSStatus {
switch {
case !p.dmsBindsExists:
status.Effective = false
status.StatusMessage = "dms/binds.conf does not exist"
status.StatusMessage = "dms/binds.lua (or legacy binds.conf) does not exist"
case !p.dmsBindsIncluded:
status.Effective = false
status.StatusMessage = "dms/binds.conf is not sourced in config"
status.StatusMessage = "dms binds are not loaded from Hyprland config (require / source)"
case p.bindsAfterDMS > 0:
status.Effective = true
status.OverriddenBy = p.bindsAfterDMS
@@ -347,8 +354,11 @@ func (p *HyprlandParser) normalizeKey(key string) string {
func (p *HyprlandParser) addBind(kb *HyprlandKeyBinding) bool {
key := p.formatBindKey(kb)
normalizedKey := p.normalizeKey(key)
isDMSBind := strings.Contains(kb.Source, "dms/binds.conf")
isDMSBind := isDMSBindsSourcePath(kb.Source)
if isDMSBindsPrimarySourcePath(kb.Source) {
p.defaultDMSKeys[normalizedKey] = true
}
if isDMSBind {
p.dmsBindKeys[normalizedKey] = true
} else if p.dmsBindKeys[normalizedKey] {
@@ -373,12 +383,21 @@ func (p *HyprlandParser) ParseWithDMS() (*HyprlandSection, error) {
return nil, err
}
dmsBindsPath := filepath.Join(expandedDir, "dms", "binds.conf")
if _, err := os.Stat(dmsBindsPath); err == nil {
dmsBindsLua := filepath.Join(expandedDir, "dms", "binds.lua")
dmsBindsConf := filepath.Join(expandedDir, "dms", "binds.conf")
dmsBindsPath := ""
if _, err := os.Stat(dmsBindsLua); err == nil {
p.dmsBindsExists = true
dmsBindsPath = dmsBindsLua
} else if _, err := os.Stat(dmsBindsConf); err == nil {
p.dmsBindsExists = true
dmsBindsPath = dmsBindsConf
}
mainConfig := filepath.Join(expandedDir, "hyprland.conf")
mainConfig, err := hyprlandMainConfigPath(p.configDir)
if err != nil {
return nil, err
}
section, err := p.parseFileWithSource(mainConfig, "")
if err != nil {
return nil, err
@@ -387,10 +406,65 @@ func (p *HyprlandParser) ParseWithDMS() (*HyprlandSection, error) {
if p.dmsBindsExists && !p.dmsProcessed {
p.parseDMSBindsDirectly(dmsBindsPath, section)
}
p.removeShadowedDMSBinds(section)
p.removeUnboundDMSBinds(section)
return section, nil
}
func (p *HyprlandParser) removeUnboundDMSBinds(section *HyprlandSection) {
if len(p.removedKeys) == 0 {
return
}
filtered := section.Keybinds[:0]
for i := range section.Keybinds {
kb := section.Keybinds[i]
if isDMSBindsSourcePath(kb.Source) && p.removedKeys[p.normalizeKey(p.formatBindKey(&kb))] {
continue
}
filtered = append(filtered, kb)
}
section.Keybinds = filtered
for i := range section.Children {
p.removeUnboundDMSBinds(&section.Children[i])
}
}
func (p *HyprlandParser) removeShadowedDMSBinds(section *HyprlandSection) {
counts := make(map[string]int)
p.countDMSBinds(section, counts)
p.filterShadowedDMSBinds(section, counts)
}
func (p *HyprlandParser) countDMSBinds(section *HyprlandSection, counts map[string]int) {
for i := range section.Keybinds {
kb := &section.Keybinds[i]
if isDMSBindsSourcePath(kb.Source) {
counts[p.normalizeKey(p.formatBindKey(kb))]++
}
}
for i := range section.Children {
p.countDMSBinds(&section.Children[i], counts)
}
}
func (p *HyprlandParser) filterShadowedDMSBinds(section *HyprlandSection, counts map[string]int) {
filtered := section.Keybinds[:0]
for i := range section.Keybinds {
kb := section.Keybinds[i]
key := p.normalizeKey(p.formatBindKey(&kb))
if isDMSBindsSourcePath(kb.Source) && counts[key] > 1 {
counts[key]--
continue
}
filtered = append(filtered, kb)
}
section.Keybinds = filtered
for i := range section.Children {
p.filterShadowedDMSBinds(&section.Children[i], counts)
}
}
func (p *HyprlandParser) parseFileWithSource(filePath, sectionName string) (*HyprlandSection, error) {
absPath, err := filepath.Abs(filePath)
if err != nil {
@@ -407,6 +481,10 @@ func (p *HyprlandParser) parseFileWithSource(filePath, sectionName string) (*Hyp
return nil, err
}
if strings.EqualFold(filepath.Ext(absPath), ".lua") {
return p.parseLuaLines(string(data), filepath.Dir(absPath), absPath, sectionName)
}
prevSource := p.currentSource
p.currentSource = absPath
@@ -446,7 +524,7 @@ func (p *HyprlandParser) handleSource(line string, section *HyprlandSection, bas
}
sourcePath := strings.TrimSpace(parts[1])
isDMSSource := sourcePath == "dms/binds.conf" || strings.HasSuffix(sourcePath, "/dms/binds.conf")
isDMSSource := isDMSBindsPrimarySourcePath(sourcePath)
p.includeCount++
if isDMSSource {
@@ -474,6 +552,17 @@ func (p *HyprlandParser) handleSource(line string, section *HyprlandSection, bas
}
func (p *HyprlandParser) parseDMSBindsDirectly(dmsBindsPath string, section *HyprlandSection) {
if strings.EqualFold(filepath.Ext(dmsBindsPath), ".lua") {
sub, err := p.parseLuaLinesFromPath(dmsBindsPath)
if err != nil {
return
}
section.Keybinds = append(section.Keybinds, sub.Keybinds...)
section.Children = append(section.Children, sub.Children...)
p.dmsProcessed = true
return
}
data, err := os.ReadFile(dmsBindsPath)
if err != nil {
return
@@ -503,6 +592,124 @@ func (p *HyprlandParser) parseDMSBindsDirectly(dmsBindsPath string, section *Hyp
p.dmsProcessed = true
}
func (p *HyprlandParser) parseLuaLinesFromPath(absPath string) (*HyprlandSection, error) {
data, err := os.ReadFile(absPath)
if err != nil {
return nil, err
}
return p.parseLuaLines(string(data), filepath.Dir(absPath), absPath, "")
}
// parseLuaLines reads a Hyprland Lua config fragment: require() includes and hl.bind keybinds.
func (p *HyprlandParser) parseLuaLines(content string, baseDir, absPath, sectionName string) (*HyprlandSection, error) {
section := &HyprlandSection{Name: sectionName}
prevSource := p.currentSource
p.currentSource = absPath
lines := strings.Split(content, "\n")
boundInFile := make(map[string]bool)
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "--") || !strings.Contains(trimmed, "hl.bind") {
continue
}
if kbc, _, _, ok := parseLuaBindInvocation(trimmed); ok {
boundInFile[strings.ToLower(luaKeyComboToInternalKey(kbc))] = true
}
}
rootDir := baseDir
if expanded, err := utils.ExpandPath(p.configDir); err == nil && expanded != "" {
rootDir = expanded
}
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "--") {
continue
}
if modules := luaconfig.Requires(trimmed); len(modules) > 0 {
for _, mod := range modules {
rel := luaconfig.ModuleToRelPath(mod)
if rel == "" {
continue
}
isDMS := isDMSBindsPrimarySourcePath(rel)
p.includeCount++
if isDMS {
p.dmsBindsIncluded = true
p.dmsIncludePos = p.includeCount
p.dmsProcessed = true
}
fullPath := luaconfig.ModuleToPath(rootDir, mod)
expanded, err := utils.ExpandPath(fullPath)
if err != nil {
continue
}
includedSection, err := p.parseFileWithSource(expanded, "")
if err != nil {
continue
}
section.Children = append(section.Children, *includedSection)
}
continue
}
if strings.HasPrefix(trimmed, "hl.unbind") {
if key, ok := parseLuaUnbindLine(trimmed); ok {
normalized := strings.ToLower(key)
if !boundInFile[normalized] {
p.removedKeys[normalized] = true
}
}
continue
}
if !strings.Contains(trimmed, "hl.bind") {
continue
}
kbc, action, optSuffix, ok := parseLuaBindInvocation(trimmed)
if !ok {
continue
}
flags := luaBindOptFlags(optSuffix)
desc := luaBindOptDescription(optSuffix)
if desc == "" {
desc = luaLineTrailingComment(line)
}
kb := luaKeyComboToBinding(kbc, action, p.currentSource, desc)
kb.Flags = flags
if p.addBind(kb) {
section.Keybinds = append(section.Keybinds, *kb)
}
}
p.currentSource = prevSource
return section, nil
}
func luaBindOptFlags(optSuffix string) string {
optSuffix = strings.TrimSpace(optSuffix)
if optSuffix == "" {
return ""
}
var flags string
if strings.Contains(optSuffix, "repeating") {
flags += "e"
}
if strings.Contains(optSuffix, "locked") {
flags += "l"
}
if strings.Contains(optSuffix, "description") {
flags += "d"
}
return flags
}
func luaBindOptDescription(optSuffix string) string {
return luaTableStringField(optSuffix, "description")
}
func (p *HyprlandParser) parseBindLine(line string) *HyprlandKeyBinding {
parts := strings.SplitN(line, "=", 2)
if len(parts) < 2 {
@@ -623,5 +830,356 @@ func ParseHyprlandKeysWithDMS(path string) (*HyprlandParseResult, error) {
DMSBindsIncluded: parser.dmsBindsIncluded,
DMSStatus: parser.buildDMSStatus(),
ConflictingConfigs: parser.conflictingConfigs,
DefaultDMSKeys: parser.defaultDMSKeys,
}, nil
}
func skipLuaWS(s string, i int) int {
for i < len(s) && (s[i] == ' ' || s[i] == '\t' || s[i] == '\r') {
i++
}
return i
}
// parseLuaStringLiteral reads a Lua "..." or '...' starting at i (first quote).
func parseLuaStringLiteral(line string, i int) (value string, next int, ok bool) {
if i >= len(line) {
return "", i, false
}
q := line[i]
if q != '"' && q != '\'' {
return "", i, false
}
i++
var sb strings.Builder
for i < len(line) {
c := line[i]
if c == '\\' && i+1 < len(line) {
i++
sb.WriteByte(line[i])
i++
continue
}
if c == q {
return sb.String(), i + 1, true
}
sb.WriteByte(c)
i++
}
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).
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
inStr := byte(0)
esc := false
exprStart := start
for ; i < len(line); i++ {
c := line[i]
if inStr != 0 {
if esc {
esc = false
continue
}
if c == '\\' && inStr == '"' {
esc = true
continue
}
if c == inStr {
inStr = 0
}
continue
}
switch c {
case '"', '\'':
inStr = c
case '(':
depth++
case ')':
depth--
if depth == 0 {
return strings.TrimSpace(line[exprStart : i+1]), i + 1, true
}
}
}
return "", start, false
}
// parseLuaBindInvocation parses one hl.bind("KEY", expr [, opts]) on a single line.
func parseLuaBindInvocation(line string) (keyCombo, actionExpr, optSuffix string, ok bool) {
idx := strings.Index(line, "hl.bind")
if idx < 0 {
return "", "", "", false
}
i := idx + len("hl.bind")
i = skipLuaWS(line, i)
if i >= len(line) || line[i] != '(' {
return "", "", "", false
}
i++
i = skipLuaWS(line, i)
keyCombo, i, ok = parseLuaStringLiteral(line, i)
if !ok {
return "", "", "", false
}
i = skipLuaWS(line, i)
if i >= len(line) || line[i] != ',' {
return "", "", "", false
}
i++
i = skipLuaWS(line, i)
actionExpr, i, ok = parseLuaFirstArgExpr(line, i)
if !ok {
return "", "", "", false
}
i = skipLuaWS(line, i)
if i < len(line) && line[i] == ',' {
optSuffix = strings.TrimSpace(line[i:])
}
return keyCombo, strings.TrimSpace(actionExpr), optSuffix, true
}
func luaKeyComboToBinding(keyCombo, actionExpr, source, lineComment string) *HyprlandKeyBinding {
keyCombo = strings.TrimSpace(keyCombo)
mods, leaf := luaKeyComboToModsKey(keyCombo)
dispatcher, params := luaExprToDispatcherParams(actionExpr)
comment := lineComment
if comment == "" {
comment = hyprlandAutogenerateComment(dispatcher, params)
}
return &HyprlandKeyBinding{
Mods: mods,
Key: leaf,
Dispatcher: dispatcher,
Params: params,
Comment: comment,
Source: source,
Flags: "",
}
}
func luaKeyComboToModsKey(combo string) (mods []string, leaf string) {
parts := strings.Split(combo, "+")
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
switch len(parts) {
case 0:
return nil, ""
case 1:
return nil, parts[0]
default:
return parts[:len(parts)-1], parts[len(parts)-1]
}
}
func luaExprToDispatcherParams(expr string) (dispatcher, params string) {
expr = strings.TrimSpace(expr)
switch {
case strings.HasPrefix(expr, "hl.dsp.exec_cmd("):
arg := extractLuaCallStringArg(expr, "hl.dsp.exec_cmd")
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 "exec", u
}
}
return "exec", strings.TrimSpace(strings.TrimPrefix(expr, "hl.dsp.exec_cmd"))
case strings.Contains(expr, "hl.dsp.window.kill()"):
return "killactive", ""
case strings.HasPrefix(expr, "hl.dsp.window.fullscreen("):
switch luaTableStringField(expr, "mode") {
case "maximized", "maximize":
return "fullscreen", "1"
case "fullscreen":
return "fullscreen", "0"
}
return "fullscreen", luaTableStringField(expr, "mode")
case strings.HasPrefix(expr, "hl.dsp.window.float("):
return "togglefloating", ""
case strings.Contains(expr, "hl.dsp.group.toggle()"):
return "togglegroup", ""
case strings.HasPrefix(expr, "hl.dsp.focus("):
switch {
case luaTableStringField(expr, "direction") != "":
return "movefocus", luaTableStringField(expr, "direction")
case luaTableStringField(expr, "monitor") != "":
return "focusmonitor", luaTableStringField(expr, "monitor")
case luaTableStringField(expr, "workspace") != "":
return "workspace", luaTableStringField(expr, "workspace")
case luaTableStringField(expr, "window") != "":
return "focuswindow", luaTableStringField(expr, "window")
}
case strings.HasPrefix(expr, "hl.dsp.window.move("):
switch {
case luaTableStringField(expr, "direction") != "":
return "movewindow", luaTableStringField(expr, "direction")
case luaTableStringField(expr, "monitor") != "":
return "movewindow", "mon:" + luaTableStringField(expr, "monitor")
case luaTableStringField(expr, "workspace") != "":
return "movetoworkspace", luaTableStringField(expr, "workspace")
}
case expr == "hl.dsp.window.drag()":
return "movewindow", ""
case expr == "hl.dsp.window.resize()":
return "resizewindow", ""
case strings.HasPrefix(expr, "hl.dsp.window.resize("):
x := luaStringValue(luaTableScalarField(expr, "x"))
y := luaStringValue(luaTableScalarField(expr, "y"))
if x != "" || y != "" {
if x == "" {
x = "0"
}
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
}
}
case strings.HasPrefix(expr, "hl.dsp.dpms("):
if action := luaTableStringField(expr, "action"); action != "" {
return "dpms", action
}
case strings.Contains(expr, "hl.dsp.exit()"):
return "exit", ""
default:
return "exec", "hyprctl dispatch lua:" + expr
}
return "exec", "hyprctl dispatch lua:" + expr
}
func extractLuaCallStringArg(callExpr, funcName string) string {
callExpr = strings.TrimSpace(callExpr)
prefix := funcName + "("
if !strings.HasPrefix(callExpr, prefix) {
return ""
}
inner := callExpr[len(prefix):]
inner = strings.TrimSpace(inner)
if len(inner) == 0 {
return ""
}
switch inner[0] {
case '"', '\'':
s, _, ok := parseLuaStringLiteral(inner, 0)
if ok {
return strconv.Quote(s)
}
case '[':
if strings.HasPrefix(inner, "[[") {
if end := strings.Index(inner[2:], "]]"); end >= 0 {
return strconv.Quote(inner[2 : 2+end])
}
}
}
return ""
}
func luaTableStringField(expr, field string) string {
return luaStringValue(luaTableScalarField(expr, field))
}
func luaTableScalarField(expr, field string) string {
re := regexp.MustCompile(`(?s)\b` + regexp.QuoteMeta(field) + `\s*=\s*("(?:\\.|[^"])*"|'(?:\\.|[^'])*'|\[\[.*?\]\]|-?\d+(?:\.\d+)?|true|false)`)
m := re.FindStringSubmatch(expr)
if len(m) < 2 {
return ""
}
return strings.TrimSpace(m[1])
}
func luaStringValue(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if strings.HasPrefix(raw, "[[") && strings.HasSuffix(raw, "]]") {
return raw[2 : len(raw)-2]
}
if len(raw) >= 2 {
q := raw[0]
if (q == '"' || q == '\'') && raw[len(raw)-1] == q {
if q == '"' {
if u, err := strconv.Unquote(raw); err == nil {
return u
}
}
return strings.ReplaceAll(raw[1:len(raw)-1], `\'`, `'`)
}
}
return raw
}
func luaLineTrailingComment(line string) string {
if idx := strings.Index(line, "--"); idx >= 0 {
return strings.TrimSpace(line[idx+2:])
}
return ""
}
func isDMSBindsSourcePath(p string) bool {
p = filepath.ToSlash(strings.TrimSpace(p))
if isDMSBindsPrimarySourcePath(p) {
return true
}
return isDMSBindsUserOverridePath(p)
}
func isDMSBindsUserOverridePath(p string) bool {
p = filepath.ToSlash(strings.TrimSpace(p))
return p == "dms/binds-user.lua" || p == "./dms/binds-user.lua" ||
strings.HasSuffix(p, "/dms/binds-user.lua")
}
func isDMSBindsPrimarySourcePath(p string) bool {
p = filepath.ToSlash(strings.TrimSpace(p))
if strings.Contains(p, "/dms/binds.lua") || strings.HasSuffix(p, "dms/binds.lua") || p == "dms/binds.lua" || p == "./dms/binds.lua" {
return true
}
if strings.Contains(p, "/dms/binds.conf") || strings.HasSuffix(p, "dms/binds.conf") {
return true
}
return p == "dms/binds.conf" || p == "./dms/binds.conf"
}
// hyprlandMainConfigPath returns hyprland.lua if present, else hyprland.conf if present.
func hyprlandMainConfigPath(dir string) (string, error) {
expandedDir, err := utils.ExpandPath(dir)
if err != nil {
return "", err
}
luaPath := filepath.Join(expandedDir, "hyprland.lua")
if st, err := os.Stat(luaPath); err == nil && st.Mode().IsRegular() {
return luaPath, nil
}
confPath := filepath.Join(expandedDir, "hyprland.conf")
if st, err := os.Stat(confPath); err == nil && st.Mode().IsRegular() {
return confPath, nil
}
return "", os.ErrNotExist
}
@@ -3,7 +3,10 @@ package providers
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
)
func TestHyprlandAutogenerateComment(t *testing.T) {
@@ -60,6 +63,341 @@ func TestHyprlandAutogenerateComment(t *testing.T) {
}
}
func TestHyprlandLuaBindRoundTripHelpers(t *testing.T) {
tests := []struct {
expr string
wantDispatcher string
wantParams string
}{
{`hl.dsp.exec_cmd([[dms ipc call brightness increment 5 ""]])`, "exec", `dms ipc call brightness increment 5 ""`},
{`hl.dsp.window.fullscreen({ mode = "maximized", action = "toggle" })`, "fullscreen", "1"},
{`hl.dsp.focus({ workspace = "e+1" })`, "workspace", "e+1"},
{`hl.dsp.window.move({ monitor = "l" })`, "movewindow", "mon:l"},
{`hl.dsp.window.resize({ x = "-10%", y = 0, relative = true })`, "resizeactive", "-10% 0"},
{`hl.dsp.layout("togglesplit")`, "layoutmsg", "togglesplit"},
{`hl.dsp.dpms({ action = "toggle" })`, "dpms", "toggle"},
}
for _, tt := range tests {
t.Run(tt.expr, func(t *testing.T) {
gotDispatcher, gotParams := luaExprToDispatcherParams(tt.expr)
if gotDispatcher != tt.wantDispatcher || gotParams != tt.wantParams {
t.Fatalf("luaExprToDispatcherParams() = %q, %q; want %q, %q", gotDispatcher, gotParams, tt.wantDispatcher, tt.wantParams)
}
})
}
}
func TestWriteLuaBindLineOptionsInsideCall(t *testing.T) {
var sb strings.Builder
writeLuaBindLine(&sb, &hyprlandOverrideBind{
Key: "Super+k",
Action: "exec kitty",
Description: "Open terminal",
Flags: "led",
})
want := `hl.unbind("SUPER + K")
hl.bind("SUPER + K", hl.dsp.exec_cmd("kitty"), { locked = true, repeating = true, description = "Open terminal" })`
if got := strings.TrimSpace(sb.String()); got != want {
t.Fatalf("writeLuaBindLine() = %q, want %q", got, want)
}
}
func TestWriteLuaBindLineMapsSpawnActionForHyprland(t *testing.T) {
var sb strings.Builder
writeLuaBindLine(&sb, &hyprlandOverrideBind{
Key: "Super+n",
Action: "spawn dms ipc call notepad toggle",
Description: "Notepad: Toggle",
})
want := `hl.unbind("SUPER + N")
hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle")) -- Notepad: Toggle`
if got := strings.TrimSpace(sb.String()); got != want {
t.Fatalf("writeLuaBindLine() = %q, want %q", got, want)
}
}
func TestHyprlandLuaBindsUserOverridesDefaults(t *testing.T) {
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.lua"), []byte(`
require("dms.binds")
require("dms.binds-user")
`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dmsDir, "binds.lua"), []byte(`hl.bind("SUPER + T", hl.dsp.exec_cmd("kitty"))`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dmsDir, "binds-user.lua"), []byte(`hl.bind("SUPER + T", hl.dsp.exec_cmd("foot"), { description = "User terminal" })`), 0o644); err != nil {
t.Fatal(err)
}
result, err := ParseHyprlandKeysWithDMS(tmpDir)
if err != nil {
t.Fatal(err)
}
var found []HyprlandKeyBinding
var walk func(HyprlandSection)
walk = func(section HyprlandSection) {
for _, kb := range section.Keybinds {
if strings.EqualFold(strings.Join(append(kb.Mods, kb.Key), "+"), "SUPER+T") {
found = append(found, kb)
}
}
for _, child := range section.Children {
walk(child)
}
}
walk(*result.Section)
if len(found) != 1 {
t.Fatalf("expected one effective SUPER+T bind, got %d: %#v", len(found), found)
}
if found[0].Params != "foot" || found[0].Comment != "User terminal" {
t.Fatalf("expected user override bind, got %#v", found[0])
}
}
func TestWriteLuaBindLineEmitsUnbindOnlyForNegativeOverride(t *testing.T) {
var sb strings.Builder
writeLuaBindLine(&sb, &hyprlandOverrideBind{Key: "Super+i", Unbind: true})
want := `hl.unbind("SUPER + I")`
if got := strings.TrimSpace(sb.String()); got != want {
t.Fatalf("writeLuaBindLine() = %q, want %q", got, want)
}
}
func TestReadLuaOverrideRecognizesLoneUnbindAsNegativeOverride(t *testing.T) {
tmpDir := t.TempDir()
overridePath := filepath.Join(tmpDir, "binds-user.lua")
contents := `-- DMS user keybind overrides
hl.unbind("SUPER + I")
hl.unbind("SUPER + N")
hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"))
`
if err := os.WriteFile(overridePath, []byte(contents), 0o644); err != nil {
t.Fatal(err)
}
binds, err := readLuaOrHyprlangOverride(overridePath)
if err != nil {
t.Fatal(err)
}
got, ok := binds["super+i"]
if !ok {
t.Fatalf("expected SUPER+I entry in override map, got: %#v", binds)
}
if !got.Unbind {
t.Fatalf("expected SUPER+I to be marked Unbind, got: %#v", got)
}
if rebind, ok := binds["super+n"]; !ok || rebind.Unbind {
t.Fatalf("expected SUPER+N to be a normal rebind override, got: %#v", rebind)
}
}
func TestParserDropsDMSDefaultsSuppressedByBindsUserUnbind(t *testing.T) {
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.lua"), []byte(`
require("dms.binds")
require("dms.binds-user")
`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dmsDir, "binds.lua"), []byte(
`hl.bind("SUPER + I", hl.dsp.focus({ workspace = "e-1" }))
hl.bind("SUPER + T", hl.dsp.exec_cmd("kitty"))`,
), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dmsDir, "binds-user.lua"), []byte(`hl.unbind("SUPER + I")`), 0o644); err != nil {
t.Fatal(err)
}
result, err := ParseHyprlandKeysWithDMS(tmpDir)
if err != nil {
t.Fatal(err)
}
var keys []string
var walk func(HyprlandSection)
walk = func(section HyprlandSection) {
for _, kb := range section.Keybinds {
keys = append(keys, strings.ToUpper(strings.Join(append(kb.Mods, kb.Key), "+")))
}
for _, child := range section.Children {
walk(child)
}
}
walk(*result.Section)
for _, k := range keys {
if k == "SUPER+I" {
t.Fatalf("expected SUPER+I to be suppressed by binds-user.lua unbind, got: %v", keys)
}
}
foundT := false
for _, k := range keys {
if k == "SUPER+T" {
foundT = true
}
}
if !foundT {
t.Fatalf("expected SUPER+T to remain (only SUPER+I was unbound), got: %v", keys)
}
}
func TestHyprlandRemoveBindWritesNegativeOverrideForDefault(t *testing.T) {
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
t.Fatal(err)
}
provider := NewHyprlandProvider(tmpDir)
if err := provider.RemoveBind("SUPER+I"); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(dmsDir, "binds-user.lua"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), `hl.unbind("SUPER + I")`) {
t.Fatalf("expected negative override hl.unbind line, got:\n%s", string(data))
}
if strings.Contains(string(data), `hl.bind("SUPER + I"`) {
t.Fatalf("expected NO hl.bind for SUPER+I, got:\n%s", string(data))
}
}
func TestHyprlandRemoveBindReplacesExistingOverrideWithNegativeOverride(t *testing.T) {
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
t.Fatal(err)
}
override := `hl.unbind("SUPER + N")
hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"))
`
if err := os.WriteFile(filepath.Join(dmsDir, "binds-user.lua"), []byte(override), 0o644); err != nil {
t.Fatal(err)
}
provider := NewHyprlandProvider(tmpDir)
if err := provider.RemoveBind("SUPER+N"); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(dmsDir, "binds-user.lua"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), `hl.unbind("SUPER + N")`) {
t.Fatalf("expected negative override hl.unbind line, got:\n%s", string(data))
}
if strings.Contains(string(data), `hl.bind("SUPER + N"`) {
t.Fatalf("expected NO hl.bind for SUPER+N after remove, got:\n%s", string(data))
}
}
func TestHyprlandResetBindRevertsExistingOverrideToDefault(t *testing.T) {
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
t.Fatal(err)
}
override := `hl.unbind("SUPER + N")
hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"))
`
if err := os.WriteFile(filepath.Join(dmsDir, "binds-user.lua"), []byte(override), 0o644); err != nil {
t.Fatal(err)
}
provider := NewHyprlandProvider(tmpDir)
if err := provider.ResetBind("SUPER+N"); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(dmsDir, "binds-user.lua"))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(data), `SUPER + N`) {
t.Fatalf("expected SUPER+N to be fully removed (revert to default), got:\n%s", string(data))
}
}
func TestHyprlandHasDefaultSetForOverrideOfDefaultKey(t *testing.T) {
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.lua"), []byte(`
require("dms.binds")
require("dms.binds-user")
`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dmsDir, "binds.lua"), []byte(
`hl.bind("SUPER + T", hl.dsp.exec_cmd("kitty"))`,
), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dmsDir, "binds-user.lua"), []byte(
`hl.unbind("SUPER + T")
hl.bind("SUPER + T", hl.dsp.exec_cmd("foot"))
hl.bind("SUPER + Z", hl.dsp.exec_cmd("custom"))`,
), 0o644); err != nil {
t.Fatal(err)
}
provider := NewHyprlandProvider(tmpDir)
sheet, err := provider.GetCheatSheet()
if err != nil {
t.Fatal(err)
}
var foundT, foundZ *keybinds.Keybind
for _, group := range sheet.Binds {
for i := range group {
kb := group[i]
keyUpper := strings.ToUpper(kb.Key)
if keyUpper == "SUPER+T" {
foundT = &group[i]
}
if keyUpper == "SUPER+Z" {
foundZ = &group[i]
}
}
}
if foundT == nil {
t.Fatalf("expected SUPER+T override in cheatsheet")
}
if !foundT.HasDefault {
t.Fatalf("expected SUPER+T HasDefault=true (default exists in binds.lua), got %+v", foundT)
}
if foundZ == nil {
t.Fatalf("expected SUPER+Z (user-only) in cheatsheet")
}
if foundZ.HasDefault {
t.Fatalf("expected SUPER+Z HasDefault=false (no default), got %+v", foundZ)
}
}
func TestHyprlandGetKeybindAtLine(t *testing.T) {
tests := []struct {
name string
+6 -2
View File
@@ -141,7 +141,7 @@ func (m *MangoWCProvider) convertKeybind(kb *MangoWCKeyBinding, conflicts map[st
source := "config"
if strings.Contains(kb.Source, "dms/binds.conf") || strings.Contains(kb.Source, "dms"+string(filepath.Separator)+"binds.conf") {
source = "dms"
source = "dms-default"
}
bind := keybinds.Keybind{
@@ -151,7 +151,7 @@ func (m *MangoWCProvider) convertKeybind(kb *MangoWCKeyBinding, conflicts map[st
Source: source,
}
if source == "dms" && conflicts != nil {
if source == "dms-default" && conflicts != nil {
normalizedKey := strings.ToLower(keyStr)
if conflictKb, ok := conflicts[normalizedKey]; ok {
bind.Conflict = &keybinds.Keybind{
@@ -249,6 +249,10 @@ func (m *MangoWCProvider) RemoveBind(key string) error {
return m.writeOverrideBinds(existingBinds)
}
func (m *MangoWCProvider) ResetBind(key string) error {
return m.RemoveBind(key)
}
type mangowcOverrideBind struct {
Key string
Action string
+10 -6
View File
@@ -149,7 +149,7 @@ func (n *NiriProvider) convertKeybind(kb *NiriKeyBinding, subcategory string, co
source := "config"
if strings.Contains(kb.Source, "dms/binds.kdl") {
source = "dms"
source = "dms-default"
}
bind := keybinds.Keybind{
@@ -165,8 +165,8 @@ func (n *NiriProvider) convertKeybind(kb *NiriKeyBinding, subcategory string, co
Repeat: kb.Repeat,
}
if source == "dms" && conflicts != nil {
if conflictKb, ok := conflicts[keyStr]; ok {
if source == "dms-default" && conflicts != nil {
if conflictKb, ok := conflicts[normalizeNiriBindKey(keyStr)]; ok {
bind.Conflict = &keybinds.Keybind{
Key: keyStr,
Description: conflictKb.Description,
@@ -249,7 +249,7 @@ func (n *NiriProvider) SetBind(key, action, description string, options map[stri
existingBinds = make(map[string]*overrideBind)
}
existingBinds[key] = &overrideBind{
existingBinds[normalizeNiriBindKey(key)] = &overrideBind{
Key: key,
Action: action,
Description: description,
@@ -265,10 +265,14 @@ func (n *NiriProvider) RemoveBind(key string) error {
return nil
}
delete(existingBinds, key)
delete(existingBinds, normalizeNiriBindKey(key))
return n.writeOverrideBinds(existingBinds)
}
func (n *NiriProvider) ResetBind(key string) error {
return n.RemoveBind(key)
}
type overrideBind struct {
Key string
Action string
@@ -312,7 +316,7 @@ func (n *NiriProvider) loadOverrideBinds() (map[string]*overrideBind, error) {
action = n.formatRawAction(kb.Action, kb.Args)
}
binds[keyStr] = &overrideBind{
binds[normalizeNiriBindKey(keyStr)] = &overrideBind{
Key: keyStr,
Action: action,
Description: kb.Description,
@@ -162,6 +162,14 @@ func NewNiriParser(configDir string) *NiriParser {
}
}
func normalizeNiriBindKey(key string) string {
parts := strings.Split(key, "+")
for i := range parts {
parts[i] = strings.ToLower(strings.TrimSpace(parts[i]))
}
return strings.Join(parts, "+")
}
func (p *NiriParser) Parse() (*NiriSection, error) {
dmsBindsPath := filepath.Join(p.configDir, "dms", "binds.kdl")
if _, err := os.Stat(dmsBindsPath); err == nil {
@@ -213,24 +221,25 @@ func (p *NiriParser) finalizeBinds() []NiriKeyBinding {
func (p *NiriParser) addBind(kb *NiriKeyBinding) {
key := p.formatBindKey(kb)
normalizedKey := normalizeNiriBindKey(key)
isDMSBind := strings.Contains(kb.Source, "dms/binds.kdl")
if isDMSBind {
p.dmsBindKeys[key] = true
p.dmsBindMap[key] = kb
} else if p.dmsBindKeys[key] {
p.dmsBindKeys[normalizedKey] = true
p.dmsBindMap[normalizedKey] = kb
} else if p.dmsBindKeys[normalizedKey] {
p.bindsAfterDMS++
p.conflictingConfigs[key] = kb
p.configBindKeys[key] = true
p.conflictingConfigs[normalizedKey] = kb
p.configBindKeys[normalizedKey] = true
return
} else {
p.configBindKeys[key] = true
p.configBindKeys[normalizedKey] = true
}
if _, exists := p.bindMap[key]; !exists {
p.bindOrder = append(p.bindOrder, key)
if _, exists := p.bindMap[normalizedKey]; !exists {
p.bindOrder = append(p.bindOrder, normalizedKey)
}
p.bindMap[key] = kb
p.bindMap[normalizedKey] = kb
}
func (p *NiriParser) formatBindKey(kb *NiriKeyBinding) string {
@@ -526,6 +526,50 @@ binds {
}
}
func TestNiriKeyIdentityIsCaseInsensitive(t *testing.T) {
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
t.Fatalf("Failed to create dms dir: %v", err)
}
config := `binds {
Alt+Space hotkey-overlay-title="Spotlight Bar" { spawn "dms" "ipc" "call" "spotlight-bar" "toggle"; }
}
include "dms/binds.kdl"
`
if err := os.WriteFile(filepath.Join(tmpDir, "config.kdl"), []byte(config), 0o644); err != nil {
t.Fatalf("Failed to write config: %v", err)
}
include := `binds {
Alt+space hotkey-overlay-title="Default Launcher" { spawn "dms" "ipc" "call" "spotlight" "toggle"; }
}
`
if err := os.WriteFile(filepath.Join(dmsDir, "binds.kdl"), []byte(include), 0o644); err != nil {
t.Fatalf("Failed to write binds include: %v", err)
}
result, err := ParseNiriKeys(tmpDir)
if err != nil {
t.Fatalf("ParseNiriKeys failed: %v", err)
}
var altSpaceBinds []NiriKeyBinding
parser := NewNiriParser("")
for _, kb := range result.Section.Keybinds {
if normalizeNiriBindKey(parser.formatBindKey(&kb)) == "alt+space" {
altSpaceBinds = append(altSpaceBinds, kb)
}
}
if len(altSpaceBinds) != 1 {
t.Fatalf("Expected one Alt+Space identity, got %d", len(altSpaceBinds))
}
if got := altSpaceBinds[0].Args; len(got) < 5 || got[3] != "spotlight" || got[4] != "toggle" {
t.Fatalf("Expected later DMS include to win with spotlight toggle, got action=%s args=%v", altSpaceBinds[0].Action, got)
}
}
func TestNiriParseMultipleArgs(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "config.kdl")
@@ -367,7 +367,7 @@ func TestNiriEmptyArgsPreservation(t *testing.T) {
}
for key, expected := range binds {
loaded, ok := loadedBinds[key]
loaded, ok := loadedBinds[normalizeNiriBindKey(key)]
if !ok {
t.Errorf("Missing bind for key %s", key)
continue
+6
View File
@@ -13,6 +13,7 @@ type Keybind struct {
AllowInhibiting *bool `json:"allowInhibiting,omitempty"` // nil=default(true), false=explicitly disabled
Repeat *bool `json:"repeat,omitempty"` // nil=default(true), false=explicitly disabled
Conflict *Keybind `json:"conflict,omitempty"`
HasDefault bool `json:"hasDefault,omitempty"` // override has a DMS default to revert to
}
type DMSBindsStatus struct {
@@ -42,6 +43,11 @@ type Provider interface {
type WritableProvider interface {
Provider
SetBind(key, action, description string, options map[string]any) error
// RemoveBind removes the bind. Hyprland writes a negative override to
// dms/binds-user.lua; single-file providers delete the line.
RemoveBind(key string) error
// ResetBind reverts a user override to its DMS default. On single-file
// providers this aliases to RemoveBind.
ResetBind(key string) error
GetOverridePath() string
}
+129
View File
@@ -0,0 +1,129 @@
package luaconfig
import (
"os"
"path/filepath"
"regexp"
"strings"
)
var luaRequireRE = regexp.MustCompile(`(?i)\brequire\s*\(\s*["']([^"']+)["']\s*\)`)
func ModuleToRelPath(module string) string {
module = strings.TrimSpace(module)
if module == "" {
return ""
}
module = strings.NewReplacer(".", string(filepath.Separator), "/", string(filepath.Separator)).Replace(module)
return filepath.Clean(module + ".lua")
}
func ModuleToPath(baseDir, module string) string {
rel := ModuleToRelPath(module)
if rel == "" {
return ""
}
return filepath.Clean(filepath.Join(baseDir, rel))
}
func Requires(line string) []string {
line = stripLineComment(line)
if strings.TrimSpace(line) == "" {
return nil
}
matches := luaRequireRE.FindAllStringSubmatch(line, -1)
if len(matches) == 0 {
return nil
}
modules := make([]string, 0, len(matches))
for _, match := range matches {
if len(match) > 1 && strings.TrimSpace(match[1]) != "" {
modules = append(modules, strings.TrimSpace(match[1]))
}
}
return modules
}
func Require(line string) (string, bool) {
modules := Requires(line)
if len(modules) != 1 {
return "", false
}
return modules[0], true
}
func RequiresTarget(filePath, targetAbs string, processed map[string]bool) bool {
absPath, err := filepath.Abs(filePath)
if err != nil {
return false
}
return requiresTarget(absPath, filepath.Dir(absPath), targetAbs, processed)
}
func requiresTarget(filePath, rootDir, targetAbs string, processed map[string]bool) bool {
absPath, err := filepath.Abs(filePath)
if err != nil {
return false
}
targetAbsClean := filepath.Clean(targetAbs)
if processed[absPath] {
return false
}
processed[absPath] = true
data, err := os.ReadFile(absPath)
if err != nil {
return false
}
for _, raw := range strings.Split(string(data), "\n") {
for _, module := range Requires(raw) {
candidate := ModuleToPath(rootDir, module)
if candidate == "" {
continue
}
if filepath.Clean(candidate) == targetAbsClean {
return true
}
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
if requiresTarget(candidate, rootDir, targetAbs, processed) {
return true
}
}
}
}
return false
}
func stripLineComment(line string) string {
inStr := byte(0)
esc := false
for i := 0; i+1 < len(line); i++ {
c := line[i]
if inStr != 0 {
if esc {
esc = false
continue
}
if c == '\\' && inStr == '"' {
esc = true
continue
}
if c == inStr {
inStr = 0
}
continue
}
switch c {
case '"', '\'':
inStr = c
case '-':
if line[i+1] == '-' {
return line[:i]
}
}
}
return line
}
+56
View File
@@ -0,0 +1,56 @@
package luaconfig
import (
"os"
"path/filepath"
"testing"
)
func TestModuleToRelPath(t *testing.T) {
tests := map[string]string{
"dms.binds": filepath.Join("dms", "binds.lua"),
"dms/binds-user": filepath.Join("dms", "binds-user.lua"),
"awesome/anim": filepath.Join("awesome", "anim.lua"),
"awesome.colors": filepath.Join("awesome", "colors.lua"),
" awesome.binds ": filepath.Join("awesome", "binds.lua"),
}
for input, want := range tests {
if got := ModuleToRelPath(input); got != want {
t.Fatalf("ModuleToRelPath(%q) = %q, want %q", input, got, want)
}
}
}
func TestRequiresSkipsComments(t *testing.T) {
if modules := Requires(`-- require("dms.binds")`); len(modules) != 0 {
t.Fatalf("expected commented require to be ignored, got %#v", modules)
}
modules := Requires(`print("-- not a comment") require("dms.binds") -- require("ignored")`)
if len(modules) != 1 || modules[0] != "dms.binds" {
t.Fatalf("unexpected modules: %#v", modules)
}
}
func TestRequiresTargetRecurses(t *testing.T) {
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
t.Fatal(err)
}
target := filepath.Join(dmsDir, "windowrules.lua")
if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.lua"), []byte(`require("dms.extra")`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dmsDir, "extra.lua"), []byte(`require("dms.windowrules")`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(target, []byte(`-- rules`), 0o644); err != nil {
t.Fatal(err)
}
if !RequiresTarget(filepath.Join(tmpDir, "hyprland.lua"), target, make(map[string]bool)) {
t.Fatal("expected recursive require lookup to find target")
}
}
@@ -1,6 +1,7 @@
package network
import (
"encoding/json"
"fmt"
"net"
"strings"
@@ -18,10 +19,41 @@ const (
)
type linkInfo struct {
ifindex int32
name string
path dbus.ObjectPath
opState string
ifindex int32
name string
path dbus.ObjectPath
opState string
linkType string
}
func (l *linkInfo) isWired() bool {
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
}
func (l *linkInfo) isWireless() bool {
if l.linkType != "" {
return l.linkType == "wlan"
}
return strings.HasPrefix(l.name, "wlan") || strings.HasPrefix(l.name, "wlp")
}
func looksVirtual(name string) bool {
virtualPrefixes := []string{
"lo", "docker", "veth", "virbr", "br-", "vnet", "tun", "tap",
"vboxnet", "vmnet", "kube", "cni", "flannel", "cali",
}
for _, prefix := range virtualPrefixes {
if strings.HasPrefix(name, prefix) {
return true
}
}
return false
}
type SystemdNetworkdBackend struct {
@@ -95,17 +127,50 @@ func (b *SystemdNetworkdBackend) enumerateLinks() error {
defer b.linksMutex.Unlock()
for _, l := range links {
b.links[l.Name] = &linkInfo{
ifindex: l.Ifindex,
name: l.Name,
path: l.Path,
if existing, ok := b.links[l.Name]; ok && existing.path == l.Path {
existing.ifindex = l.Ifindex
continue
}
log.Debugf("networkd: enumerated link %s (ifindex=%d, path=%s)", l.Name, l.Ifindex, l.Path)
info := &linkInfo{
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)
}
return nil
}
// fetchLinkType queries networkd's Describe method and extracts the link Type
// (e.g. "ether", "wlan", "loopback", "none"). Returns empty on failure; callers
// fall back to name-prefix heuristics in that case. The Type is fixed at link
// creation by the kernel, so callers cache the result for the lifetime of the
// linkInfo and only refetch when a link is re-created at a new D-Bus path.
func (b *SystemdNetworkdBackend) fetchLinkType(path dbus.ObjectPath) string {
linkObj := b.conn.Object(networkdBusName, path)
var describeJSON string
if err := linkObj.Call(networkdLinkIface+".Describe", 0).Store(&describeJSON); err != nil {
return ""
}
return parseDescribeType(describeJSON)
}
// parseDescribeType extracts the top-level "Type" field from a networkd
// Describe payload. Returns empty when the JSON is malformed or the field is
// absent, signalling callers to fall back to name-prefix heuristics.
func parseDescribeType(describeJSON string) string {
var parsed struct {
Type string `json:"Type"`
}
if err := json.Unmarshal([]byte(describeJSON), &parsed); err != nil {
return ""
}
return parsed.Type
}
func (b *SystemdNetworkdBackend) updateState() error {
b.linksMutex.RLock()
defer b.linksMutex.RUnlock()
@@ -113,8 +178,8 @@ func (b *SystemdNetworkdBackend) updateState() error {
var wiredIface *linkInfo
var wifiIface *linkInfo
for name, link := range b.links {
if b.isVirtualInterface(name) {
for _, link := range b.links {
if !link.isWired() && !link.isWireless() {
continue
}
@@ -126,11 +191,11 @@ func (b *SystemdNetworkdBackend) updateState() error {
}
}
if strings.HasPrefix(name, "wlan") || strings.HasPrefix(name, "wlp") {
if link.isWireless() {
if wifiIface == nil || link.opState == "routable" || link.opState == "carrier" {
wifiIface = link
}
} else if !b.isVirtualInterface(name) {
} else if link.isWired() {
if wiredIface == nil || link.opState == "routable" || link.opState == "carrier" {
wiredIface = link
}
@@ -140,7 +205,7 @@ func (b *SystemdNetworkdBackend) updateState() error {
var wiredConns []WiredConnection
var ethernetDevices []EthernetDevice
for name, link := range b.links {
if b.isVirtualInterface(name) || strings.HasPrefix(name, "wlan") || strings.HasPrefix(name, "wlp") {
if !link.isWired() {
continue
}
@@ -229,19 +294,6 @@ func (b *SystemdNetworkdBackend) updateState() error {
return nil
}
func (b *SystemdNetworkdBackend) isVirtualInterface(name string) bool {
virtualPrefixes := []string{
"lo", "docker", "veth", "virbr", "br-", "vnet", "tun", "tap",
"vboxnet", "vmnet", "kube", "cni", "flannel", "cali",
}
for _, prefix := range virtualPrefixes {
if strings.HasPrefix(name, prefix) {
return true
}
}
return false
}
func (b *SystemdNetworkdBackend) getAddresses(ifname string) []string {
iface, err := net.InterfaceByName(ifname)
if err != nil {
@@ -12,7 +12,7 @@ func (b *SystemdNetworkdBackend) GetWiredConnections() ([]WiredConnection, error
var conns []WiredConnection
for name, link := range b.links {
if b.isVirtualInterface(name) || strings.HasPrefix(name, "wlan") || strings.HasPrefix(name, "wlp") {
if !link.isWired() {
continue
}
@@ -73,8 +73,8 @@ func (b *SystemdNetworkdBackend) GetWiredNetworkDetails(id string) (*WiredNetwor
func (b *SystemdNetworkdBackend) ConnectEthernet() error {
b.linksMutex.RLock()
var primaryWired *linkInfo
for name, l := range b.links {
if strings.HasPrefix(name, "lo") || strings.HasPrefix(name, "wlan") || strings.HasPrefix(name, "wlp") {
for _, l := range b.links {
if !l.isWired() {
continue
}
primaryWired = l
@@ -145,3 +145,73 @@ func TestSystemdNetworkdBackend_DisconnectEthernetDevice(t *testing.T) {
assert.Error(t, err)
assert.Contains(t, err.Error(), "not supported")
}
func TestLinkInfo_Classify(t *testing.T) {
// When networkd reports a Type via Describe, classification is exact.
cases := []struct {
name string
ifname string
linkType string
wantWired bool
wantWifi bool
}{
{"ether type", "dock", "ether", true, false},
{"wlan type", "wifi", "wlan", false, true},
{"loopback type", "lo", "loopback", false, false},
{"none type (tun overlay)", "nebula.homelab", "none", false, false},
{"none type (wireguard)", "wg0", "none", false, false},
// Fallback path: linkType unavailable, name-prefix heuristic applies.
{"fallback enp wired", "enp141s0", "", true, false},
{"fallback wlan wireless", "wlan0", "", false, true},
{"fallback wlp wireless", "wlp3s0", "", false, true},
{"fallback lo skipped", "lo", "", false, false},
{"fallback docker skipped", "docker0", "", false, false},
{"fallback tun skipped", "tun0", "", false, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
l := &linkInfo{name: tc.ifname, linkType: tc.linkType}
assert.Equal(t, tc.wantWired, l.isWired(), "isWired")
assert.Equal(t, tc.wantWifi, l.isWireless(), "isWireless")
})
}
}
func TestParseDescribeType(t *testing.T) {
// parseDescribeType is the seam between networkd's Describe RPC and the
// classifier. On any failure path it must return "" so callers fall back
// to name-prefix heuristics rather than misclassifying the link.
cases := []struct {
name string
in string
want string
}{
{"ether", `{"Type":"ether","Name":"enp141s0"}`, "ether"},
{"wlan", `{"Type":"wlan","Name":"wlan0"}`, "wlan"},
{"loopback", `{"Type":"loopback","Name":"lo"}`, "loopback"},
{"none with kind", `{"Type":"none","Kind":"tun","Name":"nebula.homelab"}`, "none"},
{"empty payload", ``, ""},
{"empty object", `{}`, ""},
{"missing Type field", `{"Name":"wlan0","Kind":""}`, ""},
{"explicit empty Type", `{"Type":"","Name":"wlan0"}`, ""},
{"malformed json", `{"Type":"ether"`, ""},
{"non-string Type", `{"Type":42}`, ""},
{"unrelated payload", `"just a string"`, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, parseDescribeType(tc.in))
})
}
}
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"}
for _, n := range virtual {
assert.True(t, looksVirtual(n), "%s should look virtual", n)
}
real := []string{"enp141s0", "eno1", "wlan0", "wlp3s0", "wifi", "dock", "nebula.homelab", "wg0"}
for _, n := range real {
assert.False(t, looksVirtual(n), "%s should not look virtual", n)
}
}
+7 -2
View File
@@ -300,9 +300,14 @@ func (m Model) checkExistingConfigurations() tea.Cmd {
Exists: niriExists,
})
} else {
hyprlandPath := filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf")
hyprlandLuaPath := filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.lua")
hyprlandConfPath := filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf")
hyprlandPath := hyprlandLuaPath
hyprlandExists := false
if _, err := os.Stat(hyprlandPath); err == nil {
if _, err := os.Stat(hyprlandLuaPath); err == nil {
hyprlandExists = true
} else if _, err := os.Stat(hyprlandConfPath); err == nil {
hyprlandPath = hyprlandConfPath
hyprlandExists = true
}
configs = append(configs, ExistingConfigInfo{
File diff suppressed because it is too large Load Diff
@@ -3,7 +3,10 @@ package providers
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules"
)
func TestParseWindowRuleV1(t *testing.T) {
@@ -151,7 +154,7 @@ func TestHyprlandWritableProvider(t *testing.T) {
t.Errorf("Name() = %q, want hyprland", provider.Name())
}
expectedPath := filepath.Join(tmpDir, "dms", "windowrules.conf")
expectedPath := filepath.Join(tmpDir, "dms", "windowrules.lua")
if provider.GetOverridePath() != expectedPath {
t.Errorf("GetOverridePath() = %q, want %q", provider.GetOverridePath(), expectedPath)
}
@@ -270,6 +273,104 @@ windowrulev2 = tile, class:^(extraapp)$
}
}
func TestParseHyprlandLuaRequiresFragment(t *testing.T) {
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0755); err != nil {
t.Fatal(err)
}
mainLua := filepath.Join(tmpDir, "hyprland.lua")
fragLua := filepath.Join(dmsDir, "windowrules.lua")
if err := os.WriteFile(fragLua, []byte(`
hl.window_rule({ match = { class = "^test$" }, float = true })
`), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(mainLua, []byte(`
require("dms.windowrules")
`), 0644); err != nil {
t.Fatal(err)
}
res, err := ParseHyprlandWindowRules(tmpDir)
if err != nil {
t.Fatalf("ParseHyprlandWindowRules: %v", err)
}
if len(res.Rules) != 1 {
t.Fatalf("expected 1 rule, got %d", len(res.Rules))
}
if !res.DMSRulesIncluded {
t.Fatal("expected dms.windowrules fragment to be marked included")
}
wr := ConvertHyprlandRulesToWindowRules(res.Rules)[0]
if wr.MatchCriteria.AppID != "^test$" || wr.Actions.OpenFloating == nil || !*wr.Actions.OpenFloating {
t.Fatalf("unexpected merged rule: %#v", wr)
}
}
func TestParseHyprlandLuaNoInitialFocusAlias(t *testing.T) {
tmpDir := t.TempDir()
if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.lua"), []byte(`
hl.window_rule({
match = { class = "^steam$" },
no_initial_focus = true,
})
`), 0644); err != nil {
t.Fatal(err)
}
res, err := ParseHyprlandWindowRules(tmpDir)
if err != nil {
t.Fatalf("ParseHyprlandWindowRules: %v", err)
}
if len(res.Rules) != 1 {
t.Fatalf("expected 1 rule, got %d", len(res.Rules))
}
wr := ConvertHyprlandRulesToWindowRules(res.Rules)[0]
if wr.Actions.NoFocus == nil || !*wr.Actions.NoFocus {
t.Fatalf("expected no_initial_focus to populate NoFocus action: %#v", wr.Actions)
}
}
func TestFormatLuaManagedHyprRuleUsesLuaFieldNames(t *testing.T) {
enabled := true
rule := windowrules.WindowRule{
ID: "test-rule",
Enabled: true,
MatchCriteria: windowrules.MatchCriteria{
AppID: "^app$",
},
Actions: windowrules.Actions{
NoFocus: &enabled,
NoShadow: &enabled,
NoDim: &enabled,
NoBlur: &enabled,
NoAnim: &enabled,
ForcergbX: &enabled,
Idleinhibit: "focus",
},
}
lines := formatLuaManagedHyprRule(rule)
joined := strings.Join(lines, "\n")
for _, want := range []string{
"no_focus = true",
"no_shadow = true",
"no_dim = true",
"no_blur = true",
"no_anim = true",
"force_rgbx = true",
`idle_inhibit = "focus"`,
} {
if !strings.Contains(joined, want) {
t.Fatalf("formatted rule missing %q: %s", want, joined)
}
}
}
func TestBoolToInt(t *testing.T) {
if boolToInt(true) != 1 {
t.Error("boolToInt(true) should be 1")
+3 -1
View File
@@ -29,7 +29,9 @@ override_dh_auto_install:
install -Dm644 $$SOURCE_DIR/LICENSE \
debian/dms-greeter/usr/share/doc/dms-greeter/LICENSE && \
install -Dpm0644 $$SOURCE_DIR/systemd/tmpfiles-dms-greeter.conf \
debian/dms-greeter/usr/lib/tmpfiles.d/dms-greeter.conf; \
debian/dms-greeter/usr/lib/tmpfiles.d/dms-greeter.conf && \
install -Dm644 $$SOURCE_DIR/systemd/sysusers-dms-greeter.conf \
debian/dms-greeter/usr/lib/sysusers.d/dms-greeter.conf; \
else \
echo "ERROR: No upstream source (dms-qml or Modules/Greetd/assets/dms-greeter)!" && \
echo "Contents of current directory:" && ls -la && exit 1; \
+3
View File
@@ -53,6 +53,8 @@ install -Dm644 %{_builddir}/dms-qml/Modules/Greetd/README.md %{buildroot}%{_docd
install -Dpm0644 %{_builddir}/dms-qml/systemd/tmpfiles-dms-greeter.conf %{buildroot}%{_tmpfilesdir}/dms-greeter.conf
install -Dm644 %{_builddir}/dms-qml/systemd/sysusers-dms-greeter.conf %{buildroot}%{_sysusersdir}/dms-greeter.conf
install -Dm644 %{_builddir}/dms-qml/LICENSE %{buildroot}%{_docdir}/dms-greeter/LICENSE
install -dm755 %{buildroot}%{_sharedstatedir}/greeter
@@ -78,6 +80,7 @@ fi
%{_bindir}/dms-greeter
%{_datadir}/quickshell/dms-greeter/
%{_tmpfilesdir}/%{name}.conf
%{_sysusersdir}/dms-greeter.conf
%pre
# Create greeter user/group if they don't exist
+3
View File
@@ -53,6 +53,8 @@ install -Dm644 %{_builddir}/dms-qml/Modules/Greetd/README.md %{buildroot}%{_docd
install -Dpm0644 %{_builddir}/dms-qml/systemd/tmpfiles-dms-greeter.conf %{buildroot}%{_tmpfilesdir}/dms-greeter.conf
install -Dm644 %{_builddir}/dms-qml/systemd/sysusers-dms-greeter.conf %{buildroot}%{_sysusersdir}/dms-greeter.conf
install -Dm644 %{_builddir}/dms-qml/LICENSE %{buildroot}%{_docdir}/dms-greeter/LICENSE
install -dm755 %{buildroot}%{_sharedstatedir}/greeter
@@ -78,6 +80,7 @@ fi
%dir %{_datadir}/quickshell
%{_datadir}/quickshell/dms-greeter/
%{_tmpfilesdir}/%{name}.conf
%{_sysusersdir}/dms-greeter.conf
%pre
# Create greeter user/group if they don't exist
+5
View File
@@ -40,6 +40,11 @@ override_dh_auto_install:
install -Dm644 DankMaterialShell-$(BASE_VERSION)/LICENSE \
debian/dms-greeter/usr/share/doc/dms-greeter/LICENSE
install -Dpm0644 DankMaterialShell-$(BASE_VERSION)/quickshell/systemd/tmpfiles-dms-greeter.conf \
debian/dms-greeter/usr/lib/tmpfiles.d/dms-greeter.conf
install -Dm644 DankMaterialShell-$(BASE_VERSION)/quickshell/systemd/sysusers-dms-greeter.conf \
debian/dms-greeter/usr/lib/sysusers.d/dms-greeter.conf
# Create cache directory structure (will be created by postinst)
mkdir -p debian/dms-greeter/var/cache/dms-greeter
+193
View File
@@ -0,0 +1,193 @@
# Hyprland Lua Migration
Hyprland 0.55 moved configuration toward Lua. DMS now follows that path for new
Hyprland setup and migration.
This guide covers what changes, where files live, and how to check that your
session is using the new config.
## Quick Summary
DMS now deploys Hyprland as:
```text
~/.config/hypr/hyprland.lua
~/.config/hypr/dms/*.lua
```
The old hyprlang files are moved out of the active config tree:
```text
~/.config/hypr/hyprland.conf
~/.config/hypr/dms/*.conf
```
Backups are stored here:
```text
~/.config/hypr/.dms-backups/<timestamp>/
```
## What `dms setup` Does
When Hyprland is selected, `dms setup` writes a Lua main config and DMS Lua
fragments.
| File | Purpose |
| --- | --- |
| `hyprland.lua` | Main Hyprland config. |
| `dms/colors.lua` | Theme colors. |
| `dms/outputs.lua` | Monitors and display settings. |
| `dms/layout.lua` | Layout, gaps, borders, and decoration. |
| `dms/cursor.lua` | Cursor settings. |
| `dms/binds.lua` | DMS-managed default shortcuts. |
| `dms/binds-user.lua` | User shortcut overrides. |
| `dms/windowrules.lua` | Window rules. |
`dms/binds.lua` is managed by DMS and may be refreshed by setup. Put custom
keyboard shortcuts in `dms/binds-user.lua`, or use the Keyboard Shortcuts page in
DMS Settings.
Most other existing non-empty Lua fragments are preserved.
## Legacy Config Migration
During migration, DMS moves legacy active files into the backup folder so
Hyprland does not see both config formats at once.
DMS also migrates legacy `monitor = ...` lines from `hyprland.conf` into
`dms/outputs.lua` when `outputs.lua` is empty or missing. If you already have a
custom `outputs.lua`, DMS leaves it alone.
## DMS Settings Support
DMS Settings now targets Lua files for Hyprland:
| Settings page | Lua file |
| --- | --- |
| Keyboard Shortcuts | `dms/binds-user.lua` |
| Displays | `dms/outputs.lua` |
| Theme Colors | `dms/colors.lua` |
| Cursor | `dms/cursor.lua` |
| Window Rules | `dms/windowrules.lua` |
The main config should include the DMS fragments:
```lua
require("dms.colors")
require("dms.outputs")
require("dms.layout")
require("dms.cursor")
require("dms.binds")
require("dms.binds-user")
require("dms.windowrules")
```
### Keyboard Shortcuts: Delete and Reset
The Keyboard Shortcuts page exposes two actions on any DMS-managed bind:
- **Delete** — removes the shortcut entirely. For default DMS shortcuts (from
`dms/binds.lua`), this saves an `hl.unbind("KEY")` line into
`dms/binds-user.lua` so the removal sticks across `dms setup` runs.
- **Reset to default** — only visible when you are editing a user override of
a DMS default. It drops your override so the original DMS default re-applies.
Binds from your own `hyprland.lua` (outside the `dms/` folder) are read-only
in Settings — DMS does not write into files it does not manage.
## Starting Hyprland
For the Lua config to be active, Hyprland must start with:
```sh
Hyprland -c ~/.config/hypr/hyprland.lua
```
If Hyprland warns that it is using an autogenerated config, or the warning
mentions `hyprland.conf`, the session is not using the DMS Lua config yet.
## Verify Everything
After updating DMS, run:
```sh
dms setup
hyprctl reload
hyprctl configerrors
```
If the current session was not started from `hyprland.lua`, restart Hyprland with
the Lua config and check again.
Useful file checks:
```sh
test -f ~/.config/hypr/hyprland.lua
test ! -f ~/.config/hypr/hyprland.conf
ls ~/.config/hypr/dms
```
The live `dms` folder should contain Lua files like `binds.lua`,
`binds-user.lua`, `outputs.lua`, and `windowrules.lua`.
Note: Hyprland 0.55 still auto-generates `hyprland.conf` if you launch it
without `-c ~/.config/hypr/hyprland.lua`. DMS sweeps any stray
`hyprland.conf` into `.dms-backups/<timestamp>/` on the next `dms run`
startup, so the second check above is the right long-term state. If you see
`hyprland.conf` persist between `dms run` invocations, the session was not
started from `hyprland.lua` — restart Hyprland with the `-c` flag (or update
your session/desktop entry to include it).
## Troubleshooting
If shortcuts do not work, confirm `hyprland.lua` includes both:
```lua
require("dms.binds")
require("dms.binds-user")
```
If `hyprctl configerrors` reports errors in `dms/binds.lua`, rerun `dms setup`
with the latest DMS binary so the DMS-managed shortcut file is refreshed.
If a migrated monitor setup looks wrong, compare:
```text
~/.config/hypr/dms/outputs.lua
~/.config/hypr/.dms-backups/<timestamp>/
```
Your previous config should be available in the timestamped backup folder.
## Reference Map
```text
~/.config/hypr/
|-- hyprland.lua # Main DMS Hyprland config
|-- .dms-backups/ # Timestamped backups from setup/migration
`-- dms/
|-- colors.lua # Theme colors
|-- outputs.lua # Monitor/output config
|-- layout.lua # Layout, gaps, borders, decoration
|-- cursor.lua # Cursor settings
|-- binds.lua # DMS-managed default shortcuts
|-- binds-user.lua # User shortcut overrides
`-- windowrules.lua # DMS-managed window rules
```
Legacy files such as `hyprland.conf` and `dms/*.conf` should live in
`.dms-backups/<timestamp>/` after migration, not in the active config tree.
## Maintainer Note
Embedded source files live in `core/internal/config/embedded/` and use names like
`hypr-binds.lua`. Installed user files use shorter names like `dms/binds.lua`.
After changing Hyprland config deployment or parsing, run:
```sh
cd core
go test ./internal/config ./internal/keybinds/providers ./internal/windowrules/providers
go test ./...
```
+46
View File
@@ -212,6 +212,52 @@ dms ipc call lock lock
dms ipc call lock isLocked
```
## Target: `sessions`
Logind session enumeration and seat-local session switching. Wraps `loginctl list-sessions` and `loginctl activate`. Only switches between sessions that are *already running* on the current seat — creating a fresh login as another user requires a multi-session greeter setup (greetd-flexiserver / GDM / LightDM) and is out of scope.
### Functions
**`list`**
- Print every session DMS knows about as tab-separated columns: `sessionId\tusername\tseat\ttty\ttype\tcurrent-marker`
- Returns: Multi-line string. The current session is marked with `*current*`.
**`refresh`**
- Re-enumerate sessions in the background (the picker also refreshes itself on open)
- Returns: `"ok"`
**`open`**
- Refresh and open the Switch User picker on the focused screen
- Returns: `"ok"`
**`activate <sessionId>`**
- Activate a session by its numeric logind ID (the `Id=` field from `loginctl show-session`). Performs a VT switch
- Parameters: `sessionId` - Numeric session ID
- Returns: `"ok"` on dispatch, `"ERROR: missing session id"` if blank
- Note: Failures from `loginctl activate` surface through the `switchFailed` QML signal and a Log warning — the IPC call returns success once the spawn is queued, not after activation completes
**`switchTo <target>`**
- Switch to another session by username *or* session ID. The first non-current session matching the username wins; if there's no match, the call fails through the same logging path as `activate`
- Parameters: `target` - Username (e.g. `testuser2`) or numeric session ID
- Returns: `"ok"` on dispatch, `"ERROR: missing target (username or session id)"` if blank
### Examples
```bash
# Inspect what's switchable
dms ipc call sessions list
# Open the picker (useful for a keybind)
dms ipc call sessions open
# Jump straight to another logged-in user without the picker
dms ipc call sessions switchTo testuser2
# Or by session ID, when the user has multiple sessions
dms ipc call sessions activate 4
```
The dedicated `dms switch-user [target]` CLI command wraps the same behavior with a friendlier error path (it prints the switchable list when no target matches).
## Target: `inhibit`
Idle inhibitor control to prevent automatic sleep/lock.
+38
View File
@@ -0,0 +1,38 @@
function shQuote(value) {
return "'" + String(value ?? "").replace(/'/g, "'\\''") + "'";
}
function dirname(path) {
const idx = String(path ?? "").lastIndexOf("/");
return idx > 0 ? path.substring(0, idx) : ".";
}
function buildRepairScript(options) {
const configFile = options.configFile;
const backupFile = options.backupFile;
const fragments = options.fragmentFiles || (options.fragmentFile ? [options.fragmentFile] : []);
const includes = options.includes || [{
grepPattern: options.grepPattern,
includeLine: options.includeLine
}];
const commands = [];
if (backupFile)
commands.push(`cp ${shQuote(configFile)} ${shQuote(backupFile)} 2>/dev/null || true`);
const dirs = {};
for (const fragment of fragments)
dirs[dirname(fragment)] = true;
for (const dir in dirs)
commands.push(`mkdir -p ${shQuote(dir)}`);
if (fragments.length > 0)
commands.push("touch " + fragments.map(shQuote).join(" "));
for (const include of includes) {
if (!include.grepPattern || !include.includeLine)
continue;
commands.push(`if ! grep -v '^[[:space:]]*\\(//\\|#\\|--\\)' ${shQuote(configFile)} 2>/dev/null | grep -q ${shQuote(include.grepPattern)}; then echo '' >> ${shQuote(configFile)} && printf '%s\\n' ${shQuote(include.includeLine)} >> ${shQuote(configFile)}; fi`);
}
return commands.join("; ");
}
+7 -4
View File
@@ -8,9 +8,12 @@ const ACTION_TYPES = [
];
const DMS_ACTIONS = [
{ id: "spawn dms ipc call spotlight toggle", label: "App Launcher: Toggle" },
{ id: "spawn dms ipc call spotlight open", label: "App Launcher: Open" },
{ id: "spawn dms ipc call spotlight close", label: "App Launcher: Close" },
{ id: "spawn dms ipc call spotlight toggle", label: "Default Launcher: Toggle" },
{ id: "spawn dms ipc call spotlight open", label: "Default Launcher: Open" },
{ id: "spawn dms ipc call spotlight close", label: "Default Launcher: Close" },
{ id: "spawn dms ipc call spotlight-bar toggle", label: "Spotlight Bar: Toggle" },
{ id: "spawn dms ipc call spotlight-bar open", label: "Spotlight Bar: Open" },
{ id: "spawn dms ipc call spotlight-bar close", label: "Spotlight Bar: Close" },
{ id: "spawn dms ipc call clipboard toggle", label: "Clipboard: Toggle" },
{ id: "spawn dms ipc call clipboard open", label: "Clipboard: Open" },
{ id: "spawn dms ipc call clipboard close", label: "Clipboard: Close" },
@@ -63,7 +66,7 @@ const DMS_ACTIONS = [
{ id: "spawn dms ipc call mpris increment 5", label: "Player Volume Up (5%)" },
{ id: "spawn dms ipc call mpris decrement 5", label: "Player Volume Down (5%)" },
{ id: "spawn dms ipc call audio mute", label: "Volume Mute Toggle" },
{ id: "spawn dms ipc call audio micmute", label: "Microphone Mute Toggle" },
{ id: "spawn dms ipc call mic mute", label: "Microphone Mute Toggle" },
{ id: "spawn dms ipc call audio cycleoutput", label: "Audio Output: Cycle" },
{ id: "spawn dms ipc call brightness increment 5 \"\"", label: "Brightness Up" },
{ id: "spawn dms ipc call brightness increment 1 \"\"", label: "Brightness Up (1%)" },
+11 -3
View File
@@ -9,9 +9,11 @@ Singleton {
property var currentOSDsByScreen: ({})
Connections {
target: Quickshell
function onScreensChanged() {
Timer {
id: screensChangedDelayTimer
interval: 3000 // 3 seconds
repeat: false
onTriggered: {
const activeNames = {};
for (let i = 0; i < Quickshell.screens.length; i++)
activeNames[Quickshell.screens[i].name] = true;
@@ -22,6 +24,12 @@ Singleton {
}
}
}
Connections {
target: Quickshell
function onScreensChanged() {
screensChangedDelayTimer.restart();
}
}
function showOSD(osd) {
if (!osd || !osd.screen)
+12
View File
@@ -187,6 +187,7 @@ Singleton {
property string timeLocale: ""
property string launcherLastMode: "all"
property string launcherLastFileSearchType: "all"
property string launcherLastQuery: ""
property var launcherQueryHistory: []
property string appDrawerLastMode: "apps"
@@ -1178,6 +1179,17 @@ Singleton {
saveSettings();
}
function getLauncherRestoreMode() {
if (!SettingsData.rememberLastMode)
return "all";
return launcherLastMode || "all";
}
function setLauncherLastFileSearchType(type) {
launcherLastFileSearchType = type;
saveSettings();
}
function setLauncherLastQuery(query) {
launcherLastQuery = query;
saveSettings();
+8 -3
View File
@@ -258,8 +258,6 @@ Singleton {
onFrameLauncherEmergeSideChanged: saveSettings()
property bool frameLauncherArcExtender: false
onFrameLauncherArcExtenderChanged: saveSettings()
property bool frameUseSpotlightLauncher: false
onFrameUseSpotlightLauncherChanged: saveSettings()
readonly property string frameModalEmergeSide: frameLauncherEmergeSide === "top" ? "bottom" : "top"
property string frameMode: "connected"
onFrameModeChanged: saveSettings()
@@ -394,6 +392,7 @@ Singleton {
property string audioScrollMode: "volume"
property int audioWheelScrollAmount: 5
property bool clockCompactMode: false
property int focusedWindowSize: 1
property bool focusedWindowCompactMode: false
property bool runningAppsCompactMode: true
property int barMaxVisibleApps: 0
@@ -436,6 +435,7 @@ Singleton {
property int appLauncherGridColumns: 4
property bool spotlightCloseNiriOverview: true
property bool rememberLastQuery: false
property bool rememberLastMode: true
property var spotlightSectionViewModes: ({})
onSpotlightSectionViewModesChanged: saveSettings()
property var appDrawerSectionViewModes: ({})
@@ -449,7 +449,9 @@ Singleton {
property bool dankLauncherV2UnloadOnClose: false
property bool dankLauncherV2IncludeFilesInAll: false
property bool dankLauncherV2IncludeFoldersInAll: false
property bool launcherUseOverlayLayer: false
property string launcherStyle: "full"
property bool spotlightBarShowModeChips: false
property string _legacyWeatherLocation: "New York, NY"
property string _legacyWeatherCoordinates: "40.7128,-74.0060"
@@ -606,7 +608,7 @@ Singleton {
property bool showDock: false
property bool dockAutoHide: false
property bool dockSmartAutoHide: false
property bool dockHideOnFullscreen: true
property bool dockUseOverlayLayer: false
property bool dockGroupByApp: false
property bool dockRestoreSpecialWorkspaceOnClick: false
property bool dockOpenOnOverview: false
@@ -686,6 +688,7 @@ Singleton {
property int notificationTimeoutNormal: 5000
property int notificationTimeoutCritical: 0
property bool notificationCompactMode: false
property bool notificationDedupeEnabled: true
property int notificationPopupPosition: SettingsData.Position.Top
property int notificationAnimationSpeed: SettingsData.AnimationSpeed.Short
property int notificationCustomAnimationDuration: 400
@@ -706,6 +709,7 @@ Singleton {
property bool osdBrightnessEnabled: true
property bool osdIdleInhibitorEnabled: true
property bool osdMicMuteEnabled: true
property bool osdMicVolumeEnabled: true
property bool osdCapsLockEnabled: true
property bool osdPowerProfileEnabled: true
property bool osdAudioOutputEnabled: true
@@ -787,6 +791,7 @@ Singleton {
"popupGapsAuto": true,
"popupGapsManual": 4,
"maximizeDetection": true,
"useOverlayLayer": false,
"scrollEnabled": true,
"scrollXBehavior": "column",
"scrollYBehavior": "workspace",
@@ -87,6 +87,7 @@ var SPEC = {
timeLocale: { def: "" },
launcherLastMode: { def: "all" },
launcherLastFileSearchType: { def: "all" },
launcherLastQuery: { def: "" },
launcherQueryHistory: { def: [] },
appDrawerLastMode: { def: "apps" },
+7 -3
View File
@@ -153,6 +153,7 @@ var SPEC = {
audioWheelScrollAmount: { def: 5 },
clockCompactMode: { def: false },
focusedWindowCompactMode: { def: false },
focusedWindowSize: { def: 1 },
runningAppsCompactMode: { def: true },
barMaxVisibleApps: { def: 0 },
barMaxVisibleRunningApps: { def: 0 },
@@ -202,6 +203,7 @@ var SPEC = {
appLauncherGridColumns: { def: 4 },
spotlightCloseNiriOverview: { def: true },
rememberLastQuery: { def: false },
rememberLastMode: { def: true },
spotlightSectionViewModes: { def: {} },
appDrawerSectionViewModes: { def: {} },
niriOverviewOverlayEnabled: { def: true },
@@ -213,7 +215,9 @@ var SPEC = {
dankLauncherV2UnloadOnClose: { def: false },
dankLauncherV2IncludeFilesInAll: { def: false },
dankLauncherV2IncludeFoldersInAll: { def: false },
launcherUseOverlayLayer: { def: false },
launcherStyle: { def: "full" },
spotlightBarShowModeChips: { def: false },
useAutoLocation: { def: false },
weatherEnabled: { def: true },
@@ -332,7 +336,7 @@ var SPEC = {
showDock: { def: false },
dockAutoHide: { def: false },
dockSmartAutoHide: { def: false },
dockHideOnFullscreen: { def: true },
dockUseOverlayLayer: { def: false },
dockGroupByApp: { def: false },
dockRestoreSpecialWorkspaceOnClick: { def: false },
dockOpenOnOverview: { def: false },
@@ -395,6 +399,7 @@ var SPEC = {
notificationTimeoutNormal: { def: 5000 },
notificationTimeoutCritical: { def: 0 },
notificationCompactMode: { def: false },
notificationDedupeEnabled: { def: true },
notificationPopupPosition: { def: 0 },
notificationAnimationSpeed: { def: 1 },
notificationCustomAnimationDuration: { def: 400 },
@@ -496,7 +501,7 @@ var SPEC = {
popupGapsAuto: true,
popupGapsManual: 4,
maximizeDetection: true,
fullscreenDetection: true,
useOverlayLayer: false,
scrollEnabled: true,
scrollXBehavior: "column",
scrollYBehavior: "workspace",
@@ -573,7 +578,6 @@ var SPEC = {
frameCloseGaps: { def: true },
frameLauncherEmergeSide: { def: "bottom" },
frameLauncherArcExtender: { def: false },
frameUseSpotlightLauncher: { def: false },
frameMode: { def: "connected" }
};
+150 -4
View File
@@ -30,6 +30,7 @@ import qs.Services
Item {
id: root
readonly property var log: Log.scoped("DMSShell")
readonly property var _sessionsServiceRef: SessionsService
property bool osdSurfacesLoaded: true
property int pendingOsdResumeReloads: 0
@@ -63,15 +64,27 @@ Item {
}
}
property bool wallpaperSurfacesLoaded: true
Loader {
id: blurredWallpaperBackgroundLoader
active: SettingsData.blurredWallpaperLayer && CompositorService.isNiri
active: root.wallpaperSurfacesLoaded && SettingsData.blurredWallpaperLayer && CompositorService.isNiri
asynchronous: false
sourceComponent: BlurredWallpaperBackground {}
}
WallpaperBackground {}
DeferredAction {
id: wallpaperSurfaceReloadAction
onTriggered: root.wallpaperSurfacesLoaded = true
}
Loader {
id: wallpaperBackgroundLoader
active: root.wallpaperSurfacesLoaded
asynchronous: false
sourceComponent: WallpaperBackground {}
}
DesktopWidgetLayer {}
@@ -168,6 +181,8 @@ Item {
property bool barSurfacesLoaded: true
function recreateBarSurfaces() {
log.info("Recreating bar surfaces, screens:", Quickshell.screens.length,
Quickshell.screens.map(s => s.name).join(","));
if (barSurfacesLoaded)
barSurfacesLoaded = false;
barSurfaceReloadAction.schedule();
@@ -217,7 +232,18 @@ Item {
}
}
Frame {}
property bool frameSurfacesLoaded: true
Loader {
active: root.frameSurfacesLoaded
asynchronous: false
sourceComponent: Frame {}
}
DeferredAction {
id: frameSurfaceReloadAction
onTriggered: root.frameSurfacesLoaded = true
}
Repeater {
id: dankBarRepeater
@@ -301,6 +327,81 @@ Item {
onTriggered: root.osdSurfacesLoaded = true
}
property bool hadRealScreen: true
function _hasRealScreen() {
for (let i = 0; i < Quickshell.screens.length; i++) {
if (Quickshell.screens[i].name.length > 0)
return true;
}
return false;
}
function triggerSurfaceRecovery(source) {
log.info("Surface recovery triggered by:", source,
"screens:", Quickshell.screens.length,
Quickshell.screens.map(s => s.name).join(","),
"barLoaded:", root.barSurfacesLoaded,
"frameLoaded:", root.frameSurfacesLoaded,
"dockEnabled:", root.dockEnabled);
surfaceResumeRecoveryTimer.pass = 0;
surfaceResumeRecoveryTimer.interval = 800;
surfaceResumeRecoveryTimer.restart();
}
Connections {
target: Quickshell
function onScreensChanged() {
const hasReal = root._hasRealScreen();
log.info("Screens changed:", Quickshell.screens.length,
Quickshell.screens.map(s => "'" + s.name + "'").join(","),
"hasReal:", hasReal, "hadReal:", root.hadRealScreen);
if (!root.hadRealScreen && hasReal) {
log.info("Real screen reappeared after placeholder state, triggering surface recovery");
root.triggerSurfaceRecovery("screen-reconnect");
}
root.hadRealScreen = hasReal;
}
}
Timer {
id: surfaceResumeRecoveryTimer
interval: 800
repeat: false
property int pass: 0
onTriggered: {
pass++;
log.info("Surface recovery pass", pass,
"screens:", Quickshell.screens.length,
Quickshell.screens.map(s => s.name).join(","));
root.recreateBarSurfaces();
if (root.frameSurfacesLoaded) {
root.frameSurfacesLoaded = false;
frameSurfaceReloadAction.schedule();
}
if (root.wallpaperSurfacesLoaded) {
root.wallpaperSurfacesLoaded = false;
wallpaperSurfaceReloadAction.schedule();
}
root.dockEnabled = false;
Qt.callLater(() => {
root.dockEnabled = true;
});
if (pass < 2) {
interval = 2000;
restart();
} else {
pass = 0;
interval = 800;
}
}
}
Component.onCompleted: {
dockRecreateDebounce.start();
// Force PolkitService singleton to initialize
@@ -725,6 +826,25 @@ Item {
}
}
LazyLoader {
id: spotlightBarModalLoader
active: false
Component.onCompleted: {
PopoutService.spotlightBarModalLoader = spotlightBarModalLoader;
}
DankLauncherV2ModalSpotlight {
id: spotlightBarModal
Component.onCompleted: {
PopoutService.spotlightBarModal = spotlightBarModal;
PopoutService._onSpotlightBarModalLoaded();
}
}
}
LazyLoader {
id: clipboardHistoryPopoutLoader
@@ -868,9 +988,17 @@ Item {
target: SessionService
function onSessionResumed() {
log.info("Session resumed: screens:", Quickshell.screens.length,
Quickshell.screens.map(s => s.name).join(","),
"barLoaded:", root.barSurfacesLoaded,
"frameLoaded:", root.frameSurfacesLoaded,
"dockEnabled:", root.dockEnabled);
root.pendingOsdResumeReloads = 2;
osdResumeRecreateTimer.interval = 400;
osdResumeRecreateTimer.restart();
root.triggerSurfaceRecovery("sessionResumed");
}
}
@@ -1019,12 +1147,30 @@ Item {
lock.activate();
}
onSwitchUserRequested: {
switchUserModalLoader.active = true;
Qt.callLater(() => {
if (switchUserModalLoader.item)
switchUserModalLoader.item.showFromPowerMenu();
});
}
Component.onCompleted: {
PopoutService.powerMenuModal = powerMenuModal;
}
}
}
LazyLoader {
id: switchUserModalLoader
active: false
SwitchUserModal {
id: switchUserModal
}
}
LazyLoader {
id: hyprKeybindsModalLoader
@@ -1095,7 +1241,7 @@ Item {
Variants {
model: SettingsData.getFilteredScreens("osd")
delegate: MicMuteOSD {
delegate: MicVolumeOSD {
modelData: item
}
}
+49
View File
@@ -1340,6 +1340,25 @@ Item {
target: "spotlight"
}
IpcHandler {
function open(): string {
PopoutService.openSpotlightBar();
return "SPOTLIGHT_BAR_OPEN_SUCCESS";
}
function close(): string {
PopoutService.closeSpotlightBar();
return "SPOTLIGHT_BAR_CLOSE_SUCCESS";
}
function toggle(): string {
PopoutService.toggleSpotlightBar();
return "SPOTLIGHT_BAR_TOGGLE_SUCCESS";
}
target: "spotlight-bar"
}
IpcHandler {
function info(message: string): string {
if (!message)
@@ -1775,6 +1794,36 @@ Item {
target: "outputs"
}
IpcHandler {
target: "mic"
function setvolume(percentage: string): string {
return AudioService.setMicVolume(parseInt(percentage));
}
function increment(step: string): string {
return AudioService.incrementMicVolume(step);
}
function decrement(step: string): string {
return AudioService.decrementMicVolume(step);
}
function mute(): string {
return AudioService.toggleMicMute();
}
function status(): string {
if (!AudioService.source || !AudioService.source.audio) {
return "No audio source available";
}
const volume = Math.round(AudioService.source.audio.volume * 100);
const muteStatus = AudioService.source.audio.muted ? " (muted)" : "";
return `Microphone: ${volume}%${muteStatus}`;
}
}
IpcHandler {
function findTrayItem(itemId: string): var {
if (!itemId)
@@ -145,6 +145,7 @@ Item {
onDeleteRequested: clipboardContent.modal.deleteEntry(modelData)
onPinRequested: clipboardContent.modal.pinEntry(modelData)
onUnpinRequested: clipboardContent.modal.unpinEntry(modelData)
onEditRequested: clipboardContent.modal.editEntry(modelData)
}
}
@@ -204,6 +205,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,519 @@
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 chars = new Array(sanitized.length);
for (let i = 0; i < sanitized.length; i++) {
chars[i] = sanitized.charAt(i);
}
let buffer = null;
if (typeof Qt !== "undefined" && typeof Qt.atob === "function") {
buffer = Qt.atob(chars);
} else if (typeof atob === "function") {
const binary = atob(sanitized);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
buffer = bytes.buffer;
}
if (!buffer || buffer.byteLength === 0) {
return data;
}
const bytes = new Uint8Array(buffer);
let binary = "";
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
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();
}
});
if (!newEntry || newEntry.isImage) {
return;
}
const requestedId = newEntry.id;
DMSService.sendRequest("clipboard.getEntry", {
"id": requestedId
}, function (response) {
if (response.error) {
return;
}
if (!root.entry || root.entry.id !== requestedId) {
return;
}
const result = response.result;
let fullText = "";
if (result?.data) {
fullText = root.decodeEntryData(result.data);
} else {
fullText = result?.preview ?? "";
}
if (!fullText || fullText.length === 0) {
return;
}
root.editorText = fullText;
if (editField) {
editField.text = fullText;
}
});
}
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)
text: root.editorText
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");
}
}
}
}
}
}
}
+20 -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,20 @@ Rectangle {
onClicked: entry.pinned ? unpinRequested() : pinRequested()
}
DankActionButton {
iconName: "edit"
iconSize: Theme.iconSize - 6
iconColor: Theme.surfaceText
onClicked: {
if (entryType === "image") {
// TODO - forward to editing software
} else {
editRequested();
}
}
}
DankActionButton {
iconName: "close"
iconSize: Theme.iconSize - 6
@@ -142,8 +157,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 => {
@@ -43,6 +43,18 @@ DankModal {
service: ClipboardService
}
property string mode: "history"
onModeChanged: {
if (mode !== "history") {
return;
}
Qt.callLater(function () {
if (contentLoader.item?.searchField) {
contentLoader.item.searchField.forceActiveFocus();
}
});
}
function updateFilteredModel() {
ClipboardService.updateFilteredModel();
}
@@ -61,6 +73,7 @@ DankModal {
function show() {
open();
mode = "history";
activeImageLoads = 0;
shouldHaveFocus = true;
ClipboardService.reset();
@@ -130,6 +143,21 @@ DankModal {
return ClipboardService.getEntryType(entry);
}
function editEntry(entry) {
if (!entry) {
return;
}
if (entry.isImage) {
return;
}
const editor = contentLoader.item?.editorView;
if (!editor) {
return;
}
editor.setEntry(entry);
mode = "editor";
}
visible: false
modalWidth: ClipboardConstants.modalWidth
modalHeight: ClipboardConstants.modalHeight
@@ -138,6 +166,7 @@ DankModal {
borderColor: Theme.outlineMedium
borderWidth: 1
enableShadow: true
closeOnEscapeKey: mode !== "editor"
onBackgroundClicked: hide()
modalFocusScope.Keys.onPressed: function (event) {
keyboardController.handleKey(event);
@@ -174,9 +203,109 @@ DankModal {
property var confirmDialog: clearConfirmDialog
clipboardContent: Component {
ClipboardContent {
modal: clipboardHistoryModal
clearConfirmDialog: clipboardHistoryModal.confirmDialog
Item {
id: viewContainer
property alias editorView: editorView
property alias searchField: historyContent.searchField
anchors.fill: parent
Item {
id: historyView
anchors.fill: parent
opacity: 1
scale: 1
visible: opacity > 0.01
enabled: clipboardHistoryModal.mode === "history"
ClipboardContent {
id: historyContent
anchors.fill: parent
modal: clipboardHistoryModal
clearConfirmDialog: clipboardHistoryModal.confirmDialog
}
}
ClipboardEditor {
id: editorView
anchors.fill: parent
opacity: 0
scale: 0.98
visible: opacity > 0.01
enabled: clipboardHistoryModal.mode === "editor"
focus: clipboardHistoryModal.mode === "editor"
modal: clipboardHistoryModal
keyController: keyboardController
}
states: [
State {
name: "history"
when: clipboardHistoryModal.mode === "history"
PropertyChanges {
target: historyView
opacity: 1
scale: 1
}
PropertyChanges {
target: editorView
opacity: 0
scale: 0.98
}
},
State {
name: "editor"
when: clipboardHistoryModal.mode === "editor"
PropertyChanges {
target: historyView
opacity: 0
scale: 0.98
}
PropertyChanges {
target: editorView
opacity: 1
scale: 1
}
}
]
transitions: [
Transition {
from: "history"
to: "editor"
ParallelAnimation {
NumberAnimation {
property: "opacity"
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
NumberAnimation {
property: "scale"
duration: Theme.shortDuration
easing.type: Theme.emphasizedEasing
}
}
},
Transition {
from: "editor"
to: "history"
ParallelAnimation {
NumberAnimation {
property: "opacity"
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
NumberAnimation {
property: "scale"
duration: Theme.shortDuration
easing.type: Theme.emphasizedEasing
}
}
}
]
}
}
}
@@ -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
}
}
@@ -38,7 +38,7 @@ Item {
readonly property string resolvedConnectedBarSide: frameConnectedMode ? preferredConnectedBarSide : ""
readonly property bool frameOwnsConnectedChrome: frameConnectedMode && resolvedConnectedBarSide !== "" && !allowStacking
readonly property bool frameOwnsConnectedChrome: frameConnectedMode && resolvedConnectedBarSide !== "" && !allowStacking && CompositorService.usesConnectedFrameChromeForScreen(effectiveScreen)
function _dockOccupiesSide(side) {
if (!SettingsData.showDock)
@@ -58,7 +58,7 @@ Item {
readonly property bool _dockBlocksEmergence: frameOwnsConnectedChrome && _dockOccupiesSide(resolvedConnectedBarSide)
readonly property bool connectedMotionParity: Theme.isConnectedEffect
readonly property bool connectedMotionParity: frameOwnsConnectedChrome
property int animationDuration: connectedMotionParity ? Theme.popoutAnimationDuration : Theme.modalAnimationDuration
property real animationScaleCollapsed: Theme.effectScaleCollapsed
property real animationOffset: Theme.effectAnimOffset
@@ -68,7 +68,7 @@ Item {
property color borderColor: Theme.outlineMedium
property real borderWidth: 0
property real cornerRadius: Theme.cornerRadius
readonly property bool connectedSurfaceOverride: Theme.isConnectedEffect
readonly property bool connectedSurfaceOverride: frameOwnsConnectedChrome
readonly property color effectiveBackgroundColor: connectedSurfaceOverride ? Theme.connectedSurfaceColor : backgroundColor
readonly property color effectiveBorderColor: connectedSurfaceOverride ? "transparent" : borderColor
readonly property real effectiveBorderWidth: connectedSurfaceOverride ? 0 : borderWidth
@@ -346,7 +346,7 @@ Item {
readonly property real shadowFallbackOffset: 6
readonly property real shadowRenderPadding: (!frameOwnsConnectedChrome && root.enableShadow && Theme.elevationEnabled && SettingsData.modalElevationEnabled) ? Theme.elevationRenderPadding(shadowLevel, Theme.elevationLightDirection, shadowFallbackOffset, 8, 16) : 0
readonly property real shadowMotionPadding: {
if (Theme.isConnectedEffect)
if (frameOwnsConnectedChrome)
return 0;
if (animationType === "slide")
return 30;
@@ -10,10 +10,11 @@ Rectangle {
property var entry: null
property string cachedImageData: ""
property string cachedMimeType: ""
property var _requestedEntryId: null
readonly property bool canLoadImage: !!entry?.isImage && (entry?.mimeType ?? "").startsWith("image/")
readonly property string sourceUrl: cachedImageData.length > 0 ? "data:" + (entry?.mimeType ?? "image/png") + ";base64," + cachedImageData : ""
readonly property string sourceUrl: resolvedSourceUrl(cachedImageData, cachedMimeType || (entry?.mimeType ?? ""))
radius: Math.max(6, Theme.cornerRadius - 2)
clip: true
@@ -24,8 +25,24 @@ Rectangle {
onEntryChanged: reloadPreview()
Component.onCompleted: reloadPreview()
function isImageMimeType(mimeType) {
return (mimeType || "").toString().toLowerCase().startsWith("image/");
}
function resolvedSourceUrl(data, mimeType) {
const rawData = (data || "").toString();
if (rawData.length === 0)
return "";
if (rawData.startsWith("data:"))
return rawData.startsWith("data:image/") ? rawData : "";
if (!isImageMimeType(mimeType))
return "";
return "data:" + mimeType + ";base64," + rawData;
}
function reloadPreview() {
cachedImageData = "";
cachedMimeType = "";
if (!canLoadImage || !entry?.id) {
_requestedEntryId = null;
return;
@@ -40,9 +57,13 @@ Rectangle {
return;
if (response.error)
return;
const data = response.result?.data ?? "";
if (data.length > 0)
cachedImageData = data;
const result = response.result ?? {};
const mimeType = (result.mimeType ?? entry?.mimeType ?? "").toString();
const data = (result.data ?? "").toString();
if (data.length === 0 || !resolvedSourceUrl(data, mimeType))
return;
cachedMimeType = mimeType;
cachedImageData = data;
});
}
+63 -23
View File
@@ -35,25 +35,28 @@ Item {
property int gridColumns: SettingsData.appLauncherGridColumns
property int viewModeVersion: 0
property string viewModeContext: "spotlight"
property bool forceLinearNavigation: false
signal itemExecuted
signal searchCompleted
signal modeChanged(string mode)
signal modeChanged(string mode, bool userInitiated)
signal queryChanged(string query)
signal viewModeChanged(string sectionId, string mode)
signal searchQueryRequested(string query)
Ref {
service: AppSearchService
}
onActiveChanged: {
if (active) {
if (clipboardSearchEnabledInAll())
ClipboardService.ensureLauncherHistory();
} else {
if (!active) {
SessionData.addLauncherHistory(searchQuery);
sections = [];
flatModel = [];
selectedItem = null;
_clearModeCache();
ClipboardService.invalidateLauncherSearchCache();
}
}
@@ -88,11 +91,25 @@ Item {
Connections {
target: ClipboardService
function onInternalEntriesChanged() {
if (!active || !clipboardSearchEnabledInAll())
function onLauncherSearchReady(query) {
if (!active)
return;
if (searchMode === "all" && searchQuery.length >= 2)
performSearch();
const clipboardBuiltInActive = activePluginId === "dms_clipboard_search";
if (!clipboardBuiltInActive && !clipboardSearchEnabledInAll())
return;
if (!clipboardBuiltInActive && searchMode !== "all")
return;
const trimmed = (searchQuery || "").trim();
if (trimmed.length < 2 && query.length > 0)
return;
const triggerMatch = detectTrigger(trimmed);
const effectiveQuery = clipboardBuiltInActive && triggerMatch.pluginId === "dms_clipboard_search" ? triggerMatch.query : trimmed;
if (query !== effectiveQuery)
return;
searchDebounce.restart();
}
}
@@ -403,8 +420,19 @@ Item {
searchQuery = query;
searchDebounce.restart();
if (searchMode === "all" && clipboardSearchEnabledInAll() && query.length >= 2)
ClipboardService.ensureLauncherHistory();
if (searchMode !== "plugins" && query.startsWith("/")) {
var prefix = Utils.parseFileSearchPrefix(query);
var explicitType = prefix && prefix.type !== null ? prefix.type : null;
var targetType = explicitType !== null ? explicitType : (SessionData.launcherLastFileSearchType || "all");
if (searchMode !== "files") {
setMode("files", true, targetType);
} else if (fileSearchType !== targetType) {
fileSearchType = targetType;
}
if (explicitType !== null && SessionData.launcherLastFileSearchType !== explicitType) {
SessionData.setLauncherLastFileSearchType(explicitType);
}
}
var filesInAll = searchMode === "all" && (SettingsData.dankLauncherV2IncludeFilesInAll || SettingsData.dankLauncherV2IncludeFoldersInAll);
if (searchMode !== "plugins" && (searchMode === "files" || query.startsWith("/") || filesInAll) && query.length > 0) {
@@ -412,9 +440,14 @@ Item {
}
}
function setMode(mode, isAutoSwitch) {
if (searchMode === mode)
function setMode(mode, isAutoSwitch, fileTypeOverride, notPersist) {
if (searchMode === mode) {
if (mode === "files" && fileTypeOverride !== undefined && fileSearchType !== fileTypeOverride) {
fileSearchType = fileTypeOverride;
performFileSearch();
}
return;
}
if (isAutoSwitch) {
previousSearchMode = searchMode;
autoSwitchedToFiles = true;
@@ -422,10 +455,11 @@ Item {
autoSwitchedToFiles = false;
}
searchMode = mode;
modeChanged(mode);
if (mode === "files") {
fileSearchType = fileTypeOverride !== undefined ? fileTypeOverride : (SessionData.launcherLastFileSearchType || "all");
}
modeChanged(mode, !isAutoSwitch && notPersist !== true);
performSearch();
if (mode === "all" && clipboardSearchEnabledInAll() && searchQuery.length >= 2)
ClipboardService.ensureLauncherHistory();
var filesInAll = mode === "all" && (SettingsData.dankLauncherV2IncludeFilesInAll || SettingsData.dankLauncherV2IncludeFoldersInAll) && searchQuery.length > 0;
if (mode === "files" || filesInAll) {
fileSearchDebounce.restart();
@@ -437,7 +471,7 @@ Item {
return;
autoSwitchedToFiles = false;
searchMode = previousSearchMode;
modeChanged(previousSearchMode);
modeChanged(previousSearchMode, false);
performSearch();
}
@@ -533,6 +567,7 @@ Item {
if (fileSearchType === type)
return;
fileSearchType = type;
SessionData.setLauncherLastFileSearchType(type);
performFileSearch();
}
@@ -703,7 +738,8 @@ Item {
clearActivePluginViewPreference();
if (searchMode === "files") {
var fileQuery = searchQuery.startsWith("/") ? searchQuery.substring(1).trim() : searchQuery.trim();
var prefixInfo = Utils.parseFileSearchPrefix(searchQuery);
var fileQuery = prefixInfo ? prefixInfo.query : searchQuery.trim();
isFileSearching = fileQuery.length >= 2 && DSearchService.dsearchAvailable;
sections = [];
flatModel = [];
@@ -993,7 +1029,8 @@ Item {
var includeFolders = SettingsData.dankLauncherV2IncludeFoldersInAll;
if (searchQuery.startsWith("/")) {
fileQuery = searchQuery.substring(1).trim();
var prefixInfo = Utils.parseFileSearchPrefix(searchQuery);
fileQuery = prefixInfo ? prefixInfo.query : searchQuery.substring(1).trim();
} else if (searchMode === "files") {
fileQuery = searchQuery.trim();
} else if (searchMode === "all" && (includeFiles || includeFolders)) {
@@ -1209,7 +1246,6 @@ Item {
}
if (clipboardSearchEnabledInAll()) {
ClipboardService.ensureLauncherHistory();
var clipboardItems = AppSearchService.getBuiltInLauncherItems("dms_clipboard_search", query);
var clipboardLimit = Math.min(clipboardItems.length, 8);
for (var j = 0; j < clipboardLimit; j++) {
@@ -1713,7 +1749,9 @@ Item {
function selectNext() {
keyboardNavigationActive = true;
_cancelPendingSelectionReset();
var newIndex = Nav.calculateNextIndex(flatModel, selectedFlatIndex, null, null, gridColumns, getSectionViewMode);
var newIndex = forceLinearNavigation ? Nav.findNextNonHeaderIndex(flatModel, selectedFlatIndex + 1) : Nav.calculateNextIndex(flatModel, selectedFlatIndex, null, null, gridColumns, getSectionViewMode);
if (newIndex === -1)
newIndex = selectedFlatIndex;
if (newIndex !== selectedFlatIndex) {
selectedFlatIndex = newIndex;
updateSelectedItem();
@@ -1723,7 +1761,9 @@ Item {
function selectPrevious() {
keyboardNavigationActive = true;
_cancelPendingSelectionReset();
var newIndex = Nav.calculatePrevIndex(flatModel, selectedFlatIndex, null, null, gridColumns, getSectionViewMode);
var newIndex = forceLinearNavigation ? Nav.findPrevNonHeaderIndex(flatModel, selectedFlatIndex - 1) : Nav.calculatePrevIndex(flatModel, selectedFlatIndex, null, null, gridColumns, getSectionViewMode);
if (newIndex === -1)
newIndex = selectedFlatIndex;
if (newIndex !== selectedFlatIndex) {
selectedFlatIndex = newIndex;
updateSelectedItem();
@@ -1857,7 +1897,7 @@ Item {
if (browseTrigger && browseTrigger.length > 0) {
searchQueryRequested(browseTrigger);
} else {
setMode("plugins");
setMode("plugins", false, undefined, true);
pluginFilter = browsePluginId;
performSearch();
}
@@ -159,3 +159,14 @@ function sortPluginsOrdered(plugins, order) {
return aOrder - bOrder;
});
}
function parseFileSearchPrefix(query) {
if (!query || !query.startsWith("/"))
return null;
var rest = query.substring(1);
if (rest === "d" || rest.startsWith("d ") || rest.startsWith("d\t"))
return { type: "dir", query: rest.substring(1).trim() };
if (rest === "f" || rest.startsWith("f ") || rest.startsWith("f\t"))
return { type: "file", query: rest.substring(1).trim() };
return { type: null, query: rest.trim() };
}
@@ -23,6 +23,7 @@ Item {
readonly property bool frameOwnsConnectedChrome: impl.item ? (impl.item.frameOwnsConnectedChrome ?? false) : false
readonly property string resolvedConnectedBarSide: impl.item ? (impl.item.resolvedConnectedBarSide ?? "") : ""
readonly property bool launcherArcExtenderActive: impl.item ? (impl.item.launcherArcExtenderActive ?? false) : false
property bool triggerUsesOverlayLayer: false
signal dialogClosed
@@ -61,7 +62,7 @@ Item {
impl.item.toggleWithMode(mode);
}
readonly property bool useSpotlightBackend: SettingsData.connectedFrameModeActive ? SettingsData.frameUseSpotlightLauncher : SettingsData.launcherStyle === "spotlight"
readonly property bool useSpotlightBackend: !SettingsData.connectedFrameModeActive && SettingsData.launcherStyle === "spotlight"
readonly property var _desiredBackend: useSpotlightBackend ? spotlightComp : (SettingsData.connectedFrameModeActive ? connectedComp : standaloneComp)
property var _resolvedBackend: null
@@ -72,9 +73,6 @@ Item {
function onConnectedFrameModeActiveChanged() {
root._maybeResolveBackend();
}
function onFrameUseSpotlightLauncherChanged() {
root._maybeResolveBackend();
}
function onLauncherStyleChanged() {
root._maybeResolveBackend();
}
@@ -116,6 +114,7 @@ Item {
if (!it)
return;
it.modalHandle = root;
it.triggerUsesOverlayLayer = Qt.binding(() => root.triggerUsesOverlayLayer);
}
Connections {
@@ -13,13 +13,14 @@ Item {
readonly property var log: Log.scoped("DankLauncherV2ModalConnected")
property var modalHandle: root
property bool triggerUsesOverlayLayer: false
visible: false
property bool spotlightOpen: false
property bool keyboardActive: false
property bool contentVisible: false
readonly property bool launcherMotionVisible: Theme.isConnectedEffect ? _motionActive : (Theme.isDirectionalEffect ? spotlightOpen : _motionActive)
readonly property bool launcherMotionVisible: frameOwnsConnectedChrome ? _motionActive : (Theme.isDirectionalEffect ? spotlightOpen : _motionActive)
property var spotlightContent: launcherContentLoader.item
property bool openedFromOverview: false
property bool isClosing: false
@@ -40,6 +41,21 @@ Item {
readonly property real screenWidth: effectiveScreen?.width ?? 1920
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 int baseWidth: {
switch (SettingsData.dankLauncherV2Size) {
@@ -74,7 +90,7 @@ Item {
readonly property string resolvedConnectedBarSide: frameConnectedMode ? preferredConnectedBarSide : ""
readonly property bool frameOwnsConnectedChrome: frameConnectedMode && resolvedConnectedBarSide !== ""
readonly property bool frameOwnsConnectedChrome: frameConnectedMode && resolvedConnectedBarSide !== "" && CompositorService.usesConnectedFrameChromeForScreen(effectiveScreen)
readonly property bool launcherArcExtenderActive: frameOwnsConnectedChrome && SettingsData.frameLauncherArcExtender && (resolvedConnectedBarSide === "top" || resolvedConnectedBarSide === "bottom")
function _dockOccupiesSide(side) {
@@ -140,10 +156,10 @@ Item {
readonly property real modalX: frameOwnsConnectedChrome ? _connectedModalPos.x : ((screenWidth - modalWidth) / 2)
readonly property real modalY: frameOwnsConnectedChrome ? _connectedModalPos.y : ((screenHeight - modalHeight) / 2)
readonly property bool connectedSurfaceOverride: Theme.isConnectedEffect
readonly property int launcherAnimationDuration: Theme.isConnectedEffect ? Theme.popoutAnimationDuration : Theme.modalAnimationDuration
readonly property list<real> launcherEnterCurve: Theme.isConnectedEffect ? Theme.variantPopoutEnterCurve : Theme.variantModalEnterCurve
readonly property list<real> launcherExitCurve: Theme.isConnectedEffect ? Theme.variantPopoutExitCurve : Theme.variantModalExitCurve
readonly property bool connectedSurfaceOverride: frameOwnsConnectedChrome
readonly property int launcherAnimationDuration: frameOwnsConnectedChrome ? Theme.popoutAnimationDuration : Theme.modalAnimationDuration
readonly property list<real> launcherEnterCurve: frameOwnsConnectedChrome ? Theme.variantPopoutEnterCurve : Theme.variantModalEnterCurve
readonly property list<real> launcherExitCurve: frameOwnsConnectedChrome ? Theme.variantPopoutExitCurve : Theme.variantModalExitCurve
readonly property color backgroundColor: connectedSurfaceOverride ? Theme.connectedSurfaceColor : Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
readonly property real cornerRadius: connectedSurfaceOverride ? Theme.connectedSurfaceRadius : Theme.cornerRadius
readonly property color borderColor: {
@@ -372,6 +388,7 @@ Item {
if (!spotlightContent)
return;
contentVisible = true;
spotlightContent.closeTransientUi?.();
// NOTE: forceActiveFocus() is deliberately NOT called here.
// It is deferred to after animation starts to avoid compositor IPC stalls.
@@ -379,12 +396,12 @@ Item {
spotlightContent.searchField.text = query;
}
if (spotlightContent.controller) {
var targetMode = mode || SessionData.launcherLastMode || "all";
var targetMode = mode || SessionData.getLauncherRestoreMode();
spotlightContent.controller.searchMode = targetMode;
spotlightContent.controller.activePluginId = "";
spotlightContent.controller.activePluginName = "";
spotlightContent.controller.pluginFilter = "";
spotlightContent.controller.fileSearchType = "all";
spotlightContent.controller.fileSearchType = SessionData.launcherLastFileSearchType || "all";
spotlightContent.controller.fileSearchExt = "";
spotlightContent.controller.fileSearchFolder = "";
spotlightContent.controller.fileSearchSort = "score";
@@ -464,6 +481,7 @@ Item {
function hide() {
if (!spotlightOpen)
return;
spotlightContent?.closeTransientUi?.();
openedFromOverview = false;
isClosing = true;
// For directional effects, defer contentVisible=false so content stays rendered during exit slide
@@ -521,8 +539,8 @@ Item {
Connections {
target: spotlightContent?.controller ?? null
function onModeChanged(mode) {
if (spotlightContent.controller.autoSwitchedToFiles)
function onModeChanged(mode, userInitiated) {
if (!userInitiated || !SettingsData.rememberLastMode)
return;
SessionData.setLauncherLastMode(mode);
}
@@ -596,7 +614,7 @@ Item {
readonly property real _rightMargin: contentContainer.dockRight ? contentContainer.dockThickness : (typeof SettingsData !== "undefined" && SettingsData.barPosition === 3 ? Theme.px(42, root.dpr) : 0)
WlrLayershell.namespace: "dms:spotlight:bg"
WlrLayershell.layer: WlrLayershell.Top
WlrLayershell.layer: root.effectiveLauncherLayer
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
@@ -669,20 +687,7 @@ Item {
}
WlrLayershell.namespace: "dms:spotlight"
WlrLayershell.layer: {
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.effectiveLauncherLayer
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: keyboardActive ? (root.useHyprlandFocusGrab ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) : WlrKeyboardFocus.None
@@ -923,8 +928,12 @@ Item {
}
}
Keys.onPressed: event => root.spotlightContent?.activeContextMenu?.handleKey(event)
Keys.onEscapePressed: event => {
root.hide();
root.spotlightContent?.activeContextMenu?.handleKey(event);
if (!event.accepted)
root.hide();
event.accepted = true;
}
}
@@ -11,6 +11,7 @@ Item {
readonly property var log: Log.scoped("DankLauncherV2ModalSpotlight")
property var modalHandle: root
property bool triggerUsesOverlayLayer: false
visible: false
@@ -29,13 +30,29 @@ Item {
readonly property real screenWidth: effectiveScreen?.width ?? 1920
readonly property real screenHeight: effectiveScreen?.height ?? 1080
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 int _openDuration: 80
readonly property int _closeDuration: 70
readonly property int _motionDuration: 90
readonly property int _openDuration: 50
readonly property int _closeDuration: 40
readonly property int _motionDuration: 60
// Connected frame mode clamps the centered surface inside frame insets.
readonly property bool frameConnected: SettingsData.connectedFrameModeActive && !!effectiveScreen && SettingsData.isScreenInPreferences(effectiveScreen, SettingsData.frameScreenPreferences)
readonly property bool frameConnected: CompositorService.usesConnectedFrameChromeForScreen(effectiveScreen)
function _frameEdgeInset(side) {
if (!effectiveScreen || !frameConnected)
@@ -58,7 +75,7 @@ Item {
const searchBarH = 56;
const usableH = Math.max(searchBarH, screenHeight - insetT - insetB);
const preferred = insetT + Math.max(0, usableH * 0.33 - searchBarH / 2);
const maxY = Math.max(insetT, screenHeight - insetB - _contentImplicitH);
const maxY = Math.max(insetT, screenHeight - insetB - 56);
return Math.max(insetT, Math.min(preferred, maxY));
}
@@ -125,9 +142,10 @@ Item {
if (!spotlightContent)
return;
contentVisible = true;
spotlightContent.closeTransientUi?.();
const targetQuery = query || (SettingsData.rememberLastQuery ? (SessionData.launcherLastQuery || "") : "");
const targetMode = mode || SessionData.launcherLastMode || "all";
const targetMode = mode || SessionData.getLauncherRestoreMode();
if (spotlightContent.searchField) {
spotlightContent.searchField.text = targetQuery;
@@ -185,6 +203,7 @@ Item {
function hide() {
if (!spotlightOpen)
return;
spotlightContent?.closeTransientUi?.();
openedFromOverview = false;
isClosing = true;
contentVisible = false;
@@ -259,11 +278,11 @@ Item {
PanelWindow {
id: clickCatcher
screen: launcherWindow.screen
visible: spotlightOpen || isClosing
visible: (spotlightOpen || isClosing) && !root.useBackgroundDarken
color: "transparent"
WlrLayershell.namespace: "dms:spotlight:clickcatcher"
WlrLayershell.layer: WlrLayershell.Top
WlrLayershell.layer: root.effectiveLauncherLayer
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
@@ -324,31 +343,26 @@ Item {
}
WlrLayershell.namespace: "dms:spotlight"
WlrLayershell.layer: {
switch (Quickshell.env("DMS_MODAL_LAYER")) {
case "overlay":
return WlrLayershell.Overlay;
default:
return WlrLayershell.Top;
}
}
WlrLayershell.layer: root.effectiveLauncherLayer
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: keyboardActive ? (root.useHyprlandFocusGrab ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) : WlrKeyboardFocus.None
anchors {
top: true
left: true
right: root.useBackgroundDarken
bottom: root.useBackgroundDarken
}
WlrLayershell.margins {
left: root.windowX
top: root.windowY
left: root.useBackgroundDarken ? 0 : root.windowX
top: root.useBackgroundDarken ? 0 : root.windowY
right: 0
bottom: 0
}
implicitWidth: root.windowWidth
implicitHeight: root.windowHeight
implicitWidth: root.useBackgroundDarken ? 0 : root.windowWidth
implicitHeight: root.useBackgroundDarken ? 0 : root.windowHeight
mask: Region {
item: inputMask
@@ -358,19 +372,44 @@ Item {
id: inputMask
visible: false
color: "transparent"
x: modalContainer.x
y: modalContainer.y + modalContainer.slideOffset
width: root.alignedWidth
height: root._contentImplicitH
x: root.useBackgroundDarken ? 0 : modalContainer.x
y: root.useBackgroundDarken ? 0 : modalContainer.y + modalContainer.slideOffset
width: root.useBackgroundDarken ? launcherWindow.width : root.alignedWidth
height: root.useBackgroundDarken ? launcherWindow.height : root._contentImplicitH
}
MouseArea {
anchors.fill: parent
enabled: root.useBackgroundDarken && spotlightOpen
z: -2
onClicked: root.hide()
}
Rectangle {
id: backgroundDarken
anchors.fill: parent
color: "black"
opacity: contentVisible && root.useBackgroundDarken ? 0.5 : 0
visible: (spotlightOpen || isClosing) && (root.useBackgroundDarken || opacity > 0)
z: -3
Behavior on opacity {
NumberAnimation {
duration: contentVisible ? root._openDuration : root._closeDuration
easing.type: Easing.BezierSpline
easing.bezierCurve: contentVisible ? [0.0, 0.0, 0.2, 1.0, 1.0, 1.0] : [0.4, 0.0, 1.0, 1.0, 1.0, 1.0]
}
}
}
Item {
id: modalContainer
x: root.contentX
y: root.contentY
x: root.useBackgroundDarken ? root.alignedX : root.contentX
y: root.useBackgroundDarken ? root.alignedY : root.contentY
width: root.alignedWidth
height: root._animatedContentH
visible: _renderActive
z: 0
property bool _renderActive: contentVisible
property real slideOffset: contentVisible ? 0 : -root._animHeadroom
@@ -450,8 +489,12 @@ Item {
}
}
Keys.onPressed: event => root.spotlightContent?.activeContextMenu?.handleKey(event)
Keys.onEscapePressed: event => {
root.hide();
root.spotlightContent?.activeContextMenu?.handleKey(event);
if (!event.accepted)
root.hide();
event.accepted = true;
}
}
@@ -11,6 +11,7 @@ Item {
readonly property var log: Log.scoped("DankLauncherV2ModalStandalone")
property var modalHandle: root
property bool triggerUsesOverlayLayer: false
visible: false
@@ -31,7 +32,7 @@ Item {
readonly property real screenHeight: effectiveScreen?.height ?? 1080
readonly property real dpr: effectiveScreen ? CompositorService.getScreenScale(effectiveScreen) : 1
readonly property bool frameOwnsConnectedChrome: SettingsData.connectedFrameModeActive && !!effectiveScreen && SettingsData.isScreenInPreferences(effectiveScreen, SettingsData.frameScreenPreferences)
readonly property bool frameOwnsConnectedChrome: CompositorService.usesConnectedFrameChromeForScreen(effectiveScreen)
readonly property string resolvedConnectedBarSide: frameOwnsConnectedChrome ? (SettingsData.frameLauncherEmergeSide || "bottom") : ""
readonly property int baseWidth: {
@@ -79,6 +80,21 @@ 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 real cornerRadius: Theme.cornerRadius
readonly property color borderColor: {
if (!SettingsData.dankLauncherV2BorderEnabled)
@@ -117,6 +133,7 @@ Item {
if (!spotlightContent)
return;
contentVisible = true;
spotlightContent.closeTransientUi?.();
spotlightContent.searchField.forceActiveFocus();
var targetQuery = "";
@@ -131,12 +148,12 @@ Item {
spotlightContent.searchField.text = targetQuery;
}
if (spotlightContent.controller) {
var targetMode = mode || SessionData.launcherLastMode || "all";
var targetMode = mode || SessionData.getLauncherRestoreMode();
spotlightContent.controller.searchMode = targetMode;
spotlightContent.controller.activePluginId = "";
spotlightContent.controller.activePluginName = "";
spotlightContent.controller.pluginFilter = "";
spotlightContent.controller.fileSearchType = "all";
spotlightContent.controller.fileSearchType = SessionData.launcherLastFileSearchType || "all";
spotlightContent.controller.fileSearchExt = "";
spotlightContent.controller.fileSearchFolder = "";
spotlightContent.controller.fileSearchSort = "score";
@@ -195,6 +212,7 @@ Item {
function hide() {
if (!spotlightOpen)
return;
spotlightContent?.closeTransientUi?.();
openedFromOverview = false;
isClosing = true;
contentVisible = false;
@@ -242,8 +260,8 @@ Item {
Connections {
target: spotlightContent?.controller ?? null
function onModeChanged(mode) {
if (spotlightContent.controller.autoSwitchedToFiles)
function onModeChanged(mode, userInitiated) {
if (!userInitiated || !SettingsData.rememberLastMode || (mode !== "all" && mode !== "apps"))
return;
SessionData.setLauncherLastMode(mode);
}
@@ -296,12 +314,11 @@ Item {
PanelWindow {
id: clickCatcher
screen: launcherWindow.screen
visible: spotlightOpen || isClosing
visible: (spotlightOpen || isClosing) && !root.useBackgroundDarken
color: "transparent"
updatesEnabled: root.useBackgroundDarken && (spotlightOpen || isClosing)
WlrLayershell.namespace: "dms:spotlight:clickcatcher"
WlrLayershell.layer: WlrLayershell.Top
WlrLayershell.layer: root.effectiveLauncherLayer
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
@@ -342,22 +359,6 @@ Item {
enabled: spotlightOpen
onClicked: root.hide()
}
Rectangle {
id: backgroundDarken
anchors.fill: parent
color: "black"
opacity: contentVisible && root.useBackgroundDarken ? 0.5 : 0
visible: (spotlightOpen || isClosing) && (root.useBackgroundDarken || opacity > 0)
Behavior on opacity {
NumberAnimation {
easing.type: Easing.BezierSpline
duration: Theme.modalAnimationDuration
easing.bezierCurve: contentVisible ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized
}
}
}
}
PanelWindow {
@@ -369,7 +370,7 @@ Item {
WindowBlur {
targetWindow: launcherWindow
readonly property real s: Math.min(1, modalContainer.publishedScale)
readonly property real op: Math.max(0, Math.min(1, (modalContainer.opacity - 0.06) * 2))
readonly property real op: Math.max(0, Math.min(1, (modalContainer.publishedOpacity - 0.06) * 2))
blurX: modalContainer.x + modalContainer.width * (1 - s * op) * 0.5
blurY: modalContainer.y + modalContainer.height * (1 - s * op) * 0.5
blurWidth: contentVisible ? modalContainer.width * s * op : 0
@@ -378,39 +379,26 @@ Item {
}
WlrLayershell.namespace: "dms:spotlight"
WlrLayershell.layer: {
if (root.useBackgroundDarken)
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.effectiveLauncherLayer
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: keyboardActive ? (root.useHyprlandFocusGrab ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) : WlrKeyboardFocus.None
anchors {
top: true
left: true
right: root.useBackgroundDarken
bottom: root.useBackgroundDarken
}
WlrLayershell.margins {
left: root.windowX
top: root.windowY
left: root.useBackgroundDarken ? 0 : root.windowX
top: root.useBackgroundDarken ? 0 : root.windowY
right: 0
bottom: 0
}
implicitWidth: root.windowWidth
implicitHeight: root.windowHeight
implicitWidth: root.useBackgroundDarken ? 0 : root.windowWidth
implicitHeight: root.useBackgroundDarken ? 0 : root.windowHeight
mask: Region {
item: launcherInputMask
@@ -420,22 +408,48 @@ Item {
id: launcherInputMask
visible: false
color: "transparent"
x: modalContainer.x
y: modalContainer.y
width: modalContainer.width
height: modalContainer.height
x: root.useBackgroundDarken ? 0 : modalContainer.x
y: root.useBackgroundDarken ? 0 : modalContainer.y
width: root.useBackgroundDarken ? launcherWindow.width : modalContainer.width
height: root.useBackgroundDarken ? launcherWindow.height : modalContainer.height
}
MouseArea {
anchors.fill: parent
enabled: root.useBackgroundDarken && spotlightOpen
z: -2
onClicked: root.hide()
}
Rectangle {
id: backgroundDarken
anchors.fill: parent
color: "black"
opacity: contentVisible && root.useBackgroundDarken ? 0.5 : 0
visible: (spotlightOpen || isClosing) && (root.useBackgroundDarken || opacity > 0)
z: -3
Behavior on opacity {
NumberAnimation {
easing.type: Easing.BezierSpline
duration: Theme.modalAnimationDuration
easing.bezierCurve: contentVisible ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized
}
}
}
Item {
id: modalContainer
x: root.contentX
y: root.contentY
x: root.useBackgroundDarken ? root.alignedX : root.contentX
y: root.useBackgroundDarken ? root.alignedY : root.contentY
width: root.alignedWidth
height: root.alignedHeight
visible: _renderActive
z: 0
property bool _renderActive: contentVisible
property real publishedScale: contentVisible ? 1 : 0.96
property real publishedOpacity: contentVisible ? 1 : 0
opacity: contentVisible ? 1 : 0
scale: contentVisible ? 1 : 0.96
@@ -467,6 +481,14 @@ Item {
}
}
Behavior on publishedOpacity {
NumberAnimation {
easing.type: Easing.BezierSpline
duration: Theme.modalAnimationDuration
easing.bezierCurve: contentVisible ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized
}
}
Connections {
target: root
function onContentVisibleChanged() {
@@ -514,8 +536,12 @@ Item {
}
}
Keys.onPressed: event => root.spotlightContent?.activeContextMenu?.handleKey(event)
Keys.onEscapePressed: event => {
root.hide();
root.spotlightContent?.activeContextMenu?.handleKey(event);
if (!event.accepted)
root.hide();
event.accepted = true;
}
}
@@ -17,10 +17,22 @@ FocusScope {
property alias controller: controller
property alias resultsList: resultsList
property alias actionPanel: actionPanel
readonly property alias activeContextMenu: contextMenu
property bool editMode: false
property var editingApp: null
property string editAppId: ""
readonly property bool _blurActive: Theme.blurForegroundLayers || Theme.transparentBlurLayers
readonly property real _launcherFieldAlpha: {
if (Theme.transparentBlurLayers)
return 0.28;
if (Theme.blurForegroundLayers)
return Math.max(Theme.popupTransparency, 0.62);
return Theme.popupTransparency;
}
readonly property color _launcherSearchFieldColor: Theme.withAlpha(Theme.surfaceContainerHigh, _launcherFieldAlpha)
readonly property color _launcherSearchBorderColor: Theme.withAlpha(Theme.outline, _blurActive ? 0.16 : Theme.layerOutlineOpacity)
readonly property color _launcherSearchFocusedBorderColor: Theme.withAlpha(Theme.primary, _blurActive ? 0.72 : 1.0)
function resetScroll() {
resultsList.resetScroll();
@@ -30,6 +42,12 @@ FocusScope {
searchField.forceActiveFocus();
}
function closeTransientUi() {
contextMenu.hide();
actionPanel.hide();
root.enabled = true;
}
function openEditMode(app) {
if (!app)
return;
@@ -111,6 +129,21 @@ FocusScope {
}
}
Connections {
target: root.parentModal
ignoreUnknownSignals: true
function onSpotlightOpenChanged() {
if (!root.parentModal?.spotlightOpen)
root.closeTransientUi();
}
function onContentVisibleChanged() {
if (!root.parentModal?.contentVisible)
root.closeTransientUi();
}
}
Keys.onPressed: event => {
if (editMode) {
if (event.key === Qt.Key_Escape) {
@@ -257,13 +290,6 @@ FocusScope {
}
event.accepted = false;
return;
case Qt.Key_Slash:
if (event.modifiers === Qt.NoModifier && searchField.text.length === 0) {
controller.setMode("files", true);
return;
}
event.accepted = false;
return;
default:
event.accepted = false;
}
@@ -284,7 +310,7 @@ FocusScope {
anchors.bottom: parent.bottom
anchors.leftMargin: root.parentModal?.borderWidth ?? 1
anchors.rightMargin: root.parentModal?.borderWidth ?? 1
anchors.bottomMargin: _connectedBottomEmerge ? Theme.spacingS : (root.parentModal?.borderWidth ?? 1)
anchors.bottomMargin: _connectedBottomEmerge ? 0 : (root.parentModal?.borderWidth ?? 1)
height: showFooter ? (_connectedArcAtFooter ? 76 : 36) : 0
visible: showFooter
clip: true
@@ -293,7 +319,7 @@ FocusScope {
anchors.fill: parent
anchors.topMargin: -Theme.cornerRadius
// In connected mode the launcher provides the surface so update the toolbar for arcs
visible: !(root.parentModal?.frameOwnsConnectedChrome ?? false)
visible: !(root.parentModal?.frameOwnsConnectedChrome ?? false) && !root._blurActive
color: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
radius: Theme.cornerRadius
}
@@ -458,9 +484,11 @@ FocusScope {
id: searchField
width: parent.width - (pluginBadge.visible ? pluginBadge.width + Theme.spacingS : 0)
cornerRadius: Theme.cornerRadius
backgroundColor: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
normalBorderColor: Theme.outlineMedium
focusedBorderColor: Theme.primary
backgroundColor: root._launcherSearchFieldColor
normalBorderColor: root._launcherSearchBorderColor
focusedBorderColor: root._launcherSearchFocusedBorderColor
borderWidth: 1
focusedBorderWidth: 2
leftIconName: controller.activePluginId ? "extension" : controller.searchQuery.startsWith("/") ? "folder" : "search"
leftIconSize: Theme.iconSize
leftIconColor: Theme.surfaceVariantText
@@ -1,35 +1,72 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls
import Quickshell
import Quickshell.Wayland
import qs.Common
import qs.Services
import qs.Widgets
Popup {
Item {
id: root
visible: false
width: 0
height: 0
property var item: null
property var controller: null
property var searchField: null
property var parentHandler: null
property bool allowEditActions: true
property real menuMargin: 8
property var targetScreen: null
property real anchorX: 0
property real anchorY: 0
property bool openState: false
property bool renderActive: false
readonly property bool blurActive: renderActive && openState && BlurService.enabled && Theme.connectedSurfaceBlurEnabled
readonly property real minMenuWidth: 180
readonly property real maxMenuWidth: Math.max(0, (targetScreen?.width ?? 500) - menuMargin * 2)
readonly property real maxMenuHeight: Math.max(0, (targetScreen?.height ?? 600) - menuMargin * 2)
readonly property string longestMenuText: {
let longest = "";
for (let i = 0; i < menuItems.length; i++) {
const text = menuItems[i].text || "";
if (text.length > longest.length)
longest = text;
}
return longest;
}
readonly property real naturalMenuWidth: Math.max(minMenuWidth, menuTextMetrics.width + Theme.iconSize + Theme.spacingS * 5)
readonly property real effectiveMenuWidth: Math.max(0, Math.min(maxMenuWidth, naturalMenuWidth))
readonly property real naturalMenuHeight: menuItemsHeight() + Theme.spacingS * 2
readonly property real effectiveMenuHeight: Math.min(maxMenuHeight, naturalMenuHeight)
readonly property bool menuScrolls: naturalMenuHeight > effectiveMenuHeight + 0.5
signal hideRequested
signal editAppRequested(var app)
TextMetrics {
id: menuTextMetrics
text: root.longestMenuText
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Normal
}
function hasContextMenuActions(spotlightItem) {
if (!spotlightItem)
return false;
if (spotlightItem.type === "app")
return true;
if (spotlightItem.type === "plugin" && spotlightItem.pluginId) {
var instance = PluginService.pluginInstances[spotlightItem.pluginId];
const instance = PluginService.pluginInstances[spotlightItem.pluginId];
if (!instance)
return false;
if (typeof instance.getContextMenuActions !== "function")
return false;
var actions = instance.getContextMenuActions(spotlightItem.data);
const actions = instance.getContextMenuActions(spotlightItem.data);
return Array.isArray(actions) && actions.length > 0;
}
if (spotlightItem.actions && spotlightItem.actions.length > 0)
@@ -54,13 +91,13 @@ Popup {
if (!isPluginItem || !item?.pluginId)
return [];
var instance = PluginService.pluginInstances[item.pluginId];
const instance = PluginService.pluginInstances[item.pluginId];
if (!instance)
return [];
if (typeof instance.getContextMenuActions !== "function")
return [];
var actions = instance.getContextMenuActions(item.data);
const actions = instance.getContextMenuActions(item.data);
if (!Array.isArray(actions))
return [];
@@ -68,8 +105,8 @@ Popup {
}
function executePluginAction(actionOrObj) {
var actionFunc = typeof actionOrObj === "function" ? actionOrObj : actionOrObj?.action;
var closeLauncher = typeof actionOrObj === "object" && actionOrObj?.closeLauncher;
const actionFunc = typeof actionOrObj === "function" ? actionOrObj : actionOrObj?.action;
const closeLauncher = typeof actionOrObj === "object" && actionOrObj?.closeLauncher;
if (typeof actionFunc === "function")
actionFunc();
@@ -90,12 +127,12 @@ Popup {
}
readonly property var menuItems: {
var items = [];
const items = [];
if (isPluginItem) {
var pluginActions = getPluginContextMenuActions();
for (var i = 0; i < pluginActions.length; i++) {
var act = pluginActions[i];
const pluginActions = getPluginContextMenuActions();
for (let i = 0; i < pluginActions.length; i++) {
const act = pluginActions[i];
items.push({
type: "item",
icon: act.icon || "play_arrow",
@@ -107,8 +144,8 @@ Popup {
}
if (item?.type !== "app" && item?.actions && item.actions.length > 0) {
for (var i = 0; i < item.actions.length; i++) {
var genericAct = item.actions[i];
for (let i = 0; i < item.actions.length; i++) {
const genericAct = item.actions[i];
items.push({
type: "item",
icon: genericAct.icon || "play_arrow",
@@ -149,8 +186,8 @@ Popup {
items.push({
type: "separator"
});
for (var i = 0; i < item.actions.length; i++) {
var act = item.actions[i];
for (let i = 0; i < item.actions.length; i++) {
const act = item.actions[i];
items.push({
type: "item",
icon: act.icon || "play_arrow",
@@ -183,43 +220,52 @@ Popup {
return items;
}
function menuItemsHeight() {
let h = 0;
for (let i = 0; i < menuItems.length; i++) {
h += menuItems[i].type === "separator" ? 5 : 32;
}
if (menuItems.length > 1)
h += menuItems.length - 1;
return h;
}
function show(x, y, spotlightItem, fromKeyboard) {
if (!spotlightItem?.data)
return;
item = spotlightItem;
selectedMenuIndex = fromKeyboard ? 0 : -1;
keyboardNavigation = fromKeyboard;
const modal = parentHandler?.parentModal ?? null;
const screenRef = modal?.effectiveScreen ?? parentHandler?.Window?.window?.screen ?? searchField?.Window?.window?.screen ?? null;
const screenX = screenRef?.x || 0;
const screenY = screenRef?.y || 0;
const screenRelativeX = modal ? ((modal.alignedX ?? 0) + x) : ((parentHandler ? parentHandler.mapToGlobal(x, y).x : x) - screenX);
const screenRelativeY = modal ? ((modal.alignedY ?? 0) + y) : ((parentHandler ? parentHandler.mapToGlobal(x, y).y : y) - screenY);
targetScreen = screenRef;
anchorX = screenRelativeX + 4;
anchorY = screenRelativeY + 4;
renderActive = true;
openState = true;
if (parentHandler)
parentHandler.enabled = false;
Qt.callLater(() => {
var parentW = parent?.width ?? 500;
var parentH = parent?.height ?? 600;
var menuW = width > 0 ? width : 200;
var menuH = height > 0 ? height : 200;
var margin = 8;
var posX = x + 4;
var posY = y + 4;
if (posX + menuW > parentW - margin) {
posX = Math.max(margin, parentW - menuW - margin);
}
if (posY + menuH > parentH - margin) {
posY = Math.max(margin, parentH - menuH - margin);
}
root.x = posX;
root.y = posY;
open();
menuFlickable.contentY = 0;
keyboardHandler.forceActiveFocus();
ensureSelectedVisible();
});
}
function hide() {
if (parentHandler)
parentHandler.enabled = true;
close();
if (!renderActive)
return;
openState = false;
hideRequested();
}
function togglePin() {
@@ -286,31 +332,96 @@ Popup {
property bool keyboardNavigation: false
readonly property int visibleItemCount: {
var count = 0;
for (var i = 0; i < menuItems.length; i++) {
let count = 0;
for (let i = 0; i < menuItems.length; i++) {
if (menuItems[i].type === "item")
count++;
}
return count;
}
function handleKey(event) {
if (!openState)
return;
switch (event.key) {
case Qt.Key_Down:
selectNext();
event.accepted = true;
return;
case Qt.Key_Up:
selectPrevious();
event.accepted = true;
return;
case Qt.Key_Return:
case Qt.Key_Enter:
activateSelected();
event.accepted = true;
return;
case Qt.Key_Left:
case Qt.Key_Escape:
hide();
event.accepted = true;
return;
}
}
function selectNext() {
if (visibleItemCount > 0)
if (visibleItemCount > 0) {
keyboardNavigation = true;
selectedMenuIndex = (selectedMenuIndex + 1) % visibleItemCount;
ensureSelectedVisible();
}
}
function selectPrevious() {
if (visibleItemCount > 0)
if (visibleItemCount > 0) {
keyboardNavigation = true;
selectedMenuIndex = (selectedMenuIndex - 1 + visibleItemCount) % visibleItemCount;
ensureSelectedVisible();
}
}
function selectedDelegateIndex() {
let itemIndex = 0;
for (let i = 0; i < menuItems.length; i++) {
if (menuItems[i].type !== "item")
continue;
if (itemIndex === selectedMenuIndex)
return i;
itemIndex++;
}
return -1;
}
function ensureSelectedVisible() {
Qt.callLater(() => {
if (!menuFlickable || !menuRepeater)
return;
const delegateIndex = selectedDelegateIndex();
if (delegateIndex < 0)
return;
const delegate = menuRepeater.itemAt(delegateIndex);
if (!delegate)
return;
const top = delegate.y;
const bottom = top + delegate.height;
const viewTop = menuFlickable.contentY;
const viewBottom = viewTop + menuFlickable.height;
if (top < viewTop) {
menuFlickable.contentY = Math.max(0, top);
} else if (bottom > viewBottom) {
menuFlickable.contentY = Math.min(Math.max(0, menuFlickable.contentHeight - menuFlickable.height), bottom - menuFlickable.height);
}
});
}
function activateSelected() {
var itemIndex = 0;
for (var i = 0; i < menuItems.length; i++) {
let itemIndex = 0;
for (let i = 0; i < menuItems.length; i++) {
if (menuItems[i].type !== "item")
continue;
if (itemIndex === selectedMenuIndex) {
var menuItem = menuItems[i];
const menuItem = menuItems[i];
if (menuItem.action)
menuItem.action();
else if (menuItem.pluginAction)
@@ -325,209 +436,233 @@ Popup {
}
}
width: menuContainer.implicitWidth
height: menuContainer.implicitHeight
padding: 0
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
modal: true
dim: false
background: Item {}
PanelWindow {
id: menuWindow
onOpened: {
Qt.callLater(() => keyboardHandler.forceActiveFocus());
}
screen: root.targetScreen
visible: root.renderActive
color: "transparent"
onClosed: {
if (parentHandler)
parentHandler.enabled = true;
if (searchField?.visible) {
Qt.callLater(() => searchField.forceActiveFocus());
}
}
WlrLayershell.namespace: "dms:launcher-context-menu"
WlrLayershell.layer: WlrLayershell.Overlay
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: root.renderActive ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None
enter: Transition {
NumberAnimation {
property: "opacity"
from: 0
to: 1
duration: Theme.shortDuration
easing.type: Theme.emphasizedEasing
}
}
exit: Transition {
NumberAnimation {
property: "opacity"
from: 1
to: 0
duration: Theme.shortDuration
easing.type: Theme.emphasizedEasing
}
}
contentItem: Item {
id: keyboardHandler
focus: true
implicitWidth: menuContainer.implicitWidth
implicitHeight: menuContainer.implicitHeight
Keys.onPressed: event => {
switch (event.key) {
case Qt.Key_Down:
root.selectNext();
event.accepted = true;
return;
case Qt.Key_Up:
root.selectPrevious();
event.accepted = true;
return;
case Qt.Key_Return:
case Qt.Key_Enter:
root.activateSelected();
event.accepted = true;
return;
case Qt.Key_Escape:
case Qt.Key_Left:
root.hide();
event.accepted = true;
return;
}
anchors {
top: true
left: true
right: true
bottom: true
}
Rectangle {
id: menuContainer
WindowBlur {
targetWindow: menuWindow
blurX: root.blurActive ? menuContainer.x : 0
blurY: root.blurActive ? menuContainer.y : 0
blurWidth: root.blurActive ? menuContainer.width : 0
blurHeight: root.blurActive ? menuContainer.height : 0
blurRadius: Theme.cornerRadius
}
MouseArea {
anchors.fill: parent
implicitWidth: Math.max(180, menuColumn.implicitWidth + Theme.spacingS * 2)
implicitHeight: menuColumn.implicitHeight + Theme.spacingS * 2
color: Theme.floatingSurface
radius: Theme.cornerRadius
border.color: BlurService.enabled ? BlurService.borderColor : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
border.width: BlurService.enabled ? BlurService.borderWidth : 1
z: -1
enabled: root.renderActive
onClicked: root.hide()
}
Item {
id: keyboardHandler
anchors.fill: parent
focus: root.openState
Keys.onPressed: event => {
switch (event.key) {
case Qt.Key_Down:
root.selectNext();
event.accepted = true;
return;
case Qt.Key_Up:
root.selectPrevious();
event.accepted = true;
return;
case Qt.Key_Return:
case Qt.Key_Enter:
root.activateSelected();
event.accepted = true;
return;
case Qt.Key_Escape:
case Qt.Key_Left:
root.hide();
event.accepted = true;
return;
}
}
Rectangle {
anchors.fill: parent
anchors.topMargin: 4
anchors.leftMargin: 2
anchors.rightMargin: -2
anchors.bottomMargin: -4
radius: parent.radius
color: Qt.rgba(0, 0, 0, 0.15)
z: -1
}
id: menuContainer
x: Math.max(root.menuMargin, Math.min(menuWindow.width - width - root.menuMargin, root.anchorX))
y: Math.max(root.menuMargin, Math.min(menuWindow.height - height - root.menuMargin, root.anchorY))
width: root.effectiveMenuWidth
height: root.effectiveMenuHeight
color: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
radius: Theme.cornerRadius
border.color: BlurService.enabled ? BlurService.borderColor : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
border.width: BlurService.enabled ? BlurService.borderWidth : 1
opacity: root.openState ? 1 : 0
Column {
id: menuColumn
anchors.fill: parent
anchors.margins: Theme.spacingS
spacing: 1
Repeater {
model: root.menuItems
Item {
id: menuItemDelegate
required property var modelData
required property int index
width: menuColumn.width
height: modelData.type === "separator" ? 5 : 32
readonly property int itemIndex: {
var count = 0;
for (var i = 0; i < index; i++) {
if (root.menuItems[i].type === "item")
count++;
}
return count;
}
Rectangle {
visible: menuItemDelegate.modelData.type === "separator"
width: parent.width - Theme.spacingS * 2
height: parent.height
anchors.horizontalCenter: parent.horizontalCenter
color: "transparent"
Rectangle {
anchors.centerIn: parent
width: parent.width
height: 1
color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
Behavior on opacity {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.emphasizedEasing
onRunningChanged: {
if (!running && !root.openState) {
root.renderActive = false;
if (root.parentHandler)
root.parentHandler.enabled = true;
if (root.searchField?.visible)
Qt.callLater(() => root.searchField.forceActiveFocus());
}
}
}
}
Rectangle {
visible: menuItemDelegate.modelData.type === "item"
width: parent.width
height: parent.height
radius: Theme.cornerRadius
color: {
if (root.keyboardNavigation && root.selectedMenuIndex === menuItemDelegate.itemIndex) {
return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.2);
Rectangle {
anchors.fill: parent
anchors.topMargin: 4
anchors.leftMargin: 2
anchors.rightMargin: -2
anchors.bottomMargin: -4
radius: parent.radius
color: Qt.rgba(0, 0, 0, 0.15)
z: -1
}
Flickable {
id: menuFlickable
anchors.fill: parent
anchors.margins: Theme.spacingS
clip: true
contentWidth: width
contentHeight: menuColumn.implicitHeight
boundsBehavior: Flickable.StopAtBounds
interactive: root.menuScrolls
Column {
id: menuColumn
width: menuFlickable.width
spacing: 1
Repeater {
id: menuRepeater
model: root.menuItems
Item {
id: menuItemDelegate
required property var modelData
required property int index
width: menuColumn.width
height: modelData.type === "separator" ? 5 : 32
readonly property int itemIndex: {
let count = 0;
for (let i = 0; i < index; i++) {
if (root.menuItems[i].type === "item")
count++;
}
return count;
}
return itemMouseArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent";
}
Row {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.right: parent.right
anchors.rightMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingS
Rectangle {
visible: menuItemDelegate.modelData.type === "separator"
width: parent.width - Theme.spacingS * 2
height: parent.height
anchors.horizontalCenter: parent.horizontalCenter
color: "transparent"
Item {
width: Theme.iconSize - 2
height: Theme.iconSize - 2
anchors.verticalCenter: parent.verticalCenter
DankIcon {
visible: (menuItemDelegate.modelData?.icon ?? "").length > 0
name: menuItemDelegate.modelData?.icon ?? ""
size: Theme.iconSize - 2
color: Theme.surfaceText
opacity: 0.7
anchors.verticalCenter: parent.verticalCenter
Rectangle {
anchors.centerIn: parent
width: parent.width
height: 1
color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
}
}
StyledText {
text: menuItemDelegate.modelData.text || ""
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
font.weight: Font.Normal
anchors.verticalCenter: parent.verticalCenter
elide: Text.ElideRight
width: parent.width - (Theme.iconSize - 2) - Theme.spacingS
}
}
Rectangle {
visible: menuItemDelegate.modelData.type === "item"
width: parent.width
height: parent.height
radius: Theme.cornerRadius
color: {
if (root.keyboardNavigation && root.selectedMenuIndex === menuItemDelegate.itemIndex) {
return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.2);
}
return itemMouseArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent";
}
DankRipple {
id: menuItemRipple
rippleColor: Theme.surfaceText
cornerRadius: Theme.cornerRadius
}
Row {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.right: parent.right
anchors.rightMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingS
MouseArea {
id: itemMouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onEntered: {
root.keyboardNavigation = false;
root.selectedMenuIndex = menuItemDelegate.itemIndex;
}
onPressed: mouse => menuItemRipple.trigger(mouse.x, mouse.y)
onClicked: {
var menuItem = menuItemDelegate.modelData;
if (menuItem.action)
menuItem.action();
else if (menuItem.pluginAction)
root.executePluginAction(menuItem.pluginAction);
else if (menuItem.launcherActionData)
root.executeLauncherAction(menuItem.launcherActionData);
else if (menuItem.actionData)
root.executeDesktopAction(menuItem.actionData);
Item {
width: Theme.iconSize - 2
height: Theme.iconSize - 2
anchors.verticalCenter: parent.verticalCenter
DankIcon {
visible: (menuItemDelegate.modelData?.icon ?? "").length > 0
name: menuItemDelegate.modelData?.icon ?? ""
size: Theme.iconSize - 2
color: Theme.surfaceText
opacity: 0.7
anchors.verticalCenter: parent.verticalCenter
}
}
StyledText {
text: menuItemDelegate.modelData.text || ""
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
font.weight: Font.Normal
anchors.verticalCenter: parent.verticalCenter
elide: Text.ElideRight
width: parent.width - (Theme.iconSize - 2) - Theme.spacingS
}
}
DankRipple {
id: menuItemRipple
rippleColor: Theme.surfaceText
cornerRadius: Theme.cornerRadius
}
MouseArea {
id: itemMouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onEntered: {
root.keyboardNavigation = false;
root.selectedMenuIndex = menuItemDelegate.itemIndex;
}
onPressed: mouse => menuItemRipple.trigger(mouse.x, mouse.y)
onClicked: {
const menuItem = menuItemDelegate.modelData;
if (menuItem.action)
menuItem.action();
else if (menuItem.pluginAction)
root.executePluginAction(menuItem.pluginAction);
else if (menuItem.launcherActionData)
root.executeLauncherAction(menuItem.launcherActionData);
else if (menuItem.actionData)
root.executeDesktopAction(menuItem.actionData);
}
}
}
}
}
@@ -12,11 +12,11 @@ FocusScope {
property var parentModal: null
property alias searchField: searchInput
property alias controller: searchController
readonly property alias activeContextMenu: contextMenu
readonly property bool _hasQuery: searchInput.text.length > 0
readonly property real _searchBarH: 56
readonly property real _surfaceInset: BlurService.enabled ? (_hasQuery ? Theme.spacingS : Theme.spacingXS) : 0
readonly property real _searchAreaH: _searchBarH + _surfaceInset * 2
readonly property real _searchAreaH: _searchBarH
readonly property real _statusH: 92
readonly property real _rowH: 64
readonly property real _maxResultsH: Math.min(430, (parentModal?.screenHeight ?? 900) * 0.55)
@@ -25,13 +25,34 @@ FocusScope {
readonly property real _resultsH: _hasQuery ? Math.min(_resultsContentH, _maxResultsH) : 0
readonly property int _fastDuration: 90
readonly property int _resizeDuration: 110
readonly property bool _blurActive: Theme.blurForegroundLayers || Theme.transparentBlurLayers
readonly property real _searchSurfaceAlpha: {
if (Theme.transparentBlurLayers)
return _hasQuery ? 0.34 : 0.28;
if (Theme.blurForegroundLayers)
return Math.max(Theme.popupTransparency, _hasQuery ? 0.68 : 0.74);
return _hasQuery ? Theme.popupTransparency : Math.max(0.68, Theme.popupTransparency * 0.9);
}
readonly property color _searchSurfaceColor: Theme.withAlpha(_hasQuery ? Theme.surfaceContainerHigh : Theme.surfaceContainer, _searchSurfaceAlpha)
readonly property color _searchWellColor: {
if (searchInput.activeFocus)
return Theme.withAlpha(Theme.primaryContainer, Theme.transparentBlurLayers ? 0.42 : 1.0);
if (Theme.transparentBlurLayers)
return Theme.ccPillInactiveBg;
return Theme.surfaceContainer;
}
implicitHeight: _searchAreaH + (_resultsH > 0 ? 1 + _resultsH : 0)
implicitHeight: _searchAreaH + _resultsH
function resetScroll() {
resultsList.resetScroll();
}
function closeTransientUi() {
contextMenu.hide();
root.enabled = true;
}
function _buildRows() {
const flat = searchController.flatModel || [];
const sections = searchController.sections || [];
@@ -122,13 +143,11 @@ FocusScope {
}
break;
case Qt.Key_Tab:
if (_hasQuery)
_cycleCategory(false);
_cycleCategory(false);
event.accepted = true;
return;
case Qt.Key_Backtab:
if (_hasQuery)
_cycleCategory(true);
_cycleCategory(true);
event.accepted = true;
return;
case Qt.Key_Return:
@@ -177,13 +196,6 @@ FocusScope {
return;
}
break;
case Qt.Key_Slash:
if (event.modifiers === Qt.NoModifier && searchInput.text.length === 0) {
searchController.setMode("files", true);
event.accepted = true;
return;
}
break;
}
event.accepted = false;
@@ -193,6 +205,7 @@ FocusScope {
id: searchController
active: root.parentModal ? (root.parentModal.spotlightOpen || root.parentModal.isClosing) : true
viewModeContext: "spotlight"
forceLinearNavigation: true
onItemExecuted: {
root.parentModal?.hide();
@@ -210,10 +223,25 @@ FocusScope {
allowEditActions: false
}
Connections {
target: root.parentModal
ignoreUnknownSignals: true
function onSpotlightOpenChanged() {
if (!root.parentModal?.spotlightOpen)
root.closeTransientUi();
}
function onContentVisibleChanged() {
if (!root.parentModal?.contentVisible)
root.closeTransientUi();
}
}
Connections {
target: searchController
function onModeChanged(mode) {
if (searchController.autoSwitchedToFiles)
function onModeChanged(mode, userInitiated) {
if (!userInitiated || !SettingsData.rememberLastMode)
return;
SessionData.setLauncherLastMode(mode);
}
@@ -233,11 +261,8 @@ FocusScope {
Rectangle {
id: searchBarSurface
anchors.fill: parent
anchors.margins: root._surfaceInset
radius: height / 2
color: Theme.withAlpha(root._hasQuery ? Theme.surfaceContainerHigh : Theme.surfaceContainer, root._hasQuery ? Theme.popupTransparency : Math.max(0.68, Theme.popupTransparency * 0.9))
border.color: BlurService.enabled && !root._hasQuery ? Theme.withAlpha(Theme.outline, 0.08) : "transparent"
border.width: BlurService.enabled && !root._hasQuery ? 1 : 0
radius: Theme.cornerRadius
color: root._searchSurfaceColor
Behavior on color {
ColorAnimation {
@@ -254,7 +279,7 @@ FocusScope {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
color: searchInput.activeFocus ? Theme.primaryContainer : Theme.surfaceContainer
color: root._searchWellColor
DankIcon {
anchors.centerIn: parent
@@ -273,8 +298,8 @@ FocusScope {
Row {
id: categoryRow
visible: SettingsData.spotlightBarShowModeChips || root._hasQuery
spacing: Theme.spacingXS
visible: root._hasQuery
anchors.verticalCenter: parent.verticalCenter
Repeater {
@@ -380,28 +405,9 @@ FocusScope {
}
}
Rectangle {
anchors.top: searchBarItem.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.leftMargin: root._surfaceInset
anchors.rightMargin: root._surfaceInset
height: 1
color: Theme.outlineMedium
opacity: root._resultsH > 0 ? 0.55 : 0
Behavior on opacity {
NumberAnimation {
duration: root._fastDuration
easing.type: Theme.standardEasing
}
}
}
Item {
id: resultsContainer
anchors.top: searchBarItem.bottom
anchors.topMargin: 1
anchors.left: parent.left
anchors.right: parent.right
clip: true
@@ -12,6 +12,7 @@ Item {
property var controller: null
property bool hasQuery: false
property var rows: []
readonly property real bottomInset: Theme.spacingS
signal itemRightClicked(int index, var item, real mouseX, real mouseY)
@@ -53,7 +54,11 @@ Item {
DankListView {
id: mainListView
anchors.fill: parent
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.bottomMargin: root.bottomInset
clip: true
visible: root.rows.length > 0
@@ -64,11 +69,6 @@ Item {
objectProp: "_rowId"
}
add: null
remove: null
displaced: null
move: null
delegate: Item {
id: delegateRoot
required property var modelData
@@ -103,7 +103,11 @@ Item {
}
Item {
anchors.fill: parent
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.bottomMargin: root.bottomInset
visible: root.hasQuery && root.rows.length === 0
Row {
+13
View File
@@ -81,6 +81,8 @@ DankModal {
executeAction(action);
}
signal switchUserRequested
function executeAction(action) {
if (action === "lock") {
close();
@@ -92,6 +94,11 @@ DankModal {
Quickshell.execDetached(["dms", "restart"]);
return;
}
if (action === "switchuser") {
close();
switchUserRequested();
return;
}
close();
root.powerActionRequested(action, "", "");
}
@@ -216,6 +223,12 @@ DankModal {
"label": I18n.tr("Restart DMS"),
"key": "D"
};
case "switchuser":
return {
"icon": "switch_account",
"label": I18n.tr("Switch User"),
"key": "U"
};
default:
return {
"icon": "help",
@@ -64,6 +64,7 @@ FocusScope {
sourceComponent: KeybindsTab {
parentModal: root.parentModal
requestedSearchQuery: root.parentModal?.keybindSearchQuery ?? ""
}
onActiveChanged: {
@@ -554,5 +555,20 @@ FocusScope {
Qt.callLater(() => item.forceActiveFocus());
}
}
Loader {
id: usersLoader
anchors.fill: parent
active: root.currentIndex === 35
visible: active
focus: active
sourceComponent: UsersTab {}
onActiveChanged: {
if (active && item)
Qt.callLater(() => item.forceActiveFocus());
}
}
}
}
@@ -37,6 +37,7 @@ FloatingWindow {
property bool isCompactMode: width < 700
property bool menuVisible: !isCompactMode
property bool enableAnimations: true
property string keybindSearchQuery: ""
signal closingModal
@@ -73,6 +74,11 @@ FloatingWindow {
return sidebar.resolveTabIndex(tabName);
}
function showKeybindsSearch(query: string) {
keybindSearchQuery = query || "";
showWithTabName("keybinds");
}
function toggleMenu() {
enableAnimations = true;
menuVisible = !menuVisible;
@@ -293,6 +293,12 @@ Rectangle {
"tabIndex": 20,
"updaterOnly": true
},
{
"id": "users",
"text": I18n.tr("Users"),
"icon": "manage_accounts",
"tabIndex": 35
},
{
"id": "window_rules",
"text": I18n.tr("Window Rules"),
+272
View File
@@ -0,0 +1,272 @@
import QtQuick
import qs.Common
import qs.Modals.Common
import qs.Services
import qs.Widgets
DankModal {
id: root
property bool lockOnSwitch: false
function showFromPowerMenu() {
root.lockOnSwitch = false;
SessionsService.refresh();
open();
}
function showFromLockScreen() {
root.lockOnSwitch = true;
SessionsService.refresh();
open();
}
function _formatTty(s) {
if (s.tty && s.tty.length > 0)
return s.tty;
if (s.seat && s.seat.length > 0)
return s.seat;
return I18n.tr("remote");
}
function _formatType(s) {
if (!s.type || s.type.length === 0)
return "";
switch (s.type) {
case "wayland":
return "Wayland";
case "x11":
return "X11";
case "tty":
return "TTY";
default:
return s.type.charAt(0).toUpperCase() + s.type.substring(1);
}
}
function _doSwitch(sessionId, username) {
if (root.lockOnSwitch && typeof SessionService !== "undefined" && SessionService.loginctlAvailable)
SessionService.lock();
SessionsService.activate(sessionId, null);
close();
}
layerNamespace: "dms:switch-user-modal"
shouldBeVisible: false
allowStacking: true
modalWidth: 420
modalHeight: contentLoader.item ? Math.min(540, contentLoader.item.implicitHeight + Theme.spacingM * 2) : 320
enableShadow: true
shouldHaveFocus: true
onBackgroundClicked: close()
Connections {
target: SessionsService
function onSwitchRequested() {
root.showFromPowerMenu();
}
}
content: Component {
Item {
anchors.fill: parent
implicitHeight: mainColumn.implicitHeight
Column {
id: mainColumn
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.leftMargin: Theme.spacingL
anchors.rightMargin: Theme.spacingL
anchors.topMargin: Theme.spacingL
spacing: Theme.spacingM
Row {
width: parent.width
spacing: Theme.spacingM
DankIcon {
name: "switch_account"
size: Theme.iconSize
color: Theme.primary
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: I18n.tr("Switch User")
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.Medium
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
}
StyledText {
width: parent.width
text: I18n.tr("Select an active session to switch to. The current session stays running in the background.")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
visible: SessionsService.otherSessions().length > 0
}
Column {
width: parent.width
spacing: Theme.spacingS
visible: SessionsService.otherSessions().length > 0
Repeater {
model: SessionsService.otherSessions()
Rectangle {
id: sessionRow
required property var modelData
width: parent.width
height: 64
radius: Theme.cornerRadius
color: sessionMouse.containsMouse ? Theme.surfacePressed : Theme.surfaceContainerHighest
Row {
anchors.fill: parent
anchors.margins: Theme.spacingM
spacing: Theme.spacingM
DankIcon {
name: "account_circle"
size: Theme.iconSize + 4
color: Theme.primary
anchors.verticalCenter: parent.verticalCenter
}
Column {
width: parent.width - Theme.iconSize - 4 - chevron.width - Theme.spacingM * 2
spacing: 2
anchors.verticalCenter: parent.verticalCenter
StyledText {
text: sessionRow.modelData.username
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
}
StyledText {
text: {
const tty = root._formatTty(sessionRow.modelData);
const type = root._formatType(sessionRow.modelData);
const parts = [];
if (type)
parts.push(type);
parts.push(I18n.tr("session %1").arg(sessionRow.modelData.sessionId));
if (tty)
parts.push(tty);
return parts.join(" · ");
}
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
}
DankIcon {
id: chevron
name: "chevron_right"
size: Theme.iconSize
color: Theme.surfaceVariantText
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: sessionMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root._doSwitch(sessionRow.modelData.sessionId, sessionRow.modelData.username)
}
}
}
}
Column {
width: parent.width
spacing: Theme.spacingS
visible: SessionsService.otherSessions().length === 0
Rectangle {
width: parent.width
height: bodyCol.implicitHeight + Theme.spacingM * 2
radius: Theme.cornerRadius
color: Theme.surfaceContainerHighest
Row {
anchors.fill: parent
anchors.margins: Theme.spacingM
spacing: Theme.spacingM
DankIcon {
name: "info"
size: Theme.iconSize
color: Theme.surfaceVariantText
anchors.top: parent.top
anchors.topMargin: 2
}
Column {
id: bodyCol
width: parent.width - Theme.iconSize - Theme.spacingM
spacing: 4
StyledText {
text: I18n.tr("No other active sessions on this seat")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
}
StyledText {
width: parent.width
text: I18n.tr("To sign in as a different user, log out and pick the account from the greeter. Creating a fresh session in parallel needs a multi-session greeter (greetd-flexiserver / GDM / LightDM).")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
}
}
}
}
}
Row {
width: parent.width
spacing: Theme.spacingM
layoutDirection: Qt.RightToLeft
DankButton {
text: I18n.tr("Close")
backgroundColor: Theme.surfaceVariantAlpha
textColor: Theme.surfaceText
onClicked: root.close()
}
DankButton {
visible: SessionsService.otherSessions().length === 0 && !root.lockOnSwitch
text: I18n.tr("Log out")
iconName: "logout"
backgroundColor: Theme.primary
textColor: Theme.primaryText
onClicked: {
if (typeof SessionService !== "undefined")
SessionService.logout();
root.close();
}
}
}
Item {
width: 1
height: Theme.spacingS
}
}
}
}
}
@@ -59,21 +59,19 @@ Item {
ignoreUnknownSignals: true
function onDeviceNameChanged(newDeviceName) {
if (root.expandedWidgetData && root.expandedWidgetData.id === "brightnessSlider") {
const widgets = SettingsData.controlCenterWidgets || [];
const newWidgets = widgets.map(w => {
if (w.id === "brightnessSlider" && w.instanceId === root.expandedWidgetData.instanceId) {
const updatedWidget = Object.assign({}, w);
updatedWidget.deviceName = newDeviceName;
return updatedWidget;
}
return w;
});
SettingsData.set("controlCenterWidgets", newWidgets);
if (root.collapseCallback) {
root.collapseCallback();
}
if (!root.expandedWidgetData || root.expandedWidgetData.id !== "brightnessSlider") {
return;
}
const widgets = SettingsData.controlCenterWidgets || [];
const newWidgets = widgets.map(w => {
if (w.id === "brightnessSlider" && w.instanceId === root.expandedWidgetData.instanceId) {
const updatedWidget = Object.assign({}, w);
updatedWidget.deviceName = newDeviceName;
return updatedWidget;
}
return w;
});
SettingsData.set("controlCenterWidgets", newWidgets);
}
}
@@ -301,12 +301,22 @@ Column {
property var widgetDef: root.model?.getWidgetForId(widgetData.id || "")
width: parent.width
height: 60
iconBlinking: {
const id = widgetData.id || "";
if (id === "wifi")
return NetworkService.isWifiConnecting;
if (id === "bluetooth")
return BluetoothService.connecting;
return false;
}
iconName: {
switch (widgetData.id || "") {
case "wifi":
{
if (NetworkService.wifiToggling)
return "sync";
if (NetworkService.isConnecting && !NetworkService.ethernetConnected)
return NetworkService.wifiSignalIcon;
const status = NetworkService.networkStatus;
if (status === "ethernet")
@@ -360,6 +370,8 @@ Column {
{
if (NetworkService.wifiToggling)
return NetworkService.wifiEnabled ? I18n.tr("Disabling WiFi...", "network status") : I18n.tr("Enabling WiFi...", "network status");
if (NetworkService.isConnecting && !NetworkService.ethernetConnected)
return NetworkService.connectingSSID || I18n.tr("Connecting...", "network status");
const status = NetworkService.networkStatus;
if (status === "ethernet")
@@ -400,6 +412,8 @@ Column {
{
if (NetworkService.wifiToggling)
return I18n.tr("Please wait...", "network status");
if (NetworkService.isConnecting && !NetworkService.ethernetConnected)
return I18n.tr("Connecting...", "network status");
const status = NetworkService.networkStatus;
if (status === "ethernet")
@@ -422,6 +436,8 @@ Column {
return I18n.tr("No adapters", "bluetooth status");
if (!BluetoothService.adapter || !BluetoothService.adapter.enabled)
return I18n.tr("Off", "bluetooth status");
if (BluetoothService.connecting)
return I18n.tr("Connecting...", "bluetooth status");
const primaryDevice = (() => {
if (!BluetoothService.adapter || !BluetoothService.adapter.devices)
return null;
@@ -23,79 +23,103 @@ Rectangle {
if (!screenName)
return "";
const screen = Quickshell.screens.find(s => s.name === screenName);
if (screen) {
if (screen)
return SettingsData.getScreenDisplayName(screen);
}
if (SettingsData.displayNameMode === "model" && screenModel && screenModel.length > 0) {
if (SettingsData.displayNameMode === "model" && screenModel && screenModel.length > 0)
return screenModel;
}
return screenName;
}
function resolveDeviceName() {
if (!DisplayService.brightnessAvailable || !DisplayService.devices || DisplayService.devices.length === 0) {
function resolveCurrentDevice() {
const devices = DisplayService.devices || [];
if (!DisplayService.brightnessAvailable || devices.length === 0)
return "";
}
const pinKey = getScreenPinKey();
if (pinKey.length > 0) {
const pins = SettingsData.brightnessDevicePins || {};
const pinnedDevice = pins[pinKey];
if (pinnedDevice && pinnedDevice.length > 0) {
const found = DisplayService.devices.find(dev => dev.name === pinnedDevice);
const found = devices.find(d => d.name === pinnedDevice);
if (found)
return found.name;
}
}
if (instanceId) {
const widgets = SettingsData.controlCenterWidgets || [];
const widget = widgets.find(w => w.id === "brightnessSlider" && w.instanceId === instanceId);
if (widget && typeof widget.deviceName === "string" && widget.deviceName.length > 0) {
const found = devices.find(d => d.name === widget.deviceName);
if (found)
return found.name;
}
}
if (DisplayService.currentDevice) {
const found = devices.find(d => d.name === DisplayService.currentDevice);
if (found)
return found.name;
}
if (initialDeviceName && initialDeviceName.length > 0) {
const found = DisplayService.devices.find(dev => dev.name === initialDeviceName);
const found = devices.find(d => d.name === initialDeviceName);
if (found)
return found.name;
}
const currentDeviceNameFromService = DisplayService.currentDevice;
if (currentDeviceNameFromService) {
const found = DisplayService.devices.find(dev => dev.name === currentDeviceNameFromService);
if (found)
return found.name;
}
const backlight = DisplayService.devices.find(d => d.class === "backlight");
const backlight = devices.find(d => d.class === "backlight");
if (backlight)
return backlight.name;
const ddc = DisplayService.devices.find(d => d.class === "ddc");
const ddc = devices.find(d => d.class === "ddc");
if (ddc)
return ddc.name;
return DisplayService.devices.length > 0 ? DisplayService.devices[0].name : "";
return devices[0].name;
}
function selectDevice(deviceName) {
if (!deviceName || deviceName === root.currentDeviceName) {
return;
}
const pinKey = getScreenPinKey();
if (pinKey.length > 0) {
const pins = SettingsData.brightnessDevicePins || {};
const existing = pins[pinKey];
if (existing && existing !== deviceName) {
const next = JSON.parse(JSON.stringify(pins));
delete next[pinKey];
SettingsData.set("brightnessDevicePins", next);
}
}
root.currentDeviceName = deviceName;
DisplayService.setCurrentDevice(deviceName, true);
Qt.callLater(() => root.deviceNameChanged(deviceName));
}
Component.onCompleted: {
currentDeviceName = resolveDeviceName();
root.currentDeviceName = resolveCurrentDevice();
}
property bool isPinnedToScreen: {
function isDevicePinnedToScreen(deviceName) {
const pinKey = getScreenPinKey();
if (!pinKey || pinKey.length === 0)
if (!pinKey || !deviceName)
return false;
const pins = SettingsData.brightnessDevicePins || {};
return pins[pinKey] === currentDeviceName;
return pins[pinKey] === deviceName;
}
function togglePinToScreen() {
function togglePinForDevice(deviceName) {
const pinKey = getScreenPinKey();
if (!pinKey || pinKey.length === 0 || !currentDeviceName || currentDeviceName.length === 0)
if (!pinKey || !deviceName)
return;
const pins = JSON.parse(JSON.stringify(SettingsData.brightnessDevicePins || {}));
if (isPinnedToScreen) {
if (pins[pinKey] === deviceName) {
delete pins[pinKey];
} else {
pins[pinKey] = currentDeviceName;
pins[pinKey] = deviceName;
}
SettingsData.set("brightnessDevicePins", pins);
}
@@ -153,18 +177,23 @@ Rectangle {
}
Rectangle {
id: monitorHeader
width: parent.width
height: 40
visible: screenName && screenName.length > 0 && DisplayService.devices && DisplayService.devices.length > 1
radius: Theme.cornerRadius
color: Theme.withAlpha(Theme.surfaceContainerHighest, Theme.popupTransparency)
property bool currentDevicePinned: root.isDevicePinnedToScreen(currentDeviceName)
Item {
anchors.fill: parent
anchors.margins: Theme.spacingM
Row {
anchors.left: parent.left
anchors.right: globalPinButton.left
anchors.rightMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingM
@@ -180,47 +209,51 @@ Rectangle {
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
elide: Text.ElideRight
width: parent.width - Theme.iconSize - Theme.spacingM
}
}
Rectangle {
id: globalPinButton
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: pinRow.width + Theme.spacingS * 2
width: globalPinRow.width + Theme.spacingS * 2
height: 28
radius: height / 2
color: isPinnedToScreen ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : Theme.withAlpha(Theme.surfaceText, 0.05)
color: monitorHeader.currentDevicePinned ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.16) : Theme.withAlpha(Theme.surfaceText, 0.05)
Row {
id: pinRow
id: globalPinRow
anchors.centerIn: parent
spacing: 4
DankIcon {
name: isPinnedToScreen ? "push_pin" : "push_pin"
name: "push_pin"
size: 16
color: isPinnedToScreen ? Theme.primary : Theme.surfaceText
color: monitorHeader.currentDevicePinned ? Theme.primary : Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: isPinnedToScreen ? I18n.tr("Pinned") : I18n.tr("Pin")
text: monitorHeader.currentDevicePinned ? I18n.tr("Pinned") : I18n.tr("Pin")
font.pixelSize: Theme.fontSizeSmall
color: isPinnedToScreen ? Theme.primary : Theme.surfaceText
color: monitorHeader.currentDevicePinned ? Theme.primary : Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
}
DankRipple {
id: pinRipple
id: globalPinRipple
cornerRadius: parent.radius
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onPressed: mouse => pinRipple.trigger(mouse.x, mouse.y)
onClicked: root.togglePinToScreen()
enabled: currentDeviceName && currentDeviceName.length > 0
onPressed: mouse => globalPinRipple.trigger(mouse.x, mouse.y)
onClicked: root.togglePinForDevice(currentDeviceName)
}
}
}
@@ -229,9 +262,17 @@ Rectangle {
Repeater {
model: DisplayService.devices || []
delegate: Rectangle {
id: deviceCard
required property var modelData
required property int index
readonly property bool selected: !!(modelData && modelData.name === root.currentDeviceName)
readonly property bool devicePinnedHere: {
SettingsData.brightnessDevicePins;
return root.isDevicePinnedToScreen(modelData ? modelData.name : "");
}
property real deviceBrightness: {
DisplayService.brightnessVersion;
return DisplayService.getDeviceBrightness(modelData.name);
@@ -241,8 +282,8 @@ Rectangle {
height: 100
radius: Theme.cornerRadius
color: Theme.withAlpha(Theme.surfaceContainerHighest, Theme.popupTransparency)
border.color: modelData.name === currentDeviceName ? Theme.primary : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
border.width: modelData.name === currentDeviceName ? 2 : 0
border.color: selected ? Theme.primary : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
border.width: selected ? 2 : 0
Column {
anchors.fill: parent
@@ -251,10 +292,12 @@ Rectangle {
Item {
width: parent.width
height: Math.max(deviceIconColumn.height, deviceInfoColumn.height, exponentControls.height)
height: Math.max(deviceIconColumn.height, deviceInfoColumn.height, rightControls.height)
Row {
anchors.left: parent.left
anchors.right: rightControls.left
anchors.rightMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingM
@@ -281,7 +324,7 @@ Rectangle {
}
}
size: Theme.iconSize
color: modelData.name === currentDeviceName ? Theme.primary : Theme.surfaceText
color: deviceCard.selected ? Theme.primary : Theme.surfaceText
anchors.horizontalCenter: parent.horizontalCenter
}
@@ -296,7 +339,7 @@ Rectangle {
Column {
id: deviceInfoColumn
anchors.verticalCenter: parent.verticalCenter
width: parent.parent.width - deviceIconColumn.width - exponentControls.width - Theme.spacingM * 3
width: parent.width - deviceIconColumn.width - Theme.spacingM
StyledText {
text: {
@@ -309,7 +352,7 @@ Rectangle {
}
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
font.weight: modelData.name === currentDeviceName ? Font.Medium : Font.Normal
font.weight: deviceCard.selected ? Font.Medium : Font.Normal
elide: Text.ElideRight
width: parent.width
horizontalAlignment: Text.AlignLeft
@@ -345,80 +388,107 @@ Rectangle {
}
Row {
id: exponentControls
width: 140
id: rightControls
height: 28
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingXS
visible: SessionData.getBrightnessExponential(modelData.name)
spacing: Theme.spacingS
z: 1
StyledRect {
width: 28
Row {
id: exponentControls
height: 28
radius: Theme.cornerRadius
color: Theme.withAlpha(Theme.surfaceContainerHighest, Theme.popupTransparency)
opacity: SessionData.getBrightnessExponent(modelData.name) > 1.0 ? 1.0 : 0.4
spacing: Theme.spacingXS
visible: SessionData.getBrightnessExponential(modelData.name)
DankIcon {
anchors.centerIn: parent
name: "remove"
size: 14
color: Theme.surfaceText
StyledRect {
width: 28
height: 28
radius: Theme.cornerRadius
color: Theme.withAlpha(Theme.surfaceContainerHighest, Theme.popupTransparency)
opacity: SessionData.getBrightnessExponent(modelData.name) > 1.0 ? 1.0 : 0.4
DankIcon {
anchors.centerIn: parent
name: "remove"
size: 14
color: Theme.surfaceText
}
StateLayer {
stateColor: Theme.primary
cornerRadius: parent.radius
enabled: SessionData.getBrightnessExponent(modelData.name) > 1.0
onClicked: {
const current = SessionData.getBrightnessExponent(modelData.name);
const newValue = Math.max(1.0, Math.round((current - 0.1) * 10) / 10);
SessionData.setBrightnessExponent(modelData.name, newValue);
}
}
}
StateLayer {
stateColor: Theme.primary
cornerRadius: parent.radius
enabled: SessionData.getBrightnessExponent(modelData.name) > 1.0
onClicked: {
const current = SessionData.getBrightnessExponent(modelData.name);
const newValue = Math.max(1.0, Math.round((current - 0.1) * 10) / 10);
SessionData.setBrightnessExponent(modelData.name, newValue);
StyledRect {
width: 50
height: 28
radius: Theme.cornerRadius
color: Theme.withAlpha(Theme.surfaceContainerHighest, Theme.popupTransparency)
border.width: 0
StyledText {
anchors.centerIn: parent
text: SessionData.getBrightnessExponent(modelData.name).toFixed(1)
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
color: Theme.primary
}
}
StyledRect {
width: 28
height: 28
radius: Theme.cornerRadius
color: Theme.withAlpha(Theme.surfaceContainerHighest, Theme.popupTransparency)
opacity: SessionData.getBrightnessExponent(modelData.name) < 2.5 ? 1.0 : 0.4
DankIcon {
anchors.centerIn: parent
name: "add"
size: 14
color: Theme.surfaceText
}
StateLayer {
stateColor: Theme.primary
cornerRadius: parent.radius
enabled: SessionData.getBrightnessExponent(modelData.name) < 2.5
onClicked: {
const current = SessionData.getBrightnessExponent(modelData.name);
const newValue = Math.min(2.5, Math.round((current + 0.1) * 10) / 10);
SessionData.setBrightnessExponent(modelData.name, newValue);
}
}
}
}
StyledRect {
width: 50
height: 28
radius: Theme.cornerRadius
color: Theme.withAlpha(Theme.surfaceContainerHighest, Theme.popupTransparency)
border.width: 0
StyledText {
anchors.centerIn: parent
text: SessionData.getBrightnessExponent(modelData.name).toFixed(1)
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
color: Theme.primary
}
}
StyledRect {
id: pinButton
width: 28
height: 28
radius: Theme.cornerRadius
color: Theme.withAlpha(Theme.surfaceContainerHighest, Theme.popupTransparency)
opacity: SessionData.getBrightnessExponent(modelData.name) < 2.5 ? 1.0 : 0.4
visible: root.screenName && root.screenName.length > 0 && DisplayService.devices && DisplayService.devices.length > 1
color: devicePinnedHere ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.16) : Theme.withAlpha(Theme.surfaceContainerHighest, Theme.popupTransparency)
DankIcon {
anchors.centerIn: parent
name: "add"
name: "push_pin"
size: 14
color: Theme.surfaceText
color: devicePinnedHere ? Theme.primary : Theme.surfaceText
}
StateLayer {
stateColor: Theme.primary
cornerRadius: parent.radius
enabled: SessionData.getBrightnessExponent(modelData.name) < 2.5
onClicked: {
const current = SessionData.getBrightnessExponent(modelData.name);
const newValue = Math.min(2.5, Math.round((current + 0.1) * 10) / 10);
SessionData.setBrightnessExponent(modelData.name, newValue);
}
onClicked: root.togglePinForDevice(modelData.name)
}
}
}
@@ -474,22 +544,11 @@ Rectangle {
MouseArea {
anchors.fill: parent
anchors.bottomMargin: 28
anchors.rightMargin: SessionData.getBrightnessExponential(modelData.name) ? 145 : 0
anchors.rightMargin: rightControls.width + Theme.spacingS
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onPressed: mouse => deviceRipple.trigger(mouse.x, mouse.y)
onClicked: {
const pinKey = root.getScreenPinKey();
if (pinKey.length > 0 && modelData.name !== currentDeviceName) {
const pins = JSON.parse(JSON.stringify(SettingsData.brightnessDevicePins || {}));
if (pins[pinKey]) {
delete pins[pinKey];
SettingsData.set("brightnessDevicePins", pins);
}
}
currentDeviceName = modelData.name;
deviceNameChanged(modelData.name);
}
onClicked: root.selectDevice(modelData.name)
}
}
}
@@ -541,7 +541,11 @@ Rectangle {
return -1;
if (b.ssid === ssid)
return 1;
return b.signal - a.signal;
const aBucket = Math.floor((a.signal || 0) / 25);
const bBucket = Math.floor((b.signal || 0) / 25);
if (aBucket !== bBucket)
return bBucket - aBucket;
return (a.ssid || "").localeCompare(b.ssid || "");
});
return sorted;
}
@@ -1,4 +1,5 @@
import QtQuick
import Quickshell
import qs.Common
import qs.Services
import qs.Widgets
@@ -31,8 +32,10 @@ Row {
}
if (screenName && screenName.length > 0) {
const screen = Quickshell.screens.find(s => s.name === screenName);
const pinKey = screen ? SettingsData.getScreenDisplayName(screen) : screenName;
const pins = SettingsData.brightnessDevicePins || {};
const pinnedDevice = pins[screenName];
const pinnedDevice = pins[pinKey];
if (pinnedDevice && pinnedDevice.length > 0) {
const found = DisplayService.devices.find(dev => dev.name === pinnedDevice);
if (found) {
@@ -10,6 +10,7 @@ Rectangle {
property string iconName: ""
property color iconColor: Theme.surfaceText
property bool iconBlinking: false
property string primaryText: ""
property string secondaryText: ""
property bool expanded: false
@@ -109,10 +110,16 @@ Rectangle {
}
DankIcon {
id: pillIcon
anchors.centerIn: parent
name: iconName
size: Theme.iconSize
color: isActive ? _tileIconActive : _tileIconInactive
DankBlink {
target: pillIcon
running: root.iconBlinking
}
}
DankRipple {
+42 -11
View File
@@ -10,13 +10,15 @@ Item {
required property var axis
required property var barConfig
visible: !SettingsData.frameEnabled
readonly property bool frameShapesBar: SettingsData.frameEnabled && barWindow.usesFrameBarChrome
visible: !frameShapesBar
anchors.fill: parent
anchors.left: parent.left
anchors.top: parent.top
readonly property bool gothEnabled: (barConfig?.gothCornersEnabled ?? false) && !barWindow.hasMaximizedToplevel
readonly property bool gothEnabled: (barConfig?.gothCornersEnabled ?? false) && !(barWindow.flattenForMaximizedWindow && barWindow.hasMaximizedToplevel)
anchors.leftMargin: -(gothEnabled && axis.isVertical && axis.edge === "right" ? barWindow._wingR : 0)
anchors.rightMargin: -(gothEnabled && axis.isVertical && axis.edge === "left" ? barWindow._wingR : 0)
anchors.topMargin: -(gothEnabled && !axis.isVertical && axis.edge === "bottom" ? barWindow._wingR : 0)
@@ -39,11 +41,11 @@ Item {
}
property real rt: {
if (SettingsData.frameEnabled)
if (frameShapesBar)
return SettingsData.frameRounding;
if (barConfig?.squareCorners ?? false)
return 0;
if (barWindow.hasMaximizedToplevel)
if (barWindow.flattenForMaximizedWindow && barWindow.hasMaximizedToplevel)
return 0;
return Theme.cornerRadius;
}
@@ -113,9 +115,32 @@ Item {
readonly property real shadowOffsetX: Theme.elevationOffsetXFor(hasPerBarOverride ? null : elevLevel, effectiveShadowDirection, shadowOffsetMagnitude)
readonly property real shadowOffsetY: Theme.elevationOffsetYFor(hasPerBarOverride ? null : elevLevel, effectiveShadowDirection, shadowOffsetMagnitude)
readonly property string mainPath: generatePathForPosition(width, height)
readonly property string borderFullPath: generateBorderFullPath(width, height)
readonly property string borderEdgePath: generateBorderEdgePath(width, height)
readonly property string mainPath: {
frameShapesBar;
rt;
wing;
barWindow.flattenForMaximizedWindow;
barWindow.hasMaximizedToplevel;
width;
height;
return generatePathForPosition(width, height);
}
readonly property string borderFullPath: {
frameShapesBar;
rt;
wing;
width;
height;
return generateBorderFullPath(width, height);
}
readonly property string borderEdgePath: {
frameShapesBar;
rt;
wing;
width;
height;
return generateBorderEdgePath(width, height);
}
property bool mainPathCorrectShape: false
property bool borderFullPathCorrectShape: false
property bool borderEdgePathCorrectShape: false
@@ -136,6 +161,12 @@ Item {
}
}
onFrameShapesBarChanged: {
mainPathCorrectShape = false;
borderFullPathCorrectShape = false;
borderEdgePathCorrectShape = false;
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
@@ -259,7 +290,7 @@ Item {
h = h - wing;
const r = wing;
const cr = rt;
const crE = SettingsData.frameEnabled ? 0 : cr;
const crE = frameShapesBar ? 0 : cr;
let d = `M ${crE} 0`;
d += ` L ${w - crE} 0`;
@@ -290,7 +321,7 @@ Item {
h = h - wing;
const r = wing;
const cr = rt;
const crE = SettingsData.frameEnabled ? 0 : cr;
const crE = frameShapesBar ? 0 : cr;
let d = `M ${crE} ${fullH}`;
d += ` L ${w - crE} ${fullH}`;
@@ -320,7 +351,7 @@ Item {
w = w - wing;
const r = wing;
const cr = rt;
const crE = SettingsData.frameEnabled ? 0 : cr;
const crE = frameShapesBar ? 0 : cr;
let d = `M 0 ${crE}`;
d += ` L 0 ${h - crE}`;
@@ -351,7 +382,7 @@ Item {
w = w - wing;
const r = wing;
const cr = rt;
const crE = SettingsData.frameEnabled ? 0 : cr;
const crE = frameShapesBar ? 0 : cr;
let d = `M ${fullW} ${crE}`;
d += ` L ${fullW} ${h - crE}`;
+10 -9
View File
@@ -24,8 +24,9 @@ Item {
readonly property real innerPadding: barConfig?.innerPadding ?? 4
readonly property real outlineThickness: (barConfig?.widgetOutlineEnabled ?? false) ? (barConfig?.widgetOutlineThickness ?? 1) : 0
readonly property real _edgeBaseMargin: Math.max(Theme.spacingXS, innerPadding * 0.8)
readonly property real _frameEdgeFloorInset: SettingsData.frameEnabled ? Math.max(0, SettingsData.frameThickness - _edgeBaseMargin) : 0
readonly property bool _hasBarWindow: barWindow !== undefined && barWindow !== null
readonly property bool _usesFrameBarChrome: _hasBarWindow && (barWindow.usesFrameBarChrome ?? false)
readonly property real _frameEdgeFloorInset: (SettingsData.frameEnabled && _usesFrameBarChrome) ? Math.max(0, SettingsData.frameThickness - _edgeBaseMargin) : 0
readonly property bool _barIsVertical: _hasBarWindow ? barWindow.isVertical : false
readonly property string _barScreenName: _hasBarWindow ? (barWindow.screenName || "") : ""
readonly property bool hasAdjacentTopBarLive: _hasBarWindow && barWindow.hasAdjacentTopBar
@@ -47,22 +48,22 @@ Item {
_hadAdjacentRightBar = true
readonly property real _frameLeftInset: {
if (!_hasBarWindow || !SettingsData.frameEnabled || _barIsVertical)
if (!_hasBarWindow || !SettingsData.frameEnabled || !_usesFrameBarChrome || _barIsVertical)
return 0;
return hasAdjacentLeftBarLive ? SettingsData.frameBarSize : (_hadAdjacentLeftBar ? _frameEdgeFloorInset : 0);
}
readonly property real _frameRightInset: {
if (!_hasBarWindow || !SettingsData.frameEnabled || _barIsVertical)
if (!_hasBarWindow || !SettingsData.frameEnabled || !_usesFrameBarChrome || _barIsVertical)
return 0;
return hasAdjacentRightBarLive ? SettingsData.frameBarSize : (_hadAdjacentRightBar ? _frameEdgeFloorInset : 0);
}
readonly property real _frameTopInset: {
if (!_hasBarWindow || !SettingsData.frameEnabled || !_barIsVertical)
if (!_hasBarWindow || !SettingsData.frameEnabled || !_usesFrameBarChrome || !_barIsVertical)
return 0;
return hasAdjacentTopBarLive ? SettingsData.frameThickness : (_hadAdjacentTopBar ? _frameEdgeFloorInset : 0);
}
readonly property real _frameBottomInset: {
if (!_hasBarWindow || !SettingsData.frameEnabled || !_barIsVertical)
if (!_hasBarWindow || !SettingsData.frameEnabled || !_usesFrameBarChrome || !_barIsVertical)
return 0;
return hasAdjacentBottomBarLive ? SettingsData.frameThickness : (_hadAdjacentBottomBar ? _frameEdgeFloorInset : 0);
}
@@ -95,7 +96,7 @@ Item {
}
Behavior on anchors.leftMargin {
enabled: _animateFrameInsets && SettingsData.frameEnabled
enabled: _animateFrameInsets && _usesFrameBarChrome
NumberAnimation {
duration: Theme.shortDuration
easing.type: Easing.OutCubic
@@ -103,7 +104,7 @@ Item {
}
Behavior on anchors.rightMargin {
enabled: _animateFrameInsets && SettingsData.frameEnabled
enabled: _animateFrameInsets && _usesFrameBarChrome
NumberAnimation {
duration: Theme.shortDuration
easing.type: Easing.OutCubic
@@ -111,7 +112,7 @@ Item {
}
Behavior on anchors.topMargin {
enabled: _animateFrameInsets && SettingsData.frameEnabled
enabled: _animateFrameInsets && _usesFrameBarChrome
NumberAnimation {
duration: Theme.shortDuration
easing.type: Easing.OutCubic
@@ -119,7 +120,7 @@ Item {
}
Behavior on anchors.bottomMargin {
enabled: _animateFrameInsets && SettingsData.frameEnabled
enabled: _animateFrameInsets && _usesFrameBarChrome
NumberAnimation {
duration: Theme.shortDuration
easing.type: Easing.OutCubic
+33 -29
View File
@@ -108,6 +108,8 @@ PanelWindow {
triggerDashTab(2);
}
readonly property bool usesOverlayLayer: CompositorService.framePeerSurfacesUseOverlayForScreen(barWindow.screen) || (barConfig?.useOverlayLayer ?? false)
readonly property var dBarLayer: {
switch (Quickshell.env("DMS_DANKBAR_LAYER")) {
case "bottom":
@@ -119,10 +121,7 @@ PanelWindow {
case "top":
return WlrLayer.Top;
default:
// Elevate to Overlay when Frame is enabled so the bar stays above
// the FrameWindow (WlrLayer.Top) when it is re-mapped on mode switch,
// but drop back to Top while a true fullscreen app owns this screen.
return SettingsData.frameEnabled && !barWindow.hasFullscreenToplevel ? WlrLayer.Overlay : WlrLayer.Top;
return barWindow.usesOverlayLayer ? WlrLayer.Overlay : WlrLayer.Top;
}
}
@@ -152,6 +151,16 @@ PanelWindow {
onTriggered: barBlur.rebuild()
}
Connections {
target: barWindow
function onUsesConnectedFrameChromeChanged() {
_blurRebuildTimer.restart();
}
function onUsesFrameBarChromeChanged() {
_blurRebuildTimer.restart();
}
}
Component {
id: blurRegionComp
Region {}
@@ -179,7 +188,7 @@ PanelWindow {
// In frame mode, FrameWindow owns the blur region for the entire screen edge
// (including the bar area). The bar must not set its own competing blur region
// so that frameBlurEnabled acts as the single control for all blur in frame mode.
if (SettingsData.frameEnabled)
if (SettingsData.frameEnabled && barWindow.usesFrameBarChrome)
return;
const widgets = barWindow._blurWidgetItems.filter(w => w && w.visible && w.width > 0 && w.height > 0);
@@ -292,7 +301,7 @@ PanelWindow {
readonly property color _surfaceContainer: Theme.surfaceContainer
readonly property string _barId: barConfig?.id ?? "default"
property real _backgroundAlpha: barConfig?.transparency ?? 1.0
readonly property color _bgColor: SettingsData.frameEnabled ? Qt.rgba(SettingsData.effectiveFrameColor.r, SettingsData.effectiveFrameColor.g, SettingsData.effectiveFrameColor.b, SettingsData.frameOpacity) : Theme.withAlpha(_surfaceContainer, _backgroundAlpha)
readonly property color _bgColor: (SettingsData.frameEnabled && usesFrameBarChrome) ? Qt.rgba(SettingsData.effectiveFrameColor.r, SettingsData.effectiveFrameColor.g, SettingsData.effectiveFrameColor.b, SettingsData.frameOpacity) : Theme.withAlpha(_surfaceContainer, _backgroundAlpha)
function _updateBackgroundAlpha() {
const live = SettingsData.barConfigs.find(c => c.id === _barId);
@@ -316,16 +325,14 @@ PanelWindow {
property string screenName: modelData.name
readonly property bool usesConnectedFrameChrome: CompositorService.usesConnectedFrameChromeForScreen(screenName)
readonly property bool usesFrameBarChrome: CompositorService.frameWindowVisibleForScreen(screenName)
// Flatten/spacing collapse for maximized windows is only for frame-integrated layout.
// When the bar draws its own pill, keep rounded corners and spacing like the dock.
readonly property bool flattenForMaximizedWindow: !SettingsData.frameEnabled || usesFrameBarChrome
property bool hasMaximizedToplevel: false
readonly property bool hasFullscreenToplevel: {
if (!(barConfig?.fullscreenDetection ?? true))
return false;
CompositorService.sortedToplevels;
ToplevelManager.activeToplevel;
if (CompositorService.isNiri)
NiriService.allWorkspaces;
return CompositorService.hasFullscreenToplevelOnScreen(screenName);
}
property bool shouldHideForWindows: false
function _updateHasMaximizedToplevel() {
@@ -427,7 +434,7 @@ PanelWindow {
shouldHideForWindows = filtered.length > 0;
}
property real effectiveSpacing: SettingsData.frameEnabled ? 0 : (hasMaximizedToplevel ? 0 : (barConfig?.spacing ?? 4))
property real effectiveSpacing: (SettingsData.frameEnabled && usesFrameBarChrome) ? 0 : ((flattenForMaximizedWindow && hasMaximizedToplevel) ? 0 : (barConfig?.spacing ?? 4))
Behavior on effectiveSpacing {
enabled: barWindow.visible
@@ -438,7 +445,7 @@ PanelWindow {
}
readonly property int notificationCount: NotificationService.notifications.length
readonly property real effectiveBarThickness: SettingsData.frameEnabled ? SettingsData.frameBarSize : Theme.snap(Math.max(barWindow.widgetThickness + (barConfig?.innerPadding ?? 4) + 4, Theme.barHeight - 4 - (8 - (barConfig?.innerPadding ?? 4))), _dpr)
readonly property real effectiveBarThickness: (SettingsData.frameEnabled && usesFrameBarChrome) ? SettingsData.frameBarSize : Theme.snap(Math.max(barWindow.widgetThickness + (barConfig?.innerPadding ?? 4) + 4, Theme.barHeight - 4 - (8 - (barConfig?.innerPadding ?? 4))), _dpr)
readonly property bool effectiveOpenOnOverview: SettingsData.frameEnabled ? SettingsData.frameShowOnOverview : (barConfig?.openOnOverview ?? false)
readonly property real widgetThickness: Theme.snap(Math.max(20, 26 + (barConfig?.innerPadding ?? 4) * 0.6), _dpr)
@@ -636,9 +643,9 @@ PanelWindow {
anchors.left: !isVertical ? true : (barPos === SettingsData.Position.Left)
anchors.right: !isVertical ? true : (barPos === SettingsData.Position.Right)
readonly property bool reserveExclusiveWhenAutoHidden: SettingsData.connectedFrameModeActive && !!barWindow.screen && SettingsData.isScreenInPreferences(barWindow.screen, SettingsData.frameScreenPreferences)
readonly property bool reserveExclusiveWhenAutoHidden: SettingsData.frameEnabled && usesFrameBarChrome && !!barWindow.screen && SettingsData.isScreenInPreferences(barWindow.screen, SettingsData.frameScreenPreferences)
exclusiveZone: (barWindow.hasFullscreenToplevel || !(barConfig?.visible ?? true) || (topBarCore.autoHide && !barWindow.reserveExclusiveWhenAutoHidden)) ? -1 : (barWindow.effectiveBarThickness + effectiveSpacing + (Theme.isConnectedEffect ? 0 : (barConfig?.bottomGap ?? 0)))
exclusiveZone: (!(barConfig?.visible ?? true) || (topBarCore.autoHide && !barWindow.reserveExclusiveWhenAutoHidden)) ? -1 : (barWindow.effectiveBarThickness + effectiveSpacing + (usesFrameBarChrome ? 0 : (barConfig?.bottomGap ?? 0)))
Item {
id: inputMask
@@ -647,9 +654,9 @@ PanelWindow {
readonly property bool inOverviewWithShow: CompositorService.isNiri && NiriService.inOverview && barWindow.effectiveOpenOnOverview
readonly property bool effectiveVisible: (barConfig?.visible ?? true) || inOverviewWithShow
readonly property bool showing: effectiveVisible && !barWindow.hasFullscreenToplevel && (topBarCore.reveal || inOverviewWithShow || !topBarCore.autoHide)
readonly property bool showing: effectiveVisible && (topBarCore.reveal || inOverviewWithShow || !topBarCore.autoHide)
readonly property int maskThickness: barWindow.hasFullscreenToplevel ? 0 : (showing ? barThickness : 1)
readonly property int maskThickness: showing ? barThickness : 1
x: {
if (!axis.isVertical) {
@@ -719,7 +726,7 @@ PanelWindow {
item: clickThroughEnabled ? null : inputMask
Region {
readonly property var r: barWindow.clickThroughEnabled ? barWindow.sectionRect(barWindow._leftSection, false, barWindow._revealProgress) : {
readonly property var r: barWindow.clickThroughEnabled ? barWindow.sectionRect(barWindow._leftSection, false, barWindow._revealProgress + barWindow.width * 0) : {
"x": 0,
"y": 0,
"w": 0,
@@ -732,7 +739,7 @@ PanelWindow {
}
Region {
readonly property var r: barWindow.clickThroughEnabled ? barWindow.sectionRect(barWindow._centerSection, true, barWindow._revealProgress) : {
readonly property var r: barWindow.clickThroughEnabled ? barWindow.sectionRect(barWindow._centerSection, true, barWindow._revealProgress + barWindow.width * 0) : {
"x": 0,
"y": 0,
"w": 0,
@@ -745,7 +752,7 @@ PanelWindow {
}
Region {
readonly property var r: barWindow.clickThroughEnabled ? barWindow.sectionRect(barWindow._rightSection, false, barWindow._revealProgress) : {
readonly property var r: barWindow.clickThroughEnabled ? barWindow.sectionRect(barWindow._rightSection, false, barWindow._revealProgress + barWindow.width * 0) : {
"x": 0,
"y": 0,
"w": 0,
@@ -826,9 +833,6 @@ PanelWindow {
}
property bool reveal: {
if (barWindow.hasFullscreenToplevel)
return false;
const inOverviewWithShow = CompositorService.isNiri && NiriService.inOverview && barWindow.effectiveOpenOnOverview;
if (inOverviewWithShow)
return true;
@@ -897,9 +901,9 @@ PanelWindow {
bottom: barWindow.isVertical ? parent.bottom : undefined
}
readonly property bool inOverview: CompositorService.isNiri && NiriService.inOverview && barWindow.effectiveOpenOnOverview
hoverEnabled: (barConfig?.autoHide ?? false) && !inOverview && !barWindow.hasFullscreenToplevel && !topBarCore.popoutPinsReveal
hoverEnabled: (barConfig?.autoHide ?? false) && !inOverview && !topBarCore.popoutPinsReveal
acceptedButtons: Qt.NoButton
enabled: (barConfig?.autoHide ?? false) && !inOverview && !barWindow.hasFullscreenToplevel
enabled: (barConfig?.autoHide ?? false) && !inOverview
Item {
id: topBarContainer
@@ -131,9 +131,19 @@ BasePill {
function getNetworkIconColor() {
if (NetworkService.wifiToggling)
return Theme.primary;
if (NetworkService.isConnecting && !NetworkService.ethernetConnected)
return Theme.primary;
return NetworkService.networkStatus !== "disconnected" ? Theme.primary : Theme.surfaceText;
}
function getIconBlinking(id) {
if (id === "network")
return NetworkService.isWifiConnecting;
if (id === "bluetooth")
return BluetoothService.connecting;
return false;
}
function getVolumeIconName() {
if (!AudioService.sink?.audio)
return "volume_up";
@@ -485,6 +495,7 @@ BasePill {
}
DankIcon {
id: vIconOnlyItem
anchors.centerIn: parent
visible: !verticalGroupItem.modelData.composite
name: {
@@ -515,7 +526,7 @@ BasePill {
case "vpn":
return NetworkService.vpnConnected ? Theme.primary : Theme.surfaceText;
case "bluetooth":
return BluetoothService.connected ? Theme.primary : Theme.surfaceText;
return (BluetoothService.connected || BluetoothService.connecting) ? Theme.primary : Theme.surfaceText;
case "battery":
return root.getBatteryIconColor();
case "printer":
@@ -524,6 +535,11 @@ BasePill {
return Theme.widgetIconColor;
}
}
DankBlink {
target: vIconOnlyItem
running: root.getIconBlinking(verticalGroupItem.modelData.id)
}
}
DankIcon {
@@ -687,7 +703,7 @@ BasePill {
case "vpn":
return NetworkService.vpnConnected ? Theme.primary : Theme.surfaceText;
case "bluetooth":
return BluetoothService.connected ? Theme.primary : Theme.surfaceText;
return (BluetoothService.connected || BluetoothService.connecting) ? Theme.primary : Theme.surfaceText;
case "battery":
return root.getBatteryIconColor();
case "printer":
@@ -696,6 +712,11 @@ BasePill {
return Theme.widgetIconColor;
}
}
DankBlink {
target: iconOnlyItem
running: root.getIconBlinking(horizontalGroupItem.modelData.id)
}
}
Rectangle {
@@ -1,5 +1,6 @@
import QtQuick
import QtQuick.Effects
import QtQuick.Layouts
import Quickshell
import Quickshell.Wayland
import Quickshell.Widgets
@@ -14,9 +15,20 @@ BasePill {
property var widgetData: null
property bool compactMode: widgetData?.focusedWindowCompactMode !== undefined ? widgetData.focusedWindowCompactMode : SettingsData.focusedWindowCompactMode
property int availableWidth: 400
readonly property int maxNormalWidth: 456
readonly property int maxCompactWidth: 288
readonly property int maxWidth: {
const size = widgetData?.focusedWindowSize !== undefined ? widgetData.focusedWindowSize : SettingsData.focusedWindowSize;
switch (size) {
case 0:
return 288;
case 2:
return 656;
case 3:
return 856;
default:
return 456;
}
}
property int availableWidth: maxWidth
property Toplevel activeWindow: null
property var activeDesktopEntry: null
property bool isHovered: mouseArea.containsMouse
@@ -171,8 +183,7 @@ BasePill {
return 0;
if (root.isVerticalOrientation)
return root.widgetThickness - root.horizontalPadding * 2;
const baseWidth = contentRow.implicitWidth;
return compactMode ? Math.min(baseWidth, maxCompactWidth - root.horizontalPadding * 2) : Math.min(baseWidth, maxNormalWidth - root.horizontalPadding * 2);
return contentRow.implicitWidth;
}
implicitHeight: root.widgetThickness - root.horizontalPadding * 2
clip: false
@@ -222,7 +233,7 @@ BasePill {
color: Theme.widgetTextColor
}
Row {
RowLayout {
id: contentRow
anchors.centerIn: parent
spacing: Theme.spacingS
@@ -231,24 +242,23 @@ BasePill {
StyledText {
id: appText
text: {
if (!activeWindow || !activeWindow.appId)
if (compactMode || !activeWindow || !activeWindow.appId)
return "";
return Paths.getAppName(activeWindow.appId, activeDesktopEntry);
}
font.pixelSize: Theme.barTextSize(root.barThickness, root.barConfig?.fontScale, root.barConfig?.maximizeWidgetText)
color: Theme.widgetTextColor
anchors.verticalCenter: parent.verticalCenter
elide: Text.ElideRight
maximumLineCount: 1
width: Math.min(implicitWidth, compactMode ? 80 : 180)
visible: !compactMode && text.length > 0
Layout.maximumWidth: compactMode ? 80 : 180
visible: text.length > 0
}
StyledText {
text: "•"
id: appSeparator
text: compactMode ? "" : "•"
font.pixelSize: Theme.barTextSize(root.barThickness, root.barConfig?.fontScale, root.barConfig?.maximizeWidgetText)
color: Theme.outlineButton
anchors.verticalCenter: parent.verticalCenter
visible: !compactMode && appText.text && titleText.text
}
@@ -276,10 +286,9 @@ BasePill {
}
font.pixelSize: Theme.barTextSize(root.barThickness, root.barConfig?.fontScale, root.barConfig?.maximizeWidgetText)
color: Theme.widgetTextColor
anchors.verticalCenter: parent.verticalCenter
elide: Text.ElideRight
maximumLineCount: 1
width: Math.min(implicitWidth, compactMode ? 280 : 250)
Layout.maximumWidth: maxWidth - appText.implicitWidth - appSeparator.implicitWidth
visible: text.length > 0
}
}
@@ -11,13 +11,14 @@ BasePill {
id: root
readonly property string focusedScreenName: (CompositorService.isHyprland && typeof Hyprland !== "undefined" && Hyprland.focusedWorkspace && Hyprland.focusedWorkspace.monitor ? (Hyprland.focusedWorkspace.monitor.name || "") : CompositorService.isNiri && typeof NiriService !== "undefined" && NiriService.currentOutput ? NiriService.currentOutput : "")
readonly property string targetScreenName: parentScreen?.name || focusedScreenName
function resolveNotepadInstance() {
if (typeof notepadSlideoutVariants === "undefined" || !notepadSlideoutVariants || !notepadSlideoutVariants.instances) {
return null;
}
const targetScreen = focusedScreenName;
const targetScreen = targetScreenName;
if (targetScreen) {
for (var i = 0; i < notepadSlideoutVariants.instances.length; i++) {
var slideout = notepadSlideoutVariants.instances[i];
@@ -34,6 +35,12 @@ BasePill {
readonly property bool isActive: notepadInstance?.isVisible ?? false
property bool isAutoHideBar: false
function prepareNotepadInstance(instance) {
if (instance)
instance.triggerUsesOverlayLayer = root.barUsesOverlayLayer;
return instance;
}
readonly property real minTooltipY: {
if (!parentScreen || !(axis?.isVertical ?? false)) {
return 0;
@@ -68,8 +75,9 @@ BasePill {
function openTabByIndex(tabIndex) {
if (tabIndex < 0)
return;
if (root.notepadInstance && typeof root.notepadInstance.show === "function") {
root.notepadInstance.show();
const instance = prepareNotepadInstance(root.notepadInstance);
if (instance && typeof instance.show === "function") {
instance.show();
}
Qt.callLater(() => {
NotepadStorageService.switchToTab(tabIndex);
@@ -77,8 +85,9 @@ BasePill {
}
function openNewNote() {
if (root.notepadInstance && typeof root.notepadInstance.show === "function") {
root.notepadInstance.show();
const instance = prepareNotepadInstance(root.notepadInstance);
if (instance && typeof instance.show === "function") {
instance.show();
}
Qt.callLater(() => {
NotepadStorageService.createNewTab();
@@ -138,7 +147,7 @@ BasePill {
openContextMenu();
return;
}
const inst = root.notepadInstance;
const inst = prepareNotepadInstance(root.notepadInstance);
if (inst) {
inst.toggle();
}
@@ -978,7 +978,7 @@ BasePill {
visible: root.useOverflowPopup && root.menuOpen
screen: root.parentScreen
WlrLayershell.layer: WlrLayershell.Top
WlrLayershell.layer: root.barUsesOverlayLayer ? WlrLayershell.Overlay : WlrLayershell.Top
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: {
if (!root.menuOpen)
@@ -1446,7 +1446,7 @@ BasePill {
WlrLayershell.namespace: "dms:tray-menu-window"
visible: menuRoot.showMenu && (menuRoot.trayItem?.hasMenu ?? false)
screen: menuRoot.parentScreen
WlrLayershell.layer: WlrLayershell.Top
WlrLayershell.layer: root.barUsesOverlayLayer ? WlrLayershell.Overlay : WlrLayershell.Top
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: {
if (!menuRoot.showMenu)
+36 -106
View File
@@ -20,16 +20,16 @@ Variants {
WindowBlur {
targetWindow: dock
blurEnabled: dock.effectiveBlurEnabled && !SettingsData.connectedFrameModeActive
blurEnabled: dock.effectiveBlurEnabled && !dock.usesConnectedFrameChrome
blurX: dockBackground.x + dockContainer.x + dockMouseArea.x + dockCore.x + dockSlide.x
blurY: dockBackground.y + dockContainer.y + dockMouseArea.y + dockCore.y + dockSlide.y
blurWidth: dock.hasApps && dock.reveal ? dockBackground.width : 0
blurHeight: dock.hasApps && dock.reveal ? dockBackground.height : 0
blurRadius: Theme.isConnectedEffect ? Theme.connectedCornerRadius : dock.surfaceRadius
blurRadius: dock.usesConnectedFrameChrome ? Theme.connectedCornerRadius : dock.surfaceRadius
}
WlrLayershell.namespace: "dms:dock"
WlrLayershell.layer: SettingsData.frameEnabled && !dock.hasFullscreenToplevel ? WlrLayer.Overlay : WlrLayer.Top
WlrLayershell.layer: dock.usesOverlayLayer ? WlrLayer.Overlay : WlrLayer.Top
readonly property bool isVertical: SettingsData.dockPosition === SettingsData.Position.Left || SettingsData.dockPosition === SettingsData.Position.Right
@@ -50,16 +50,16 @@ Variants {
readonly property bool connectedBarActiveOnEdge: dockGeometry.connectedBarActiveOnEdge
readonly property real connectedJoinInset: dockGeometry.connectedJoinInset
readonly property real dockFrameInset: dockGeometry.frameInset
readonly property real surfaceRadius: Theme.connectedSurfaceRadius
readonly property color surfaceColor: Theme.isConnectedEffect ? Theme.connectedSurfaceColor : Theme.withAlpha(Theme.surfaceContainer, backgroundTransparency)
readonly property color surfaceBorderColor: Theme.isConnectedEffect ? "transparent" : BlurService.borderColor
readonly property real surfaceBorderWidth: Theme.isConnectedEffect ? 0 : BlurService.borderWidth
readonly property real surfaceTopLeftRadius: Theme.isConnectedEffect && (SettingsData.dockPosition === SettingsData.Position.Top || SettingsData.dockPosition === SettingsData.Position.Left) ? 0 : surfaceRadius
readonly property real surfaceTopRightRadius: Theme.isConnectedEffect && (SettingsData.dockPosition === SettingsData.Position.Top || SettingsData.dockPosition === SettingsData.Position.Right) ? 0 : surfaceRadius
readonly property real surfaceBottomLeftRadius: Theme.isConnectedEffect && (SettingsData.dockPosition === SettingsData.Position.Bottom || SettingsData.dockPosition === SettingsData.Position.Left) ? 0 : surfaceRadius
readonly property real surfaceBottomRightRadius: Theme.isConnectedEffect && (SettingsData.dockPosition === SettingsData.Position.Bottom || SettingsData.dockPosition === SettingsData.Position.Right) ? 0 : surfaceRadius
readonly property real horizontalConnectorExtent: Theme.isConnectedEffect && !isVertical ? Theme.connectedCornerRadius : 0
readonly property real verticalConnectorExtent: Theme.isConnectedEffect && isVertical ? Theme.connectedCornerRadius : 0
readonly property real surfaceRadius: usesConnectedFrameChrome ? Theme.connectedSurfaceRadius : Theme.cornerRadius
readonly property color surfaceColor: usesConnectedFrameChrome ? Theme.connectedSurfaceColor : Theme.withAlpha(Theme.surfaceContainer, backgroundTransparency)
readonly property color surfaceBorderColor: usesConnectedFrameChrome ? "transparent" : BlurService.borderColor
readonly property real surfaceBorderWidth: usesConnectedFrameChrome ? 0 : BlurService.borderWidth
readonly property real surfaceTopLeftRadius: usesConnectedFrameChrome && (SettingsData.dockPosition === SettingsData.Position.Top || SettingsData.dockPosition === SettingsData.Position.Left) ? 0 : surfaceRadius
readonly property real surfaceTopRightRadius: usesConnectedFrameChrome && (SettingsData.dockPosition === SettingsData.Position.Top || SettingsData.dockPosition === SettingsData.Position.Right) ? 0 : surfaceRadius
readonly property real surfaceBottomLeftRadius: usesConnectedFrameChrome && (SettingsData.dockPosition === SettingsData.Position.Bottom || SettingsData.dockPosition === SettingsData.Position.Left) ? 0 : surfaceRadius
readonly property real surfaceBottomRightRadius: usesConnectedFrameChrome && (SettingsData.dockPosition === SettingsData.Position.Bottom || SettingsData.dockPosition === SettingsData.Position.Right) ? 0 : surfaceRadius
readonly property real horizontalConnectorExtent: usesConnectedFrameChrome && !isVertical ? Theme.connectedCornerRadius : 0
readonly property real verticalConnectorExtent: usesConnectedFrameChrome && isVertical ? Theme.connectedCornerRadius : 0
readonly property int hasApps: dockApps.implicitWidth > 0 || dockApps.implicitHeight > 0
@@ -149,7 +149,6 @@ Variants {
edge: dock.connectedBarSide
dockVisible: dock.visible
autoHide: dock.autoHide
hasFullscreenToplevel: dock.hasFullscreenToplevel
iconSize: dock.widgetHeight
spacing: SettingsData.dockSpacing
borderThickness: dock.borderThickness
@@ -176,25 +175,13 @@ Variants {
}
readonly property string _dockScreenName: dock.modelData ? dock.modelData.name : (dock.screen ? dock.screen.name : "")
readonly property bool hasFullscreenToplevel: {
if (!SettingsData.dockHideOnFullscreen)
return false;
CompositorService.sortedToplevels;
ToplevelManager.activeToplevel;
if (CompositorService.isNiri) {
NiriService.currentOutput;
NiriService.windows;
NiriService.allWorkspaces;
}
if (CompositorService.isHyprland)
Hyprland.focusedWorkspace;
return CompositorService.hasFullscreenToplevelOnScreen(dock._dockScreenName);
}
readonly property bool usesConnectedFrameChrome: CompositorService.usesConnectedFrameChromeForScreen(dock._dockScreenName)
readonly property bool usesOverlayLayer: CompositorService.framePeerSurfacesUseOverlayForScreen(dock._dockScreenName) || SettingsData.dockUseOverlayLayer
function _syncDockChromeState() {
if (!dock._dockScreenName)
return;
if (!SettingsData.connectedFrameModeActive) {
if (!dock.usesConnectedFrameChrome) {
ConnectedModeState.clearDockState(dock._dockScreenName);
return;
}
@@ -212,19 +199,19 @@ Variants {
}
function _syncDockSlide() {
if (!dock._dockScreenName || !SettingsData.connectedFrameModeActive)
if (!dock._dockScreenName || !dock.usesConnectedFrameChrome)
return;
ConnectedModeState.setDockSlide(dock._dockScreenName, dockSlide.x, dockSlide.y);
}
DeferredAction {
id: dockSlideSync
enabled: SettingsData.connectedFrameModeActive
enabled: dock.usesConnectedFrameChrome
onTriggered: dock._syncDockSlide()
}
function _queueSlideSync() {
if (!SettingsData.connectedFrameModeActive)
if (!dock.usesConnectedFrameChrome)
return;
dockSlideSync.schedule();
}
@@ -304,65 +291,10 @@ Variants {
return false;
}
// Hyprland implementation
// Hyprland implementation (current workspace + visible special workspaces)
Hyprland.focusedWorkspace;
const filtered = CompositorService.filterCurrentWorkspace(CompositorService.sortedToplevels, screenName);
if (filtered.length === 0)
return false;
for (let i = 0; i < filtered.length; i++) {
const toplevel = filtered[i];
let hyprToplevel = null;
if (Hyprland.toplevels) {
const hyprToplevels = Array.from(Hyprland.toplevels.values);
for (let j = 0; j < hyprToplevels.length; j++) {
if (hyprToplevels[j].wayland === toplevel) {
hyprToplevel = hyprToplevels[j];
break;
}
}
}
if (!hyprToplevel?.lastIpcObject)
continue;
const ipc = hyprToplevel.lastIpcObject;
const at = ipc.at;
const size = ipc.size;
if (!at || !size)
continue;
const monX = hyprToplevel.monitor?.x ?? 0;
const monY = hyprToplevel.monitor?.y ?? 0;
const winX = at[0] - monX;
const winY = at[1] - monY;
const winW = size[0];
const winH = size[1];
switch (SettingsData.dockPosition) {
case SettingsData.Position.Top:
if (winY < dockThickness)
return true;
break;
case SettingsData.Position.Bottom:
if (winY + winH > screenHeight - dockThickness)
return true;
break;
case SettingsData.Position.Left:
if (winX < dockThickness)
return true;
break;
case SettingsData.Position.Right:
if (winX + winW > screenWidth - dockThickness)
return true;
break;
}
}
return false;
Hyprland.toplevels;
return CompositorService.hyprlandDockOverlapForSmartAutoHide(screenName, SettingsData.dockPosition, dockThickness, screenWidth, screenHeight);
}
Timer {
@@ -383,9 +315,6 @@ Variants {
if (_modalRetractActive)
return false;
if (dock.hasFullscreenToplevel)
return false;
if (CompositorService.isNiri && NiriService.inOverview && SettingsData.dockOpenOnOverview) {
return true;
}
@@ -421,7 +350,7 @@ Variants {
onVisibleChanged: dock._syncDockChromeState()
onHasAppsChanged: dock._syncDockChromeState()
onConnectedBarSideChanged: dock._syncDockChromeState()
onHasFullscreenToplevelChanged: dock._syncDockChromeState()
onUsesConnectedFrameChromeChanged: dock._syncDockChromeState()
Connections {
target: SettingsData
@@ -680,7 +609,7 @@ Variants {
return 0;
if (dock.reveal)
return 0;
if (Theme.isConnectedEffect) {
if (dock.usesConnectedFrameChrome) {
const retractDist = dockBackground.width + SettingsData.dockSpacing + 10;
return SettingsData.dockPosition === SettingsData.Position.Right ? retractDist : -retractDist;
}
@@ -696,7 +625,7 @@ Variants {
return 0;
if (dock.reveal)
return 0;
if (Theme.isConnectedEffect) {
if (dock.usesConnectedFrameChrome) {
const retractDist = dockBackground.height + SettingsData.dockSpacing + 10;
return SettingsData.dockPosition === SettingsData.Position.Bottom ? retractDist : -retractDist;
}
@@ -711,9 +640,9 @@ Variants {
Behavior on x {
NumberAnimation {
id: slideXAnimation
duration: Theme.isConnectedEffect ? Theme.variantDuration(Theme.popoutAnimationDuration, dock.reveal) : Theme.shortDuration
easing.type: Theme.isConnectedEffect ? Easing.BezierSpline : Easing.OutCubic
easing.bezierCurve: Theme.isConnectedEffect ? (dock.reveal ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve) : []
duration: dock.usesConnectedFrameChrome ? Theme.variantDuration(Theme.popoutAnimationDuration, dock.reveal) : Theme.shortDuration
easing.type: dock.usesConnectedFrameChrome ? Easing.BezierSpline : Easing.OutCubic
easing.bezierCurve: dock.usesConnectedFrameChrome ? (dock.reveal ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve) : []
onRunningChanged: if (!running)
dock._syncDockChromeState()
}
@@ -722,9 +651,9 @@ Variants {
Behavior on y {
NumberAnimation {
id: slideYAnimation
duration: Theme.isConnectedEffect ? Theme.variantDuration(Theme.popoutAnimationDuration, dock.reveal) : Theme.shortDuration
easing.type: Theme.isConnectedEffect ? Easing.BezierSpline : Easing.OutCubic
easing.bezierCurve: Theme.isConnectedEffect ? (dock.reveal ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve) : []
duration: dock.usesConnectedFrameChrome ? Theme.variantDuration(Theme.popoutAnimationDuration, dock.reveal) : Theme.shortDuration
easing.type: dock.usesConnectedFrameChrome ? Easing.BezierSpline : Easing.OutCubic
easing.bezierCurve: dock.usesConnectedFrameChrome ? (dock.reveal ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve) : []
onRunningChanged: if (!running)
dock._syncDockChromeState()
}
@@ -756,12 +685,12 @@ Variants {
height: implicitHeight
// Avoid an offscreen texture seam where the connected dock meets the frame.
layer.enabled: !Theme.isConnectedEffect
layer.enabled: !usesConnectedFrameChrome
clip: false
Rectangle {
anchors.fill: parent
visible: !SettingsData.connectedFrameModeActive && !(Theme.isConnectedEffect && dock.reveal)
visible: !usesConnectedFrameChrome && (!SettingsData.connectedFrameModeActive || dock.reveal)
color: dock.surfaceColor
topLeftRadius: dock.surfaceTopLeftRadius
topRightRadius: dock.surfaceTopRightRadius
@@ -771,7 +700,7 @@ Variants {
Rectangle {
anchors.fill: parent
visible: !SettingsData.connectedFrameModeActive && !(Theme.isConnectedEffect && dock.reveal)
visible: !usesConnectedFrameChrome && (!SettingsData.connectedFrameModeActive || dock.reveal)
color: "transparent"
topLeftRadius: dock.surfaceTopLeftRadius
topRightRadius: dock.surfaceTopRightRadius
@@ -807,7 +736,7 @@ Variants {
y: dockBackground.y - borderThickness
width: dockBackground.width + borderThickness * 2
height: dockBackground.height + borderThickness * 2
visible: SettingsData.dockBorderEnabled && dock.hasApps && !Theme.isConnectedEffect
visible: SettingsData.dockBorderEnabled && dock.hasApps && !usesConnectedFrameChrome
preferredRendererType: Shape.CurveRenderer
readonly property real borderThickness: Math.max(1, dock.borderThickness)
@@ -883,6 +812,7 @@ Variants {
isVertical: dock.isVertical
dockScreen: dock.screen
iconSize: dock.widgetHeight
usesOverlayLayer: dock.usesOverlayLayer
}
}
}
+1
View File
@@ -15,6 +15,7 @@ Item {
property bool isVertical: false
property var dockScreen: null
property real iconSize: 40
property bool usesOverlayLayer: false
property int draggedIndex: -1
property int dropTargetIndex: -1
property bool suppressShiftAnimation: false
+11 -11
View File
@@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound
import QtQuick
import qs.Common
import qs.Services
QtObject {
id: root
@@ -10,7 +11,6 @@ QtObject {
property string edge: "bottom"
property bool dockVisible: false
property bool autoHide: false
property bool hasFullscreenToplevel: false
property real iconSize: 40
property real spacing: 4
property real borderThickness: 0
@@ -23,14 +23,14 @@ QtObject {
return Math.round(value * dpr) / dpr;
}
readonly property bool frameExclusionActive: SettingsData.frameEnabled && !!screen && SettingsData.isScreenInPreferences(screen, SettingsData.frameScreenPreferences)
readonly property bool connectedMode: Theme.isConnectedEffect
readonly property bool connectedBarActiveOnEdge: connectedMode && !!screen && SettingsData.getActiveBarEdgesForScreen(screen).includes(edge)
readonly property bool frameExclusionActive: CompositorService.frameWindowVisibleForScreen(screen)
readonly property bool usesConnectedFrameChrome: CompositorService.usesConnectedFrameChromeForScreen(screen)
readonly property bool connectedBarActiveOnEdge: usesConnectedFrameChrome && !!screen && SettingsData.getActiveBarEdgesForScreen(screen).includes(edge)
readonly property real connectedJoinInset: {
if (connectedMode)
if (usesConnectedFrameChrome)
return connectedBarActiveOnEdge ? SettingsData.frameBarSize : SettingsData.frameThickness;
if (SettingsData.frameEnabled)
if (frameExclusionActive)
return SettingsData.frameEdgeInsetForSide(screen, edge);
return 0;
}
@@ -38,15 +38,15 @@ QtObject {
readonly property real frameInset: {
if (!frameExclusionActive)
return 0;
if (connectedMode)
if (usesConnectedFrameChrome)
return connectedJoinInset;
return SettingsData.frameThickness;
}
readonly property real effectiveMargin: connectedMode ? 0 : margin
readonly property real visualOffset: connectedMode ? 0 : offset
readonly property real effectiveMargin: usesConnectedFrameChrome ? 0 : margin
readonly property real visualOffset: usesConnectedFrameChrome ? 0 : offset
readonly property real reserveOffset: offset
readonly property real joinedEdgeMargin: connectedMode ? 0 : (barSpacing + effectiveMargin + 1 + borderThickness)
readonly property real joinedEdgeMargin: usesConnectedFrameChrome ? 0 : (barSpacing + effectiveMargin + 1 + borderThickness)
readonly property real bodyEdgeMargin: frameInset + joinedEdgeMargin
readonly property real bodyThickness: iconSize + spacing * 2 + borderThickness * 2
@@ -57,5 +57,5 @@ QtObject {
// Frame/bar edge exclusions already reserve the edge itself, so the dock
// reservation covers only the dock body and user offset beyond that edge.
readonly property real reserveZone: px(bodyThickness + reserveOffset + effectiveMargin)
readonly property bool shouldReserveSpace: dockVisible && !hasFullscreenToplevel && !autoHide && barSpacing <= 0
readonly property bool shouldReserveSpace: dockVisible && !autoHide && barSpacing <= 0
}
@@ -148,7 +148,7 @@ Item {
if (wasDragging || mouse.button !== Qt.LeftButton)
return;
PopoutService.toggleDankLauncherV2();
PopoutService.toggleDankLauncherV2(dockApps?.usesOverlayLayer ?? false);
}
onPositionChanged: mouse => {
if (longPressing && !dragging) {
+2 -1
View File
@@ -4,6 +4,7 @@ import QtQuick
import Quickshell
import Quickshell.Wayland
import qs.Common
import qs.Services
Scope {
id: root
@@ -18,7 +19,7 @@ Scope {
// One thin invisible PanelWindow per edge.
// Skips any edge where a bar already provides its own exclusiveZone.
readonly property bool screenEnabled: SettingsData.frameEnabled && SettingsData.isScreenInPreferences(root.screen, SettingsData.frameScreenPreferences)
readonly property bool screenEnabled: CompositorService.frameWindowVisibleForScreen(root.screen)
Loader {
active: root.screenEnabled && !root.barEdges.includes("top")
+4 -3
View File
@@ -17,8 +17,9 @@ PanelWindow {
required property var targetScreen
screen: targetScreen
visible: _frameActive
updatesEnabled: _frameActive
readonly property bool _frameVisible: CompositorService.frameWindowVisibleForScreen(win.targetScreen)
visible: win._frameVisible
updatesEnabled: win._frameVisible
WlrLayershell.namespace: "dms:frame"
WlrLayershell.layer: WlrLayer.Top
@@ -52,7 +53,7 @@ PanelWindow {
readonly property var _notifState: ConnectedModeState.notificationStates[win._screenName] || ConnectedModeState.emptyNotificationState
readonly property var _modalState: ConnectedModeState.modalStates[win._screenName] || ConnectedModeState.emptyModalState
readonly property bool _connectedActive: win._frameActive && SettingsData.connectedFrameModeActive
readonly property bool _connectedActive: CompositorService.usesConnectedFrameChromeForScreen(win.targetScreen)
readonly property string _barSide: {
const edges = win.barEdges;
if (edges.includes("top"))
+3 -2
View File
@@ -97,7 +97,8 @@ sudo rpm -ivh x86_64/dms-greeter-*.rpm
```
The package automatically:
- Creates the greeter user
- Creates the greeter user (via `systemd-sysusers` from `/usr/lib/sysusers.d/dms-greeter.conf` for atomic/immutable compatibility, with package script fallback)
- Sets up directories and permissions
- Configures greetd with auto-detected compositor
- Applies SELinux contexts
@@ -178,7 +179,7 @@ sudo systemctl enable greetd
#### Legacy installation (deprecated)
If you prefer the old method with separate shell scripts and config files:
1. Copy `assets/dms-niri.kdl` or `assets/dms-hypr.conf` to `/etc/greetd`
1. Copy `assets/dms-niri.kdl` or `assets/dms-hypr.lua` (legacy: `assets/dms-hypr.conf`) to `/etc/greetd`
2. Copy `assets/greet-niri.sh` or `assets/greet-hyprland.sh` to `/usr/local/bin/start-dms-greetd.sh`
3. Edit the config file and replace `_DMS_PATH_` with your DMS installation path
4. Configure greetd to use `/usr/local/bin/start-dms-greetd.sh`
@@ -1,3 +1,4 @@
# Deprecated: greetd expects Hyprland 0.55+ Lua; use `/etc/greetd/dms-hypr.lua` instead.
env = DMS_RUN_GREETER,1
exec = sh -c "qs -p _DMS_PATH_; hyprctl dispatch exit"
@@ -0,0 +1,8 @@
-- Minimal Hyprland (Lua) session for greetd — replace _DMS_PATH_ with your DMS checkout.
-- Copy to `/etc/greetd/dms-hypr.lua` alongside `greet-hyprland.sh`.
hl.env("DMS_RUN_GREETER", "1")
hl.on("hyprland.start", function()
hl.exec_cmd('sh -c "qs -p _DMS_PATH_; hyprctl dispatch exit"')
end)

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