1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-08-02 19:48:27 -04:00

Compare commits

..

1 Commits

Author SHA1 Message Date
purian23 d91481c464 fix(nix): restore pre-wrap Qt paths for launcher-spawned apps 2026-07-04 23:10:49 -04:00
56 changed files with 633 additions and 1706 deletions
-17
View File
@@ -1,17 +0,0 @@
[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,6 +774,5 @@ func getCommonCommands() []*cobra.Command {
trashCmd, trashCmd,
systemCmd, systemCmd,
switchUserCmd, switchUserCmd,
trayWatcherCmd,
} }
} }
-24
View File
@@ -1,24 +0,0 @@
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)
}
},
}
+2 -9
View File
@@ -582,11 +582,7 @@ func runShellDaemon(session bool) {
} }
var qsHasAnyDisplay = sync.OnceValue(func() bool { var qsHasAnyDisplay = sync.OnceValue(func() bool {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) out, err := exec.Command("qs", "ipc", "--help").Output()
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
} }
@@ -646,10 +642,7 @@ func getShellIPCCompletions(args []string, _ string) []string {
return nil return nil
} }
cmdArgs := append(baseArgs, "show") cmdArgs := append(baseArgs, "show")
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) cmd := exec.Command("qs", cmdArgs...)
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 {
+2 -3
View File
@@ -880,9 +880,8 @@ func (cd *ConfigDeployer) transformNiriConfigForNonSystemd(config, terminalComma
config = regexp.MustCompile(`environment \{[^}]*\}`).ReplaceAllString(config, envVars) config = regexp.MustCompile(`environment \{[^}]*\}`).ReplaceAllString(config, envVars)
// Watcher spawns first so it owns the SNI name before dms/tray apps start. spawnDms := `spawn-at-startup "dms" "run"`
spawnDms := "spawn-at-startup \"dms\" \"tray-watcher\"\nspawn-at-startup \"dms\" \"run\"" if !strings.Contains(config, spawnDms) {
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,21 +520,9 @@ 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")
+1 -3
View File
@@ -7,9 +7,7 @@ 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. The tray watcher starts first so it owns # spawn a new shell on every reload.
# 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,7 +116,6 @@ 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
+2 -4
View File
@@ -587,15 +587,13 @@ 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", "dms-tray-watcher").Run(); err != nil { if err := exec.CommandContext(ctx, "systemctl", "--user", "add-wants", "niri.service", "dms").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", "dms-tray-watcher").Run(); err != nil { if err := exec.CommandContext(ctx, "systemctl", "--user", "add-wants", "hyprland-session.target", "dms").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 := expandLuaConfigLines(strings.Split(string(data), "\n")) lines := 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 {
@@ -1,414 +0,0 @@
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
}
@@ -1,134 +0,0 @@
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 := expandLuaConfigLines(strings.Split(content, "\n")) lines := 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,9 +1056,6 @@ 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,18 +545,12 @@ 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) {
secrets := make(map[string]string) pw := reply.Secrets["password"]
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: reply.Secrets["password"], Password: pw,
Secrets: secrets,
SavePassword: reply.Save, SavePassword: reply.Save,
} }
a.backend.pendingVPNSaveMu.Unlock() a.backend.pendingVPNSaveMu.Unlock()
@@ -796,23 +790,7 @@ 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"):
switch connType { if connType == "password" || connType == "password-tls" {
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"}
} }
@@ -827,19 +805,6 @@ 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,44 +315,6 @@ 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,10 +90,7 @@ type pendingVPNCredentials struct {
ConnectionPath string ConnectionPath string
Username string Username string
Password string Password string
// Secrets holds all VPN secret fields keyed by name (e.g. "cert-pass"); SavePassword bool
// falls back to Password under the "password" key when empty.
Secrets map[string]string
SavePassword bool
} }
type cachedVPNCredentials struct { type cachedVPNCredentials struct {
@@ -863,17 +863,13 @@ func (b *NetworkManagerBackend) saveVPNCredentials(creds *pendingVPNCredentials)
log.Infof("[saveVPNCredentials] Saving username") log.Infof("[saveVPNCredentials] Saving username")
} }
// Save secrets if requested // Save password if requested
if creds.SavePassword { if creds.SavePassword {
secs := creds.Secrets data["password-flags"] = "0"
if len(secs) == 0 { secs := make(map[string]string)
secs = map[string]string{"password": creds.Password} secs["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 %d secret field(s) with flags=0", len(secs)) log.Infof("[saveVPNCredentials] Saving password with password-flags=0")
} }
vpn["data"] = dbus.MakeVariant(data) vpn["data"] = dbus.MakeVariant(data)
-205
View File
@@ -1,205 +0,0 @@
// 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,7 +72,6 @@ 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,7 +65,6 @@ 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,7 +119,6 @@ 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
@@ -146,7 +145,6 @@ 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,7 +87,6 @@ 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
@@ -113,7 +112,6 @@ 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,24 +101,6 @@ 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,24 +39,6 @@ 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,7 +114,6 @@ 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
@@ -153,7 +152,6 @@ 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,7 +63,6 @@ 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
@@ -100,7 +99,6 @@ 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,8 +69,6 @@ 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,8 +50,6 @@ 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
@@ -138,8 +138,11 @@
install -D ${rootSrc}/core/assets/danklogo.svg \ install -D ${rootSrc}/core/assets/danklogo.svg \
$out/share/hicolor/scalable/apps/danklogo.svg $out/share/hicolor/scalable/apps/danklogo.svg
# Snapshot pre-wrap Qt paths for launched apps to restore.
wrapProgram $out/bin/dms \ wrapProgram $out/bin/dms \
--add-flags "-c $out/share/quickshell/dms" \ --add-flags "-c $out/share/quickshell/dms" \
--run 'export DMS_ORIG_NIXPKGS_QT6_QML_IMPORT_PATH="''${NIXPKGS_QT6_QML_IMPORT_PATH:-}"' \
--run 'export DMS_ORIG_QT_PLUGIN_PATH="''${QT_PLUGIN_PATH:-}"' \
--prefix "NIXPKGS_QT6_QML_IMPORT_PATH" ":" "${mkQmlImportPath pkgs qtPackages}" \ --prefix "NIXPKGS_QT6_QML_IMPORT_PATH" ":" "${mkQmlImportPath pkgs qtPackages}" \
--prefix "QT_PLUGIN_PATH" ":" "${mkQtPluginPath pkgs qtPackages}" --prefix "QT_PLUGIN_PATH" ":" "${mkQtPluginPath pkgs qtPackages}"
-3
View File
@@ -3,7 +3,6 @@ pragma ComponentBehavior: Bound
import Quickshell import Quickshell
import QtCore import QtCore
import QtQuick
import qs.Services import qs.Services
Singleton { Singleton {
@@ -20,8 +19,6 @@ 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, " ");
} }
+17 -2
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: true property bool focusedWindowShowIcon: false
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,6 +1027,17 @@ 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
@@ -2468,13 +2479,17 @@ 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);
barConfigs = _sanitizeBarConfigsForConnectedFrame(configs).configs; const sanitizedConfigs = _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: true }, focusedWindowShowIcon: { def: false },
runningAppsCompactMode: { def: true }, runningAppsCompactMode: { def: true },
barMaxVisibleApps: { def: 0 }, barMaxVisibleApps: { def: 0 },
barMaxVisibleRunningApps: { def: 0 }, barMaxVisibleRunningApps: { def: 0 },
+6 -2
View File
@@ -1,4 +1,5 @@
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
@@ -60,9 +61,12 @@ Item {
} }
// Hyprland OnDemand grab delivers keyboard focus to the modal content surface. // Hyprland OnDemand grab delivers keyboard focus to the modal content surface.
DankFocusGrab { HyprlandFocusGrab {
windows: (root.contentWindow ? [root.contentWindow] : []).concat(root.transientSurfaceTracker?.focusWindows ?? []) windows: (root.contentWindow ? [root.contentWindow] : []).concat(root.transientSurfaceTracker?.focusWindows ?? [])
wanted: KeyboardFocus.wantsGrab(root.shouldHaveFocus, root.customKeyboardFocus) active: 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,6 +169,8 @@ 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 "";
@@ -190,28 +192,13 @@ 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 && isVideo) || (weMode && delegateRoot.fileIsDir)) visible: gridPreviewImage.status === Image.Ready && ((!delegateRoot.fileIsDir && (isImage || isVideo)) || (weMode && delegateRoot.fileIsDir))
maskThresholdMin: 0.5 maskThresholdMin: 0.5
maskSpreadAtMin: 1 maskSpreadAtMin: 1
} }
@@ -168,7 +168,13 @@ StyledRect {
Image { Image {
id: listPreviewImage id: listPreviewImage
anchors.fill: parent anchors.fill: parent
property string imagePath: _videoThumb property string imagePath: {
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
@@ -177,26 +183,12 @@ 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 && isVideo visible: listPreviewImage.status === Image.Ready && !listDelegateRoot.fileIsDir && (isImage || isVideo)
maskThresholdMin: 0.5 maskThresholdMin: 0.5
maskSpreadAtMin: 1 maskSpreadAtMin: 1
} }
@@ -423,14 +423,12 @@ Column {
} }
} }
// targetValue-based direction: `active` can be stale when the behavior triggers
Behavior on height { Behavior on height {
id: detailHeightBehavior enabled: true
NumberAnimation { NumberAnimation {
readonly property bool growing: (detailHeightBehavior.targetValue ?? 0) >= detailHost.height duration: Theme.variantDuration(Theme.popoutAnimationDuration, detailHost.active)
duration: Theme.variantDuration(Theme.popoutAnimationDuration, growing)
easing.type: Easing.BezierSpline easing.type: Easing.BezierSpline
easing.bezierCurve: growing ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve easing.bezierCurve: detailHost.active ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve
} }
} }
} }
+11 -18
View File
@@ -941,16 +941,12 @@ Item {
section: topBarContent.getWidgetSection(parent) section: topBarContent.getWidgetSection(parent)
parentScreen: barWindow.screen parentScreen: barWindow.screen
onClicked: { onClicked: {
if (!powerMenuModalLoader) if (powerMenuModalLoader) {
return; powerMenuModalLoader.active = true;
powerMenuModalLoader.active = true; if (powerMenuModalLoader.item) {
if (!powerMenuModalLoader.item) powerMenuModalLoader.item.openCentered();
return; }
if (powerMenuModalLoader.item.shouldBeVisible) {
powerMenuModalLoader.item.close();
return;
} }
powerMenuModalLoader.item.openCentered();
} }
} }
} }
@@ -1105,13 +1101,12 @@ 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, tabIndex: 0,
triggerSource: topBarContent._dashTriggerSource(section, tabIndex), triggerSource: topBarContent._dashTriggerSource(section, 0),
mode: "click", mode: "click",
useCenterSection: true, useCenterSection: true,
setTriggerScreen: true setTriggerScreen: true
@@ -1134,13 +1129,12 @@ 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, tabIndex: 1,
triggerSource: topBarContent._dashTriggerSource(section, tabIndex), triggerSource: topBarContent._dashTriggerSource(section, 1),
mode: "click", mode: "click",
useCenterSection: true, useCenterSection: true,
setTriggerScreen: true setTriggerScreen: true
@@ -1162,13 +1156,12 @@ 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, tabIndex: 3,
triggerSource: topBarContent._dashTriggerSource(section, tabIndex), triggerSource: topBarContent._dashTriggerSource(section, 3),
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 = SettingsData.dashTabIndexForId(widgetId === "clock" ? "overview" : (widgetId === "music" ? "media" : "weather")); const tabIndex = widgetId === "clock" ? 0 : (widgetId === "music" ? 1 : 3);
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 > 0 && barWindow._backgroundAlpha < 1 readonly property bool barHasTransparency: barWindow._backgroundAlpha < 1
function rebuild() { function rebuild() {
teardown(); teardown();
@@ -1,6 +1,7 @@
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
@@ -1047,9 +1048,12 @@ BasePill {
WlrLayershell.namespace: "dms:tray-overflow-menu" WlrLayershell.namespace: "dms:tray-overflow-menu"
color: "transparent" color: "transparent"
DankFocusGrab { HyprlandFocusGrab {
windows: [overflowMenu].concat(KeyboardFocus.barWindows) windows: [overflowMenu].concat(KeyboardFocus.barWindows)
wanted: root.useOverflowPopup && KeyboardFocus.wantsGrab(root.menuOpen, null) active: root.useOverflowPopup && KeyboardFocus.wantsGrab(root.menuOpen, null)
property var restoreToplevel: null
onActiveChanged: restoreToplevel = active ? KeyboardFocus.captureActiveToplevel() : KeyboardFocus.restoreToplevel(restoreToplevel)
} }
Connections { Connections {
@@ -1597,9 +1601,12 @@ BasePill {
WlrLayershell.keyboardFocus: KeyboardFocus.keyboardFocus(menuRoot.showMenu, null) WlrLayershell.keyboardFocus: KeyboardFocus.keyboardFocus(menuRoot.showMenu, null)
color: "transparent" color: "transparent"
DankFocusGrab { HyprlandFocusGrab {
windows: [menuWindow].concat(KeyboardFocus.barWindows) windows: [menuWindow].concat(KeyboardFocus.barWindows)
wanted: KeyboardFocus.wantsGrab(menuRoot.showMenu, null) active: KeyboardFocus.wantsGrab(menuRoot.showMenu, null)
property var restoreToplevel: null
onActiveChanged: restoreToplevel = active ? KeyboardFocus.captureActiveToplevel() : KeyboardFocus.restoreToplevel(restoreToplevel)
} }
anchors { anchors {
@@ -107,14 +107,6 @@ Item {
} }
} }
Connections {
target: CompositorService
function onCompositorChanged() {
root._placeholderPool = [];
root._hyprSlotPool = {};
}
}
property var currentWorkspace: { property var currentWorkspace: {
if (useExtWorkspace) if (useExtWorkspace)
return getExtWorkspaceActiveWorkspace(); return getExtWorkspaceActiveWorkspace();
@@ -153,7 +145,8 @@ Item {
baseList = getNiriWorkspaces(); baseList = getNiriWorkspaces();
break; break;
case "hyprland": case "hyprland":
return hyprlandSlotList(getHyprlandWorkspaces()); baseList = getHyprlandWorkspaces();
break;
case "mango": case "mango":
if (root.mangoOverviewActive) if (root.mangoOverviewActive)
return []; return [];
@@ -433,84 +426,41 @@ Item {
return Object.values(byApp); return Object.values(byApp);
} }
function _makePlaceholder() { function padWorkspaces(list) {
if (useExtWorkspace) const padded = list.slice();
return { let placeholder;
if (useExtWorkspace) {
placeholder = {
"id": "", "id": "",
"name": "", "name": "",
"active": false, "active": false,
"_placeholder": true "_placeholder": true
}; };
if (CompositorService.isNiri) } else if (CompositorService.isNiri) {
return { placeholder = {
"id": -1, "id": -1,
"idx": -1, "idx": -1,
"name": "" "name": ""
}; };
if (CompositorService.isHyprland) } else if (CompositorService.isHyprland) {
return { placeholder = {
"id": -1, "id": -1,
"name": "" "name": ""
}; };
if (root.isMango) } else if (root.isMango) {
return { placeholder = {
"tag": -1 "tag": -1
}; };
if (CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle) } else if (CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle) {
return { placeholder = {
"num": -1, "num": -1,
"_placeholder": true "_placeholder": true
}; };
return -1; } else {
} 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) {
if (root._placeholderPool.length <= slot) padded.push(placeholder);
root._placeholderPool.push(root._makePlaceholder());
padded.push(root._placeholderPool[slot]);
slot++;
} }
return padded; return padded;
} }
@@ -688,7 +638,7 @@ Item {
NiriService.switchToWorkspace(data.id); NiriService.switchToWorkspace(data.id);
break; break;
case "hyprland": case "hyprland":
if (data.id && data.id !== -1) { if (data.id) {
HyprlandService.focusWorkspace(hyprlandWorkspaceSelector(data)); HyprlandService.focusWorkspace(hyprlandWorkspaceSelector(data));
} }
break; break;
@@ -713,7 +663,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 || item.isPlaceholder) if (!item)
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,7 +447,6 @@ 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
@@ -459,12 +458,6 @@ 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,10 +86,12 @@ Rectangle {
} }
function updateSelectedDateEvents() { function updateSelectedDateEvents() {
const events = (CalendarService && CalendarService.calendarAvailable) ? CalendarService.getEventsForDate(selectedDate) : []; if (CalendarService && CalendarService.calendarAvailable) {
if (JSON.stringify(events) === JSON.stringify(selectedDateEvents)) const events = CalendarService.getEventsForDate(selectedDate);
return; selectedDateEvents = events;
selectedDateEvents = events; } else {
selectedDateEvents = [];
}
} }
function loadEventsForMonth() { function loadEventsForMonth() {
@@ -274,29 +276,57 @@ Rectangle {
height: 40 height: 40
visible: showEventDetails visible: showEventDetails
DankActionButton { Rectangle {
buttonSize: 32 width: 32
iconSize: 14 height: 32
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
onClicked: root.showEventDetails = false radius: Theme.cornerRadius
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
}
} }
DankActionButton { Rectangle {
buttonSize: 32 width: 32
iconSize: 16 height: 32
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
onClicked: { color: addEventArea.containsMouse ? Theme.primaryHover : Theme.withAlpha(Theme.primaryHover, 0)
root.editorEvent = null;
root.showEditor = true; DankIcon {
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;
}
} }
} }
@@ -328,12 +358,31 @@ Rectangle {
height: 28 height: 28
visible: !showEventDetails visible: !showEventDetails
DankActionButton { Rectangle {
buttonSize: 28 width: 28
iconSize: 14 height: 28
iconName: "chevron_left" radius: Theme.cornerRadius
iconColor: Theme.primary color: prevMonthArea.containsMouse ? Theme.primaryHover : Theme.withAlpha(Theme.primaryHover, 0)
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 {
@@ -347,20 +396,53 @@ Rectangle {
verticalAlignment: Text.AlignVCenter verticalAlignment: Text.AlignVCenter
} }
DankActionButton { Rectangle {
buttonSize: 28 width: 28
iconSize: 14 height: 28
iconName: "today" radius: Theme.cornerRadius
iconColor: Theme.primary color: todayArea.containsMouse ? Theme.primaryHover : Theme.withAlpha(Theme.primaryHover, 0)
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()
}
} }
DankActionButton { Rectangle {
buttonSize: 28 width: 28
iconSize: 14 height: 28
iconName: "chevron_right" radius: Theme.cornerRadius
iconColor: Theme.primary color: nextMonthArea.containsMouse ? Theme.primaryHover : Theme.withAlpha(Theme.primaryHover, 0)
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();
}
}
} }
} }
@@ -732,16 +814,12 @@ Rectangle {
} }
} }
readonly property bool isLocalTask: taskId.startsWith("task_") readonly property bool isTask: modelData && modelData.id && modelData.id.startsWith("task_")
readonly property bool isDankTask: taskId.startsWith("vtodo_") readonly property color accentColor: {
readonly property bool isTask: isLocalTask || isDankTask if (isTask)
readonly property bool canModify: isLocalTask || (isDankTask && modelData && !modelData.readOnly) return modelData.completed ? Theme.withAlpha(Theme.primary, 0.4) : Theme.primary;
readonly property color baseAccent: { return (modelData && modelData.color && modelData.color.length) ? modelData.color : Theme.primary;
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
@@ -810,13 +888,13 @@ Rectangle {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
radius: Theme.cornerRadius radius: Theme.cornerRadius
color: "transparent" color: "transparent"
visible: taskItem.isLocalTask && !taskItem.isEditing visible: modelData && modelData.id && modelData.id.startsWith("task_") && !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.surfaceTextMedium color: dragMouseArea.containsMouse ? Theme.primary : Theme.surfaceTextAlpha
} }
MouseArea { MouseArea {
@@ -859,14 +937,34 @@ 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: taskItem.isLocalTask ? 60 : taskItem.isDankTask ? 36 : (Theme.spacingS + 6) anchors.leftMargin: (modelData && modelData.id && modelData.id.startsWith("task_")) ? 60 : (Theme.spacingS + 6)
anchors.rightMargin: taskItem.canModify ? 64 : Theme.spacingXS anchors.rightMargin: (modelData && modelData.id && modelData.id.startsWith("task_")) ? 64 : Theme.spacingXS
spacing: Theme.spacingXXS spacing: Theme.spacingXXS
visible: !taskItem.isEditing visible: !taskItem.isEditing
@@ -874,7 +972,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: (taskItem.isTask && modelData && modelData.completed) ? Theme.surfaceTextSecondary : Theme.surfaceText color: (modelData && modelData.id && modelData.id.startsWith("task_") && 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
@@ -902,7 +1000,7 @@ Rectangle {
color: Theme.surfaceTextMedium color: Theme.surfaceTextMedium
font.weight: Font.Normal font.weight: Font.Normal
horizontalAlignment: Text.AlignLeft horizontalAlignment: Text.AlignLeft
visible: text !== "" && !taskItem.isLocalTask visible: text !== "" && modelData && modelData.id && !modelData.id.startsWith("task_")
} }
} }
@@ -949,73 +1047,90 @@ Rectangle {
id: eventMouseArea id: eventMouseArea
anchors.fill: parent anchors.fill: parent
anchors.leftMargin: taskItem.isLocalTask ? 32 : 6 anchors.leftMargin: (modelData && modelData.id && modelData.id.startsWith("task_")) ? 32 : 6
anchors.rightMargin: taskItem.canModify ? 64 : 0 anchors.rightMargin: (modelData && modelData.id && modelData.id.startsWith("task_")) ? 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) if (modelData && modelData.id && modelData.id.startsWith("task_")) {
return;
if (taskItem.isTask && taskItem.canModify) {
CalendarService.toggleTask(modelData.id); CalendarService.toggleTask(modelData.id);
return; return;
} }
root.detailEvent = modelData; if (modelData)
root.detailEvent = modelData;
} }
} }
DankActionButton { // Delete / Cancel Button
buttonSize: 24 Rectangle {
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
buttonSize: 24 width: 24
iconSize: 14 height: 24
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
visible: taskItem.canModify radius: Theme.cornerRadius
onClicked: { 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)
if (taskItem.isEditing) { visible: modelData && modelData.id && modelData.id.startsWith("task_")
taskItem.isEditing = false;
return; DankIcon {
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);
} }
} }
DankActionButton { // Edit / Save Button
buttonSize: 24 Rectangle {
iconSize: 14 id: editButton
iconName: taskItem.isEditing ? "check" : "edit" width: 24
iconColor: taskItem.isEditing ? Theme.primary : Theme.surfaceText height: 24
anchors.right: deleteButton.left anchors.right: deleteButton.left
anchors.rightMargin: Theme.spacingXS anchors.rightMargin: Theme.spacingXS
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
visible: taskItem.canModify radius: Theme.cornerRadius
onClicked: { color: editMouseArea.containsMouse ? Theme.primaryHover : Theme.withAlpha(Theme.primaryHover, 0)
if (!taskItem.isEditing) { visible: modelData && modelData.id && modelData.id.startsWith("task_")
taskItem.isEditing = true;
return; DankIcon {
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;
} }
} }
} }
+136 -147
View File
@@ -1,8 +1,7 @@
import Qt.labs.folderlistmodel import Qt.labs.folderlistmodel
import QtCore import QtCore
import QtQuick import QtQuick
import Quickshell import QtQuick.Effects
import Quickshell.Widgets
import qs.Common import qs.Common
import qs.Modals.FileBrowser import qs.Modals.FileBrowser
import qs.Widgets import qs.Widgets
@@ -21,11 +20,12 @@ 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: pager property Item focusTarget: wallpaperGrid
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: updateSelectedFileName() onCurrentPageChanged: {
if (currentPage !== lastPage) {
onTotalPagesChanged: { enableAnimation = false;
if (currentPage >= totalPages) { lastPage = currentPage;
currentPage = Math.max(0, totalPages - 1);
} }
updateSelectedFileName();
} }
onGridIndexChanged: { onGridIndexChanged: {
@@ -257,7 +257,6 @@ 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;
@@ -351,6 +350,17 @@ 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() {
@@ -359,6 +369,7 @@ Item {
setInitialSelection(); setInitialSelection();
} }
updateSelectedFileName(); updateSelectedFileName();
thumbnailPreloader.paths = collectWallpaperPaths();
} }
} }
function onStatusChanged() { function onStatusChanged() {
@@ -367,10 +378,16 @@ Item {
setInitialSelection(); setInitialSelection();
} }
updateSelectedFileName(); updateSelectedFileName();
thumbnailPreloader.paths = collectWallpaperPaths();
} }
} }
} }
WallpaperThumbnailPreloader {
id: thumbnailPreloader
cacheSize: 256
}
FolderListModel { FolderListModel {
id: wallpaperFolderModel id: wallpaperFolderModel
@@ -430,169 +447,147 @@ Item {
width: parent.width width: parent.width
height: parent.height - 50 height: parent.height - 50
ListView { GridView {
id: pager id: wallpaperGrid
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
orientation: ListView.Vertical cellWidth: width / 4
snapMode: ListView.SnapOneItem cellHeight: height / 4
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
onCurrentIndexChanged: { highlight: Item {
if (!moving) { z: 1000
return; Rectangle {
} anchors.fill: parent
if (currentIndex >= 0 && currentIndex !== root.currentPage) { anchors.margins: Theme.spacingXS
root.currentPage = currentIndex; color: "transparent"
border.width: 3
border.color: Theme.primary
radius: Theme.cornerRadius
} }
} }
Component.onCompleted: currentIndex = root.currentPage model: {
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 onCurrentPageChanged() { function onGridIndexChanged() {
if (pager.currentIndex !== root.currentPage) { if (wallpaperGrid.count > 0) {
pager.currentIndex = root.currentPage; wallpaperGrid.currentIndex = gridIndex;
if (!enableAnimation) {
wallpaperGrid.positionViewAtIndex(gridIndex, GridView.Contain);
}
} }
} }
} }
delegate: GridView { delegate: Item {
id: pageGrid width: wallpaperGrid.cellWidth
height: wallpaperGrid.cellHeight
property int pageIndex: index property string wallpaperPath: modelData || ""
property bool isSelected: getCurrentWallpaper() === modelData
width: pager.width Rectangle {
height: pager.height id: wallpaperCard
cellWidth: width / 4 anchors.fill: parent
cellHeight: height / 4 anchors.margins: Theme.spacingXS
interactive: false color: Theme.withAlpha(Theme.surfaceContainerHighest, Theme.popupTransparency)
keyNavigationEnabled: false radius: Theme.cornerRadius
activeFocusOnTab: false clip: true
focus: false
highlightFollowsCurrentItem: true
highlightMoveDuration: root.enableAnimation ? Theme.shortDuration : 0
currentIndex: root.currentPage === pageIndex ? root.gridIndex : -1
highlight: Item {
z: 1000
Rectangle {
anchors.fill: parent
anchors.margins: Theme.spacingXS
color: "transparent"
border.width: 3
border.color: Theme.primary
radius: Theme.cornerRadius
}
}
reuseItems: true
model: ScriptModel {
values: {
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: wallpaperCard
anchors.fill: parent anchors.fill: parent
anchors.margins: Theme.spacingXS color: isSelected ? Theme.primaryPressed : Theme.withAlpha(Theme.primaryPressed, 0)
color: Theme.withAlpha(Theme.surfaceContainerHighest, Theme.popupTransparency) radius: parent.radius
Behavior on color {
ColorAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
Rectangle {
id: maskRect
width: thumbnailImage.width
height: thumbnailImage.height
radius: Theme.cornerRadius radius: Theme.cornerRadius
visible: false
layer.enabled: true
}
Rectangle { CachingImage {
anchors.fill: parent id: thumbnailImage
color: isSelected ? Theme.primaryPressed : Theme.withAlpha(Theme.primaryPressed, 0) anchors.fill: parent
radius: parent.radius imagePath: modelData || ""
maxCacheSize: 256
Behavior on color { layer.enabled: true
ColorAnimation { layer.effect: MultiEffect {
duration: Theme.shortDuration maskEnabled: true
easing.type: Theme.standardEasing maskThresholdMin: 0.5
} maskSpreadAtMin: 1.0
} maskSource: maskRect
} }
}
ClippingRectangle { StateLayer {
anchors.fill: parent anchors.fill: parent
radius: wallpaperCard.radius cornerRadius: parent.radius
color: "transparent" stateColor: Theme.primary
}
CachingImage { MouseArea {
id: thumbnailImage id: wallpaperMouseArea
anchors.fill: parent anchors.fill: parent
imagePath: modelData || "" hoverEnabled: true
maxCacheSize: 256 cursorShape: Qt.PointingHandCursor
animate: false
opacity: status === Image.Ready ? 1 : 0
Behavior on opacity { onClicked: {
NumberAnimation { gridIndex = index;
duration: Theme.shortDuration if (modelData) {
easing.type: Theme.standardEasing setCurrentWallpaper(modelData);
}
}
}
}
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);
}
} }
} }
} }
@@ -600,15 +595,9 @@ 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.status === FolderListModel.Ready && wallpaperFolderModel.count === 0 visible: 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,19 +168,6 @@ 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);
@@ -1020,7 +1007,8 @@ PanelWindow {
z: 15 z: 15
onClicked: { onClicked: {
dismissPopupReliably(); if (notificationData && !win.exiting)
notificationData.popup = false;
} }
} }
@@ -1092,7 +1080,8 @@ PanelWindow {
onClicked: { onClicked: {
if (modelData && modelData.invoke) if (modelData && modelData.invoke)
modelData.invoke(); modelData.invoke();
dismissPopupReliably(); if (notificationData && !win.exiting)
notificationData.popup = false;
} }
} }
} }
@@ -1174,7 +1163,7 @@ PanelWindow {
notificationData.actions[0].invoke(); notificationData.actions[0].invoke();
NotificationService.dismissNotification(notificationData); NotificationService.dismissNotification(notificationData);
} else { } else {
dismissPopupReliably(); notificationData.popup = false;
} }
} }
} }
@@ -368,7 +368,7 @@ Item {
hoverEnabled: true hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onClicked: { onClicked: {
root.deleteVpnConfirm.showWithOptions({ 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"),
+2 -191
View File
@@ -21,9 +21,6 @@ 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
@@ -133,7 +130,6 @@ 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)
@@ -197,19 +193,12 @@ 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", "tasks", "sync"] "topics": ["accounts", "calendars", "events", "sync"]
} }
}); });
} }
@@ -254,14 +243,8 @@ 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;
} }
} }
@@ -299,7 +282,6 @@ Item {
} }
calendars = list; calendars = list;
_rebuildEventsByDate(); _rebuildEventsByDate();
_rebuildTasksByDate();
}); });
} }
@@ -371,7 +353,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_") || id.startsWith("vtodo_")) if (id.startsWith("task_"))
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,
@@ -496,175 +478,4 @@ 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);
});
}
} }
+8 -43
View File
@@ -63,7 +63,6 @@ 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);
@@ -199,17 +198,6 @@ 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])
@@ -226,13 +214,6 @@ 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;
@@ -256,10 +237,6 @@ 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;
@@ -306,12 +283,6 @@ 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;
@@ -334,17 +305,6 @@ 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();
@@ -352,9 +312,14 @@ Singleton {
for (let dateKey in backendEvents) for (let dateKey in backendEvents)
merged[dateKey] = [].concat(backendEvents[dateKey]); merged[dateKey] = [].concat(backendEvents[dateKey]);
_mergeInto(merged, root.taskEventsByDate); for (let dateKey in root.taskEventsByDate) {
if (isDankActive) if (!merged[dateKey])
_mergeInto(merged, dankBackend.tasksByDate); merged[dateKey] = [];
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];
+6 -9
View File
@@ -341,11 +341,10 @@ 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
// 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 xrayNamespaces = ["dms:bar"]; const barFrameTargetNamespace = !frameEnabled ? (SettingsData.standaloneBarXrayAvailable ? "dms:bar" : null) : (frameConnectedMode ? null : "dms:frame");
if (frameEnabled && SettingsData.frameMode !== "connected")
xrayNamespaces.push("dms:frame");
const generalLines = []; const generalLines = [];
if (manageGaps) if (manageGaps)
@@ -372,15 +371,13 @@ hl.layer_rule({
}) })
`; `;
} }
if (layoutBarXrayEnabled) { if (layoutBarXrayEnabled && barFrameTargetNamespace) {
for (const ns of xrayNamespaces) { content += `
content += `
hl.layer_rule({ hl.layer_rule({
match = { namespace = "^${ns}$" }, match = { namespace = "^${barFrameTargetNamespace}$" },
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) {
+7 -6
View File
@@ -1189,18 +1189,19 @@ 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;
// dms:frame only in separate mode connected-mode frame blur overlaps windows via popouts/arcs const frameConnectedMode = frameEnabled && SettingsData.frameMode === "connected";
const excludeNamespaces = ["dms:bar"]; // Connected frame mode has no separate bar/frame surface to target
if (frameEnabled && SettingsData.frameMode !== "connected") const barFrameTargetNamespace = !frameEnabled ? (SettingsData.standaloneBarXrayAvailable ? "dms:bar" : null) : (frameConnectedMode ? null : "dms:frame");
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 excludeLines = layoutBarXrayEnabled ? excludeNamespaces.map(ns => `\n exclude namespace="^${ns}$"`).join("") : ""; const excludeLine = (layoutBarXrayEnabled && barFrameTargetNamespace) ? `\n exclude namespace="^${barFrameTargetNamespace}$"` : "";
xrayRules += ` xrayRules += `
layer-rule {${excludeLines} layer-rule {${excludeLine}
background-effect { background-effect {
xray false xray false
} }
+17 -3
View File
@@ -192,6 +192,19 @@ Singleton {
return envObj; return envObj;
} }
// Restore pre-wrap Qt paths (Nix) so launched apps use their own.
function restoreWrapperEnv(env) {
const restore = (target, snapshot) => {
const orig = Quickshell.env(snapshot);
if (orig === null)
return;
env[target] = orig.length > 0 ? orig : null;
};
restore("NIXPKGS_QT6_QML_IMPORT_PATH", "DMS_ORIG_NIXPKGS_QT6_QML_IMPORT_PATH");
restore("QT_PLUGIN_PATH", "DMS_ORIG_QT_PLUGIN_PATH");
return env;
}
function launchDesktopEntry(desktopEntry, useNvidia) { function launchDesktopEntry(desktopEntry, useNvidia) {
if (!desktopEntry || !desktopEntry.command) if (!desktopEntry || !desktopEntry.command)
return; return;
@@ -216,7 +229,7 @@ Singleton {
const cursorEnv = typeof SettingsData.getCursorEnvironment === "function" ? SettingsData.getCursorEnvironment() : {}; const cursorEnv = typeof SettingsData.getCursorEnvironment === "function" ? SettingsData.getCursorEnvironment() : {};
const overrideEnv = override?.envVars ? parseEnvVars(override.envVars) : {}; const overrideEnv = override?.envVars ? parseEnvVars(override.envVars) : {};
const finalEnv = Object.assign({}, cursorEnv, overrideEnv); const finalEnv = restoreWrapperEnv(Object.assign({}, cursorEnv, overrideEnv));
if (desktopEntry.runInTerminal) { if (desktopEntry.runInTerminal) {
const terminal = SessionData.resolveTerminal() || "xterm"; const terminal = SessionData.resolveTerminal() || "xterm";
@@ -266,13 +279,14 @@ Singleton {
const prefix = userPrefix.length > 0 ? userPrefix : defaultPrefix; const prefix = userPrefix.length > 0 ? userPrefix : defaultPrefix;
const workDir = desktopEntry.workingDirectory || Quickshell.env("HOME"); const workDir = desktopEntry.workingDirectory || Quickshell.env("HOME");
const cursorEnv = typeof SettingsData.getCursorEnvironment === "function" ? SettingsData.getCursorEnvironment() : {}; const cursorEnv = typeof SettingsData.getCursorEnvironment === "function" ? SettingsData.getCursorEnvironment() : {};
const finalEnv = restoreWrapperEnv(Object.assign({}, cursorEnv));
if (prefix.length > 0 && needsShellExecution(prefix)) { if (prefix.length > 0 && needsShellExecution(prefix)) {
const escapedCmd = cmd.map(arg => escapeShellArg(arg)).join(" "); const escapedCmd = cmd.map(arg => escapeShellArg(arg)).join(" ");
Quickshell.execDetached({ Quickshell.execDetached({
command: ["sh", "-c", `${prefix} ${escapedCmd}`], command: ["sh", "-c", `${prefix} ${escapedCmd}`],
workingDirectory: workDir, workingDirectory: workDir,
environment: cursorEnv environment: finalEnv
}); });
return; return;
} }
@@ -283,7 +297,7 @@ Singleton {
Quickshell.execDetached({ Quickshell.execDetached({
command: cmd, command: cmd,
workingDirectory: workDir, workingDirectory: workDir,
environment: cursorEnv environment: finalEnv
}); });
} }
+37 -34
View File
@@ -1,4 +1,5 @@
import QtQuick import QtQuick
import Quickshell.Io
import qs.Common import qs.Common
Item { Item {
@@ -8,14 +9,10 @@ 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 (!animate || !imagePath) if (!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");
@@ -42,8 +39,7 @@ Item {
} }
readonly property string imageHash: normalizedPath ? djb2Hash(normalizedPath) : "" readonly property string imageHash: normalizedPath ? djb2Hash(normalizedPath) : ""
readonly property string cacheFileName: imageHash && !isRemoteUrl && !isAnimated ? `${imageHash}@${maxCacheSize}x${maxCacheSize}.png` : "" readonly property string cachePath: imageHash && !isRemoteUrl && !isAnimated ? `${Paths.stringify(Paths.imagecache)}/${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 "";
@@ -73,51 +69,58 @@ Item {
smooth: true smooth: true
onStatusChanged: { onStatusChanged: {
switch (status) { if (source === root.cachePath && status === Image.Error) {
case Image.Error:
if (!root._fromCache)
return;
root._fromCache = false;
source = root.encodedImagePath; source = root.encodedImagePath;
return; return;
case Image.Ready: }
if (root._fromCache || root.isRemoteUrl || !root.cachePath) if (root.isRemoteUrl || source !== root.encodedImagePath || status !== Image.Ready || !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));
} }
} }
} }
function resolveSource() { Process {
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;
} }
if (!cachePath) { Paths.mkdir(Paths.imagecache);
_fromCache = false; const hash = djb2Hash(normalizedPath);
staticImg.source = encodedImagePath; const cPath = hash ? `${Paths.stringify(Paths.imagecache)}/${hash}@${maxCacheSize}x${maxCacheSize}.png` : "";
const encoded = "file://" + normalizedPath.split('/').map(s => encodeURIComponent(s)).join('/');
if (!cPath) {
staticImg.source = encoded;
return; return;
} }
// Cache-first; a miss errors and falls back to encodedImagePath cacheProbe.running = false;
_fromCache = true; cacheProbe.cachePath = cPath;
staticImg.source = cachePath; cacheProbe.fallbackSource = encoded;
cacheProbe.running = true;
} }
onImagePathChanged: resolveSource()
// During creation onImagePathChanged fires before sibling properties (maxCacheSize) initialize
onCachePathChanged: resolveSource()
} }
-41
View File
@@ -1,41 +0,0 @@
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
}
+6 -2
View File
@@ -1,4 +1,5 @@
import QtQuick import QtQuick
import Quickshell.Hyprland
import qs.Common import qs.Common
import qs.Services import qs.Services
@@ -61,7 +62,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.
DankFocusGrab { HyprlandFocusGrab {
windows: { windows: {
const list = []; const list = [];
if (root.contentWindow) if (root.contentWindow)
@@ -71,7 +72,10 @@ 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);
} }
wanted: KeyboardFocus.wantsGrab(root.shouldBeVisible, root.customKeyboardFocus) active: KeyboardFocus.wantsGrab(root.shouldBeVisible, root.customKeyboardFocus)
property var restoreToplevel: null
onActiveChanged: restoreToplevel = active ? KeyboardFocus.captureActiveToplevel() : KeyboardFocus.restoreToplevel(restoreToplevel)
} }
Loader { Loader {
@@ -0,0 +1,94 @@
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);
}
}