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