1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-06-28 05:55:21 -04:00

Compare commits

...

37 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
bbedward 8dd891f93a i18n: term sync 2026-05-18 09:43:25 -04:00
Josh Symonds 9bd68d44a1 notifications: honor freedesktop suppress-sound hint (#2440)
Senders that play their own audio for a notification can set the
standard org.freedesktop.Notifications "suppress-sound" boolean hint
to ask the server not to double up. NotificationService skipped its
sound only as a side effect of the dedup early-return (when an
identically-keyed popup was still visible), so transient notifications
double-sounded while lingering ones didn't — nondeterministic. Read
notif.hints["suppress-sound"] and gate the AudioService call on it.
2026-05-18 09:40:50 -04:00
Sheershak sharma 90ea136379 fixed batterylevel shown zero in case of unknown state (#2436) 2026-05-18 09:40:20 -04:00
Huỳnh Thiện Lộc 2f4a39f9eb fix(settings): ensure plugin browser install button resizes correctly (#2431)
Fixed a visual bug where the install button would overflow its container after a plugin was installed. This was caused by the width not re-evaluating correctly and StyledText's default elide/wrap properties interfering with implicit width calculations.
2026-05-18 09:40:00 -04:00
sima 5e558660c3 Fix Hyprland scrolling overview geometry (#2442) 2026-05-18 08:21:39 -04:00
176 changed files with 16497 additions and 2973 deletions
+3
View File
@@ -6,6 +6,7 @@ import (
"regexp" "regexp"
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/plugins" "github.com/AvengeMedia/DankMaterialShell/core/internal/plugins"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server" "github.com/AvengeMedia/DankMaterialShell/core/internal/server"
@@ -37,6 +38,7 @@ var runCmd = &cobra.Command{
} }
} }
log.ApplyEnvOverrides() log.ApplyEnvOverrides()
config.CleanupStrayHyprlandConfFile(log.Infof)
if daemon { if daemon {
runShellDaemon(session) runShellDaemon(session)
} else { } else {
@@ -539,5 +541,6 @@ func getCommonCommands() []*cobra.Command {
blurCmd, blurCmd,
trashCmd, trashCmd,
systemCmd, systemCmd,
switchUserCmd,
} }
} }
+41 -8
View File
@@ -8,6 +8,7 @@ import (
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/luaconfig"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils" "github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -27,7 +28,21 @@ var resolveIncludeCmd = &cobra.Command{
case 0: case 0:
return []string{"hyprland", "niri", "mangowc"}, cobra.ShellCompDirectiveNoFileComp return []string{"hyprland", "niri", "mangowc"}, cobra.ShellCompDirectiveNoFileComp
case 1: 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 return nil, cobra.ShellCompDirectiveNoFileComp
}, },
@@ -82,17 +97,35 @@ func checkHyprlandInclude(filename string) (IncludeResult, error) {
result.Exists = true result.Exists = true
} }
mainConfig := filepath.Join(configDir, "hyprland.conf") targetAbs, err := filepath.Abs(targetPath)
if _, err := os.Stat(mainConfig); os.IsNotExist(err) { if err != nil {
return result, 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 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) absPath, err := filepath.Abs(filePath)
if err != nil { if err != nil {
return false return false
@@ -141,7 +174,7 @@ func hyprlandFindInclude(filePath, target string, processed map[string]bool) boo
continue continue
} }
if hyprlandFindInclude(expanded, target, processed) { if hyprlandFindIncludeHyprlang(expanded, target, processed) {
return true return true
} }
} }
+27 -2
View File
@@ -51,12 +51,20 @@ var keybindsSetCmd = &cobra.Command{
var keybindsRemoveCmd = &cobra.Command{ var keybindsRemoveCmd = &cobra.Command{
Use: "remove <provider> <key>", Use: "remove <provider> <key>",
Short: "Remove a keybind override", Short: "Remove a keybind",
Long: "Remove a keybind override from the specified provider", 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), Args: cobra.ExactArgs(2),
Run: runKeybindsRemove, 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() { func init() {
keybindsListCmd.Flags().BoolP("json", "j", false, "Output as JSON") keybindsListCmd.Flags().BoolP("json", "j", false, "Output as JSON")
keybindsShowCmd.Flags().String("path", "", "Override config path for the provider") keybindsShowCmd.Flags().String("path", "", "Override config path for the provider")
@@ -72,6 +80,7 @@ func init() {
keybindsCmd.AddCommand(keybindsShowCmd) keybindsCmd.AddCommand(keybindsShowCmd)
keybindsCmd.AddCommand(keybindsSetCmd) keybindsCmd.AddCommand(keybindsSetCmd)
keybindsCmd.AddCommand(keybindsRemoveCmd) keybindsCmd.AddCommand(keybindsRemoveCmd)
keybindsCmd.AddCommand(keybindsResetCmd)
keybinds.SetJSONProviderFactory(func(filePath string) (keybinds.Provider, error) { keybinds.SetJSONProviderFactory(func(filePath string) (keybinds.Provider, error) {
return providers.NewJSONFileProvider(filePath) return providers.NewJSONFileProvider(filePath)
@@ -263,3 +272,19 @@ func runKeybindsRemove(_ *cobra.Command, args []string) {
}, "", " ") }, "", " ")
fmt.Fprintln(os.Stdout, string(output)) 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{ var dmsConfigSpecs = map[string]dmsConfigSpec{
"binds": { "binds": {
niriFile: "binds.kdl", niriFile: "binds.kdl",
hyprFile: "binds.conf", hyprFile: "binds.lua",
niriContent: func(t string) string { niriContent: func(t string) string {
return strings.ReplaceAll(config.NiriBindsConfig, "{{TERMINAL_COMMAND}}", t) return strings.ReplaceAll(config.NiriBindsConfig, "{{TERMINAL_COMMAND}}", t)
}, },
hyprContent: func(t string) string { hyprContent: func(t string) string {
return strings.ReplaceAll(config.HyprBindsConfig, "{{TERMINAL_COMMAND}}", t) return strings.ReplaceAll(config.DMSBindsLuaConfig, "{{TERMINAL_COMMAND}}", t)
}, },
}, },
"layout": { "layout": {
niriFile: "layout.kdl", niriFile: "layout.kdl",
hyprFile: "layout.conf", hyprFile: "layout.lua",
niriContent: func(_ string) string { return config.NiriLayoutConfig }, niriContent: func(_ string) string { return config.NiriLayoutConfig },
hyprContent: func(_ string) string { return config.HyprLayoutConfig }, hyprContent: func(_ string) string { return config.DMSLayoutLuaConfig },
}, },
"colors": { "colors": {
niriFile: "colors.kdl", niriFile: "colors.kdl",
hyprFile: "colors.conf", hyprFile: "colors.lua",
niriContent: func(_ string) string { return config.NiriColorsConfig }, niriContent: func(_ string) string { return config.NiriColorsConfig },
hyprContent: func(_ string) string { return config.HyprColorsConfig }, hyprContent: func(_ string) string { return config.DMSColorsLuaConfig },
}, },
"alttab": { "alttab": {
niriFile: "alttab.kdl", niriFile: "alttab.kdl",
@@ -135,21 +135,21 @@ var dmsConfigSpecs = map[string]dmsConfigSpec{
}, },
"outputs": { "outputs": {
niriFile: "outputs.kdl", niriFile: "outputs.kdl",
hyprFile: "outputs.conf", hyprFile: "outputs.lua",
niriContent: func(_ string) string { return "" }, niriContent: func(_ string) string { return "" },
hyprContent: func(_ string) string { return "" }, hyprContent: func(_ string) string { return config.DMSOutputsLuaConfig },
}, },
"cursor": { "cursor": {
niriFile: "cursor.kdl", niriFile: "cursor.kdl",
hyprFile: "cursor.conf", hyprFile: "cursor.lua",
niriContent: func(_ string) string { return "" }, niriContent: func(_ string) string { return "" },
hyprContent: func(_ string) string { return "" }, hyprContent: func(_ string) string { return config.DMSCursorLuaConfig },
}, },
"windowrules": { "windowrules": {
niriFile: "windowrules.kdl", niriFile: "windowrules.kdl",
hyprFile: "windowrules.conf", hyprFile: "windowrules.lua",
niriContent: func(_ string) string { return "" }, 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 willBackup := false
if wmSelected { if wmSelected {
var configPath string var configPaths []string
switch wm { switch wm {
case deps.WindowManagerNiri: case deps.WindowManagerNiri:
configPath = filepath.Join(homeDir, ".config", "niri", "config.kdl") configPaths = []string{filepath.Join(homeDir, ".config", "niri", "config.kdl")}
case deps.WindowManagerHyprland: 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 { for _, configPath := range configPaths {
willBackup = true 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), Args: cobra.MaximumNArgs(1),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 { if len(args) == 0 {
return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
} }
return nil, cobra.ShellCompDirectiveNoFileComp return nil, cobra.ShellCompDirectiveNoFileComp
}, },
@@ -40,8 +40,7 @@ var windowrulesAddCmd = &cobra.Command{
Args: cobra.ExactArgs(2), Args: cobra.ExactArgs(2),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 { if len(args) == 0 {
// ! disabled hyprland return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp
} }
return nil, cobra.ShellCompDirectiveNoFileComp return nil, cobra.ShellCompDirectiveNoFileComp
}, },
@@ -55,7 +54,7 @@ var windowrulesUpdateCmd = &cobra.Command{
Args: cobra.ExactArgs(3), Args: cobra.ExactArgs(3),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 { if len(args) == 0 {
return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
} }
return nil, cobra.ShellCompDirectiveNoFileComp return nil, cobra.ShellCompDirectiveNoFileComp
}, },
@@ -69,7 +68,7 @@ var windowrulesRemoveCmd = &cobra.Command{
Args: cobra.ExactArgs(2), Args: cobra.ExactArgs(2),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 { if len(args) == 0 {
return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
} }
return nil, cobra.ShellCompDirectiveNoFileComp return nil, cobra.ShellCompDirectiveNoFileComp
}, },
@@ -83,7 +82,7 @@ var windowrulesReorderCmd = &cobra.Command{
Args: cobra.ExactArgs(2), Args: cobra.ExactArgs(2),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 { if len(args) == 0 {
return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
} }
return nil, cobra.ShellCompDirectiveNoFileComp return nil, cobra.ShellCompDirectiveNoFileComp
}, },
@@ -118,9 +117,9 @@ func getCompositor(args []string) string {
if os.Getenv("NIRI_SOCKET") != "" { if os.Getenv("NIRI_SOCKET") != "" {
return "niri" return "niri"
} }
// if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "" { if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "" {
// return "hyprland" return "hyprland"
// } }
return "" return ""
} }
@@ -183,7 +182,6 @@ func runWindowrulesList(cmd *cobra.Command, args []string) {
result.DMSStatus = parseResult.DMSStatus result.DMSStatus = parseResult.DMSStatus
case "hyprland": case "hyprland":
log.Fatalf("Hyprland support is currently disabled.") // ! disabled hyprland
configDir, err := utils.ExpandPath("$HOME/.config/hypr") configDir, err := utils.ExpandPath("$HOME/.config/hypr")
if err != nil { if err != nil {
log.Fatalf("Failed to expand hyprland config path: %v", err) 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" "github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
) )
const hyprlandBackupDirName = ".dms-backups"
type ConfigDeployer struct { type ConfigDeployer struct {
logChan chan<- string logChan chan<- string
} }
@@ -63,12 +65,23 @@ func (cd *ConfigDeployer) deployConfigurationsInternal(ctx context.Context, wm d
var results []DeploymentResult var results []DeploymentResult
// Primary config file paths used to detect fresh installs. // Primary config file paths used to detect fresh installs.
configPrimaryPaths := map[string]string{ configPrimaryPaths := map[string][]string{
"Niri": filepath.Join(os.Getenv("HOME"), ".config", "niri", "config.kdl"), "Niri": {
"Hyprland": filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf"), filepath.Join(os.Getenv("HOME"), ".config", "niri", "config.kdl"),
"Ghostty": filepath.Join(os.Getenv("HOME"), ".config", "ghostty", "config"), },
"Kitty": filepath.Join(os.Getenv("HOME"), ".config", "kitty", "kitty.conf"), "Hyprland": {
"Alacritty": filepath.Join(os.Getenv("HOME"), ".config", "alacritty", "alacritty.toml"), 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 { 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 // Config is explicitly set to "don't replace" — but still deploy
// if the config file doesn't exist yet (fresh install scenario). // if the config file doesn't exist yet (fresh install scenario).
if primaryPath, ok := configPrimaryPaths[configType]; ok { if primaryPaths, ok := configPrimaryPaths[configType]; ok {
if _, err := os.Stat(primaryPath); os.IsNotExist(err) { exists := false
for _, primaryPath := range primaryPaths {
if _, err := os.Stat(primaryPath); err == nil {
exists = true
break
}
}
if !exists {
return true return true
} }
} }
@@ -495,7 +515,7 @@ func (cd *ConfigDeployer) mergeNiriOutputSections(newConfig, existingConfig, dms
func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystemd bool) (DeploymentResult, error) { func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystemd bool) (DeploymentResult, error) {
result := DeploymentResult{ result := DeploymentResult{
ConfigType: "Hyprland", 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) configDir := filepath.Dir(result.Path)
@@ -510,20 +530,20 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
return result, result.Error return result, result.Error
} }
timestamp := time.Now().Format("2006-01-02_15-04-05")
backupDir := filepath.Join(configDir, hyprlandBackupDirName, timestamp)
var existingConfig string var existingConfig string
if _, err := os.Stat(result.Path); err == nil { existingData, existingPath, err := readExistingHyprlandConfig(configDir)
cd.log("Found existing Hyprland configuration") 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) result.BackupPath = filepath.Join(backupDir, filepath.Base(existingPath))
if err != nil { if err := backupHyprlandConfigFile(existingPath, result.BackupPath, []byte(existingData), strings.EqualFold(filepath.Ext(existingPath), ".conf")); 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.Error = fmt.Errorf("failed to create backup: %w", err) result.Error = fmt.Errorf("failed to create backup: %w", err)
return result, result.Error return result, result.Error
} }
@@ -542,10 +562,10 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
terminalCommand = "ghostty" terminalCommand = "ghostty"
} }
newConfig := strings.ReplaceAll(HyprlandConfig, "{{TERMINAL_COMMAND}}", terminalCommand) newConfig := strings.ReplaceAll(HyprlandLuaConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
if !useSystemd { if !useSystemd {
newConfig = cd.transformHyprlandConfigForNonSystemd(newConfig, terminalCommand) newConfig = transformHyprlandLuaForNonSystemd(newConfig, terminalCommand)
} }
if existingConfig != "" { if existingConfig != "" {
@@ -563,6 +583,18 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
return result, result.Error 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 { if err := cd.deployHyprlandDmsConfigs(dmsDir, terminalCommand); err != nil {
result.Error = fmt.Errorf("failed to deploy dms configs: %w", err) result.Error = fmt.Errorf("failed to deploy dms configs: %w", err)
return result, result.Error return result, result.Error
@@ -573,29 +605,118 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
return result, nil 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 { func (cd *ConfigDeployer) deployHyprlandDmsConfigs(dmsDir string, terminalCommand string) error {
configs := []struct { configs := []struct {
name string name string
content string content string
overwrite bool
}{ }{
{"colors.conf", HyprColorsConfig}, {name: "colors.lua", content: DMSColorsLuaConfig},
{"layout.conf", HyprLayoutConfig}, {name: "layout.lua", content: DMSLayoutLuaConfig},
{"binds.conf", strings.ReplaceAll(HyprBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)}, {name: "binds.lua", content: strings.ReplaceAll(DMSBindsLuaConfig, "{{TERMINAL_COMMAND}}", terminalCommand), overwrite: true},
{"outputs.conf", ""}, {name: "binds-user.lua", content: DMSBindsUserLuaConfig},
{"cursor.conf", ""}, {name: "outputs.lua", content: DMSOutputsLuaConfig},
{"windowrules.conf", ""}, {name: "cursor.lua", content: DMSCursorLuaConfig},
{name: "windowrules.lua", content: DMSWindowRulesLuaConfig},
} }
for _, cfg := range configs { for _, cfg := range configs {
path := filepath.Join(dmsDir, cfg.name) 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 { 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)) cd.log(fmt.Sprintf("Skipping %s (already exists)", cfg.name))
continue continue
} }
if err := os.WriteFile(path, []byte(cfg.content), 0o644); err != nil { if err := os.WriteFile(path, []byte(cfg.content), 0o644); err != nil {
return fmt.Errorf("failed to write %s: %w", cfg.name, err) 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)) 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) { func (cd *ConfigDeployer) mergeHyprlandMonitorSections(newConfig, existingConfig, dmsDir string) (string, error) {
monitorRegex := regexp.MustCompile(`(?m)^#?\s*monitor\s*=.*$`) _ = newConfig
existingMonitors := monitorRegex.FindAllString(existingConfig, -1) lines := extractHyprlangMonitorLines(existingConfig)
if len(lines) == 0 {
if len(existingMonitors) == 0 {
return newConfig, nil return newConfig, nil
} }
outputsPath := filepath.Join(dmsDir, "outputs.conf") outputsPath := filepath.Join(dmsDir, "outputs.lua")
if _, err := os.Stat(outputsPath); err != nil { if info, err := os.Stat(outputsPath); err == nil && info.Size() > 0 {
var outputsContent strings.Builder cd.log("Skipping monitor migration: dms/outputs.lua already exists")
for _, monitor := range existingMonitors { return newConfig, nil
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")
}
} }
exampleMonitorRegex := regexp.MustCompile(`(?m)^# monitor = eDP-2.*$`) var b strings.Builder
mergedConfig := exampleMonitorRegex.ReplaceAllString(newConfig, "") b.WriteString("-- Migrated from existing hyprlang monitor lines\n\n")
ok := 0
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
for _, line := range lines { for _, line := range lines {
trimmed := strings.TrimSpace(line) lua, err := hyprlangMonitorLineToLua(line)
if strings.HasPrefix(trimmed, "exec-once = dbus-update-activation-environment") { if err != nil {
cd.log(fmt.Sprintf("Warning: could not migrate monitor line %q: %v", line, err))
continue continue
} }
if strings.HasPrefix(trimmed, "exec-once = systemctl --user start") { b.WriteString(lua)
startupSectionFound = true b.WriteByte('\n')
result = append(result, "exec-once = dms run") ok++
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)
} }
if ok == 0 {
if !startupSectionFound { return newConfig, nil
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
}
}
} }
b.WriteByte('\n')
return strings.Join(result, "\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 { func (cd *ConfigDeployer) transformNiriConfigForNonSystemd(config, terminalCommand string) string {
+208 -132
View File
@@ -11,6 +11,46 @@ import (
"github.com/stretchr/testify/require" "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) { func TestMergeNiriOutputSections(t *testing.T) {
cd := &ConfigDeployer{} cd := &ConfigDeployer{}
@@ -259,130 +299,56 @@ func getGhosttyPath() string {
func TestMergeHyprlandMonitorSections(t *testing.T) { func TestMergeHyprlandMonitorSections(t *testing.T) {
cd := &ConfigDeployer{} cd := &ConfigDeployer{}
tests := []struct { t.Run("no monitors in existing", func(t *testing.T) {
name string tmp := t.TempDir()
newConfig string out, err := cd.mergeHyprlandMonitorSections(`hl.config({})`, `input { kb_layout = us }`, tmp)
existingConfig string require.NoError(t, err)
wantError bool assert.Equal(t, `hl.config({})`, out)
wantContains []string _, e := os.Stat(filepath.Join(tmp, "outputs.lua"))
wantNotContains []string assert.True(t, os.IsNotExist(e))
}{ })
{
name: "no existing monitors",
newConfig: `# ==================
# MONITOR CONFIG
# ==================
# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1
# ================== t.Run("writes outputs lua from hyprlang monitors", func(t *testing.T) {
# ENVIRONMENT VARS tmp := t.TempDir()
# ================== existing := `monitor = DP-1, 1920x1080@144, 0x0, 1
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
# monitor = HDMI-A-1, 1920x1080@60, 1920x0, 1 # monitor = HDMI-A-1, 1920x1080@60, 1920x0, 1
monitor = eDP-1, 2560x1440@165, auto, 1.25`, monitor = eDP-1, 2560x1440@165, auto, 1.25`
wantError: false, out, err := cd.mergeHyprlandMonitorSections(`return`, existing, tmp)
wantContains: []string{ require.NoError(t, err)
"monitor = DP-1", assert.Equal(t, `return`, out)
"# monitor = HDMI-A-1", // Commented monitor preserved b, err := os.ReadFile(filepath.Join(tmp, "outputs.lua"))
"monitor = eDP-1", require.NoError(t, err)
"Monitors from existing configuration", s := string(b)
}, assert.Contains(t, s, "hl.monitor")
wantNotContains: []string{ assert.Contains(t, s, "DP-1")
"monitor = eDP-2", // Example monitor should be removed assert.Contains(t, s, "HDMI-A-1")
}, assert.Contains(t, s, "eDP-1")
}, assert.Contains(t, s, "preferred") // fallback rule at end
{ })
name: "preserve commented monitors",
newConfig: `# ==================
# MONITOR CONFIG
# ==================
# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1
# ==================`, t.Run("skips when outputs lua already exists", func(t *testing.T) {
existingConfig: `# monitor = DP-1, 1920x1080@144, 0x0, 1 tmp := t.TempDir()
# monitor = HDMI-A-1, 1920x1080@60, 1920x0, 1`, path := filepath.Join(tmp, "outputs.lua")
wantError: false, require.NoError(t, os.WriteFile(path, []byte("-- keep\n"), 0o644))
wantContains: []string{ _, err := cd.mergeHyprlandMonitorSections(`x`, `monitor = DP-1, 1920x1080@144, 0x0, 1`, tmp)
"# monitor = DP-1", require.NoError(t, err)
"# monitor = HDMI-A-1", b, err := os.ReadFile(path)
"Monitors from existing configuration", require.NoError(t, err)
}, assert.Equal(t, "-- keep\n", string(b))
}, })
{ }
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,
},
}
for _, tt := range tests { func TestHyprlangMonitorLineToLuaPreservesOptions(t *testing.T) {
t.Run(tt.name, func(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`)
tmpDir := t.TempDir() require.NoError(t, err)
result, err := cd.mergeHyprlandMonitorSections(tt.newConfig, tt.existingConfig, tmpDir)
if tt.wantError { assert.Contains(t, got, `output = "DP-1"`)
assert.Error(t, err) assert.Contains(t, got, `transform = 1`)
return assert.Contains(t, got, `vrr = 2`)
} assert.Contains(t, got, `bitdepth = 10`)
assert.Contains(t, got, `cm = "hdr"`)
require.NoError(t, err) assert.Contains(t, got, `sdrbrightness = 1.2`)
assert.Contains(t, got, `sdrsaturation = 0.98`)
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)
}
})
}
} }
func TestHyprlandConfigDeployment(t *testing.T) { func TestHyprlandConfigDeployment(t *testing.T) {
@@ -398,6 +364,10 @@ func TestHyprlandConfigDeployment(t *testing.T) {
cd := NewConfigDeployer(logChan) cd := NewConfigDeployer(logChan)
t.Run("deploy hyprland config to empty directory", func(t *testing.T) { 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) result, err := cd.deployHyprlandConfig(deps.TerminalGhostty, true)
require.NoError(t, err) require.NoError(t, err)
@@ -408,12 +378,16 @@ func TestHyprlandConfigDeployment(t *testing.T) {
content, err := os.ReadFile(result.Path) content, err := os.ReadFile(result.Path)
require.NoError(t, err) require.NoError(t, err)
assert.Contains(t, string(content), "# MONITOR CONFIG") assert.Contains(t, string(content), `require("dms.binds")`)
assert.Contains(t, string(content), "source = ./dms/binds.conf") assert.Contains(t, string(content), "DMS_STARTUP_BEGIN")
assert.Contains(t, string(content), "exec-once = ") assert.Contains(t, string(content), "hl.config(")
}) })
t.Run("deploy hyprland config with existing monitors", func(t *testing.T) { 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 existingContent := `# My existing Hyprland config
monitor = DP-1, 1920x1080@144, 0x0, 1 monitor = DP-1, 1920x1080@144, 0x0, 1
monitor = HDMI-A-1, 3840x2160@60, 1920x0, 1.5 monitor = HDMI-A-1, 3840x2160@60, 1920x0, 1.5
@@ -422,11 +396,17 @@ general {
gaps_in = 10 gaps_in = 10
} }
` `
hyprPath := filepath.Join(tempDir, ".config", "hypr", "hyprland.conf") hyprPath := filepath.Join(td, ".config", "hypr", "hyprland.conf")
err := os.MkdirAll(filepath.Dir(hyprPath), 0o755) err = os.MkdirAll(filepath.Dir(hyprPath), 0o755)
require.NoError(t, err) require.NoError(t, err)
err = os.WriteFile(hyprPath, []byte(existingContent), 0o644) err = os.WriteFile(hyprPath, []byte(existingContent), 0o644)
require.NoError(t, err) 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) result, err := cd.deployHyprlandConfig(deps.TerminalKitty, true)
require.NoError(t, err) require.NoError(t, err)
@@ -440,13 +420,76 @@ general {
backupContent, err := os.ReadFile(result.BackupPath) backupContent, err := os.ReadFile(result.BackupPath)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, existingContent, string(backupContent)) 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) newContent, err := os.ReadFile(result.Path)
require.NoError(t, err) require.NoError(t, err)
assert.Contains(t, string(newContent), "monitor = DP-1, 1920x1080@144") assert.Contains(t, string(newContent), `require("dms.binds")`)
assert.Contains(t, string(newContent), "monitor = HDMI-A-1, 3840x2160@60")
assert.Contains(t, string(newContent), "source = ./dms/binds.conf") outputsPath := filepath.Join(td, ".config", "hypr", "dms", "outputs.lua")
assert.NotContains(t, string(newContent), "monitor = eDP-2") 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) { func TestHyprlandConfigStructure(t *testing.T) {
assert.Contains(t, HyprlandConfig, "# MONITOR CONFIG") assert.Contains(t, HyprlandLuaConfig, `require("dms.binds")`)
assert.Contains(t, HyprlandConfig, "# STARTUP APPS") assert.Contains(t, HyprlandLuaConfig, "DMS_STARTUP_BEGIN")
assert.Contains(t, HyprlandConfig, "# INPUT CONFIG") assert.Contains(t, HyprlandLuaConfig, "hl.config(")
assert.Contains(t, HyprlandConfig, "source = ./dms/binds.conf") assert.Contains(t, HyprlandLuaConfig, "input =")
} }
func TestGhosttyConfigStructure(t *testing.T) { 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") 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" { Mod+Space hotkey-overlay-title="Application Launcher" {
spawn "dms" "ipc" "call" "spotlight" "toggle"; 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" { Mod+V hotkey-overlay-title="Clipboard Manager" {
spawn "dms" "ipc" "call" "clipboard" "toggle"; spawn "dms" "ipc" "call" "clipboard" "toggle";
} }
+20 -8
View File
@@ -2,14 +2,26 @@ package config
import _ "embed" import _ "embed"
//go:embed embedded/hyprland.conf //go:embed embedded/hyprland.lua
var HyprlandConfig string var HyprlandLuaConfig string
//go:embed embedded/hypr-colors.conf //go:embed embedded/hypr-colors.lua
var HyprColorsConfig string var DMSColorsLuaConfig string
//go:embed embedded/hypr-layout.conf //go:embed embedded/hypr-layout.lua
var HyprLayoutConfig string var DMSLayoutLuaConfig string
//go:embed embedded/hypr-binds.conf //go:embed embedded/hypr-binds.lua
var HyprBindsConfig string 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" "os"
"path/filepath" "path/filepath"
"sort" "sort"
"strconv"
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds" "github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
@@ -48,7 +49,7 @@ func (h *HyprlandProvider) GetCheatSheet() (*keybinds.CheatSheet, error) {
h.parsed = true h.parsed = true
categorizedBinds := make(map[string][]keybinds.Keybind) 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{ sheet := &keybinds.CheatSheet{
Title: "Hyprland Keybinds", Title: "Hyprland Keybinds",
@@ -88,7 +89,7 @@ func (h *HyprlandProvider) HasDMSBindsIncluded() bool {
return h.dmsBindsIncluded 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 currentSubcat := subcategory
if section.Name != "" { if section.Name != "" {
currentSubcat = section.Name currentSubcat = section.Name
@@ -96,12 +97,12 @@ func (h *HyprlandProvider) convertSection(section *HyprlandSection, subcategory
for _, kb := range section.Keybinds { for _, kb := range section.Keybinds {
category := h.categorizeByDispatcher(kb.Dispatcher) category := h.categorizeByDispatcher(kb.Dispatcher)
bind := h.convertKeybind(&kb, currentSubcat, conflicts) bind := h.convertKeybind(&kb, currentSubcat, conflicts, defaultKeys)
categorizedBinds[category] = append(categorizedBinds[category], bind) categorizedBinds[category] = append(categorizedBinds[category], bind)
} }
for _, child := range section.Children { 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) keyStr := h.formatKey(kb)
rawAction := h.formatRawAction(kb.Dispatcher, kb.Params) rawAction := h.formatRawAction(kb.Dispatcher, kb.Params)
desc := kb.Comment desc := kb.Comment
@@ -143,8 +144,15 @@ func (h *HyprlandProvider) convertKeybind(kb *HyprlandKeyBinding, subcategory st
} }
source := "config" source := "config"
if strings.Contains(kb.Source, "dms/binds.conf") { if isDMSBindsUserOverridePath(kb.Source) {
source = "dms" 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{ bind := keybinds.Keybind{
@@ -154,9 +162,10 @@ func (h *HyprlandProvider) convertKeybind(kb *HyprlandKeyBinding, subcategory st
Subcategory: subcategory, Subcategory: subcategory,
Source: source, Source: source,
Flags: kb.Flags, Flags: kb.Flags,
HasDefault: hasDefault,
} }
if source == "dms" && conflicts != nil { if (source == "dms" || source == "dms-default") && conflicts != nil {
normalizedKey := strings.ToLower(keyStr) normalizedKey := strings.ToLower(keyStr)
if conflictKb, ok := conflicts[normalizedKey]; ok { if conflictKb, ok := conflicts[normalizedKey]; ok {
bind.Conflict = &keybinds.Keybind{ bind.Conflict = &keybinds.Keybind{
@@ -188,9 +197,9 @@ func (h *HyprlandProvider) formatKey(kb *HyprlandKeyBinding) string {
func (h *HyprlandProvider) GetOverridePath() string { func (h *HyprlandProvider) GetOverridePath() string {
expanded, err := utils.ExpandPath(h.configPath) expanded, err := utils.ExpandPath(h.configPath)
if err != nil { 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 { func (h *HyprlandProvider) validateAction(action string) error {
@@ -250,7 +259,16 @@ func (h *HyprlandProvider) RemoveBind(key string) error {
if err != nil { if err != nil {
return 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) normalizedKey := strings.ToLower(key)
delete(existingBinds, normalizedKey) delete(existingBinds, normalizedKey)
return h.writeOverrideBinds(existingBinds) return h.writeOverrideBinds(existingBinds)
@@ -262,116 +280,12 @@ type hyprlandOverrideBind struct {
Description string 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 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 Options map[string]any
// Unbind: negative override (hl.unbind only, no rebind).
Unbind bool
} }
func (h *HyprlandProvider) loadOverrideBinds() (map[string]*hyprlandOverrideBind, error) { func (h *HyprlandProvider) loadOverrideBinds() (map[string]*hyprlandOverrideBind, error) {
overridePath := h.GetOverridePath() return readLuaOrHyprlangOverride(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, "+")
} }
func (h *HyprlandProvider) getBindSortPriority(action string) int { func (h *HyprlandProvider) getBindSortPriority(action string) int {
@@ -420,78 +334,203 @@ func (h *HyprlandProvider) generateBindsContent(binds map[string]*hyprlandOverri
}) })
var sb strings.Builder 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 { for _, bind := range bindList {
h.writeBindLine(&sb, bind) writeLuaBindLine(&sb, bind)
} }
return sb.String() return sb.String()
} }
func (h *HyprlandProvider) writeBindLine(sb *strings.Builder, bind *hyprlandOverrideBind) { func formatLuaBindKey(internalKey string) string {
mods, key := h.parseKeyString(bind.Key) internalKey = strings.TrimSpace(internalKey)
dispatcher, params := h.parseAction(bind.Action) parts := strings.Split(internalKey, "+")
for i := range parts {
// Write bind type with flags (e.g., "bind", "binde", "bindel") parts[i] = normalizeLuaBindKeyPart(strings.TrimSpace(parts[i]))
sb.WriteString("bind")
if bind.Flags != "" {
sb.WriteString(bind.Flags)
} }
sb.WriteString(" = ") return strings.Join(parts, " + ")
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")
} }
func (h *HyprlandProvider) parseKeyString(keyStr string) (mods, key string) { func normalizeLuaBindKeyPart(part string) string {
parts := strings.Split(keyStr, "+") switch strings.ToLower(part) {
switch len(parts) { case "super", "mod4", "mainmod":
case 0: return "SUPER"
return "", keyStr case "ctrl", "control":
case 1: return "CTRL"
return "", parts[0] 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: 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) { func luaExprToInternalAction(expr string) string {
parts := strings.SplitN(action, " ", 2) d, p := luaExprToDispatcherParams(expr)
switch len(parts) { if d == "exec" && p != "" && !strings.HasPrefix(p, "hyprctl dispatch lua:") {
case 0: return "exec " + p
return action, ""
case 1:
dispatcher = parts[0]
default:
dispatcher = parts[0]
params = parts[1]
} }
if p != "" {
// Convert internal spawn format to Hyprland's exec return d + " " + p
if dispatcher == "spawn" {
dispatcher = "exec"
} }
return d
return dispatcher, params }
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" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strconv"
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/luaconfig"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils" "github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
) )
@@ -50,6 +52,8 @@ type HyprlandParser struct {
bindOrder []string bindOrder []string
processedFiles map[string]bool processedFiles map[string]bool
dmsProcessed 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 { func NewHyprlandParser(configDir string) *HyprlandParser {
@@ -64,6 +68,8 @@ func NewHyprlandParser(configDir string) *HyprlandParser {
bindMap: make(map[string]*HyprlandKeyBinding), bindMap: make(map[string]*HyprlandKeyBinding),
bindOrder: []string{}, bindOrder: []string{},
processedFiles: make(map[string]bool), processedFiles: make(map[string]bool),
removedKeys: make(map[string]bool),
defaultDMSKeys: make(map[string]bool),
} }
} }
@@ -292,6 +298,7 @@ type HyprlandParseResult struct {
DMSBindsIncluded bool DMSBindsIncluded bool
DMSStatus *HyprlandDMSStatus DMSStatus *HyprlandDMSStatus
ConflictingConfigs map[string]*HyprlandKeyBinding ConflictingConfigs map[string]*HyprlandKeyBinding
DefaultDMSKeys map[string]bool // keys with a DMS default in binds.{lua,conf}
} }
type HyprlandDMSStatus struct { type HyprlandDMSStatus struct {
@@ -317,10 +324,10 @@ func (p *HyprlandParser) buildDMSStatus() *HyprlandDMSStatus {
switch { switch {
case !p.dmsBindsExists: case !p.dmsBindsExists:
status.Effective = false 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: case !p.dmsBindsIncluded:
status.Effective = false 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: case p.bindsAfterDMS > 0:
status.Effective = true status.Effective = true
status.OverriddenBy = p.bindsAfterDMS status.OverriddenBy = p.bindsAfterDMS
@@ -347,8 +354,11 @@ func (p *HyprlandParser) normalizeKey(key string) string {
func (p *HyprlandParser) addBind(kb *HyprlandKeyBinding) bool { func (p *HyprlandParser) addBind(kb *HyprlandKeyBinding) bool {
key := p.formatBindKey(kb) key := p.formatBindKey(kb)
normalizedKey := p.normalizeKey(key) 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 { if isDMSBind {
p.dmsBindKeys[normalizedKey] = true p.dmsBindKeys[normalizedKey] = true
} else if p.dmsBindKeys[normalizedKey] { } else if p.dmsBindKeys[normalizedKey] {
@@ -373,12 +383,21 @@ func (p *HyprlandParser) ParseWithDMS() (*HyprlandSection, error) {
return nil, err return nil, err
} }
dmsBindsPath := filepath.Join(expandedDir, "dms", "binds.conf") dmsBindsLua := filepath.Join(expandedDir, "dms", "binds.lua")
if _, err := os.Stat(dmsBindsPath); err == nil { dmsBindsConf := filepath.Join(expandedDir, "dms", "binds.conf")
dmsBindsPath := ""
if _, err := os.Stat(dmsBindsLua); err == nil {
p.dmsBindsExists = true 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, "") section, err := p.parseFileWithSource(mainConfig, "")
if err != nil { if err != nil {
return nil, err return nil, err
@@ -387,10 +406,65 @@ func (p *HyprlandParser) ParseWithDMS() (*HyprlandSection, error) {
if p.dmsBindsExists && !p.dmsProcessed { if p.dmsBindsExists && !p.dmsProcessed {
p.parseDMSBindsDirectly(dmsBindsPath, section) p.parseDMSBindsDirectly(dmsBindsPath, section)
} }
p.removeShadowedDMSBinds(section)
p.removeUnboundDMSBinds(section)
return section, nil 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) { func (p *HyprlandParser) parseFileWithSource(filePath, sectionName string) (*HyprlandSection, error) {
absPath, err := filepath.Abs(filePath) absPath, err := filepath.Abs(filePath)
if err != nil { if err != nil {
@@ -407,6 +481,10 @@ func (p *HyprlandParser) parseFileWithSource(filePath, sectionName string) (*Hyp
return nil, err return nil, err
} }
if strings.EqualFold(filepath.Ext(absPath), ".lua") {
return p.parseLuaLines(string(data), filepath.Dir(absPath), absPath, sectionName)
}
prevSource := p.currentSource prevSource := p.currentSource
p.currentSource = absPath p.currentSource = absPath
@@ -446,7 +524,7 @@ func (p *HyprlandParser) handleSource(line string, section *HyprlandSection, bas
} }
sourcePath := strings.TrimSpace(parts[1]) sourcePath := strings.TrimSpace(parts[1])
isDMSSource := sourcePath == "dms/binds.conf" || strings.HasSuffix(sourcePath, "/dms/binds.conf") isDMSSource := isDMSBindsPrimarySourcePath(sourcePath)
p.includeCount++ p.includeCount++
if isDMSSource { if isDMSSource {
@@ -474,6 +552,17 @@ func (p *HyprlandParser) handleSource(line string, section *HyprlandSection, bas
} }
func (p *HyprlandParser) parseDMSBindsDirectly(dmsBindsPath string, section *HyprlandSection) { 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) data, err := os.ReadFile(dmsBindsPath)
if err != nil { if err != nil {
return return
@@ -503,6 +592,124 @@ func (p *HyprlandParser) parseDMSBindsDirectly(dmsBindsPath string, section *Hyp
p.dmsProcessed = true 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 { func (p *HyprlandParser) parseBindLine(line string) *HyprlandKeyBinding {
parts := strings.SplitN(line, "=", 2) parts := strings.SplitN(line, "=", 2)
if len(parts) < 2 { if len(parts) < 2 {
@@ -623,5 +830,356 @@ func ParseHyprlandKeysWithDMS(path string) (*HyprlandParseResult, error) {
DMSBindsIncluded: parser.dmsBindsIncluded, DMSBindsIncluded: parser.dmsBindsIncluded,
DMSStatus: parser.buildDMSStatus(), DMSStatus: parser.buildDMSStatus(),
ConflictingConfigs: parser.conflictingConfigs, ConflictingConfigs: parser.conflictingConfigs,
DefaultDMSKeys: parser.defaultDMSKeys,
}, nil }, 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 ( import (
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
) )
func TestHyprlandAutogenerateComment(t *testing.T) { 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) { func TestHyprlandGetKeybindAtLine(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
+6 -2
View File
@@ -141,7 +141,7 @@ func (m *MangoWCProvider) convertKeybind(kb *MangoWCKeyBinding, conflicts map[st
source := "config" source := "config"
if strings.Contains(kb.Source, "dms/binds.conf") || strings.Contains(kb.Source, "dms"+string(filepath.Separator)+"binds.conf") { 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{ bind := keybinds.Keybind{
@@ -151,7 +151,7 @@ func (m *MangoWCProvider) convertKeybind(kb *MangoWCKeyBinding, conflicts map[st
Source: source, Source: source,
} }
if source == "dms" && conflicts != nil { if source == "dms-default" && conflicts != nil {
normalizedKey := strings.ToLower(keyStr) normalizedKey := strings.ToLower(keyStr)
if conflictKb, ok := conflicts[normalizedKey]; ok { if conflictKb, ok := conflicts[normalizedKey]; ok {
bind.Conflict = &keybinds.Keybind{ bind.Conflict = &keybinds.Keybind{
@@ -249,6 +249,10 @@ func (m *MangoWCProvider) RemoveBind(key string) error {
return m.writeOverrideBinds(existingBinds) return m.writeOverrideBinds(existingBinds)
} }
func (m *MangoWCProvider) ResetBind(key string) error {
return m.RemoveBind(key)
}
type mangowcOverrideBind struct { type mangowcOverrideBind struct {
Key string Key string
Action string Action string
+10 -6
View File
@@ -149,7 +149,7 @@ func (n *NiriProvider) convertKeybind(kb *NiriKeyBinding, subcategory string, co
source := "config" source := "config"
if strings.Contains(kb.Source, "dms/binds.kdl") { if strings.Contains(kb.Source, "dms/binds.kdl") {
source = "dms" source = "dms-default"
} }
bind := keybinds.Keybind{ bind := keybinds.Keybind{
@@ -165,8 +165,8 @@ func (n *NiriProvider) convertKeybind(kb *NiriKeyBinding, subcategory string, co
Repeat: kb.Repeat, Repeat: kb.Repeat,
} }
if source == "dms" && conflicts != nil { if source == "dms-default" && conflicts != nil {
if conflictKb, ok := conflicts[keyStr]; ok { if conflictKb, ok := conflicts[normalizeNiriBindKey(keyStr)]; ok {
bind.Conflict = &keybinds.Keybind{ bind.Conflict = &keybinds.Keybind{
Key: keyStr, Key: keyStr,
Description: conflictKb.Description, Description: conflictKb.Description,
@@ -249,7 +249,7 @@ func (n *NiriProvider) SetBind(key, action, description string, options map[stri
existingBinds = make(map[string]*overrideBind) existingBinds = make(map[string]*overrideBind)
} }
existingBinds[key] = &overrideBind{ existingBinds[normalizeNiriBindKey(key)] = &overrideBind{
Key: key, Key: key,
Action: action, Action: action,
Description: description, Description: description,
@@ -265,10 +265,14 @@ func (n *NiriProvider) RemoveBind(key string) error {
return nil return nil
} }
delete(existingBinds, key) delete(existingBinds, normalizeNiriBindKey(key))
return n.writeOverrideBinds(existingBinds) return n.writeOverrideBinds(existingBinds)
} }
func (n *NiriProvider) ResetBind(key string) error {
return n.RemoveBind(key)
}
type overrideBind struct { type overrideBind struct {
Key string Key string
Action string Action string
@@ -312,7 +316,7 @@ func (n *NiriProvider) loadOverrideBinds() (map[string]*overrideBind, error) {
action = n.formatRawAction(kb.Action, kb.Args) action = n.formatRawAction(kb.Action, kb.Args)
} }
binds[keyStr] = &overrideBind{ binds[normalizeNiriBindKey(keyStr)] = &overrideBind{
Key: keyStr, Key: keyStr,
Action: action, Action: action,
Description: kb.Description, 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) { func (p *NiriParser) Parse() (*NiriSection, error) {
dmsBindsPath := filepath.Join(p.configDir, "dms", "binds.kdl") dmsBindsPath := filepath.Join(p.configDir, "dms", "binds.kdl")
if _, err := os.Stat(dmsBindsPath); err == nil { if _, err := os.Stat(dmsBindsPath); err == nil {
@@ -213,24 +221,25 @@ func (p *NiriParser) finalizeBinds() []NiriKeyBinding {
func (p *NiriParser) addBind(kb *NiriKeyBinding) { func (p *NiriParser) addBind(kb *NiriKeyBinding) {
key := p.formatBindKey(kb) key := p.formatBindKey(kb)
normalizedKey := normalizeNiriBindKey(key)
isDMSBind := strings.Contains(kb.Source, "dms/binds.kdl") isDMSBind := strings.Contains(kb.Source, "dms/binds.kdl")
if isDMSBind { if isDMSBind {
p.dmsBindKeys[key] = true p.dmsBindKeys[normalizedKey] = true
p.dmsBindMap[key] = kb p.dmsBindMap[normalizedKey] = kb
} else if p.dmsBindKeys[key] { } else if p.dmsBindKeys[normalizedKey] {
p.bindsAfterDMS++ p.bindsAfterDMS++
p.conflictingConfigs[key] = kb p.conflictingConfigs[normalizedKey] = kb
p.configBindKeys[key] = true p.configBindKeys[normalizedKey] = true
return return
} else { } else {
p.configBindKeys[key] = true p.configBindKeys[normalizedKey] = true
} }
if _, exists := p.bindMap[key]; !exists { if _, exists := p.bindMap[normalizedKey]; !exists {
p.bindOrder = append(p.bindOrder, key) p.bindOrder = append(p.bindOrder, normalizedKey)
} }
p.bindMap[key] = kb p.bindMap[normalizedKey] = kb
} }
func (p *NiriParser) formatBindKey(kb *NiriKeyBinding) string { 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) { func TestNiriParseMultipleArgs(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "config.kdl") configFile := filepath.Join(tmpDir, "config.kdl")
@@ -367,7 +367,7 @@ func TestNiriEmptyArgsPreservation(t *testing.T) {
} }
for key, expected := range binds { for key, expected := range binds {
loaded, ok := loadedBinds[key] loaded, ok := loadedBinds[normalizeNiriBindKey(key)]
if !ok { if !ok {
t.Errorf("Missing bind for key %s", key) t.Errorf("Missing bind for key %s", key)
continue continue
+6
View File
@@ -13,6 +13,7 @@ type Keybind struct {
AllowInhibiting *bool `json:"allowInhibiting,omitempty"` // nil=default(true), false=explicitly disabled AllowInhibiting *bool `json:"allowInhibiting,omitempty"` // nil=default(true), false=explicitly disabled
Repeat *bool `json:"repeat,omitempty"` // nil=default(true), false=explicitly disabled Repeat *bool `json:"repeat,omitempty"` // nil=default(true), false=explicitly disabled
Conflict *Keybind `json:"conflict,omitempty"` Conflict *Keybind `json:"conflict,omitempty"`
HasDefault bool `json:"hasDefault,omitempty"` // override has a DMS default to revert to
} }
type DMSBindsStatus struct { type DMSBindsStatus struct {
@@ -42,6 +43,11 @@ type Provider interface {
type WritableProvider interface { type WritableProvider interface {
Provider Provider
SetBind(key, action, description string, options map[string]any) error 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 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 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 package network
import ( import (
"encoding/json"
"fmt" "fmt"
"net" "net"
"strings" "strings"
@@ -18,10 +19,41 @@ const (
) )
type linkInfo struct { type linkInfo struct {
ifindex int32 ifindex int32
name string name string
path dbus.ObjectPath path dbus.ObjectPath
opState string 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 { type SystemdNetworkdBackend struct {
@@ -95,17 +127,50 @@ func (b *SystemdNetworkdBackend) enumerateLinks() error {
defer b.linksMutex.Unlock() defer b.linksMutex.Unlock()
for _, l := range links { for _, l := range links {
b.links[l.Name] = &linkInfo{ if existing, ok := b.links[l.Name]; ok && existing.path == l.Path {
ifindex: l.Ifindex, existing.ifindex = l.Ifindex
name: l.Name, continue
path: l.Path,
} }
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 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 { func (b *SystemdNetworkdBackend) updateState() error {
b.linksMutex.RLock() b.linksMutex.RLock()
defer b.linksMutex.RUnlock() defer b.linksMutex.RUnlock()
@@ -113,8 +178,8 @@ func (b *SystemdNetworkdBackend) updateState() error {
var wiredIface *linkInfo var wiredIface *linkInfo
var wifiIface *linkInfo var wifiIface *linkInfo
for name, link := range b.links { for _, link := range b.links {
if b.isVirtualInterface(name) { if !link.isWired() && !link.isWireless() {
continue 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" { if wifiIface == nil || link.opState == "routable" || link.opState == "carrier" {
wifiIface = link wifiIface = link
} }
} else if !b.isVirtualInterface(name) { } else if link.isWired() {
if wiredIface == nil || link.opState == "routable" || link.opState == "carrier" { if wiredIface == nil || link.opState == "routable" || link.opState == "carrier" {
wiredIface = link wiredIface = link
} }
@@ -140,7 +205,7 @@ func (b *SystemdNetworkdBackend) updateState() error {
var wiredConns []WiredConnection var wiredConns []WiredConnection
var ethernetDevices []EthernetDevice var ethernetDevices []EthernetDevice
for name, link := range b.links { for name, link := range b.links {
if b.isVirtualInterface(name) || strings.HasPrefix(name, "wlan") || strings.HasPrefix(name, "wlp") { if !link.isWired() {
continue continue
} }
@@ -229,19 +294,6 @@ func (b *SystemdNetworkdBackend) updateState() error {
return nil 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 { func (b *SystemdNetworkdBackend) getAddresses(ifname string) []string {
iface, err := net.InterfaceByName(ifname) iface, err := net.InterfaceByName(ifname)
if err != nil { if err != nil {
@@ -12,7 +12,7 @@ func (b *SystemdNetworkdBackend) GetWiredConnections() ([]WiredConnection, error
var conns []WiredConnection var conns []WiredConnection
for name, link := range b.links { for name, link := range b.links {
if b.isVirtualInterface(name) || strings.HasPrefix(name, "wlan") || strings.HasPrefix(name, "wlp") { if !link.isWired() {
continue continue
} }
@@ -73,8 +73,8 @@ func (b *SystemdNetworkdBackend) GetWiredNetworkDetails(id string) (*WiredNetwor
func (b *SystemdNetworkdBackend) ConnectEthernet() error { func (b *SystemdNetworkdBackend) ConnectEthernet() error {
b.linksMutex.RLock() b.linksMutex.RLock()
var primaryWired *linkInfo var primaryWired *linkInfo
for name, l := range b.links { for _, l := range b.links {
if strings.HasPrefix(name, "lo") || strings.HasPrefix(name, "wlan") || strings.HasPrefix(name, "wlp") { if !l.isWired() {
continue continue
} }
primaryWired = l primaryWired = l
@@ -145,3 +145,73 @@ func TestSystemdNetworkdBackend_DisconnectEthernetDevice(t *testing.T) {
assert.Error(t, err) assert.Error(t, err)
assert.Contains(t, err.Error(), "not supported") 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, Exists: niriExists,
}) })
} else { } 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 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 hyprlandExists = true
} }
configs = append(configs, ExistingConfigInfo{ configs = append(configs, ExistingConfigInfo{
File diff suppressed because it is too large Load Diff
@@ -3,7 +3,10 @@ package providers
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules"
) )
func TestParseWindowRuleV1(t *testing.T) { func TestParseWindowRuleV1(t *testing.T) {
@@ -151,7 +154,7 @@ func TestHyprlandWritableProvider(t *testing.T) {
t.Errorf("Name() = %q, want hyprland", provider.Name()) 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 { if provider.GetOverridePath() != expectedPath {
t.Errorf("GetOverridePath() = %q, want %q", 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) { func TestBoolToInt(t *testing.T) {
if boolToInt(true) != 1 { if boolToInt(true) != 1 {
t.Error("boolToInt(true) should be 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 \ install -Dm644 $$SOURCE_DIR/LICENSE \
debian/dms-greeter/usr/share/doc/dms-greeter/LICENSE && \ debian/dms-greeter/usr/share/doc/dms-greeter/LICENSE && \
install -Dpm0644 $$SOURCE_DIR/systemd/tmpfiles-dms-greeter.conf \ 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 \ else \
echo "ERROR: No upstream source (dms-qml or Modules/Greetd/assets/dms-greeter)!" && \ echo "ERROR: No upstream source (dms-qml or Modules/Greetd/assets/dms-greeter)!" && \
echo "Contents of current directory:" && ls -la && exit 1; \ 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 -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 -Dm644 %{_builddir}/dms-qml/LICENSE %{buildroot}%{_docdir}/dms-greeter/LICENSE
install -dm755 %{buildroot}%{_sharedstatedir}/greeter install -dm755 %{buildroot}%{_sharedstatedir}/greeter
@@ -78,6 +80,7 @@ fi
%{_bindir}/dms-greeter %{_bindir}/dms-greeter
%{_datadir}/quickshell/dms-greeter/ %{_datadir}/quickshell/dms-greeter/
%{_tmpfilesdir}/%{name}.conf %{_tmpfilesdir}/%{name}.conf
%{_sysusersdir}/dms-greeter.conf
%pre %pre
# Create greeter user/group if they don't exist # 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 -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 -Dm644 %{_builddir}/dms-qml/LICENSE %{buildroot}%{_docdir}/dms-greeter/LICENSE
install -dm755 %{buildroot}%{_sharedstatedir}/greeter install -dm755 %{buildroot}%{_sharedstatedir}/greeter
@@ -78,6 +80,7 @@ fi
%dir %{_datadir}/quickshell %dir %{_datadir}/quickshell
%{_datadir}/quickshell/dms-greeter/ %{_datadir}/quickshell/dms-greeter/
%{_tmpfilesdir}/%{name}.conf %{_tmpfilesdir}/%{name}.conf
%{_sysusersdir}/dms-greeter.conf
%pre %pre
# Create greeter user/group if they don't exist # 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 \ install -Dm644 DankMaterialShell-$(BASE_VERSION)/LICENSE \
debian/dms-greeter/usr/share/doc/dms-greeter/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) # Create cache directory structure (will be created by postinst)
mkdir -p debian/dms-greeter/var/cache/dms-greeter 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 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` ## Target: `inhibit`
Idle inhibitor control to prevent automatic sleep/lock. 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 = [ const DMS_ACTIONS = [
{ id: "spawn dms ipc call spotlight toggle", label: "App Launcher: Toggle" }, { id: "spawn dms ipc call spotlight toggle", label: "Default Launcher: Toggle" },
{ id: "spawn dms ipc call spotlight open", label: "App Launcher: Open" }, { id: "spawn dms ipc call spotlight open", label: "Default Launcher: Open" },
{ id: "spawn dms ipc call spotlight close", label: "App Launcher: Close" }, { 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 toggle", label: "Clipboard: Toggle" },
{ id: "spawn dms ipc call clipboard open", label: "Clipboard: Open" }, { id: "spawn dms ipc call clipboard open", label: "Clipboard: Open" },
{ id: "spawn dms ipc call clipboard close", label: "Clipboard: Close" }, { 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 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 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 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 audio cycleoutput", label: "Audio Output: Cycle" },
{ id: "spawn dms ipc call brightness increment 5 \"\"", label: "Brightness Up" }, { id: "spawn dms ipc call brightness increment 5 \"\"", label: "Brightness Up" },
{ id: "spawn dms ipc call brightness increment 1 \"\"", label: "Brightness Up (1%)" }, { id: "spawn dms ipc call brightness increment 1 \"\"", label: "Brightness Up (1%)" },
+11 -3
View File
@@ -9,9 +9,11 @@ Singleton {
property var currentOSDsByScreen: ({}) property var currentOSDsByScreen: ({})
Connections { Timer {
target: Quickshell id: screensChangedDelayTimer
function onScreensChanged() { interval: 3000 // 3 seconds
repeat: false
onTriggered: {
const activeNames = {}; const activeNames = {};
for (let i = 0; i < Quickshell.screens.length; i++) for (let i = 0; i < Quickshell.screens.length; i++)
activeNames[Quickshell.screens[i].name] = true; activeNames[Quickshell.screens[i].name] = true;
@@ -22,6 +24,12 @@ Singleton {
} }
} }
} }
Connections {
target: Quickshell
function onScreensChanged() {
screensChangedDelayTimer.restart();
}
}
function showOSD(osd) { function showOSD(osd) {
if (!osd || !osd.screen) if (!osd || !osd.screen)
+12
View File
@@ -187,6 +187,7 @@ Singleton {
property string timeLocale: "" property string timeLocale: ""
property string launcherLastMode: "all" property string launcherLastMode: "all"
property string launcherLastFileSearchType: "all"
property string launcherLastQuery: "" property string launcherLastQuery: ""
property var launcherQueryHistory: [] property var launcherQueryHistory: []
property string appDrawerLastMode: "apps" property string appDrawerLastMode: "apps"
@@ -1178,6 +1179,17 @@ Singleton {
saveSettings(); saveSettings();
} }
function getLauncherRestoreMode() {
if (!SettingsData.rememberLastMode)
return "all";
return launcherLastMode || "all";
}
function setLauncherLastFileSearchType(type) {
launcherLastFileSearchType = type;
saveSettings();
}
function setLauncherLastQuery(query) { function setLauncherLastQuery(query) {
launcherLastQuery = query; launcherLastQuery = query;
saveSettings(); saveSettings();
+8 -3
View File
@@ -258,8 +258,6 @@ Singleton {
onFrameLauncherEmergeSideChanged: saveSettings() onFrameLauncherEmergeSideChanged: saveSettings()
property bool frameLauncherArcExtender: false property bool frameLauncherArcExtender: false
onFrameLauncherArcExtenderChanged: saveSettings() onFrameLauncherArcExtenderChanged: saveSettings()
property bool frameUseSpotlightLauncher: false
onFrameUseSpotlightLauncherChanged: saveSettings()
readonly property string frameModalEmergeSide: frameLauncherEmergeSide === "top" ? "bottom" : "top" readonly property string frameModalEmergeSide: frameLauncherEmergeSide === "top" ? "bottom" : "top"
property string frameMode: "connected" property string frameMode: "connected"
onFrameModeChanged: saveSettings() onFrameModeChanged: saveSettings()
@@ -394,6 +392,7 @@ Singleton {
property string audioScrollMode: "volume" property string audioScrollMode: "volume"
property int audioWheelScrollAmount: 5 property int audioWheelScrollAmount: 5
property bool clockCompactMode: false property bool clockCompactMode: false
property int focusedWindowSize: 1
property bool focusedWindowCompactMode: false property bool focusedWindowCompactMode: false
property bool runningAppsCompactMode: true property bool runningAppsCompactMode: true
property int barMaxVisibleApps: 0 property int barMaxVisibleApps: 0
@@ -436,6 +435,7 @@ Singleton {
property int appLauncherGridColumns: 4 property int appLauncherGridColumns: 4
property bool spotlightCloseNiriOverview: true property bool spotlightCloseNiriOverview: true
property bool rememberLastQuery: false property bool rememberLastQuery: false
property bool rememberLastMode: true
property var spotlightSectionViewModes: ({}) property var spotlightSectionViewModes: ({})
onSpotlightSectionViewModesChanged: saveSettings() onSpotlightSectionViewModesChanged: saveSettings()
property var appDrawerSectionViewModes: ({}) property var appDrawerSectionViewModes: ({})
@@ -449,7 +449,9 @@ Singleton {
property bool dankLauncherV2UnloadOnClose: false property bool dankLauncherV2UnloadOnClose: false
property bool dankLauncherV2IncludeFilesInAll: false property bool dankLauncherV2IncludeFilesInAll: false
property bool dankLauncherV2IncludeFoldersInAll: false property bool dankLauncherV2IncludeFoldersInAll: false
property bool launcherUseOverlayLayer: false
property string launcherStyle: "full" property string launcherStyle: "full"
property bool spotlightBarShowModeChips: false
property string _legacyWeatherLocation: "New York, NY" property string _legacyWeatherLocation: "New York, NY"
property string _legacyWeatherCoordinates: "40.7128,-74.0060" property string _legacyWeatherCoordinates: "40.7128,-74.0060"
@@ -606,7 +608,7 @@ Singleton {
property bool showDock: false property bool showDock: false
property bool dockAutoHide: false property bool dockAutoHide: false
property bool dockSmartAutoHide: false property bool dockSmartAutoHide: false
property bool dockHideOnFullscreen: true property bool dockUseOverlayLayer: false
property bool dockGroupByApp: false property bool dockGroupByApp: false
property bool dockRestoreSpecialWorkspaceOnClick: false property bool dockRestoreSpecialWorkspaceOnClick: false
property bool dockOpenOnOverview: false property bool dockOpenOnOverview: false
@@ -686,6 +688,7 @@ Singleton {
property int notificationTimeoutNormal: 5000 property int notificationTimeoutNormal: 5000
property int notificationTimeoutCritical: 0 property int notificationTimeoutCritical: 0
property bool notificationCompactMode: false property bool notificationCompactMode: false
property bool notificationDedupeEnabled: true
property int notificationPopupPosition: SettingsData.Position.Top property int notificationPopupPosition: SettingsData.Position.Top
property int notificationAnimationSpeed: SettingsData.AnimationSpeed.Short property int notificationAnimationSpeed: SettingsData.AnimationSpeed.Short
property int notificationCustomAnimationDuration: 400 property int notificationCustomAnimationDuration: 400
@@ -706,6 +709,7 @@ Singleton {
property bool osdBrightnessEnabled: true property bool osdBrightnessEnabled: true
property bool osdIdleInhibitorEnabled: true property bool osdIdleInhibitorEnabled: true
property bool osdMicMuteEnabled: true property bool osdMicMuteEnabled: true
property bool osdMicVolumeEnabled: true
property bool osdCapsLockEnabled: true property bool osdCapsLockEnabled: true
property bool osdPowerProfileEnabled: true property bool osdPowerProfileEnabled: true
property bool osdAudioOutputEnabled: true property bool osdAudioOutputEnabled: true
@@ -787,6 +791,7 @@ Singleton {
"popupGapsAuto": true, "popupGapsAuto": true,
"popupGapsManual": 4, "popupGapsManual": 4,
"maximizeDetection": true, "maximizeDetection": true,
"useOverlayLayer": false,
"scrollEnabled": true, "scrollEnabled": true,
"scrollXBehavior": "column", "scrollXBehavior": "column",
"scrollYBehavior": "workspace", "scrollYBehavior": "workspace",
@@ -87,6 +87,7 @@ var SPEC = {
timeLocale: { def: "" }, timeLocale: { def: "" },
launcherLastMode: { def: "all" }, launcherLastMode: { def: "all" },
launcherLastFileSearchType: { def: "all" },
launcherLastQuery: { def: "" }, launcherLastQuery: { def: "" },
launcherQueryHistory: { def: [] }, launcherQueryHistory: { def: [] },
appDrawerLastMode: { def: "apps" }, appDrawerLastMode: { def: "apps" },
+7 -3
View File
@@ -153,6 +153,7 @@ var SPEC = {
audioWheelScrollAmount: { def: 5 }, audioWheelScrollAmount: { def: 5 },
clockCompactMode: { def: false }, clockCompactMode: { def: false },
focusedWindowCompactMode: { def: false }, focusedWindowCompactMode: { def: false },
focusedWindowSize: { def: 1 },
runningAppsCompactMode: { def: true }, runningAppsCompactMode: { def: true },
barMaxVisibleApps: { def: 0 }, barMaxVisibleApps: { def: 0 },
barMaxVisibleRunningApps: { def: 0 }, barMaxVisibleRunningApps: { def: 0 },
@@ -202,6 +203,7 @@ var SPEC = {
appLauncherGridColumns: { def: 4 }, appLauncherGridColumns: { def: 4 },
spotlightCloseNiriOverview: { def: true }, spotlightCloseNiriOverview: { def: true },
rememberLastQuery: { def: false }, rememberLastQuery: { def: false },
rememberLastMode: { def: true },
spotlightSectionViewModes: { def: {} }, spotlightSectionViewModes: { def: {} },
appDrawerSectionViewModes: { def: {} }, appDrawerSectionViewModes: { def: {} },
niriOverviewOverlayEnabled: { def: true }, niriOverviewOverlayEnabled: { def: true },
@@ -213,7 +215,9 @@ var SPEC = {
dankLauncherV2UnloadOnClose: { def: false }, dankLauncherV2UnloadOnClose: { def: false },
dankLauncherV2IncludeFilesInAll: { def: false }, dankLauncherV2IncludeFilesInAll: { def: false },
dankLauncherV2IncludeFoldersInAll: { def: false }, dankLauncherV2IncludeFoldersInAll: { def: false },
launcherUseOverlayLayer: { def: false },
launcherStyle: { def: "full" }, launcherStyle: { def: "full" },
spotlightBarShowModeChips: { def: false },
useAutoLocation: { def: false }, useAutoLocation: { def: false },
weatherEnabled: { def: true }, weatherEnabled: { def: true },
@@ -332,7 +336,7 @@ var SPEC = {
showDock: { def: false }, showDock: { def: false },
dockAutoHide: { def: false }, dockAutoHide: { def: false },
dockSmartAutoHide: { def: false }, dockSmartAutoHide: { def: false },
dockHideOnFullscreen: { def: true }, dockUseOverlayLayer: { def: false },
dockGroupByApp: { def: false }, dockGroupByApp: { def: false },
dockRestoreSpecialWorkspaceOnClick: { def: false }, dockRestoreSpecialWorkspaceOnClick: { def: false },
dockOpenOnOverview: { def: false }, dockOpenOnOverview: { def: false },
@@ -395,6 +399,7 @@ var SPEC = {
notificationTimeoutNormal: { def: 5000 }, notificationTimeoutNormal: { def: 5000 },
notificationTimeoutCritical: { def: 0 }, notificationTimeoutCritical: { def: 0 },
notificationCompactMode: { def: false }, notificationCompactMode: { def: false },
notificationDedupeEnabled: { def: true },
notificationPopupPosition: { def: 0 }, notificationPopupPosition: { def: 0 },
notificationAnimationSpeed: { def: 1 }, notificationAnimationSpeed: { def: 1 },
notificationCustomAnimationDuration: { def: 400 }, notificationCustomAnimationDuration: { def: 400 },
@@ -496,7 +501,7 @@ var SPEC = {
popupGapsAuto: true, popupGapsAuto: true,
popupGapsManual: 4, popupGapsManual: 4,
maximizeDetection: true, maximizeDetection: true,
fullscreenDetection: true, useOverlayLayer: false,
scrollEnabled: true, scrollEnabled: true,
scrollXBehavior: "column", scrollXBehavior: "column",
scrollYBehavior: "workspace", scrollYBehavior: "workspace",
@@ -573,7 +578,6 @@ var SPEC = {
frameCloseGaps: { def: true }, frameCloseGaps: { def: true },
frameLauncherEmergeSide: { def: "bottom" }, frameLauncherEmergeSide: { def: "bottom" },
frameLauncherArcExtender: { def: false }, frameLauncherArcExtender: { def: false },
frameUseSpotlightLauncher: { def: false },
frameMode: { def: "connected" } frameMode: { def: "connected" }
}; };
+150 -4
View File
@@ -30,6 +30,7 @@ import qs.Services
Item { Item {
id: root id: root
readonly property var log: Log.scoped("DMSShell") readonly property var log: Log.scoped("DMSShell")
readonly property var _sessionsServiceRef: SessionsService
property bool osdSurfacesLoaded: true property bool osdSurfacesLoaded: true
property int pendingOsdResumeReloads: 0 property int pendingOsdResumeReloads: 0
@@ -63,15 +64,27 @@ Item {
} }
} }
property bool wallpaperSurfacesLoaded: true
Loader { Loader {
id: blurredWallpaperBackgroundLoader id: blurredWallpaperBackgroundLoader
active: SettingsData.blurredWallpaperLayer && CompositorService.isNiri active: root.wallpaperSurfacesLoaded && SettingsData.blurredWallpaperLayer && CompositorService.isNiri
asynchronous: false asynchronous: false
sourceComponent: BlurredWallpaperBackground {} sourceComponent: BlurredWallpaperBackground {}
} }
WallpaperBackground {} DeferredAction {
id: wallpaperSurfaceReloadAction
onTriggered: root.wallpaperSurfacesLoaded = true
}
Loader {
id: wallpaperBackgroundLoader
active: root.wallpaperSurfacesLoaded
asynchronous: false
sourceComponent: WallpaperBackground {}
}
DesktopWidgetLayer {} DesktopWidgetLayer {}
@@ -168,6 +181,8 @@ Item {
property bool barSurfacesLoaded: true property bool barSurfacesLoaded: true
function recreateBarSurfaces() { function recreateBarSurfaces() {
log.info("Recreating bar surfaces, screens:", Quickshell.screens.length,
Quickshell.screens.map(s => s.name).join(","));
if (barSurfacesLoaded) if (barSurfacesLoaded)
barSurfacesLoaded = false; barSurfacesLoaded = false;
barSurfaceReloadAction.schedule(); 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 { Repeater {
id: dankBarRepeater id: dankBarRepeater
@@ -301,6 +327,81 @@ Item {
onTriggered: root.osdSurfacesLoaded = true 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: { Component.onCompleted: {
dockRecreateDebounce.start(); dockRecreateDebounce.start();
// Force PolkitService singleton to initialize // 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 { LazyLoader {
id: clipboardHistoryPopoutLoader id: clipboardHistoryPopoutLoader
@@ -868,9 +988,17 @@ Item {
target: SessionService target: SessionService
function onSessionResumed() { 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; root.pendingOsdResumeReloads = 2;
osdResumeRecreateTimer.interval = 400; osdResumeRecreateTimer.interval = 400;
osdResumeRecreateTimer.restart(); osdResumeRecreateTimer.restart();
root.triggerSurfaceRecovery("sessionResumed");
} }
} }
@@ -1019,12 +1147,30 @@ Item {
lock.activate(); lock.activate();
} }
onSwitchUserRequested: {
switchUserModalLoader.active = true;
Qt.callLater(() => {
if (switchUserModalLoader.item)
switchUserModalLoader.item.showFromPowerMenu();
});
}
Component.onCompleted: { Component.onCompleted: {
PopoutService.powerMenuModal = powerMenuModal; PopoutService.powerMenuModal = powerMenuModal;
} }
} }
} }
LazyLoader {
id: switchUserModalLoader
active: false
SwitchUserModal {
id: switchUserModal
}
}
LazyLoader { LazyLoader {
id: hyprKeybindsModalLoader id: hyprKeybindsModalLoader
@@ -1095,7 +1241,7 @@ Item {
Variants { Variants {
model: SettingsData.getFilteredScreens("osd") model: SettingsData.getFilteredScreens("osd")
delegate: MicMuteOSD { delegate: MicVolumeOSD {
modelData: item modelData: item
} }
} }
+49
View File
@@ -1340,6 +1340,25 @@ Item {
target: "spotlight" 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 { IpcHandler {
function info(message: string): string { function info(message: string): string {
if (!message) if (!message)
@@ -1775,6 +1794,36 @@ Item {
target: "outputs" 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 { IpcHandler {
function findTrayItem(itemId: string): var { function findTrayItem(itemId: string): var {
if (!itemId) if (!itemId)
@@ -145,6 +145,7 @@ Item {
onDeleteRequested: clipboardContent.modal.deleteEntry(modelData) onDeleteRequested: clipboardContent.modal.deleteEntry(modelData)
onPinRequested: clipboardContent.modal.pinEntry(modelData) onPinRequested: clipboardContent.modal.pinEntry(modelData)
onUnpinRequested: clipboardContent.modal.unpinEntry(modelData) onUnpinRequested: clipboardContent.modal.unpinEntry(modelData)
onEditRequested: clipboardContent.modal.editEntry(modelData)
} }
} }
@@ -204,6 +205,7 @@ Item {
onDeleteRequested: clipboardContent.modal.deletePinnedEntry(modelData) onDeleteRequested: clipboardContent.modal.deletePinnedEntry(modelData)
onPinRequested: clipboardContent.modal.pinEntry(modelData) onPinRequested: clipboardContent.modal.pinEntry(modelData)
onUnpinRequested: clipboardContent.modal.unpinEntry(modelData) onUnpinRequested: clipboardContent.modal.unpinEntry(modelData)
onEditRequested: clipboardContent.modal.editEntry(modelData)
} }
} }
@@ -0,0 +1,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 deleteRequested
signal pinRequested signal pinRequested
signal unpinRequested signal unpinRequested
signal editRequested
readonly property string entryType: modal ? modal.getEntryType(entry) : "text" readonly property string entryType: modal ? modal.getEntryType(entry) : "text"
readonly property string entryPreview: modal ? modal.getEntryPreview(entry) : "" readonly property string entryPreview: modal ? modal.getEntryPreview(entry) : ""
@@ -70,6 +71,20 @@ Rectangle {
onClicked: entry.pinned ? unpinRequested() : pinRequested() onClicked: entry.pinned ? unpinRequested() : pinRequested()
} }
DankActionButton {
iconName: "edit"
iconSize: Theme.iconSize - 6
iconColor: Theme.surfaceText
onClicked: {
if (entryType === "image") {
// TODO - forward to editing software
} else {
editRequested();
}
}
}
DankActionButton { DankActionButton {
iconName: "close" iconName: "close"
iconSize: Theme.iconSize - 6 iconSize: Theme.iconSize - 6
@@ -142,8 +157,11 @@ Rectangle {
MouseArea { MouseArea {
id: mouseArea id: mouseArea
anchors.fill: parent anchors.left: parent.left
anchors.rightMargin: 80 anchors.right: actionButtons.left
anchors.rightMargin: Theme.spacingS
anchors.top: parent.top
anchors.bottom: parent.bottom
hoverEnabled: true hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onPressed: mouse => { onPressed: mouse => {
@@ -43,6 +43,18 @@ DankModal {
service: ClipboardService service: ClipboardService
} }
property string mode: "history"
onModeChanged: {
if (mode !== "history") {
return;
}
Qt.callLater(function () {
if (contentLoader.item?.searchField) {
contentLoader.item.searchField.forceActiveFocus();
}
});
}
function updateFilteredModel() { function updateFilteredModel() {
ClipboardService.updateFilteredModel(); ClipboardService.updateFilteredModel();
} }
@@ -61,6 +73,7 @@ DankModal {
function show() { function show() {
open(); open();
mode = "history";
activeImageLoads = 0; activeImageLoads = 0;
shouldHaveFocus = true; shouldHaveFocus = true;
ClipboardService.reset(); ClipboardService.reset();
@@ -130,6 +143,21 @@ DankModal {
return ClipboardService.getEntryType(entry); 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 visible: false
modalWidth: ClipboardConstants.modalWidth modalWidth: ClipboardConstants.modalWidth
modalHeight: ClipboardConstants.modalHeight modalHeight: ClipboardConstants.modalHeight
@@ -138,6 +166,7 @@ DankModal {
borderColor: Theme.outlineMedium borderColor: Theme.outlineMedium
borderWidth: 1 borderWidth: 1
enableShadow: true enableShadow: true
closeOnEscapeKey: mode !== "editor"
onBackgroundClicked: hide() onBackgroundClicked: hide()
modalFocusScope.Keys.onPressed: function (event) { modalFocusScope.Keys.onPressed: function (event) {
keyboardController.handleKey(event); keyboardController.handleKey(event);
@@ -174,9 +203,109 @@ DankModal {
property var confirmDialog: clearConfirmDialog property var confirmDialog: clearConfirmDialog
clipboardContent: Component { clipboardContent: Component {
ClipboardContent { Item {
modal: clipboardHistoryModal id: viewContainer
clearConfirmDialog: clipboardHistoryModal.confirmDialog
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) { function handleKey(event) {
if (modal.mode === "editor") {
if (event.key === Qt.Key_Escape) {
modal.mode = "history";
event.accepted = true;
}
return;
}
switch (event.key) { switch (event.key) {
case Qt.Key_Escape: case Qt.Key_Escape:
if (ClipboardService.keyboardNavigationActive) { if (ClipboardService.keyboardNavigationActive) {
@@ -152,6 +169,10 @@ QtObject {
event.accepted = true; event.accepted = true;
} }
return; return;
case Qt.Key_E:
editSelected();
event.accepted = true;
return;
} }
} }
@@ -10,7 +10,7 @@ Rectangle {
readonly property string hintsText: { readonly property string hintsText: {
if (!wtypeAvailable) if (!wtypeAvailable)
return I18n.tr("Ctrl+Tab: Switch Tab • Ctrl+S: Pin/Unpin • Shift+Del: Clear All • Esc: Close"); return I18n.tr("Ctrl+Tab: Switch Tab • Ctrl+S: Pin/Unpin • Shift+Del: Clear All • Esc: Close");
return enterToPaste ? I18n.tr("Ctrl+Tab: Switch Tab • Ctrl+S: Pin/Unpin • Shift+Enter: Copy • Shift+Del: Clear All • Esc: Close", "Keyboard hints when enter-to-paste is enabled") : I18n.tr("Ctrl+Tab: Switch Tab • Ctrl+S: Pin/Unpin • Shift+Enter: Paste • Shift+Del: Clear All • Esc: Close"); return enterToPaste ? I18n.tr("Ctrl+Tab: Switch Tabs • Ctrl+S: Pin/Unpin • Shift+Enter: Copy • Shift+Del: Clear All • F10: Help • Esc: Close", "Keyboard hints when enter-to-paste is enabled") : I18n.tr("Ctrl+Tab: Switch Tabs • Ctrl+S: Pin/Unpin • Shift+Enter: Paste • Shift+Del: Clear All • F10: Help • Esc: Close");
} }
height: ClipboardConstants.keyboardHintsHeight height: ClipboardConstants.keyboardHintsHeight
@@ -22,13 +22,17 @@ Rectangle {
z: 100 z: 100
Column { Column {
width: parent.width - Theme.spacingL * 2
anchors.centerIn: parent anchors.centerIn: parent
spacing: 2 spacing: 2
StyledText { StyledText {
text: keyboardHints.enterToPaste ? I18n.tr("↑/↓: Navigate • Enter: Paste • Del: Delete • F10: Help", "Keyboard hints when enter-to-paste is enabled") : I18n.tr("↑/↓: Navigate • Enter/Ctrl+C: Copy • Del: Delete • F10: Help") text: keyboardHints.enterToPaste ? I18n.tr("↑/↓: Navigate • Enter: Paste • Ctrl+C: Copy • Del: Delete • Ctrl+E: Edit • Ctrl+S: Pin/Unpin • F10: Help", "Keyboard hints when enter-to-paste is enabled") : I18n.tr("↑/↓: Navigate • Enter/Ctrl+C: Copy • Del: Delete • Ctrl+E: Edit • Ctrl+S: Pin/Unpin • F10: Help")
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText color: Theme.surfaceText
width: parent.width
wrapMode: Text.WordWrap
horizontalAlignment: Text.AlignHCenter
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
} }
@@ -36,6 +40,9 @@ Rectangle {
text: keyboardHints.hintsText text: keyboardHints.hintsText
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText color: Theme.surfaceText
width: parent.width
wrapMode: Text.WordWrap
horizontalAlignment: Text.AlignHCenter
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
} }
} }
@@ -38,7 +38,7 @@ Item {
readonly property string resolvedConnectedBarSide: frameConnectedMode ? preferredConnectedBarSide : "" 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) { function _dockOccupiesSide(side) {
if (!SettingsData.showDock) if (!SettingsData.showDock)
@@ -58,7 +58,7 @@ Item {
readonly property bool _dockBlocksEmergence: frameOwnsConnectedChrome && _dockOccupiesSide(resolvedConnectedBarSide) 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 int animationDuration: connectedMotionParity ? Theme.popoutAnimationDuration : Theme.modalAnimationDuration
property real animationScaleCollapsed: Theme.effectScaleCollapsed property real animationScaleCollapsed: Theme.effectScaleCollapsed
property real animationOffset: Theme.effectAnimOffset property real animationOffset: Theme.effectAnimOffset
@@ -68,7 +68,7 @@ Item {
property color borderColor: Theme.outlineMedium property color borderColor: Theme.outlineMedium
property real borderWidth: 0 property real borderWidth: 0
property real cornerRadius: Theme.cornerRadius 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 effectiveBackgroundColor: connectedSurfaceOverride ? Theme.connectedSurfaceColor : backgroundColor
readonly property color effectiveBorderColor: connectedSurfaceOverride ? "transparent" : borderColor readonly property color effectiveBorderColor: connectedSurfaceOverride ? "transparent" : borderColor
readonly property real effectiveBorderWidth: connectedSurfaceOverride ? 0 : borderWidth readonly property real effectiveBorderWidth: connectedSurfaceOverride ? 0 : borderWidth
@@ -346,7 +346,7 @@ Item {
readonly property real shadowFallbackOffset: 6 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 shadowRenderPadding: (!frameOwnsConnectedChrome && root.enableShadow && Theme.elevationEnabled && SettingsData.modalElevationEnabled) ? Theme.elevationRenderPadding(shadowLevel, Theme.elevationLightDirection, shadowFallbackOffset, 8, 16) : 0
readonly property real shadowMotionPadding: { readonly property real shadowMotionPadding: {
if (Theme.isConnectedEffect) if (frameOwnsConnectedChrome)
return 0; return 0;
if (animationType === "slide") if (animationType === "slide")
return 30; return 30;
@@ -10,10 +10,11 @@ Rectangle {
property var entry: null property var entry: null
property string cachedImageData: "" property string cachedImageData: ""
property string cachedMimeType: ""
property var _requestedEntryId: null property var _requestedEntryId: null
readonly property bool canLoadImage: !!entry?.isImage && (entry?.mimeType ?? "").startsWith("image/") 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) radius: Math.max(6, Theme.cornerRadius - 2)
clip: true clip: true
@@ -24,8 +25,24 @@ Rectangle {
onEntryChanged: reloadPreview() onEntryChanged: reloadPreview()
Component.onCompleted: 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() { function reloadPreview() {
cachedImageData = ""; cachedImageData = "";
cachedMimeType = "";
if (!canLoadImage || !entry?.id) { if (!canLoadImage || !entry?.id) {
_requestedEntryId = null; _requestedEntryId = null;
return; return;
@@ -40,9 +57,13 @@ Rectangle {
return; return;
if (response.error) if (response.error)
return; return;
const data = response.result?.data ?? ""; const result = response.result ?? {};
if (data.length > 0) const mimeType = (result.mimeType ?? entry?.mimeType ?? "").toString();
cachedImageData = data; 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 gridColumns: SettingsData.appLauncherGridColumns
property int viewModeVersion: 0 property int viewModeVersion: 0
property string viewModeContext: "spotlight" property string viewModeContext: "spotlight"
property bool forceLinearNavigation: false
signal itemExecuted signal itemExecuted
signal searchCompleted signal searchCompleted
signal modeChanged(string mode) signal modeChanged(string mode, bool userInitiated)
signal queryChanged(string query) signal queryChanged(string query)
signal viewModeChanged(string sectionId, string mode) signal viewModeChanged(string sectionId, string mode)
signal searchQueryRequested(string query) signal searchQueryRequested(string query)
Ref {
service: AppSearchService
}
onActiveChanged: { onActiveChanged: {
if (active) { if (!active) {
if (clipboardSearchEnabledInAll())
ClipboardService.ensureLauncherHistory();
} else {
SessionData.addLauncherHistory(searchQuery); SessionData.addLauncherHistory(searchQuery);
sections = []; sections = [];
flatModel = []; flatModel = [];
selectedItem = null; selectedItem = null;
_clearModeCache(); _clearModeCache();
ClipboardService.invalidateLauncherSearchCache();
} }
} }
@@ -88,11 +91,25 @@ Item {
Connections { Connections {
target: ClipboardService target: ClipboardService
function onInternalEntriesChanged() { function onLauncherSearchReady(query) {
if (!active || !clipboardSearchEnabledInAll()) if (!active)
return; 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; searchQuery = query;
searchDebounce.restart(); searchDebounce.restart();
if (searchMode === "all" && clipboardSearchEnabledInAll() && query.length >= 2) if (searchMode !== "plugins" && query.startsWith("/")) {
ClipboardService.ensureLauncherHistory(); 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); var filesInAll = searchMode === "all" && (SettingsData.dankLauncherV2IncludeFilesInAll || SettingsData.dankLauncherV2IncludeFoldersInAll);
if (searchMode !== "plugins" && (searchMode === "files" || query.startsWith("/") || filesInAll) && query.length > 0) { if (searchMode !== "plugins" && (searchMode === "files" || query.startsWith("/") || filesInAll) && query.length > 0) {
@@ -412,9 +440,14 @@ Item {
} }
} }
function setMode(mode, isAutoSwitch) { function setMode(mode, isAutoSwitch, fileTypeOverride, notPersist) {
if (searchMode === mode) if (searchMode === mode) {
if (mode === "files" && fileTypeOverride !== undefined && fileSearchType !== fileTypeOverride) {
fileSearchType = fileTypeOverride;
performFileSearch();
}
return; return;
}
if (isAutoSwitch) { if (isAutoSwitch) {
previousSearchMode = searchMode; previousSearchMode = searchMode;
autoSwitchedToFiles = true; autoSwitchedToFiles = true;
@@ -422,10 +455,11 @@ Item {
autoSwitchedToFiles = false; autoSwitchedToFiles = false;
} }
searchMode = mode; searchMode = mode;
modeChanged(mode); if (mode === "files") {
fileSearchType = fileTypeOverride !== undefined ? fileTypeOverride : (SessionData.launcherLastFileSearchType || "all");
}
modeChanged(mode, !isAutoSwitch && notPersist !== true);
performSearch(); performSearch();
if (mode === "all" && clipboardSearchEnabledInAll() && searchQuery.length >= 2)
ClipboardService.ensureLauncherHistory();
var filesInAll = mode === "all" && (SettingsData.dankLauncherV2IncludeFilesInAll || SettingsData.dankLauncherV2IncludeFoldersInAll) && searchQuery.length > 0; var filesInAll = mode === "all" && (SettingsData.dankLauncherV2IncludeFilesInAll || SettingsData.dankLauncherV2IncludeFoldersInAll) && searchQuery.length > 0;
if (mode === "files" || filesInAll) { if (mode === "files" || filesInAll) {
fileSearchDebounce.restart(); fileSearchDebounce.restart();
@@ -437,7 +471,7 @@ Item {
return; return;
autoSwitchedToFiles = false; autoSwitchedToFiles = false;
searchMode = previousSearchMode; searchMode = previousSearchMode;
modeChanged(previousSearchMode); modeChanged(previousSearchMode, false);
performSearch(); performSearch();
} }
@@ -533,6 +567,7 @@ Item {
if (fileSearchType === type) if (fileSearchType === type)
return; return;
fileSearchType = type; fileSearchType = type;
SessionData.setLauncherLastFileSearchType(type);
performFileSearch(); performFileSearch();
} }
@@ -703,7 +738,8 @@ Item {
clearActivePluginViewPreference(); clearActivePluginViewPreference();
if (searchMode === "files") { 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; isFileSearching = fileQuery.length >= 2 && DSearchService.dsearchAvailable;
sections = []; sections = [];
flatModel = []; flatModel = [];
@@ -993,7 +1029,8 @@ Item {
var includeFolders = SettingsData.dankLauncherV2IncludeFoldersInAll; var includeFolders = SettingsData.dankLauncherV2IncludeFoldersInAll;
if (searchQuery.startsWith("/")) { 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") { } else if (searchMode === "files") {
fileQuery = searchQuery.trim(); fileQuery = searchQuery.trim();
} else if (searchMode === "all" && (includeFiles || includeFolders)) { } else if (searchMode === "all" && (includeFiles || includeFolders)) {
@@ -1209,7 +1246,6 @@ Item {
} }
if (clipboardSearchEnabledInAll()) { if (clipboardSearchEnabledInAll()) {
ClipboardService.ensureLauncherHistory();
var clipboardItems = AppSearchService.getBuiltInLauncherItems("dms_clipboard_search", query); var clipboardItems = AppSearchService.getBuiltInLauncherItems("dms_clipboard_search", query);
var clipboardLimit = Math.min(clipboardItems.length, 8); var clipboardLimit = Math.min(clipboardItems.length, 8);
for (var j = 0; j < clipboardLimit; j++) { for (var j = 0; j < clipboardLimit; j++) {
@@ -1713,7 +1749,9 @@ Item {
function selectNext() { function selectNext() {
keyboardNavigationActive = true; keyboardNavigationActive = true;
_cancelPendingSelectionReset(); _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) { if (newIndex !== selectedFlatIndex) {
selectedFlatIndex = newIndex; selectedFlatIndex = newIndex;
updateSelectedItem(); updateSelectedItem();
@@ -1723,7 +1761,9 @@ Item {
function selectPrevious() { function selectPrevious() {
keyboardNavigationActive = true; keyboardNavigationActive = true;
_cancelPendingSelectionReset(); _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) { if (newIndex !== selectedFlatIndex) {
selectedFlatIndex = newIndex; selectedFlatIndex = newIndex;
updateSelectedItem(); updateSelectedItem();
@@ -1857,7 +1897,7 @@ Item {
if (browseTrigger && browseTrigger.length > 0) { if (browseTrigger && browseTrigger.length > 0) {
searchQueryRequested(browseTrigger); searchQueryRequested(browseTrigger);
} else { } else {
setMode("plugins"); setMode("plugins", false, undefined, true);
pluginFilter = browsePluginId; pluginFilter = browsePluginId;
performSearch(); performSearch();
} }
@@ -159,3 +159,14 @@ function sortPluginsOrdered(plugins, order) {
return aOrder - bOrder; 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 bool frameOwnsConnectedChrome: impl.item ? (impl.item.frameOwnsConnectedChrome ?? false) : false
readonly property string resolvedConnectedBarSide: impl.item ? (impl.item.resolvedConnectedBarSide ?? "") : "" readonly property string resolvedConnectedBarSide: impl.item ? (impl.item.resolvedConnectedBarSide ?? "") : ""
readonly property bool launcherArcExtenderActive: impl.item ? (impl.item.launcherArcExtenderActive ?? false) : false readonly property bool launcherArcExtenderActive: impl.item ? (impl.item.launcherArcExtenderActive ?? false) : false
property bool triggerUsesOverlayLayer: false
signal dialogClosed signal dialogClosed
@@ -61,7 +62,7 @@ Item {
impl.item.toggleWithMode(mode); 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) readonly property var _desiredBackend: useSpotlightBackend ? spotlightComp : (SettingsData.connectedFrameModeActive ? connectedComp : standaloneComp)
property var _resolvedBackend: null property var _resolvedBackend: null
@@ -72,9 +73,6 @@ Item {
function onConnectedFrameModeActiveChanged() { function onConnectedFrameModeActiveChanged() {
root._maybeResolveBackend(); root._maybeResolveBackend();
} }
function onFrameUseSpotlightLauncherChanged() {
root._maybeResolveBackend();
}
function onLauncherStyleChanged() { function onLauncherStyleChanged() {
root._maybeResolveBackend(); root._maybeResolveBackend();
} }
@@ -116,6 +114,7 @@ Item {
if (!it) if (!it)
return; return;
it.modalHandle = root; it.modalHandle = root;
it.triggerUsesOverlayLayer = Qt.binding(() => root.triggerUsesOverlayLayer);
} }
Connections { Connections {
@@ -13,13 +13,14 @@ Item {
readonly property var log: Log.scoped("DankLauncherV2ModalConnected") readonly property var log: Log.scoped("DankLauncherV2ModalConnected")
property var modalHandle: root property var modalHandle: root
property bool triggerUsesOverlayLayer: false
visible: false visible: false
property bool spotlightOpen: false property bool spotlightOpen: false
property bool keyboardActive: false property bool keyboardActive: false
property bool contentVisible: 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 var spotlightContent: launcherContentLoader.item
property bool openedFromOverview: false property bool openedFromOverview: false
property bool isClosing: false property bool isClosing: false
@@ -40,6 +41,21 @@ Item {
readonly property real screenWidth: effectiveScreen?.width ?? 1920 readonly property real screenWidth: effectiveScreen?.width ?? 1920
readonly property real screenHeight: effectiveScreen?.height ?? 1080 readonly property real screenHeight: effectiveScreen?.height ?? 1080
readonly property real dpr: effectiveScreen ? CompositorService.getScreenScale(effectiveScreen) : 1 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: { readonly property int baseWidth: {
switch (SettingsData.dankLauncherV2Size) { switch (SettingsData.dankLauncherV2Size) {
@@ -74,7 +90,7 @@ Item {
readonly property string resolvedConnectedBarSide: frameConnectedMode ? preferredConnectedBarSide : "" 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") readonly property bool launcherArcExtenderActive: frameOwnsConnectedChrome && SettingsData.frameLauncherArcExtender && (resolvedConnectedBarSide === "top" || resolvedConnectedBarSide === "bottom")
function _dockOccupiesSide(side) { function _dockOccupiesSide(side) {
@@ -140,10 +156,10 @@ Item {
readonly property real modalX: frameOwnsConnectedChrome ? _connectedModalPos.x : ((screenWidth - modalWidth) / 2) readonly property real modalX: frameOwnsConnectedChrome ? _connectedModalPos.x : ((screenWidth - modalWidth) / 2)
readonly property real modalY: frameOwnsConnectedChrome ? _connectedModalPos.y : ((screenHeight - modalHeight) / 2) readonly property real modalY: frameOwnsConnectedChrome ? _connectedModalPos.y : ((screenHeight - modalHeight) / 2)
readonly property bool connectedSurfaceOverride: Theme.isConnectedEffect readonly property bool connectedSurfaceOverride: frameOwnsConnectedChrome
readonly property int launcherAnimationDuration: Theme.isConnectedEffect ? Theme.popoutAnimationDuration : Theme.modalAnimationDuration readonly property int launcherAnimationDuration: frameOwnsConnectedChrome ? Theme.popoutAnimationDuration : Theme.modalAnimationDuration
readonly property list<real> launcherEnterCurve: Theme.isConnectedEffect ? Theme.variantPopoutEnterCurve : Theme.variantModalEnterCurve readonly property list<real> launcherEnterCurve: frameOwnsConnectedChrome ? Theme.variantPopoutEnterCurve : Theme.variantModalEnterCurve
readonly property list<real> launcherExitCurve: Theme.isConnectedEffect ? Theme.variantPopoutExitCurve : Theme.variantModalExitCurve 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 color backgroundColor: connectedSurfaceOverride ? Theme.connectedSurfaceColor : Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
readonly property real cornerRadius: connectedSurfaceOverride ? Theme.connectedSurfaceRadius : Theme.cornerRadius readonly property real cornerRadius: connectedSurfaceOverride ? Theme.connectedSurfaceRadius : Theme.cornerRadius
readonly property color borderColor: { readonly property color borderColor: {
@@ -372,6 +388,7 @@ Item {
if (!spotlightContent) if (!spotlightContent)
return; return;
contentVisible = true; contentVisible = true;
spotlightContent.closeTransientUi?.();
// NOTE: forceActiveFocus() is deliberately NOT called here. // NOTE: forceActiveFocus() is deliberately NOT called here.
// It is deferred to after animation starts to avoid compositor IPC stalls. // It is deferred to after animation starts to avoid compositor IPC stalls.
@@ -379,12 +396,12 @@ Item {
spotlightContent.searchField.text = query; spotlightContent.searchField.text = query;
} }
if (spotlightContent.controller) { if (spotlightContent.controller) {
var targetMode = mode || SessionData.launcherLastMode || "all"; var targetMode = mode || SessionData.getLauncherRestoreMode();
spotlightContent.controller.searchMode = targetMode; spotlightContent.controller.searchMode = targetMode;
spotlightContent.controller.activePluginId = ""; spotlightContent.controller.activePluginId = "";
spotlightContent.controller.activePluginName = ""; spotlightContent.controller.activePluginName = "";
spotlightContent.controller.pluginFilter = ""; spotlightContent.controller.pluginFilter = "";
spotlightContent.controller.fileSearchType = "all"; spotlightContent.controller.fileSearchType = SessionData.launcherLastFileSearchType || "all";
spotlightContent.controller.fileSearchExt = ""; spotlightContent.controller.fileSearchExt = "";
spotlightContent.controller.fileSearchFolder = ""; spotlightContent.controller.fileSearchFolder = "";
spotlightContent.controller.fileSearchSort = "score"; spotlightContent.controller.fileSearchSort = "score";
@@ -464,6 +481,7 @@ Item {
function hide() { function hide() {
if (!spotlightOpen) if (!spotlightOpen)
return; return;
spotlightContent?.closeTransientUi?.();
openedFromOverview = false; openedFromOverview = false;
isClosing = true; isClosing = true;
// For directional effects, defer contentVisible=false so content stays rendered during exit slide // For directional effects, defer contentVisible=false so content stays rendered during exit slide
@@ -521,8 +539,8 @@ Item {
Connections { Connections {
target: spotlightContent?.controller ?? null target: spotlightContent?.controller ?? null
function onModeChanged(mode) { function onModeChanged(mode, userInitiated) {
if (spotlightContent.controller.autoSwitchedToFiles) if (!userInitiated || !SettingsData.rememberLastMode)
return; return;
SessionData.setLauncherLastMode(mode); 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) 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.namespace: "dms:spotlight:bg"
WlrLayershell.layer: WlrLayershell.Top WlrLayershell.layer: root.effectiveLauncherLayer
WlrLayershell.exclusiveZone: -1 WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
@@ -669,20 +687,7 @@ Item {
} }
WlrLayershell.namespace: "dms:spotlight" WlrLayershell.namespace: "dms:spotlight"
WlrLayershell.layer: { WlrLayershell.layer: root.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 WlrLayershell.Top;
}
}
WlrLayershell.exclusiveZone: -1 WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: keyboardActive ? (root.useHyprlandFocusGrab ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) : WlrKeyboardFocus.None 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 => { Keys.onEscapePressed: event => {
root.hide(); root.spotlightContent?.activeContextMenu?.handleKey(event);
if (!event.accepted)
root.hide();
event.accepted = true; event.accepted = true;
} }
} }
@@ -11,6 +11,7 @@ Item {
readonly property var log: Log.scoped("DankLauncherV2ModalSpotlight") readonly property var log: Log.scoped("DankLauncherV2ModalSpotlight")
property var modalHandle: root property var modalHandle: root
property bool triggerUsesOverlayLayer: false
visible: false visible: false
@@ -29,13 +30,29 @@ Item {
readonly property real screenWidth: effectiveScreen?.width ?? 1920 readonly property real screenWidth: effectiveScreen?.width ?? 1920
readonly property real screenHeight: effectiveScreen?.height ?? 1080 readonly property real screenHeight: effectiveScreen?.height ?? 1080
readonly property real dpr: effectiveScreen ? CompositorService.getScreenScale(effectiveScreen) : 1 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 _openDuration: 50
readonly property int _closeDuration: 70 readonly property int _closeDuration: 40
readonly property int _motionDuration: 90 readonly property int _motionDuration: 60
// Connected frame mode clamps the centered surface inside frame insets. // 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) { function _frameEdgeInset(side) {
if (!effectiveScreen || !frameConnected) if (!effectiveScreen || !frameConnected)
@@ -58,7 +75,7 @@ Item {
const searchBarH = 56; const searchBarH = 56;
const usableH = Math.max(searchBarH, screenHeight - insetT - insetB); const usableH = Math.max(searchBarH, screenHeight - insetT - insetB);
const preferred = insetT + Math.max(0, usableH * 0.33 - searchBarH / 2); 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)); return Math.max(insetT, Math.min(preferred, maxY));
} }
@@ -125,9 +142,10 @@ Item {
if (!spotlightContent) if (!spotlightContent)
return; return;
contentVisible = true; contentVisible = true;
spotlightContent.closeTransientUi?.();
const targetQuery = query || (SettingsData.rememberLastQuery ? (SessionData.launcherLastQuery || "") : ""); const targetQuery = query || (SettingsData.rememberLastQuery ? (SessionData.launcherLastQuery || "") : "");
const targetMode = mode || SessionData.launcherLastMode || "all"; const targetMode = mode || SessionData.getLauncherRestoreMode();
if (spotlightContent.searchField) { if (spotlightContent.searchField) {
spotlightContent.searchField.text = targetQuery; spotlightContent.searchField.text = targetQuery;
@@ -185,6 +203,7 @@ Item {
function hide() { function hide() {
if (!spotlightOpen) if (!spotlightOpen)
return; return;
spotlightContent?.closeTransientUi?.();
openedFromOverview = false; openedFromOverview = false;
isClosing = true; isClosing = true;
contentVisible = false; contentVisible = false;
@@ -259,11 +278,11 @@ Item {
PanelWindow { PanelWindow {
id: clickCatcher id: clickCatcher
screen: launcherWindow.screen screen: launcherWindow.screen
visible: spotlightOpen || isClosing visible: (spotlightOpen || isClosing) && !root.useBackgroundDarken
color: "transparent" color: "transparent"
WlrLayershell.namespace: "dms:spotlight:clickcatcher" WlrLayershell.namespace: "dms:spotlight:clickcatcher"
WlrLayershell.layer: WlrLayershell.Top WlrLayershell.layer: root.effectiveLauncherLayer
WlrLayershell.exclusiveZone: -1 WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
@@ -324,31 +343,26 @@ Item {
} }
WlrLayershell.namespace: "dms:spotlight" WlrLayershell.namespace: "dms:spotlight"
WlrLayershell.layer: { WlrLayershell.layer: root.effectiveLauncherLayer
switch (Quickshell.env("DMS_MODAL_LAYER")) {
case "overlay":
return WlrLayershell.Overlay;
default:
return WlrLayershell.Top;
}
}
WlrLayershell.exclusiveZone: -1 WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: keyboardActive ? (root.useHyprlandFocusGrab ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) : WlrKeyboardFocus.None WlrLayershell.keyboardFocus: keyboardActive ? (root.useHyprlandFocusGrab ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) : WlrKeyboardFocus.None
anchors { anchors {
top: true top: true
left: true left: true
right: root.useBackgroundDarken
bottom: root.useBackgroundDarken
} }
WlrLayershell.margins { WlrLayershell.margins {
left: root.windowX left: root.useBackgroundDarken ? 0 : root.windowX
top: root.windowY top: root.useBackgroundDarken ? 0 : root.windowY
right: 0 right: 0
bottom: 0 bottom: 0
} }
implicitWidth: root.windowWidth implicitWidth: root.useBackgroundDarken ? 0 : root.windowWidth
implicitHeight: root.windowHeight implicitHeight: root.useBackgroundDarken ? 0 : root.windowHeight
mask: Region { mask: Region {
item: inputMask item: inputMask
@@ -358,19 +372,44 @@ Item {
id: inputMask id: inputMask
visible: false visible: false
color: "transparent" color: "transparent"
x: modalContainer.x x: root.useBackgroundDarken ? 0 : modalContainer.x
y: modalContainer.y + modalContainer.slideOffset y: root.useBackgroundDarken ? 0 : modalContainer.y + modalContainer.slideOffset
width: root.alignedWidth width: root.useBackgroundDarken ? launcherWindow.width : root.alignedWidth
height: root._contentImplicitH 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 { Item {
id: modalContainer id: modalContainer
x: root.contentX x: root.useBackgroundDarken ? root.alignedX : root.contentX
y: root.contentY y: root.useBackgroundDarken ? root.alignedY : root.contentY
width: root.alignedWidth width: root.alignedWidth
height: root._animatedContentH height: root._animatedContentH
visible: _renderActive visible: _renderActive
z: 0
property bool _renderActive: contentVisible property bool _renderActive: contentVisible
property real slideOffset: contentVisible ? 0 : -root._animHeadroom property real slideOffset: contentVisible ? 0 : -root._animHeadroom
@@ -450,8 +489,12 @@ Item {
} }
} }
Keys.onPressed: event => root.spotlightContent?.activeContextMenu?.handleKey(event)
Keys.onEscapePressed: event => { Keys.onEscapePressed: event => {
root.hide(); root.spotlightContent?.activeContextMenu?.handleKey(event);
if (!event.accepted)
root.hide();
event.accepted = true; event.accepted = true;
} }
} }
@@ -11,6 +11,7 @@ Item {
readonly property var log: Log.scoped("DankLauncherV2ModalStandalone") readonly property var log: Log.scoped("DankLauncherV2ModalStandalone")
property var modalHandle: root property var modalHandle: root
property bool triggerUsesOverlayLayer: false
visible: false visible: false
@@ -31,7 +32,7 @@ Item {
readonly property real screenHeight: effectiveScreen?.height ?? 1080 readonly property real screenHeight: effectiveScreen?.height ?? 1080
readonly property real dpr: effectiveScreen ? CompositorService.getScreenScale(effectiveScreen) : 1 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 string resolvedConnectedBarSide: frameOwnsConnectedChrome ? (SettingsData.frameLauncherEmergeSide || "bottom") : ""
readonly property int baseWidth: { readonly property int baseWidth: {
@@ -79,6 +80,21 @@ Item {
readonly property color backgroundColor: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency) readonly property color backgroundColor: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
readonly property bool useBackgroundDarken: !SettingsData.frameEnabled && SettingsData.modalDarkenBackground 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 real cornerRadius: Theme.cornerRadius
readonly property color borderColor: { readonly property color borderColor: {
if (!SettingsData.dankLauncherV2BorderEnabled) if (!SettingsData.dankLauncherV2BorderEnabled)
@@ -117,6 +133,7 @@ Item {
if (!spotlightContent) if (!spotlightContent)
return; return;
contentVisible = true; contentVisible = true;
spotlightContent.closeTransientUi?.();
spotlightContent.searchField.forceActiveFocus(); spotlightContent.searchField.forceActiveFocus();
var targetQuery = ""; var targetQuery = "";
@@ -131,12 +148,12 @@ Item {
spotlightContent.searchField.text = targetQuery; spotlightContent.searchField.text = targetQuery;
} }
if (spotlightContent.controller) { if (spotlightContent.controller) {
var targetMode = mode || SessionData.launcherLastMode || "all"; var targetMode = mode || SessionData.getLauncherRestoreMode();
spotlightContent.controller.searchMode = targetMode; spotlightContent.controller.searchMode = targetMode;
spotlightContent.controller.activePluginId = ""; spotlightContent.controller.activePluginId = "";
spotlightContent.controller.activePluginName = ""; spotlightContent.controller.activePluginName = "";
spotlightContent.controller.pluginFilter = ""; spotlightContent.controller.pluginFilter = "";
spotlightContent.controller.fileSearchType = "all"; spotlightContent.controller.fileSearchType = SessionData.launcherLastFileSearchType || "all";
spotlightContent.controller.fileSearchExt = ""; spotlightContent.controller.fileSearchExt = "";
spotlightContent.controller.fileSearchFolder = ""; spotlightContent.controller.fileSearchFolder = "";
spotlightContent.controller.fileSearchSort = "score"; spotlightContent.controller.fileSearchSort = "score";
@@ -195,6 +212,7 @@ Item {
function hide() { function hide() {
if (!spotlightOpen) if (!spotlightOpen)
return; return;
spotlightContent?.closeTransientUi?.();
openedFromOverview = false; openedFromOverview = false;
isClosing = true; isClosing = true;
contentVisible = false; contentVisible = false;
@@ -242,8 +260,8 @@ Item {
Connections { Connections {
target: spotlightContent?.controller ?? null target: spotlightContent?.controller ?? null
function onModeChanged(mode) { function onModeChanged(mode, userInitiated) {
if (spotlightContent.controller.autoSwitchedToFiles) if (!userInitiated || !SettingsData.rememberLastMode || (mode !== "all" && mode !== "apps"))
return; return;
SessionData.setLauncherLastMode(mode); SessionData.setLauncherLastMode(mode);
} }
@@ -296,12 +314,11 @@ Item {
PanelWindow { PanelWindow {
id: clickCatcher id: clickCatcher
screen: launcherWindow.screen screen: launcherWindow.screen
visible: spotlightOpen || isClosing visible: (spotlightOpen || isClosing) && !root.useBackgroundDarken
color: "transparent" color: "transparent"
updatesEnabled: root.useBackgroundDarken && (spotlightOpen || isClosing)
WlrLayershell.namespace: "dms:spotlight:clickcatcher" WlrLayershell.namespace: "dms:spotlight:clickcatcher"
WlrLayershell.layer: WlrLayershell.Top WlrLayershell.layer: root.effectiveLauncherLayer
WlrLayershell.exclusiveZone: -1 WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
@@ -342,22 +359,6 @@ Item {
enabled: spotlightOpen enabled: spotlightOpen
onClicked: root.hide() 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 { PanelWindow {
@@ -369,7 +370,7 @@ Item {
WindowBlur { WindowBlur {
targetWindow: launcherWindow targetWindow: launcherWindow
readonly property real s: Math.min(1, modalContainer.publishedScale) 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 blurX: modalContainer.x + modalContainer.width * (1 - s * op) * 0.5
blurY: modalContainer.y + modalContainer.height * (1 - s * op) * 0.5 blurY: modalContainer.y + modalContainer.height * (1 - s * op) * 0.5
blurWidth: contentVisible ? modalContainer.width * s * op : 0 blurWidth: contentVisible ? modalContainer.width * s * op : 0
@@ -378,39 +379,26 @@ Item {
} }
WlrLayershell.namespace: "dms:spotlight" WlrLayershell.namespace: "dms:spotlight"
WlrLayershell.layer: { WlrLayershell.layer: root.effectiveLauncherLayer
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.exclusiveZone: -1 WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: keyboardActive ? (root.useHyprlandFocusGrab ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) : WlrKeyboardFocus.None WlrLayershell.keyboardFocus: keyboardActive ? (root.useHyprlandFocusGrab ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) : WlrKeyboardFocus.None
anchors { anchors {
top: true top: true
left: true left: true
right: root.useBackgroundDarken
bottom: root.useBackgroundDarken
} }
WlrLayershell.margins { WlrLayershell.margins {
left: root.windowX left: root.useBackgroundDarken ? 0 : root.windowX
top: root.windowY top: root.useBackgroundDarken ? 0 : root.windowY
right: 0 right: 0
bottom: 0 bottom: 0
} }
implicitWidth: root.windowWidth implicitWidth: root.useBackgroundDarken ? 0 : root.windowWidth
implicitHeight: root.windowHeight implicitHeight: root.useBackgroundDarken ? 0 : root.windowHeight
mask: Region { mask: Region {
item: launcherInputMask item: launcherInputMask
@@ -420,22 +408,48 @@ Item {
id: launcherInputMask id: launcherInputMask
visible: false visible: false
color: "transparent" color: "transparent"
x: modalContainer.x x: root.useBackgroundDarken ? 0 : modalContainer.x
y: modalContainer.y y: root.useBackgroundDarken ? 0 : modalContainer.y
width: modalContainer.width width: root.useBackgroundDarken ? launcherWindow.width : modalContainer.width
height: modalContainer.height 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 { Item {
id: modalContainer id: modalContainer
x: root.contentX x: root.useBackgroundDarken ? root.alignedX : root.contentX
y: root.contentY y: root.useBackgroundDarken ? root.alignedY : root.contentY
width: root.alignedWidth width: root.alignedWidth
height: root.alignedHeight height: root.alignedHeight
visible: _renderActive visible: _renderActive
z: 0
property bool _renderActive: contentVisible property bool _renderActive: contentVisible
property real publishedScale: contentVisible ? 1 : 0.96 property real publishedScale: contentVisible ? 1 : 0.96
property real publishedOpacity: contentVisible ? 1 : 0
opacity: contentVisible ? 1 : 0 opacity: contentVisible ? 1 : 0
scale: contentVisible ? 1 : 0.96 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 { Connections {
target: root target: root
function onContentVisibleChanged() { function onContentVisibleChanged() {
@@ -514,8 +536,12 @@ Item {
} }
} }
Keys.onPressed: event => root.spotlightContent?.activeContextMenu?.handleKey(event)
Keys.onEscapePressed: event => { Keys.onEscapePressed: event => {
root.hide(); root.spotlightContent?.activeContextMenu?.handleKey(event);
if (!event.accepted)
root.hide();
event.accepted = true; event.accepted = true;
} }
} }
@@ -17,10 +17,22 @@ FocusScope {
property alias controller: controller property alias controller: controller
property alias resultsList: resultsList property alias resultsList: resultsList
property alias actionPanel: actionPanel property alias actionPanel: actionPanel
readonly property alias activeContextMenu: contextMenu
property bool editMode: false property bool editMode: false
property var editingApp: null property var editingApp: null
property string editAppId: "" 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() { function resetScroll() {
resultsList.resetScroll(); resultsList.resetScroll();
@@ -30,6 +42,12 @@ FocusScope {
searchField.forceActiveFocus(); searchField.forceActiveFocus();
} }
function closeTransientUi() {
contextMenu.hide();
actionPanel.hide();
root.enabled = true;
}
function openEditMode(app) { function openEditMode(app) {
if (!app) if (!app)
return; 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 => { Keys.onPressed: event => {
if (editMode) { if (editMode) {
if (event.key === Qt.Key_Escape) { if (event.key === Qt.Key_Escape) {
@@ -257,13 +290,6 @@ FocusScope {
} }
event.accepted = false; event.accepted = false;
return; return;
case Qt.Key_Slash:
if (event.modifiers === Qt.NoModifier && searchField.text.length === 0) {
controller.setMode("files", true);
return;
}
event.accepted = false;
return;
default: default:
event.accepted = false; event.accepted = false;
} }
@@ -284,7 +310,7 @@ FocusScope {
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
anchors.leftMargin: root.parentModal?.borderWidth ?? 1 anchors.leftMargin: root.parentModal?.borderWidth ?? 1
anchors.rightMargin: 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 height: showFooter ? (_connectedArcAtFooter ? 76 : 36) : 0
visible: showFooter visible: showFooter
clip: true clip: true
@@ -293,7 +319,7 @@ FocusScope {
anchors.fill: parent anchors.fill: parent
anchors.topMargin: -Theme.cornerRadius anchors.topMargin: -Theme.cornerRadius
// In connected mode the launcher provides the surface so update the toolbar for arcs // 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) color: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
radius: Theme.cornerRadius radius: Theme.cornerRadius
} }
@@ -458,9 +484,11 @@ FocusScope {
id: searchField id: searchField
width: parent.width - (pluginBadge.visible ? pluginBadge.width + Theme.spacingS : 0) width: parent.width - (pluginBadge.visible ? pluginBadge.width + Theme.spacingS : 0)
cornerRadius: Theme.cornerRadius cornerRadius: Theme.cornerRadius
backgroundColor: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency) backgroundColor: root._launcherSearchFieldColor
normalBorderColor: Theme.outlineMedium normalBorderColor: root._launcherSearchBorderColor
focusedBorderColor: Theme.primary focusedBorderColor: root._launcherSearchFocusedBorderColor
borderWidth: 1
focusedBorderWidth: 2
leftIconName: controller.activePluginId ? "extension" : controller.searchQuery.startsWith("/") ? "folder" : "search" leftIconName: controller.activePluginId ? "extension" : controller.searchQuery.startsWith("/") ? "folder" : "search"
leftIconSize: Theme.iconSize leftIconSize: Theme.iconSize
leftIconColor: Theme.surfaceVariantText leftIconColor: Theme.surfaceVariantText
@@ -1,35 +1,72 @@
pragma ComponentBehavior: Bound pragma ComponentBehavior: Bound
import QtQuick import QtQuick
import QtQuick.Controls import Quickshell
import Quickshell.Wayland
import qs.Common import qs.Common
import qs.Services import qs.Services
import qs.Widgets import qs.Widgets
Popup { Item {
id: root id: root
visible: false
width: 0
height: 0
property var item: null property var item: null
property var controller: null property var controller: null
property var searchField: null property var searchField: null
property var parentHandler: null property var parentHandler: null
property bool allowEditActions: true 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 hideRequested
signal editAppRequested(var app) signal editAppRequested(var app)
TextMetrics {
id: menuTextMetrics
text: root.longestMenuText
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Normal
}
function hasContextMenuActions(spotlightItem) { function hasContextMenuActions(spotlightItem) {
if (!spotlightItem) if (!spotlightItem)
return false; return false;
if (spotlightItem.type === "app") if (spotlightItem.type === "app")
return true; return true;
if (spotlightItem.type === "plugin" && spotlightItem.pluginId) { if (spotlightItem.type === "plugin" && spotlightItem.pluginId) {
var instance = PluginService.pluginInstances[spotlightItem.pluginId]; const instance = PluginService.pluginInstances[spotlightItem.pluginId];
if (!instance) if (!instance)
return false; return false;
if (typeof instance.getContextMenuActions !== "function") if (typeof instance.getContextMenuActions !== "function")
return false; return false;
var actions = instance.getContextMenuActions(spotlightItem.data); const actions = instance.getContextMenuActions(spotlightItem.data);
return Array.isArray(actions) && actions.length > 0; return Array.isArray(actions) && actions.length > 0;
} }
if (spotlightItem.actions && spotlightItem.actions.length > 0) if (spotlightItem.actions && spotlightItem.actions.length > 0)
@@ -54,13 +91,13 @@ Popup {
if (!isPluginItem || !item?.pluginId) if (!isPluginItem || !item?.pluginId)
return []; return [];
var instance = PluginService.pluginInstances[item.pluginId]; const instance = PluginService.pluginInstances[item.pluginId];
if (!instance) if (!instance)
return []; return [];
if (typeof instance.getContextMenuActions !== "function") if (typeof instance.getContextMenuActions !== "function")
return []; return [];
var actions = instance.getContextMenuActions(item.data); const actions = instance.getContextMenuActions(item.data);
if (!Array.isArray(actions)) if (!Array.isArray(actions))
return []; return [];
@@ -68,8 +105,8 @@ Popup {
} }
function executePluginAction(actionOrObj) { function executePluginAction(actionOrObj) {
var actionFunc = typeof actionOrObj === "function" ? actionOrObj : actionOrObj?.action; const actionFunc = typeof actionOrObj === "function" ? actionOrObj : actionOrObj?.action;
var closeLauncher = typeof actionOrObj === "object" && actionOrObj?.closeLauncher; const closeLauncher = typeof actionOrObj === "object" && actionOrObj?.closeLauncher;
if (typeof actionFunc === "function") if (typeof actionFunc === "function")
actionFunc(); actionFunc();
@@ -90,12 +127,12 @@ Popup {
} }
readonly property var menuItems: { readonly property var menuItems: {
var items = []; const items = [];
if (isPluginItem) { if (isPluginItem) {
var pluginActions = getPluginContextMenuActions(); const pluginActions = getPluginContextMenuActions();
for (var i = 0; i < pluginActions.length; i++) { for (let i = 0; i < pluginActions.length; i++) {
var act = pluginActions[i]; const act = pluginActions[i];
items.push({ items.push({
type: "item", type: "item",
icon: act.icon || "play_arrow", icon: act.icon || "play_arrow",
@@ -107,8 +144,8 @@ Popup {
} }
if (item?.type !== "app" && item?.actions && item.actions.length > 0) { if (item?.type !== "app" && item?.actions && item.actions.length > 0) {
for (var i = 0; i < item.actions.length; i++) { for (let i = 0; i < item.actions.length; i++) {
var genericAct = item.actions[i]; const genericAct = item.actions[i];
items.push({ items.push({
type: "item", type: "item",
icon: genericAct.icon || "play_arrow", icon: genericAct.icon || "play_arrow",
@@ -149,8 +186,8 @@ Popup {
items.push({ items.push({
type: "separator" type: "separator"
}); });
for (var i = 0; i < item.actions.length; i++) { for (let i = 0; i < item.actions.length; i++) {
var act = item.actions[i]; const act = item.actions[i];
items.push({ items.push({
type: "item", type: "item",
icon: act.icon || "play_arrow", icon: act.icon || "play_arrow",
@@ -183,43 +220,52 @@ Popup {
return items; 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) { function show(x, y, spotlightItem, fromKeyboard) {
if (!spotlightItem?.data) if (!spotlightItem?.data)
return; return;
item = spotlightItem; item = spotlightItem;
selectedMenuIndex = fromKeyboard ? 0 : -1; selectedMenuIndex = fromKeyboard ? 0 : -1;
keyboardNavigation = fromKeyboard; 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) if (parentHandler)
parentHandler.enabled = false; parentHandler.enabled = false;
Qt.callLater(() => { Qt.callLater(() => {
var parentW = parent?.width ?? 500; menuFlickable.contentY = 0;
var parentH = parent?.height ?? 600; keyboardHandler.forceActiveFocus();
var menuW = width > 0 ? width : 200; ensureSelectedVisible();
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();
}); });
} }
function hide() { function hide() {
if (parentHandler) if (!renderActive)
parentHandler.enabled = true; return;
close(); openState = false;
hideRequested();
} }
function togglePin() { function togglePin() {
@@ -286,31 +332,96 @@ Popup {
property bool keyboardNavigation: false property bool keyboardNavigation: false
readonly property int visibleItemCount: { readonly property int visibleItemCount: {
var count = 0; let count = 0;
for (var i = 0; i < menuItems.length; i++) { for (let i = 0; i < menuItems.length; i++) {
if (menuItems[i].type === "item") if (menuItems[i].type === "item")
count++; count++;
} }
return 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() { function selectNext() {
if (visibleItemCount > 0) if (visibleItemCount > 0) {
keyboardNavigation = true;
selectedMenuIndex = (selectedMenuIndex + 1) % visibleItemCount; selectedMenuIndex = (selectedMenuIndex + 1) % visibleItemCount;
ensureSelectedVisible();
}
} }
function selectPrevious() { function selectPrevious() {
if (visibleItemCount > 0) if (visibleItemCount > 0) {
keyboardNavigation = true;
selectedMenuIndex = (selectedMenuIndex - 1 + visibleItemCount) % visibleItemCount; 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() { function activateSelected() {
var itemIndex = 0; let itemIndex = 0;
for (var i = 0; i < menuItems.length; i++) { for (let i = 0; i < menuItems.length; i++) {
if (menuItems[i].type !== "item") if (menuItems[i].type !== "item")
continue; continue;
if (itemIndex === selectedMenuIndex) { if (itemIndex === selectedMenuIndex) {
var menuItem = menuItems[i]; const menuItem = menuItems[i];
if (menuItem.action) if (menuItem.action)
menuItem.action(); menuItem.action();
else if (menuItem.pluginAction) else if (menuItem.pluginAction)
@@ -325,209 +436,233 @@ Popup {
} }
} }
width: menuContainer.implicitWidth PanelWindow {
height: menuContainer.implicitHeight id: menuWindow
padding: 0
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
modal: true
dim: false
background: Item {}
onOpened: { screen: root.targetScreen
Qt.callLater(() => keyboardHandler.forceActiveFocus()); visible: root.renderActive
} color: "transparent"
onClosed: { WlrLayershell.namespace: "dms:launcher-context-menu"
if (parentHandler) WlrLayershell.layer: WlrLayershell.Overlay
parentHandler.enabled = true; WlrLayershell.exclusiveZone: -1
if (searchField?.visible) { WlrLayershell.keyboardFocus: root.renderActive ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None
Qt.callLater(() => searchField.forceActiveFocus());
}
}
enter: Transition { anchors {
NumberAnimation { top: true
property: "opacity" left: true
from: 0 right: true
to: 1 bottom: true
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;
}
} }
Rectangle { WindowBlur {
id: menuContainer 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 anchors.fill: parent
implicitWidth: Math.max(180, menuColumn.implicitWidth + Theme.spacingS * 2) z: -1
implicitHeight: menuColumn.implicitHeight + Theme.spacingS * 2 enabled: root.renderActive
color: Theme.floatingSurface onClicked: root.hide()
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 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 { Rectangle {
anchors.fill: parent id: menuContainer
anchors.topMargin: 4 x: Math.max(root.menuMargin, Math.min(menuWindow.width - width - root.menuMargin, root.anchorX))
anchors.leftMargin: 2 y: Math.max(root.menuMargin, Math.min(menuWindow.height - height - root.menuMargin, root.anchorY))
anchors.rightMargin: -2 width: root.effectiveMenuWidth
anchors.bottomMargin: -4 height: root.effectiveMenuHeight
radius: parent.radius color: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
color: Qt.rgba(0, 0, 0, 0.15) radius: Theme.cornerRadius
z: -1 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 { Behavior on opacity {
id: menuColumn NumberAnimation {
anchors.fill: parent duration: Theme.shortDuration
anchors.margins: Theme.spacingS easing.type: Theme.emphasizedEasing
spacing: 1 onRunningChanged: {
if (!running && !root.openState) {
Repeater { root.renderActive = false;
model: root.menuItems if (root.parentHandler)
root.parentHandler.enabled = true;
Item { if (root.searchField?.visible)
id: menuItemDelegate Qt.callLater(() => root.searchField.forceActiveFocus());
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)
} }
} }
}
}
Rectangle { Rectangle {
visible: menuItemDelegate.modelData.type === "item" anchors.fill: parent
width: parent.width anchors.topMargin: 4
height: parent.height anchors.leftMargin: 2
radius: Theme.cornerRadius anchors.rightMargin: -2
color: { anchors.bottomMargin: -4
if (root.keyboardNavigation && root.selectedMenuIndex === menuItemDelegate.itemIndex) { radius: parent.radius
return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.2); 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 { Rectangle {
anchors.left: parent.left visible: menuItemDelegate.modelData.type === "separator"
anchors.leftMargin: Theme.spacingS width: parent.width - Theme.spacingS * 2
anchors.right: parent.right height: parent.height
anchors.rightMargin: Theme.spacingS anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter color: "transparent"
spacing: Theme.spacingS
Item { Rectangle {
width: Theme.iconSize - 2 anchors.centerIn: parent
height: Theme.iconSize - 2 width: parent.width
anchors.verticalCenter: parent.verticalCenter height: 1
color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
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 { Rectangle {
text: menuItemDelegate.modelData.text || "" visible: menuItemDelegate.modelData.type === "item"
font.pixelSize: Theme.fontSizeSmall width: parent.width
color: Theme.surfaceText height: parent.height
font.weight: Font.Normal radius: Theme.cornerRadius
anchors.verticalCenter: parent.verticalCenter color: {
elide: Text.ElideRight if (root.keyboardNavigation && root.selectedMenuIndex === menuItemDelegate.itemIndex) {
width: parent.width - (Theme.iconSize - 2) - Theme.spacingS return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.2);
} }
} return itemMouseArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent";
}
DankRipple { Row {
id: menuItemRipple anchors.left: parent.left
rippleColor: Theme.surfaceText anchors.leftMargin: Theme.spacingS
cornerRadius: Theme.cornerRadius anchors.right: parent.right
} anchors.rightMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingS
MouseArea { Item {
id: itemMouseArea width: Theme.iconSize - 2
anchors.fill: parent height: Theme.iconSize - 2
hoverEnabled: true anchors.verticalCenter: parent.verticalCenter
cursorShape: Qt.PointingHandCursor
onEntered: { DankIcon {
root.keyboardNavigation = false; visible: (menuItemDelegate.modelData?.icon ?? "").length > 0
root.selectedMenuIndex = menuItemDelegate.itemIndex; name: menuItemDelegate.modelData?.icon ?? ""
} size: Theme.iconSize - 2
onPressed: mouse => menuItemRipple.trigger(mouse.x, mouse.y) color: Theme.surfaceText
onClicked: { opacity: 0.7
var menuItem = menuItemDelegate.modelData; anchors.verticalCenter: parent.verticalCenter
if (menuItem.action) }
menuItem.action(); }
else if (menuItem.pluginAction)
root.executePluginAction(menuItem.pluginAction); StyledText {
else if (menuItem.launcherActionData) text: menuItemDelegate.modelData.text || ""
root.executeLauncherAction(menuItem.launcherActionData); font.pixelSize: Theme.fontSizeSmall
else if (menuItem.actionData) color: Theme.surfaceText
root.executeDesktopAction(menuItem.actionData); 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 var parentModal: null
property alias searchField: searchInput property alias searchField: searchInput
property alias controller: searchController property alias controller: searchController
readonly property alias activeContextMenu: contextMenu
readonly property bool _hasQuery: searchInput.text.length > 0 readonly property bool _hasQuery: searchInput.text.length > 0
readonly property real _searchBarH: 56 readonly property real _searchBarH: 56
readonly property real _surfaceInset: BlurService.enabled ? (_hasQuery ? Theme.spacingS : Theme.spacingXS) : 0 readonly property real _searchAreaH: _searchBarH
readonly property real _searchAreaH: _searchBarH + _surfaceInset * 2
readonly property real _statusH: 92 readonly property real _statusH: 92
readonly property real _rowH: 64 readonly property real _rowH: 64
readonly property real _maxResultsH: Math.min(430, (parentModal?.screenHeight ?? 900) * 0.55) 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 real _resultsH: _hasQuery ? Math.min(_resultsContentH, _maxResultsH) : 0
readonly property int _fastDuration: 90 readonly property int _fastDuration: 90
readonly property int _resizeDuration: 110 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() { function resetScroll() {
resultsList.resetScroll(); resultsList.resetScroll();
} }
function closeTransientUi() {
contextMenu.hide();
root.enabled = true;
}
function _buildRows() { function _buildRows() {
const flat = searchController.flatModel || []; const flat = searchController.flatModel || [];
const sections = searchController.sections || []; const sections = searchController.sections || [];
@@ -122,13 +143,11 @@ FocusScope {
} }
break; break;
case Qt.Key_Tab: case Qt.Key_Tab:
if (_hasQuery) _cycleCategory(false);
_cycleCategory(false);
event.accepted = true; event.accepted = true;
return; return;
case Qt.Key_Backtab: case Qt.Key_Backtab:
if (_hasQuery) _cycleCategory(true);
_cycleCategory(true);
event.accepted = true; event.accepted = true;
return; return;
case Qt.Key_Return: case Qt.Key_Return:
@@ -177,13 +196,6 @@ FocusScope {
return; return;
} }
break; 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; event.accepted = false;
@@ -193,6 +205,7 @@ FocusScope {
id: searchController id: searchController
active: root.parentModal ? (root.parentModal.spotlightOpen || root.parentModal.isClosing) : true active: root.parentModal ? (root.parentModal.spotlightOpen || root.parentModal.isClosing) : true
viewModeContext: "spotlight" viewModeContext: "spotlight"
forceLinearNavigation: true
onItemExecuted: { onItemExecuted: {
root.parentModal?.hide(); root.parentModal?.hide();
@@ -210,10 +223,25 @@ FocusScope {
allowEditActions: false 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 { Connections {
target: searchController target: searchController
function onModeChanged(mode) { function onModeChanged(mode, userInitiated) {
if (searchController.autoSwitchedToFiles) if (!userInitiated || !SettingsData.rememberLastMode)
return; return;
SessionData.setLauncherLastMode(mode); SessionData.setLauncherLastMode(mode);
} }
@@ -233,11 +261,8 @@ FocusScope {
Rectangle { Rectangle {
id: searchBarSurface id: searchBarSurface
anchors.fill: parent anchors.fill: parent
anchors.margins: root._surfaceInset radius: Theme.cornerRadius
radius: height / 2 color: root._searchSurfaceColor
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
Behavior on color { Behavior on color {
ColorAnimation { ColorAnimation {
@@ -254,7 +279,7 @@ FocusScope {
anchors.left: parent.left anchors.left: parent.left
anchors.leftMargin: Theme.spacingM anchors.leftMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
color: searchInput.activeFocus ? Theme.primaryContainer : Theme.surfaceContainer color: root._searchWellColor
DankIcon { DankIcon {
anchors.centerIn: parent anchors.centerIn: parent
@@ -273,8 +298,8 @@ FocusScope {
Row { Row {
id: categoryRow id: categoryRow
visible: SettingsData.spotlightBarShowModeChips || root._hasQuery
spacing: Theme.spacingXS spacing: Theme.spacingXS
visible: root._hasQuery
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
Repeater { 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 { Item {
id: resultsContainer id: resultsContainer
anchors.top: searchBarItem.bottom anchors.top: searchBarItem.bottom
anchors.topMargin: 1
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
clip: true clip: true
@@ -12,6 +12,7 @@ Item {
property var controller: null property var controller: null
property bool hasQuery: false property bool hasQuery: false
property var rows: [] property var rows: []
readonly property real bottomInset: Theme.spacingS
signal itemRightClicked(int index, var item, real mouseX, real mouseY) signal itemRightClicked(int index, var item, real mouseX, real mouseY)
@@ -53,7 +54,11 @@ Item {
DankListView { DankListView {
id: mainListView 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 clip: true
visible: root.rows.length > 0 visible: root.rows.length > 0
@@ -64,11 +69,6 @@ Item {
objectProp: "_rowId" objectProp: "_rowId"
} }
add: null
remove: null
displaced: null
move: null
delegate: Item { delegate: Item {
id: delegateRoot id: delegateRoot
required property var modelData required property var modelData
@@ -103,7 +103,11 @@ Item {
} }
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 visible: root.hasQuery && root.rows.length === 0
Row { Row {
+13
View File
@@ -81,6 +81,8 @@ DankModal {
executeAction(action); executeAction(action);
} }
signal switchUserRequested
function executeAction(action) { function executeAction(action) {
if (action === "lock") { if (action === "lock") {
close(); close();
@@ -92,6 +94,11 @@ DankModal {
Quickshell.execDetached(["dms", "restart"]); Quickshell.execDetached(["dms", "restart"]);
return; return;
} }
if (action === "switchuser") {
close();
switchUserRequested();
return;
}
close(); close();
root.powerActionRequested(action, "", ""); root.powerActionRequested(action, "", "");
} }
@@ -216,6 +223,12 @@ DankModal {
"label": I18n.tr("Restart DMS"), "label": I18n.tr("Restart DMS"),
"key": "D" "key": "D"
}; };
case "switchuser":
return {
"icon": "switch_account",
"label": I18n.tr("Switch User"),
"key": "U"
};
default: default:
return { return {
"icon": "help", "icon": "help",
@@ -64,6 +64,7 @@ FocusScope {
sourceComponent: KeybindsTab { sourceComponent: KeybindsTab {
parentModal: root.parentModal parentModal: root.parentModal
requestedSearchQuery: root.parentModal?.keybindSearchQuery ?? ""
} }
onActiveChanged: { onActiveChanged: {
@@ -554,5 +555,20 @@ FocusScope {
Qt.callLater(() => item.forceActiveFocus()); 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 isCompactMode: width < 700
property bool menuVisible: !isCompactMode property bool menuVisible: !isCompactMode
property bool enableAnimations: true property bool enableAnimations: true
property string keybindSearchQuery: ""
signal closingModal signal closingModal
@@ -73,6 +74,11 @@ FloatingWindow {
return sidebar.resolveTabIndex(tabName); return sidebar.resolveTabIndex(tabName);
} }
function showKeybindsSearch(query: string) {
keybindSearchQuery = query || "";
showWithTabName("keybinds");
}
function toggleMenu() { function toggleMenu() {
enableAnimations = true; enableAnimations = true;
menuVisible = !menuVisible; menuVisible = !menuVisible;
@@ -293,6 +293,12 @@ Rectangle {
"tabIndex": 20, "tabIndex": 20,
"updaterOnly": true "updaterOnly": true
}, },
{
"id": "users",
"text": I18n.tr("Users"),
"icon": "manage_accounts",
"tabIndex": 35
},
{ {
"id": "window_rules", "id": "window_rules",
"text": I18n.tr("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 ignoreUnknownSignals: true
function onDeviceNameChanged(newDeviceName) { function onDeviceNameChanged(newDeviceName) {
if (root.expandedWidgetData && root.expandedWidgetData.id === "brightnessSlider") { if (!root.expandedWidgetData || root.expandedWidgetData.id !== "brightnessSlider") {
const widgets = SettingsData.controlCenterWidgets || []; return;
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();
}
} }
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 || "") property var widgetDef: root.model?.getWidgetForId(widgetData.id || "")
width: parent.width width: parent.width
height: 60 height: 60
iconBlinking: {
const id = widgetData.id || "";
if (id === "wifi")
return NetworkService.isWifiConnecting;
if (id === "bluetooth")
return BluetoothService.connecting;
return false;
}
iconName: { iconName: {
switch (widgetData.id || "") { switch (widgetData.id || "") {
case "wifi": case "wifi":
{ {
if (NetworkService.wifiToggling) if (NetworkService.wifiToggling)
return "sync"; return "sync";
if (NetworkService.isConnecting && !NetworkService.ethernetConnected)
return NetworkService.wifiSignalIcon;
const status = NetworkService.networkStatus; const status = NetworkService.networkStatus;
if (status === "ethernet") if (status === "ethernet")
@@ -360,6 +370,8 @@ Column {
{ {
if (NetworkService.wifiToggling) if (NetworkService.wifiToggling)
return NetworkService.wifiEnabled ? I18n.tr("Disabling WiFi...", "network status") : I18n.tr("Enabling WiFi...", "network status"); 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; const status = NetworkService.networkStatus;
if (status === "ethernet") if (status === "ethernet")
@@ -400,6 +412,8 @@ Column {
{ {
if (NetworkService.wifiToggling) if (NetworkService.wifiToggling)
return I18n.tr("Please wait...", "network status"); return I18n.tr("Please wait...", "network status");
if (NetworkService.isConnecting && !NetworkService.ethernetConnected)
return I18n.tr("Connecting...", "network status");
const status = NetworkService.networkStatus; const status = NetworkService.networkStatus;
if (status === "ethernet") if (status === "ethernet")
@@ -422,6 +436,8 @@ Column {
return I18n.tr("No adapters", "bluetooth status"); return I18n.tr("No adapters", "bluetooth status");
if (!BluetoothService.adapter || !BluetoothService.adapter.enabled) if (!BluetoothService.adapter || !BluetoothService.adapter.enabled)
return I18n.tr("Off", "bluetooth status"); return I18n.tr("Off", "bluetooth status");
if (BluetoothService.connecting)
return I18n.tr("Connecting...", "bluetooth status");
const primaryDevice = (() => { const primaryDevice = (() => {
if (!BluetoothService.adapter || !BluetoothService.adapter.devices) if (!BluetoothService.adapter || !BluetoothService.adapter.devices)
return null; return null;
@@ -23,79 +23,103 @@ Rectangle {
if (!screenName) if (!screenName)
return ""; return "";
const screen = Quickshell.screens.find(s => s.name === screenName); const screen = Quickshell.screens.find(s => s.name === screenName);
if (screen) { if (screen)
return SettingsData.getScreenDisplayName(screen); return SettingsData.getScreenDisplayName(screen);
} if (SettingsData.displayNameMode === "model" && screenModel && screenModel.length > 0)
if (SettingsData.displayNameMode === "model" && screenModel && screenModel.length > 0) {
return screenModel; return screenModel;
}
return screenName; return screenName;
} }
function resolveDeviceName() { function resolveCurrentDevice() {
if (!DisplayService.brightnessAvailable || !DisplayService.devices || DisplayService.devices.length === 0) { const devices = DisplayService.devices || [];
if (!DisplayService.brightnessAvailable || devices.length === 0)
return ""; return "";
}
const pinKey = getScreenPinKey(); const pinKey = getScreenPinKey();
if (pinKey.length > 0) { if (pinKey.length > 0) {
const pins = SettingsData.brightnessDevicePins || {}; const pins = SettingsData.brightnessDevicePins || {};
const pinnedDevice = pins[pinKey]; const pinnedDevice = pins[pinKey];
if (pinnedDevice && pinnedDevice.length > 0) { if (pinnedDevice && pinnedDevice.length > 0) {
const found = DisplayService.devices.find(dev => dev.name === pinnedDevice); const found = devices.find(d => d.name === pinnedDevice);
if (found) if (found)
return found.name; 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) { if (initialDeviceName && initialDeviceName.length > 0) {
const found = DisplayService.devices.find(dev => dev.name === initialDeviceName); const found = devices.find(d => d.name === initialDeviceName);
if (found) if (found)
return found.name; return found.name;
} }
const currentDeviceNameFromService = DisplayService.currentDevice; const backlight = devices.find(d => d.class === "backlight");
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");
if (backlight) if (backlight)
return backlight.name; return backlight.name;
const ddc = DisplayService.devices.find(d => d.class === "ddc"); const ddc = devices.find(d => d.class === "ddc");
if (ddc) if (ddc)
return ddc.name; 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: { Component.onCompleted: {
currentDeviceName = resolveDeviceName(); root.currentDeviceName = resolveCurrentDevice();
} }
property bool isPinnedToScreen: { function isDevicePinnedToScreen(deviceName) {
const pinKey = getScreenPinKey(); const pinKey = getScreenPinKey();
if (!pinKey || pinKey.length === 0) if (!pinKey || !deviceName)
return false; return false;
const pins = SettingsData.brightnessDevicePins || {}; const pins = SettingsData.brightnessDevicePins || {};
return pins[pinKey] === currentDeviceName; return pins[pinKey] === deviceName;
} }
function togglePinToScreen() { function togglePinForDevice(deviceName) {
const pinKey = getScreenPinKey(); const pinKey = getScreenPinKey();
if (!pinKey || pinKey.length === 0 || !currentDeviceName || currentDeviceName.length === 0) if (!pinKey || !deviceName)
return; return;
const pins = JSON.parse(JSON.stringify(SettingsData.brightnessDevicePins || {})); const pins = JSON.parse(JSON.stringify(SettingsData.brightnessDevicePins || {}));
if (pins[pinKey] === deviceName) {
if (isPinnedToScreen) {
delete pins[pinKey]; delete pins[pinKey];
} else { } else {
pins[pinKey] = currentDeviceName; pins[pinKey] = deviceName;
} }
SettingsData.set("brightnessDevicePins", pins); SettingsData.set("brightnessDevicePins", pins);
} }
@@ -153,18 +177,23 @@ Rectangle {
} }
Rectangle { Rectangle {
id: monitorHeader
width: parent.width width: parent.width
height: 40 height: 40
visible: screenName && screenName.length > 0 && DisplayService.devices && DisplayService.devices.length > 1 visible: screenName && screenName.length > 0 && DisplayService.devices && DisplayService.devices.length > 1
radius: Theme.cornerRadius radius: Theme.cornerRadius
color: Theme.withAlpha(Theme.surfaceContainerHighest, Theme.popupTransparency) color: Theme.withAlpha(Theme.surfaceContainerHighest, Theme.popupTransparency)
property bool currentDevicePinned: root.isDevicePinnedToScreen(currentDeviceName)
Item { Item {
anchors.fill: parent anchors.fill: parent
anchors.margins: Theme.spacingM anchors.margins: Theme.spacingM
Row { Row {
anchors.left: parent.left anchors.left: parent.left
anchors.right: globalPinButton.left
anchors.rightMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingM spacing: Theme.spacingM
@@ -180,47 +209,51 @@ Rectangle {
font.pixelSize: Theme.fontSizeMedium font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
elide: Text.ElideRight
width: parent.width - Theme.iconSize - Theme.spacingM
} }
} }
Rectangle { Rectangle {
id: globalPinButton
anchors.right: parent.right anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
width: pinRow.width + Theme.spacingS * 2 width: globalPinRow.width + Theme.spacingS * 2
height: 28 height: 28
radius: height / 2 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 { Row {
id: pinRow id: globalPinRow
anchors.centerIn: parent anchors.centerIn: parent
spacing: 4 spacing: 4
DankIcon { DankIcon {
name: isPinnedToScreen ? "push_pin" : "push_pin" name: "push_pin"
size: 16 size: 16
color: isPinnedToScreen ? Theme.primary : Theme.surfaceText color: monitorHeader.currentDevicePinned ? Theme.primary : Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
} }
StyledText { StyledText {
text: isPinnedToScreen ? I18n.tr("Pinned") : I18n.tr("Pin") text: monitorHeader.currentDevicePinned ? I18n.tr("Pinned") : I18n.tr("Pin")
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
color: isPinnedToScreen ? Theme.primary : Theme.surfaceText color: monitorHeader.currentDevicePinned ? Theme.primary : Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
} }
} }
DankRipple { DankRipple {
id: pinRipple id: globalPinRipple
cornerRadius: parent.radius cornerRadius: parent.radius
} }
MouseArea { MouseArea {
anchors.fill: parent anchors.fill: parent
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onPressed: mouse => pinRipple.trigger(mouse.x, mouse.y) enabled: currentDeviceName && currentDeviceName.length > 0
onClicked: root.togglePinToScreen() onPressed: mouse => globalPinRipple.trigger(mouse.x, mouse.y)
onClicked: root.togglePinForDevice(currentDeviceName)
} }
} }
} }
@@ -229,9 +262,17 @@ Rectangle {
Repeater { Repeater {
model: DisplayService.devices || [] model: DisplayService.devices || []
delegate: Rectangle { delegate: Rectangle {
id: deviceCard
required property var modelData required property var modelData
required property int index 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: { property real deviceBrightness: {
DisplayService.brightnessVersion; DisplayService.brightnessVersion;
return DisplayService.getDeviceBrightness(modelData.name); return DisplayService.getDeviceBrightness(modelData.name);
@@ -241,8 +282,8 @@ Rectangle {
height: 100 height: 100
radius: Theme.cornerRadius radius: Theme.cornerRadius
color: Theme.withAlpha(Theme.surfaceContainerHighest, Theme.popupTransparency) 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.color: selected ? Theme.primary : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
border.width: modelData.name === currentDeviceName ? 2 : 0 border.width: selected ? 2 : 0
Column { Column {
anchors.fill: parent anchors.fill: parent
@@ -251,10 +292,12 @@ Rectangle {
Item { Item {
width: parent.width width: parent.width
height: Math.max(deviceIconColumn.height, deviceInfoColumn.height, exponentControls.height) height: Math.max(deviceIconColumn.height, deviceInfoColumn.height, rightControls.height)
Row { Row {
anchors.left: parent.left anchors.left: parent.left
anchors.right: rightControls.left
anchors.rightMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingM spacing: Theme.spacingM
@@ -281,7 +324,7 @@ Rectangle {
} }
} }
size: Theme.iconSize size: Theme.iconSize
color: modelData.name === currentDeviceName ? Theme.primary : Theme.surfaceText color: deviceCard.selected ? Theme.primary : Theme.surfaceText
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
} }
@@ -296,7 +339,7 @@ Rectangle {
Column { Column {
id: deviceInfoColumn id: deviceInfoColumn
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
width: parent.parent.width - deviceIconColumn.width - exponentControls.width - Theme.spacingM * 3 width: parent.width - deviceIconColumn.width - Theme.spacingM
StyledText { StyledText {
text: { text: {
@@ -309,7 +352,7 @@ Rectangle {
} }
font.pixelSize: Theme.fontSizeMedium font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText color: Theme.surfaceText
font.weight: modelData.name === currentDeviceName ? Font.Medium : Font.Normal font.weight: deviceCard.selected ? Font.Medium : Font.Normal
elide: Text.ElideRight elide: Text.ElideRight
width: parent.width width: parent.width
horizontalAlignment: Text.AlignLeft horizontalAlignment: Text.AlignLeft
@@ -345,80 +388,107 @@ Rectangle {
} }
Row { Row {
id: exponentControls id: rightControls
width: 140
height: 28 height: 28
anchors.right: parent.right anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingXS spacing: Theme.spacingS
visible: SessionData.getBrightnessExponential(modelData.name)
z: 1 z: 1
StyledRect { Row {
width: 28 id: exponentControls
height: 28 height: 28
radius: Theme.cornerRadius spacing: Theme.spacingXS
color: Theme.withAlpha(Theme.surfaceContainerHighest, Theme.popupTransparency) visible: SessionData.getBrightnessExponential(modelData.name)
opacity: SessionData.getBrightnessExponent(modelData.name) > 1.0 ? 1.0 : 0.4
DankIcon { StyledRect {
anchors.centerIn: parent width: 28
name: "remove" height: 28
size: 14 radius: Theme.cornerRadius
color: Theme.surfaceText 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 { StyledRect {
stateColor: Theme.primary width: 50
cornerRadius: parent.radius height: 28
enabled: SessionData.getBrightnessExponent(modelData.name) > 1.0 radius: Theme.cornerRadius
onClicked: { color: Theme.withAlpha(Theme.surfaceContainerHighest, Theme.popupTransparency)
const current = SessionData.getBrightnessExponent(modelData.name); border.width: 0
const newValue = Math.max(1.0, Math.round((current - 0.1) * 10) / 10);
SessionData.setBrightnessExponent(modelData.name, newValue); 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 { StyledRect {
width: 50 id: pinButton
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 width: 28
height: 28 height: 28
radius: Theme.cornerRadius radius: Theme.cornerRadius
color: Theme.withAlpha(Theme.surfaceContainerHighest, Theme.popupTransparency) visible: root.screenName && root.screenName.length > 0 && DisplayService.devices && DisplayService.devices.length > 1
opacity: SessionData.getBrightnessExponent(modelData.name) < 2.5 ? 1.0 : 0.4 color: devicePinnedHere ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.16) : Theme.withAlpha(Theme.surfaceContainerHighest, Theme.popupTransparency)
DankIcon { DankIcon {
anchors.centerIn: parent anchors.centerIn: parent
name: "add" name: "push_pin"
size: 14 size: 14
color: Theme.surfaceText color: devicePinnedHere ? Theme.primary : Theme.surfaceText
} }
StateLayer { StateLayer {
stateColor: Theme.primary stateColor: Theme.primary
cornerRadius: parent.radius cornerRadius: parent.radius
enabled: SessionData.getBrightnessExponent(modelData.name) < 2.5 onClicked: root.togglePinForDevice(modelData.name)
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);
}
} }
} }
} }
@@ -474,22 +544,11 @@ Rectangle {
MouseArea { MouseArea {
anchors.fill: parent anchors.fill: parent
anchors.bottomMargin: 28 anchors.bottomMargin: 28
anchors.rightMargin: SessionData.getBrightnessExponential(modelData.name) ? 145 : 0 anchors.rightMargin: rightControls.width + Theme.spacingS
hoverEnabled: true hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onPressed: mouse => deviceRipple.trigger(mouse.x, mouse.y) onPressed: mouse => deviceRipple.trigger(mouse.x, mouse.y)
onClicked: { onClicked: root.selectDevice(modelData.name)
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);
}
} }
} }
} }
@@ -541,7 +541,11 @@ Rectangle {
return -1; return -1;
if (b.ssid === ssid) if (b.ssid === ssid)
return 1; 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; return sorted;
} }
@@ -1,4 +1,5 @@
import QtQuick import QtQuick
import Quickshell
import qs.Common import qs.Common
import qs.Services import qs.Services
import qs.Widgets import qs.Widgets
@@ -31,8 +32,10 @@ Row {
} }
if (screenName && screenName.length > 0) { 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 pins = SettingsData.brightnessDevicePins || {};
const pinnedDevice = pins[screenName]; const pinnedDevice = pins[pinKey];
if (pinnedDevice && pinnedDevice.length > 0) { if (pinnedDevice && pinnedDevice.length > 0) {
const found = DisplayService.devices.find(dev => dev.name === pinnedDevice); const found = DisplayService.devices.find(dev => dev.name === pinnedDevice);
if (found) { if (found) {
@@ -10,6 +10,7 @@ Rectangle {
property string iconName: "" property string iconName: ""
property color iconColor: Theme.surfaceText property color iconColor: Theme.surfaceText
property bool iconBlinking: false
property string primaryText: "" property string primaryText: ""
property string secondaryText: "" property string secondaryText: ""
property bool expanded: false property bool expanded: false
@@ -109,10 +110,16 @@ Rectangle {
} }
DankIcon { DankIcon {
id: pillIcon
anchors.centerIn: parent anchors.centerIn: parent
name: iconName name: iconName
size: Theme.iconSize size: Theme.iconSize
color: isActive ? _tileIconActive : _tileIconInactive color: isActive ? _tileIconActive : _tileIconInactive
DankBlink {
target: pillIcon
running: root.iconBlinking
}
} }
DankRipple { DankRipple {
+42 -11
View File
@@ -10,13 +10,15 @@ Item {
required property var axis required property var axis
required property var barConfig required property var barConfig
visible: !SettingsData.frameEnabled readonly property bool frameShapesBar: SettingsData.frameEnabled && barWindow.usesFrameBarChrome
visible: !frameShapesBar
anchors.fill: parent anchors.fill: parent
anchors.left: parent.left anchors.left: parent.left
anchors.top: parent.top 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.leftMargin: -(gothEnabled && axis.isVertical && axis.edge === "right" ? barWindow._wingR : 0)
anchors.rightMargin: -(gothEnabled && axis.isVertical && axis.edge === "left" ? 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) anchors.topMargin: -(gothEnabled && !axis.isVertical && axis.edge === "bottom" ? barWindow._wingR : 0)
@@ -39,11 +41,11 @@ Item {
} }
property real rt: { property real rt: {
if (SettingsData.frameEnabled) if (frameShapesBar)
return SettingsData.frameRounding; return SettingsData.frameRounding;
if (barConfig?.squareCorners ?? false) if (barConfig?.squareCorners ?? false)
return 0; return 0;
if (barWindow.hasMaximizedToplevel) if (barWindow.flattenForMaximizedWindow && barWindow.hasMaximizedToplevel)
return 0; return 0;
return Theme.cornerRadius; return Theme.cornerRadius;
} }
@@ -113,9 +115,32 @@ Item {
readonly property real shadowOffsetX: Theme.elevationOffsetXFor(hasPerBarOverride ? null : elevLevel, effectiveShadowDirection, shadowOffsetMagnitude) readonly property real shadowOffsetX: Theme.elevationOffsetXFor(hasPerBarOverride ? null : elevLevel, effectiveShadowDirection, shadowOffsetMagnitude)
readonly property real shadowOffsetY: Theme.elevationOffsetYFor(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 mainPath: {
readonly property string borderFullPath: generateBorderFullPath(width, height) frameShapesBar;
readonly property string borderEdgePath: generateBorderEdgePath(width, height) 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 mainPathCorrectShape: false
property bool borderFullPathCorrectShape: false property bool borderFullPathCorrectShape: false
property bool borderEdgePathCorrectShape: false property bool borderEdgePathCorrectShape: false
@@ -136,6 +161,12 @@ Item {
} }
} }
onFrameShapesBarChanged: {
mainPathCorrectShape = false;
borderFullPathCorrectShape = false;
borderEdgePathCorrectShape = false;
}
MouseArea { MouseArea {
anchors.fill: parent anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
@@ -259,7 +290,7 @@ Item {
h = h - wing; h = h - wing;
const r = wing; const r = wing;
const cr = rt; const cr = rt;
const crE = SettingsData.frameEnabled ? 0 : cr; const crE = frameShapesBar ? 0 : cr;
let d = `M ${crE} 0`; let d = `M ${crE} 0`;
d += ` L ${w - crE} 0`; d += ` L ${w - crE} 0`;
@@ -290,7 +321,7 @@ Item {
h = h - wing; h = h - wing;
const r = wing; const r = wing;
const cr = rt; const cr = rt;
const crE = SettingsData.frameEnabled ? 0 : cr; const crE = frameShapesBar ? 0 : cr;
let d = `M ${crE} ${fullH}`; let d = `M ${crE} ${fullH}`;
d += ` L ${w - crE} ${fullH}`; d += ` L ${w - crE} ${fullH}`;
@@ -320,7 +351,7 @@ Item {
w = w - wing; w = w - wing;
const r = wing; const r = wing;
const cr = rt; const cr = rt;
const crE = SettingsData.frameEnabled ? 0 : cr; const crE = frameShapesBar ? 0 : cr;
let d = `M 0 ${crE}`; let d = `M 0 ${crE}`;
d += ` L 0 ${h - crE}`; d += ` L 0 ${h - crE}`;
@@ -351,7 +382,7 @@ Item {
w = w - wing; w = w - wing;
const r = wing; const r = wing;
const cr = rt; const cr = rt;
const crE = SettingsData.frameEnabled ? 0 : cr; const crE = frameShapesBar ? 0 : cr;
let d = `M ${fullW} ${crE}`; let d = `M ${fullW} ${crE}`;
d += ` L ${fullW} ${h - 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 innerPadding: barConfig?.innerPadding ?? 4
readonly property real outlineThickness: (barConfig?.widgetOutlineEnabled ?? false) ? (barConfig?.widgetOutlineThickness ?? 1) : 0 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 _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 _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 bool _barIsVertical: _hasBarWindow ? barWindow.isVertical : false
readonly property string _barScreenName: _hasBarWindow ? (barWindow.screenName || "") : "" readonly property string _barScreenName: _hasBarWindow ? (barWindow.screenName || "") : ""
readonly property bool hasAdjacentTopBarLive: _hasBarWindow && barWindow.hasAdjacentTopBar readonly property bool hasAdjacentTopBarLive: _hasBarWindow && barWindow.hasAdjacentTopBar
@@ -47,22 +48,22 @@ Item {
_hadAdjacentRightBar = true _hadAdjacentRightBar = true
readonly property real _frameLeftInset: { readonly property real _frameLeftInset: {
if (!_hasBarWindow || !SettingsData.frameEnabled || _barIsVertical) if (!_hasBarWindow || !SettingsData.frameEnabled || !_usesFrameBarChrome || _barIsVertical)
return 0; return 0;
return hasAdjacentLeftBarLive ? SettingsData.frameBarSize : (_hadAdjacentLeftBar ? _frameEdgeFloorInset : 0); return hasAdjacentLeftBarLive ? SettingsData.frameBarSize : (_hadAdjacentLeftBar ? _frameEdgeFloorInset : 0);
} }
readonly property real _frameRightInset: { readonly property real _frameRightInset: {
if (!_hasBarWindow || !SettingsData.frameEnabled || _barIsVertical) if (!_hasBarWindow || !SettingsData.frameEnabled || !_usesFrameBarChrome || _barIsVertical)
return 0; return 0;
return hasAdjacentRightBarLive ? SettingsData.frameBarSize : (_hadAdjacentRightBar ? _frameEdgeFloorInset : 0); return hasAdjacentRightBarLive ? SettingsData.frameBarSize : (_hadAdjacentRightBar ? _frameEdgeFloorInset : 0);
} }
readonly property real _frameTopInset: { readonly property real _frameTopInset: {
if (!_hasBarWindow || !SettingsData.frameEnabled || !_barIsVertical) if (!_hasBarWindow || !SettingsData.frameEnabled || !_usesFrameBarChrome || !_barIsVertical)
return 0; return 0;
return hasAdjacentTopBarLive ? SettingsData.frameThickness : (_hadAdjacentTopBar ? _frameEdgeFloorInset : 0); return hasAdjacentTopBarLive ? SettingsData.frameThickness : (_hadAdjacentTopBar ? _frameEdgeFloorInset : 0);
} }
readonly property real _frameBottomInset: { readonly property real _frameBottomInset: {
if (!_hasBarWindow || !SettingsData.frameEnabled || !_barIsVertical) if (!_hasBarWindow || !SettingsData.frameEnabled || !_usesFrameBarChrome || !_barIsVertical)
return 0; return 0;
return hasAdjacentBottomBarLive ? SettingsData.frameThickness : (_hadAdjacentBottomBar ? _frameEdgeFloorInset : 0); return hasAdjacentBottomBarLive ? SettingsData.frameThickness : (_hadAdjacentBottomBar ? _frameEdgeFloorInset : 0);
} }
@@ -95,7 +96,7 @@ Item {
} }
Behavior on anchors.leftMargin { Behavior on anchors.leftMargin {
enabled: _animateFrameInsets && SettingsData.frameEnabled enabled: _animateFrameInsets && _usesFrameBarChrome
NumberAnimation { NumberAnimation {
duration: Theme.shortDuration duration: Theme.shortDuration
easing.type: Easing.OutCubic easing.type: Easing.OutCubic
@@ -103,7 +104,7 @@ Item {
} }
Behavior on anchors.rightMargin { Behavior on anchors.rightMargin {
enabled: _animateFrameInsets && SettingsData.frameEnabled enabled: _animateFrameInsets && _usesFrameBarChrome
NumberAnimation { NumberAnimation {
duration: Theme.shortDuration duration: Theme.shortDuration
easing.type: Easing.OutCubic easing.type: Easing.OutCubic
@@ -111,7 +112,7 @@ Item {
} }
Behavior on anchors.topMargin { Behavior on anchors.topMargin {
enabled: _animateFrameInsets && SettingsData.frameEnabled enabled: _animateFrameInsets && _usesFrameBarChrome
NumberAnimation { NumberAnimation {
duration: Theme.shortDuration duration: Theme.shortDuration
easing.type: Easing.OutCubic easing.type: Easing.OutCubic
@@ -119,7 +120,7 @@ Item {
} }
Behavior on anchors.bottomMargin { Behavior on anchors.bottomMargin {
enabled: _animateFrameInsets && SettingsData.frameEnabled enabled: _animateFrameInsets && _usesFrameBarChrome
NumberAnimation { NumberAnimation {
duration: Theme.shortDuration duration: Theme.shortDuration
easing.type: Easing.OutCubic easing.type: Easing.OutCubic
+33 -29
View File
@@ -108,6 +108,8 @@ PanelWindow {
triggerDashTab(2); triggerDashTab(2);
} }
readonly property bool usesOverlayLayer: CompositorService.framePeerSurfacesUseOverlayForScreen(barWindow.screen) || (barConfig?.useOverlayLayer ?? false)
readonly property var dBarLayer: { readonly property var dBarLayer: {
switch (Quickshell.env("DMS_DANKBAR_LAYER")) { switch (Quickshell.env("DMS_DANKBAR_LAYER")) {
case "bottom": case "bottom":
@@ -119,10 +121,7 @@ PanelWindow {
case "top": case "top":
return WlrLayer.Top; return WlrLayer.Top;
default: default:
// Elevate to Overlay when Frame is enabled so the bar stays above return barWindow.usesOverlayLayer ? WlrLayer.Overlay : WlrLayer.Top;
// 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;
} }
} }
@@ -152,6 +151,16 @@ PanelWindow {
onTriggered: barBlur.rebuild() onTriggered: barBlur.rebuild()
} }
Connections {
target: barWindow
function onUsesConnectedFrameChromeChanged() {
_blurRebuildTimer.restart();
}
function onUsesFrameBarChromeChanged() {
_blurRebuildTimer.restart();
}
}
Component { Component {
id: blurRegionComp id: blurRegionComp
Region {} Region {}
@@ -179,7 +188,7 @@ PanelWindow {
// In frame mode, FrameWindow owns the blur region for the entire screen edge // 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 // (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. // so that frameBlurEnabled acts as the single control for all blur in frame mode.
if (SettingsData.frameEnabled) if (SettingsData.frameEnabled && barWindow.usesFrameBarChrome)
return; return;
const widgets = barWindow._blurWidgetItems.filter(w => w && w.visible && w.width > 0 && w.height > 0); 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 color _surfaceContainer: Theme.surfaceContainer
readonly property string _barId: barConfig?.id ?? "default" readonly property string _barId: barConfig?.id ?? "default"
property real _backgroundAlpha: barConfig?.transparency ?? 1.0 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() { function _updateBackgroundAlpha() {
const live = SettingsData.barConfigs.find(c => c.id === _barId); const live = SettingsData.barConfigs.find(c => c.id === _barId);
@@ -316,16 +325,14 @@ PanelWindow {
property string screenName: modelData.name 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 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 property bool shouldHideForWindows: false
function _updateHasMaximizedToplevel() { function _updateHasMaximizedToplevel() {
@@ -427,7 +434,7 @@ PanelWindow {
shouldHideForWindows = filtered.length > 0; 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 { Behavior on effectiveSpacing {
enabled: barWindow.visible enabled: barWindow.visible
@@ -438,7 +445,7 @@ PanelWindow {
} }
readonly property int notificationCount: NotificationService.notifications.length 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 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) 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.left: !isVertical ? true : (barPos === SettingsData.Position.Left)
anchors.right: !isVertical ? true : (barPos === SettingsData.Position.Right) 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 { Item {
id: inputMask id: inputMask
@@ -647,9 +654,9 @@ PanelWindow {
readonly property bool inOverviewWithShow: CompositorService.isNiri && NiriService.inOverview && barWindow.effectiveOpenOnOverview readonly property bool inOverviewWithShow: CompositorService.isNiri && NiriService.inOverview && barWindow.effectiveOpenOnOverview
readonly property bool effectiveVisible: (barConfig?.visible ?? true) || inOverviewWithShow 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: { x: {
if (!axis.isVertical) { if (!axis.isVertical) {
@@ -719,7 +726,7 @@ PanelWindow {
item: clickThroughEnabled ? null : inputMask item: clickThroughEnabled ? null : inputMask
Region { 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, "x": 0,
"y": 0, "y": 0,
"w": 0, "w": 0,
@@ -732,7 +739,7 @@ PanelWindow {
} }
Region { 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, "x": 0,
"y": 0, "y": 0,
"w": 0, "w": 0,
@@ -745,7 +752,7 @@ PanelWindow {
} }
Region { 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, "x": 0,
"y": 0, "y": 0,
"w": 0, "w": 0,
@@ -826,9 +833,6 @@ PanelWindow {
} }
property bool reveal: { property bool reveal: {
if (barWindow.hasFullscreenToplevel)
return false;
const inOverviewWithShow = CompositorService.isNiri && NiriService.inOverview && barWindow.effectiveOpenOnOverview; const inOverviewWithShow = CompositorService.isNiri && NiriService.inOverview && barWindow.effectiveOpenOnOverview;
if (inOverviewWithShow) if (inOverviewWithShow)
return true; return true;
@@ -897,9 +901,9 @@ PanelWindow {
bottom: barWindow.isVertical ? parent.bottom : undefined bottom: barWindow.isVertical ? parent.bottom : undefined
} }
readonly property bool inOverview: CompositorService.isNiri && NiriService.inOverview && barWindow.effectiveOpenOnOverview 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 acceptedButtons: Qt.NoButton
enabled: (barConfig?.autoHide ?? false) && !inOverview && !barWindow.hasFullscreenToplevel enabled: (barConfig?.autoHide ?? false) && !inOverview
Item { Item {
id: topBarContainer id: topBarContainer
@@ -131,9 +131,19 @@ BasePill {
function getNetworkIconColor() { function getNetworkIconColor() {
if (NetworkService.wifiToggling) if (NetworkService.wifiToggling)
return Theme.primary; return Theme.primary;
if (NetworkService.isConnecting && !NetworkService.ethernetConnected)
return Theme.primary;
return NetworkService.networkStatus !== "disconnected" ? Theme.primary : Theme.surfaceText; 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() { function getVolumeIconName() {
if (!AudioService.sink?.audio) if (!AudioService.sink?.audio)
return "volume_up"; return "volume_up";
@@ -485,6 +495,7 @@ BasePill {
} }
DankIcon { DankIcon {
id: vIconOnlyItem
anchors.centerIn: parent anchors.centerIn: parent
visible: !verticalGroupItem.modelData.composite visible: !verticalGroupItem.modelData.composite
name: { name: {
@@ -515,7 +526,7 @@ BasePill {
case "vpn": case "vpn":
return NetworkService.vpnConnected ? Theme.primary : Theme.surfaceText; return NetworkService.vpnConnected ? Theme.primary : Theme.surfaceText;
case "bluetooth": case "bluetooth":
return BluetoothService.connected ? Theme.primary : Theme.surfaceText; return (BluetoothService.connected || BluetoothService.connecting) ? Theme.primary : Theme.surfaceText;
case "battery": case "battery":
return root.getBatteryIconColor(); return root.getBatteryIconColor();
case "printer": case "printer":
@@ -524,6 +535,11 @@ BasePill {
return Theme.widgetIconColor; return Theme.widgetIconColor;
} }
} }
DankBlink {
target: vIconOnlyItem
running: root.getIconBlinking(verticalGroupItem.modelData.id)
}
} }
DankIcon { DankIcon {
@@ -687,7 +703,7 @@ BasePill {
case "vpn": case "vpn":
return NetworkService.vpnConnected ? Theme.primary : Theme.surfaceText; return NetworkService.vpnConnected ? Theme.primary : Theme.surfaceText;
case "bluetooth": case "bluetooth":
return BluetoothService.connected ? Theme.primary : Theme.surfaceText; return (BluetoothService.connected || BluetoothService.connecting) ? Theme.primary : Theme.surfaceText;
case "battery": case "battery":
return root.getBatteryIconColor(); return root.getBatteryIconColor();
case "printer": case "printer":
@@ -696,6 +712,11 @@ BasePill {
return Theme.widgetIconColor; return Theme.widgetIconColor;
} }
} }
DankBlink {
target: iconOnlyItem
running: root.getIconBlinking(horizontalGroupItem.modelData.id)
}
} }
Rectangle { Rectangle {
@@ -1,5 +1,6 @@
import QtQuick import QtQuick
import QtQuick.Effects import QtQuick.Effects
import QtQuick.Layouts
import Quickshell import Quickshell
import Quickshell.Wayland import Quickshell.Wayland
import Quickshell.Widgets import Quickshell.Widgets
@@ -14,9 +15,20 @@ BasePill {
property var widgetData: null property var widgetData: null
property bool compactMode: widgetData?.focusedWindowCompactMode !== undefined ? widgetData.focusedWindowCompactMode : SettingsData.focusedWindowCompactMode property bool compactMode: widgetData?.focusedWindowCompactMode !== undefined ? widgetData.focusedWindowCompactMode : SettingsData.focusedWindowCompactMode
property int availableWidth: 400 readonly property int maxWidth: {
readonly property int maxNormalWidth: 456 const size = widgetData?.focusedWindowSize !== undefined ? widgetData.focusedWindowSize : SettingsData.focusedWindowSize;
readonly property int maxCompactWidth: 288 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 Toplevel activeWindow: null
property var activeDesktopEntry: null property var activeDesktopEntry: null
property bool isHovered: mouseArea.containsMouse property bool isHovered: mouseArea.containsMouse
@@ -171,8 +183,7 @@ BasePill {
return 0; return 0;
if (root.isVerticalOrientation) if (root.isVerticalOrientation)
return root.widgetThickness - root.horizontalPadding * 2; return root.widgetThickness - root.horizontalPadding * 2;
const baseWidth = contentRow.implicitWidth; return contentRow.implicitWidth;
return compactMode ? Math.min(baseWidth, maxCompactWidth - root.horizontalPadding * 2) : Math.min(baseWidth, maxNormalWidth - root.horizontalPadding * 2);
} }
implicitHeight: root.widgetThickness - root.horizontalPadding * 2 implicitHeight: root.widgetThickness - root.horizontalPadding * 2
clip: false clip: false
@@ -222,7 +233,7 @@ BasePill {
color: Theme.widgetTextColor color: Theme.widgetTextColor
} }
Row { RowLayout {
id: contentRow id: contentRow
anchors.centerIn: parent anchors.centerIn: parent
spacing: Theme.spacingS spacing: Theme.spacingS
@@ -231,24 +242,23 @@ BasePill {
StyledText { StyledText {
id: appText id: appText
text: { text: {
if (!activeWindow || !activeWindow.appId) if (compactMode || !activeWindow || !activeWindow.appId)
return ""; return "";
return Paths.getAppName(activeWindow.appId, activeDesktopEntry); return Paths.getAppName(activeWindow.appId, activeDesktopEntry);
} }
font.pixelSize: Theme.barTextSize(root.barThickness, root.barConfig?.fontScale, root.barConfig?.maximizeWidgetText) font.pixelSize: Theme.barTextSize(root.barThickness, root.barConfig?.fontScale, root.barConfig?.maximizeWidgetText)
color: Theme.widgetTextColor color: Theme.widgetTextColor
anchors.verticalCenter: parent.verticalCenter
elide: Text.ElideRight elide: Text.ElideRight
maximumLineCount: 1 maximumLineCount: 1
width: Math.min(implicitWidth, compactMode ? 80 : 180) Layout.maximumWidth: compactMode ? 80 : 180
visible: !compactMode && text.length > 0 visible: text.length > 0
} }
StyledText { StyledText {
text: "•" id: appSeparator
text: compactMode ? "" : "•"
font.pixelSize: Theme.barTextSize(root.barThickness, root.barConfig?.fontScale, root.barConfig?.maximizeWidgetText) font.pixelSize: Theme.barTextSize(root.barThickness, root.barConfig?.fontScale, root.barConfig?.maximizeWidgetText)
color: Theme.outlineButton color: Theme.outlineButton
anchors.verticalCenter: parent.verticalCenter
visible: !compactMode && appText.text && titleText.text visible: !compactMode && appText.text && titleText.text
} }
@@ -276,10 +286,9 @@ BasePill {
} }
font.pixelSize: Theme.barTextSize(root.barThickness, root.barConfig?.fontScale, root.barConfig?.maximizeWidgetText) font.pixelSize: Theme.barTextSize(root.barThickness, root.barConfig?.fontScale, root.barConfig?.maximizeWidgetText)
color: Theme.widgetTextColor color: Theme.widgetTextColor
anchors.verticalCenter: parent.verticalCenter
elide: Text.ElideRight elide: Text.ElideRight
maximumLineCount: 1 maximumLineCount: 1
width: Math.min(implicitWidth, compactMode ? 280 : 250) Layout.maximumWidth: maxWidth - appText.implicitWidth - appSeparator.implicitWidth
visible: text.length > 0 visible: text.length > 0
} }
} }
@@ -11,13 +11,14 @@ BasePill {
id: root 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 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() { function resolveNotepadInstance() {
if (typeof notepadSlideoutVariants === "undefined" || !notepadSlideoutVariants || !notepadSlideoutVariants.instances) { if (typeof notepadSlideoutVariants === "undefined" || !notepadSlideoutVariants || !notepadSlideoutVariants.instances) {
return null; return null;
} }
const targetScreen = focusedScreenName; const targetScreen = targetScreenName;
if (targetScreen) { if (targetScreen) {
for (var i = 0; i < notepadSlideoutVariants.instances.length; i++) { for (var i = 0; i < notepadSlideoutVariants.instances.length; i++) {
var slideout = notepadSlideoutVariants.instances[i]; var slideout = notepadSlideoutVariants.instances[i];
@@ -34,6 +35,12 @@ BasePill {
readonly property bool isActive: notepadInstance?.isVisible ?? false readonly property bool isActive: notepadInstance?.isVisible ?? false
property bool isAutoHideBar: false property bool isAutoHideBar: false
function prepareNotepadInstance(instance) {
if (instance)
instance.triggerUsesOverlayLayer = root.barUsesOverlayLayer;
return instance;
}
readonly property real minTooltipY: { readonly property real minTooltipY: {
if (!parentScreen || !(axis?.isVertical ?? false)) { if (!parentScreen || !(axis?.isVertical ?? false)) {
return 0; return 0;
@@ -68,8 +75,9 @@ BasePill {
function openTabByIndex(tabIndex) { function openTabByIndex(tabIndex) {
if (tabIndex < 0) if (tabIndex < 0)
return; return;
if (root.notepadInstance && typeof root.notepadInstance.show === "function") { const instance = prepareNotepadInstance(root.notepadInstance);
root.notepadInstance.show(); if (instance && typeof instance.show === "function") {
instance.show();
} }
Qt.callLater(() => { Qt.callLater(() => {
NotepadStorageService.switchToTab(tabIndex); NotepadStorageService.switchToTab(tabIndex);
@@ -77,8 +85,9 @@ BasePill {
} }
function openNewNote() { function openNewNote() {
if (root.notepadInstance && typeof root.notepadInstance.show === "function") { const instance = prepareNotepadInstance(root.notepadInstance);
root.notepadInstance.show(); if (instance && typeof instance.show === "function") {
instance.show();
} }
Qt.callLater(() => { Qt.callLater(() => {
NotepadStorageService.createNewTab(); NotepadStorageService.createNewTab();
@@ -138,7 +147,7 @@ BasePill {
openContextMenu(); openContextMenu();
return; return;
} }
const inst = root.notepadInstance; const inst = prepareNotepadInstance(root.notepadInstance);
if (inst) { if (inst) {
inst.toggle(); inst.toggle();
} }
@@ -978,7 +978,7 @@ BasePill {
visible: root.useOverflowPopup && root.menuOpen visible: root.useOverflowPopup && root.menuOpen
screen: root.parentScreen screen: root.parentScreen
WlrLayershell.layer: WlrLayershell.Top WlrLayershell.layer: root.barUsesOverlayLayer ? WlrLayershell.Overlay : WlrLayershell.Top
WlrLayershell.exclusiveZone: -1 WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: { WlrLayershell.keyboardFocus: {
if (!root.menuOpen) if (!root.menuOpen)
@@ -1446,7 +1446,7 @@ BasePill {
WlrLayershell.namespace: "dms:tray-menu-window" WlrLayershell.namespace: "dms:tray-menu-window"
visible: menuRoot.showMenu && (menuRoot.trayItem?.hasMenu ?? false) visible: menuRoot.showMenu && (menuRoot.trayItem?.hasMenu ?? false)
screen: menuRoot.parentScreen screen: menuRoot.parentScreen
WlrLayershell.layer: WlrLayershell.Top WlrLayershell.layer: root.barUsesOverlayLayer ? WlrLayershell.Overlay : WlrLayershell.Top
WlrLayershell.exclusiveZone: -1 WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: { WlrLayershell.keyboardFocus: {
if (!menuRoot.showMenu) if (!menuRoot.showMenu)
+36 -106
View File
@@ -20,16 +20,16 @@ Variants {
WindowBlur { WindowBlur {
targetWindow: dock targetWindow: dock
blurEnabled: dock.effectiveBlurEnabled && !SettingsData.connectedFrameModeActive blurEnabled: dock.effectiveBlurEnabled && !dock.usesConnectedFrameChrome
blurX: dockBackground.x + dockContainer.x + dockMouseArea.x + dockCore.x + dockSlide.x blurX: dockBackground.x + dockContainer.x + dockMouseArea.x + dockCore.x + dockSlide.x
blurY: dockBackground.y + dockContainer.y + dockMouseArea.y + dockCore.y + dockSlide.y blurY: dockBackground.y + dockContainer.y + dockMouseArea.y + dockCore.y + dockSlide.y
blurWidth: dock.hasApps && dock.reveal ? dockBackground.width : 0 blurWidth: dock.hasApps && dock.reveal ? dockBackground.width : 0
blurHeight: dock.hasApps && dock.reveal ? dockBackground.height : 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.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 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 bool connectedBarActiveOnEdge: dockGeometry.connectedBarActiveOnEdge
readonly property real connectedJoinInset: dockGeometry.connectedJoinInset readonly property real connectedJoinInset: dockGeometry.connectedJoinInset
readonly property real dockFrameInset: dockGeometry.frameInset readonly property real dockFrameInset: dockGeometry.frameInset
readonly property real surfaceRadius: Theme.connectedSurfaceRadius readonly property real surfaceRadius: usesConnectedFrameChrome ? Theme.connectedSurfaceRadius : Theme.cornerRadius
readonly property color surfaceColor: Theme.isConnectedEffect ? Theme.connectedSurfaceColor : Theme.withAlpha(Theme.surfaceContainer, backgroundTransparency) readonly property color surfaceColor: usesConnectedFrameChrome ? Theme.connectedSurfaceColor : Theme.withAlpha(Theme.surfaceContainer, backgroundTransparency)
readonly property color surfaceBorderColor: Theme.isConnectedEffect ? "transparent" : BlurService.borderColor readonly property color surfaceBorderColor: usesConnectedFrameChrome ? "transparent" : BlurService.borderColor
readonly property real surfaceBorderWidth: Theme.isConnectedEffect ? 0 : BlurService.borderWidth readonly property real surfaceBorderWidth: usesConnectedFrameChrome ? 0 : BlurService.borderWidth
readonly property real surfaceTopLeftRadius: Theme.isConnectedEffect && (SettingsData.dockPosition === SettingsData.Position.Top || SettingsData.dockPosition === SettingsData.Position.Left) ? 0 : surfaceRadius readonly property real surfaceTopLeftRadius: usesConnectedFrameChrome && (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 surfaceTopRightRadius: usesConnectedFrameChrome && (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 surfaceBottomLeftRadius: usesConnectedFrameChrome && (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 surfaceBottomRightRadius: usesConnectedFrameChrome && (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 horizontalConnectorExtent: usesConnectedFrameChrome && !isVertical ? Theme.connectedCornerRadius : 0
readonly property real verticalConnectorExtent: Theme.isConnectedEffect && isVertical ? Theme.connectedCornerRadius : 0 readonly property real verticalConnectorExtent: usesConnectedFrameChrome && isVertical ? Theme.connectedCornerRadius : 0
readonly property int hasApps: dockApps.implicitWidth > 0 || dockApps.implicitHeight > 0 readonly property int hasApps: dockApps.implicitWidth > 0 || dockApps.implicitHeight > 0
@@ -149,7 +149,6 @@ Variants {
edge: dock.connectedBarSide edge: dock.connectedBarSide
dockVisible: dock.visible dockVisible: dock.visible
autoHide: dock.autoHide autoHide: dock.autoHide
hasFullscreenToplevel: dock.hasFullscreenToplevel
iconSize: dock.widgetHeight iconSize: dock.widgetHeight
spacing: SettingsData.dockSpacing spacing: SettingsData.dockSpacing
borderThickness: dock.borderThickness borderThickness: dock.borderThickness
@@ -176,25 +175,13 @@ Variants {
} }
readonly property string _dockScreenName: dock.modelData ? dock.modelData.name : (dock.screen ? dock.screen.name : "") readonly property string _dockScreenName: dock.modelData ? dock.modelData.name : (dock.screen ? dock.screen.name : "")
readonly property bool hasFullscreenToplevel: { readonly property bool usesConnectedFrameChrome: CompositorService.usesConnectedFrameChromeForScreen(dock._dockScreenName)
if (!SettingsData.dockHideOnFullscreen) readonly property bool usesOverlayLayer: CompositorService.framePeerSurfacesUseOverlayForScreen(dock._dockScreenName) || SettingsData.dockUseOverlayLayer
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);
}
function _syncDockChromeState() { function _syncDockChromeState() {
if (!dock._dockScreenName) if (!dock._dockScreenName)
return; return;
if (!SettingsData.connectedFrameModeActive) { if (!dock.usesConnectedFrameChrome) {
ConnectedModeState.clearDockState(dock._dockScreenName); ConnectedModeState.clearDockState(dock._dockScreenName);
return; return;
} }
@@ -212,19 +199,19 @@ Variants {
} }
function _syncDockSlide() { function _syncDockSlide() {
if (!dock._dockScreenName || !SettingsData.connectedFrameModeActive) if (!dock._dockScreenName || !dock.usesConnectedFrameChrome)
return; return;
ConnectedModeState.setDockSlide(dock._dockScreenName, dockSlide.x, dockSlide.y); ConnectedModeState.setDockSlide(dock._dockScreenName, dockSlide.x, dockSlide.y);
} }
DeferredAction { DeferredAction {
id: dockSlideSync id: dockSlideSync
enabled: SettingsData.connectedFrameModeActive enabled: dock.usesConnectedFrameChrome
onTriggered: dock._syncDockSlide() onTriggered: dock._syncDockSlide()
} }
function _queueSlideSync() { function _queueSlideSync() {
if (!SettingsData.connectedFrameModeActive) if (!dock.usesConnectedFrameChrome)
return; return;
dockSlideSync.schedule(); dockSlideSync.schedule();
} }
@@ -304,65 +291,10 @@ Variants {
return false; return false;
} }
// Hyprland implementation // Hyprland implementation (current workspace + visible special workspaces)
Hyprland.focusedWorkspace; Hyprland.focusedWorkspace;
const filtered = CompositorService.filterCurrentWorkspace(CompositorService.sortedToplevels, screenName); Hyprland.toplevels;
return CompositorService.hyprlandDockOverlapForSmartAutoHide(screenName, SettingsData.dockPosition, dockThickness, screenWidth, screenHeight);
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;
} }
Timer { Timer {
@@ -383,9 +315,6 @@ Variants {
if (_modalRetractActive) if (_modalRetractActive)
return false; return false;
if (dock.hasFullscreenToplevel)
return false;
if (CompositorService.isNiri && NiriService.inOverview && SettingsData.dockOpenOnOverview) { if (CompositorService.isNiri && NiriService.inOverview && SettingsData.dockOpenOnOverview) {
return true; return true;
} }
@@ -421,7 +350,7 @@ Variants {
onVisibleChanged: dock._syncDockChromeState() onVisibleChanged: dock._syncDockChromeState()
onHasAppsChanged: dock._syncDockChromeState() onHasAppsChanged: dock._syncDockChromeState()
onConnectedBarSideChanged: dock._syncDockChromeState() onConnectedBarSideChanged: dock._syncDockChromeState()
onHasFullscreenToplevelChanged: dock._syncDockChromeState() onUsesConnectedFrameChromeChanged: dock._syncDockChromeState()
Connections { Connections {
target: SettingsData target: SettingsData
@@ -680,7 +609,7 @@ Variants {
return 0; return 0;
if (dock.reveal) if (dock.reveal)
return 0; return 0;
if (Theme.isConnectedEffect) { if (dock.usesConnectedFrameChrome) {
const retractDist = dockBackground.width + SettingsData.dockSpacing + 10; const retractDist = dockBackground.width + SettingsData.dockSpacing + 10;
return SettingsData.dockPosition === SettingsData.Position.Right ? retractDist : -retractDist; return SettingsData.dockPosition === SettingsData.Position.Right ? retractDist : -retractDist;
} }
@@ -696,7 +625,7 @@ Variants {
return 0; return 0;
if (dock.reveal) if (dock.reveal)
return 0; return 0;
if (Theme.isConnectedEffect) { if (dock.usesConnectedFrameChrome) {
const retractDist = dockBackground.height + SettingsData.dockSpacing + 10; const retractDist = dockBackground.height + SettingsData.dockSpacing + 10;
return SettingsData.dockPosition === SettingsData.Position.Bottom ? retractDist : -retractDist; return SettingsData.dockPosition === SettingsData.Position.Bottom ? retractDist : -retractDist;
} }
@@ -711,9 +640,9 @@ Variants {
Behavior on x { Behavior on x {
NumberAnimation { NumberAnimation {
id: slideXAnimation id: slideXAnimation
duration: Theme.isConnectedEffect ? Theme.variantDuration(Theme.popoutAnimationDuration, dock.reveal) : Theme.shortDuration duration: dock.usesConnectedFrameChrome ? Theme.variantDuration(Theme.popoutAnimationDuration, dock.reveal) : Theme.shortDuration
easing.type: Theme.isConnectedEffect ? Easing.BezierSpline : Easing.OutCubic easing.type: dock.usesConnectedFrameChrome ? Easing.BezierSpline : Easing.OutCubic
easing.bezierCurve: Theme.isConnectedEffect ? (dock.reveal ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve) : [] easing.bezierCurve: dock.usesConnectedFrameChrome ? (dock.reveal ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve) : []
onRunningChanged: if (!running) onRunningChanged: if (!running)
dock._syncDockChromeState() dock._syncDockChromeState()
} }
@@ -722,9 +651,9 @@ Variants {
Behavior on y { Behavior on y {
NumberAnimation { NumberAnimation {
id: slideYAnimation id: slideYAnimation
duration: Theme.isConnectedEffect ? Theme.variantDuration(Theme.popoutAnimationDuration, dock.reveal) : Theme.shortDuration duration: dock.usesConnectedFrameChrome ? Theme.variantDuration(Theme.popoutAnimationDuration, dock.reveal) : Theme.shortDuration
easing.type: Theme.isConnectedEffect ? Easing.BezierSpline : Easing.OutCubic easing.type: dock.usesConnectedFrameChrome ? Easing.BezierSpline : Easing.OutCubic
easing.bezierCurve: Theme.isConnectedEffect ? (dock.reveal ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve) : [] easing.bezierCurve: dock.usesConnectedFrameChrome ? (dock.reveal ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve) : []
onRunningChanged: if (!running) onRunningChanged: if (!running)
dock._syncDockChromeState() dock._syncDockChromeState()
} }
@@ -756,12 +685,12 @@ Variants {
height: implicitHeight height: implicitHeight
// Avoid an offscreen texture seam where the connected dock meets the frame. // Avoid an offscreen texture seam where the connected dock meets the frame.
layer.enabled: !Theme.isConnectedEffect layer.enabled: !usesConnectedFrameChrome
clip: false clip: false
Rectangle { Rectangle {
anchors.fill: parent anchors.fill: parent
visible: !SettingsData.connectedFrameModeActive && !(Theme.isConnectedEffect && dock.reveal) visible: !usesConnectedFrameChrome && (!SettingsData.connectedFrameModeActive || dock.reveal)
color: dock.surfaceColor color: dock.surfaceColor
topLeftRadius: dock.surfaceTopLeftRadius topLeftRadius: dock.surfaceTopLeftRadius
topRightRadius: dock.surfaceTopRightRadius topRightRadius: dock.surfaceTopRightRadius
@@ -771,7 +700,7 @@ Variants {
Rectangle { Rectangle {
anchors.fill: parent anchors.fill: parent
visible: !SettingsData.connectedFrameModeActive && !(Theme.isConnectedEffect && dock.reveal) visible: !usesConnectedFrameChrome && (!SettingsData.connectedFrameModeActive || dock.reveal)
color: "transparent" color: "transparent"
topLeftRadius: dock.surfaceTopLeftRadius topLeftRadius: dock.surfaceTopLeftRadius
topRightRadius: dock.surfaceTopRightRadius topRightRadius: dock.surfaceTopRightRadius
@@ -807,7 +736,7 @@ Variants {
y: dockBackground.y - borderThickness y: dockBackground.y - borderThickness
width: dockBackground.width + borderThickness * 2 width: dockBackground.width + borderThickness * 2
height: dockBackground.height + borderThickness * 2 height: dockBackground.height + borderThickness * 2
visible: SettingsData.dockBorderEnabled && dock.hasApps && !Theme.isConnectedEffect visible: SettingsData.dockBorderEnabled && dock.hasApps && !usesConnectedFrameChrome
preferredRendererType: Shape.CurveRenderer preferredRendererType: Shape.CurveRenderer
readonly property real borderThickness: Math.max(1, dock.borderThickness) readonly property real borderThickness: Math.max(1, dock.borderThickness)
@@ -883,6 +812,7 @@ Variants {
isVertical: dock.isVertical isVertical: dock.isVertical
dockScreen: dock.screen dockScreen: dock.screen
iconSize: dock.widgetHeight iconSize: dock.widgetHeight
usesOverlayLayer: dock.usesOverlayLayer
} }
} }
} }
+1
View File
@@ -15,6 +15,7 @@ Item {
property bool isVertical: false property bool isVertical: false
property var dockScreen: null property var dockScreen: null
property real iconSize: 40 property real iconSize: 40
property bool usesOverlayLayer: false
property int draggedIndex: -1 property int draggedIndex: -1
property int dropTargetIndex: -1 property int dropTargetIndex: -1
property bool suppressShiftAnimation: false property bool suppressShiftAnimation: false
+11 -11
View File
@@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound
import QtQuick import QtQuick
import qs.Common import qs.Common
import qs.Services
QtObject { QtObject {
id: root id: root
@@ -10,7 +11,6 @@ QtObject {
property string edge: "bottom" property string edge: "bottom"
property bool dockVisible: false property bool dockVisible: false
property bool autoHide: false property bool autoHide: false
property bool hasFullscreenToplevel: false
property real iconSize: 40 property real iconSize: 40
property real spacing: 4 property real spacing: 4
property real borderThickness: 0 property real borderThickness: 0
@@ -23,14 +23,14 @@ QtObject {
return Math.round(value * dpr) / dpr; return Math.round(value * dpr) / dpr;
} }
readonly property bool frameExclusionActive: SettingsData.frameEnabled && !!screen && SettingsData.isScreenInPreferences(screen, SettingsData.frameScreenPreferences) readonly property bool frameExclusionActive: CompositorService.frameWindowVisibleForScreen(screen)
readonly property bool connectedMode: Theme.isConnectedEffect readonly property bool usesConnectedFrameChrome: CompositorService.usesConnectedFrameChromeForScreen(screen)
readonly property bool connectedBarActiveOnEdge: connectedMode && !!screen && SettingsData.getActiveBarEdgesForScreen(screen).includes(edge) readonly property bool connectedBarActiveOnEdge: usesConnectedFrameChrome && !!screen && SettingsData.getActiveBarEdgesForScreen(screen).includes(edge)
readonly property real connectedJoinInset: { readonly property real connectedJoinInset: {
if (connectedMode) if (usesConnectedFrameChrome)
return connectedBarActiveOnEdge ? SettingsData.frameBarSize : SettingsData.frameThickness; return connectedBarActiveOnEdge ? SettingsData.frameBarSize : SettingsData.frameThickness;
if (SettingsData.frameEnabled) if (frameExclusionActive)
return SettingsData.frameEdgeInsetForSide(screen, edge); return SettingsData.frameEdgeInsetForSide(screen, edge);
return 0; return 0;
} }
@@ -38,15 +38,15 @@ QtObject {
readonly property real frameInset: { readonly property real frameInset: {
if (!frameExclusionActive) if (!frameExclusionActive)
return 0; return 0;
if (connectedMode) if (usesConnectedFrameChrome)
return connectedJoinInset; return connectedJoinInset;
return SettingsData.frameThickness; return SettingsData.frameThickness;
} }
readonly property real effectiveMargin: connectedMode ? 0 : margin readonly property real effectiveMargin: usesConnectedFrameChrome ? 0 : margin
readonly property real visualOffset: connectedMode ? 0 : offset readonly property real visualOffset: usesConnectedFrameChrome ? 0 : offset
readonly property real reserveOffset: 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 bodyEdgeMargin: frameInset + joinedEdgeMargin
readonly property real bodyThickness: iconSize + spacing * 2 + borderThickness * 2 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 // Frame/bar edge exclusions already reserve the edge itself, so the dock
// reservation covers only the dock body and user offset beyond that edge. // reservation covers only the dock body and user offset beyond that edge.
readonly property real reserveZone: px(bodyThickness + reserveOffset + effectiveMargin) 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) if (wasDragging || mouse.button !== Qt.LeftButton)
return; return;
PopoutService.toggleDankLauncherV2(); PopoutService.toggleDankLauncherV2(dockApps?.usesOverlayLayer ?? false);
} }
onPositionChanged: mouse => { onPositionChanged: mouse => {
if (longPressing && !dragging) { if (longPressing && !dragging) {
+2 -1
View File
@@ -4,6 +4,7 @@ import QtQuick
import Quickshell import Quickshell
import Quickshell.Wayland import Quickshell.Wayland
import qs.Common import qs.Common
import qs.Services
Scope { Scope {
id: root id: root
@@ -18,7 +19,7 @@ Scope {
// One thin invisible PanelWindow per edge. // One thin invisible PanelWindow per edge.
// Skips any edge where a bar already provides its own exclusiveZone. // 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 { Loader {
active: root.screenEnabled && !root.barEdges.includes("top") active: root.screenEnabled && !root.barEdges.includes("top")
+4 -3
View File
@@ -17,8 +17,9 @@ PanelWindow {
required property var targetScreen required property var targetScreen
screen: targetScreen screen: targetScreen
visible: _frameActive readonly property bool _frameVisible: CompositorService.frameWindowVisibleForScreen(win.targetScreen)
updatesEnabled: _frameActive visible: win._frameVisible
updatesEnabled: win._frameVisible
WlrLayershell.namespace: "dms:frame" WlrLayershell.namespace: "dms:frame"
WlrLayershell.layer: WlrLayer.Top WlrLayershell.layer: WlrLayer.Top
@@ -52,7 +53,7 @@ PanelWindow {
readonly property var _notifState: ConnectedModeState.notificationStates[win._screenName] || ConnectedModeState.emptyNotificationState readonly property var _notifState: ConnectedModeState.notificationStates[win._screenName] || ConnectedModeState.emptyNotificationState
readonly property var _modalState: ConnectedModeState.modalStates[win._screenName] || ConnectedModeState.emptyModalState 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: { readonly property string _barSide: {
const edges = win.barEdges; const edges = win.barEdges;
if (edges.includes("top")) if (edges.includes("top"))
+3 -2
View File
@@ -97,7 +97,8 @@ sudo rpm -ivh x86_64/dms-greeter-*.rpm
``` ```
The package automatically: 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 - Sets up directories and permissions
- Configures greetd with auto-detected compositor - Configures greetd with auto-detected compositor
- Applies SELinux contexts - Applies SELinux contexts
@@ -178,7 +179,7 @@ sudo systemctl enable greetd
#### Legacy installation (deprecated) #### Legacy installation (deprecated)
If you prefer the old method with separate shell scripts and config files: 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` 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 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` 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 env = DMS_RUN_GREETER,1
exec = sh -c "qs -p _DMS_PATH_; hyprctl dispatch exit" 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