mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-08-02 03:28:28 -04:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3420d2be93 | |||
| 405ea708b3 | |||
| cc0bec2682 | |||
| 240adfa28f | |||
| c175f4823a | |||
| 35613ebba8 | |||
| 583e46f2f0 | |||
| 590555dfc6 | |||
| c11169eb4c | |||
| bddcdb3f99 | |||
| 6ec67cefd0 | |||
| 8d9d7ff0ef | |||
| acc39ceb16 | |||
| 43863a86fb | |||
| 36ad34a555 | |||
| 78e823e23a | |||
| b6a27dc713 | |||
| 79dfd34ca2 | |||
| eedba0e8c9 | |||
| 2b12292895 |
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=DMS Status Notifier Watcher (early tray)
|
||||
# The tray watcher starts in parallel with the compositor and owns org.kde.StatusNotifierWatcher before
|
||||
# graphical-session.target, i.e. before any XDG autostart app launches.
|
||||
PartOf=graphical-session.target
|
||||
Before=graphical-session.target
|
||||
|
||||
[Service]
|
||||
Type=dbus
|
||||
BusName=org.kde.StatusNotifierWatcher
|
||||
ExecStart=/usr/bin/dms tray-watcher
|
||||
Restart=on-failure
|
||||
RestartSec=1
|
||||
Slice=session.slice
|
||||
|
||||
[Install]
|
||||
WantedBy=graphical-session.target
|
||||
@@ -774,5 +774,6 @@ func getCommonCommands() []*cobra.Command {
|
||||
trashCmd,
|
||||
systemCmd,
|
||||
switchUserCmd,
|
||||
trayWatcherCmd,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/traywatcher"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var trayWatcherCmd = &cobra.Command{
|
||||
Use: "tray-watcher",
|
||||
Short: "Run a minimal StatusNotifierWatcher for early tray registration",
|
||||
Long: `Run a minimal org.kde.StatusNotifierWatcher daemon.
|
||||
|
||||
Started early in the session (Before=graphical-session.target via
|
||||
dms-tray-watcher.service), it lets tray apps launched by XDG autostart
|
||||
register their items before the shell finishes loading, and keeps them
|
||||
registered across shell restarts. The shell's tray host picks items up from
|
||||
this watcher; if it is not running, the shell's built-in watcher takes over.`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := traywatcher.Run(); err != nil {
|
||||
log.Fatalf("%v", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -582,7 +582,11 @@ func runShellDaemon(session bool) {
|
||||
}
|
||||
|
||||
var qsHasAnyDisplay = sync.OnceValue(func() bool {
|
||||
out, err := exec.Command("qs", "ipc", "--help").Output()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "qs", "ipc", "--help")
|
||||
cmd.WaitDelay = 500 * time.Millisecond
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
@@ -642,7 +646,10 @@ func getShellIPCCompletions(args []string, _ string) []string {
|
||||
return nil
|
||||
}
|
||||
cmdArgs := append(baseArgs, "show")
|
||||
cmd := exec.Command("qs", cmdArgs...)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "qs", cmdArgs...)
|
||||
cmd.WaitDelay = 500 * time.Millisecond
|
||||
var targets ipcTargets
|
||||
|
||||
if output, err := cmd.Output(); err == nil {
|
||||
|
||||
@@ -880,8 +880,9 @@ func (cd *ConfigDeployer) transformNiriConfigForNonSystemd(config, terminalComma
|
||||
|
||||
config = regexp.MustCompile(`environment \{[^}]*\}`).ReplaceAllString(config, envVars)
|
||||
|
||||
spawnDms := `spawn-at-startup "dms" "run"`
|
||||
if !strings.Contains(config, spawnDms) {
|
||||
// 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"`) {
|
||||
// Insert spawn-at-startup for dms after the environment block
|
||||
envBlockEnd := regexp.MustCompile(`environment \{[^}]*\}`)
|
||||
if loc := envBlockEnd.FindStringIndex(config); loc != nil {
|
||||
|
||||
@@ -520,9 +520,21 @@ 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")
|
||||
|
||||
@@ -7,7 +7,9 @@ env=XDG_SESSION_TYPE,wayland
|
||||
|
||||
# exec-once runs only at startup. Do NOT use exec= for the shell: mango re-runs
|
||||
# every exec= on each config reload, and DMS reloads the config, which would
|
||||
# spawn a new shell on every reload.
|
||||
# spawn a new shell on every reload. The tray watcher starts first so it owns
|
||||
# the SNI name before dms and the tray apps come up.
|
||||
exec-once=dms tray-watcher
|
||||
exec-once=dms run
|
||||
|
||||
source=./dms/colors.conf
|
||||
|
||||
@@ -116,6 +116,7 @@ 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
|
||||
|
||||
@@ -587,13 +587,15 @@ 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").Run(); err != nil {
|
||||
if err := exec.CommandContext(ctx, "systemctl", "--user", "add-wants", "niri.service", "dms", "dms-tray-watcher").Run(); err != nil {
|
||||
b.log("Warning: failed to add dms as a want for niri.service")
|
||||
}
|
||||
case deps.WindowManagerHyprland:
|
||||
if err := exec.CommandContext(ctx, "systemctl", "--user", "add-wants", "hyprland-session.target", "dms").Run(); err != nil {
|
||||
if err := exec.CommandContext(ctx, "systemctl", "--user", "add-wants", "hyprland-session.target", "dms", "dms-tray-watcher").Run(); err != nil {
|
||||
b.log("Warning: failed to add dms as a want for hyprland-session.target")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1154,7 +1154,7 @@ func readLuaOrHyprlangOverride(path string) (map[string]*hyprlandOverrideBind, e
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lines := strings.Split(string(data), "\n")
|
||||
lines := expandLuaConfigLines(strings.Split(string(data), "\n"))
|
||||
parser := NewHyprlandParser("")
|
||||
pendingUnbinds := make(map[string]string)
|
||||
for _, line := range lines {
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Lua configs can express binds dynamically: variables (mainMod .. " + C"),
|
||||
// tostring() calls, and numeric for loops (workspace binds). This resolves such
|
||||
// expressions to literal key combos so the static bind parser can read them.
|
||||
|
||||
const luaMaxLoopIterations = 1000
|
||||
|
||||
var (
|
||||
luaAssignRE = regexp.MustCompile(`^(?:local\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$`)
|
||||
luaForRE = regexp.MustCompile(`^for\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(-?\d+)\s*,\s*(-?\d+)\s*(?:,\s*(-?\d+)\s*)?do\b(.*)$`)
|
||||
luaTostringRE = regexp.MustCompile(`to(?:string|number)\s*\(\s*("(?:\\.|[^"])*"|'(?:\\.|[^'])*'|-?\d+(?:\.\d+)?)\s*\)`)
|
||||
luaBlockOpenRE = regexp.MustCompile(`\b(?:function|for|while|if)\b`)
|
||||
luaBlockCloseRE = regexp.MustCompile(`\bend\b`)
|
||||
luaNumberRE = regexp.MustCompile(`^-?\d+(?:\.\d+)?$`)
|
||||
)
|
||||
|
||||
type luaVarEnv map[string]string
|
||||
|
||||
type luaForHeader struct {
|
||||
varName string
|
||||
start int
|
||||
stop int
|
||||
step int
|
||||
inline string
|
||||
}
|
||||
|
||||
func expandLuaConfigLines(lines []string) []string {
|
||||
return expandLuaBlock(lines, luaVarEnv{})
|
||||
}
|
||||
|
||||
func expandLuaBlock(lines []string, env luaVarEnv) []string {
|
||||
out := make([]string, 0, len(lines))
|
||||
for i := 0; i < len(lines); i++ {
|
||||
code := strings.TrimSpace(luaStripLineComment(lines[i]))
|
||||
|
||||
if name, value, ok := parseLuaStringAssignment(code, env); ok {
|
||||
env[name] = value
|
||||
out = append(out, lines[i])
|
||||
continue
|
||||
}
|
||||
|
||||
header, ok := parseLuaForHeader(code)
|
||||
if !ok {
|
||||
out = append(out, resolveLuaDynamicLine(lines[i], env))
|
||||
continue
|
||||
}
|
||||
|
||||
body, consumed, complete := collectLuaForBody(header, lines, i)
|
||||
if !complete {
|
||||
out = append(out, resolveLuaDynamicLine(lines[i], env))
|
||||
continue
|
||||
}
|
||||
out = append(out, expandLuaForLoop(header, body, env)...)
|
||||
i = consumed
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseLuaStringAssignment(code string, env luaVarEnv) (name, value string, ok bool) {
|
||||
m := luaAssignRE.FindStringSubmatch(code)
|
||||
if m == nil {
|
||||
return "", "", false
|
||||
}
|
||||
value, ok = evalLuaConcat(m[2], env)
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
return m[1], value, true
|
||||
}
|
||||
|
||||
func parseLuaForHeader(code string) (luaForHeader, bool) {
|
||||
m := luaForRE.FindStringSubmatch(code)
|
||||
if m == nil {
|
||||
return luaForHeader{}, false
|
||||
}
|
||||
start, _ := strconv.Atoi(m[2])
|
||||
stop, _ := strconv.Atoi(m[3])
|
||||
step := 1
|
||||
if m[4] != "" {
|
||||
step, _ = strconv.Atoi(m[4])
|
||||
}
|
||||
if step == 0 {
|
||||
return luaForHeader{}, false
|
||||
}
|
||||
return luaForHeader{varName: m[1], start: start, stop: stop, step: step, inline: strings.TrimSpace(m[5])}, true
|
||||
}
|
||||
|
||||
func collectLuaForBody(header luaForHeader, lines []string, headerIdx int) (body []string, consumed int, complete bool) {
|
||||
depth := 1
|
||||
if header.inline != "" {
|
||||
delta := luaBlockDelta(header.inline)
|
||||
if depth+delta <= 0 {
|
||||
if stmt := strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(header.inline), "end")); stmt != "" {
|
||||
body = append(body, stmt)
|
||||
}
|
||||
return body, headerIdx, true
|
||||
}
|
||||
depth += delta
|
||||
body = append(body, header.inline)
|
||||
}
|
||||
for j := headerIdx + 1; j < len(lines); j++ {
|
||||
delta := luaBlockDelta(lines[j])
|
||||
if depth+delta <= 0 {
|
||||
return body, j, true
|
||||
}
|
||||
depth += delta
|
||||
body = append(body, lines[j])
|
||||
}
|
||||
return nil, headerIdx, false
|
||||
}
|
||||
|
||||
func expandLuaForLoop(header luaForHeader, body []string, env luaVarEnv) []string {
|
||||
var out []string
|
||||
inRange := func(v int) bool {
|
||||
if header.step > 0 {
|
||||
return v <= header.stop
|
||||
}
|
||||
return v >= header.stop
|
||||
}
|
||||
count := 0
|
||||
for v := header.start; inRange(v); v += header.step {
|
||||
if count++; count > luaMaxLoopIterations {
|
||||
break
|
||||
}
|
||||
value := strconv.Itoa(v)
|
||||
iterLines := make([]string, len(body))
|
||||
for k, bl := range body {
|
||||
iterLines[k] = substituteLuaIdent(bl, header.varName, value)
|
||||
}
|
||||
out = append(out, expandLuaBlock(iterLines, cloneLuaEnv(env))...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func luaBlockDelta(line string) int {
|
||||
masked := luaMaskStrings(line)
|
||||
return len(luaBlockOpenRE.FindAllString(masked, -1)) - len(luaBlockCloseRE.FindAllString(masked, -1))
|
||||
}
|
||||
|
||||
func resolveLuaDynamicLine(line string, env luaVarEnv) string {
|
||||
if !strings.Contains(line, "hl.bind") && !strings.Contains(line, "hl.unbind") {
|
||||
return line
|
||||
}
|
||||
line = normalizeLuaToString(line)
|
||||
return rewriteLuaBindKeyArg(line, env)
|
||||
}
|
||||
|
||||
func normalizeLuaToString(line string) string {
|
||||
return luaTostringRE.ReplaceAllStringFunc(line, func(m string) string {
|
||||
inner := luaTostringRE.FindStringSubmatch(m)[1]
|
||||
if inner[0] == '"' || inner[0] == '\'' {
|
||||
return inner
|
||||
}
|
||||
return strconv.Quote(inner)
|
||||
})
|
||||
}
|
||||
|
||||
func rewriteLuaBindKeyArg(line string, env luaVarEnv) string {
|
||||
for _, fn := range []string{"hl.bind", "hl.unbind"} {
|
||||
idx := strings.Index(line, fn)
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
open := skipLuaWS(line, idx+len(fn))
|
||||
if open >= len(line) || line[open] != '(' {
|
||||
continue
|
||||
}
|
||||
argStart := skipLuaWS(line, open+1)
|
||||
expr, end, ok := parseLuaFirstArgExpr(line, argStart)
|
||||
if !ok || isLuaPlainStringArg(expr) {
|
||||
continue
|
||||
}
|
||||
value, ok := evalLuaConcat(expr, env)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
return line[:argStart] + strconv.Quote(value) + line[end:]
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
func evalLuaConcat(expr string, env luaVarEnv) (string, bool) {
|
||||
parts := splitLuaConcat(expr)
|
||||
var sb strings.Builder
|
||||
for _, part := range parts {
|
||||
value, ok := evalLuaOperand(part, env)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
sb.WriteString(value)
|
||||
}
|
||||
return sb.String(), true
|
||||
}
|
||||
|
||||
func evalLuaOperand(op string, env luaVarEnv) (string, bool) {
|
||||
op = strings.TrimSpace(op)
|
||||
if op == "" {
|
||||
return "", false
|
||||
}
|
||||
switch op[0] {
|
||||
case '"', '\'':
|
||||
s, next, ok := parseLuaStringLiteral(op, 0)
|
||||
return s, next == len(op) && ok
|
||||
}
|
||||
if luaNumberRE.MatchString(op) {
|
||||
return op, true
|
||||
}
|
||||
if inner, ok := luaUnwrapCall(op, "tostring"); ok {
|
||||
return evalLuaConcat(inner, env)
|
||||
}
|
||||
if inner, ok := luaUnwrapCall(op, "tonumber"); ok {
|
||||
return evalLuaConcat(inner, env)
|
||||
}
|
||||
if value, ok := env[op]; ok {
|
||||
return value, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func splitLuaConcat(expr string) []string {
|
||||
var parts []string
|
||||
parenDepth, braceDepth, bracketDepth := 0, 0, 0
|
||||
inStr := byte(0)
|
||||
esc := false
|
||||
start := 0
|
||||
for i := 0; i < len(expr); i++ {
|
||||
c := expr[i]
|
||||
if inStr != 0 {
|
||||
switch {
|
||||
case esc:
|
||||
esc = false
|
||||
case c == '\\' && inStr == '"':
|
||||
esc = true
|
||||
case c == inStr:
|
||||
inStr = 0
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch c {
|
||||
case '"', '\'':
|
||||
inStr = c
|
||||
case '(':
|
||||
parenDepth++
|
||||
case ')':
|
||||
if parenDepth > 0 {
|
||||
parenDepth--
|
||||
}
|
||||
case '{':
|
||||
braceDepth++
|
||||
case '}':
|
||||
if braceDepth > 0 {
|
||||
braceDepth--
|
||||
}
|
||||
case '[':
|
||||
bracketDepth++
|
||||
case ']':
|
||||
if bracketDepth > 0 {
|
||||
bracketDepth--
|
||||
}
|
||||
case '.':
|
||||
if parenDepth == 0 && braceDepth == 0 && bracketDepth == 0 && i+1 < len(expr) && expr[i+1] == '.' {
|
||||
parts = append(parts, expr[start:i])
|
||||
i++
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return append(parts, expr[start:])
|
||||
}
|
||||
|
||||
func substituteLuaIdent(line, name, value string) string {
|
||||
if !strings.Contains(line, name) {
|
||||
return line
|
||||
}
|
||||
var sb strings.Builder
|
||||
inStr := byte(0)
|
||||
esc := false
|
||||
for i := 0; i < len(line); {
|
||||
c := line[i]
|
||||
if inStr != 0 {
|
||||
sb.WriteByte(c)
|
||||
switch {
|
||||
case esc:
|
||||
esc = false
|
||||
case c == '\\' && inStr == '"':
|
||||
esc = true
|
||||
case c == inStr:
|
||||
inStr = 0
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if c == '"' || c == '\'' {
|
||||
inStr = c
|
||||
sb.WriteByte(c)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if isLuaIdentStart(c) {
|
||||
j := i + 1
|
||||
for j < len(line) && isLuaIdentByte(line[j]) {
|
||||
j++
|
||||
}
|
||||
word := line[i:j]
|
||||
if word == name && (i == 0 || line[i-1] != '.') {
|
||||
sb.WriteString(value)
|
||||
} else {
|
||||
sb.WriteString(word)
|
||||
}
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
sb.WriteByte(c)
|
||||
i++
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func luaMaskStrings(line string) string {
|
||||
b := []byte(line)
|
||||
inStr := byte(0)
|
||||
esc := false
|
||||
for i := 0; i < len(b); i++ {
|
||||
c := b[i]
|
||||
if inStr != 0 {
|
||||
wasEnd := !esc && c == inStr
|
||||
esc = !esc && c == '\\' && inStr == '"'
|
||||
b[i] = ' '
|
||||
if wasEnd {
|
||||
inStr = 0
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch c {
|
||||
case '"', '\'':
|
||||
inStr = c
|
||||
b[i] = ' '
|
||||
case '-':
|
||||
if i+1 < len(b) && b[i+1] == '-' {
|
||||
for ; i < len(b); i++ {
|
||||
b[i] = ' '
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func luaStripLineComment(line string) string {
|
||||
inStr := byte(0)
|
||||
esc := false
|
||||
for i := 0; i+1 < len(line); i++ {
|
||||
c := line[i]
|
||||
if inStr != 0 {
|
||||
switch {
|
||||
case esc:
|
||||
esc = false
|
||||
case c == '\\' && inStr == '"':
|
||||
esc = true
|
||||
case c == inStr:
|
||||
inStr = 0
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch c {
|
||||
case '"', '\'':
|
||||
inStr = c
|
||||
case '-':
|
||||
if line[i+1] == '-' {
|
||||
return line[:i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
func luaUnwrapCall(op, fn string) (string, bool) {
|
||||
op = strings.TrimSpace(op)
|
||||
if !strings.HasPrefix(op, fn) {
|
||||
return "", false
|
||||
}
|
||||
rest := strings.TrimSpace(op[len(fn):])
|
||||
if !strings.HasPrefix(rest, "(") || !strings.HasSuffix(rest, ")") {
|
||||
return "", false
|
||||
}
|
||||
return rest[1 : len(rest)-1], true
|
||||
}
|
||||
|
||||
func isLuaPlainStringArg(expr string) bool {
|
||||
expr = strings.TrimSpace(expr)
|
||||
if expr == "" || (expr[0] != '"' && expr[0] != '\'') {
|
||||
return false
|
||||
}
|
||||
_, next, ok := parseLuaStringLiteral(expr, 0)
|
||||
return ok && next == len(expr)
|
||||
}
|
||||
|
||||
func isLuaIdentStart(c byte) bool {
|
||||
return c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
|
||||
}
|
||||
|
||||
func cloneLuaEnv(env luaVarEnv) luaVarEnv {
|
||||
clone := make(luaVarEnv, len(env))
|
||||
maps.Copy(clone, env)
|
||||
return clone
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExpandLuaConfigLinesVariableConcat(t *testing.T) {
|
||||
lines := []string{
|
||||
`local mainMod = "SUPER"`,
|
||||
`hl.bind(mainMod .. " + C", hl.dsp.window.close())`,
|
||||
`hl.bind(mainMod .. " + H", hl.dsp.focus({direction = "l"}))`,
|
||||
`hl.bind("ALT + TAB", hl.dsp.window.cycle_next({}))`,
|
||||
}
|
||||
got := expandLuaConfigLines(lines)
|
||||
|
||||
want := []string{
|
||||
`hl.bind("SUPER + C",`,
|
||||
`hl.bind("SUPER + H",`,
|
||||
`hl.bind("ALT + TAB",`,
|
||||
}
|
||||
joined := strings.Join(got, "\n")
|
||||
for _, w := range want {
|
||||
if !strings.Contains(joined, w) {
|
||||
t.Errorf("expanded output missing %q\n---\n%s", w, joined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandLuaConfigLinesForLoop(t *testing.T) {
|
||||
lines := []string{
|
||||
`local mainMod = "SUPER"`,
|
||||
`for i = 1, 3 do`,
|
||||
` hl.bind(mainMod .. " + " .. i, hl.dsp.focus({workspace = tostring(i)}))`,
|
||||
` hl.bind(mainMod .. " SHIFT + " .. i, hl.dsp.window.move({workspace = tostring(i)}))`,
|
||||
`end`,
|
||||
}
|
||||
got := strings.Join(expandLuaConfigLines(lines), "\n")
|
||||
|
||||
for _, w := range []string{
|
||||
`hl.bind("SUPER + 1",`,
|
||||
`hl.bind("SUPER + 2",`,
|
||||
`hl.bind("SUPER + 3",`,
|
||||
`hl.bind("SUPER SHIFT + 3",`,
|
||||
`{workspace = "1"}`,
|
||||
} {
|
||||
if !strings.Contains(got, w) {
|
||||
t.Errorf("expanded loop missing %q\n---\n%s", w, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLuaLinesDynamicBinds(t *testing.T) {
|
||||
content := strings.Join([]string{
|
||||
`local mainMod = "SUPER"`,
|
||||
`hl.bind(mainMod .. " + C", hl.dsp.window.close())`,
|
||||
`hl.bind("ALT + TAB", hl.dsp.window.cycle_next({}))`,
|
||||
`for i = 1, 2 do`,
|
||||
` hl.bind(mainMod .. " + " .. i, hl.dsp.focus({workspace = tostring(i)}))`,
|
||||
`end`,
|
||||
}, "\n")
|
||||
|
||||
parser := NewHyprlandParser("")
|
||||
section, err := parser.parseLuaLines(content, "", "test.lua", "")
|
||||
if err != nil {
|
||||
t.Fatalf("parseLuaLines: %v", err)
|
||||
}
|
||||
|
||||
keys := map[string]*HyprlandKeyBinding{}
|
||||
for i := range section.Keybinds {
|
||||
kb := §ion.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 := strings.Split(content, "\n")
|
||||
lines := expandLuaConfigLines(strings.Split(content, "\n"))
|
||||
boundInFile := make(map[string]bool)
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
@@ -1056,6 +1056,9 @@ 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,12 +545,18 @@ func (a *SecretAgent) GetSecrets(
|
||||
out[settingName] = sec
|
||||
}
|
||||
if settingName == "vpn" && a.backend != nil && !isPKCS11 && (vpnUsername != "" || reply.Save) {
|
||||
pw := reply.Secrets["password"]
|
||||
secrets := make(map[string]string)
|
||||
for k, v := range reply.Secrets {
|
||||
if k != "username" {
|
||||
secrets[k] = v
|
||||
}
|
||||
}
|
||||
a.backend.pendingVPNSaveMu.Lock()
|
||||
a.backend.pendingVPNSave = &pendingVPNCredentials{
|
||||
ConnectionPath: string(path),
|
||||
Username: vpnUsername,
|
||||
Password: pw,
|
||||
Password: reply.Secrets["password"],
|
||||
Secrets: secrets,
|
||||
SavePassword: reply.Save,
|
||||
}
|
||||
a.backend.pendingVPNSaveMu.Unlock()
|
||||
@@ -790,7 +796,23 @@ func inferVPNFields(conn map[string]nmVariantMap, vpnService string) []string {
|
||||
fields = []string{"username", "password"}
|
||||
}
|
||||
case strings.Contains(vpnService, "openvpn"):
|
||||
if connType == "password" || connType == "password-tls" {
|
||||
switch connType {
|
||||
case "tls":
|
||||
// Cert auth wants the private-key passphrase, not a password.
|
||||
if secretFlagsNotRequired(dataMap["cert-pass-flags"]) {
|
||||
return nil
|
||||
}
|
||||
return []string{"cert-pass"}
|
||||
case "password-tls":
|
||||
fields = []string{}
|
||||
if dataMap["username"] == "" {
|
||||
fields = append(fields, "username")
|
||||
}
|
||||
fields = append(fields, "password")
|
||||
if !secretFlagsNotRequired(dataMap["cert-pass-flags"]) {
|
||||
fields = append(fields, "cert-pass")
|
||||
}
|
||||
case "password":
|
||||
if dataMap["username"] == "" {
|
||||
fields = []string{"username", "password"}
|
||||
}
|
||||
@@ -805,6 +827,19 @@ 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,6 +315,44 @@ 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,7 +90,10 @@ type pendingVPNCredentials struct {
|
||||
ConnectionPath string
|
||||
Username string
|
||||
Password string
|
||||
SavePassword bool
|
||||
// Secrets holds all VPN secret fields keyed by name (e.g. "cert-pass");
|
||||
// falls back to Password under the "password" key when empty.
|
||||
Secrets map[string]string
|
||||
SavePassword bool
|
||||
}
|
||||
|
||||
type cachedVPNCredentials struct {
|
||||
|
||||
@@ -863,13 +863,17 @@ func (b *NetworkManagerBackend) saveVPNCredentials(creds *pendingVPNCredentials)
|
||||
log.Infof("[saveVPNCredentials] Saving username")
|
||||
}
|
||||
|
||||
// Save password if requested
|
||||
// Save secrets if requested
|
||||
if creds.SavePassword {
|
||||
data["password-flags"] = "0"
|
||||
secs := make(map[string]string)
|
||||
secs["password"] = creds.Password
|
||||
secs := creds.Secrets
|
||||
if len(secs) == 0 {
|
||||
secs = map[string]string{"password": creds.Password}
|
||||
}
|
||||
for field := range secs {
|
||||
data[field+"-flags"] = "0"
|
||||
}
|
||||
vpn["secrets"] = dbus.MakeVariant(secs)
|
||||
log.Infof("[saveVPNCredentials] Saving password with password-flags=0")
|
||||
log.Infof("[saveVPNCredentials] Saving %d secret field(s) with flags=0", len(secs))
|
||||
}
|
||||
|
||||
vpn["data"] = dbus.MakeVariant(data)
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
// Package traywatcher implements a minimal org.kde.StatusNotifierWatcher that
|
||||
// claims the SNI name early in the session so autostart tray apps can register
|
||||
// before the shell finishes loading. See docs/TRAY_WATCHER.md.
|
||||
package traywatcher
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/godbus/dbus/v5"
|
||||
"github.com/godbus/dbus/v5/introspect"
|
||||
"github.com/godbus/dbus/v5/prop"
|
||||
)
|
||||
|
||||
const (
|
||||
busName = "org.kde.StatusNotifierWatcher"
|
||||
objPath = dbus.ObjectPath("/StatusNotifierWatcher")
|
||||
iface = "org.kde.StatusNotifierWatcher"
|
||||
)
|
||||
|
||||
const introXML = `<node>
|
||||
<interface name="org.kde.StatusNotifierWatcher">
|
||||
<method name="RegisterStatusNotifierItem">
|
||||
<arg type="s" direction="in" name="service"/>
|
||||
</method>
|
||||
<method name="RegisterStatusNotifierHost">
|
||||
<arg type="s" direction="in" name="service"/>
|
||||
</method>
|
||||
<property name="RegisteredStatusNotifierItems" type="as" access="read"/>
|
||||
<property name="IsStatusNotifierHostRegistered" type="b" access="read"/>
|
||||
<property name="ProtocolVersion" type="i" access="read"/>
|
||||
<signal name="StatusNotifierItemRegistered">
|
||||
<arg type="s" name="service"/>
|
||||
</signal>
|
||||
<signal name="StatusNotifierItemUnregistered">
|
||||
<arg type="s" name="service"/>
|
||||
</signal>
|
||||
<signal name="StatusNotifierHostRegistered"/>
|
||||
<signal name="StatusNotifierHostUnregistered"/>
|
||||
</interface>
|
||||
` + introspect.IntrospectDataString + prop.IntrospectDataString + `</node>`
|
||||
|
||||
type watcher struct {
|
||||
conn *dbus.Conn
|
||||
props *prop.Properties
|
||||
mu sync.Mutex
|
||||
items map[string]string // item id ("name/path") -> bus name to watch
|
||||
hosts map[string]bool
|
||||
}
|
||||
|
||||
func (w *watcher) itemListLocked() []string {
|
||||
list := make([]string, 0, len(w.items))
|
||||
for item := range w.items {
|
||||
list = append(list, item)
|
||||
}
|
||||
sort.Strings(list)
|
||||
return list
|
||||
}
|
||||
|
||||
func (w *watcher) emit(signal string, args ...any) {
|
||||
if err := w.conn.Emit(objPath, iface+"."+signal, args...); err != nil {
|
||||
log.Warnf("failed to emit %s: %v", signal, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Clients pass either an object path (item on the caller) or a bus name.
|
||||
func (w *watcher) RegisterStatusNotifierItem(sender dbus.Sender, service string) *dbus.Error {
|
||||
var item, watch string
|
||||
if strings.HasPrefix(service, "/") {
|
||||
item, watch = string(sender)+service, string(sender)
|
||||
} else {
|
||||
item, watch = service+"/StatusNotifierItem", service
|
||||
}
|
||||
|
||||
w.mu.Lock()
|
||||
if _, exists := w.items[item]; exists {
|
||||
w.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
w.items[item] = watch
|
||||
list := w.itemListLocked()
|
||||
w.mu.Unlock()
|
||||
|
||||
log.Infof("item registered: %s", item)
|
||||
w.emit("StatusNotifierItemRegistered", item)
|
||||
w.props.SetMust(iface, "RegisteredStatusNotifierItems", list)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *watcher) RegisterStatusNotifierHost(sender dbus.Sender, service string) *dbus.Error {
|
||||
watch := string(sender)
|
||||
if service != "" && !strings.HasPrefix(service, "/") {
|
||||
watch = service
|
||||
}
|
||||
|
||||
w.mu.Lock()
|
||||
if w.hosts[watch] {
|
||||
w.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
w.hosts[watch] = true
|
||||
w.mu.Unlock()
|
||||
|
||||
log.Infof("host registered: %s", watch)
|
||||
w.emit("StatusNotifierHostRegistered")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *watcher) pruneOwner(name string) {
|
||||
w.mu.Lock()
|
||||
var removed []string
|
||||
for item, watch := range w.items {
|
||||
if watch == name {
|
||||
delete(w.items, item)
|
||||
removed = append(removed, item)
|
||||
}
|
||||
}
|
||||
list := w.itemListLocked()
|
||||
hostGone := w.hosts[name]
|
||||
delete(w.hosts, name)
|
||||
w.mu.Unlock()
|
||||
|
||||
for _, item := range removed {
|
||||
log.Infof("item gone: %s", item)
|
||||
w.emit("StatusNotifierItemUnregistered", item)
|
||||
}
|
||||
if len(removed) > 0 {
|
||||
w.props.SetMust(iface, "RegisteredStatusNotifierItems", list)
|
||||
}
|
||||
if hostGone {
|
||||
log.Infof("host gone: %s", name)
|
||||
w.emit("StatusNotifierHostUnregistered")
|
||||
}
|
||||
}
|
||||
|
||||
// Run serves the watcher until the session bus connection closes.
|
||||
func Run() error {
|
||||
conn, err := dbus.ConnectSessionBus()
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to session bus: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
w := &watcher{conn: conn, items: map[string]string{}, hosts: map[string]bool{}}
|
||||
|
||||
if err := conn.Export(w, objPath, iface); err != nil {
|
||||
return fmt.Errorf("export watcher: %w", err)
|
||||
}
|
||||
w.props, err = prop.Export(conn, objPath, map[string]map[string]*prop.Prop{
|
||||
iface: {
|
||||
"RegisteredStatusNotifierItems": {Value: []string{}, Emit: prop.EmitTrue},
|
||||
// Always true: libappindicator clients drop the icon if it is false.
|
||||
"IsStatusNotifierHostRegistered": {Value: true, Emit: prop.EmitFalse},
|
||||
"ProtocolVersion": {Value: int32(0), Emit: prop.EmitFalse},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("export properties: %w", err)
|
||||
}
|
||||
if err := conn.Export(introspect.Introspectable(introXML), objPath, "org.freedesktop.DBus.Introspectable"); err != nil {
|
||||
return fmt.Errorf("export introspection: %w", err)
|
||||
}
|
||||
|
||||
if err := conn.AddMatchSignal(
|
||||
dbus.WithMatchSender("org.freedesktop.DBus"),
|
||||
dbus.WithMatchInterface("org.freedesktop.DBus"),
|
||||
dbus.WithMatchMember("NameOwnerChanged"),
|
||||
dbus.WithMatchObjectPath("/org/freedesktop/DBus"),
|
||||
); err != nil {
|
||||
return fmt.Errorf("match NameOwnerChanged: %w", err)
|
||||
}
|
||||
sigs := make(chan *dbus.Signal, 128)
|
||||
conn.Signal(sigs)
|
||||
|
||||
// No flags: queue for the name and take it when the current owner exits.
|
||||
reply, err := conn.RequestName(busName, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request %s: %w", busName, err)
|
||||
}
|
||||
if reply == dbus.RequestNameReplyInQueue {
|
||||
log.Infof("name busy, queued for %s", busName)
|
||||
}
|
||||
|
||||
for sig := range sigs {
|
||||
switch sig.Name {
|
||||
case "org.freedesktop.DBus.NameAcquired":
|
||||
if len(sig.Body) == 1 && sig.Body[0] == busName {
|
||||
log.Infof("acquired %s", busName)
|
||||
}
|
||||
case "org.freedesktop.DBus.NameOwnerChanged":
|
||||
if len(sig.Body) != 3 {
|
||||
continue
|
||||
}
|
||||
name, _ := sig.Body[0].(string)
|
||||
newOwner, _ := sig.Body[2].(string)
|
||||
if name != busName && newOwner == "" {
|
||||
w.pruneOwner(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("session bus connection closed")
|
||||
}
|
||||
@@ -72,6 +72,7 @@ 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; \
|
||||
|
||||
@@ -65,6 +65,7 @@ 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; \
|
||||
|
||||
@@ -119,6 +119,7 @@ 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
|
||||
@@ -145,6 +146,7 @@ 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
|
||||
|
||||
@@ -87,6 +87,7 @@ install -d %{buildroot}%{_datadir}/fish/vendor_completions.d
|
||||
%{_builddir}/dms-cli completion fish > %{buildroot}%{_datadir}/fish/vendor_completions.d/dms.fish || :
|
||||
|
||||
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
|
||||
@@ -112,6 +113,7 @@ 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
|
||||
|
||||
@@ -101,6 +101,24 @@ 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;
|
||||
};
|
||||
|
||||
@@ -39,6 +39,24 @@ in
|
||||
};
|
||||
};
|
||||
|
||||
# Early SNI watcher so autostart tray apps register before the shell loads.
|
||||
systemd.user.services.dms-tray-watcher = lib.mkIf cfg.systemd.enable {
|
||||
description = "DMS Status Notifier Watcher (early tray)";
|
||||
path = lib.mkForce [ ];
|
||||
|
||||
partOf = [ cfg.systemd.target ];
|
||||
before = [ cfg.systemd.target ];
|
||||
wantedBy = [ cfg.systemd.target ];
|
||||
restartIfChanged = cfg.systemd.restartIfChanged;
|
||||
|
||||
serviceConfig = {
|
||||
Type = "dbus";
|
||||
BusName = "org.kde.StatusNotifierWatcher";
|
||||
ExecStart = lib.getExe cfg.package + " tray-watcher";
|
||||
Restart = "on-failure";
|
||||
};
|
||||
};
|
||||
|
||||
environment.systemPackages = [ cfg.quickshell.package ] ++ common.packages;
|
||||
|
||||
environment.etc = lib.mapAttrs' (name: value: {
|
||||
|
||||
@@ -114,6 +114,7 @@ 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
|
||||
@@ -152,6 +153,7 @@ 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
|
||||
|
||||
@@ -63,6 +63,7 @@ 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
|
||||
@@ -99,6 +100,7 @@ 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
|
||||
|
||||
@@ -69,6 +69,8 @@ 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 \
|
||||
|
||||
@@ -50,6 +50,8 @@ 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 \
|
||||
|
||||
@@ -138,11 +138,8 @@
|
||||
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,6 +3,7 @@ pragma ComponentBehavior: Bound
|
||||
|
||||
import Quickshell
|
||||
import QtCore
|
||||
import QtQuick
|
||||
import qs.Services
|
||||
|
||||
Singleton {
|
||||
@@ -19,6 +20,8 @@ Singleton {
|
||||
|
||||
readonly property url imagecache: `${cache}/imagecache`
|
||||
|
||||
Component.onCompleted: mkdir(imagecache)
|
||||
|
||||
function stringify(path: url): string {
|
||||
return path.toString().replace(/%20/g, " ");
|
||||
}
|
||||
|
||||
@@ -462,7 +462,7 @@ Singleton {
|
||||
property bool clockCompactMode: false
|
||||
property int focusedWindowSize: 1
|
||||
property bool focusedWindowCompactMode: false
|
||||
property bool focusedWindowShowIcon: false
|
||||
property bool focusedWindowShowIcon: true
|
||||
property bool runningAppsCompactMode: true
|
||||
property int barMaxVisibleApps: 0
|
||||
property int barMaxVisibleRunningApps: 0
|
||||
@@ -1027,17 +1027,6 @@ Singleton {
|
||||
}
|
||||
]
|
||||
|
||||
// Standalone bar xray is unsafe when windows can render beneath its surface
|
||||
function _standaloneBarXrayAvailable(configs) {
|
||||
const list = configs || [];
|
||||
const activeBars = list.filter(c => c && c.enabled && (c.visible ?? true));
|
||||
const gapsOverride = (typeof CompositorService !== "undefined" && CompositorService.isHyprland) ? hyprlandLayoutGapsOverride : niriLayoutGapsOverride;
|
||||
const layoutGaps = gapsOverride >= 0 ? gapsOverride : Math.max(4, (list[0]?.spacing ?? 4));
|
||||
return activeBars.every(c => !c.autoHide && !(c.useOverlayLayer ?? false) && (c.spacing ?? 4) + (c.bottomGap ?? 0) + layoutGaps >= 0);
|
||||
}
|
||||
|
||||
readonly property bool standaloneBarXrayAvailable: _standaloneBarXrayAvailable(barConfigs)
|
||||
|
||||
property bool desktopClockEnabled: false
|
||||
property string desktopClockStyle: "analog"
|
||||
property real desktopClockTransparency: 0.8
|
||||
@@ -2479,17 +2468,13 @@ 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);
|
||||
const sanitizedConfigs = _sanitizeBarConfigsForConnectedFrame(configs).configs;
|
||||
barConfigs = sanitizedConfigs;
|
||||
barConfigs = _sanitizeBarConfigsForConnectedFrame(configs).configs;
|
||||
updateBarConfigs();
|
||||
|
||||
if (!frameEnabled && _standaloneBarXrayAvailable(sanitizedConfigs) !== barXrayTargetWasAvailable)
|
||||
updateCompositorLayout();
|
||||
if (positionChanged) {
|
||||
NotificationService.dismissAllPopups();
|
||||
}
|
||||
|
||||
@@ -192,7 +192,7 @@ var SPEC = {
|
||||
clockCompactMode: { def: false },
|
||||
focusedWindowCompactMode: { def: false },
|
||||
focusedWindowSize: { def: 1 },
|
||||
focusedWindowShowIcon: { def: false },
|
||||
focusedWindowShowIcon: { def: true },
|
||||
runningAppsCompactMode: { def: true },
|
||||
barMaxVisibleApps: { def: 0 },
|
||||
barMaxVisibleRunningApps: { def: 0 },
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import QtQuick
|
||||
import Quickshell.Hyprland
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
@@ -61,12 +60,9 @@ Item {
|
||||
}
|
||||
|
||||
// Hyprland OnDemand grab delivers keyboard focus to the modal content surface.
|
||||
HyprlandFocusGrab {
|
||||
DankFocusGrab {
|
||||
windows: (root.contentWindow ? [root.contentWindow] : []).concat(root.transientSurfaceTracker?.focusWindows ?? [])
|
||||
active: KeyboardFocus.wantsGrab(root.shouldHaveFocus, root.customKeyboardFocus)
|
||||
|
||||
property var restoreToplevel: null
|
||||
onActiveChanged: restoreToplevel = active ? KeyboardFocus.captureActiveToplevel() : KeyboardFocus.restoreToplevel(restoreToplevel)
|
||||
wanted: KeyboardFocus.wantsGrab(root.shouldHaveFocus, root.customKeyboardFocus)
|
||||
}
|
||||
readonly property var contentWindow: impl.item ? impl.item.contentWindow : null
|
||||
readonly property var effectiveScreen: impl.item ? impl.item.effectiveScreen : null
|
||||
|
||||
@@ -169,8 +169,6 @@ 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 "";
|
||||
@@ -192,13 +190,28 @@ 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 && (isImage || isVideo)) || (weMode && delegateRoot.fileIsDir))
|
||||
visible: gridPreviewImage.status === Image.Ready && ((!delegateRoot.fileIsDir && isVideo) || (weMode && delegateRoot.fileIsDir))
|
||||
maskThresholdMin: 0.5
|
||||
maskSpreadAtMin: 1
|
||||
}
|
||||
|
||||
@@ -168,13 +168,7 @@ StyledRect {
|
||||
Image {
|
||||
id: listPreviewImage
|
||||
anchors.fill: parent
|
||||
property string imagePath: {
|
||||
if (!listDelegateRoot.fileIsDir && isImage)
|
||||
return listDelegateRoot.filePath;
|
||||
if (_videoThumb)
|
||||
return _videoThumb;
|
||||
return "";
|
||||
}
|
||||
property string imagePath: _videoThumb
|
||||
source: imagePath ? "file://" + imagePath.split('/').map(s => encodeURIComponent(s)).join('/') : ""
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
sourceSize.width: 32
|
||||
@@ -183,12 +177,26 @@ 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 && (isImage || isVideo)
|
||||
visible: listPreviewImage.status === Image.Ready && !listDelegateRoot.fileIsDir && isVideo
|
||||
maskThresholdMin: 0.5
|
||||
maskSpreadAtMin: 1
|
||||
}
|
||||
|
||||
@@ -423,12 +423,14 @@ Column {
|
||||
}
|
||||
}
|
||||
|
||||
// targetValue-based direction: `active` can be stale when the behavior triggers
|
||||
Behavior on height {
|
||||
enabled: true
|
||||
id: detailHeightBehavior
|
||||
NumberAnimation {
|
||||
duration: Theme.variantDuration(Theme.popoutAnimationDuration, detailHost.active)
|
||||
readonly property bool growing: (detailHeightBehavior.targetValue ?? 0) >= detailHost.height
|
||||
duration: Theme.variantDuration(Theme.popoutAnimationDuration, growing)
|
||||
easing.type: Easing.BezierSpline
|
||||
easing.bezierCurve: detailHost.active ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve
|
||||
easing.bezierCurve: growing ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -941,12 +941,16 @@ Item {
|
||||
section: topBarContent.getWidgetSection(parent)
|
||||
parentScreen: barWindow.screen
|
||||
onClicked: {
|
||||
if (powerMenuModalLoader) {
|
||||
powerMenuModalLoader.active = true;
|
||||
if (powerMenuModalLoader.item) {
|
||||
powerMenuModalLoader.item.openCentered();
|
||||
}
|
||||
if (!powerMenuModalLoader)
|
||||
return;
|
||||
powerMenuModalLoader.active = true;
|
||||
if (!powerMenuModalLoader.item)
|
||||
return;
|
||||
if (powerMenuModalLoader.item.shouldBeVisible) {
|
||||
powerMenuModalLoader.item.close();
|
||||
return;
|
||||
}
|
||||
powerMenuModalLoader.item.openCentered();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1101,12 +1105,13 @@ Item {
|
||||
|
||||
onClockClicked: {
|
||||
const section = topBarContent.getWidgetSection(parent) || "center";
|
||||
const tabIndex = SettingsData.dashTabIndexForId("overview");
|
||||
topBarContent.openWidgetPopout({
|
||||
loader: dankDashPopoutLoader,
|
||||
widgetItem: clockWidget,
|
||||
section,
|
||||
tabIndex: 0,
|
||||
triggerSource: topBarContent._dashTriggerSource(section, 0),
|
||||
tabIndex,
|
||||
triggerSource: topBarContent._dashTriggerSource(section, tabIndex),
|
||||
mode: "click",
|
||||
useCenterSection: true,
|
||||
setTriggerScreen: true
|
||||
@@ -1129,12 +1134,13 @@ Item {
|
||||
parentScreen: barWindow.screen
|
||||
onClicked: {
|
||||
const section = topBarContent.getWidgetSection(parent) || "center";
|
||||
const tabIndex = SettingsData.dashTabIndexForId("media");
|
||||
topBarContent.openWidgetPopout({
|
||||
loader: dankDashPopoutLoader,
|
||||
widgetItem: mediaWidget,
|
||||
section,
|
||||
tabIndex: 1,
|
||||
triggerSource: topBarContent._dashTriggerSource(section, 1),
|
||||
tabIndex,
|
||||
triggerSource: topBarContent._dashTriggerSource(section, tabIndex),
|
||||
mode: "click",
|
||||
useCenterSection: true,
|
||||
setTriggerScreen: true
|
||||
@@ -1156,12 +1162,13 @@ Item {
|
||||
parentScreen: barWindow.screen
|
||||
onClicked: {
|
||||
const section = topBarContent.getWidgetSection(parent) || "center";
|
||||
const tabIndex = SettingsData.dashTabIndexForId("weather");
|
||||
topBarContent.openWidgetPopout({
|
||||
loader: dankDashPopoutLoader,
|
||||
widgetItem: weatherWidget,
|
||||
section,
|
||||
tabIndex: 3,
|
||||
triggerSource: topBarContent._dashTriggerSource(section, 3),
|
||||
tabIndex,
|
||||
triggerSource: topBarContent._dashTriggerSource(section, tabIndex),
|
||||
mode: "click",
|
||||
useCenterSection: true,
|
||||
setTriggerScreen: true
|
||||
|
||||
@@ -736,7 +736,7 @@ Item {
|
||||
case "music":
|
||||
case "weather":
|
||||
{
|
||||
const tabIndex = widgetId === "clock" ? 0 : (widgetId === "music" ? 1 : 3);
|
||||
const tabIndex = SettingsData.dashTabIndexForId(widgetId === "clock" ? "overview" : (widgetId === "music" ? "media" : "weather"));
|
||||
return barContent.openWidgetPopout(Object.assign({}, base, {
|
||||
loader,
|
||||
tabIndex,
|
||||
|
||||
@@ -240,7 +240,7 @@ PanelWindow {
|
||||
id: barBlur
|
||||
visible: false
|
||||
|
||||
readonly property bool barHasTransparency: barWindow._backgroundAlpha < 1
|
||||
readonly property bool barHasTransparency: barWindow._backgroundAlpha > 0 && barWindow._backgroundAlpha < 1
|
||||
|
||||
function rebuild() {
|
||||
teardown();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import QtQuick
|
||||
import QtQuick.Effects
|
||||
import Quickshell
|
||||
import Quickshell.Hyprland
|
||||
import Quickshell.Services.SystemTray
|
||||
import Quickshell.Wayland
|
||||
import Quickshell.Widgets
|
||||
@@ -1048,12 +1047,9 @@ BasePill {
|
||||
WlrLayershell.namespace: "dms:tray-overflow-menu"
|
||||
color: "transparent"
|
||||
|
||||
HyprlandFocusGrab {
|
||||
DankFocusGrab {
|
||||
windows: [overflowMenu].concat(KeyboardFocus.barWindows)
|
||||
active: root.useOverflowPopup && KeyboardFocus.wantsGrab(root.menuOpen, null)
|
||||
|
||||
property var restoreToplevel: null
|
||||
onActiveChanged: restoreToplevel = active ? KeyboardFocus.captureActiveToplevel() : KeyboardFocus.restoreToplevel(restoreToplevel)
|
||||
wanted: root.useOverflowPopup && KeyboardFocus.wantsGrab(root.menuOpen, null)
|
||||
}
|
||||
|
||||
Connections {
|
||||
@@ -1601,12 +1597,9 @@ BasePill {
|
||||
WlrLayershell.keyboardFocus: KeyboardFocus.keyboardFocus(menuRoot.showMenu, null)
|
||||
color: "transparent"
|
||||
|
||||
HyprlandFocusGrab {
|
||||
DankFocusGrab {
|
||||
windows: [menuWindow].concat(KeyboardFocus.barWindows)
|
||||
active: KeyboardFocus.wantsGrab(menuRoot.showMenu, null)
|
||||
|
||||
property var restoreToplevel: null
|
||||
onActiveChanged: restoreToplevel = active ? KeyboardFocus.captureActiveToplevel() : KeyboardFocus.restoreToplevel(restoreToplevel)
|
||||
wanted: KeyboardFocus.wantsGrab(menuRoot.showMenu, null)
|
||||
}
|
||||
|
||||
anchors {
|
||||
|
||||
@@ -107,6 +107,14 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: CompositorService
|
||||
function onCompositorChanged() {
|
||||
root._placeholderPool = [];
|
||||
root._hyprSlotPool = {};
|
||||
}
|
||||
}
|
||||
|
||||
property var currentWorkspace: {
|
||||
if (useExtWorkspace)
|
||||
return getExtWorkspaceActiveWorkspace();
|
||||
@@ -145,8 +153,7 @@ Item {
|
||||
baseList = getNiriWorkspaces();
|
||||
break;
|
||||
case "hyprland":
|
||||
baseList = getHyprlandWorkspaces();
|
||||
break;
|
||||
return hyprlandSlotList(getHyprlandWorkspaces());
|
||||
case "mango":
|
||||
if (root.mangoOverviewActive)
|
||||
return [];
|
||||
@@ -426,41 +433,84 @@ Item {
|
||||
return Object.values(byApp);
|
||||
}
|
||||
|
||||
function padWorkspaces(list) {
|
||||
const padded = list.slice();
|
||||
let placeholder;
|
||||
if (useExtWorkspace) {
|
||||
placeholder = {
|
||||
function _makePlaceholder() {
|
||||
if (useExtWorkspace)
|
||||
return {
|
||||
"id": "",
|
||||
"name": "",
|
||||
"active": false,
|
||||
"_placeholder": true
|
||||
};
|
||||
} else if (CompositorService.isNiri) {
|
||||
placeholder = {
|
||||
if (CompositorService.isNiri)
|
||||
return {
|
||||
"id": -1,
|
||||
"idx": -1,
|
||||
"name": ""
|
||||
};
|
||||
} else if (CompositorService.isHyprland) {
|
||||
placeholder = {
|
||||
if (CompositorService.isHyprland)
|
||||
return {
|
||||
"id": -1,
|
||||
"name": ""
|
||||
};
|
||||
} else if (root.isMango) {
|
||||
placeholder = {
|
||||
if (root.isMango)
|
||||
return {
|
||||
"tag": -1
|
||||
};
|
||||
} else if (CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle) {
|
||||
placeholder = {
|
||||
if (CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle)
|
||||
return {
|
||||
"num": -1,
|
||||
"_placeholder": true
|
||||
};
|
||||
} else {
|
||||
placeholder = -1;
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
padded.push(placeholder);
|
||||
if (root._placeholderPool.length <= slot)
|
||||
root._placeholderPool.push(root._makePlaceholder());
|
||||
padded.push(root._placeholderPool[slot]);
|
||||
slot++;
|
||||
}
|
||||
return padded;
|
||||
}
|
||||
@@ -638,7 +688,7 @@ Item {
|
||||
NiriService.switchToWorkspace(data.id);
|
||||
break;
|
||||
case "hyprland":
|
||||
if (data.id) {
|
||||
if (data.id && data.id !== -1) {
|
||||
HyprlandService.focusWorkspace(hyprlandWorkspaceSelector(data));
|
||||
}
|
||||
break;
|
||||
@@ -663,7 +713,7 @@ Item {
|
||||
|
||||
for (let i = 0; i < workspaceRepeater.count; i++) {
|
||||
const item = workspaceRepeater.itemAt(i);
|
||||
if (!item)
|
||||
if (!item || item.isPlaceholder)
|
||||
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,6 +447,7 @@ DankPopout {
|
||||
anchors.fill: parent
|
||||
active: root.currentTabId === "wallpaper"
|
||||
visible: active
|
||||
asynchronous: true
|
||||
sourceComponent: Component {
|
||||
WallpaperTab {
|
||||
active: true
|
||||
@@ -458,6 +459,12 @@ DankPopout {
|
||||
}
|
||||
}
|
||||
|
||||
DankSpinner {
|
||||
anchors.centerIn: parent
|
||||
size: 40
|
||||
visible: wallpaperLoader.active && wallpaperLoader.status === Loader.Loading
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: weatherLoader
|
||||
anchors.fill: parent
|
||||
|
||||
@@ -86,12 +86,10 @@ Rectangle {
|
||||
}
|
||||
|
||||
function updateSelectedDateEvents() {
|
||||
if (CalendarService && CalendarService.calendarAvailable) {
|
||||
const events = CalendarService.getEventsForDate(selectedDate);
|
||||
selectedDateEvents = events;
|
||||
} else {
|
||||
selectedDateEvents = [];
|
||||
}
|
||||
const events = (CalendarService && CalendarService.calendarAvailable) ? CalendarService.getEventsForDate(selectedDate) : [];
|
||||
if (JSON.stringify(events) === JSON.stringify(selectedDateEvents))
|
||||
return;
|
||||
selectedDateEvents = events;
|
||||
}
|
||||
|
||||
function loadEventsForMonth() {
|
||||
@@ -276,57 +274,29 @@ Rectangle {
|
||||
height: 40
|
||||
visible: showEventDetails
|
||||
|
||||
Rectangle {
|
||||
width: 32
|
||||
height: 32
|
||||
DankActionButton {
|
||||
buttonSize: 32
|
||||
iconSize: 14
|
||||
iconName: "arrow_back"
|
||||
iconColor: Theme.primary
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Theme.spacingS
|
||||
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
|
||||
}
|
||||
onClicked: root.showEventDetails = false
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: 32
|
||||
height: 32
|
||||
DankActionButton {
|
||||
buttonSize: 32
|
||||
iconSize: 16
|
||||
iconName: "event"
|
||||
iconColor: Theme.primary
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: Theme.spacingS
|
||||
radius: Theme.cornerRadius
|
||||
visible: CalendarService && CalendarService.canCreateEvents
|
||||
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;
|
||||
}
|
||||
onClicked: {
|
||||
root.editorEvent = null;
|
||||
root.showEditor = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -358,31 +328,12 @@ Rectangle {
|
||||
height: 28
|
||||
visible: !showEventDetails
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
DankActionButton {
|
||||
buttonSize: 28
|
||||
iconSize: 14
|
||||
iconName: "chevron_left"
|
||||
iconColor: Theme.primary
|
||||
onClicked: root.shiftMonth(-1)
|
||||
}
|
||||
|
||||
StyledText {
|
||||
@@ -396,53 +347,20 @@ Rectangle {
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
|
||||
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: "today"
|
||||
iconColor: Theme.primary
|
||||
onClicked: root.goToToday()
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
DankActionButton {
|
||||
buttonSize: 28
|
||||
iconSize: 14
|
||||
iconName: "chevron_right"
|
||||
iconColor: Theme.primary
|
||||
onClicked: root.shiftMonth(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -814,12 +732,16 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
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 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 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
|
||||
@@ -888,13 +810,13 @@ Rectangle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
radius: Theme.cornerRadius
|
||||
color: "transparent"
|
||||
visible: modelData && modelData.id && modelData.id.startsWith("task_") && !taskItem.isEditing
|
||||
visible: taskItem.isLocalTask && !taskItem.isEditing
|
||||
|
||||
DankIcon {
|
||||
anchors.centerIn: parent
|
||||
name: "drag_indicator"
|
||||
size: 14
|
||||
color: dragMouseArea.containsMouse ? Theme.primary : Theme.surfaceTextAlpha
|
||||
color: dragMouseArea.containsMouse ? Theme.primary : Theme.surfaceTextMedium
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
@@ -937,34 +859,14 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// Checkbox status icon
|
||||
Rectangle {
|
||||
id: checkboxContainer
|
||||
width: 24
|
||||
height: 24
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: (modelData && modelData.id && modelData.id.startsWith("task_")) ? (taskItem.isEditing ? 8 : 32) : 8
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
radius: Theme.cornerRadius
|
||||
color: "transparent"
|
||||
visible: modelData && modelData.id && modelData.id.startsWith("task_")
|
||||
|
||||
DankIcon {
|
||||
anchors.centerIn: parent
|
||||
name: (modelData && modelData.completed) ? "check_box" : "check_box_outline_blank"
|
||||
size: 16
|
||||
color: (modelData && modelData.completed) ? Theme.primary : Theme.onSurface_38
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
id: eventContent
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
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
|
||||
anchors.leftMargin: taskItem.isLocalTask ? 60 : taskItem.isDankTask ? 36 : (Theme.spacingS + 6)
|
||||
anchors.rightMargin: taskItem.canModify ? 64 : Theme.spacingXS
|
||||
spacing: Theme.spacingXXS
|
||||
visible: !taskItem.isEditing
|
||||
|
||||
@@ -972,7 +874,7 @@ Rectangle {
|
||||
width: parent.width
|
||||
text: modelData ? modelData.title : ""
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: (modelData && modelData.id && modelData.id.startsWith("task_") && modelData.completed) ? Theme.surfaceTextSecondary : Theme.surfaceText
|
||||
color: (taskItem.isTask && modelData && modelData.completed) ? Theme.surfaceTextSecondary : Theme.surfaceText
|
||||
font.weight: Font.Medium
|
||||
horizontalAlignment: Text.AlignLeft
|
||||
elide: Text.ElideRight
|
||||
@@ -1000,7 +902,7 @@ Rectangle {
|
||||
color: Theme.surfaceTextMedium
|
||||
font.weight: Font.Normal
|
||||
horizontalAlignment: Text.AlignLeft
|
||||
visible: text !== "" && modelData && modelData.id && !modelData.id.startsWith("task_")
|
||||
visible: text !== "" && !taskItem.isLocalTask
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1047,90 +949,73 @@ Rectangle {
|
||||
id: eventMouseArea
|
||||
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: (modelData && modelData.id && modelData.id.startsWith("task_")) ? 32 : 6
|
||||
anchors.rightMargin: (modelData && modelData.id && modelData.id.startsWith("task_")) ? 64 : 0
|
||||
anchors.leftMargin: taskItem.isLocalTask ? 32 : 6
|
||||
anchors.rightMargin: taskItem.canModify ? 64 : 0
|
||||
hoverEnabled: true
|
||||
cursorShape: modelData ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
enabled: modelData && !taskItem.isEditing
|
||||
onClicked: {
|
||||
if (modelData && modelData.id && modelData.id.startsWith("task_")) {
|
||||
if (!modelData)
|
||||
return;
|
||||
if (taskItem.isTask && taskItem.canModify) {
|
||||
CalendarService.toggleTask(modelData.id);
|
||||
return;
|
||||
}
|
||||
if (modelData)
|
||||
root.detailEvent = modelData;
|
||||
root.detailEvent = modelData;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete / Cancel Button
|
||||
Rectangle {
|
||||
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 {
|
||||
id: deleteButton
|
||||
width: 24
|
||||
height: 24
|
||||
buttonSize: 24
|
||||
iconSize: 14
|
||||
iconName: taskItem.isEditing ? "close" : "delete"
|
||||
iconColor: taskItem.isEditing ? Theme.surfaceText : Theme.error
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: Theme.spacingS
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
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);
|
||||
}
|
||||
visible: taskItem.canModify
|
||||
onClicked: {
|
||||
if (taskItem.isEditing) {
|
||||
taskItem.isEditing = false;
|
||||
return;
|
||||
}
|
||||
if (modelData && modelData.id)
|
||||
CalendarService.removeTask(modelData.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Edit / Save Button
|
||||
Rectangle {
|
||||
id: editButton
|
||||
width: 24
|
||||
height: 24
|
||||
DankActionButton {
|
||||
buttonSize: 24
|
||||
iconSize: 14
|
||||
iconName: taskItem.isEditing ? "check" : "edit"
|
||||
iconColor: taskItem.isEditing ? Theme.primary : Theme.surfaceText
|
||||
anchors.right: deleteButton.left
|
||||
anchors.rightMargin: Theme.spacingXS
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
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;
|
||||
}
|
||||
visible: taskItem.canModify
|
||||
onClicked: {
|
||||
if (!taskItem.isEditing) {
|
||||
taskItem.isEditing = true;
|
||||
return;
|
||||
}
|
||||
let txt = editInput.text.trim();
|
||||
if (txt !== "" && modelData && modelData.id)
|
||||
CalendarService.editTask(modelData.id, txt);
|
||||
taskItem.isEditing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import Qt.labs.folderlistmodel
|
||||
import QtCore
|
||||
import QtQuick
|
||||
import QtQuick.Effects
|
||||
import Quickshell
|
||||
import Quickshell.Widgets
|
||||
import qs.Common
|
||||
import qs.Modals.FileBrowser
|
||||
import qs.Widgets
|
||||
@@ -20,12 +21,11 @@ Item {
|
||||
property int itemsPerPage: 16
|
||||
property int totalPages: Math.max(1, Math.ceil(wallpaperFolderModel.count / itemsPerPage))
|
||||
property bool active: false
|
||||
property Item focusTarget: wallpaperGrid
|
||||
property Item focusTarget: pager
|
||||
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: {
|
||||
if (currentPage !== lastPage) {
|
||||
enableAnimation = false;
|
||||
lastPage = currentPage;
|
||||
onCurrentPageChanged: updateSelectedFileName()
|
||||
|
||||
onTotalPagesChanged: {
|
||||
if (currentPage >= totalPages) {
|
||||
currentPage = Math.max(0, totalPages - 1);
|
||||
}
|
||||
updateSelectedFileName();
|
||||
}
|
||||
|
||||
onGridIndexChanged: {
|
||||
@@ -257,6 +257,7 @@ Item {
|
||||
}
|
||||
|
||||
function setInitialSelection() {
|
||||
enableAnimation = false;
|
||||
const currentWallpaper = getCurrentWallpaper();
|
||||
if (!currentWallpaper || wallpaperFolderModel.count === 0) {
|
||||
gridIndex = 0;
|
||||
@@ -350,17 +351,6 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
function collectWallpaperPaths() {
|
||||
const paths = [];
|
||||
for (var i = 0; i < wallpaperFolderModel.count; i++) {
|
||||
const filePath = wallpaperFolderModel.get(i, "filePath");
|
||||
if (filePath) {
|
||||
paths.push(filePath.toString().replace(/^file:\/\//, ''));
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: wallpaperFolderModel
|
||||
function onCountChanged() {
|
||||
@@ -369,7 +359,6 @@ Item {
|
||||
setInitialSelection();
|
||||
}
|
||||
updateSelectedFileName();
|
||||
thumbnailPreloader.paths = collectWallpaperPaths();
|
||||
}
|
||||
}
|
||||
function onStatusChanged() {
|
||||
@@ -378,16 +367,10 @@ Item {
|
||||
setInitialSelection();
|
||||
}
|
||||
updateSelectedFileName();
|
||||
thumbnailPreloader.paths = collectWallpaperPaths();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
WallpaperThumbnailPreloader {
|
||||
id: thumbnailPreloader
|
||||
cacheSize: 256
|
||||
}
|
||||
|
||||
FolderListModel {
|
||||
id: wallpaperFolderModel
|
||||
|
||||
@@ -447,147 +430,169 @@ Item {
|
||||
width: parent.width
|
||||
height: parent.height - 50
|
||||
|
||||
GridView {
|
||||
id: wallpaperGrid
|
||||
ListView {
|
||||
id: pager
|
||||
anchors.centerIn: parent
|
||||
width: parent.width - Theme.spacingS
|
||||
height: parent.height - Theme.spacingS
|
||||
cellWidth: width / 4
|
||||
cellHeight: height / 4
|
||||
orientation: ListView.Vertical
|
||||
snapMode: ListView.SnapOneItem
|
||||
highlightRangeMode: ListView.StrictlyEnforceRange
|
||||
preferredHighlightBegin: 0
|
||||
preferredHighlightEnd: height
|
||||
highlightMoveDuration: root.enableAnimation ? Theme.mediumDuration : 0
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
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
|
||||
|
||||
highlight: Item {
|
||||
z: 1000
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.spacingXS
|
||||
color: "transparent"
|
||||
border.width: 3
|
||||
border.color: Theme.primary
|
||||
radius: Theme.cornerRadius
|
||||
onCurrentIndexChanged: {
|
||||
if (!moving) {
|
||||
return;
|
||||
}
|
||||
if (currentIndex >= 0 && currentIndex !== root.currentPage) {
|
||||
root.currentPage = currentIndex;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
Component.onCompleted: currentIndex = root.currentPage
|
||||
|
||||
Connections {
|
||||
target: root
|
||||
function onGridIndexChanged() {
|
||||
if (wallpaperGrid.count > 0) {
|
||||
wallpaperGrid.currentIndex = gridIndex;
|
||||
if (!enableAnimation) {
|
||||
wallpaperGrid.positionViewAtIndex(gridIndex, GridView.Contain);
|
||||
}
|
||||
function onCurrentPageChanged() {
|
||||
if (pager.currentIndex !== root.currentPage) {
|
||||
pager.currentIndex = root.currentPage;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
delegate: Item {
|
||||
width: wallpaperGrid.cellWidth
|
||||
height: wallpaperGrid.cellHeight
|
||||
delegate: GridView {
|
||||
id: pageGrid
|
||||
|
||||
property string wallpaperPath: modelData || ""
|
||||
property bool isSelected: getCurrentWallpaper() === modelData
|
||||
property int pageIndex: index
|
||||
|
||||
Rectangle {
|
||||
id: wallpaperCard
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.spacingXS
|
||||
color: Theme.withAlpha(Theme.surfaceContainerHighest, Theme.popupTransparency)
|
||||
radius: Theme.cornerRadius
|
||||
clip: true
|
||||
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
|
||||
color: isSelected ? Theme.primaryPressed : Theme.withAlpha(Theme.primaryPressed, 0)
|
||||
radius: parent.radius
|
||||
anchors.margins: Theme.spacingXS
|
||||
color: "transparent"
|
||||
border.width: 3
|
||||
border.color: Theme.primary
|
||||
radius: Theme.cornerRadius
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Theme.shortDuration
|
||||
easing.type: Theme.standardEasing
|
||||
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: maskRect
|
||||
width: thumbnailImage.width
|
||||
height: thumbnailImage.height
|
||||
id: wallpaperCard
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.spacingXS
|
||||
color: Theme.withAlpha(Theme.surfaceContainerHighest, Theme.popupTransparency)
|
||||
radius: Theme.cornerRadius
|
||||
visible: false
|
||||
layer.enabled: true
|
||||
}
|
||||
|
||||
CachingImage {
|
||||
id: thumbnailImage
|
||||
anchors.fill: parent
|
||||
imagePath: modelData || ""
|
||||
maxCacheSize: 256
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: isSelected ? Theme.primaryPressed : Theme.withAlpha(Theme.primaryPressed, 0)
|
||||
radius: parent.radius
|
||||
|
||||
layer.enabled: true
|
||||
layer.effect: MultiEffect {
|
||||
maskEnabled: true
|
||||
maskThresholdMin: 0.5
|
||||
maskSpreadAtMin: 1.0
|
||||
maskSource: maskRect
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Theme.shortDuration
|
||||
easing.type: Theme.standardEasing
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StateLayer {
|
||||
anchors.fill: parent
|
||||
cornerRadius: parent.radius
|
||||
stateColor: Theme.primary
|
||||
}
|
||||
ClippingRectangle {
|
||||
anchors.fill: parent
|
||||
radius: wallpaperCard.radius
|
||||
color: "transparent"
|
||||
|
||||
MouseArea {
|
||||
id: wallpaperMouseArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
CachingImage {
|
||||
id: thumbnailImage
|
||||
anchors.fill: parent
|
||||
imagePath: modelData || ""
|
||||
maxCacheSize: 256
|
||||
animate: false
|
||||
opacity: status === Image.Ready ? 1 : 0
|
||||
|
||||
onClicked: {
|
||||
gridIndex = index;
|
||||
if (modelData) {
|
||||
setCurrentWallpaper(modelData);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -595,9 +600,15 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
DankSpinner {
|
||||
anchors.centerIn: parent
|
||||
size: 40
|
||||
visible: wallpaperFolderModel.status === FolderListModel.Loading && wallpaperFolderModel.count === 0
|
||||
}
|
||||
|
||||
StyledText {
|
||||
anchors.centerIn: parent
|
||||
visible: wallpaperFolderModel.count === 0
|
||||
visible: wallpaperFolderModel.status === FolderListModel.Ready && wallpaperFolderModel.count === 0
|
||||
text: I18n.tr("No wallpapers found\n\nClick the folder icon below to browse")
|
||||
font.pixelSize: 14
|
||||
color: Theme.outline
|
||||
|
||||
@@ -168,6 +168,19 @@ 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);
|
||||
@@ -1007,8 +1020,7 @@ PanelWindow {
|
||||
z: 15
|
||||
|
||||
onClicked: {
|
||||
if (notificationData && !win.exiting)
|
||||
notificationData.popup = false;
|
||||
dismissPopupReliably();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1080,8 +1092,7 @@ PanelWindow {
|
||||
onClicked: {
|
||||
if (modelData && modelData.invoke)
|
||||
modelData.invoke();
|
||||
if (notificationData && !win.exiting)
|
||||
notificationData.popup = false;
|
||||
dismissPopupReliably();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1163,7 +1174,7 @@ PanelWindow {
|
||||
notificationData.actions[0].invoke();
|
||||
NotificationService.dismissNotification(notificationData);
|
||||
} else {
|
||||
notificationData.popup = false;
|
||||
dismissPopupReliably();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,7 +368,7 @@ Item {
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
deleteVpnConfirm.showWithOptions({
|
||||
root.deleteVpnConfirm.showWithOptions({
|
||||
title: I18n.tr("Delete VPN"),
|
||||
message: I18n.tr("Delete \"%1\"?").arg(modelData.name),
|
||||
confirmText: I18n.tr("Delete"),
|
||||
|
||||
@@ -21,6 +21,9 @@ 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
|
||||
@@ -130,6 +133,7 @@ Item {
|
||||
root.log.info("connected to dankcal:", root.socketPath);
|
||||
root.refreshCalendars();
|
||||
root.reloadEvents();
|
||||
root.reloadTasks();
|
||||
return;
|
||||
}
|
||||
if (!root.connected && !root.socketFound)
|
||||
@@ -193,12 +197,19 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: tasksDebounce
|
||||
interval: 400
|
||||
repeat: false
|
||||
onTriggered: root.reloadTasks()
|
||||
}
|
||||
|
||||
function _sendSubscribe() {
|
||||
subscribeSocket.send({
|
||||
"id": _nextId(),
|
||||
"method": "subscribe",
|
||||
"params": {
|
||||
"topics": ["accounts", "calendars", "events", "sync"]
|
||||
"topics": ["accounts", "calendars", "events", "tasks", "sync"]
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -243,8 +254,14 @@ Item {
|
||||
refreshDebounce.restart();
|
||||
break;
|
||||
case "events":
|
||||
refreshDebounce.restart();
|
||||
break;
|
||||
case "tasks":
|
||||
tasksDebounce.restart();
|
||||
break;
|
||||
case "sync":
|
||||
refreshDebounce.restart();
|
||||
tasksDebounce.restart();
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -282,6 +299,7 @@ Item {
|
||||
}
|
||||
calendars = list;
|
||||
_rebuildEventsByDate();
|
||||
_rebuildTasksByDate();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -353,7 +371,7 @@ Item {
|
||||
function _normalizeEvent(e) {
|
||||
const allDay = !!e.allDay;
|
||||
const id = e.id || "";
|
||||
if (id.startsWith("task_"))
|
||||
if (id.startsWith("task_") || id.startsWith("vtodo_"))
|
||||
log.warn("daemon event id collides with task prefix:", id);
|
||||
return {
|
||||
"id": id,
|
||||
@@ -478,4 +496,175 @@ 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ 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);
|
||||
@@ -198,6 +199,17 @@ 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])
|
||||
@@ -214,6 +226,13 @@ 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;
|
||||
@@ -237,6 +256,10 @@ 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;
|
||||
@@ -283,6 +306,12 @@ 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;
|
||||
@@ -305,6 +334,17 @@ Singleton {
|
||||
}
|
||||
}
|
||||
|
||||
function _mergeInto(merged, byDate) {
|
||||
for (let dateKey in byDate) {
|
||||
if (!merged[dateKey])
|
||||
merged[dateKey] = [];
|
||||
for (let event of byDate[dateKey]) {
|
||||
if (!merged[dateKey].some(e => e.id === event.id))
|
||||
merged[dateKey].push(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mergeEvents() {
|
||||
let merged = {};
|
||||
let backendEvents = _activeBackendEventsByDate();
|
||||
@@ -312,14 +352,9 @@ Singleton {
|
||||
for (let dateKey in backendEvents)
|
||||
merged[dateKey] = [].concat(backendEvents[dateKey]);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
_mergeInto(merged, root.taskEventsByDate);
|
||||
if (isDankActive)
|
||||
_mergeInto(merged, dankBackend.tasksByDate);
|
||||
|
||||
for (let dateKey in merged) {
|
||||
let list = merged[dateKey];
|
||||
|
||||
@@ -341,10 +341,11 @@ 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
|
||||
// Connected frame mode has no separate bar/frame surface to target
|
||||
const barFrameTargetNamespace = !frameEnabled ? (SettingsData.standaloneBarXrayAvailable ? "dms:bar" : null) : (frameConnectedMode ? null : "dms:frame");
|
||||
// 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");
|
||||
|
||||
const generalLines = [];
|
||||
if (manageGaps)
|
||||
@@ -371,13 +372,15 @@ hl.layer_rule({
|
||||
})
|
||||
`;
|
||||
}
|
||||
if (layoutBarXrayEnabled && barFrameTargetNamespace) {
|
||||
content += `
|
||||
if (layoutBarXrayEnabled) {
|
||||
for (const ns of xrayNamespaces) {
|
||||
content += `
|
||||
hl.layer_rule({
|
||||
match = { namespace = "^${barFrameTargetNamespace}$" },
|
||||
match = { namespace = "^${ns}$" },
|
||||
xray = true,
|
||||
})
|
||||
`;
|
||||
}
|
||||
}
|
||||
// Marker persists the preference even while the rule has no target
|
||||
if (!layoutBarXrayEnabled) {
|
||||
|
||||
@@ -1189,19 +1189,18 @@ 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;
|
||||
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");
|
||||
// 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");
|
||||
|
||||
// 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 excludeLine = (layoutBarXrayEnabled && barFrameTargetNamespace) ? `\n exclude namespace="^${barFrameTargetNamespace}$"` : "";
|
||||
const excludeLines = layoutBarXrayEnabled ? excludeNamespaces.map(ns => `\n exclude namespace="^${ns}$"`).join("") : "";
|
||||
xrayRules += `
|
||||
|
||||
layer-rule {${excludeLine}
|
||||
layer-rule {${excludeLines}
|
||||
background-effect {
|
||||
xray false
|
||||
}
|
||||
|
||||
@@ -192,19 +192,6 @@ 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;
|
||||
@@ -229,7 +216,7 @@ Singleton {
|
||||
const cursorEnv = typeof SettingsData.getCursorEnvironment === "function" ? SettingsData.getCursorEnvironment() : {};
|
||||
|
||||
const overrideEnv = override?.envVars ? parseEnvVars(override.envVars) : {};
|
||||
const finalEnv = restoreWrapperEnv(Object.assign({}, cursorEnv, overrideEnv));
|
||||
const finalEnv = Object.assign({}, cursorEnv, overrideEnv);
|
||||
|
||||
if (desktopEntry.runInTerminal) {
|
||||
const terminal = SessionData.resolveTerminal() || "xterm";
|
||||
@@ -279,14 +266,13 @@ 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: finalEnv
|
||||
environment: cursorEnv
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -297,7 +283,7 @@ Singleton {
|
||||
Quickshell.execDetached({
|
||||
command: cmd,
|
||||
workingDirectory: workDir,
|
||||
environment: finalEnv
|
||||
environment: cursorEnv
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import QtQuick
|
||||
import Quickshell.Io
|
||||
import qs.Common
|
||||
|
||||
Item {
|
||||
@@ -9,10 +8,14 @@ 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 (!imagePath)
|
||||
if (!animate || !imagePath)
|
||||
return false;
|
||||
const lower = imagePath.toLowerCase();
|
||||
return lower.endsWith(".gif") || lower.endsWith(".webp");
|
||||
@@ -39,7 +42,8 @@ Item {
|
||||
}
|
||||
|
||||
readonly property string imageHash: normalizedPath ? djb2Hash(normalizedPath) : ""
|
||||
readonly property string cachePath: imageHash && !isRemoteUrl && !isAnimated ? `${Paths.stringify(Paths.imagecache)}/${imageHash}@${maxCacheSize}x${maxCacheSize}.png` : ""
|
||||
readonly property string cacheFileName: imageHash && !isRemoteUrl && !isAnimated ? `${imageHash}@${maxCacheSize}x${maxCacheSize}.png` : ""
|
||||
readonly property string cachePath: cacheFileName ? `${Paths.stringify(Paths.imagecache)}/${cacheFileName}` : ""
|
||||
readonly property string encodedImagePath: {
|
||||
if (!normalizedPath)
|
||||
return "";
|
||||
@@ -69,58 +73,51 @@ Item {
|
||||
smooth: true
|
||||
|
||||
onStatusChanged: {
|
||||
if (source === root.cachePath && status === Image.Error) {
|
||||
switch (status) {
|
||||
case Image.Error:
|
||||
if (!root._fromCache)
|
||||
return;
|
||||
root._fromCache = false;
|
||||
source = root.encodedImagePath;
|
||||
return;
|
||||
}
|
||||
if (root.isRemoteUrl || source !== root.encodedImagePath || status !== Image.Ready || !root.cachePath)
|
||||
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);
|
||||
});
|
||||
return;
|
||||
Paths.mkdir(Paths.imagecache);
|
||||
const grabPath = root.cachePath;
|
||||
if (visible && width > 0 && height > 0 && Window.window?.visible) {
|
||||
grabToImage(res => res.saveToFile(grabPath));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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: {
|
||||
function resolveSource() {
|
||||
if (!imagePath) {
|
||||
_fromCache = false;
|
||||
staticImg.source = "";
|
||||
return;
|
||||
}
|
||||
if (isAnimated)
|
||||
return;
|
||||
if (isRemoteUrl) {
|
||||
_fromCache = false;
|
||||
staticImg.source = imagePath;
|
||||
return;
|
||||
}
|
||||
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;
|
||||
if (!cachePath) {
|
||||
_fromCache = false;
|
||||
staticImg.source = encodedImagePath;
|
||||
return;
|
||||
}
|
||||
cacheProbe.running = false;
|
||||
cacheProbe.cachePath = cPath;
|
||||
cacheProbe.fallbackSource = encoded;
|
||||
cacheProbe.running = true;
|
||||
// Cache-first; a miss errors and falls back to encodedImagePath
|
||||
_fromCache = true;
|
||||
staticImg.source = cachePath;
|
||||
}
|
||||
|
||||
onImagePathChanged: resolveSource()
|
||||
// During creation onImagePathChanged fires before sibling properties (maxCacheSize) initialize
|
||||
onCachePathChanged: resolveSource()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import QtQuick
|
||||
import Quickshell.Hyprland
|
||||
import qs.Common
|
||||
|
||||
// Delays grab release so keyboardFocus=None commits before the grab dies,
|
||||
// keeping Hyprland from handing focus back to the closing surface (#2577)
|
||||
HyprlandFocusGrab {
|
||||
id: root
|
||||
|
||||
property bool wanted: false
|
||||
property bool _held: false
|
||||
property bool _compositorCleared: false
|
||||
property var _restoreToplevel: null
|
||||
|
||||
property Timer _releaseTimer: Timer {
|
||||
interval: 50
|
||||
onTriggered: {
|
||||
root._held = false;
|
||||
root.active = false;
|
||||
root._restoreToplevel = root._compositorCleared ? null : KeyboardFocus.restoreToplevel(root._restoreToplevel);
|
||||
}
|
||||
}
|
||||
|
||||
onWantedChanged: _sync()
|
||||
Component.onCompleted: _sync()
|
||||
|
||||
function _sync() {
|
||||
if (!wanted) {
|
||||
if (_held)
|
||||
_releaseTimer.restart();
|
||||
return;
|
||||
}
|
||||
_releaseTimer.stop();
|
||||
_held = true;
|
||||
_compositorCleared = false;
|
||||
_restoreToplevel = KeyboardFocus.captureActiveToplevel();
|
||||
active = true;
|
||||
}
|
||||
|
||||
onCleared: _compositorCleared = true
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import QtQuick
|
||||
import Quickshell.Hyprland
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
|
||||
@@ -62,7 +61,7 @@ Item {
|
||||
}
|
||||
|
||||
// Hyprland OnDemand grab: whitelist popout surfaces and bars so dismiss clicks still land.
|
||||
HyprlandFocusGrab {
|
||||
DankFocusGrab {
|
||||
windows: {
|
||||
const list = [];
|
||||
if (root.contentWindow)
|
||||
@@ -72,10 +71,7 @@ Item {
|
||||
const transientWindows = root.transientSurfaceTracker?.focusWindows ?? [];
|
||||
return list.concat(transientWindows).concat(KeyboardFocus.barWindows);
|
||||
}
|
||||
active: KeyboardFocus.wantsGrab(root.shouldBeVisible, root.customKeyboardFocus)
|
||||
|
||||
property var restoreToplevel: null
|
||||
onActiveChanged: restoreToplevel = active ? KeyboardFocus.captureActiveToplevel() : KeyboardFocus.restoreToplevel(restoreToplevel)
|
||||
wanted: KeyboardFocus.wantsGrab(root.shouldBeVisible, root.customKeyboardFocus)
|
||||
}
|
||||
|
||||
Loader {
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
|
||||
// Preload the CachingImage disk cache for a folder of wallpapers via ffmpegthumbnailer
|
||||
// so the switcher grid renders instantly. No-op (graceful fallback) if the tool is absent.
|
||||
Item {
|
||||
id: root
|
||||
|
||||
visible: false
|
||||
|
||||
property var paths: []
|
||||
property int cacheSize: 256
|
||||
property bool autoStart: true
|
||||
property int maxConcurrent: 3
|
||||
|
||||
property int _active: 0
|
||||
property var _queue: []
|
||||
property int _toolState: -1 // -1 unknown, 0 unavailable, 1 available
|
||||
|
||||
onPathsChanged: if (autoStart)
|
||||
preload()
|
||||
|
||||
// Must match djb2Hash + cachePath in Widgets/CachingImage.qml.
|
||||
function _hash(str) {
|
||||
if (!str)
|
||||
return "";
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = ((hash << 5) + hash) + str.charCodeAt(i);
|
||||
hash = hash & 0x7FFFFFFF;
|
||||
}
|
||||
return hash.toString(16).padStart(8, '0');
|
||||
}
|
||||
|
||||
function _cachePathFor(path) {
|
||||
const hash = _hash(path);
|
||||
if (!hash)
|
||||
return "";
|
||||
return `${Paths.stringify(Paths.imagecache)}/${hash}@${cacheSize}x${cacheSize}.png`;
|
||||
}
|
||||
|
||||
function _isAnimated(path) {
|
||||
const lower = path.toLowerCase();
|
||||
return lower.endsWith(".gif") || lower.endsWith(".webp");
|
||||
}
|
||||
|
||||
function preload() {
|
||||
if (!paths || paths.length === 0 || _toolState === 0)
|
||||
return;
|
||||
if (_toolState === -1) {
|
||||
Proc.runCommand("wallpaperThumbToolCheck", ["sh", "-c", "command -v ffmpegthumbnailer"], function (out, code) {
|
||||
root._toolState = code === 0 ? 1 : 0;
|
||||
if (root._toolState === 1)
|
||||
root._start();
|
||||
});
|
||||
return;
|
||||
}
|
||||
_start();
|
||||
}
|
||||
|
||||
function _start() {
|
||||
Paths.mkdir(Paths.imagecache);
|
||||
const q = [];
|
||||
for (let i = 0; i < paths.length; i++) {
|
||||
const p = paths[i];
|
||||
if (!p || p.startsWith("#") || _isAnimated(p))
|
||||
continue;
|
||||
q.push(p);
|
||||
}
|
||||
_queue = q;
|
||||
for (let i = 0; i < maxConcurrent; i++)
|
||||
_pump();
|
||||
}
|
||||
|
||||
function _pump() {
|
||||
if (_queue.length === 0)
|
||||
return;
|
||||
const path = _queue.shift();
|
||||
const cachePath = _cachePathFor(path);
|
||||
if (!cachePath) {
|
||||
_pump();
|
||||
return;
|
||||
}
|
||||
_active++;
|
||||
// One process per file: skip if already cached, otherwise generate.
|
||||
const script = "test -f \"$1\" || ffmpegthumbnailer -i \"$2\" -o \"$1\" -s " + cacheSize;
|
||||
Proc.runCommand(null, ["sh", "-c", script, "thumb", cachePath, path], function (out, code) {
|
||||
root._active--;
|
||||
root._pump();
|
||||
}, 0, 20000);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user