1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-08-03 03:59:11 -04:00

Compare commits

...

20 Commits

Author SHA1 Message Date
purian23 3420d2be93 add(systemd): dms-tray-watcher service & commands for early tray registration 2026-07-06 14:52:36 -04:00
bbedward 405ea708b3 wallpaper: optimize caching and performance of wallpaper tab 2026-07-06 13:52:31 -04:00
bbedward cc0bec2682 dash: fix clock index hardcoded at tab 0 2026-07-06 13:06:30 -04:00
bbedward 240adfa28f calendar: support tasks/VTODO from dank calendar 2026-07-06 12:40:41 -04:00
bbedward c175f4823a hyprland: workaround for focus re-grabbing a closing surface
related #2577
2026-07-06 11:45:38 -04:00
bbedward 35613ebba8 worksapces: attempt to fix hyprland workspace stability in ScriptModel
related #2754
2026-07-06 11:27:49 -04:00
purian23 583e46f2f0 fix(imageCache): improve cache management & file handling 2026-07-06 00:35:00 -04:00
purian23 590555dfc6 fix(dankdash): update tabIndex reordering for media & weather widgets
- Fixes #2762
2026-07-06 00:33:42 -04:00
bbedward c11169eb4c matugen: only write accent-color if changed 2026-07-05 23:13:49 -04:00
bbedward bddcdb3f99 core/completions: handle QS timeouts instead of hanging
related #1404
2026-07-05 23:12:30 -04:00
purian23 6ec67cefd0 add(settings): enable focused window icon display by default 2026-07-05 22:45:35 -04:00
David Mireles 8d9d7ff0ef fix(notifications): dismiss popup reliably on user-initiated close paths (#2761)
The notification popup's close button (X), action button clicks, and the
cardClick body's else branch all set notificationData.popup = false
directly. This relies on wrapperConn.onPopupChanged firing startExit(),
which can be interrupted by four races:

1. enterDelay (160 ms Timer) starts notificationData.timer after the
   user clicks, on an already-orphan wrapper.
2. The dismiss Timer keeps running post-click and flips popup again.
3. wrapperConn.target is set imperatively in onNotificationDataChanged
   (line 293) and may be stale after NotificationPopupManager._sync()
   reorders wrappers.
4. exiting or _isDestroying stuck true gates wrapperConn.enabled.

Introduce a single helper dismissPopupReliably() that stops the timer,
sets popup = false, and kicks off startExit() via Qt.callLater as a
belt-and-suspenders fallback. Apply to the three vulnerable handlers.

This matches the existing upstream pattern used in the hover, contextMenu,
and Component.onDestruction paths (which all stop the timer before
mutating popup state).

Closes #2760
2026-07-05 22:01:31 -04:00
Scott McKendry acc39ceb16 fix(settings): missing vpn confirm delete modal (#2759) 2026-07-05 22:00:57 -04:00
bbedward 43863a86fb workspaces: use stable references for placeholders in ScriptModel to fix
animation jitter
related #2754
2026-07-05 21:59:52 -04:00
bbedward 36ad34a555 keybinds: fix lua parsing on Hyprland for non-static configuration
fixes #2753
2026-07-05 17:16:55 -04:00
bbedward 78e823e23a workspaces: skip placeholders in findClosestWorkspace
related #2754
2026-07-05 11:26:38 -04:00
bbedward b6a27dc713 wallpaper: rewrite CachingImage and make it work with file browser
fixes #2756
2026-07-05 11:17:21 -04:00
bbedward 79dfd34ca2 control center: fix height expansion animation being out of sync 2026-07-05 10:42:15 -04:00
bbedward eedba0e8c9 vpn: fix tls openvpn connection types
related #1344
2026-07-05 01:35:01 -04:00
bbedward 2b12292895 Revert "dankbar: keep blur enabled at opacity-0"
This reverts commit 8a5e7c78fb.

fixes #2750
2026-07-05 00:20:31 -04:00
54 changed files with 1697 additions and 607 deletions
+17
View File
@@ -0,0 +1,17 @@
[Unit]
Description=DMS Status Notifier Watcher (early tray)
# The tray watcher starts in parallel with the compositor and owns org.kde.StatusNotifierWatcher before
# graphical-session.target, i.e. before any XDG autostart app launches.
PartOf=graphical-session.target
Before=graphical-session.target
[Service]
Type=dbus
BusName=org.kde.StatusNotifierWatcher
ExecStart=/usr/bin/dms tray-watcher
Restart=on-failure
RestartSec=1
Slice=session.slice
[Install]
WantedBy=graphical-session.target
+1
View File
@@ -774,5 +774,6 @@ func getCommonCommands() []*cobra.Command {
trashCmd, trashCmd,
systemCmd, systemCmd,
switchUserCmd, switchUserCmd,
trayWatcherCmd,
} }
} }
+24
View File
@@ -0,0 +1,24 @@
package main
import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/traywatcher"
"github.com/spf13/cobra"
)
var trayWatcherCmd = &cobra.Command{
Use: "tray-watcher",
Short: "Run a minimal StatusNotifierWatcher for early tray registration",
Long: `Run a minimal org.kde.StatusNotifierWatcher daemon.
Started early in the session (Before=graphical-session.target via
dms-tray-watcher.service), it lets tray apps launched by XDG autostart
register their items before the shell finishes loading, and keeps them
registered across shell restarts. The shell's tray host picks items up from
this watcher; if it is not running, the shell's built-in watcher takes over.`,
Run: func(cmd *cobra.Command, args []string) {
if err := traywatcher.Run(); err != nil {
log.Fatalf("%v", err)
}
},
}
+9 -2
View File
@@ -582,7 +582,11 @@ func runShellDaemon(session bool) {
} }
var qsHasAnyDisplay = sync.OnceValue(func() bool { var qsHasAnyDisplay = sync.OnceValue(func() bool {
out, err := exec.Command("qs", "ipc", "--help").Output() ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "qs", "ipc", "--help")
cmd.WaitDelay = 500 * time.Millisecond
out, err := cmd.Output()
if err != nil { if err != nil {
return false return false
} }
@@ -642,7 +646,10 @@ func getShellIPCCompletions(args []string, _ string) []string {
return nil return nil
} }
cmdArgs := append(baseArgs, "show") cmdArgs := append(baseArgs, "show")
cmd := exec.Command("qs", cmdArgs...) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "qs", cmdArgs...)
cmd.WaitDelay = 500 * time.Millisecond
var targets ipcTargets var targets ipcTargets
if output, err := cmd.Output(); err == nil { if output, err := cmd.Output(); err == nil {
+3 -2
View File
@@ -880,8 +880,9 @@ func (cd *ConfigDeployer) transformNiriConfigForNonSystemd(config, terminalComma
config = regexp.MustCompile(`environment \{[^}]*\}`).ReplaceAllString(config, envVars) config = regexp.MustCompile(`environment \{[^}]*\}`).ReplaceAllString(config, envVars)
spawnDms := `spawn-at-startup "dms" "run"` // Watcher spawns first so it owns the SNI name before dms/tray apps start.
if !strings.Contains(config, spawnDms) { spawnDms := "spawn-at-startup \"dms\" \"tray-watcher\"\nspawn-at-startup \"dms\" \"run\""
if !strings.Contains(config, `spawn-at-startup "dms" "run"`) {
// Insert spawn-at-startup for dms after the environment block // Insert spawn-at-startup for dms after the environment block
envBlockEnd := regexp.MustCompile(`environment \{[^}]*\}`) envBlockEnd := regexp.MustCompile(`environment \{[^}]*\}`)
if loc := envBlockEnd.FindStringIndex(config); loc != nil { if loc := envBlockEnd.FindStringIndex(config); loc != nil {
+12
View File
@@ -520,9 +520,21 @@ func TestHyprlandConfigStructure(t *testing.T) {
assert.Contains(t, HyprlandLuaConfig, "input =") assert.Contains(t, HyprlandLuaConfig, "input =")
} }
// In non-systemd mode dms is launched from the compositor config, so the tray
// watcher must be launched there too, before dms, to own the SNI name first.
func TestNonSystemdLaunchesTrayWatcherBeforeShell(t *testing.T) {
hypr := transformHyprlandLuaForNonSystemd(HyprlandLuaConfig, "ghostty")
assert.Contains(t, hypr, "hl.exec_cmd(\"dms tray-watcher\")\n\thl.exec_cmd(\"dms run\")")
niri := (&ConfigDeployer{}).transformNiriConfigForNonSystemd(NiriConfig, "ghostty")
assert.Contains(t, niri, "spawn-at-startup \"dms\" \"tray-watcher\"\nspawn-at-startup \"dms\" \"run\"")
}
func TestMangoConfigStructure(t *testing.T) { func TestMangoConfigStructure(t *testing.T) {
assert.Contains(t, MangoConfig, "exec-once=dms run") assert.Contains(t, MangoConfig, "exec-once=dms run")
assert.NotContains(t, MangoConfig, "exec_once=dms run") assert.NotContains(t, MangoConfig, "exec_once=dms run")
// Tray watcher must start before the shell so it owns the SNI name first.
assert.Contains(t, MangoConfig, "exec-once=dms tray-watcher\nexec-once=dms run")
assert.Contains(t, MangoConfig, "source=./dms/binds.conf") assert.Contains(t, MangoConfig, "source=./dms/binds.conf")
assert.Contains(t, MangoBindsConfig, "bind=SUPER,H,focusdir,left") assert.Contains(t, MangoBindsConfig, "bind=SUPER,H,focusdir,left")
assert.Contains(t, MangoBindsConfig, "bind=SUPER,J,focusdir,down") assert.Contains(t, MangoBindsConfig, "bind=SUPER,J,focusdir,down")
+3 -1
View File
@@ -7,7 +7,9 @@ env=XDG_SESSION_TYPE,wayland
# exec-once runs only at startup. Do NOT use exec= for the shell: mango re-runs # exec-once runs only at startup. Do NOT use exec= for the shell: mango re-runs
# every exec= on each config reload, and DMS reloads the config, which would # every exec= on each config reload, and DMS reloads the config, which would
# spawn a new shell on every reload. # spawn a new shell on every reload. The tray watcher starts first so it owns
# the SNI name before dms and the tray apps come up.
exec-once=dms tray-watcher
exec-once=dms run exec-once=dms run
source=./dms/colors.conf source=./dms/colors.conf
+1
View File
@@ -116,6 +116,7 @@ func transformHyprlandLuaForNonSystemd(config, terminalCommand string) string {
`hl.env("QT_QPA_PLATFORMTHEME_QT6", "gtk3")` + "\n" + `hl.env("QT_QPA_PLATFORMTHEME_QT6", "gtk3")` + "\n" +
fmt.Sprintf(`hl.env("TERMINAL", %s)`, strconv.Quote(terminalCommand)) + "\n\n" + fmt.Sprintf(`hl.env("TERMINAL", %s)`, strconv.Quote(terminalCommand)) + "\n\n" +
`hl.on("hyprland.start", function()` + "\n" + `hl.on("hyprland.start", function()` + "\n" +
` hl.exec_cmd("dms tray-watcher")` + "\n" +
` hl.exec_cmd("dms run")` + "\n" + ` hl.exec_cmd("dms run")` + "\n" +
`end)` + "\n" + `end)` + "\n" +
hyprlandStartupEnd hyprlandStartupEnd
+4 -2
View File
@@ -587,13 +587,15 @@ TERMINAL=%s
} }
func (b *BaseDistribution) EnableDMSService(ctx context.Context, wm deps.WindowManager) error { func (b *BaseDistribution) EnableDMSService(ctx context.Context, wm deps.WindowManager) error {
// Pull in the tray watcher alongside dms; its Before=graphical-session.target
// ordering makes it claim the SNI name before autostart apps start.
switch wm { switch wm {
case deps.WindowManagerNiri: case deps.WindowManagerNiri:
if err := exec.CommandContext(ctx, "systemctl", "--user", "add-wants", "niri.service", "dms").Run(); err != nil { if err := exec.CommandContext(ctx, "systemctl", "--user", "add-wants", "niri.service", "dms", "dms-tray-watcher").Run(); err != nil {
b.log("Warning: failed to add dms as a want for niri.service") b.log("Warning: failed to add dms as a want for niri.service")
} }
case deps.WindowManagerHyprland: case deps.WindowManagerHyprland:
if err := exec.CommandContext(ctx, "systemctl", "--user", "add-wants", "hyprland-session.target", "dms").Run(); err != nil { if err := exec.CommandContext(ctx, "systemctl", "--user", "add-wants", "hyprland-session.target", "dms", "dms-tray-watcher").Run(); err != nil {
b.log("Warning: failed to add dms as a want for hyprland-session.target") b.log("Warning: failed to add dms as a want for hyprland-session.target")
} }
} }
+1 -1
View File
@@ -1154,7 +1154,7 @@ func readLuaOrHyprlangOverride(path string) (map[string]*hyprlandOverrideBind, e
if err != nil { if err != nil {
return nil, err return nil, err
} }
lines := strings.Split(string(data), "\n") lines := expandLuaConfigLines(strings.Split(string(data), "\n"))
parser := NewHyprlandParser("") parser := NewHyprlandParser("")
pendingUnbinds := make(map[string]string) pendingUnbinds := make(map[string]string)
for _, line := range lines { for _, line := range lines {
@@ -0,0 +1,414 @@
package providers
import (
"maps"
"regexp"
"strconv"
"strings"
)
// Lua configs can express binds dynamically: variables (mainMod .. " + C"),
// tostring() calls, and numeric for loops (workspace binds). This resolves such
// expressions to literal key combos so the static bind parser can read them.
const luaMaxLoopIterations = 1000
var (
luaAssignRE = regexp.MustCompile(`^(?:local\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$`)
luaForRE = regexp.MustCompile(`^for\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(-?\d+)\s*,\s*(-?\d+)\s*(?:,\s*(-?\d+)\s*)?do\b(.*)$`)
luaTostringRE = regexp.MustCompile(`to(?:string|number)\s*\(\s*("(?:\\.|[^"])*"|'(?:\\.|[^'])*'|-?\d+(?:\.\d+)?)\s*\)`)
luaBlockOpenRE = regexp.MustCompile(`\b(?:function|for|while|if)\b`)
luaBlockCloseRE = regexp.MustCompile(`\bend\b`)
luaNumberRE = regexp.MustCompile(`^-?\d+(?:\.\d+)?$`)
)
type luaVarEnv map[string]string
type luaForHeader struct {
varName string
start int
stop int
step int
inline string
}
func expandLuaConfigLines(lines []string) []string {
return expandLuaBlock(lines, luaVarEnv{})
}
func expandLuaBlock(lines []string, env luaVarEnv) []string {
out := make([]string, 0, len(lines))
for i := 0; i < len(lines); i++ {
code := strings.TrimSpace(luaStripLineComment(lines[i]))
if name, value, ok := parseLuaStringAssignment(code, env); ok {
env[name] = value
out = append(out, lines[i])
continue
}
header, ok := parseLuaForHeader(code)
if !ok {
out = append(out, resolveLuaDynamicLine(lines[i], env))
continue
}
body, consumed, complete := collectLuaForBody(header, lines, i)
if !complete {
out = append(out, resolveLuaDynamicLine(lines[i], env))
continue
}
out = append(out, expandLuaForLoop(header, body, env)...)
i = consumed
}
return out
}
func parseLuaStringAssignment(code string, env luaVarEnv) (name, value string, ok bool) {
m := luaAssignRE.FindStringSubmatch(code)
if m == nil {
return "", "", false
}
value, ok = evalLuaConcat(m[2], env)
if !ok {
return "", "", false
}
return m[1], value, true
}
func parseLuaForHeader(code string) (luaForHeader, bool) {
m := luaForRE.FindStringSubmatch(code)
if m == nil {
return luaForHeader{}, false
}
start, _ := strconv.Atoi(m[2])
stop, _ := strconv.Atoi(m[3])
step := 1
if m[4] != "" {
step, _ = strconv.Atoi(m[4])
}
if step == 0 {
return luaForHeader{}, false
}
return luaForHeader{varName: m[1], start: start, stop: stop, step: step, inline: strings.TrimSpace(m[5])}, true
}
func collectLuaForBody(header luaForHeader, lines []string, headerIdx int) (body []string, consumed int, complete bool) {
depth := 1
if header.inline != "" {
delta := luaBlockDelta(header.inline)
if depth+delta <= 0 {
if stmt := strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(header.inline), "end")); stmt != "" {
body = append(body, stmt)
}
return body, headerIdx, true
}
depth += delta
body = append(body, header.inline)
}
for j := headerIdx + 1; j < len(lines); j++ {
delta := luaBlockDelta(lines[j])
if depth+delta <= 0 {
return body, j, true
}
depth += delta
body = append(body, lines[j])
}
return nil, headerIdx, false
}
func expandLuaForLoop(header luaForHeader, body []string, env luaVarEnv) []string {
var out []string
inRange := func(v int) bool {
if header.step > 0 {
return v <= header.stop
}
return v >= header.stop
}
count := 0
for v := header.start; inRange(v); v += header.step {
if count++; count > luaMaxLoopIterations {
break
}
value := strconv.Itoa(v)
iterLines := make([]string, len(body))
for k, bl := range body {
iterLines[k] = substituteLuaIdent(bl, header.varName, value)
}
out = append(out, expandLuaBlock(iterLines, cloneLuaEnv(env))...)
}
return out
}
func luaBlockDelta(line string) int {
masked := luaMaskStrings(line)
return len(luaBlockOpenRE.FindAllString(masked, -1)) - len(luaBlockCloseRE.FindAllString(masked, -1))
}
func resolveLuaDynamicLine(line string, env luaVarEnv) string {
if !strings.Contains(line, "hl.bind") && !strings.Contains(line, "hl.unbind") {
return line
}
line = normalizeLuaToString(line)
return rewriteLuaBindKeyArg(line, env)
}
func normalizeLuaToString(line string) string {
return luaTostringRE.ReplaceAllStringFunc(line, func(m string) string {
inner := luaTostringRE.FindStringSubmatch(m)[1]
if inner[0] == '"' || inner[0] == '\'' {
return inner
}
return strconv.Quote(inner)
})
}
func rewriteLuaBindKeyArg(line string, env luaVarEnv) string {
for _, fn := range []string{"hl.bind", "hl.unbind"} {
idx := strings.Index(line, fn)
if idx < 0 {
continue
}
open := skipLuaWS(line, idx+len(fn))
if open >= len(line) || line[open] != '(' {
continue
}
argStart := skipLuaWS(line, open+1)
expr, end, ok := parseLuaFirstArgExpr(line, argStart)
if !ok || isLuaPlainStringArg(expr) {
continue
}
value, ok := evalLuaConcat(expr, env)
if !ok {
continue
}
return line[:argStart] + strconv.Quote(value) + line[end:]
}
return line
}
func evalLuaConcat(expr string, env luaVarEnv) (string, bool) {
parts := splitLuaConcat(expr)
var sb strings.Builder
for _, part := range parts {
value, ok := evalLuaOperand(part, env)
if !ok {
return "", false
}
sb.WriteString(value)
}
return sb.String(), true
}
func evalLuaOperand(op string, env luaVarEnv) (string, bool) {
op = strings.TrimSpace(op)
if op == "" {
return "", false
}
switch op[0] {
case '"', '\'':
s, next, ok := parseLuaStringLiteral(op, 0)
return s, next == len(op) && ok
}
if luaNumberRE.MatchString(op) {
return op, true
}
if inner, ok := luaUnwrapCall(op, "tostring"); ok {
return evalLuaConcat(inner, env)
}
if inner, ok := luaUnwrapCall(op, "tonumber"); ok {
return evalLuaConcat(inner, env)
}
if value, ok := env[op]; ok {
return value, true
}
return "", false
}
func splitLuaConcat(expr string) []string {
var parts []string
parenDepth, braceDepth, bracketDepth := 0, 0, 0
inStr := byte(0)
esc := false
start := 0
for i := 0; i < len(expr); i++ {
c := expr[i]
if inStr != 0 {
switch {
case esc:
esc = false
case c == '\\' && inStr == '"':
esc = true
case c == inStr:
inStr = 0
}
continue
}
switch c {
case '"', '\'':
inStr = c
case '(':
parenDepth++
case ')':
if parenDepth > 0 {
parenDepth--
}
case '{':
braceDepth++
case '}':
if braceDepth > 0 {
braceDepth--
}
case '[':
bracketDepth++
case ']':
if bracketDepth > 0 {
bracketDepth--
}
case '.':
if parenDepth == 0 && braceDepth == 0 && bracketDepth == 0 && i+1 < len(expr) && expr[i+1] == '.' {
parts = append(parts, expr[start:i])
i++
start = i + 1
}
}
}
return append(parts, expr[start:])
}
func substituteLuaIdent(line, name, value string) string {
if !strings.Contains(line, name) {
return line
}
var sb strings.Builder
inStr := byte(0)
esc := false
for i := 0; i < len(line); {
c := line[i]
if inStr != 0 {
sb.WriteByte(c)
switch {
case esc:
esc = false
case c == '\\' && inStr == '"':
esc = true
case c == inStr:
inStr = 0
}
i++
continue
}
if c == '"' || c == '\'' {
inStr = c
sb.WriteByte(c)
i++
continue
}
if isLuaIdentStart(c) {
j := i + 1
for j < len(line) && isLuaIdentByte(line[j]) {
j++
}
word := line[i:j]
if word == name && (i == 0 || line[i-1] != '.') {
sb.WriteString(value)
} else {
sb.WriteString(word)
}
i = j
continue
}
sb.WriteByte(c)
i++
}
return sb.String()
}
func luaMaskStrings(line string) string {
b := []byte(line)
inStr := byte(0)
esc := false
for i := 0; i < len(b); i++ {
c := b[i]
if inStr != 0 {
wasEnd := !esc && c == inStr
esc = !esc && c == '\\' && inStr == '"'
b[i] = ' '
if wasEnd {
inStr = 0
}
continue
}
switch c {
case '"', '\'':
inStr = c
b[i] = ' '
case '-':
if i+1 < len(b) && b[i+1] == '-' {
for ; i < len(b); i++ {
b[i] = ' '
}
}
}
}
return string(b)
}
func luaStripLineComment(line string) string {
inStr := byte(0)
esc := false
for i := 0; i+1 < len(line); i++ {
c := line[i]
if inStr != 0 {
switch {
case esc:
esc = false
case c == '\\' && inStr == '"':
esc = true
case c == inStr:
inStr = 0
}
continue
}
switch c {
case '"', '\'':
inStr = c
case '-':
if line[i+1] == '-' {
return line[:i]
}
}
}
return line
}
func luaUnwrapCall(op, fn string) (string, bool) {
op = strings.TrimSpace(op)
if !strings.HasPrefix(op, fn) {
return "", false
}
rest := strings.TrimSpace(op[len(fn):])
if !strings.HasPrefix(rest, "(") || !strings.HasSuffix(rest, ")") {
return "", false
}
return rest[1 : len(rest)-1], true
}
func isLuaPlainStringArg(expr string) bool {
expr = strings.TrimSpace(expr)
if expr == "" || (expr[0] != '"' && expr[0] != '\'') {
return false
}
_, next, ok := parseLuaStringLiteral(expr, 0)
return ok && next == len(expr)
}
func isLuaIdentStart(c byte) bool {
return c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
}
func cloneLuaEnv(env luaVarEnv) luaVarEnv {
clone := make(luaVarEnv, len(env))
maps.Copy(clone, env)
return clone
}
@@ -0,0 +1,134 @@
package providers
import (
"strings"
"testing"
)
func TestExpandLuaConfigLinesVariableConcat(t *testing.T) {
lines := []string{
`local mainMod = "SUPER"`,
`hl.bind(mainMod .. " + C", hl.dsp.window.close())`,
`hl.bind(mainMod .. " + H", hl.dsp.focus({direction = "l"}))`,
`hl.bind("ALT + TAB", hl.dsp.window.cycle_next({}))`,
}
got := expandLuaConfigLines(lines)
want := []string{
`hl.bind("SUPER + C",`,
`hl.bind("SUPER + H",`,
`hl.bind("ALT + TAB",`,
}
joined := strings.Join(got, "\n")
for _, w := range want {
if !strings.Contains(joined, w) {
t.Errorf("expanded output missing %q\n---\n%s", w, joined)
}
}
}
func TestExpandLuaConfigLinesForLoop(t *testing.T) {
lines := []string{
`local mainMod = "SUPER"`,
`for i = 1, 3 do`,
` hl.bind(mainMod .. " + " .. i, hl.dsp.focus({workspace = tostring(i)}))`,
` hl.bind(mainMod .. " SHIFT + " .. i, hl.dsp.window.move({workspace = tostring(i)}))`,
`end`,
}
got := strings.Join(expandLuaConfigLines(lines), "\n")
for _, w := range []string{
`hl.bind("SUPER + 1",`,
`hl.bind("SUPER + 2",`,
`hl.bind("SUPER + 3",`,
`hl.bind("SUPER SHIFT + 3",`,
`{workspace = "1"}`,
} {
if !strings.Contains(got, w) {
t.Errorf("expanded loop missing %q\n---\n%s", w, got)
}
}
}
func TestParseLuaLinesDynamicBinds(t *testing.T) {
content := strings.Join([]string{
`local mainMod = "SUPER"`,
`hl.bind(mainMod .. " + C", hl.dsp.window.close())`,
`hl.bind("ALT + TAB", hl.dsp.window.cycle_next({}))`,
`for i = 1, 2 do`,
` hl.bind(mainMod .. " + " .. i, hl.dsp.focus({workspace = tostring(i)}))`,
`end`,
}, "\n")
parser := NewHyprlandParser("")
section, err := parser.parseLuaLines(content, "", "test.lua", "")
if err != nil {
t.Fatalf("parseLuaLines: %v", err)
}
keys := map[string]*HyprlandKeyBinding{}
for i := range section.Keybinds {
kb := &section.Keybinds[i]
keys[parser.formatBindKey(kb)] = kb
}
for _, want := range []string{"SUPER+C", "ALT+TAB", "SUPER+1", "SUPER+2"} {
if _, ok := keys[want]; !ok {
t.Errorf("missing bind %q; got %v", want, keysList(keys))
}
}
if kb := keys["SUPER+C"]; kb != nil && kb.Dispatcher != "killactive" {
t.Errorf("SUPER+C dispatcher = %q, want killactive", kb.Dispatcher)
}
if kb := keys["SUPER+1"]; kb != nil {
if kb.Dispatcher != "workspace" || kb.Params != "1" {
t.Errorf("SUPER+1 = %q %q, want workspace 1", kb.Dispatcher, kb.Params)
}
}
}
func keysList(m map[string]*HyprlandKeyBinding) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
func TestEvalLuaConcat(t *testing.T) {
env := luaVarEnv{"mainMod": "SUPER", "i": "5"}
tests := []struct {
expr string
want string
ok bool
}{
{`mainMod .. " + C"`, "SUPER + C", true},
{`mainMod .. " + " .. i`, "SUPER + 5", true},
{`mainMod .. " + " .. tostring(i)`, "SUPER + 5", true},
{`"ALT + TAB"`, "ALT + TAB", true},
{`mainMod .. someFunc()`, "", false},
{`unknownVar .. "x"`, "", false},
}
for _, tt := range tests {
got, ok := evalLuaConcat(tt.expr, env)
if ok != tt.ok || (ok && got != tt.want) {
t.Errorf("evalLuaConcat(%q) = %q,%v want %q,%v", tt.expr, got, ok, tt.want, tt.ok)
}
}
}
func TestSubstituteLuaIdent(t *testing.T) {
tests := []struct {
line, name, value, want string
}{
{`hl.bind(m .. " + " .. i, x)`, "i", "3", `hl.bind(m .. " + " .. 3, x)`},
{`hl.dsp.exec("light -i")`, "i", "3", `hl.dsp.exec("light -i")`},
{`foo.i`, "i", "3", `foo.i`},
{`tostring(i)`, "i", "3", `tostring(3)`},
}
for _, tt := range tests {
if got := substituteLuaIdent(tt.line, tt.name, tt.value); got != tt.want {
t.Errorf("substituteLuaIdent(%q,%q,%q) = %q, want %q", tt.line, tt.name, tt.value, got, tt.want)
}
}
}
@@ -623,7 +623,7 @@ func (p *HyprlandParser) parseLuaLines(content string, baseDir, absPath, section
prevSource := p.currentSource prevSource := p.currentSource
p.currentSource = absPath p.currentSource = absPath
lines := strings.Split(content, "\n") lines := expandLuaConfigLines(strings.Split(content, "\n"))
boundInFile := make(map[string]bool) boundInFile := make(map[string]bool)
for _, line := range lines { for _, line := range lines {
trimmed := strings.TrimSpace(line) trimmed := strings.TrimSpace(line)
+3
View File
@@ -1056,6 +1056,9 @@ func closestAdwaitaAccent(primaryHex string) string {
func syncAccentColor(primaryHex string) { func syncAccentColor(primaryHex string) {
accent := closestAdwaitaAccent(primaryHex) accent := closestAdwaitaAccent(primaryHex)
if cur, err := utils.GsettingsGet("org.gnome.desktop.interface", "accent-color"); err == nil && strings.Trim(cur, "'") == accent {
return
}
log.Infof("Setting GNOME accent color: %s", accent) log.Infof("Setting GNOME accent color: %s", accent)
if err := utils.GsettingsSet("org.gnome.desktop.interface", "accent-color", accent); err != nil { if err := utils.GsettingsSet("org.gnome.desktop.interface", "accent-color", accent); err != nil {
log.Warnf("Failed to set accent-color: %v", err) log.Warnf("Failed to set accent-color: %v", err)
@@ -545,12 +545,18 @@ func (a *SecretAgent) GetSecrets(
out[settingName] = sec out[settingName] = sec
} }
if settingName == "vpn" && a.backend != nil && !isPKCS11 && (vpnUsername != "" || reply.Save) { if settingName == "vpn" && a.backend != nil && !isPKCS11 && (vpnUsername != "" || reply.Save) {
pw := reply.Secrets["password"] secrets := make(map[string]string)
for k, v := range reply.Secrets {
if k != "username" {
secrets[k] = v
}
}
a.backend.pendingVPNSaveMu.Lock() a.backend.pendingVPNSaveMu.Lock()
a.backend.pendingVPNSave = &pendingVPNCredentials{ a.backend.pendingVPNSave = &pendingVPNCredentials{
ConnectionPath: string(path), ConnectionPath: string(path),
Username: vpnUsername, Username: vpnUsername,
Password: pw, Password: reply.Secrets["password"],
Secrets: secrets,
SavePassword: reply.Save, SavePassword: reply.Save,
} }
a.backend.pendingVPNSaveMu.Unlock() a.backend.pendingVPNSaveMu.Unlock()
@@ -790,7 +796,23 @@ func inferVPNFields(conn map[string]nmVariantMap, vpnService string) []string {
fields = []string{"username", "password"} fields = []string{"username", "password"}
} }
case strings.Contains(vpnService, "openvpn"): case strings.Contains(vpnService, "openvpn"):
if connType == "password" || connType == "password-tls" { switch connType {
case "tls":
// Cert auth wants the private-key passphrase, not a password.
if secretFlagsNotRequired(dataMap["cert-pass-flags"]) {
return nil
}
return []string{"cert-pass"}
case "password-tls":
fields = []string{}
if dataMap["username"] == "" {
fields = append(fields, "username")
}
fields = append(fields, "password")
if !secretFlagsNotRequired(dataMap["cert-pass-flags"]) {
fields = append(fields, "cert-pass")
}
case "password":
if dataMap["username"] == "" { if dataMap["username"] == "" {
fields = []string{"username", "password"} fields = []string{"username", "password"}
} }
@@ -805,6 +827,19 @@ func inferVPNFields(conn map[string]nmVariantMap, vpnService string) []string {
return fields return fields
} }
// NetworkManager secret flags: bit 1 (value 2) marks a secret as not-saved,
// bit 2 (value 4) as not-required. A not-required secret must not be prompted.
func secretFlagsNotRequired(flags string) bool {
if flags == "" {
return false
}
n, err := strconv.Atoi(flags)
if err != nil {
return false
}
return n&0x4 != 0
}
func needsExternalBrowserAuth(protocol, authType, username string, data map[string]string) bool { func needsExternalBrowserAuth(protocol, authType, username string, data map[string]string) bool {
if method, ok := data["saml-auth-method"]; ok { if method, ok := data["saml-auth-method"]; ok {
if method == "REDIRECT" || method == "POST" { if method == "REDIRECT" || method == "POST" {
@@ -315,6 +315,44 @@ func TestInferVPNFields_GPSaml(t *testing.T) {
expectedLen: 1, expectedLen: 1,
shouldHave: []string{"password"}, shouldHave: []string{"password"},
}, },
{
name: "OpenVPN cert auth (tls) - private-key passphrase",
vpnService: "org.freedesktop.NetworkManager.openvpn",
dataMap: map[string]string{
"connection-type": "tls",
},
expectedLen: 1,
shouldHave: []string{"cert-pass"},
},
{
name: "OpenVPN cert auth (tls) with passphrase-less key",
vpnService: "org.freedesktop.NetworkManager.openvpn",
dataMap: map[string]string{
"connection-type": "tls",
"cert-pass-flags": "4",
},
expectedLen: 0,
},
{
name: "OpenVPN password-tls no username",
vpnService: "org.freedesktop.NetworkManager.openvpn",
dataMap: map[string]string{
"connection-type": "password-tls",
},
expectedLen: 3,
shouldHave: []string{"username", "password", "cert-pass"},
},
{
name: "OpenVPN password-tls with username, passphrase-less key",
vpnService: "org.freedesktop.NetworkManager.openvpn",
dataMap: map[string]string{
"connection-type": "password-tls",
"username": "john",
"cert-pass-flags": "4",
},
expectedLen: 1,
shouldHave: []string{"password"},
},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -90,7 +90,10 @@ type pendingVPNCredentials struct {
ConnectionPath string ConnectionPath string
Username string Username string
Password string Password string
SavePassword bool // Secrets holds all VPN secret fields keyed by name (e.g. "cert-pass");
// falls back to Password under the "password" key when empty.
Secrets map[string]string
SavePassword bool
} }
type cachedVPNCredentials struct { type cachedVPNCredentials struct {
@@ -863,13 +863,17 @@ func (b *NetworkManagerBackend) saveVPNCredentials(creds *pendingVPNCredentials)
log.Infof("[saveVPNCredentials] Saving username") log.Infof("[saveVPNCredentials] Saving username")
} }
// Save password if requested // Save secrets if requested
if creds.SavePassword { if creds.SavePassword {
data["password-flags"] = "0" secs := creds.Secrets
secs := make(map[string]string) if len(secs) == 0 {
secs["password"] = creds.Password secs = map[string]string{"password": creds.Password}
}
for field := range secs {
data[field+"-flags"] = "0"
}
vpn["secrets"] = dbus.MakeVariant(secs) vpn["secrets"] = dbus.MakeVariant(secs)
log.Infof("[saveVPNCredentials] Saving password with password-flags=0") log.Infof("[saveVPNCredentials] Saving %d secret field(s) with flags=0", len(secs))
} }
vpn["data"] = dbus.MakeVariant(data) vpn["data"] = dbus.MakeVariant(data)
+205
View File
@@ -0,0 +1,205 @@
// Package traywatcher implements a minimal org.kde.StatusNotifierWatcher that
// claims the SNI name early in the session so autostart tray apps can register
// before the shell finishes loading. See docs/TRAY_WATCHER.md.
package traywatcher
import (
"fmt"
"sort"
"strings"
"sync"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/godbus/dbus/v5"
"github.com/godbus/dbus/v5/introspect"
"github.com/godbus/dbus/v5/prop"
)
const (
busName = "org.kde.StatusNotifierWatcher"
objPath = dbus.ObjectPath("/StatusNotifierWatcher")
iface = "org.kde.StatusNotifierWatcher"
)
const introXML = `<node>
<interface name="org.kde.StatusNotifierWatcher">
<method name="RegisterStatusNotifierItem">
<arg type="s" direction="in" name="service"/>
</method>
<method name="RegisterStatusNotifierHost">
<arg type="s" direction="in" name="service"/>
</method>
<property name="RegisteredStatusNotifierItems" type="as" access="read"/>
<property name="IsStatusNotifierHostRegistered" type="b" access="read"/>
<property name="ProtocolVersion" type="i" access="read"/>
<signal name="StatusNotifierItemRegistered">
<arg type="s" name="service"/>
</signal>
<signal name="StatusNotifierItemUnregistered">
<arg type="s" name="service"/>
</signal>
<signal name="StatusNotifierHostRegistered"/>
<signal name="StatusNotifierHostUnregistered"/>
</interface>
` + introspect.IntrospectDataString + prop.IntrospectDataString + `</node>`
type watcher struct {
conn *dbus.Conn
props *prop.Properties
mu sync.Mutex
items map[string]string // item id ("name/path") -> bus name to watch
hosts map[string]bool
}
func (w *watcher) itemListLocked() []string {
list := make([]string, 0, len(w.items))
for item := range w.items {
list = append(list, item)
}
sort.Strings(list)
return list
}
func (w *watcher) emit(signal string, args ...any) {
if err := w.conn.Emit(objPath, iface+"."+signal, args...); err != nil {
log.Warnf("failed to emit %s: %v", signal, err)
}
}
// Clients pass either an object path (item on the caller) or a bus name.
func (w *watcher) RegisterStatusNotifierItem(sender dbus.Sender, service string) *dbus.Error {
var item, watch string
if strings.HasPrefix(service, "/") {
item, watch = string(sender)+service, string(sender)
} else {
item, watch = service+"/StatusNotifierItem", service
}
w.mu.Lock()
if _, exists := w.items[item]; exists {
w.mu.Unlock()
return nil
}
w.items[item] = watch
list := w.itemListLocked()
w.mu.Unlock()
log.Infof("item registered: %s", item)
w.emit("StatusNotifierItemRegistered", item)
w.props.SetMust(iface, "RegisteredStatusNotifierItems", list)
return nil
}
func (w *watcher) RegisterStatusNotifierHost(sender dbus.Sender, service string) *dbus.Error {
watch := string(sender)
if service != "" && !strings.HasPrefix(service, "/") {
watch = service
}
w.mu.Lock()
if w.hosts[watch] {
w.mu.Unlock()
return nil
}
w.hosts[watch] = true
w.mu.Unlock()
log.Infof("host registered: %s", watch)
w.emit("StatusNotifierHostRegistered")
return nil
}
func (w *watcher) pruneOwner(name string) {
w.mu.Lock()
var removed []string
for item, watch := range w.items {
if watch == name {
delete(w.items, item)
removed = append(removed, item)
}
}
list := w.itemListLocked()
hostGone := w.hosts[name]
delete(w.hosts, name)
w.mu.Unlock()
for _, item := range removed {
log.Infof("item gone: %s", item)
w.emit("StatusNotifierItemUnregistered", item)
}
if len(removed) > 0 {
w.props.SetMust(iface, "RegisteredStatusNotifierItems", list)
}
if hostGone {
log.Infof("host gone: %s", name)
w.emit("StatusNotifierHostUnregistered")
}
}
// Run serves the watcher until the session bus connection closes.
func Run() error {
conn, err := dbus.ConnectSessionBus()
if err != nil {
return fmt.Errorf("connect to session bus: %w", err)
}
defer conn.Close()
w := &watcher{conn: conn, items: map[string]string{}, hosts: map[string]bool{}}
if err := conn.Export(w, objPath, iface); err != nil {
return fmt.Errorf("export watcher: %w", err)
}
w.props, err = prop.Export(conn, objPath, map[string]map[string]*prop.Prop{
iface: {
"RegisteredStatusNotifierItems": {Value: []string{}, Emit: prop.EmitTrue},
// Always true: libappindicator clients drop the icon if it is false.
"IsStatusNotifierHostRegistered": {Value: true, Emit: prop.EmitFalse},
"ProtocolVersion": {Value: int32(0), Emit: prop.EmitFalse},
},
})
if err != nil {
return fmt.Errorf("export properties: %w", err)
}
if err := conn.Export(introspect.Introspectable(introXML), objPath, "org.freedesktop.DBus.Introspectable"); err != nil {
return fmt.Errorf("export introspection: %w", err)
}
if err := conn.AddMatchSignal(
dbus.WithMatchSender("org.freedesktop.DBus"),
dbus.WithMatchInterface("org.freedesktop.DBus"),
dbus.WithMatchMember("NameOwnerChanged"),
dbus.WithMatchObjectPath("/org/freedesktop/DBus"),
); err != nil {
return fmt.Errorf("match NameOwnerChanged: %w", err)
}
sigs := make(chan *dbus.Signal, 128)
conn.Signal(sigs)
// No flags: queue for the name and take it when the current owner exits.
reply, err := conn.RequestName(busName, 0)
if err != nil {
return fmt.Errorf("request %s: %w", busName, err)
}
if reply == dbus.RequestNameReplyInQueue {
log.Infof("name busy, queued for %s", busName)
}
for sig := range sigs {
switch sig.Name {
case "org.freedesktop.DBus.NameAcquired":
if len(sig.Body) == 1 && sig.Body[0] == busName {
log.Infof("acquired %s", busName)
}
case "org.freedesktop.DBus.NameOwnerChanged":
if len(sig.Body) != 3 {
continue
}
name, _ := sig.Body[0].(string)
newOwner, _ := sig.Body[2].(string)
if name != busName && newOwner == "" {
w.pruneOwner(name)
}
}
}
return fmt.Errorf("session bus connection closed")
}
+1
View File
@@ -72,6 +72,7 @@ override_dh_auto_install:
if [ -d quickshell ]; then \ if [ -d quickshell ]; then \
cp -r quickshell/* debian/dms-git/usr/share/quickshell/dms/; \ cp -r quickshell/* debian/dms-git/usr/share/quickshell/dms/; \
install -Dm644 assets/systemd/dms.service debian/dms-git/usr/lib/systemd/user/dms.service; \ install -Dm644 assets/systemd/dms.service debian/dms-git/usr/lib/systemd/user/dms.service; \
install -Dm644 assets/systemd/dms-tray-watcher.service debian/dms-git/usr/lib/systemd/user/dms-tray-watcher.service; \
install -Dm644 assets/dms-open.desktop debian/dms-git/usr/share/applications/dms-open.desktop; \ install -Dm644 assets/dms-open.desktop debian/dms-git/usr/share/applications/dms-open.desktop; \
install -Dm644 assets/com.danklinux.dms.desktop debian/dms-git/usr/share/applications/com.danklinux.dms.desktop; \ install -Dm644 assets/com.danklinux.dms.desktop debian/dms-git/usr/share/applications/com.danklinux.dms.desktop; \
install -Dm644 assets/com.danklinux.dms.notepad.desktop debian/dms-git/usr/share/applications/com.danklinux.dms.notepad.desktop; \ install -Dm644 assets/com.danklinux.dms.notepad.desktop debian/dms-git/usr/share/applications/com.danklinux.dms.notepad.desktop; \
+1
View File
@@ -65,6 +65,7 @@ override_dh_auto_install:
if [ -d DankMaterialShell-$(UPSTREAM_VERSION) ]; then \ if [ -d DankMaterialShell-$(UPSTREAM_VERSION) ]; then \
cp -r DankMaterialShell-$(UPSTREAM_VERSION)/quickshell/* debian/dms/usr/share/quickshell/dms/; \ cp -r DankMaterialShell-$(UPSTREAM_VERSION)/quickshell/* debian/dms/usr/share/quickshell/dms/; \
install -Dm644 DankMaterialShell-$(UPSTREAM_VERSION)/assets/systemd/dms.service debian/dms/usr/lib/systemd/user/dms.service; \ install -Dm644 DankMaterialShell-$(UPSTREAM_VERSION)/assets/systemd/dms.service debian/dms/usr/lib/systemd/user/dms.service; \
install -Dm644 DankMaterialShell-$(UPSTREAM_VERSION)/assets/systemd/dms-tray-watcher.service debian/dms/usr/lib/systemd/user/dms-tray-watcher.service; \
install -Dm644 DankMaterialShell-$(UPSTREAM_VERSION)/assets/dms-open.desktop debian/dms/usr/share/applications/dms-open.desktop; \ install -Dm644 DankMaterialShell-$(UPSTREAM_VERSION)/assets/dms-open.desktop debian/dms/usr/share/applications/dms-open.desktop; \
install -Dm644 DankMaterialShell-$(UPSTREAM_VERSION)/assets/com.danklinux.dms.desktop debian/dms/usr/share/applications/com.danklinux.dms.desktop; \ install -Dm644 DankMaterialShell-$(UPSTREAM_VERSION)/assets/com.danklinux.dms.desktop debian/dms/usr/share/applications/com.danklinux.dms.desktop; \
install -Dm644 DankMaterialShell-$(UPSTREAM_VERSION)/assets/com.danklinux.dms.notepad.desktop debian/dms/usr/share/applications/com.danklinux.dms.notepad.desktop; \ install -Dm644 DankMaterialShell-$(UPSTREAM_VERSION)/assets/com.danklinux.dms.notepad.desktop debian/dms/usr/share/applications/com.danklinux.dms.notepad.desktop; \
+2
View File
@@ -119,6 +119,7 @@ core/bin/${DMS_BINARY} completion fish > %{buildroot}%{_datadir}/fish/vendor_com
# Install systemd user service # Install systemd user service
install -Dm644 assets/systemd/dms.service %{buildroot}%{_userunitdir}/dms.service install -Dm644 assets/systemd/dms.service %{buildroot}%{_userunitdir}/dms.service
install -Dm644 assets/systemd/dms-tray-watcher.service %{buildroot}%{_userunitdir}/dms-tray-watcher.service
install -Dm644 assets/dms-open.desktop %{buildroot}%{_datadir}/applications/dms-open.desktop install -Dm644 assets/dms-open.desktop %{buildroot}%{_datadir}/applications/dms-open.desktop
install -Dm644 assets/com.danklinux.dms.desktop %{buildroot}%{_datadir}/applications/com.danklinux.dms.desktop install -Dm644 assets/com.danklinux.dms.desktop %{buildroot}%{_datadir}/applications/com.danklinux.dms.desktop
@@ -145,6 +146,7 @@ pkill -USR1 -x dms >/dev/null 2>&1 || :
%doc quickshell/README.md %doc quickshell/README.md
%{_datadir}/quickshell/dms/ %{_datadir}/quickshell/dms/
%{_userunitdir}/dms.service %{_userunitdir}/dms.service
%{_userunitdir}/dms-tray-watcher.service
%{_datadir}/applications/dms-open.desktop %{_datadir}/applications/dms-open.desktop
%{_datadir}/applications/com.danklinux.dms.desktop %{_datadir}/applications/com.danklinux.dms.desktop
%{_datadir}/applications/com.danklinux.dms.notepad.desktop %{_datadir}/applications/com.danklinux.dms.notepad.desktop
+2
View File
@@ -87,6 +87,7 @@ install -d %{buildroot}%{_datadir}/fish/vendor_completions.d
%{_builddir}/dms-cli completion fish > %{buildroot}%{_datadir}/fish/vendor_completions.d/dms.fish || : %{_builddir}/dms-cli completion fish > %{buildroot}%{_datadir}/fish/vendor_completions.d/dms.fish || :
install -Dm644 %{_builddir}/dms-qml/assets/systemd/dms.service %{buildroot}%{_userunitdir}/dms.service install -Dm644 %{_builddir}/dms-qml/assets/systemd/dms.service %{buildroot}%{_userunitdir}/dms.service
install -Dm644 %{_builddir}/dms-qml/assets/systemd/dms-tray-watcher.service %{buildroot}%{_userunitdir}/dms-tray-watcher.service
install -Dm644 %{_builddir}/dms-qml/assets/dms-open.desktop %{buildroot}%{_datadir}/applications/dms-open.desktop install -Dm644 %{_builddir}/dms-qml/assets/dms-open.desktop %{buildroot}%{_datadir}/applications/dms-open.desktop
install -Dm644 %{_builddir}/dms-qml/assets/com.danklinux.dms.desktop %{buildroot}%{_datadir}/applications/com.danklinux.dms.desktop install -Dm644 %{_builddir}/dms-qml/assets/com.danklinux.dms.desktop %{buildroot}%{_datadir}/applications/com.danklinux.dms.desktop
@@ -112,6 +113,7 @@ pkill -USR1 -x dms >/dev/null 2>&1 || :
%doc README.md CONTRIBUTING.md %doc README.md CONTRIBUTING.md
%{_datadir}/quickshell/dms/ %{_datadir}/quickshell/dms/
%{_userunitdir}/dms.service %{_userunitdir}/dms.service
%{_userunitdir}/dms-tray-watcher.service
%{_datadir}/applications/dms-open.desktop %{_datadir}/applications/dms-open.desktop
%{_datadir}/applications/com.danklinux.dms.desktop %{_datadir}/applications/com.danklinux.dms.desktop
%{_datadir}/applications/com.danklinux.dms.notepad.desktop %{_datadir}/applications/com.danklinux.dms.notepad.desktop
+18
View File
@@ -101,6 +101,24 @@ in
Install.WantedBy = [ cfg.systemd.target ]; Install.WantedBy = [ cfg.systemd.target ];
}; };
# Early SNI watcher so autostart tray apps register before the shell loads.
systemd.user.services.dms-tray-watcher = lib.mkIf cfg.systemd.enable {
Unit = {
Description = "DMS Status Notifier Watcher (early tray)";
PartOf = [ cfg.systemd.target ];
Before = [ cfg.systemd.target ];
};
Service = {
Type = "dbus";
BusName = "org.kde.StatusNotifierWatcher";
ExecStart = lib.getExe cfg.package + " tray-watcher";
Restart = "on-failure";
};
Install.WantedBy = [ cfg.systemd.target ];
};
xdg.stateFile."DankMaterialShell/session.json" = lib.mkIf (cfg.session != { }) { xdg.stateFile."DankMaterialShell/session.json" = lib.mkIf (cfg.session != { }) {
source = jsonFormat.generate "session.json" cfg.session; source = jsonFormat.generate "session.json" cfg.session;
}; };
+18
View File
@@ -39,6 +39,24 @@ in
}; };
}; };
# Early SNI watcher so autostart tray apps register before the shell loads.
systemd.user.services.dms-tray-watcher = lib.mkIf cfg.systemd.enable {
description = "DMS Status Notifier Watcher (early tray)";
path = lib.mkForce [ ];
partOf = [ cfg.systemd.target ];
before = [ cfg.systemd.target ];
wantedBy = [ cfg.systemd.target ];
restartIfChanged = cfg.systemd.restartIfChanged;
serviceConfig = {
Type = "dbus";
BusName = "org.kde.StatusNotifierWatcher";
ExecStart = lib.getExe cfg.package + " tray-watcher";
Restart = "on-failure";
};
};
environment.systemPackages = [ cfg.quickshell.package ] ++ common.packages; environment.systemPackages = [ cfg.quickshell.package ] ++ common.packages;
environment.etc = lib.mapAttrs' (name: value: { environment.etc = lib.mapAttrs' (name: value: {
+2
View File
@@ -114,6 +114,7 @@ install -d %{buildroot}%{_datadir}/fish/vendor_completions.d
./dms completion fish > %{buildroot}%{_datadir}/fish/vendor_completions.d/dms.fish || : ./dms completion fish > %{buildroot}%{_datadir}/fish/vendor_completions.d/dms.fish || :
install -Dm644 assets/systemd/dms.service %{buildroot}%{_userunitdir}/dms.service install -Dm644 assets/systemd/dms.service %{buildroot}%{_userunitdir}/dms.service
install -Dm644 assets/systemd/dms-tray-watcher.service %{buildroot}%{_userunitdir}/dms-tray-watcher.service
install -Dm644 assets/dms-open.desktop %{buildroot}%{_datadir}/applications/dms-open.desktop install -Dm644 assets/dms-open.desktop %{buildroot}%{_datadir}/applications/dms-open.desktop
install -Dm644 assets/com.danklinux.dms.desktop %{buildroot}%{_datadir}/applications/com.danklinux.dms.desktop install -Dm644 assets/com.danklinux.dms.desktop %{buildroot}%{_datadir}/applications/com.danklinux.dms.desktop
@@ -152,6 +153,7 @@ pkill -USR1 -x dms >/dev/null 2>&1 || :
%dir %{_datadir}/quickshell %dir %{_datadir}/quickshell
%{_datadir}/quickshell/dms/ %{_datadir}/quickshell/dms/
%{_userunitdir}/dms.service %{_userunitdir}/dms.service
%{_userunitdir}/dms-tray-watcher.service
%{_datadir}/applications/dms-open.desktop %{_datadir}/applications/dms-open.desktop
%{_datadir}/applications/com.danklinux.dms.desktop %{_datadir}/applications/com.danklinux.dms.desktop
%{_datadir}/applications/com.danklinux.dms.notepad.desktop %{_datadir}/applications/com.danklinux.dms.notepad.desktop
+2
View File
@@ -63,6 +63,7 @@ install -d %{buildroot}%{_datadir}/fish/vendor_completions.d
./dms completion fish > %{buildroot}%{_datadir}/fish/vendor_completions.d/dms.fish || : ./dms completion fish > %{buildroot}%{_datadir}/fish/vendor_completions.d/dms.fish || :
install -Dm644 assets/systemd/dms.service %{buildroot}%{_userunitdir}/dms.service install -Dm644 assets/systemd/dms.service %{buildroot}%{_userunitdir}/dms.service
install -Dm644 assets/systemd/dms-tray-watcher.service %{buildroot}%{_userunitdir}/dms-tray-watcher.service
install -Dm644 assets/dms-open.desktop %{buildroot}%{_datadir}/applications/dms-open.desktop install -Dm644 assets/dms-open.desktop %{buildroot}%{_datadir}/applications/dms-open.desktop
install -Dm644 assets/com.danklinux.dms.desktop %{buildroot}%{_datadir}/applications/com.danklinux.dms.desktop install -Dm644 assets/com.danklinux.dms.desktop %{buildroot}%{_datadir}/applications/com.danklinux.dms.desktop
@@ -99,6 +100,7 @@ pkill -USR1 -x dms >/dev/null 2>&1 || :
%dir %{_datadir}/quickshell %dir %{_datadir}/quickshell
%{_datadir}/quickshell/dms/ %{_datadir}/quickshell/dms/
%{_userunitdir}/dms.service %{_userunitdir}/dms.service
%{_userunitdir}/dms-tray-watcher.service
%{_datadir}/applications/dms-open.desktop %{_datadir}/applications/dms-open.desktop
%{_datadir}/applications/com.danklinux.dms.desktop %{_datadir}/applications/com.danklinux.dms.desktop
%{_datadir}/applications/com.danklinux.dms.notepad.desktop %{_datadir}/applications/com.danklinux.dms.notepad.desktop
+2
View File
@@ -69,6 +69,8 @@ override_dh_auto_install:
# Install systemd user service # Install systemd user service
install -Dm644 dms-git-repo/assets/systemd/dms.service \ install -Dm644 dms-git-repo/assets/systemd/dms.service \
debian/dms-git/usr/lib/systemd/user/dms.service debian/dms-git/usr/lib/systemd/user/dms.service
install -Dm644 dms-git-repo/assets/systemd/dms-tray-watcher.service \
debian/dms-git/usr/lib/systemd/user/dms-tray-watcher.service
# Install desktop file and icon # Install desktop file and icon
install -Dm644 dms-git-repo/assets/dms-open.desktop \ install -Dm644 dms-git-repo/assets/dms-open.desktop \
+2
View File
@@ -50,6 +50,8 @@ override_dh_auto_install:
# Install systemd user service (before copying everything else) # Install systemd user service (before copying everything else)
install -Dm644 DankMaterialShell-$(BASE_VERSION)/assets/systemd/dms.service \ install -Dm644 DankMaterialShell-$(BASE_VERSION)/assets/systemd/dms.service \
debian/dms/usr/lib/systemd/user/dms.service debian/dms/usr/lib/systemd/user/dms.service
install -Dm644 DankMaterialShell-$(BASE_VERSION)/assets/systemd/dms-tray-watcher.service \
debian/dms/usr/lib/systemd/user/dms-tray-watcher.service
# Install desktop file and icon # Install desktop file and icon
install -Dm644 DankMaterialShell-$(BASE_VERSION)/assets/dms-open.desktop \ install -Dm644 DankMaterialShell-$(BASE_VERSION)/assets/dms-open.desktop \
+3
View File
@@ -3,6 +3,7 @@ pragma ComponentBehavior: Bound
import Quickshell import Quickshell
import QtCore import QtCore
import QtQuick
import qs.Services import qs.Services
Singleton { Singleton {
@@ -19,6 +20,8 @@ Singleton {
readonly property url imagecache: `${cache}/imagecache` readonly property url imagecache: `${cache}/imagecache`
Component.onCompleted: mkdir(imagecache)
function stringify(path: url): string { function stringify(path: url): string {
return path.toString().replace(/%20/g, " "); return path.toString().replace(/%20/g, " ");
} }
+2 -17
View File
@@ -462,7 +462,7 @@ Singleton {
property bool clockCompactMode: false property bool clockCompactMode: false
property int focusedWindowSize: 1 property int focusedWindowSize: 1
property bool focusedWindowCompactMode: false property bool focusedWindowCompactMode: false
property bool focusedWindowShowIcon: false property bool focusedWindowShowIcon: true
property bool runningAppsCompactMode: true property bool runningAppsCompactMode: true
property int barMaxVisibleApps: 0 property int barMaxVisibleApps: 0
property int barMaxVisibleRunningApps: 0 property int barMaxVisibleRunningApps: 0
@@ -1027,17 +1027,6 @@ Singleton {
} }
] ]
// Standalone bar xray is unsafe when windows can render beneath its surface
function _standaloneBarXrayAvailable(configs) {
const list = configs || [];
const activeBars = list.filter(c => c && c.enabled && (c.visible ?? true));
const gapsOverride = (typeof CompositorService !== "undefined" && CompositorService.isHyprland) ? hyprlandLayoutGapsOverride : niriLayoutGapsOverride;
const layoutGaps = gapsOverride >= 0 ? gapsOverride : Math.max(4, (list[0]?.spacing ?? 4));
return activeBars.every(c => !c.autoHide && !(c.useOverlayLayer ?? false) && (c.spacing ?? 4) + (c.bottomGap ?? 0) + layoutGaps >= 0);
}
readonly property bool standaloneBarXrayAvailable: _standaloneBarXrayAvailable(barConfigs)
property bool desktopClockEnabled: false property bool desktopClockEnabled: false
property string desktopClockStyle: "analog" property string desktopClockStyle: "analog"
property real desktopClockTransparency: 0.8 property real desktopClockTransparency: 0.8
@@ -2479,17 +2468,13 @@ Singleton {
if (index === -1) if (index === -1)
return; return;
const positionChanged = updates.position !== undefined && configs[index].position !== updates.position; const positionChanged = updates.position !== undefined && configs[index].position !== updates.position;
const barXrayTargetWasAvailable = _standaloneBarXrayAvailable(configs);
if (updates.autoHide === false || updates.visible === false) if (updates.autoHide === false || updates.visible === false)
setBarIpcReveal(barId, false); setBarIpcReveal(barId, false);
Object.assign(configs[index], updates); Object.assign(configs[index], updates);
const sanitizedConfigs = _sanitizeBarConfigsForConnectedFrame(configs).configs; barConfigs = _sanitizeBarConfigsForConnectedFrame(configs).configs;
barConfigs = sanitizedConfigs;
updateBarConfigs(); updateBarConfigs();
if (!frameEnabled && _standaloneBarXrayAvailable(sanitizedConfigs) !== barXrayTargetWasAvailable)
updateCompositorLayout();
if (positionChanged) { if (positionChanged) {
NotificationService.dismissAllPopups(); NotificationService.dismissAllPopups();
} }
+1 -1
View File
@@ -192,7 +192,7 @@ var SPEC = {
clockCompactMode: { def: false }, clockCompactMode: { def: false },
focusedWindowCompactMode: { def: false }, focusedWindowCompactMode: { def: false },
focusedWindowSize: { def: 1 }, focusedWindowSize: { def: 1 },
focusedWindowShowIcon: { def: false }, focusedWindowShowIcon: { def: true },
runningAppsCompactMode: { def: true }, runningAppsCompactMode: { def: true },
barMaxVisibleApps: { def: 0 }, barMaxVisibleApps: { def: 0 },
barMaxVisibleRunningApps: { def: 0 }, barMaxVisibleRunningApps: { def: 0 },
+2 -6
View File
@@ -1,5 +1,4 @@
import QtQuick import QtQuick
import Quickshell.Hyprland
import qs.Common import qs.Common
import qs.Services import qs.Services
import qs.Widgets import qs.Widgets
@@ -61,12 +60,9 @@ Item {
} }
// Hyprland OnDemand grab delivers keyboard focus to the modal content surface. // Hyprland OnDemand grab delivers keyboard focus to the modal content surface.
HyprlandFocusGrab { DankFocusGrab {
windows: (root.contentWindow ? [root.contentWindow] : []).concat(root.transientSurfaceTracker?.focusWindows ?? []) windows: (root.contentWindow ? [root.contentWindow] : []).concat(root.transientSurfaceTracker?.focusWindows ?? [])
active: KeyboardFocus.wantsGrab(root.shouldHaveFocus, root.customKeyboardFocus) wanted: KeyboardFocus.wantsGrab(root.shouldHaveFocus, root.customKeyboardFocus)
property var restoreToplevel: null
onActiveChanged: restoreToplevel = active ? KeyboardFocus.captureActiveToplevel() : KeyboardFocus.restoreToplevel(restoreToplevel)
} }
readonly property var contentWindow: impl.item ? impl.item.contentWindow : null readonly property var contentWindow: impl.item ? impl.item.contentWindow : null
readonly property var effectiveScreen: impl.item ? impl.item.effectiveScreen : null readonly property var effectiveScreen: impl.item ? impl.item.effectiveScreen : null
@@ -169,8 +169,6 @@ StyledRect {
property string imagePath: { property string imagePath: {
if (weMode && delegateRoot.fileIsDir) if (weMode && delegateRoot.fileIsDir)
return delegateRoot.filePath + "/preview" + weExtensions[weExtIndex]; return delegateRoot.filePath + "/preview" + weExtensions[weExtIndex];
if (!delegateRoot.fileIsDir && isImage)
return delegateRoot.filePath;
if (_videoThumb) if (_videoThumb)
return _videoThumb; return _videoThumb;
return ""; return "";
@@ -192,13 +190,28 @@ StyledRect {
visible: false visible: false
} }
CachingImage {
anchors.fill: parent
anchors.margins: 2
imagePath: !delegateRoot.fileIsDir && isImage ? delegateRoot.filePath : ""
maxCacheSize: 256
visible: !delegateRoot.fileIsDir && isImage
layer.enabled: true
layer.effect: MultiEffect {
maskEnabled: true
maskSource: gridImageMask
maskThresholdMin: 0.5
maskSpreadAtMin: 1
}
}
MultiEffect { MultiEffect {
anchors.fill: parent anchors.fill: parent
anchors.margins: 2 anchors.margins: 2
source: gridPreviewImage source: gridPreviewImage
maskEnabled: true maskEnabled: true
maskSource: gridImageMask maskSource: gridImageMask
visible: gridPreviewImage.status === Image.Ready && ((!delegateRoot.fileIsDir && (isImage || isVideo)) || (weMode && delegateRoot.fileIsDir)) visible: gridPreviewImage.status === Image.Ready && ((!delegateRoot.fileIsDir && isVideo) || (weMode && delegateRoot.fileIsDir))
maskThresholdMin: 0.5 maskThresholdMin: 0.5
maskSpreadAtMin: 1 maskSpreadAtMin: 1
} }
@@ -168,13 +168,7 @@ StyledRect {
Image { Image {
id: listPreviewImage id: listPreviewImage
anchors.fill: parent anchors.fill: parent
property string imagePath: { property string imagePath: _videoThumb
if (!listDelegateRoot.fileIsDir && isImage)
return listDelegateRoot.filePath;
if (_videoThumb)
return _videoThumb;
return "";
}
source: imagePath ? "file://" + imagePath.split('/').map(s => encodeURIComponent(s)).join('/') : "" source: imagePath ? "file://" + imagePath.split('/').map(s => encodeURIComponent(s)).join('/') : ""
fillMode: Image.PreserveAspectCrop fillMode: Image.PreserveAspectCrop
sourceSize.width: 32 sourceSize.width: 32
@@ -183,12 +177,26 @@ StyledRect {
visible: false visible: false
} }
CachingImage {
anchors.fill: parent
imagePath: !listDelegateRoot.fileIsDir && isImage ? listDelegateRoot.filePath : ""
maxCacheSize: 256
visible: !listDelegateRoot.fileIsDir && isImage
layer.enabled: true
layer.effect: MultiEffect {
maskEnabled: true
maskSource: listImageMask
maskThresholdMin: 0.5
maskSpreadAtMin: 1
}
}
MultiEffect { MultiEffect {
anchors.fill: parent anchors.fill: parent
source: listPreviewImage source: listPreviewImage
maskEnabled: true maskEnabled: true
maskSource: listImageMask maskSource: listImageMask
visible: listPreviewImage.status === Image.Ready && !listDelegateRoot.fileIsDir && (isImage || isVideo) visible: listPreviewImage.status === Image.Ready && !listDelegateRoot.fileIsDir && isVideo
maskThresholdMin: 0.5 maskThresholdMin: 0.5
maskSpreadAtMin: 1 maskSpreadAtMin: 1
} }
@@ -423,12 +423,14 @@ Column {
} }
} }
// targetValue-based direction: `active` can be stale when the behavior triggers
Behavior on height { Behavior on height {
enabled: true id: detailHeightBehavior
NumberAnimation { NumberAnimation {
duration: Theme.variantDuration(Theme.popoutAnimationDuration, detailHost.active) readonly property bool growing: (detailHeightBehavior.targetValue ?? 0) >= detailHost.height
duration: Theme.variantDuration(Theme.popoutAnimationDuration, growing)
easing.type: Easing.BezierSpline easing.type: Easing.BezierSpline
easing.bezierCurve: detailHost.active ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve easing.bezierCurve: growing ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve
} }
} }
} }
+18 -11
View File
@@ -941,12 +941,16 @@ Item {
section: topBarContent.getWidgetSection(parent) section: topBarContent.getWidgetSection(parent)
parentScreen: barWindow.screen parentScreen: barWindow.screen
onClicked: { onClicked: {
if (powerMenuModalLoader) { if (!powerMenuModalLoader)
powerMenuModalLoader.active = true; return;
if (powerMenuModalLoader.item) { powerMenuModalLoader.active = true;
powerMenuModalLoader.item.openCentered(); if (!powerMenuModalLoader.item)
} return;
if (powerMenuModalLoader.item.shouldBeVisible) {
powerMenuModalLoader.item.close();
return;
} }
powerMenuModalLoader.item.openCentered();
} }
} }
} }
@@ -1101,12 +1105,13 @@ Item {
onClockClicked: { onClockClicked: {
const section = topBarContent.getWidgetSection(parent) || "center"; const section = topBarContent.getWidgetSection(parent) || "center";
const tabIndex = SettingsData.dashTabIndexForId("overview");
topBarContent.openWidgetPopout({ topBarContent.openWidgetPopout({
loader: dankDashPopoutLoader, loader: dankDashPopoutLoader,
widgetItem: clockWidget, widgetItem: clockWidget,
section, section,
tabIndex: 0, tabIndex,
triggerSource: topBarContent._dashTriggerSource(section, 0), triggerSource: topBarContent._dashTriggerSource(section, tabIndex),
mode: "click", mode: "click",
useCenterSection: true, useCenterSection: true,
setTriggerScreen: true setTriggerScreen: true
@@ -1129,12 +1134,13 @@ Item {
parentScreen: barWindow.screen parentScreen: barWindow.screen
onClicked: { onClicked: {
const section = topBarContent.getWidgetSection(parent) || "center"; const section = topBarContent.getWidgetSection(parent) || "center";
const tabIndex = SettingsData.dashTabIndexForId("media");
topBarContent.openWidgetPopout({ topBarContent.openWidgetPopout({
loader: dankDashPopoutLoader, loader: dankDashPopoutLoader,
widgetItem: mediaWidget, widgetItem: mediaWidget,
section, section,
tabIndex: 1, tabIndex,
triggerSource: topBarContent._dashTriggerSource(section, 1), triggerSource: topBarContent._dashTriggerSource(section, tabIndex),
mode: "click", mode: "click",
useCenterSection: true, useCenterSection: true,
setTriggerScreen: true setTriggerScreen: true
@@ -1156,12 +1162,13 @@ Item {
parentScreen: barWindow.screen parentScreen: barWindow.screen
onClicked: { onClicked: {
const section = topBarContent.getWidgetSection(parent) || "center"; const section = topBarContent.getWidgetSection(parent) || "center";
const tabIndex = SettingsData.dashTabIndexForId("weather");
topBarContent.openWidgetPopout({ topBarContent.openWidgetPopout({
loader: dankDashPopoutLoader, loader: dankDashPopoutLoader,
widgetItem: weatherWidget, widgetItem: weatherWidget,
section, section,
tabIndex: 3, tabIndex,
triggerSource: topBarContent._dashTriggerSource(section, 3), triggerSource: topBarContent._dashTriggerSource(section, tabIndex),
mode: "click", mode: "click",
useCenterSection: true, useCenterSection: true,
setTriggerScreen: true setTriggerScreen: true
@@ -736,7 +736,7 @@ Item {
case "music": case "music":
case "weather": case "weather":
{ {
const tabIndex = widgetId === "clock" ? 0 : (widgetId === "music" ? 1 : 3); const tabIndex = SettingsData.dashTabIndexForId(widgetId === "clock" ? "overview" : (widgetId === "music" ? "media" : "weather"));
return barContent.openWidgetPopout(Object.assign({}, base, { return barContent.openWidgetPopout(Object.assign({}, base, {
loader, loader,
tabIndex, tabIndex,
+1 -1
View File
@@ -240,7 +240,7 @@ PanelWindow {
id: barBlur id: barBlur
visible: false visible: false
readonly property bool barHasTransparency: barWindow._backgroundAlpha < 1 readonly property bool barHasTransparency: barWindow._backgroundAlpha > 0 && barWindow._backgroundAlpha < 1
function rebuild() { function rebuild() {
teardown(); teardown();
@@ -1,7 +1,6 @@
import QtQuick import QtQuick
import QtQuick.Effects import QtQuick.Effects
import Quickshell import Quickshell
import Quickshell.Hyprland
import Quickshell.Services.SystemTray import Quickshell.Services.SystemTray
import Quickshell.Wayland import Quickshell.Wayland
import Quickshell.Widgets import Quickshell.Widgets
@@ -1048,12 +1047,9 @@ BasePill {
WlrLayershell.namespace: "dms:tray-overflow-menu" WlrLayershell.namespace: "dms:tray-overflow-menu"
color: "transparent" color: "transparent"
HyprlandFocusGrab { DankFocusGrab {
windows: [overflowMenu].concat(KeyboardFocus.barWindows) windows: [overflowMenu].concat(KeyboardFocus.barWindows)
active: root.useOverflowPopup && KeyboardFocus.wantsGrab(root.menuOpen, null) wanted: root.useOverflowPopup && KeyboardFocus.wantsGrab(root.menuOpen, null)
property var restoreToplevel: null
onActiveChanged: restoreToplevel = active ? KeyboardFocus.captureActiveToplevel() : KeyboardFocus.restoreToplevel(restoreToplevel)
} }
Connections { Connections {
@@ -1601,12 +1597,9 @@ BasePill {
WlrLayershell.keyboardFocus: KeyboardFocus.keyboardFocus(menuRoot.showMenu, null) WlrLayershell.keyboardFocus: KeyboardFocus.keyboardFocus(menuRoot.showMenu, null)
color: "transparent" color: "transparent"
HyprlandFocusGrab { DankFocusGrab {
windows: [menuWindow].concat(KeyboardFocus.barWindows) windows: [menuWindow].concat(KeyboardFocus.barWindows)
active: KeyboardFocus.wantsGrab(menuRoot.showMenu, null) wanted: KeyboardFocus.wantsGrab(menuRoot.showMenu, null)
property var restoreToplevel: null
onActiveChanged: restoreToplevel = active ? KeyboardFocus.captureActiveToplevel() : KeyboardFocus.restoreToplevel(restoreToplevel)
} }
anchors { anchors {
@@ -107,6 +107,14 @@ Item {
} }
} }
Connections {
target: CompositorService
function onCompositorChanged() {
root._placeholderPool = [];
root._hyprSlotPool = {};
}
}
property var currentWorkspace: { property var currentWorkspace: {
if (useExtWorkspace) if (useExtWorkspace)
return getExtWorkspaceActiveWorkspace(); return getExtWorkspaceActiveWorkspace();
@@ -145,8 +153,7 @@ Item {
baseList = getNiriWorkspaces(); baseList = getNiriWorkspaces();
break; break;
case "hyprland": case "hyprland":
baseList = getHyprlandWorkspaces(); return hyprlandSlotList(getHyprlandWorkspaces());
break;
case "mango": case "mango":
if (root.mangoOverviewActive) if (root.mangoOverviewActive)
return []; return [];
@@ -426,41 +433,84 @@ Item {
return Object.values(byApp); return Object.values(byApp);
} }
function padWorkspaces(list) { function _makePlaceholder() {
const padded = list.slice(); if (useExtWorkspace)
let placeholder; return {
if (useExtWorkspace) {
placeholder = {
"id": "", "id": "",
"name": "", "name": "",
"active": false, "active": false,
"_placeholder": true "_placeholder": true
}; };
} else if (CompositorService.isNiri) { if (CompositorService.isNiri)
placeholder = { return {
"id": -1, "id": -1,
"idx": -1, "idx": -1,
"name": "" "name": ""
}; };
} else if (CompositorService.isHyprland) { if (CompositorService.isHyprland)
placeholder = { return {
"id": -1, "id": -1,
"name": "" "name": ""
}; };
} else if (root.isMango) { if (root.isMango)
placeholder = { return {
"tag": -1 "tag": -1
}; };
} else if (CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle) { if (CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle)
placeholder = { return {
"num": -1, "num": -1,
"_placeholder": true "_placeholder": true
}; };
} else { return -1;
placeholder = -1; }
// Hyprland creates/destroys workspaces on empty enter/leave; slots keyed by id keep delegate identity so pills animate instead of popping
property var _hyprSlotPool: ({})
Component {
id: hyprSlotComponent
QtObject {
property var ws: null
readonly property var id: ws ? ws.id : -1
readonly property string name: ws?.name ?? ""
readonly property bool urgent: ws?.urgent ?? false
} }
}
function _hyprSlot(key, ws) {
let slot = _hyprSlotPool[key];
if (!slot) {
slot = hyprSlotComponent.createObject(root);
_hyprSlotPool[key] = slot;
}
if (slot.ws !== ws)
slot.ws = ws;
return slot;
}
function hyprlandSlotList(raw) {
const slots = raw.map(ws => _hyprSlot(ws.id > 0 ? ws.id : "name:" + (ws.name ?? ""), ws));
if (!SettingsData.showWorkspacePadding)
return slots;
// pad past the highest real id so a placeholder becomes that workspace's slot once created
let nextId = raw.reduce((max, ws) => Math.max(max, ws.id ?? 0), 0);
while (slots.length < 3)
slots.push(_hyprSlot(++nextId, null));
return slots;
}
// Stable placeholder instances so ScriptModel (identity-diffed) reuses padding delegates instead of recreating them on workspace churn
property var _placeholderPool: []
function padWorkspaces(list) {
const padded = list.slice();
let slot = 0;
while (padded.length < 3) { while (padded.length < 3) {
padded.push(placeholder); if (root._placeholderPool.length <= slot)
root._placeholderPool.push(root._makePlaceholder());
padded.push(root._placeholderPool[slot]);
slot++;
} }
return padded; return padded;
} }
@@ -638,7 +688,7 @@ Item {
NiriService.switchToWorkspace(data.id); NiriService.switchToWorkspace(data.id);
break; break;
case "hyprland": case "hyprland":
if (data.id) { if (data.id && data.id !== -1) {
HyprlandService.focusWorkspace(hyprlandWorkspaceSelector(data)); HyprlandService.focusWorkspace(hyprlandWorkspaceSelector(data));
} }
break; break;
@@ -663,7 +713,7 @@ Item {
for (let i = 0; i < workspaceRepeater.count; i++) { for (let i = 0; i < workspaceRepeater.count; i++) {
const item = workspaceRepeater.itemAt(i); const item = workspaceRepeater.itemAt(i);
if (!item) if (!item || item.isPlaceholder)
continue; continue;
const center = item.mapToItem(root, item.width / 2, item.height / 2); const center = item.mapToItem(root, item.width / 2, item.height / 2);
const dist = isVertical ? Math.abs(localY - center.y) : Math.abs(localX - center.x); const dist = isVertical ? Math.abs(localY - center.y) : Math.abs(localX - center.x);
@@ -447,6 +447,7 @@ DankPopout {
anchors.fill: parent anchors.fill: parent
active: root.currentTabId === "wallpaper" active: root.currentTabId === "wallpaper"
visible: active visible: active
asynchronous: true
sourceComponent: Component { sourceComponent: Component {
WallpaperTab { WallpaperTab {
active: true active: true
@@ -458,6 +459,12 @@ DankPopout {
} }
} }
DankSpinner {
anchors.centerIn: parent
size: 40
visible: wallpaperLoader.active && wallpaperLoader.status === Loader.Loading
}
Loader { Loader {
id: weatherLoader id: weatherLoader
anchors.fill: parent anchors.fill: parent
@@ -86,12 +86,10 @@ Rectangle {
} }
function updateSelectedDateEvents() { function updateSelectedDateEvents() {
if (CalendarService && CalendarService.calendarAvailable) { const events = (CalendarService && CalendarService.calendarAvailable) ? CalendarService.getEventsForDate(selectedDate) : [];
const events = CalendarService.getEventsForDate(selectedDate); if (JSON.stringify(events) === JSON.stringify(selectedDateEvents))
selectedDateEvents = events; return;
} else { selectedDateEvents = events;
selectedDateEvents = [];
}
} }
function loadEventsForMonth() { function loadEventsForMonth() {
@@ -276,57 +274,29 @@ Rectangle {
height: 40 height: 40
visible: showEventDetails visible: showEventDetails
Rectangle { DankActionButton {
width: 32 buttonSize: 32
height: 32 iconSize: 14
iconName: "arrow_back"
iconColor: Theme.primary
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left anchors.left: parent.left
anchors.leftMargin: Theme.spacingS anchors.leftMargin: Theme.spacingS
radius: Theme.cornerRadius onClicked: root.showEventDetails = false
color: backButtonArea.containsMouse ? Theme.primaryHover : Theme.withAlpha(Theme.primaryHover, 0)
DankIcon {
anchors.centerIn: parent
name: "arrow_back"
size: 14
color: Theme.primary
}
MouseArea {
id: backButtonArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.showEventDetails = false
}
} }
Rectangle { DankActionButton {
width: 32 buttonSize: 32
height: 32 iconSize: 16
iconName: "event"
iconColor: Theme.primary
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
anchors.right: parent.right anchors.right: parent.right
anchors.rightMargin: Theme.spacingS anchors.rightMargin: Theme.spacingS
radius: Theme.cornerRadius
visible: CalendarService && CalendarService.canCreateEvents visible: CalendarService && CalendarService.canCreateEvents
color: addEventArea.containsMouse ? Theme.primaryHover : Theme.withAlpha(Theme.primaryHover, 0) onClicked: {
root.editorEvent = null;
DankIcon { root.showEditor = true;
anchors.centerIn: parent
name: "event"
size: 16
color: Theme.primary
}
MouseArea {
id: addEventArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
root.editorEvent = null;
root.showEditor = true;
}
} }
} }
@@ -358,31 +328,12 @@ Rectangle {
height: 28 height: 28
visible: !showEventDetails visible: !showEventDetails
Rectangle { DankActionButton {
width: 28 buttonSize: 28
height: 28 iconSize: 14
radius: Theme.cornerRadius iconName: "chevron_left"
color: prevMonthArea.containsMouse ? Theme.primaryHover : Theme.withAlpha(Theme.primaryHover, 0) iconColor: Theme.primary
onClicked: root.shiftMonth(-1)
DankIcon {
anchors.centerIn: parent
name: "chevron_left"
size: 14
color: Theme.primary
}
MouseArea {
id: prevMonthArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
let newDate = new Date(calendarGrid.displayDate);
newDate.setMonth(newDate.getMonth() - 1);
calendarGrid.displayDate = newDate;
loadEventsForMonth();
}
}
} }
StyledText { StyledText {
@@ -396,53 +347,20 @@ Rectangle {
verticalAlignment: Text.AlignVCenter verticalAlignment: Text.AlignVCenter
} }
Rectangle { DankActionButton {
width: 28 buttonSize: 28
height: 28 iconSize: 14
radius: Theme.cornerRadius iconName: "today"
color: todayArea.containsMouse ? Theme.primaryHover : Theme.withAlpha(Theme.primaryHover, 0) iconColor: Theme.primary
onClicked: root.goToToday()
DankIcon {
anchors.centerIn: parent
name: "today"
size: 14
color: Theme.primary
}
MouseArea {
id: todayArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.goToToday()
}
} }
Rectangle { DankActionButton {
width: 28 buttonSize: 28
height: 28 iconSize: 14
radius: Theme.cornerRadius iconName: "chevron_right"
color: nextMonthArea.containsMouse ? Theme.primaryHover : Theme.withAlpha(Theme.primaryHover, 0) iconColor: Theme.primary
onClicked: root.shiftMonth(1)
DankIcon {
anchors.centerIn: parent
name: "chevron_right"
size: 14
color: Theme.primary
}
MouseArea {
id: nextMonthArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
let newDate = new Date(calendarGrid.displayDate);
newDate.setMonth(newDate.getMonth() + 1);
calendarGrid.displayDate = newDate;
loadEventsForMonth();
}
}
} }
} }
@@ -814,12 +732,16 @@ Rectangle {
} }
} }
readonly property bool isTask: modelData && modelData.id && modelData.id.startsWith("task_") readonly property bool isLocalTask: taskId.startsWith("task_")
readonly property color accentColor: { readonly property bool isDankTask: taskId.startsWith("vtodo_")
if (isTask) readonly property bool isTask: isLocalTask || isDankTask
return modelData.completed ? Theme.withAlpha(Theme.primary, 0.4) : Theme.primary; readonly property bool canModify: isLocalTask || (isDankTask && modelData && !modelData.readOnly)
return (modelData && modelData.color && modelData.color.length) ? modelData.color : Theme.primary; readonly property color baseAccent: {
if (isLocalTask || !modelData || !modelData.color || !modelData.color.length)
return Theme.primary;
return modelData.color;
} }
readonly property color accentColor: (isTask && modelData && modelData.completed) ? Theme.withAlpha(baseAccent, 0.4) : baseAccent
readonly property color surfaceColor: isDragging ? Theme.primaryPressed : (eventMouseArea.containsMouse ? Theme.primaryBackground : Theme.nestedSurface) readonly property color surfaceColor: isDragging ? Theme.primaryPressed : (eventMouseArea.containsMouse ? Theme.primaryBackground : Theme.nestedSurface)
color: surfaceColor color: surfaceColor
@@ -888,13 +810,13 @@ Rectangle {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
radius: Theme.cornerRadius radius: Theme.cornerRadius
color: "transparent" color: "transparent"
visible: modelData && modelData.id && modelData.id.startsWith("task_") && !taskItem.isEditing visible: taskItem.isLocalTask && !taskItem.isEditing
DankIcon { DankIcon {
anchors.centerIn: parent anchors.centerIn: parent
name: "drag_indicator" name: "drag_indicator"
size: 14 size: 14
color: dragMouseArea.containsMouse ? Theme.primary : Theme.surfaceTextAlpha color: dragMouseArea.containsMouse ? Theme.primary : Theme.surfaceTextMedium
} }
MouseArea { MouseArea {
@@ -937,34 +859,14 @@ Rectangle {
} }
} }
// Checkbox status icon
Rectangle {
id: checkboxContainer
width: 24
height: 24
anchors.left: parent.left
anchors.leftMargin: (modelData && modelData.id && modelData.id.startsWith("task_")) ? (taskItem.isEditing ? 8 : 32) : 8
anchors.verticalCenter: parent.verticalCenter
radius: Theme.cornerRadius
color: "transparent"
visible: modelData && modelData.id && modelData.id.startsWith("task_")
DankIcon {
anchors.centerIn: parent
name: (modelData && modelData.completed) ? "check_box" : "check_box_outline_blank"
size: 16
color: (modelData && modelData.completed) ? Theme.primary : Theme.onSurface_38
}
}
Column { Column {
id: eventContent id: eventContent
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: (modelData && modelData.id && modelData.id.startsWith("task_")) ? 60 : (Theme.spacingS + 6) anchors.leftMargin: taskItem.isLocalTask ? 60 : taskItem.isDankTask ? 36 : (Theme.spacingS + 6)
anchors.rightMargin: (modelData && modelData.id && modelData.id.startsWith("task_")) ? 64 : Theme.spacingXS anchors.rightMargin: taskItem.canModify ? 64 : Theme.spacingXS
spacing: Theme.spacingXXS spacing: Theme.spacingXXS
visible: !taskItem.isEditing visible: !taskItem.isEditing
@@ -972,7 +874,7 @@ Rectangle {
width: parent.width width: parent.width
text: modelData ? modelData.title : "" text: modelData ? modelData.title : ""
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
color: (modelData && modelData.id && modelData.id.startsWith("task_") && modelData.completed) ? Theme.surfaceTextSecondary : Theme.surfaceText color: (taskItem.isTask && modelData && modelData.completed) ? Theme.surfaceTextSecondary : Theme.surfaceText
font.weight: Font.Medium font.weight: Font.Medium
horizontalAlignment: Text.AlignLeft horizontalAlignment: Text.AlignLeft
elide: Text.ElideRight elide: Text.ElideRight
@@ -1000,7 +902,7 @@ Rectangle {
color: Theme.surfaceTextMedium color: Theme.surfaceTextMedium
font.weight: Font.Normal font.weight: Font.Normal
horizontalAlignment: Text.AlignLeft horizontalAlignment: Text.AlignLeft
visible: text !== "" && modelData && modelData.id && !modelData.id.startsWith("task_") visible: text !== "" && !taskItem.isLocalTask
} }
} }
@@ -1047,90 +949,73 @@ Rectangle {
id: eventMouseArea id: eventMouseArea
anchors.fill: parent anchors.fill: parent
anchors.leftMargin: (modelData && modelData.id && modelData.id.startsWith("task_")) ? 32 : 6 anchors.leftMargin: taskItem.isLocalTask ? 32 : 6
anchors.rightMargin: (modelData && modelData.id && modelData.id.startsWith("task_")) ? 64 : 0 anchors.rightMargin: taskItem.canModify ? 64 : 0
hoverEnabled: true hoverEnabled: true
cursorShape: modelData ? Qt.PointingHandCursor : Qt.ArrowCursor cursorShape: modelData ? Qt.PointingHandCursor : Qt.ArrowCursor
enabled: modelData && !taskItem.isEditing enabled: modelData && !taskItem.isEditing
onClicked: { onClicked: {
if (modelData && modelData.id && modelData.id.startsWith("task_")) { if (!modelData)
return;
if (taskItem.isTask && taskItem.canModify) {
CalendarService.toggleTask(modelData.id); CalendarService.toggleTask(modelData.id);
return; return;
} }
if (modelData) root.detailEvent = modelData;
root.detailEvent = modelData;
} }
} }
// Delete / Cancel Button DankActionButton {
Rectangle { buttonSize: 24
iconSize: 16
iconName: (modelData && modelData.completed) ? "check_box" : "check_box_outline_blank"
iconColor: (modelData && modelData.completed) ? Theme.primary : Theme.surfaceText
anchors.left: parent.left
anchors.leftMargin: taskItem.isLocalTask ? (taskItem.isEditing ? 8 : 32) : 8
anchors.verticalCenter: parent.verticalCenter
visible: taskItem.isTask
enabled: taskItem.canModify && !taskItem.isEditing
onClicked: CalendarService.toggleTask(modelData.id)
}
DankActionButton {
id: deleteButton id: deleteButton
width: 24 buttonSize: 24
height: 24 iconSize: 14
iconName: taskItem.isEditing ? "close" : "delete"
iconColor: taskItem.isEditing ? Theme.surfaceText : Theme.error
anchors.right: parent.right anchors.right: parent.right
anchors.rightMargin: Theme.spacingS anchors.rightMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
radius: Theme.cornerRadius visible: taskItem.canModify
color: deleteMouseArea.containsMouse ? (taskItem.isEditing ? Theme.primaryHover : Qt.rgba(0.9, 0.2, 0.2, 0.15)) : Theme.withAlpha(Qt.rgba(0.9, 0.2, 0.2, 0.15), 0) onClicked: {
visible: modelData && modelData.id && modelData.id.startsWith("task_") if (taskItem.isEditing) {
taskItem.isEditing = false;
DankIcon { return;
anchors.centerIn: parent
name: taskItem.isEditing ? "close" : "delete"
size: 14
color: deleteMouseArea.containsMouse ? (taskItem.isEditing ? Theme.primary : Qt.rgba(0.9, 0.2, 0.2, 1.0)) : Theme.onSurface_38
}
MouseArea {
id: deleteMouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (taskItem.isEditing) {
taskItem.isEditing = false;
} else if (modelData && modelData.id) {
CalendarService.removeTask(modelData.id);
}
} }
if (modelData && modelData.id)
CalendarService.removeTask(modelData.id);
} }
} }
// Edit / Save Button DankActionButton {
Rectangle { buttonSize: 24
id: editButton iconSize: 14
width: 24 iconName: taskItem.isEditing ? "check" : "edit"
height: 24 iconColor: taskItem.isEditing ? Theme.primary : Theme.surfaceText
anchors.right: deleteButton.left anchors.right: deleteButton.left
anchors.rightMargin: Theme.spacingXS anchors.rightMargin: Theme.spacingXS
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
radius: Theme.cornerRadius visible: taskItem.canModify
color: editMouseArea.containsMouse ? Theme.primaryHover : Theme.withAlpha(Theme.primaryHover, 0) onClicked: {
visible: modelData && modelData.id && modelData.id.startsWith("task_") if (!taskItem.isEditing) {
taskItem.isEditing = true;
DankIcon { return;
anchors.centerIn: parent
name: taskItem.isEditing ? "check" : "edit"
size: 14
color: editMouseArea.containsMouse ? Theme.primary : Theme.onSurface_38
}
MouseArea {
id: editMouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (taskItem.isEditing) {
let txt = editInput.text.trim();
if (txt !== "" && modelData && modelData.id) {
CalendarService.editTask(modelData.id, txt);
}
taskItem.isEditing = false;
} else {
taskItem.isEditing = true;
}
} }
let txt = editInput.text.trim();
if (txt !== "" && modelData && modelData.id)
CalendarService.editTask(modelData.id, txt);
taskItem.isEditing = false;
} }
} }
} }
+141 -130
View File
@@ -1,7 +1,8 @@
import Qt.labs.folderlistmodel import Qt.labs.folderlistmodel
import QtCore import QtCore
import QtQuick import QtQuick
import QtQuick.Effects import Quickshell
import Quickshell.Widgets
import qs.Common import qs.Common
import qs.Modals.FileBrowser import qs.Modals.FileBrowser
import qs.Widgets import qs.Widgets
@@ -20,12 +21,11 @@ Item {
property int itemsPerPage: 16 property int itemsPerPage: 16
property int totalPages: Math.max(1, Math.ceil(wallpaperFolderModel.count / itemsPerPage)) property int totalPages: Math.max(1, Math.ceil(wallpaperFolderModel.count / itemsPerPage))
property bool active: false property bool active: false
property Item focusTarget: wallpaperGrid property Item focusTarget: pager
property Item tabBarItem: null property Item tabBarItem: null
property int gridIndex: 0 property int gridIndex: 0
property Item keyForwardTarget: null property Item keyForwardTarget: null
property var parentPopout: null property var parentPopout: null
property int lastPage: 0
property bool enableAnimation: false property bool enableAnimation: false
property string homeDir: StandardPaths.writableLocation(StandardPaths.HomeLocation) property string homeDir: StandardPaths.writableLocation(StandardPaths.HomeLocation)
property string selectedFileName: "" property string selectedFileName: ""
@@ -86,12 +86,12 @@ Item {
} }
} }
onCurrentPageChanged: { onCurrentPageChanged: updateSelectedFileName()
if (currentPage !== lastPage) {
enableAnimation = false; onTotalPagesChanged: {
lastPage = currentPage; if (currentPage >= totalPages) {
currentPage = Math.max(0, totalPages - 1);
} }
updateSelectedFileName();
} }
onGridIndexChanged: { onGridIndexChanged: {
@@ -257,6 +257,7 @@ Item {
} }
function setInitialSelection() { function setInitialSelection() {
enableAnimation = false;
const currentWallpaper = getCurrentWallpaper(); const currentWallpaper = getCurrentWallpaper();
if (!currentWallpaper || wallpaperFolderModel.count === 0) { if (!currentWallpaper || wallpaperFolderModel.count === 0) {
gridIndex = 0; gridIndex = 0;
@@ -350,17 +351,6 @@ Item {
} }
} }
function collectWallpaperPaths() {
const paths = [];
for (var i = 0; i < wallpaperFolderModel.count; i++) {
const filePath = wallpaperFolderModel.get(i, "filePath");
if (filePath) {
paths.push(filePath.toString().replace(/^file:\/\//, ''));
}
}
return paths;
}
Connections { Connections {
target: wallpaperFolderModel target: wallpaperFolderModel
function onCountChanged() { function onCountChanged() {
@@ -369,7 +359,6 @@ Item {
setInitialSelection(); setInitialSelection();
} }
updateSelectedFileName(); updateSelectedFileName();
thumbnailPreloader.paths = collectWallpaperPaths();
} }
} }
function onStatusChanged() { function onStatusChanged() {
@@ -378,16 +367,10 @@ Item {
setInitialSelection(); setInitialSelection();
} }
updateSelectedFileName(); updateSelectedFileName();
thumbnailPreloader.paths = collectWallpaperPaths();
} }
} }
} }
WallpaperThumbnailPreloader {
id: thumbnailPreloader
cacheSize: 256
}
FolderListModel { FolderListModel {
id: wallpaperFolderModel id: wallpaperFolderModel
@@ -447,147 +430,169 @@ Item {
width: parent.width width: parent.width
height: parent.height - 50 height: parent.height - 50
GridView { ListView {
id: wallpaperGrid id: pager
anchors.centerIn: parent anchors.centerIn: parent
width: parent.width - Theme.spacingS width: parent.width - Theme.spacingS
height: parent.height - Theme.spacingS height: parent.height - Theme.spacingS
cellWidth: width / 4 orientation: ListView.Vertical
cellHeight: height / 4 snapMode: ListView.SnapOneItem
highlightRangeMode: ListView.StrictlyEnforceRange
preferredHighlightBegin: 0
preferredHighlightEnd: height
highlightMoveDuration: root.enableAnimation ? Theme.mediumDuration : 0
boundsBehavior: Flickable.StopAtBounds
clip: true clip: true
enabled: root.active enabled: root.active
interactive: root.active interactive: root.active
boundsBehavior: Flickable.StopAtBounds
keyNavigationEnabled: false keyNavigationEnabled: false
activeFocusOnTab: false activeFocusOnTab: false
highlightFollowsCurrentItem: true
highlightMoveDuration: enableAnimation ? Theme.shortDuration : 0
focus: false focus: false
cacheBuffer: Math.max(0, height * 2)
reuseItems: true
model: root.totalPages
highlight: Item { onCurrentIndexChanged: {
z: 1000 if (!moving) {
Rectangle { return;
anchors.fill: parent }
anchors.margins: Theme.spacingXS if (currentIndex >= 0 && currentIndex !== root.currentPage) {
color: "transparent" root.currentPage = currentIndex;
border.width: 3
border.color: Theme.primary
radius: Theme.cornerRadius
} }
} }
model: { Component.onCompleted: currentIndex = root.currentPage
root.gridRevision; // re-evaluate when sort order changes in place
const startIndex = currentPage * itemsPerPage;
const endIndex = Math.min(startIndex + itemsPerPage, wallpaperFolderModel.count);
const items = [];
for (var i = startIndex; i < endIndex; i++) {
const filePath = wallpaperFolderModel.get(i, "filePath");
if (filePath) {
items.push(filePath.toString().replace(/^file:\/\//, ''));
}
}
return items;
}
onModelChanged: {
const clampedIndex = model.length > 0 ? Math.min(Math.max(0, gridIndex), model.length - 1) : 0;
if (gridIndex !== clampedIndex) {
gridIndex = clampedIndex;
}
}
onCountChanged: {
if (count > 0) {
const clampedIndex = Math.min(gridIndex, count - 1);
currentIndex = clampedIndex;
positionViewAtIndex(clampedIndex, GridView.Contain);
}
Qt.callLater(() => {
enableAnimation = true;
});
}
Connections { Connections {
target: root target: root
function onGridIndexChanged() { function onCurrentPageChanged() {
if (wallpaperGrid.count > 0) { if (pager.currentIndex !== root.currentPage) {
wallpaperGrid.currentIndex = gridIndex; pager.currentIndex = root.currentPage;
if (!enableAnimation) {
wallpaperGrid.positionViewAtIndex(gridIndex, GridView.Contain);
}
} }
} }
} }
delegate: Item { delegate: GridView {
width: wallpaperGrid.cellWidth id: pageGrid
height: wallpaperGrid.cellHeight
property string wallpaperPath: modelData || "" property int pageIndex: index
property bool isSelected: getCurrentWallpaper() === modelData
Rectangle { width: pager.width
id: wallpaperCard height: pager.height
anchors.fill: parent cellWidth: width / 4
anchors.margins: Theme.spacingXS cellHeight: height / 4
color: Theme.withAlpha(Theme.surfaceContainerHighest, Theme.popupTransparency) interactive: false
radius: Theme.cornerRadius keyNavigationEnabled: false
clip: true activeFocusOnTab: false
focus: false
highlightFollowsCurrentItem: true
highlightMoveDuration: root.enableAnimation ? Theme.shortDuration : 0
currentIndex: root.currentPage === pageIndex ? root.gridIndex : -1
highlight: Item {
z: 1000
Rectangle { Rectangle {
anchors.fill: parent anchors.fill: parent
color: isSelected ? Theme.primaryPressed : Theme.withAlpha(Theme.primaryPressed, 0) anchors.margins: Theme.spacingXS
radius: parent.radius color: "transparent"
border.width: 3
border.color: Theme.primary
radius: Theme.cornerRadius
}
}
Behavior on color { reuseItems: true
ColorAnimation { model: ScriptModel {
duration: Theme.shortDuration values: {
easing.type: Theme.standardEasing root.gridRevision; // re-evaluate when sort order changes in place
const startIndex = pageGrid.pageIndex * root.itemsPerPage;
const endIndex = Math.min(startIndex + root.itemsPerPage, wallpaperFolderModel.count);
const items = [];
for (var i = startIndex; i < endIndex; i++) {
const filePath = wallpaperFolderModel.get(i, "filePath");
if (filePath) {
items.push(filePath.toString().replace(/^file:\/\//, ''));
} }
} }
return items;
} }
}
onCountChanged: {
if (root.currentPage !== pageIndex || count === 0) {
return;
}
if (root.gridIndex >= count) {
root.gridIndex = count - 1;
}
}
delegate: Item {
width: pageGrid.cellWidth
height: pageGrid.cellHeight
property string wallpaperPath: modelData || ""
property bool isSelected: getCurrentWallpaper() === modelData
Rectangle { Rectangle {
id: maskRect id: wallpaperCard
width: thumbnailImage.width anchors.fill: parent
height: thumbnailImage.height anchors.margins: Theme.spacingXS
color: Theme.withAlpha(Theme.surfaceContainerHighest, Theme.popupTransparency)
radius: Theme.cornerRadius radius: Theme.cornerRadius
visible: false
layer.enabled: true
}
CachingImage { Rectangle {
id: thumbnailImage anchors.fill: parent
anchors.fill: parent color: isSelected ? Theme.primaryPressed : Theme.withAlpha(Theme.primaryPressed, 0)
imagePath: modelData || "" radius: parent.radius
maxCacheSize: 256
layer.enabled: true Behavior on color {
layer.effect: MultiEffect { ColorAnimation {
maskEnabled: true duration: Theme.shortDuration
maskThresholdMin: 0.5 easing.type: Theme.standardEasing
maskSpreadAtMin: 1.0 }
maskSource: maskRect }
} }
}
StateLayer { ClippingRectangle {
anchors.fill: parent anchors.fill: parent
cornerRadius: parent.radius radius: wallpaperCard.radius
stateColor: Theme.primary color: "transparent"
}
MouseArea { CachingImage {
id: wallpaperMouseArea id: thumbnailImage
anchors.fill: parent anchors.fill: parent
hoverEnabled: true imagePath: modelData || ""
cursorShape: Qt.PointingHandCursor maxCacheSize: 256
animate: false
opacity: status === Image.Ready ? 1 : 0
onClicked: { Behavior on opacity {
gridIndex = index; NumberAnimation {
if (modelData) { duration: Theme.shortDuration
setCurrentWallpaper(modelData); easing.type: Theme.standardEasing
}
}
}
}
StateLayer {
anchors.fill: parent
cornerRadius: parent.radius
stateColor: Theme.primary
}
MouseArea {
id: wallpaperMouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
gridIndex = index;
if (modelData) {
setCurrentWallpaper(modelData);
}
} }
} }
} }
@@ -595,9 +600,15 @@ Item {
} }
} }
DankSpinner {
anchors.centerIn: parent
size: 40
visible: wallpaperFolderModel.status === FolderListModel.Loading && wallpaperFolderModel.count === 0
}
StyledText { StyledText {
anchors.centerIn: parent anchors.centerIn: parent
visible: wallpaperFolderModel.count === 0 visible: wallpaperFolderModel.status === FolderListModel.Ready && wallpaperFolderModel.count === 0
text: I18n.tr("No wallpapers found\n\nClick the folder icon below to browse") text: I18n.tr("No wallpapers found\n\nClick the folder icon below to browse")
font.pixelSize: 14 font.pixelSize: 14
color: Theme.outline color: Theme.outline
@@ -168,6 +168,19 @@ PanelWindow {
popupContextMenuLoader.active = false; popupContextMenuLoader.active = false;
} }
function dismissPopupReliably() {
if (!notificationData || win.exiting || win._isDestroying)
return;
if (notificationData.timer)
notificationData.timer.stop();
notificationData.popup = false;
// Fallback if wrapperConn.onPopupChanged doesn't reach startExit.
Qt.callLater(() => {
if (!win.exiting && !win._isDestroying)
startExit();
});
}
visible: !_finalized visible: !_finalized
WlrLayershell.layer: { WlrLayershell.layer: {
const shouldUseOverlay = notificationData && (SettingsData.notificationOverlayEnabled || notificationData.urgency === NotificationUrgency.Critical); const shouldUseOverlay = notificationData && (SettingsData.notificationOverlayEnabled || notificationData.urgency === NotificationUrgency.Critical);
@@ -1007,8 +1020,7 @@ PanelWindow {
z: 15 z: 15
onClicked: { onClicked: {
if (notificationData && !win.exiting) dismissPopupReliably();
notificationData.popup = false;
} }
} }
@@ -1080,8 +1092,7 @@ PanelWindow {
onClicked: { onClicked: {
if (modelData && modelData.invoke) if (modelData && modelData.invoke)
modelData.invoke(); modelData.invoke();
if (notificationData && !win.exiting) dismissPopupReliably();
notificationData.popup = false;
} }
} }
} }
@@ -1163,7 +1174,7 @@ PanelWindow {
notificationData.actions[0].invoke(); notificationData.actions[0].invoke();
NotificationService.dismissNotification(notificationData); NotificationService.dismissNotification(notificationData);
} else { } else {
notificationData.popup = false; dismissPopupReliably();
} }
} }
} }
@@ -368,7 +368,7 @@ Item {
hoverEnabled: true hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onClicked: { onClicked: {
deleteVpnConfirm.showWithOptions({ root.deleteVpnConfirm.showWithOptions({
title: I18n.tr("Delete VPN"), title: I18n.tr("Delete VPN"),
message: I18n.tr("Delete \"%1\"?").arg(modelData.name), message: I18n.tr("Delete \"%1\"?").arg(modelData.name),
confirmText: I18n.tr("Delete"), confirmText: I18n.tr("Delete"),
+191 -2
View File
@@ -21,6 +21,9 @@ Item {
property var calendars: [] property var calendars: []
property var events: [] property var events: []
property var eventsByDate: ({}) property var eventsByDate: ({})
property var tasks: []
property var tasksByDate: ({})
property var _pendingTaskState: ({})
property string lastError: "" property string lastError: ""
property date focusDate: new Date() property date focusDate: new Date()
property var _loadedFrom: null property var _loadedFrom: null
@@ -130,6 +133,7 @@ Item {
root.log.info("connected to dankcal:", root.socketPath); root.log.info("connected to dankcal:", root.socketPath);
root.refreshCalendars(); root.refreshCalendars();
root.reloadEvents(); root.reloadEvents();
root.reloadTasks();
return; return;
} }
if (!root.connected && !root.socketFound) if (!root.connected && !root.socketFound)
@@ -193,12 +197,19 @@ Item {
} }
} }
Timer {
id: tasksDebounce
interval: 400
repeat: false
onTriggered: root.reloadTasks()
}
function _sendSubscribe() { function _sendSubscribe() {
subscribeSocket.send({ subscribeSocket.send({
"id": _nextId(), "id": _nextId(),
"method": "subscribe", "method": "subscribe",
"params": { "params": {
"topics": ["accounts", "calendars", "events", "sync"] "topics": ["accounts", "calendars", "events", "tasks", "sync"]
} }
}); });
} }
@@ -243,8 +254,14 @@ Item {
refreshDebounce.restart(); refreshDebounce.restart();
break; break;
case "events": case "events":
refreshDebounce.restart();
break;
case "tasks":
tasksDebounce.restart();
break;
case "sync": case "sync":
refreshDebounce.restart(); refreshDebounce.restart();
tasksDebounce.restart();
break; break;
} }
} }
@@ -282,6 +299,7 @@ Item {
} }
calendars = list; calendars = list;
_rebuildEventsByDate(); _rebuildEventsByDate();
_rebuildTasksByDate();
}); });
} }
@@ -353,7 +371,7 @@ Item {
function _normalizeEvent(e) { function _normalizeEvent(e) {
const allDay = !!e.allDay; const allDay = !!e.allDay;
const id = e.id || ""; const id = e.id || "";
if (id.startsWith("task_")) if (id.startsWith("task_") || id.startsWith("vtodo_"))
log.warn("daemon event id collides with task prefix:", id); log.warn("daemon event id collides with task prefix:", id);
return { return {
"id": id, "id": id,
@@ -478,4 +496,175 @@ Item {
callback(response); callback(response);
}); });
} }
function reloadTasks() {
if (!connected)
return;
sendRequest("tasks.list", {
"includeCompleted": true,
"limit": 5000
}, response => {
if (response.error) {
lastError = response.error;
return;
}
const raw = (response.result || {}).tasks || [];
tasks = raw.map(t => _applyPendingState(_normalizeTask(t)));
_rebuildTasksByDate();
});
}
// A completion toggle is applied optimistically; slow providers can serve a
// reload that predates the write, so the desired state wins over a
// disagreeing reload until the daemon confirms it or the hold expires.
function _applyPendingState(t) {
const pending = _pendingTaskState[t.id];
if (!pending)
return t;
if (t.completed === pending.completed || Date.now() > pending.expires) {
delete _pendingTaskState[t.id];
return t;
}
return Object.assign({}, t, {
"completed": pending.completed,
"status": pending.completed ? "completed" : "needs_action"
});
}
function _normalizeTask(t) {
const allDay = !!t.allDay;
return {
"id": t.id || "",
"calendarId": t.calendarId || "",
"title": t.summary || "(untitled)",
"description": t.description || "",
"location": t.location || "",
"status": t.status || "needs_action",
"completed": t.status === "completed",
"priority": t.priority || 0,
"due": t.due ? (allDay ? _dayBoundary(t.due) : new Date(t.due)) : null,
"allDay": allDay
};
}
function taskById(id) {
for (let i = 0; i < tasks.length; i++) {
if (tasks[i].id === id)
return tasks[i];
}
return null;
}
function taskCalendars() {
return calendars.filter(c => c.holdsTasks && !c.readOnly);
}
function defaultTaskCalendar() {
const writable = taskCalendars().filter(c => !c.hidden);
return writable.length > 0 ? writable[0] : null;
}
function _taskAsEvent(t) {
const cal = calendarById(t.calendarId);
return {
"id": "vtodo_" + t.id,
"title": t.title,
"description": t.description,
"location": t.location,
"url": "",
"calendar": cal ? cal.name : "",
"color": cal ? cal.color : fallbackPalette[0],
"readOnly": cal ? !!cal.readOnly : false,
"start": t.due,
"end": t.due,
"allDay": t.allDay,
"completed": t.completed,
"status": t.status,
"isMultiDay": false
};
}
function _rebuildTasksByDate() {
const hidden = _hiddenCalendarIds();
const map = {};
for (const t of tasks) {
if (!t.due || t.status === "cancelled" || hidden[t.calendarId])
continue;
const key = Qt.formatDate(t.due, "yyyy-MM-dd");
if (!map[key])
map[key] = [];
map[key].push(_taskAsEvent(t));
}
tasksByDate = map;
}
function _patchTask(id, changes) {
const next = tasks.slice();
for (let i = 0; i < next.length; i++) {
if (next[i].id !== id)
continue;
next[i] = Object.assign({}, next[i], changes);
break;
}
tasks = next;
_rebuildTasksByDate();
}
function createTask(fields, callback) {
sendRequest("tasks.create", fields, response => {
if (response.error)
lastError = response.error;
reloadTasks();
if (callback)
callback(response);
});
}
function updateTask(id, fields, callback) {
const params = Object.assign({
"id": id
}, fields);
sendRequest("tasks.update", params, response => {
if (response.error)
lastError = response.error;
reloadTasks();
if (callback)
callback(response);
});
}
function completeTask(id, completed, callback) {
_pendingTaskState[id] = {
"completed": completed,
"expires": Date.now() + 15000
};
_patchTask(id, {
"completed": completed,
"status": completed ? "completed" : "needs_action"
});
sendRequest("tasks.complete", {
"id": id,
"completed": completed
}, response => {
if (response.error) {
lastError = response.error;
delete _pendingTaskState[id];
reloadTasks();
}
if (callback)
callback(response);
});
}
function deleteTask(id, callback) {
sendRequest("tasks.delete", {
"id": id
}, response => {
if (response.error)
lastError = response.error;
reloadTasks();
if (callback)
callback(response);
});
}
} }
+43 -8
View File
@@ -63,6 +63,7 @@ Singleton {
id: dankBackend id: dankBackend
enabled: root.backendPref === "dankcal" || root.backendPref === "auto" enabled: root.backendPref === "dankcal" || root.backendPref === "auto"
onEventsByDateChanged: root.mergeEvents() onEventsByDateChanged: root.mergeEvents()
onTasksByDateChanged: root.mergeEvents()
onConnectedChanged: { onConnectedChanged: {
if (connected && root._rangeSet) if (connected && root._rangeSet)
root.loadEvents(root.lastStartDate, root.lastEndDate); root.loadEvents(root.lastStartDate, root.lastEndDate);
@@ -198,6 +199,17 @@ Singleton {
} }
function addTaskForDate(date, text) { function addTaskForDate(date, text) {
const taskCal = isDankActive && dankBackend.connected ? dankBackend.defaultTaskCalendar() : null;
if (taskCal) {
const due = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
dankBackend.createTask({
"calendarId": taskCal.id,
"summary": text,
"allDay": true,
"due": due.toISOString()
});
return;
}
let dateKey = Qt.formatDate(date, "yyyy-MM-dd"); let dateKey = Qt.formatDate(date, "yyyy-MM-dd");
let tasks = Object.assign({}, root.localTasks); let tasks = Object.assign({}, root.localTasks);
if (!tasks[dateKey]) if (!tasks[dateKey])
@@ -214,6 +226,13 @@ Singleton {
} }
function toggleTask(taskId) { function toggleTask(taskId) {
if (taskId.startsWith("vtodo_")) {
const id = taskId.slice(6);
const task = dankBackend.taskById(id);
if (task)
dankBackend.completeTask(id, !task.completed);
return;
}
let cleanId = taskId.replace("task_", ""); let cleanId = taskId.replace("task_", "");
let tasks = Object.assign({}, root.localTasks); let tasks = Object.assign({}, root.localTasks);
let updated = false; let updated = false;
@@ -237,6 +256,10 @@ Singleton {
} }
function removeTask(taskId) { function removeTask(taskId) {
if (taskId.startsWith("vtodo_")) {
dankBackend.deleteTask(taskId.slice(6));
return;
}
let cleanId = taskId.replace("task_", ""); let cleanId = taskId.replace("task_", "");
let tasks = Object.assign({}, root.localTasks); let tasks = Object.assign({}, root.localTasks);
let updated = false; let updated = false;
@@ -283,6 +306,12 @@ Singleton {
} }
function editTask(taskId, newText) { function editTask(taskId, newText) {
if (taskId.startsWith("vtodo_")) {
dankBackend.updateTask(taskId.slice(6), {
"summary": newText
});
return;
}
let cleanId = taskId.replace("task_", ""); let cleanId = taskId.replace("task_", "");
let tasks = Object.assign({}, root.localTasks); let tasks = Object.assign({}, root.localTasks);
let updated = false; let updated = false;
@@ -305,6 +334,17 @@ Singleton {
} }
} }
function _mergeInto(merged, byDate) {
for (let dateKey in byDate) {
if (!merged[dateKey])
merged[dateKey] = [];
for (let event of byDate[dateKey]) {
if (!merged[dateKey].some(e => e.id === event.id))
merged[dateKey].push(event);
}
}
}
function mergeEvents() { function mergeEvents() {
let merged = {}; let merged = {};
let backendEvents = _activeBackendEventsByDate(); let backendEvents = _activeBackendEventsByDate();
@@ -312,14 +352,9 @@ Singleton {
for (let dateKey in backendEvents) for (let dateKey in backendEvents)
merged[dateKey] = [].concat(backendEvents[dateKey]); merged[dateKey] = [].concat(backendEvents[dateKey]);
for (let dateKey in root.taskEventsByDate) { _mergeInto(merged, root.taskEventsByDate);
if (!merged[dateKey]) if (isDankActive)
merged[dateKey] = []; _mergeInto(merged, dankBackend.tasksByDate);
for (let event of root.taskEventsByDate[dateKey]) {
if (!merged[dateKey].some(e => e.id === event.id))
merged[dateKey].push(event);
}
}
for (let dateKey in merged) { for (let dateKey in merged) {
let list = merged[dateKey]; let list = merged[dateKey];
+9 -6
View File
@@ -341,10 +341,11 @@ Singleton {
const borderSize = (typeof SettingsData !== "undefined" && SettingsData.hyprlandLayoutBorderSize >= 0) ? SettingsData.hyprlandLayoutBorderSize : defaultBorderSize; const borderSize = (typeof SettingsData !== "undefined" && SettingsData.hyprlandLayoutBorderSize >= 0) ? SettingsData.hyprlandLayoutBorderSize : defaultBorderSize;
const resizeOnBorder = (typeof SettingsData !== "undefined" && SettingsData.hyprlandResizeOnBorder) ? true : false; const resizeOnBorder = (typeof SettingsData !== "undefined" && SettingsData.hyprlandResizeOnBorder) ? true : false;
const frameEnabled = typeof SettingsData !== "undefined" && SettingsData.frameEnabled; const frameEnabled = typeof SettingsData !== "undefined" && SettingsData.frameEnabled;
const frameConnectedMode = frameEnabled && SettingsData.frameMode === "connected";
// Hyprland `xray = false` is still early-development; unset already samples real content, so only force xray=true // Hyprland `xray = false` is still early-development; unset already samples real content, so only force xray=true
// Connected frame mode has no separate bar/frame surface to target // dms:frame only in separate mode connected-mode frame blur overlaps windows via popouts/arcs
const barFrameTargetNamespace = !frameEnabled ? (SettingsData.standaloneBarXrayAvailable ? "dms:bar" : null) : (frameConnectedMode ? null : "dms:frame"); const xrayNamespaces = ["dms:bar"];
if (frameEnabled && SettingsData.frameMode !== "connected")
xrayNamespaces.push("dms:frame");
const generalLines = []; const generalLines = [];
if (manageGaps) if (manageGaps)
@@ -371,13 +372,15 @@ hl.layer_rule({
}) })
`; `;
} }
if (layoutBarXrayEnabled && barFrameTargetNamespace) { if (layoutBarXrayEnabled) {
content += ` for (const ns of xrayNamespaces) {
content += `
hl.layer_rule({ hl.layer_rule({
match = { namespace = "^${barFrameTargetNamespace}$" }, match = { namespace = "^${ns}$" },
xray = true, xray = true,
}) })
`; `;
}
} }
// Marker persists the preference even while the rule has no target // Marker persists the preference even while the rule has no target
if (!layoutBarXrayEnabled) { if (!layoutBarXrayEnabled) {
+6 -7
View File
@@ -1189,19 +1189,18 @@ Singleton {
const gaps = gapsOverride >= 0 ? gapsOverride : defaultGaps; const gaps = gapsOverride >= 0 ? gapsOverride : defaultGaps;
const borderSize = (typeof SettingsData !== "undefined" && SettingsData.niriLayoutBorderSize >= 0) ? SettingsData.niriLayoutBorderSize : defaultBorderSize; const borderSize = (typeof SettingsData !== "undefined" && SettingsData.niriLayoutBorderSize >= 0) ? SettingsData.niriLayoutBorderSize : defaultBorderSize;
const frameEnabled = typeof SettingsData !== "undefined" && SettingsData.frameEnabled; const frameEnabled = typeof SettingsData !== "undefined" && SettingsData.frameEnabled;
const frameConnectedMode = frameEnabled && SettingsData.frameMode === "connected"; // dms:frame only in separate mode connected-mode frame blur overlaps windows via popouts/arcs
// Connected frame mode has no separate bar/frame surface to target const excludeNamespaces = ["dms:bar"];
const barFrameTargetNamespace = !frameEnabled ? (SettingsData.standaloneBarXrayAvailable ? "dms:bar" : null) : (frameConnectedMode ? null : "dms:frame"); if (frameEnabled && SettingsData.frameMode !== "connected")
excludeNamespaces.push("dms:frame");
// Xray is niri's default blur, so only the off state needs a rule. // Xray is niri's default blur, so only the off state needs a rule.
// A single rule excluding the bar/frame keeps its blur on the wallpaper
// without a second namespace-matched override rule.
let xrayRules = ""; let xrayRules = "";
if (!layoutXrayEnabled) { if (!layoutXrayEnabled) {
const excludeLine = (layoutBarXrayEnabled && barFrameTargetNamespace) ? `\n exclude namespace="^${barFrameTargetNamespace}$"` : ""; const excludeLines = layoutBarXrayEnabled ? excludeNamespaces.map(ns => `\n exclude namespace="^${ns}$"`).join("") : "";
xrayRules += ` xrayRules += `
layer-rule {${excludeLine} layer-rule {${excludeLines}
background-effect { background-effect {
xray false xray false
} }
+34 -37
View File
@@ -1,5 +1,4 @@
import QtQuick import QtQuick
import Quickshell.Io
import qs.Common import qs.Common
Item { Item {
@@ -9,10 +8,14 @@ Item {
property int maxCacheSize: 512 property int maxCacheSize: 512
property int status: isAnimated ? animatedImg.status : staticImg.status property int status: isAnimated ? animatedImg.status : staticImg.status
property int fillMode: Image.PreserveAspectCrop property int fillMode: Image.PreserveAspectCrop
// AnimatedImage decodes full-size on the GUI thread and is never cached;
// disable for thumbnail grids
property bool animate: true
property bool _fromCache: false
readonly property bool isRemoteUrl: imagePath.startsWith("http://") || imagePath.startsWith("https://") readonly property bool isRemoteUrl: imagePath.startsWith("http://") || imagePath.startsWith("https://")
readonly property bool isAnimated: { readonly property bool isAnimated: {
if (!imagePath) if (!animate || !imagePath)
return false; return false;
const lower = imagePath.toLowerCase(); const lower = imagePath.toLowerCase();
return lower.endsWith(".gif") || lower.endsWith(".webp"); return lower.endsWith(".gif") || lower.endsWith(".webp");
@@ -39,7 +42,8 @@ Item {
} }
readonly property string imageHash: normalizedPath ? djb2Hash(normalizedPath) : "" readonly property string imageHash: normalizedPath ? djb2Hash(normalizedPath) : ""
readonly property string cachePath: imageHash && !isRemoteUrl && !isAnimated ? `${Paths.stringify(Paths.imagecache)}/${imageHash}@${maxCacheSize}x${maxCacheSize}.png` : "" readonly property string cacheFileName: imageHash && !isRemoteUrl && !isAnimated ? `${imageHash}@${maxCacheSize}x${maxCacheSize}.png` : ""
readonly property string cachePath: cacheFileName ? `${Paths.stringify(Paths.imagecache)}/${cacheFileName}` : ""
readonly property string encodedImagePath: { readonly property string encodedImagePath: {
if (!normalizedPath) if (!normalizedPath)
return ""; return "";
@@ -69,58 +73,51 @@ Item {
smooth: true smooth: true
onStatusChanged: { onStatusChanged: {
if (source === root.cachePath && status === Image.Error) { switch (status) {
case Image.Error:
if (!root._fromCache)
return;
root._fromCache = false;
source = root.encodedImagePath; source = root.encodedImagePath;
return; return;
} case Image.Ready:
if (root.isRemoteUrl || source !== root.encodedImagePath || status !== Image.Ready || !root.cachePath) if (root._fromCache || root.isRemoteUrl || !root.cachePath)
return;
if (!visible || width <= 0 || height <= 0 || !Window.window?.visible)
return;
const grabPath = root.cachePath;
grabToImage(res => {
res.saveToFile(grabPath);
});
return; return;
Paths.mkdir(Paths.imagecache);
const grabPath = root.cachePath;
if (visible && width > 0 && height > 0 && Window.window?.visible) {
grabToImage(res => res.saveToFile(grabPath));
} }
} }
} }
Process { function resolveSource() {
id: cacheProbe
property string cachePath: ""
property string fallbackSource: ""
running: false
command: ["test", "-f", cachePath]
onExited: exitCode => {
if (cacheProbe.cachePath !== root.cachePath)
return;
staticImg.source = exitCode === 0 ? cacheProbe.cachePath : cacheProbe.fallbackSource;
}
}
onImagePathChanged: {
if (!imagePath) { if (!imagePath) {
_fromCache = false;
staticImg.source = ""; staticImg.source = "";
return; return;
} }
if (isAnimated) if (isAnimated)
return; return;
if (isRemoteUrl) { if (isRemoteUrl) {
_fromCache = false;
staticImg.source = imagePath; staticImg.source = imagePath;
return; return;
} }
Paths.mkdir(Paths.imagecache); if (!cachePath) {
const hash = djb2Hash(normalizedPath); _fromCache = false;
const cPath = hash ? `${Paths.stringify(Paths.imagecache)}/${hash}@${maxCacheSize}x${maxCacheSize}.png` : ""; staticImg.source = encodedImagePath;
const encoded = "file://" + normalizedPath.split('/').map(s => encodeURIComponent(s)).join('/');
if (!cPath) {
staticImg.source = encoded;
return; return;
} }
cacheProbe.running = false; // Cache-first; a miss errors and falls back to encodedImagePath
cacheProbe.cachePath = cPath; _fromCache = true;
cacheProbe.fallbackSource = encoded; staticImg.source = cachePath;
cacheProbe.running = true;
} }
onImagePathChanged: resolveSource()
// During creation onImagePathChanged fires before sibling properties (maxCacheSize) initialize
onCachePathChanged: resolveSource()
} }
+41
View File
@@ -0,0 +1,41 @@
import QtQuick
import Quickshell.Hyprland
import qs.Common
// Delays grab release so keyboardFocus=None commits before the grab dies,
// keeping Hyprland from handing focus back to the closing surface (#2577)
HyprlandFocusGrab {
id: root
property bool wanted: false
property bool _held: false
property bool _compositorCleared: false
property var _restoreToplevel: null
property Timer _releaseTimer: Timer {
interval: 50
onTriggered: {
root._held = false;
root.active = false;
root._restoreToplevel = root._compositorCleared ? null : KeyboardFocus.restoreToplevel(root._restoreToplevel);
}
}
onWantedChanged: _sync()
Component.onCompleted: _sync()
function _sync() {
if (!wanted) {
if (_held)
_releaseTimer.restart();
return;
}
_releaseTimer.stop();
_held = true;
_compositorCleared = false;
_restoreToplevel = KeyboardFocus.captureActiveToplevel();
active = true;
}
onCleared: _compositorCleared = true
}
+2 -6
View File
@@ -1,5 +1,4 @@
import QtQuick import QtQuick
import Quickshell.Hyprland
import qs.Common import qs.Common
import qs.Services import qs.Services
@@ -62,7 +61,7 @@ Item {
} }
// Hyprland OnDemand grab: whitelist popout surfaces and bars so dismiss clicks still land. // Hyprland OnDemand grab: whitelist popout surfaces and bars so dismiss clicks still land.
HyprlandFocusGrab { DankFocusGrab {
windows: { windows: {
const list = []; const list = [];
if (root.contentWindow) if (root.contentWindow)
@@ -72,10 +71,7 @@ Item {
const transientWindows = root.transientSurfaceTracker?.focusWindows ?? []; const transientWindows = root.transientSurfaceTracker?.focusWindows ?? [];
return list.concat(transientWindows).concat(KeyboardFocus.barWindows); return list.concat(transientWindows).concat(KeyboardFocus.barWindows);
} }
active: KeyboardFocus.wantsGrab(root.shouldBeVisible, root.customKeyboardFocus) wanted: KeyboardFocus.wantsGrab(root.shouldBeVisible, root.customKeyboardFocus)
property var restoreToplevel: null
onActiveChanged: restoreToplevel = active ? KeyboardFocus.captureActiveToplevel() : KeyboardFocus.restoreToplevel(restoreToplevel)
} }
Loader { Loader {
@@ -1,94 +0,0 @@
pragma ComponentBehavior: Bound
import QtQuick
import qs.Common
// Preload the CachingImage disk cache for a folder of wallpapers via ffmpegthumbnailer
// so the switcher grid renders instantly. No-op (graceful fallback) if the tool is absent.
Item {
id: root
visible: false
property var paths: []
property int cacheSize: 256
property bool autoStart: true
property int maxConcurrent: 3
property int _active: 0
property var _queue: []
property int _toolState: -1 // -1 unknown, 0 unavailable, 1 available
onPathsChanged: if (autoStart)
preload()
// Must match djb2Hash + cachePath in Widgets/CachingImage.qml.
function _hash(str) {
if (!str)
return "";
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash) + str.charCodeAt(i);
hash = hash & 0x7FFFFFFF;
}
return hash.toString(16).padStart(8, '0');
}
function _cachePathFor(path) {
const hash = _hash(path);
if (!hash)
return "";
return `${Paths.stringify(Paths.imagecache)}/${hash}@${cacheSize}x${cacheSize}.png`;
}
function _isAnimated(path) {
const lower = path.toLowerCase();
return lower.endsWith(".gif") || lower.endsWith(".webp");
}
function preload() {
if (!paths || paths.length === 0 || _toolState === 0)
return;
if (_toolState === -1) {
Proc.runCommand("wallpaperThumbToolCheck", ["sh", "-c", "command -v ffmpegthumbnailer"], function (out, code) {
root._toolState = code === 0 ? 1 : 0;
if (root._toolState === 1)
root._start();
});
return;
}
_start();
}
function _start() {
Paths.mkdir(Paths.imagecache);
const q = [];
for (let i = 0; i < paths.length; i++) {
const p = paths[i];
if (!p || p.startsWith("#") || _isAnimated(p))
continue;
q.push(p);
}
_queue = q;
for (let i = 0; i < maxConcurrent; i++)
_pump();
}
function _pump() {
if (_queue.length === 0)
return;
const path = _queue.shift();
const cachePath = _cachePathFor(path);
if (!cachePath) {
_pump();
return;
}
_active++;
// One process per file: skip if already cached, otherwise generate.
const script = "test -f \"$1\" || ffmpegthumbnailer -i \"$2\" -o \"$1\" -s " + cacheSize;
Proc.runCommand(null, ["sh", "-c", script, "thumb", cachePath, path], function (out, code) {
root._active--;
root._pump();
}, 0, 20000);
}
}