1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-08-02 11:38:30 -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,
systemCmd,
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 {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "qs", "ipc", "--help")
cmd.WaitDelay = 500 * time.Millisecond
out, err := cmd.Output()
out, err := exec.Command("qs", "ipc", "--help").Output()
if err != nil {
return false
}
@@ -646,10 +642,7 @@ func getShellIPCCompletions(args []string, _ string) []string {
return nil
}
cmdArgs := append(baseArgs, "show")
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "qs", cmdArgs...)
cmd.WaitDelay = 500 * time.Millisecond
cmd := exec.Command("qs", cmdArgs...)
var targets ipcTargets
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)
// Watcher spawns first so it owns the SNI name before dms/tray apps start.
spawnDms := "spawn-at-startup \"dms\" \"tray-watcher\"\nspawn-at-startup \"dms\" \"run\""
if !strings.Contains(config, `spawn-at-startup "dms" "run"`) {
spawnDms := `spawn-at-startup "dms" "run"`
if !strings.Contains(config, spawnDms) {
// Insert spawn-at-startup for dms after the environment block
envBlockEnd := regexp.MustCompile(`environment \{[^}]*\}`)
if loc := envBlockEnd.FindStringIndex(config); loc != nil {
-12
View File
@@ -520,21 +520,9 @@ func TestHyprlandConfigStructure(t *testing.T) {
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) {
assert.Contains(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, MangoBindsConfig, "bind=SUPER,H,focusdir,left")
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
# 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
# the SNI name before dms and the tray apps come up.
exec-once=dms tray-watcher
# spawn a new shell on every reload.
exec-once=dms run
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" +
fmt.Sprintf(`hl.env("TERMINAL", %s)`, strconv.Quote(terminalCommand)) + "\n\n" +
`hl.on("hyprland.start", function()` + "\n" +
` hl.exec_cmd("dms tray-watcher")` + "\n" +
` hl.exec_cmd("dms run")` + "\n" +
`end)` + "\n" +
hyprlandStartupEnd
+2 -4
View File
@@ -587,15 +587,13 @@ TERMINAL=%s
}
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 {
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")
}
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")
}
}
+1 -1
View File
@@ -1154,7 +1154,7 @@ func readLuaOrHyprlangOverride(path string) (map[string]*hyprlandOverrideBind, e
if err != nil {
return nil, err
}
lines := expandLuaConfigLines(strings.Split(string(data), "\n"))
lines := strings.Split(string(data), "\n")
parser := NewHyprlandParser("")
pendingUnbinds := make(map[string]string)
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
p.currentSource = absPath
lines := expandLuaConfigLines(strings.Split(content, "\n"))
lines := strings.Split(content, "\n")
boundInFile := make(map[string]bool)
for _, line := range lines {
trimmed := strings.TrimSpace(line)
-3
View File
@@ -1056,9 +1056,6 @@ func closestAdwaitaAccent(primaryHex string) string {
func syncAccentColor(primaryHex string) {
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)
if err := utils.GsettingsSet("org.gnome.desktop.interface", "accent-color", accent); err != nil {
log.Warnf("Failed to set accent-color: %v", err)
@@ -545,18 +545,12 @@ func (a *SecretAgent) GetSecrets(
out[settingName] = sec
}
if settingName == "vpn" && a.backend != nil && !isPKCS11 && (vpnUsername != "" || reply.Save) {
secrets := make(map[string]string)
for k, v := range reply.Secrets {
if k != "username" {
secrets[k] = v
}
}
pw := reply.Secrets["password"]
a.backend.pendingVPNSaveMu.Lock()
a.backend.pendingVPNSave = &pendingVPNCredentials{
ConnectionPath: string(path),
Username: vpnUsername,
Password: reply.Secrets["password"],
Secrets: secrets,
Password: pw,
SavePassword: reply.Save,
}
a.backend.pendingVPNSaveMu.Unlock()
@@ -796,23 +790,7 @@ func inferVPNFields(conn map[string]nmVariantMap, vpnService string) []string {
fields = []string{"username", "password"}
}
case strings.Contains(vpnService, "openvpn"):
switch connType {
case "tls":
// Cert auth wants the private-key passphrase, not a password.
if secretFlagsNotRequired(dataMap["cert-pass-flags"]) {
return nil
}
return []string{"cert-pass"}
case "password-tls":
fields = []string{}
if dataMap["username"] == "" {
fields = append(fields, "username")
}
fields = append(fields, "password")
if !secretFlagsNotRequired(dataMap["cert-pass-flags"]) {
fields = append(fields, "cert-pass")
}
case "password":
if connType == "password" || connType == "password-tls" {
if dataMap["username"] == "" {
fields = []string{"username", "password"}
}
@@ -827,19 +805,6 @@ func inferVPNFields(conn map[string]nmVariantMap, vpnService string) []string {
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 {
if method, ok := data["saml-auth-method"]; ok {
if method == "REDIRECT" || method == "POST" {
@@ -315,44 +315,6 @@ func TestInferVPNFields_GPSaml(t *testing.T) {
expectedLen: 1,
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 {
@@ -90,10 +90,7 @@ type pendingVPNCredentials struct {
ConnectionPath string
Username string
Password string
// Secrets holds all VPN secret fields keyed by name (e.g. "cert-pass");
// falls back to Password under the "password" key when empty.
Secrets map[string]string
SavePassword bool
SavePassword bool
}
type cachedVPNCredentials struct {
@@ -863,17 +863,13 @@ func (b *NetworkManagerBackend) saveVPNCredentials(creds *pendingVPNCredentials)
log.Infof("[saveVPNCredentials] Saving username")
}
// Save secrets if requested
// Save password if requested
if creds.SavePassword {
secs := creds.Secrets
if len(secs) == 0 {
secs = map[string]string{"password": creds.Password}
}
for field := range secs {
data[field+"-flags"] = "0"
}
data["password-flags"] = "0"
secs := make(map[string]string)
secs["password"] = creds.Password
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)
-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 \
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-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/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; \
-1
View File
@@ -65,7 +65,6 @@ override_dh_auto_install:
if [ -d DankMaterialShell-$(UPSTREAM_VERSION) ]; then \
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-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/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; \
-2
View File
@@ -119,7 +119,6 @@ core/bin/${DMS_BINARY} completion fish > %{buildroot}%{_datadir}/fish/vendor_com
# Install systemd user 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/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
%{_datadir}/quickshell/dms/
%{_userunitdir}/dms.service
%{_userunitdir}/dms-tray-watcher.service
%{_datadir}/applications/dms-open.desktop
%{_datadir}/applications/com.danklinux.dms.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 || :
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/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
%{_datadir}/quickshell/dms/
%{_userunitdir}/dms.service
%{_userunitdir}/dms-tray-watcher.service
%{_datadir}/applications/dms-open.desktop
%{_datadir}/applications/com.danklinux.dms.desktop
%{_datadir}/applications/com.danklinux.dms.notepad.desktop
-18
View File
@@ -101,24 +101,6 @@ in
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 != { }) {
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.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 || :
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/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
%{_datadir}/quickshell/dms/
%{_userunitdir}/dms.service
%{_userunitdir}/dms-tray-watcher.service
%{_datadir}/applications/dms-open.desktop
%{_datadir}/applications/com.danklinux.dms.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 || :
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/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
%{_datadir}/quickshell/dms/
%{_userunitdir}/dms.service
%{_userunitdir}/dms-tray-watcher.service
%{_datadir}/applications/dms-open.desktop
%{_datadir}/applications/com.danklinux.dms.desktop
%{_datadir}/applications/com.danklinux.dms.notepad.desktop
-2
View File
@@ -69,8 +69,6 @@ override_dh_auto_install:
# Install systemd user service
install -Dm644 dms-git-repo/assets/systemd/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 -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 -Dm644 DankMaterialShell-$(BASE_VERSION)/assets/systemd/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 -Dm644 DankMaterialShell-$(BASE_VERSION)/assets/dms-open.desktop \
+3
View File
@@ -138,8 +138,11 @@
install -D ${rootSrc}/core/assets/danklogo.svg \
$out/share/hicolor/scalable/apps/danklogo.svg
# Snapshot pre-wrap Qt paths for launched apps to restore.
wrapProgram $out/bin/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 "QT_PLUGIN_PATH" ":" "${mkQtPluginPath pkgs qtPackages}"
-3
View File
@@ -3,7 +3,6 @@ pragma ComponentBehavior: Bound
import Quickshell
import QtCore
import QtQuick
import qs.Services
Singleton {
@@ -20,8 +19,6 @@ Singleton {
readonly property url imagecache: `${cache}/imagecache`
Component.onCompleted: mkdir(imagecache)
function stringify(path: url): string {
return path.toString().replace(/%20/g, " ");
}
+17 -2
View File
@@ -462,7 +462,7 @@ Singleton {
property bool clockCompactMode: false
property int focusedWindowSize: 1
property bool focusedWindowCompactMode: false
property bool focusedWindowShowIcon: true
property bool focusedWindowShowIcon: false
property bool runningAppsCompactMode: true
property int barMaxVisibleApps: 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 string desktopClockStyle: "analog"
property real desktopClockTransparency: 0.8
@@ -2468,13 +2479,17 @@ Singleton {
if (index === -1)
return;
const positionChanged = updates.position !== undefined && configs[index].position !== updates.position;
const barXrayTargetWasAvailable = _standaloneBarXrayAvailable(configs);
if (updates.autoHide === false || updates.visible === false)
setBarIpcReveal(barId, false);
Object.assign(configs[index], updates);
barConfigs = _sanitizeBarConfigsForConnectedFrame(configs).configs;
const sanitizedConfigs = _sanitizeBarConfigsForConnectedFrame(configs).configs;
barConfigs = sanitizedConfigs;
updateBarConfigs();
if (!frameEnabled && _standaloneBarXrayAvailable(sanitizedConfigs) !== barXrayTargetWasAvailable)
updateCompositorLayout();
if (positionChanged) {
NotificationService.dismissAllPopups();
}
+1 -1
View File
@@ -192,7 +192,7 @@ var SPEC = {
clockCompactMode: { def: false },
focusedWindowCompactMode: { def: false },
focusedWindowSize: { def: 1 },
focusedWindowShowIcon: { def: true },
focusedWindowShowIcon: { def: false },
runningAppsCompactMode: { def: true },
barMaxVisibleApps: { def: 0 },
barMaxVisibleRunningApps: { def: 0 },
+6 -2
View File
@@ -1,4 +1,5 @@
import QtQuick
import Quickshell.Hyprland
import qs.Common
import qs.Services
import qs.Widgets
@@ -60,9 +61,12 @@ Item {
}
// Hyprland OnDemand grab delivers keyboard focus to the modal content surface.
DankFocusGrab {
HyprlandFocusGrab {
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 effectiveScreen: impl.item ? impl.item.effectiveScreen : null
@@ -169,6 +169,8 @@ StyledRect {
property string imagePath: {
if (weMode && delegateRoot.fileIsDir)
return delegateRoot.filePath + "/preview" + weExtensions[weExtIndex];
if (!delegateRoot.fileIsDir && isImage)
return delegateRoot.filePath;
if (_videoThumb)
return _videoThumb;
return "";
@@ -190,28 +192,13 @@ StyledRect {
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 {
anchors.fill: parent
anchors.margins: 2
source: gridPreviewImage
maskEnabled: true
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
maskSpreadAtMin: 1
}
@@ -168,7 +168,13 @@ StyledRect {
Image {
id: listPreviewImage
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('/') : ""
fillMode: Image.PreserveAspectCrop
sourceSize.width: 32
@@ -177,26 +183,12 @@ StyledRect {
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 {
anchors.fill: parent
source: listPreviewImage
maskEnabled: true
maskSource: listImageMask
visible: listPreviewImage.status === Image.Ready && !listDelegateRoot.fileIsDir && isVideo
visible: listPreviewImage.status === Image.Ready && !listDelegateRoot.fileIsDir && (isImage || isVideo)
maskThresholdMin: 0.5
maskSpreadAtMin: 1
}
@@ -423,14 +423,12 @@ Column {
}
}
// targetValue-based direction: `active` can be stale when the behavior triggers
Behavior on height {
id: detailHeightBehavior
enabled: true
NumberAnimation {
readonly property bool growing: (detailHeightBehavior.targetValue ?? 0) >= detailHost.height
duration: Theme.variantDuration(Theme.popoutAnimationDuration, growing)
duration: Theme.variantDuration(Theme.popoutAnimationDuration, detailHost.active)
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)
parentScreen: barWindow.screen
onClicked: {
if (!powerMenuModalLoader)
return;
powerMenuModalLoader.active = true;
if (!powerMenuModalLoader.item)
return;
if (powerMenuModalLoader.item.shouldBeVisible) {
powerMenuModalLoader.item.close();
return;
if (powerMenuModalLoader) {
powerMenuModalLoader.active = true;
if (powerMenuModalLoader.item) {
powerMenuModalLoader.item.openCentered();
}
}
powerMenuModalLoader.item.openCentered();
}
}
}
@@ -1105,13 +1101,12 @@ Item {
onClockClicked: {
const section = topBarContent.getWidgetSection(parent) || "center";
const tabIndex = SettingsData.dashTabIndexForId("overview");
topBarContent.openWidgetPopout({
loader: dankDashPopoutLoader,
widgetItem: clockWidget,
section,
tabIndex,
triggerSource: topBarContent._dashTriggerSource(section, tabIndex),
tabIndex: 0,
triggerSource: topBarContent._dashTriggerSource(section, 0),
mode: "click",
useCenterSection: true,
setTriggerScreen: true
@@ -1134,13 +1129,12 @@ Item {
parentScreen: barWindow.screen
onClicked: {
const section = topBarContent.getWidgetSection(parent) || "center";
const tabIndex = SettingsData.dashTabIndexForId("media");
topBarContent.openWidgetPopout({
loader: dankDashPopoutLoader,
widgetItem: mediaWidget,
section,
tabIndex,
triggerSource: topBarContent._dashTriggerSource(section, tabIndex),
tabIndex: 1,
triggerSource: topBarContent._dashTriggerSource(section, 1),
mode: "click",
useCenterSection: true,
setTriggerScreen: true
@@ -1162,13 +1156,12 @@ Item {
parentScreen: barWindow.screen
onClicked: {
const section = topBarContent.getWidgetSection(parent) || "center";
const tabIndex = SettingsData.dashTabIndexForId("weather");
topBarContent.openWidgetPopout({
loader: dankDashPopoutLoader,
widgetItem: weatherWidget,
section,
tabIndex,
triggerSource: topBarContent._dashTriggerSource(section, tabIndex),
tabIndex: 3,
triggerSource: topBarContent._dashTriggerSource(section, 3),
mode: "click",
useCenterSection: true,
setTriggerScreen: true
@@ -736,7 +736,7 @@ Item {
case "music":
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, {
loader,
tabIndex,
+1 -1
View File
@@ -240,7 +240,7 @@ PanelWindow {
id: barBlur
visible: false
readonly property bool barHasTransparency: barWindow._backgroundAlpha > 0 && barWindow._backgroundAlpha < 1
readonly property bool barHasTransparency: barWindow._backgroundAlpha < 1
function rebuild() {
teardown();
@@ -1,6 +1,7 @@
import QtQuick
import QtQuick.Effects
import Quickshell
import Quickshell.Hyprland
import Quickshell.Services.SystemTray
import Quickshell.Wayland
import Quickshell.Widgets
@@ -1047,9 +1048,12 @@ BasePill {
WlrLayershell.namespace: "dms:tray-overflow-menu"
color: "transparent"
DankFocusGrab {
HyprlandFocusGrab {
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 {
@@ -1597,9 +1601,12 @@ BasePill {
WlrLayershell.keyboardFocus: KeyboardFocus.keyboardFocus(menuRoot.showMenu, null)
color: "transparent"
DankFocusGrab {
HyprlandFocusGrab {
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 {
@@ -107,14 +107,6 @@ Item {
}
}
Connections {
target: CompositorService
function onCompositorChanged() {
root._placeholderPool = [];
root._hyprSlotPool = {};
}
}
property var currentWorkspace: {
if (useExtWorkspace)
return getExtWorkspaceActiveWorkspace();
@@ -153,7 +145,8 @@ Item {
baseList = getNiriWorkspaces();
break;
case "hyprland":
return hyprlandSlotList(getHyprlandWorkspaces());
baseList = getHyprlandWorkspaces();
break;
case "mango":
if (root.mangoOverviewActive)
return [];
@@ -433,84 +426,41 @@ Item {
return Object.values(byApp);
}
function _makePlaceholder() {
if (useExtWorkspace)
return {
function padWorkspaces(list) {
const padded = list.slice();
let placeholder;
if (useExtWorkspace) {
placeholder = {
"id": "",
"name": "",
"active": false,
"_placeholder": true
};
if (CompositorService.isNiri)
return {
} else if (CompositorService.isNiri) {
placeholder = {
"id": -1,
"idx": -1,
"name": ""
};
if (CompositorService.isHyprland)
return {
} else if (CompositorService.isHyprland) {
placeholder = {
"id": -1,
"name": ""
};
if (root.isMango)
return {
} else if (root.isMango) {
placeholder = {
"tag": -1
};
if (CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle)
return {
} else if (CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle) {
placeholder = {
"num": -1,
"_placeholder": true
};
return -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
} else {
placeholder = -1;
}
}
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) {
if (root._placeholderPool.length <= slot)
root._placeholderPool.push(root._makePlaceholder());
padded.push(root._placeholderPool[slot]);
slot++;
padded.push(placeholder);
}
return padded;
}
@@ -688,7 +638,7 @@ Item {
NiriService.switchToWorkspace(data.id);
break;
case "hyprland":
if (data.id && data.id !== -1) {
if (data.id) {
HyprlandService.focusWorkspace(hyprlandWorkspaceSelector(data));
}
break;
@@ -713,7 +663,7 @@ Item {
for (let i = 0; i < workspaceRepeater.count; i++) {
const item = workspaceRepeater.itemAt(i);
if (!item || item.isPlaceholder)
if (!item)
continue;
const center = item.mapToItem(root, item.width / 2, item.height / 2);
const dist = isVertical ? Math.abs(localY - center.y) : Math.abs(localX - center.x);
@@ -447,7 +447,6 @@ DankPopout {
anchors.fill: parent
active: root.currentTabId === "wallpaper"
visible: active
asynchronous: true
sourceComponent: Component {
WallpaperTab {
active: true
@@ -459,12 +458,6 @@ DankPopout {
}
}
DankSpinner {
anchors.centerIn: parent
size: 40
visible: wallpaperLoader.active && wallpaperLoader.status === Loader.Loading
}
Loader {
id: weatherLoader
anchors.fill: parent
@@ -86,10 +86,12 @@ Rectangle {
}
function updateSelectedDateEvents() {
const events = (CalendarService && CalendarService.calendarAvailable) ? CalendarService.getEventsForDate(selectedDate) : [];
if (JSON.stringify(events) === JSON.stringify(selectedDateEvents))
return;
selectedDateEvents = events;
if (CalendarService && CalendarService.calendarAvailable) {
const events = CalendarService.getEventsForDate(selectedDate);
selectedDateEvents = events;
} else {
selectedDateEvents = [];
}
}
function loadEventsForMonth() {
@@ -274,29 +276,57 @@ Rectangle {
height: 40
visible: showEventDetails
DankActionButton {
buttonSize: 32
iconSize: 14
iconName: "arrow_back"
iconColor: Theme.primary
Rectangle {
width: 32
height: 32
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
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 {
buttonSize: 32
iconSize: 16
iconName: "event"
iconColor: Theme.primary
Rectangle {
width: 32
height: 32
anchors.verticalCenter: parent.verticalCenter
anchors.right: parent.right
anchors.rightMargin: Theme.spacingS
radius: Theme.cornerRadius
visible: CalendarService && CalendarService.canCreateEvents
onClicked: {
root.editorEvent = null;
root.showEditor = true;
color: addEventArea.containsMouse ? Theme.primaryHover : Theme.withAlpha(Theme.primaryHover, 0)
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
visible: !showEventDetails
DankActionButton {
buttonSize: 28
iconSize: 14
iconName: "chevron_left"
iconColor: Theme.primary
onClicked: root.shiftMonth(-1)
Rectangle {
width: 28
height: 28
radius: Theme.cornerRadius
color: prevMonthArea.containsMouse ? Theme.primaryHover : Theme.withAlpha(Theme.primaryHover, 0)
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 {
@@ -347,20 +396,53 @@ Rectangle {
verticalAlignment: Text.AlignVCenter
}
DankActionButton {
buttonSize: 28
iconSize: 14
iconName: "today"
iconColor: Theme.primary
onClicked: root.goToToday()
Rectangle {
width: 28
height: 28
radius: Theme.cornerRadius
color: todayArea.containsMouse ? Theme.primaryHover : Theme.withAlpha(Theme.primaryHover, 0)
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 {
buttonSize: 28
iconSize: 14
iconName: "chevron_right"
iconColor: Theme.primary
onClicked: root.shiftMonth(1)
Rectangle {
width: 28
height: 28
radius: Theme.cornerRadius
color: nextMonthArea.containsMouse ? Theme.primaryHover : Theme.withAlpha(Theme.primaryHover, 0)
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 isDankTask: taskId.startsWith("vtodo_")
readonly property bool isTask: isLocalTask || isDankTask
readonly property bool canModify: isLocalTask || (isDankTask && modelData && !modelData.readOnly)
readonly property color baseAccent: {
if (isLocalTask || !modelData || !modelData.color || !modelData.color.length)
return Theme.primary;
return modelData.color;
readonly property bool isTask: modelData && modelData.id && modelData.id.startsWith("task_")
readonly property color accentColor: {
if (isTask)
return modelData.completed ? Theme.withAlpha(Theme.primary, 0.4) : Theme.primary;
return (modelData && modelData.color && modelData.color.length) ? modelData.color : Theme.primary;
}
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)
color: surfaceColor
@@ -810,13 +888,13 @@ Rectangle {
anchors.verticalCenter: parent.verticalCenter
radius: Theme.cornerRadius
color: "transparent"
visible: taskItem.isLocalTask && !taskItem.isEditing
visible: modelData && modelData.id && modelData.id.startsWith("task_") && !taskItem.isEditing
DankIcon {
anchors.centerIn: parent
name: "drag_indicator"
size: 14
color: dragMouseArea.containsMouse ? Theme.primary : Theme.surfaceTextMedium
color: dragMouseArea.containsMouse ? Theme.primary : Theme.surfaceTextAlpha
}
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 {
id: eventContent
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: taskItem.isLocalTask ? 60 : taskItem.isDankTask ? 36 : (Theme.spacingS + 6)
anchors.rightMargin: taskItem.canModify ? 64 : Theme.spacingXS
anchors.leftMargin: (modelData && modelData.id && modelData.id.startsWith("task_")) ? 60 : (Theme.spacingS + 6)
anchors.rightMargin: (modelData && modelData.id && modelData.id.startsWith("task_")) ? 64 : Theme.spacingXS
spacing: Theme.spacingXXS
visible: !taskItem.isEditing
@@ -874,7 +972,7 @@ Rectangle {
width: parent.width
text: modelData ? modelData.title : ""
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
horizontalAlignment: Text.AlignLeft
elide: Text.ElideRight
@@ -902,7 +1000,7 @@ Rectangle {
color: Theme.surfaceTextMedium
font.weight: Font.Normal
horizontalAlignment: Text.AlignLeft
visible: text !== "" && !taskItem.isLocalTask
visible: text !== "" && modelData && modelData.id && !modelData.id.startsWith("task_")
}
}
@@ -949,73 +1047,90 @@ Rectangle {
id: eventMouseArea
anchors.fill: parent
anchors.leftMargin: taskItem.isLocalTask ? 32 : 6
anchors.rightMargin: taskItem.canModify ? 64 : 0
anchors.leftMargin: (modelData && modelData.id && modelData.id.startsWith("task_")) ? 32 : 6
anchors.rightMargin: (modelData && modelData.id && modelData.id.startsWith("task_")) ? 64 : 0
hoverEnabled: true
cursorShape: modelData ? Qt.PointingHandCursor : Qt.ArrowCursor
enabled: modelData && !taskItem.isEditing
onClicked: {
if (!modelData)
return;
if (taskItem.isTask && taskItem.canModify) {
if (modelData && modelData.id && modelData.id.startsWith("task_")) {
CalendarService.toggleTask(modelData.id);
return;
}
root.detailEvent = modelData;
if (modelData)
root.detailEvent = modelData;
}
}
DankActionButton {
buttonSize: 24
iconSize: 16
iconName: (modelData && modelData.completed) ? "check_box" : "check_box_outline_blank"
iconColor: (modelData && modelData.completed) ? Theme.primary : Theme.surfaceText
anchors.left: parent.left
anchors.leftMargin: taskItem.isLocalTask ? (taskItem.isEditing ? 8 : 32) : 8
anchors.verticalCenter: parent.verticalCenter
visible: taskItem.isTask
enabled: taskItem.canModify && !taskItem.isEditing
onClicked: CalendarService.toggleTask(modelData.id)
}
DankActionButton {
// Delete / Cancel Button
Rectangle {
id: deleteButton
buttonSize: 24
iconSize: 14
iconName: taskItem.isEditing ? "close" : "delete"
iconColor: taskItem.isEditing ? Theme.surfaceText : Theme.error
width: 24
height: 24
anchors.right: parent.right
anchors.rightMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
visible: taskItem.canModify
onClicked: {
if (taskItem.isEditing) {
taskItem.isEditing = false;
return;
radius: Theme.cornerRadius
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)
visible: modelData && modelData.id && modelData.id.startsWith("task_")
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 {
buttonSize: 24
iconSize: 14
iconName: taskItem.isEditing ? "check" : "edit"
iconColor: taskItem.isEditing ? Theme.primary : Theme.surfaceText
// Edit / Save Button
Rectangle {
id: editButton
width: 24
height: 24
anchors.right: deleteButton.left
anchors.rightMargin: Theme.spacingXS
anchors.verticalCenter: parent.verticalCenter
visible: taskItem.canModify
onClicked: {
if (!taskItem.isEditing) {
taskItem.isEditing = true;
return;
radius: Theme.cornerRadius
color: editMouseArea.containsMouse ? Theme.primaryHover : Theme.withAlpha(Theme.primaryHover, 0)
visible: modelData && modelData.id && modelData.id.startsWith("task_")
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 QtCore
import QtQuick
import Quickshell
import Quickshell.Widgets
import QtQuick.Effects
import qs.Common
import qs.Modals.FileBrowser
import qs.Widgets
@@ -21,11 +20,12 @@ Item {
property int itemsPerPage: 16
property int totalPages: Math.max(1, Math.ceil(wallpaperFolderModel.count / itemsPerPage))
property bool active: false
property Item focusTarget: pager
property Item focusTarget: wallpaperGrid
property Item tabBarItem: null
property int gridIndex: 0
property Item keyForwardTarget: null
property var parentPopout: null
property int lastPage: 0
property bool enableAnimation: false
property string homeDir: StandardPaths.writableLocation(StandardPaths.HomeLocation)
property string selectedFileName: ""
@@ -86,12 +86,12 @@ Item {
}
}
onCurrentPageChanged: updateSelectedFileName()
onTotalPagesChanged: {
if (currentPage >= totalPages) {
currentPage = Math.max(0, totalPages - 1);
onCurrentPageChanged: {
if (currentPage !== lastPage) {
enableAnimation = false;
lastPage = currentPage;
}
updateSelectedFileName();
}
onGridIndexChanged: {
@@ -257,7 +257,6 @@ Item {
}
function setInitialSelection() {
enableAnimation = false;
const currentWallpaper = getCurrentWallpaper();
if (!currentWallpaper || wallpaperFolderModel.count === 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 {
target: wallpaperFolderModel
function onCountChanged() {
@@ -359,6 +369,7 @@ Item {
setInitialSelection();
}
updateSelectedFileName();
thumbnailPreloader.paths = collectWallpaperPaths();
}
}
function onStatusChanged() {
@@ -367,10 +378,16 @@ Item {
setInitialSelection();
}
updateSelectedFileName();
thumbnailPreloader.paths = collectWallpaperPaths();
}
}
}
WallpaperThumbnailPreloader {
id: thumbnailPreloader
cacheSize: 256
}
FolderListModel {
id: wallpaperFolderModel
@@ -430,169 +447,147 @@ Item {
width: parent.width
height: parent.height - 50
ListView {
id: pager
GridView {
id: wallpaperGrid
anchors.centerIn: parent
width: parent.width - Theme.spacingS
height: parent.height - Theme.spacingS
orientation: ListView.Vertical
snapMode: ListView.SnapOneItem
highlightRangeMode: ListView.StrictlyEnforceRange
preferredHighlightBegin: 0
preferredHighlightEnd: height
highlightMoveDuration: root.enableAnimation ? Theme.mediumDuration : 0
boundsBehavior: Flickable.StopAtBounds
cellWidth: width / 4
cellHeight: height / 4
clip: true
enabled: root.active
interactive: root.active
boundsBehavior: Flickable.StopAtBounds
keyNavigationEnabled: false
activeFocusOnTab: false
highlightFollowsCurrentItem: true
highlightMoveDuration: enableAnimation ? Theme.shortDuration : 0
focus: false
cacheBuffer: Math.max(0, height * 2)
reuseItems: true
model: root.totalPages
onCurrentIndexChanged: {
if (!moving) {
return;
}
if (currentIndex >= 0 && currentIndex !== root.currentPage) {
root.currentPage = currentIndex;
highlight: Item {
z: 1000
Rectangle {
anchors.fill: parent
anchors.margins: Theme.spacingXS
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 {
target: root
function onCurrentPageChanged() {
if (pager.currentIndex !== root.currentPage) {
pager.currentIndex = root.currentPage;
function onGridIndexChanged() {
if (wallpaperGrid.count > 0) {
wallpaperGrid.currentIndex = gridIndex;
if (!enableAnimation) {
wallpaperGrid.positionViewAtIndex(gridIndex, GridView.Contain);
}
}
}
}
delegate: GridView {
id: pageGrid
delegate: Item {
width: wallpaperGrid.cellWidth
height: wallpaperGrid.cellHeight
property int pageIndex: index
property string wallpaperPath: modelData || ""
property bool isSelected: getCurrentWallpaper() === modelData
width: pager.width
height: pager.height
cellWidth: width / 4
cellHeight: height / 4
interactive: false
keyNavigationEnabled: false
activeFocusOnTab: false
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 {
id: wallpaperCard
anchors.fill: parent
anchors.margins: Theme.spacingXS
color: Theme.withAlpha(Theme.surfaceContainerHighest, Theme.popupTransparency)
radius: Theme.cornerRadius
clip: true
Rectangle {
id: wallpaperCard
anchors.fill: parent
anchors.margins: Theme.spacingXS
color: Theme.withAlpha(Theme.surfaceContainerHighest, Theme.popupTransparency)
color: isSelected ? Theme.primaryPressed : Theme.withAlpha(Theme.primaryPressed, 0)
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
visible: false
layer.enabled: true
}
Rectangle {
anchors.fill: parent
color: isSelected ? Theme.primaryPressed : Theme.withAlpha(Theme.primaryPressed, 0)
radius: parent.radius
CachingImage {
id: thumbnailImage
anchors.fill: parent
imagePath: modelData || ""
maxCacheSize: 256
Behavior on color {
ColorAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
layer.enabled: true
layer.effect: MultiEffect {
maskEnabled: true
maskThresholdMin: 0.5
maskSpreadAtMin: 1.0
maskSource: maskRect
}
}
ClippingRectangle {
anchors.fill: parent
radius: wallpaperCard.radius
color: "transparent"
StateLayer {
anchors.fill: parent
cornerRadius: parent.radius
stateColor: Theme.primary
}
CachingImage {
id: thumbnailImage
anchors.fill: parent
imagePath: modelData || ""
maxCacheSize: 256
animate: false
opacity: status === Image.Ready ? 1 : 0
MouseArea {
id: wallpaperMouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
Behavior on opacity {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
}
StateLayer {
anchors.fill: parent
cornerRadius: parent.radius
stateColor: Theme.primary
}
MouseArea {
id: wallpaperMouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
gridIndex = index;
if (modelData) {
setCurrentWallpaper(modelData);
}
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 {
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")
font.pixelSize: 14
color: Theme.outline
@@ -168,19 +168,6 @@ PanelWindow {
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
WlrLayershell.layer: {
const shouldUseOverlay = notificationData && (SettingsData.notificationOverlayEnabled || notificationData.urgency === NotificationUrgency.Critical);
@@ -1020,7 +1007,8 @@ PanelWindow {
z: 15
onClicked: {
dismissPopupReliably();
if (notificationData && !win.exiting)
notificationData.popup = false;
}
}
@@ -1092,7 +1080,8 @@ PanelWindow {
onClicked: {
if (modelData && modelData.invoke)
modelData.invoke();
dismissPopupReliably();
if (notificationData && !win.exiting)
notificationData.popup = false;
}
}
}
@@ -1174,7 +1163,7 @@ PanelWindow {
notificationData.actions[0].invoke();
NotificationService.dismissNotification(notificationData);
} else {
dismissPopupReliably();
notificationData.popup = false;
}
}
}
@@ -368,7 +368,7 @@ Item {
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
root.deleteVpnConfirm.showWithOptions({
deleteVpnConfirm.showWithOptions({
title: I18n.tr("Delete VPN"),
message: I18n.tr("Delete \"%1\"?").arg(modelData.name),
confirmText: I18n.tr("Delete"),
+2 -191
View File
@@ -21,9 +21,6 @@ Item {
property var calendars: []
property var events: []
property var eventsByDate: ({})
property var tasks: []
property var tasksByDate: ({})
property var _pendingTaskState: ({})
property string lastError: ""
property date focusDate: new Date()
property var _loadedFrom: null
@@ -133,7 +130,6 @@ Item {
root.log.info("connected to dankcal:", root.socketPath);
root.refreshCalendars();
root.reloadEvents();
root.reloadTasks();
return;
}
if (!root.connected && !root.socketFound)
@@ -197,19 +193,12 @@ Item {
}
}
Timer {
id: tasksDebounce
interval: 400
repeat: false
onTriggered: root.reloadTasks()
}
function _sendSubscribe() {
subscribeSocket.send({
"id": _nextId(),
"method": "subscribe",
"params": {
"topics": ["accounts", "calendars", "events", "tasks", "sync"]
"topics": ["accounts", "calendars", "events", "sync"]
}
});
}
@@ -254,14 +243,8 @@ Item {
refreshDebounce.restart();
break;
case "events":
refreshDebounce.restart();
break;
case "tasks":
tasksDebounce.restart();
break;
case "sync":
refreshDebounce.restart();
tasksDebounce.restart();
break;
}
}
@@ -299,7 +282,6 @@ Item {
}
calendars = list;
_rebuildEventsByDate();
_rebuildTasksByDate();
});
}
@@ -371,7 +353,7 @@ Item {
function _normalizeEvent(e) {
const allDay = !!e.allDay;
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);
return {
"id": id,
@@ -496,175 +478,4 @@ Item {
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
enabled: root.backendPref === "dankcal" || root.backendPref === "auto"
onEventsByDateChanged: root.mergeEvents()
onTasksByDateChanged: root.mergeEvents()
onConnectedChanged: {
if (connected && root._rangeSet)
root.loadEvents(root.lastStartDate, root.lastEndDate);
@@ -199,17 +198,6 @@ Singleton {
}
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 tasks = Object.assign({}, root.localTasks);
if (!tasks[dateKey])
@@ -226,13 +214,6 @@ Singleton {
}
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 tasks = Object.assign({}, root.localTasks);
let updated = false;
@@ -256,10 +237,6 @@ Singleton {
}
function removeTask(taskId) {
if (taskId.startsWith("vtodo_")) {
dankBackend.deleteTask(taskId.slice(6));
return;
}
let cleanId = taskId.replace("task_", "");
let tasks = Object.assign({}, root.localTasks);
let updated = false;
@@ -306,12 +283,6 @@ Singleton {
}
function editTask(taskId, newText) {
if (taskId.startsWith("vtodo_")) {
dankBackend.updateTask(taskId.slice(6), {
"summary": newText
});
return;
}
let cleanId = taskId.replace("task_", "");
let tasks = Object.assign({}, root.localTasks);
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() {
let merged = {};
let backendEvents = _activeBackendEventsByDate();
@@ -352,9 +312,14 @@ Singleton {
for (let dateKey in backendEvents)
merged[dateKey] = [].concat(backendEvents[dateKey]);
_mergeInto(merged, root.taskEventsByDate);
if (isDankActive)
_mergeInto(merged, dankBackend.tasksByDate);
for (let dateKey in root.taskEventsByDate) {
if (!merged[dateKey])
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) {
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 resizeOnBorder = (typeof SettingsData !== "undefined" && SettingsData.hyprlandResizeOnBorder) ? true : false;
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
// dms:frame only in separate mode connected-mode frame blur overlaps windows via popouts/arcs
const xrayNamespaces = ["dms:bar"];
if (frameEnabled && SettingsData.frameMode !== "connected")
xrayNamespaces.push("dms:frame");
// Connected frame mode has no separate bar/frame surface to target
const barFrameTargetNamespace = !frameEnabled ? (SettingsData.standaloneBarXrayAvailable ? "dms:bar" : null) : (frameConnectedMode ? null : "dms:frame");
const generalLines = [];
if (manageGaps)
@@ -372,15 +371,13 @@ hl.layer_rule({
})
`;
}
if (layoutBarXrayEnabled) {
for (const ns of xrayNamespaces) {
content += `
if (layoutBarXrayEnabled && barFrameTargetNamespace) {
content += `
hl.layer_rule({
match = { namespace = "^${ns}$" },
match = { namespace = "^${barFrameTargetNamespace}$" },
xray = true,
})
`;
}
}
// Marker persists the preference even while the rule has no target
if (!layoutBarXrayEnabled) {
+7 -6
View File
@@ -1189,18 +1189,19 @@ Singleton {
const gaps = gapsOverride >= 0 ? gapsOverride : defaultGaps;
const borderSize = (typeof SettingsData !== "undefined" && SettingsData.niriLayoutBorderSize >= 0) ? SettingsData.niriLayoutBorderSize : defaultBorderSize;
const frameEnabled = typeof SettingsData !== "undefined" && SettingsData.frameEnabled;
// dms:frame only in separate mode connected-mode frame blur overlaps windows via popouts/arcs
const excludeNamespaces = ["dms:bar"];
if (frameEnabled && SettingsData.frameMode !== "connected")
excludeNamespaces.push("dms:frame");
const frameConnectedMode = frameEnabled && SettingsData.frameMode === "connected";
// Connected frame mode has no separate bar/frame surface to target
const barFrameTargetNamespace = !frameEnabled ? (SettingsData.standaloneBarXrayAvailable ? "dms:bar" : null) : (frameConnectedMode ? null : "dms:frame");
// 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 = "";
if (!layoutXrayEnabled) {
const excludeLines = layoutBarXrayEnabled ? excludeNamespaces.map(ns => `\n exclude namespace="^${ns}$"`).join("") : "";
const excludeLine = (layoutBarXrayEnabled && barFrameTargetNamespace) ? `\n exclude namespace="^${barFrameTargetNamespace}$"` : "";
xrayRules += `
layer-rule {${excludeLines}
layer-rule {${excludeLine}
background-effect {
xray false
}
+17 -3
View File
@@ -192,6 +192,19 @@ Singleton {
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) {
if (!desktopEntry || !desktopEntry.command)
return;
@@ -216,7 +229,7 @@ Singleton {
const cursorEnv = typeof SettingsData.getCursorEnvironment === "function" ? SettingsData.getCursorEnvironment() : {};
const overrideEnv = override?.envVars ? parseEnvVars(override.envVars) : {};
const finalEnv = Object.assign({}, cursorEnv, overrideEnv);
const finalEnv = restoreWrapperEnv(Object.assign({}, cursorEnv, overrideEnv));
if (desktopEntry.runInTerminal) {
const terminal = SessionData.resolveTerminal() || "xterm";
@@ -266,13 +279,14 @@ Singleton {
const prefix = userPrefix.length > 0 ? userPrefix : defaultPrefix;
const workDir = desktopEntry.workingDirectory || Quickshell.env("HOME");
const cursorEnv = typeof SettingsData.getCursorEnvironment === "function" ? SettingsData.getCursorEnvironment() : {};
const finalEnv = restoreWrapperEnv(Object.assign({}, cursorEnv));
if (prefix.length > 0 && needsShellExecution(prefix)) {
const escapedCmd = cmd.map(arg => escapeShellArg(arg)).join(" ");
Quickshell.execDetached({
command: ["sh", "-c", `${prefix} ${escapedCmd}`],
workingDirectory: workDir,
environment: cursorEnv
environment: finalEnv
});
return;
}
@@ -283,7 +297,7 @@ Singleton {
Quickshell.execDetached({
command: cmd,
workingDirectory: workDir,
environment: cursorEnv
environment: finalEnv
});
}
+37 -34
View File
@@ -1,4 +1,5 @@
import QtQuick
import Quickshell.Io
import qs.Common
Item {
@@ -8,14 +9,10 @@ Item {
property int maxCacheSize: 512
property int status: isAnimated ? animatedImg.status : staticImg.status
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 isAnimated: {
if (!animate || !imagePath)
if (!imagePath)
return false;
const lower = imagePath.toLowerCase();
return lower.endsWith(".gif") || lower.endsWith(".webp");
@@ -42,8 +39,7 @@ Item {
}
readonly property string imageHash: normalizedPath ? djb2Hash(normalizedPath) : ""
readonly property string cacheFileName: imageHash && !isRemoteUrl && !isAnimated ? `${imageHash}@${maxCacheSize}x${maxCacheSize}.png` : ""
readonly property string cachePath: cacheFileName ? `${Paths.stringify(Paths.imagecache)}/${cacheFileName}` : ""
readonly property string cachePath: imageHash && !isRemoteUrl && !isAnimated ? `${Paths.stringify(Paths.imagecache)}/${imageHash}@${maxCacheSize}x${maxCacheSize}.png` : ""
readonly property string encodedImagePath: {
if (!normalizedPath)
return "";
@@ -73,51 +69,58 @@ Item {
smooth: true
onStatusChanged: {
switch (status) {
case Image.Error:
if (!root._fromCache)
return;
root._fromCache = false;
if (source === root.cachePath && status === Image.Error) {
source = root.encodedImagePath;
return;
case Image.Ready:
if (root._fromCache || root.isRemoteUrl || !root.cachePath)
return;
if (!visible || width <= 0 || height <= 0 || !Window.window?.visible)
return;
const grabPath = root.cachePath;
grabToImage(res => {
res.saveToFile(grabPath);
});
}
if (root.isRemoteUrl || source !== root.encodedImagePath || status !== Image.Ready || !root.cachePath)
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) {
_fromCache = false;
staticImg.source = "";
return;
}
if (isAnimated)
return;
if (isRemoteUrl) {
_fromCache = false;
staticImg.source = imagePath;
return;
}
if (!cachePath) {
_fromCache = false;
staticImg.source = encodedImagePath;
Paths.mkdir(Paths.imagecache);
const hash = djb2Hash(normalizedPath);
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;
}
// Cache-first; a miss errors and falls back to encodedImagePath
_fromCache = true;
staticImg.source = cachePath;
cacheProbe.running = false;
cacheProbe.cachePath = cPath;
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 Quickshell.Hyprland
import qs.Common
import qs.Services
@@ -61,7 +62,7 @@ Item {
}
// Hyprland OnDemand grab: whitelist popout surfaces and bars so dismiss clicks still land.
DankFocusGrab {
HyprlandFocusGrab {
windows: {
const list = [];
if (root.contentWindow)
@@ -71,7 +72,10 @@ Item {
const transientWindows = root.transientSurfaceTracker?.focusWindows ?? [];
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 {
@@ -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);
}
}