mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-05-04 19:42:08 -04:00
Compare commits
41 Commits
dc5636bed5
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e60caf8028 | ||
|
|
1e67927f8a | ||
|
|
e6e343dacb | ||
|
|
f87ad3d2ca | ||
|
|
a6ba4b1c79 | ||
|
|
7cf718cd50 | ||
|
|
d223a74740 | ||
|
|
408beb202c | ||
|
|
cfe6e6867e | ||
|
|
7c991bc4e3 | ||
|
|
50f0cbb122 | ||
|
|
7ee0879103 | ||
|
|
56ef186ce8 | ||
|
|
5b507136e3 | ||
|
|
19c561da14 | ||
|
|
cc47703d48 | ||
|
|
31e60a3df5 | ||
|
|
082de6f1f0 | ||
|
|
fd24b4a36d | ||
|
|
dd668469d7 | ||
|
|
434490e100 | ||
|
|
d2f6cb3ae4 | ||
|
|
c1cbd0994f | ||
|
|
c81645bacb | ||
|
|
cdc4ca7e1f | ||
|
|
7d92842ff2 | ||
|
|
d8bf3bdfe8 | ||
|
|
23ed795e85 | ||
|
|
2877c63c97 | ||
|
|
86096db26b | ||
|
|
f76724f7cd | ||
|
|
3b96c6ab22 | ||
|
|
1467f5dba9 | ||
|
|
baaa30c94e | ||
|
|
24a3cd5a3d | ||
|
|
65151dbfd7 | ||
|
|
7bd9574868 | ||
|
|
a4cfdf4a59 | ||
|
|
fd651dc943 | ||
|
|
919b09fc96 | ||
|
|
aeb3fdd637 |
@@ -20,3 +20,11 @@ repos:
|
||||
language: system
|
||||
files: ^core/.*\.(go|mod|sum)$
|
||||
pass_filenames: false
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: no-console-in-qml
|
||||
name: no console.* in QML (use Log service)
|
||||
entry: bash -c 'if grep -nE "console\.(log|error|info|warn|debug)" "$@"; then echo "Use the Log service (log.info/warn/error/debug/fatal) instead of console.*" >&2; exit 1; fi' --
|
||||
language: system
|
||||
files: ^quickshell/.*\.qml$
|
||||
exclude: ^quickshell/(Services/Log\.qml$|dms-plugins/|PLUGINS/)
|
||||
|
||||
@@ -1,26 +1,13 @@
|
||||
repos:
|
||||
- repo: local
|
||||
- repo: https://github.com/golangci/golangci-lint
|
||||
rev: v2.10.1
|
||||
hooks:
|
||||
- id: golangci-lint-fmt
|
||||
name: golangci-lint-fmt
|
||||
entry: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.3 fmt
|
||||
language: system
|
||||
require_serial: true
|
||||
types: [go]
|
||||
pass_filenames: false
|
||||
- id: golangci-lint-full
|
||||
name: golangci-lint-full
|
||||
entry: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.3 run --fix
|
||||
language: system
|
||||
require_serial: true
|
||||
types: [go]
|
||||
pass_filenames: false
|
||||
- id: golangci-lint-config-verify
|
||||
name: golangci-lint-config-verify
|
||||
entry: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.3 config verify
|
||||
language: system
|
||||
files: \.golangci\.(?:yml|yaml|toml|json)
|
||||
pass_filenames: false
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: go-test
|
||||
name: go test
|
||||
entry: go test ./...
|
||||
|
||||
@@ -26,6 +26,17 @@ var runCmd = &cobra.Command{
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
daemon, _ := cmd.Flags().GetBool("daemon")
|
||||
session, _ := cmd.Flags().GetBool("session")
|
||||
if v, _ := cmd.Flags().GetString("log-level"); v != "" {
|
||||
if err := os.Setenv("DMS_LOG_LEVEL", v); err != nil {
|
||||
log.Fatalf("Failed to set DMS_LOG_LEVEL: %v", err)
|
||||
}
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("log-file"); v != "" {
|
||||
if err := os.Setenv("DMS_LOG_FILE", v); err != nil {
|
||||
log.Fatalf("Failed to set DMS_LOG_FILE: %v", err)
|
||||
}
|
||||
}
|
||||
log.ApplyEnvOverrides()
|
||||
if daemon {
|
||||
runShellDaemon(session)
|
||||
} else {
|
||||
@@ -527,5 +538,6 @@ func getCommonCommands() []*cobra.Command {
|
||||
randrCmd,
|
||||
blurCmd,
|
||||
trashCmd,
|
||||
systemCmd,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -267,6 +269,8 @@ func runSetupDmsConfig(name string) error {
|
||||
func runSetup() error {
|
||||
fmt.Println("=== DMS Configuration Setup ===")
|
||||
|
||||
ensureInputGroup()
|
||||
|
||||
wm, wmSelected := promptCompositor()
|
||||
terminal, terminalSelected := promptTerminal()
|
||||
useSystemd := promptSystemd()
|
||||
@@ -340,6 +344,37 @@ func runSetup() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Add user to the input group for the evdev manager for inut state tracking.
|
||||
// Caps Lock OSD and the Caps Lock bar indicator.
|
||||
func ensureInputGroup() {
|
||||
if !utils.HasGroup("input") {
|
||||
return
|
||||
}
|
||||
currentUser := os.Getenv("USER")
|
||||
if currentUser == "" {
|
||||
currentUser = os.Getenv("LOGNAME")
|
||||
}
|
||||
if currentUser == "" {
|
||||
return
|
||||
}
|
||||
out, err := execGroups(currentUser)
|
||||
if err == nil && strings.Contains(out, "input") {
|
||||
fmt.Printf("✓ %s is already in the input group (Caps Lock OSD enabled)\n", currentUser)
|
||||
return
|
||||
}
|
||||
fmt.Println("Adding user to input group for Caps Lock OSD support...")
|
||||
if err := privesc.Run(context.Background(), "", "usermod", "-aG", "input", currentUser); err != nil {
|
||||
fmt.Printf("⚠ Could not add %s to input group (Caps Lock OSD will be unavailable): %v\n", currentUser, err)
|
||||
} else {
|
||||
fmt.Printf("✓ Added %s to input group (logout/login required to take effect)\n", currentUser)
|
||||
}
|
||||
}
|
||||
|
||||
func execGroups(user string) (string, error) {
|
||||
out, err := exec.Command("groups", user).Output()
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
func promptCompositor() (deps.WindowManager, bool) {
|
||||
fmt.Println("Select compositor:")
|
||||
fmt.Println("1) Niri")
|
||||
|
||||
336
core/cmd/dms/commands_system.go
Normal file
336
core/cmd/dms/commands_system.go
Normal file
@@ -0,0 +1,336 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/sysupdate"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var systemCmd = &cobra.Command{
|
||||
Use: "system",
|
||||
Short: "System operations",
|
||||
Long: "System-level operations (updates, etc.). Runs against installed package managers directly; does not require the DMS server.",
|
||||
}
|
||||
|
||||
var systemUpdateCmd = &cobra.Command{
|
||||
Use: "update",
|
||||
Short: "Apply or list system updates",
|
||||
Long: `Apply or list system updates across detected package managers.
|
||||
|
||||
Default behavior is to apply available updates after prompting for confirmation.
|
||||
Use --check to list updates without applying.
|
||||
|
||||
Examples:
|
||||
dms system update --check # list available updates
|
||||
dms system update # apply updates (interactive prompt)
|
||||
dms system update --noconfirm # apply updates without prompting
|
||||
dms system update --dry # simulate without changing anything
|
||||
dms system update --no-flatpak --noconfirm # apply system updates only
|
||||
dms system update --interval 3600 # set the server poll interval to 1h`,
|
||||
Run: runSystemUpdate,
|
||||
}
|
||||
|
||||
var (
|
||||
sysUpdateCheck bool
|
||||
sysUpdateNoConfirm bool
|
||||
sysUpdateDry bool
|
||||
sysUpdateJSON bool
|
||||
sysUpdateNoFlatpak bool
|
||||
sysUpdateNoAUR bool
|
||||
sysUpdateIntervalS int
|
||||
sysUpdateListPmTime = 5 * time.Minute
|
||||
)
|
||||
|
||||
func init() {
|
||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateCheck, "check", false, "List available updates without applying")
|
||||
systemUpdateCmd.Flags().BoolVarP(&sysUpdateNoConfirm, "noconfirm", "y", false, "Apply updates without prompting")
|
||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateDry, "dry", false, "Simulate the upgrade without applying changes")
|
||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateJSON, "json", false, "Output as JSON (with --check)")
|
||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoFlatpak, "no-flatpak", false, "Skip the Flatpak overlay")
|
||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoAUR, "no-aur", false, "Skip the AUR (paru/yay only)")
|
||||
systemUpdateCmd.Flags().IntVar(&sysUpdateIntervalS, "interval", -1, "Set the DMS server poll interval in seconds and exit (requires running server)")
|
||||
|
||||
systemCmd.AddCommand(systemUpdateCmd)
|
||||
}
|
||||
|
||||
func runSystemUpdate(cmd *cobra.Command, args []string) {
|
||||
switch {
|
||||
case sysUpdateIntervalS >= 0:
|
||||
runSystemUpdateSetInterval(sysUpdateIntervalS)
|
||||
case sysUpdateCheck:
|
||||
runSystemUpdateCheck()
|
||||
default:
|
||||
runSystemUpdateApply()
|
||||
}
|
||||
}
|
||||
|
||||
func selectBackends(ctx context.Context) []sysupdate.Backend {
|
||||
sel := sysupdate.Select(ctx)
|
||||
backends := sel.All()
|
||||
if !sysUpdateNoFlatpak {
|
||||
return backends
|
||||
}
|
||||
out := backends[:0]
|
||||
for _, b := range backends {
|
||||
if b.Repo() == sysupdate.RepoFlatpak {
|
||||
continue
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func runSystemUpdateCheck() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), sysUpdateListPmTime)
|
||||
defer cancel()
|
||||
|
||||
backends := selectBackends(ctx)
|
||||
if len(backends) == 0 {
|
||||
log.Fatal("No supported package manager found")
|
||||
}
|
||||
|
||||
stopSpin := startSpinner("Checking for updates… ")
|
||||
|
||||
type backendResult struct {
|
||||
ID string `json:"id"`
|
||||
Display string `json:"displayName"`
|
||||
Packages []sysupdate.Package `json:"packages"`
|
||||
}
|
||||
var results []backendResult
|
||||
var allPkgs []sysupdate.Package
|
||||
var firstErr error
|
||||
|
||||
for _, b := range backends {
|
||||
pkgs, err := b.CheckUpdates(ctx)
|
||||
if err != nil && firstErr == nil {
|
||||
firstErr = fmt.Errorf("%s: %w", b.ID(), err)
|
||||
}
|
||||
results = append(results, backendResult{ID: b.ID(), Display: b.DisplayName(), Packages: pkgs})
|
||||
allPkgs = append(allPkgs, pkgs...)
|
||||
}
|
||||
stopSpin()
|
||||
|
||||
if sysUpdateJSON {
|
||||
out, _ := json.MarshalIndent(map[string]any{
|
||||
"backends": results,
|
||||
"packages": allPkgs,
|
||||
"error": errOrEmpty(firstErr),
|
||||
"count": len(allPkgs),
|
||||
}, "", " ")
|
||||
fmt.Println(string(out))
|
||||
return
|
||||
}
|
||||
|
||||
printBackends(backends)
|
||||
fmt.Printf("Updates: %d\n", len(allPkgs))
|
||||
if firstErr != nil {
|
||||
fmt.Printf("Error: %v\n", firstErr)
|
||||
}
|
||||
if len(allPkgs) == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Println()
|
||||
for _, p := range allPkgs {
|
||||
printPackage(p)
|
||||
}
|
||||
}
|
||||
|
||||
func runSystemUpdateApply() {
|
||||
checkCtx, checkCancel := context.WithTimeout(context.Background(), sysUpdateListPmTime)
|
||||
defer checkCancel()
|
||||
|
||||
backends := selectBackends(checkCtx)
|
||||
if len(backends) == 0 {
|
||||
log.Fatal("No supported package manager found")
|
||||
}
|
||||
|
||||
stopSpin := startSpinner("Checking for updates…")
|
||||
pkgs, firstErr := collectUpdates(checkCtx, backends)
|
||||
stopSpin()
|
||||
if firstErr != nil {
|
||||
fmt.Printf("Warning: %v\n\n", firstErr)
|
||||
}
|
||||
|
||||
printBackends(backends)
|
||||
fmt.Printf("Updates: %d\n", len(pkgs))
|
||||
if len(pkgs) == 0 {
|
||||
fmt.Println("Nothing to upgrade.")
|
||||
return
|
||||
}
|
||||
fmt.Println()
|
||||
for _, p := range pkgs {
|
||||
printPackage(p)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
if !sysUpdateNoConfirm && !sysUpdateDry {
|
||||
if !promptYesNo("Proceed with upgrade? [Y/n]: ") {
|
||||
fmt.Println("Aborted.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
opts := sysupdate.UpgradeOptions{
|
||||
Targets: pkgs,
|
||||
IncludeFlatpak: !sysUpdateNoFlatpak,
|
||||
IncludeAUR: !sysUpdateNoAUR,
|
||||
DryRun: sysUpdateDry,
|
||||
UseSudo: true,
|
||||
}
|
||||
|
||||
onLine := func(line string) { fmt.Println(line) }
|
||||
for _, b := range backends {
|
||||
fmt.Printf("\n== %s ==\n", b.DisplayName())
|
||||
if err := b.Upgrade(ctx, opts, onLine); err != nil {
|
||||
log.Fatalf("%s upgrade failed: %v", b.ID(), err)
|
||||
}
|
||||
}
|
||||
if sysUpdateDry {
|
||||
fmt.Println("\nDry run complete (no changes applied).")
|
||||
return
|
||||
}
|
||||
fmt.Println("\nUpgrade complete.")
|
||||
}
|
||||
|
||||
func collectUpdates(ctx context.Context, backends []sysupdate.Backend) ([]sysupdate.Package, error) {
|
||||
var all []sysupdate.Package
|
||||
var firstErr error
|
||||
for _, b := range backends {
|
||||
pkgs, err := b.CheckUpdates(ctx)
|
||||
if err != nil && firstErr == nil {
|
||||
firstErr = fmt.Errorf("%s: %w", b.ID(), err)
|
||||
}
|
||||
all = append(all, pkgs...)
|
||||
}
|
||||
return all, firstErr
|
||||
}
|
||||
|
||||
func runSystemUpdateSetInterval(seconds int) {
|
||||
resp, err := sendServerRequest(models.Request{
|
||||
ID: 1,
|
||||
Method: "sysupdate.setInterval",
|
||||
Params: map[string]any{"seconds": float64(seconds)},
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Failed: %v (is dms server running?)", err)
|
||||
}
|
||||
if resp.Error != "" {
|
||||
log.Fatalf("Error: %s", resp.Error)
|
||||
}
|
||||
fmt.Printf("Interval set to %d seconds.\n", seconds)
|
||||
}
|
||||
|
||||
func promptYesNo(prompt string) bool {
|
||||
if !stdinIsTTY() {
|
||||
log.Fatal("Refusing to apply updates non-interactively. Re-run with --noconfirm or --check.")
|
||||
}
|
||||
fmt.Print(prompt)
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(line)) {
|
||||
case "n", "no":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func printBackends(backends []sysupdate.Backend) {
|
||||
if len(backends) == 0 {
|
||||
return
|
||||
}
|
||||
names := make([]string, 0, len(backends))
|
||||
for _, b := range backends {
|
||||
names = append(names, b.DisplayName())
|
||||
}
|
||||
fmt.Printf("Backends: %s\n", strings.Join(names, ", "))
|
||||
}
|
||||
|
||||
func stdinIsTTY() bool {
|
||||
fi, err := os.Stdin.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return (fi.Mode() & os.ModeCharDevice) != 0
|
||||
}
|
||||
|
||||
func stdoutIsTTY() bool {
|
||||
fi, err := os.Stdout.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return (fi.Mode() & os.ModeCharDevice) != 0
|
||||
}
|
||||
|
||||
// startSpinner prints an animated spinner to stdout for progress indication
|
||||
func startSpinner(msg string) func() {
|
||||
if !stdoutIsTTY() {
|
||||
return func() {}
|
||||
}
|
||||
frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
for i := 0; ; i++ {
|
||||
select {
|
||||
case <-done:
|
||||
fmt.Print("\r\033[K")
|
||||
return
|
||||
case <-time.After(80 * time.Millisecond):
|
||||
fmt.Printf("\r%s %s", frames[i%len(frames)], msg)
|
||||
}
|
||||
}
|
||||
}()
|
||||
return func() { close(done) }
|
||||
}
|
||||
|
||||
var (
|
||||
styleRepo = lipgloss.NewStyle().Foreground(lipgloss.Color("244")).Bold(false)
|
||||
styleName = lipgloss.NewStyle().Foreground(lipgloss.Color("39")).Bold(true)
|
||||
styleFrom = lipgloss.NewStyle().Foreground(lipgloss.Color("243"))
|
||||
styleArrow = lipgloss.NewStyle().Foreground(lipgloss.Color("244"))
|
||||
styleTo = lipgloss.NewStyle().Foreground(lipgloss.Color("76")).Bold(true)
|
||||
)
|
||||
|
||||
func printPackage(p sysupdate.Package) {
|
||||
if !stdoutIsTTY() {
|
||||
fmt.Printf(" [%s] %s %s -> %s\n", p.Repo, p.Name, defaultIfEmpty(p.FromVersion, "?"), defaultIfEmpty(p.ToVersion, "?"))
|
||||
return
|
||||
}
|
||||
fmt.Printf(" %s %s %s %s %s\n",
|
||||
styleRepo.Render("["+string(p.Repo)+"]"),
|
||||
styleName.Render(p.Name),
|
||||
styleFrom.Render(defaultIfEmpty(p.FromVersion, "?")),
|
||||
styleArrow.Render("->"),
|
||||
styleTo.Render(defaultIfEmpty(p.ToVersion, "?")),
|
||||
)
|
||||
}
|
||||
|
||||
func errOrEmpty(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
func defaultIfEmpty(s, def string) string {
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -15,6 +15,8 @@ func init() {
|
||||
runCmd.Flags().BoolP("daemon", "d", false, "Run in daemon mode")
|
||||
runCmd.Flags().Bool("daemon-child", false, "Internal flag for daemon child process")
|
||||
runCmd.Flags().Bool("session", false, "Session managed (like as a systemd unit)")
|
||||
runCmd.Flags().String("log-level", "", "Log level: debug, info, warn, error, fatal (overrides DMS_LOG_LEVEL)")
|
||||
runCmd.Flags().String("log-file", "", "Append logs to this file in addition to stderr (overrides DMS_LOG_FILE)")
|
||||
runCmd.Flags().MarkHidden("daemon-child")
|
||||
|
||||
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd)
|
||||
|
||||
@@ -15,6 +15,8 @@ func init() {
|
||||
runCmd.Flags().BoolP("daemon", "d", false, "Run in daemon mode")
|
||||
runCmd.Flags().Bool("daemon-child", false, "Internal flag for daemon child process")
|
||||
runCmd.Flags().Bool("session", false, "Session managed (like as a systemd unit)")
|
||||
runCmd.Flags().String("log-level", "", "Log level: debug, info, warn, error, fatal (overrides DMS_LOG_LEVEL)")
|
||||
runCmd.Flags().String("log-file", "", "Append logs to this file in addition to stderr (overrides DMS_LOG_FILE)")
|
||||
runCmd.Flags().MarkHidden("daemon-child")
|
||||
|
||||
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd)
|
||||
|
||||
@@ -80,6 +80,16 @@ func getRuntimeDir() string {
|
||||
return os.TempDir()
|
||||
}
|
||||
|
||||
func appendLogEnv(env []string) []string {
|
||||
if v := os.Getenv("DMS_LOG_LEVEL"); v != "" {
|
||||
env = append(env, "DMS_LOG_LEVEL="+v)
|
||||
}
|
||||
if v := os.Getenv("DMS_LOG_FILE"); v != "" {
|
||||
env = append(env, "DMS_LOG_FILE="+v)
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func hasSystemdRun() bool {
|
||||
_, err := exec.LookPath("systemd-run")
|
||||
return err == nil
|
||||
@@ -216,6 +226,8 @@ func runShellInteractive(session bool) {
|
||||
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb")
|
||||
}
|
||||
|
||||
cmd.Env = appendLogEnv(cmd.Env)
|
||||
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
@@ -459,6 +471,8 @@ func runShellDaemon(session bool) {
|
||||
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb")
|
||||
}
|
||||
|
||||
cmd.Env = appendLogEnv(cmd.Env)
|
||||
|
||||
devNull, err := os.OpenFile("/dev/null", os.O_RDWR, 0)
|
||||
if err != nil {
|
||||
log.Fatalf("Error opening /dev/null: %v", err)
|
||||
|
||||
@@ -15,7 +15,7 @@ func isReadOnlyCommand(args []string) bool {
|
||||
continue
|
||||
}
|
||||
switch arg {
|
||||
case "completion", "help", "__complete":
|
||||
case "completion", "help", "__complete", "system":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
74
core/go.mod
74
core/go.mod
@@ -1,84 +1,100 @@
|
||||
module github.com/AvengeMedia/DankMaterialShell/core
|
||||
|
||||
go 1.26.0
|
||||
|
||||
toolchain go1.26.1
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/Wifx/gonetworkmanager/v2 v2.2.0
|
||||
github.com/alecthomas/chroma/v2 v2.23.1
|
||||
github.com/alecthomas/chroma/v2 v2.24.1
|
||||
github.com/charmbracelet/bubbles v1.0.0
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/charmbracelet/log v0.4.2
|
||||
github.com/fsnotify/fsnotify v1.9.0
|
||||
github.com/charmbracelet/log v1.0.0
|
||||
github.com/fsnotify/fsnotify v1.10.1
|
||||
github.com/godbus/dbus/v5 v5.2.2
|
||||
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83
|
||||
github.com/holoplot/go-evdev v0.0.0-20260504100651-66d1748fe847
|
||||
github.com/pilebones/go-udev v0.9.1
|
||||
github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/yeqown/go-qrcode/v2 v2.2.5
|
||||
github.com/yeqown/go-qrcode/writer/standard v1.3.0
|
||||
github.com/yuin/goldmark v1.7.16
|
||||
github.com/yuin/goldmark v1.8.2
|
||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
|
||||
go.etcd.io/bbolt v1.4.3
|
||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a
|
||||
golang.org/x/image v0.36.0
|
||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f
|
||||
golang.org/x/image v0.39.0
|
||||
tailscale.com v1.96.5
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.10.0 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.4.1 // indirect
|
||||
github.com/akutz/memconn v0.1.0 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/cloudflare/circl v1.6.3 // indirect
|
||||
github.com/coder/websocket v1.8.14 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/dblohm7/wingoes v0.0.0-20250822163801-6d8e6105c62d // indirect
|
||||
github.com/dlclark/regexp2 v1.12.0 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/fogleman/gg v1.3.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
|
||||
github.com/go-git/gcfg/v2 v2.0.2 // indirect
|
||||
github.com/go-git/go-billy/v6 v6.0.0-20260209124918-37866f83c2d3 // indirect
|
||||
github.com/go-git/go-billy/v6 v6.0.0-20260504142752-cb8e9d337266 // indirect
|
||||
github.com/go-json-experiment/json v0.0.0-20260430182902-b6187a392ed4 // indirect
|
||||
github.com/go-logfmt/logfmt v0.6.1 // indirect
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/hdevalence/ed25519consensus v0.2.0 // indirect
|
||||
github.com/jsimonetti/rtnetlink v1.4.2 // indirect
|
||||
github.com/kevinburke/ssh_config v1.6.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/pjbgf/sha1cd v0.5.0 // indirect
|
||||
github.com/mdlayher/netlink v1.11.1 // indirect
|
||||
github.com/mdlayher/socket v0.6.0 // indirect
|
||||
github.com/mitchellh/go-ps v1.0.0 // indirect
|
||||
github.com/pjbgf/sha1cd v0.6.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/sergi/go-diff v1.4.0 // indirect
|
||||
github.com/stretchr/objx v0.5.3 // indirect
|
||||
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/yeqown/reedsolomon v1.0.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
||||
golang.org/x/crypto v0.50.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.2 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.7 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/go-git/go-git/v6 v6.0.0-20260216160506-e6a3f881772f
|
||||
github.com/go-git/go-git/v6 v6.0.0-alpha.2
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0
|
||||
github.com/mattn/go-isatty v0.0.22
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.23 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/muesli/termenv v0.16.0
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/spf13/afero v1.15.0
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sys v0.41.0
|
||||
golang.org/x/text v0.34.0
|
||||
golang.org/x/sys v0.43.0
|
||||
golang.org/x/text v0.36.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
|
||||
158
core/go.sum
158
core/go.sum
@@ -1,14 +1,18 @@
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
|
||||
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
|
||||
github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
|
||||
github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
|
||||
github.com/Wifx/gonetworkmanager/v2 v2.2.0 h1:kPstgsQtY8CmDOOFZd81ytM9Gi3f6ImzPCKF7nNhQ2U=
|
||||
github.com/Wifx/gonetworkmanager/v2 v2.2.0/go.mod h1:fMDb//SHsKWxyDUAwXvCqurV3npbIyyaQWenGpZ/uXg=
|
||||
github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A=
|
||||
github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
|
||||
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
|
||||
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
|
||||
github.com/alecthomas/chroma/v2 v2.24.1 h1:m5ffpfZbIb++k8AqFEKy9uVgY12xIQtBsQlc6DfZJQM=
|
||||
github.com/alecthomas/chroma/v2 v2.24.1/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI=
|
||||
github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
|
||||
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
||||
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
@@ -24,54 +28,71 @@ github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5f
|
||||
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
|
||||
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
|
||||
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
|
||||
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
|
||||
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
github.com/charmbracelet/log v1.0.0 h1:HVVVMmfOorfj3BA9i8X8UL69Hoz9lI0PYwXfJvOdRc4=
|
||||
github.com/charmbracelet/log v1.0.0/go.mod h1:uYgY3SmLpwJWxmlrPwXvzVYujxis1vAKRV/0VQB7yWA=
|
||||
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
|
||||
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/clipperhouse/displaywidth v0.10.0 h1:GhBG8WuerxjFQQYeuZAeVTuyxuX+UraiZGD4HJQ3Y8g=
|
||||
github.com/clipperhouse/displaywidth v0.10.0/go.mod h1:XqJajYsaiEwkxOj4bowCTMcT1SgvHo9flfF3jQasdbs=
|
||||
github.com/cilium/ebpf v0.16.0 h1:+BiEnHL6Z7lXnlGUsXQPPAE7+kenAd4ES8MQ5min0Ok=
|
||||
github.com/cilium/ebpf v0.16.0/go.mod h1:L7u2Blt2jMM/vLAVgjxluxtBKlz3/GWjB0dMOEngfwE=
|
||||
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc=
|
||||
github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g=
|
||||
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
|
||||
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dblohm7/wingoes v0.0.0-20250822163801-6d8e6105c62d h1:QRKpU+9ZBDs62LyBfwhZkJdB5DJX2Sm3p4kUh7l1aA0=
|
||||
github.com/dblohm7/wingoes v0.0.0-20250822163801-6d8e6105c62d/go.mod h1:SUxUaAK/0UG5lYyZR1L1nC4AaYYvSSYTWQSH3FPcxKU=
|
||||
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
|
||||
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
||||
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
|
||||
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
|
||||
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo=
|
||||
github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
|
||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||
github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo=
|
||||
github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs=
|
||||
github.com/go-git/go-billy/v6 v6.0.0-20260209124918-37866f83c2d3 h1:UU7oARtwQ5g85aFiCSwIUA6PBmAshYj0sytl/5CCBgs=
|
||||
github.com/go-git/go-billy/v6 v6.0.0-20260209124918-37866f83c2d3/go.mod h1:ZW9JC5gionMP1kv5uiaOaV23q0FFmNrVOV8VW+y/acc=
|
||||
github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20260122163445-0622d7459a67 h1:3hutPZF+/FBjR/9MdsLJ7e1mlt9pwHgwxMW7CrbmWII=
|
||||
github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20260122163445-0622d7459a67/go.mod h1:xKt0pNHST9tYHvbiLxSY27CQWFwgIxBJuDrOE0JvbZw=
|
||||
github.com/go-git/go-git/v6 v6.0.0-20260216160506-e6a3f881772f h1:TBkCJv9YwPOuXq1OG0r01bcxRrvs15Hp/DtZuPt4H6s=
|
||||
github.com/go-git/go-git/v6 v6.0.0-20260216160506-e6a3f881772f/go.mod h1:B88nWzfnhTlIikoJ4d84Nc9noKS5mJoA7SgDdkt0aPU=
|
||||
github.com/go-git/go-billy/v6 v6.0.0-20260504142752-cb8e9d337266 h1:wH21vHuv323v9x78JNFNJ6P7HEAsdwr9yq2k9/o4zEE=
|
||||
github.com/go-git/go-billy/v6 v6.0.0-20260504142752-cb8e9d337266/go.mod h1:CdBVp7CXl9l3sOyNEog46cP1Pvx/hjCe9AD0mtaIUYU=
|
||||
github.com/go-git/go-git-fixtures/v6 v6.0.0-20260405195209-b16dd39735e0 h1:XoTsdvaghuVfIr7HpNTmFDLu2nz3I2iGqyn6Uk6MkJc=
|
||||
github.com/go-git/go-git-fixtures/v6 v6.0.0-20260405195209-b16dd39735e0/go.mod h1:1Lr7/vYEYyl6Ir9Ku0tKrCIRreM5zovv0Jdx2MPSM4s=
|
||||
github.com/go-git/go-git/v6 v6.0.0-alpha.2 h1:T3loNtDuAixNzXtlQxZhnYiYpaQ3CA4vn9RssAniEeI=
|
||||
github.com/go-git/go-git/v6 v6.0.0-alpha.2/go.mod h1:oCD3i19CTz7gBpeb11ZZqL91WzqbMq9avn5KpUYy/Ak=
|
||||
github.com/go-json-experiment/json v0.0.0-20260430182902-b6187a392ed4 h1:2WmHkJINIjgXXYDGik8d3oJvFA3DAwPy00csDJ3vo+o=
|
||||
github.com/go-json-experiment/json v0.0.0-20260430182902-b6187a392ed4/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
||||
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
|
||||
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
@@ -84,12 +105,16 @@ github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUv
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU=
|
||||
github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83 h1:B+A58zGFuDrvEZpPN+yS6swJA0nzqgZvDzgl/OPyefU=
|
||||
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83/go.mod h1:iHAf8OIncO2gcQ8XOjS7CMJ2aPbX2Bs0wl5pZyanEqk=
|
||||
github.com/holoplot/go-evdev v0.0.0-20260504100651-66d1748fe847 h1:1rQ5UQXFm02DXEtsIpotfA32WJ9KceS6t8w5K8QtFqc=
|
||||
github.com/holoplot/go-evdev v0.0.0-20260504100651-66d1748fe847/go.mod h1:iHAf8OIncO2gcQ8XOjS7CMJ2aPbX2Bs0wl5pZyanEqk=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jsimonetti/rtnetlink v1.4.2 h1:Df9w9TZ3npHTyDn0Ev9e1uzmN2odmXd0QX+J5GTEn90=
|
||||
github.com/jsimonetti/rtnetlink v1.4.2/go.mod h1:92s6LJdE+1iOrw+F2/RO7LYI2Qd8pPpFNNUYW06gcoM=
|
||||
github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=
|
||||
github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
@@ -101,14 +126,20 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 h1:P8UmIzZMYDR+NGImiFvErt6VWfIRPuGM+vyjiEdkmIw=
|
||||
github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
|
||||
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mdlayher/netlink v1.11.1 h1:T136gDS6Gkt+hLncaBwKdW5GpEC8Z0ykqimOebVoal0=
|
||||
github.com/mdlayher/netlink v1.11.1/go.mod h1:ao4LjamyK4Uq9L8+fQzqFYpAncbeCdwbvd9Edv/pYnc=
|
||||
github.com/mdlayher/socket v0.6.0 h1:ScZPaAGyO1icQnbFrhPM8mnXyMu9qukC1K4ZoM2IQKU=
|
||||
github.com/mdlayher/socket v0.6.0/go.mod h1:q7vozUAnxSqnjHc12Fik5yUKIzfZ8ITCfMkhOtE9z18=
|
||||
github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc=
|
||||
github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
@@ -117,12 +148,13 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/pilebones/go-udev v0.9.1 h1:uN72M1C1fgzhsVmBGEM8w9RD1JY4iVsPZpr+Z6rb3O8=
|
||||
github.com/pilebones/go-udev v0.9.1/go.mod h1:Bgcl07crebF3JSeS4+nuaRvhWFdCeFoBhXXeAp93XNo=
|
||||
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
|
||||
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
|
||||
github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
|
||||
github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
@@ -146,6 +178,12 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4=
|
||||
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg=
|
||||
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da h1:jVRUZPRs9sqyKlYHHzHjAqKN+6e/Vog6NpHYeNPJqOw=
|
||||
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yeqown/go-qrcode/v2 v2.2.5 h1:HCOe2bSjkhZyYoyyNaXNzh4DJZll6inVJQQw+8228Zk=
|
||||
@@ -155,37 +193,47 @@ github.com/yeqown/go-qrcode/writer/standard v1.3.0/go.mod h1:O4MbzsotGCvy8upYPCR
|
||||
github.com/yeqown/reedsolomon v1.0.0 h1:x1h/Ej/uJnNu8jaX7GLHBWmZKCAWjEJTetkqaabr4B0=
|
||||
github.com/yeqown/reedsolomon v1.0.0/go.mod h1:P76zpcn2TCuL0ul1Fso373qHRc69LKwAw/Iy6g1WiiM=
|
||||
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
|
||||
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
|
||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
|
||||
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
||||
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
|
||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
|
||||
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek=
|
||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
|
||||
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
||||
golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH8ysFIbw8=
|
||||
golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
tailscale.com v1.96.5 h1:gNkfA/KSZAl6jCH9cj8urq00HRWItDDTtGsyATI89jA=
|
||||
tailscale.com v1.96.5/go.mod h1:/3lnZBYb2UEwnN0MNu2SDXUtT06AGd5k0s+OWx3WmcY=
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
cblog "github.com/charmbracelet/log"
|
||||
"github.com/mattn/go-isatty"
|
||||
"github.com/muesli/termenv"
|
||||
)
|
||||
|
||||
// Logger embeds the Charm Logger and adds Printf/Fatalf
|
||||
@@ -21,8 +25,26 @@ func (l *Logger) Fatalf(format string, v ...any) { l.Logger.Fatalf(format, v...)
|
||||
var (
|
||||
logger *Logger
|
||||
initLogger sync.Once
|
||||
|
||||
logMu sync.Mutex
|
||||
logFile *os.File
|
||||
logStderr io.Writer = os.Stderr
|
||||
|
||||
ansiRe = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`)
|
||||
)
|
||||
|
||||
// ansiStripWriter strips ANSI escape sequences before forwarding to w. Used
|
||||
// for the file sink so colored stderr stays colored while the file stays plain.
|
||||
type ansiStripWriter struct{ w io.Writer }
|
||||
|
||||
func (a *ansiStripWriter) Write(p []byte) (int, error) {
|
||||
stripped := ansiRe.ReplaceAll(p, nil)
|
||||
if _, err := a.w.Write(stripped); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func parseLogLevel(level string) cblog.Level {
|
||||
switch strings.ToLower(level) {
|
||||
case "debug":
|
||||
@@ -86,7 +108,7 @@ func GetLogger() *Logger {
|
||||
SetString(" DEBUG").
|
||||
Foreground(lipgloss.Color("4"))
|
||||
|
||||
base := cblog.New(os.Stderr)
|
||||
base := cblog.New(logStderr)
|
||||
base.SetStyles(styles)
|
||||
base.SetReportTimestamp(false)
|
||||
|
||||
@@ -98,10 +120,85 @@ func GetLogger() *Logger {
|
||||
base.SetPrefix(" go")
|
||||
|
||||
logger = &Logger{base}
|
||||
|
||||
if path := os.Getenv("DMS_LOG_FILE"); path != "" {
|
||||
_ = SetLogFile(path)
|
||||
}
|
||||
})
|
||||
return logger
|
||||
}
|
||||
|
||||
// SetLevel updates the active log level. Accepts the same strings as
|
||||
// DMS_LOG_LEVEL. Unknown values default to info.
|
||||
func SetLevel(level string) {
|
||||
GetLogger().SetLevel(parseLogLevel(level))
|
||||
}
|
||||
|
||||
// SetLogFile makes the logger append to path in addition to stderr. Passing an
|
||||
// empty string detaches the file sink. Atomic per-line writes (≤PIPE_BUF) on
|
||||
// O_APPEND keep concurrent Go and QML writers from corrupting each other.
|
||||
//
|
||||
// Color handling: charmbracelet/log auto-detects color support from its
|
||||
// io.Writer, and io.MultiWriter doesn't pass that through, so we force the ANSI
|
||||
// profile when stderr is a TTY and route the file through ansiStripWriter so
|
||||
// the file stays plain while stderr keeps its colors.
|
||||
func SetLogFile(path string) error {
|
||||
logMu.Lock()
|
||||
defer logMu.Unlock()
|
||||
|
||||
if logFile != nil {
|
||||
logFile.Close()
|
||||
logFile = nil
|
||||
}
|
||||
|
||||
l := GetLogger()
|
||||
if path == "" {
|
||||
l.SetOutput(logStderr)
|
||||
applyColorProfile(l, logStderr)
|
||||
return nil
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logFile = f
|
||||
out := io.MultiWriter(logStderr, &ansiStripWriter{w: f})
|
||||
l.SetOutput(out)
|
||||
applyColorProfile(l, logStderr)
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyColorProfile forces the renderer's color profile to match what stderr
|
||||
// would produce on its own, undoing the auto-downgrade triggered by wrapping
|
||||
// stderr in a non-TTY writer (e.g. io.MultiWriter).
|
||||
func applyColorProfile(l *Logger, stderr io.Writer) {
|
||||
f, ok := stderr.(*os.File)
|
||||
if !ok {
|
||||
l.SetColorProfile(termenv.Ascii)
|
||||
return
|
||||
}
|
||||
if isatty.IsTerminal(f.Fd()) {
|
||||
l.SetColorProfile(termenv.ANSI)
|
||||
return
|
||||
}
|
||||
l.SetColorProfile(termenv.Ascii)
|
||||
}
|
||||
|
||||
// ApplyEnvOverrides re-reads DMS_LOG_LEVEL and DMS_LOG_FILE and reconfigures
|
||||
// the singleton. Safe to call after CLI flags have rewritten the environment.
|
||||
func ApplyEnvOverrides() {
|
||||
GetLogger()
|
||||
if level := os.Getenv("DMS_LOG_LEVEL"); level != "" {
|
||||
SetLevel(level)
|
||||
}
|
||||
if path := os.Getenv("DMS_LOG_FILE"); path != "" {
|
||||
if err := SetLogFile(path); err != nil {
|
||||
Warnf("Failed to open log file %q: %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// * Convenience wrappers
|
||||
|
||||
func Debug(msg any, keyvals ...any) { GetLogger().Debug(msg, keyvals...) }
|
||||
|
||||
@@ -60,6 +60,7 @@ var templateRegistry = []TemplateDef{
|
||||
{ID: "pywalfox", Commands: []string{"pywalfox"}, ConfigFile: "pywalfox.toml"},
|
||||
{ID: "zenbrowser", Commands: []string{"zen", "zen-browser", "zen-beta", "zen-twilight"}, Flatpaks: []string{"app.zen_browser.zen"}, ConfigFile: "zenbrowser.toml"},
|
||||
{ID: "vesktop", Commands: []string{"vesktop"}, Flatpaks: []string{"dev.vencord.Vesktop"}, ConfigFile: "vesktop.toml"},
|
||||
{ID: "vencord", Commands: []string{"discord", "Discord", "discord-canary", "DiscordCanary"}, Flatpaks: []string{"com.discordapp.Discord", "com.discordapp.DiscordCanary"}, ConfigFile: "vencord.toml"},
|
||||
{ID: "equibop", Commands: []string{"equibop"}, ConfigFile: "equibop.toml"},
|
||||
{ID: "ghostty", Commands: []string{"ghostty"}, ConfigFile: "ghostty.toml", Kind: TemplateKindTerminal},
|
||||
{ID: "kitty", Commands: []string{"kitty"}, ConfigFile: "kitty.toml", Kind: TemplateKindTerminal},
|
||||
|
||||
@@ -391,7 +391,7 @@ func (m *Manager) Close() {
|
||||
|
||||
func InitializeManager() (*Manager, error) {
|
||||
if os.Getuid() != 0 && !hasInputGroupAccess() {
|
||||
return nil, fmt.Errorf("insufficient permissions to access input devices")
|
||||
return nil, fmt.Errorf("insufficient permissions to access input devices. Add your user to the 'input' group: `sudo usermod -a -G input $USER` or run `dms setup`")
|
||||
}
|
||||
|
||||
return NewManager()
|
||||
|
||||
@@ -104,7 +104,7 @@ func (m *Manager) claimScreensaverName(handler *screensaverHandler, name, iface
|
||||
return false
|
||||
}
|
||||
if reply != dbus.RequestNameReplyPrimaryOwner {
|
||||
log.Warnf("Screensaver name %s already owned by another process", name)
|
||||
log.Infof("Screensaver name %s already owned by another process (e.g. hypridle/swayidle)", name)
|
||||
return false
|
||||
}
|
||||
if err := m.exportScreensaverOnPaths(handler, iface, paths...); err != nil {
|
||||
|
||||
@@ -20,6 +20,8 @@ import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
|
||||
serverPlugins "github.com/AvengeMedia/DankMaterialShell/core/internal/server/plugins"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/sysupdate"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/tailscale"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
|
||||
serverThemes "github.com/AvengeMedia/DankMaterialShell/core/internal/server/themes"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
|
||||
@@ -109,6 +111,15 @@ func RouteRequest(conn net.Conn, req models.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(req.Method, "tailscale.") {
|
||||
if tailscaleManager == nil {
|
||||
models.RespondError(conn, req.ID, "Tailscale not available")
|
||||
return
|
||||
}
|
||||
tailscale.HandleRequest(conn, req, tailscaleManager)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(req.Method, "dwl.") {
|
||||
if dwlManager == nil {
|
||||
models.RespondError(conn, req.ID, "dwl manager not initialized")
|
||||
@@ -202,6 +213,15 @@ func RouteRequest(conn net.Conn, req models.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(req.Method, "sysupdate.") {
|
||||
if sysUpdateManager == nil {
|
||||
models.RespondError(conn, req.ID, "sysupdate manager not initialized")
|
||||
return
|
||||
}
|
||||
sysupdate.HandleRequest(conn, req, sysUpdateManager)
|
||||
return
|
||||
}
|
||||
|
||||
switch req.Method {
|
||||
case "ping":
|
||||
models.Respond(conn, req.ID, "pong")
|
||||
|
||||
@@ -30,6 +30,8 @@ import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/sysupdate"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/tailscale"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/trayrecovery"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
|
||||
@@ -64,6 +66,7 @@ var waylandManager *wayland.Manager
|
||||
var bluezManager *bluez.Manager
|
||||
var appPickerManager *apppicker.Manager
|
||||
var cupsManager *cups.Manager
|
||||
var tailscaleManager *tailscale.Manager
|
||||
var dwlManager *dwl.Manager
|
||||
var extWorkspaceManager *extworkspace.Manager
|
||||
var brightnessManager *brightness.Manager
|
||||
@@ -75,6 +78,7 @@ var wlContext *wlcontext.SharedContext
|
||||
var themeModeManager *thememode.Manager
|
||||
var trayRecoveryManager *trayrecovery.Manager
|
||||
var locationManager *location.Manager
|
||||
var sysUpdateManager *sysupdate.Manager
|
||||
var geoClientInstance geolocation.Client
|
||||
|
||||
const dbusClientID = "dms-dbus-client"
|
||||
@@ -421,6 +425,19 @@ func InitializeLocationManager(geoClient geolocation.Client) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func InitializeSysUpdateManager() error {
|
||||
manager, err := sysupdate.NewManager()
|
||||
if err != nil {
|
||||
log.Warnf("Failed to initialize sysupdate manager: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
sysUpdateManager = manager
|
||||
|
||||
log.Info("Sysupdate manager initialized")
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleConnection(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
|
||||
@@ -474,6 +491,10 @@ func getCapabilities() Capabilities {
|
||||
caps = append(caps, "cups")
|
||||
}
|
||||
|
||||
if tailscaleManager != nil && tailscaleManager.IsAvailable() {
|
||||
caps = append(caps, "tailscale")
|
||||
}
|
||||
|
||||
if dwlManager != nil {
|
||||
caps = append(caps, "dwl")
|
||||
}
|
||||
@@ -506,6 +527,10 @@ func getCapabilities() Capabilities {
|
||||
caps = append(caps, "dbus")
|
||||
}
|
||||
|
||||
if sysUpdateManager != nil {
|
||||
caps = append(caps, "sysupdate")
|
||||
}
|
||||
|
||||
return Capabilities{Capabilities: caps}
|
||||
}
|
||||
|
||||
@@ -540,6 +565,10 @@ func getServerInfo() ServerInfo {
|
||||
caps = append(caps, "cups")
|
||||
}
|
||||
|
||||
if tailscaleManager != nil && tailscaleManager.IsAvailable() {
|
||||
caps = append(caps, "tailscale")
|
||||
}
|
||||
|
||||
if dwlManager != nil {
|
||||
caps = append(caps, "dwl")
|
||||
}
|
||||
@@ -576,6 +605,10 @@ func getServerInfo() ServerInfo {
|
||||
caps = append(caps, "dbus")
|
||||
}
|
||||
|
||||
if sysUpdateManager != nil {
|
||||
caps = append(caps, "sysupdate")
|
||||
}
|
||||
|
||||
return ServerInfo{
|
||||
APIVersion: APIVersion,
|
||||
CLIVersion: CLIVersion,
|
||||
@@ -1016,6 +1049,38 @@ func handleSubscribe(conn net.Conn, req models.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
if shouldSubscribe("tailscale") && tailscaleManager != nil && tailscaleManager.IsAvailable() {
|
||||
wg.Add(1)
|
||||
tailscaleChan := tailscaleManager.Subscribe(clientID + "-tailscale")
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer tailscaleManager.Unsubscribe(clientID + "-tailscale")
|
||||
|
||||
initialState := tailscaleManager.GetState()
|
||||
select {
|
||||
case eventChan <- ServiceEvent{Service: "tailscale", Data: initialState}:
|
||||
case <-stopChan:
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case state, ok := <-tailscaleChan:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case eventChan <- ServiceEvent{Service: "tailscale", Data: state}:
|
||||
case <-stopChan:
|
||||
return
|
||||
}
|
||||
case <-stopChan:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if shouldSubscribe("dwl") && dwlManager != nil {
|
||||
wg.Add(1)
|
||||
dwlChan := dwlManager.Subscribe(clientID + "-dwl")
|
||||
@@ -1243,6 +1308,38 @@ func handleSubscribe(conn net.Conn, req models.Request) {
|
||||
}()
|
||||
}
|
||||
|
||||
if shouldSubscribe("sysupdate") && sysUpdateManager != nil {
|
||||
wg.Add(1)
|
||||
sysupdateChan := sysUpdateManager.Subscribe(clientID + "-sysupdate")
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer sysUpdateManager.Unsubscribe(clientID + "-sysupdate")
|
||||
|
||||
initialState := sysUpdateManager.GetState()
|
||||
select {
|
||||
case eventChan <- ServiceEvent{Service: "sysupdate", Data: initialState}:
|
||||
case <-stopChan:
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case state, ok := <-sysupdateChan:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case eventChan <- ServiceEvent{Service: "sysupdate", Data: state}:
|
||||
case <-stopChan:
|
||||
return
|
||||
}
|
||||
case <-stopChan:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if shouldSubscribe("dbus") && dbusManager != nil {
|
||||
wg.Add(1)
|
||||
dbusChan := dbusManager.SubscribeSignals(dbusClientID)
|
||||
@@ -1348,14 +1445,28 @@ func cleanupManagers() {
|
||||
if locationManager != nil {
|
||||
locationManager.Close()
|
||||
}
|
||||
if sysUpdateManager != nil {
|
||||
sysUpdateManager.Close()
|
||||
}
|
||||
if geoClientInstance != nil {
|
||||
geoClientInstance.Close()
|
||||
}
|
||||
if tailscaleManager != nil {
|
||||
tailscaleManager.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func Start(printDocs bool) error {
|
||||
cleanupStaleSockets()
|
||||
|
||||
// Tailscale manager always starts — reconnects internally via WatchIPNBus.
|
||||
// The capability is only advertised once tailscaled is reachable; the
|
||||
// callback wakes capability subscribers so QML clients see it transition.
|
||||
tailscaleManager = tailscale.NewManager("")
|
||||
tailscaleManager.SetAvailabilityCallback(func(bool) {
|
||||
notifyCapabilityChange()
|
||||
})
|
||||
|
||||
socketPath := GetSocketPath()
|
||||
os.Remove(socketPath)
|
||||
|
||||
@@ -1733,6 +1844,10 @@ func Start(printDocs bool) error {
|
||||
}
|
||||
}()
|
||||
|
||||
if err := InitializeSysUpdateManager(); err != nil {
|
||||
log.Warnf("Sysupdate manager unavailable: %v", err)
|
||||
}
|
||||
|
||||
log.Info("")
|
||||
log.Infof("Ready! Capabilities: %v", getCapabilities().Capabilities)
|
||||
|
||||
|
||||
96
core/internal/server/sysupdate/backend.go
Normal file
96
core/internal/server/sysupdate/backend.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package sysupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Backend interface {
|
||||
ID() string
|
||||
DisplayName() string
|
||||
Repo() RepoKind
|
||||
IsAvailable(ctx context.Context) bool
|
||||
NeedsAuth() bool
|
||||
RunsInTerminal() bool
|
||||
CheckUpdates(ctx context.Context) ([]Package, error)
|
||||
Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error
|
||||
}
|
||||
|
||||
type Selection struct {
|
||||
System Backend
|
||||
Overlay []Backend
|
||||
}
|
||||
|
||||
func (s Selection) All() []Backend {
|
||||
if s.System == nil {
|
||||
return s.Overlay
|
||||
}
|
||||
out := make([]Backend, 0, 1+len(s.Overlay))
|
||||
out = append(out, s.System)
|
||||
out = append(out, s.Overlay...)
|
||||
return out
|
||||
}
|
||||
|
||||
func (s Selection) Info() []BackendInfo {
|
||||
all := s.All()
|
||||
out := make([]BackendInfo, 0, len(all))
|
||||
for _, b := range all {
|
||||
out = append(out, BackendInfo{
|
||||
ID: b.ID(),
|
||||
DisplayName: b.DisplayName(),
|
||||
Repo: b.Repo(),
|
||||
NeedsAuth: b.NeedsAuth(),
|
||||
RunsInTerminal: b.RunsInTerminal(),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var (
|
||||
registryMu sync.RWMutex
|
||||
systemCandidates []func() Backend
|
||||
overlayCandidate []func() Backend
|
||||
)
|
||||
|
||||
func RegisterSystemBackend(factory func() Backend) {
|
||||
registryMu.Lock()
|
||||
defer registryMu.Unlock()
|
||||
systemCandidates = append(systemCandidates, factory)
|
||||
}
|
||||
|
||||
func RegisterOverlayBackend(factory func() Backend) {
|
||||
registryMu.Lock()
|
||||
defer registryMu.Unlock()
|
||||
overlayCandidate = append(overlayCandidate, factory)
|
||||
}
|
||||
|
||||
func Select(ctx context.Context) Selection {
|
||||
registryMu.RLock()
|
||||
sys := append([]func() Backend(nil), systemCandidates...)
|
||||
ov := append([]func() Backend(nil), overlayCandidate...)
|
||||
registryMu.RUnlock()
|
||||
|
||||
var sel Selection
|
||||
for _, factory := range sys {
|
||||
b := factory()
|
||||
if !b.IsAvailable(ctx) {
|
||||
continue
|
||||
}
|
||||
sel.System = b
|
||||
break
|
||||
}
|
||||
for _, factory := range ov {
|
||||
b := factory()
|
||||
if !b.IsAvailable(ctx) {
|
||||
continue
|
||||
}
|
||||
sel.Overlay = append(sel.Overlay, b)
|
||||
}
|
||||
return sel
|
||||
}
|
||||
|
||||
func commandExists(name string) bool {
|
||||
_, err := exec.LookPath(name)
|
||||
return err == nil
|
||||
}
|
||||
80
core/internal/server/sysupdate/backend_apt.go
Normal file
80
core/internal/server/sysupdate/backend_apt.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package sysupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func init() {
|
||||
RegisterSystemBackend(func() Backend { return &aptBackend{} })
|
||||
}
|
||||
|
||||
var aptUpgradableLine = regexp.MustCompile(`^([^/]+)/\S+\s+(\S+)\s+\S+\s+\[upgradable from:\s+([^\]]+)\]`)
|
||||
|
||||
type aptBackend struct{}
|
||||
|
||||
func (aptBackend) ID() string { return "apt" }
|
||||
func (aptBackend) DisplayName() string { return "APT" }
|
||||
func (aptBackend) Repo() RepoKind { return RepoSystem }
|
||||
func (aptBackend) NeedsAuth() bool { return true }
|
||||
func (aptBackend) RunsInTerminal() bool { return false }
|
||||
func (aptBackend) IsAvailable(_ context.Context) bool {
|
||||
return commandExists("apt") || commandExists("apt-get")
|
||||
}
|
||||
|
||||
func (aptBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
|
||||
cmd := exec.CommandContext(ctx, "apt", "list", "--upgradable")
|
||||
cmd.Env = append(cmd.Environ(), "LC_ALL=C")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseAptUpgradable(string(out)), nil
|
||||
}
|
||||
|
||||
func (aptBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
|
||||
bin := "apt-get"
|
||||
if !commandExists(bin) {
|
||||
bin = "apt"
|
||||
}
|
||||
if opts.DryRun {
|
||||
return Run(ctx, []string{bin, "upgrade", "--dry-run"}, RunOptions{
|
||||
Env: []string{"DEBIAN_FRONTEND=noninteractive", "LC_ALL=C"},
|
||||
OnLine: onLine,
|
||||
})
|
||||
}
|
||||
names := pickTargetNames(opts.Targets, "apt", true)
|
||||
if len(names) == 0 {
|
||||
return nil
|
||||
}
|
||||
privesc := privescBin(opts.UseSudo)
|
||||
argv := append([]string{privesc, "env", "DEBIAN_FRONTEND=noninteractive", "LC_ALL=C", bin, "install", "-y", "--only-upgrade"}, names...)
|
||||
return Run(ctx, argv, RunOptions{OnLine: onLine})
|
||||
}
|
||||
|
||||
func parseAptUpgradable(text string) []Package {
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
var pkgs []Package
|
||||
for line := range strings.SplitSeq(text, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
m := aptUpgradableLine.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
pkgs = append(pkgs, Package{
|
||||
Name: m[1],
|
||||
Repo: RepoSystem,
|
||||
Backend: "apt",
|
||||
FromVersion: m[3],
|
||||
ToVersion: m[2],
|
||||
})
|
||||
}
|
||||
return pkgs
|
||||
}
|
||||
72
core/internal/server/sysupdate/backend_apt_test.go
Normal file
72
core/internal/server/sysupdate/backend_apt_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package sysupdate
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseAptUpgradable(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want []Package
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
input: "",
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "header line only",
|
||||
input: `Listing... Done
|
||||
`,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "single upgradable",
|
||||
input: `Listing... Done
|
||||
bash/stable 5.2.40-1 amd64 [upgradable from: 5.2.39-1]`,
|
||||
want: []Package{
|
||||
{Name: "bash", Repo: RepoSystem, Backend: "apt", FromVersion: "5.2.39-1", ToVersion: "5.2.40-1"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple architectures and suites",
|
||||
input: `Listing... Done
|
||||
bash/stable 5.2.40-1 amd64 [upgradable from: 5.2.39-1]
|
||||
libfoo/stable-security 1.0.0-2 amd64 [upgradable from: 1.0.0-1]
|
||||
zsh/testing 5.9-6 arm64 [upgradable from: 5.9-5]`,
|
||||
want: []Package{
|
||||
{Name: "bash", Repo: RepoSystem, Backend: "apt", FromVersion: "5.2.39-1", ToVersion: "5.2.40-1"},
|
||||
{Name: "libfoo", Repo: RepoSystem, Backend: "apt", FromVersion: "1.0.0-1", ToVersion: "1.0.0-2"},
|
||||
{Name: "zsh", Repo: RepoSystem, Backend: "apt", FromVersion: "5.9-5", ToVersion: "5.9-6"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "package name with hyphens, dots, plus signs",
|
||||
input: `Listing... Done
|
||||
g++/stable 4:13.3.0-1 amd64 [upgradable from: 4:13.2.0-1]
|
||||
libsdl2-2.0-0/stable 2.30.0+dfsg-1 amd64 [upgradable from: 2.28.5+dfsg-1]`,
|
||||
want: []Package{
|
||||
{Name: "g++", Repo: RepoSystem, Backend: "apt", FromVersion: "4:13.2.0-1", ToVersion: "4:13.3.0-1"},
|
||||
{Name: "libsdl2-2.0-0", Repo: RepoSystem, Backend: "apt", FromVersion: "2.28.5+dfsg-1", ToVersion: "2.30.0+dfsg-1"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "non-matching lines ignored",
|
||||
input: "WARNING: this is some warning\nbash/stable 5.2.40-1 amd64 [upgradable from: 5.2.39-1]",
|
||||
want: []Package{
|
||||
{Name: "bash", Repo: RepoSystem, Backend: "apt", FromVersion: "5.2.39-1", ToVersion: "5.2.40-1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := parseAptUpgradable(tt.input)
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("parseAptUpgradable() = %#v\nwant %#v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
126
core/internal/server/sysupdate/backend_dnf.go
Normal file
126
core/internal/server/sysupdate/backend_dnf.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package sysupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func init() {
|
||||
RegisterSystemBackend(func() Backend { return &dnfBackend{bin: "dnf5"} })
|
||||
RegisterSystemBackend(func() Backend { return &dnfBackend{bin: "dnf"} })
|
||||
}
|
||||
|
||||
type dnfBackend struct {
|
||||
bin string
|
||||
}
|
||||
|
||||
func (b dnfBackend) ID() string { return b.bin }
|
||||
func (b dnfBackend) DisplayName() string { return strings.ToUpper(b.bin) }
|
||||
func (b dnfBackend) Repo() RepoKind { return RepoSystem }
|
||||
func (b dnfBackend) NeedsAuth() bool { return true }
|
||||
func (b dnfBackend) RunsInTerminal() bool { return false }
|
||||
|
||||
func (b dnfBackend) IsAvailable(ctx context.Context) bool {
|
||||
if !commandExists(b.bin) {
|
||||
return false
|
||||
}
|
||||
if commandExists("rpm-ostree") && ostreeBooted(ctx) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (b dnfBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
|
||||
out, err := dnfListUpgrades(ctx, b.bin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
installed := rpmInstalledVersions(ctx)
|
||||
return parseDnfList(out, b.bin, installed), nil
|
||||
}
|
||||
|
||||
func (b dnfBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
|
||||
if opts.DryRun {
|
||||
return Run(ctx, []string{b.bin, "upgrade", "--assumeno"}, RunOptions{OnLine: onLine})
|
||||
}
|
||||
names := pickTargetNames(opts.Targets, b.bin, true)
|
||||
if len(names) == 0 {
|
||||
return nil
|
||||
}
|
||||
privesc := privescBin(opts.UseSudo)
|
||||
argv := append([]string{privesc, b.bin, "upgrade", "-y"}, names...)
|
||||
return Run(ctx, argv, RunOptions{OnLine: onLine})
|
||||
}
|
||||
|
||||
func dnfListUpgrades(ctx context.Context, bin string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, bin, "list", "--upgrades", "--refresh", "--quiet")
|
||||
out, err := cmd.Output()
|
||||
if err == nil {
|
||||
return string(out), nil
|
||||
}
|
||||
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && exitErr.ExitCode() == 1 {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
func rpmInstalledVersions(ctx context.Context) map[string]string {
|
||||
out, err := exec.CommandContext(ctx, "rpm", "-qa", "--qf", `%{NAME}\t%{VERSION}-%{RELEASE}\n`).Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
m := make(map[string]string)
|
||||
for line := range strings.SplitSeq(string(out), "\n") {
|
||||
name, ver, ok := strings.Cut(line, "\t")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
m[name] = ver
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func parseDnfList(text, backendID string, installed map[string]string) []Package {
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
var pkgs []Package
|
||||
for line := range strings.SplitSeq(text, "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
nameArch := fields[0]
|
||||
version := fields[1]
|
||||
dot := strings.LastIndex(nameArch, ".")
|
||||
if dot <= 0 {
|
||||
continue
|
||||
}
|
||||
if !looksLikeRpmVersion(version) {
|
||||
continue
|
||||
}
|
||||
name := nameArch[:dot]
|
||||
pkgs = append(pkgs, Package{
|
||||
Name: nameArch,
|
||||
Repo: RepoSystem,
|
||||
Backend: backendID,
|
||||
FromVersion: installed[name],
|
||||
ToVersion: version,
|
||||
})
|
||||
}
|
||||
return pkgs
|
||||
}
|
||||
|
||||
func looksLikeRpmVersion(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if r >= '0' && r <= '9' {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
80
core/internal/server/sysupdate/backend_dnf_test.go
Normal file
80
core/internal/server/sysupdate/backend_dnf_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package sysupdate
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseDnfList(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
backendID string
|
||||
installed map[string]string
|
||||
want []Package
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
input: "",
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "single package with installed cross-ref",
|
||||
input: "bash.x86_64 5.2.40-1.fc41 updates",
|
||||
backendID: "dnf",
|
||||
installed: map[string]string{"bash": "5.2.39-1.fc41"},
|
||||
want: []Package{
|
||||
{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf", FromVersion: "5.2.39-1.fc41", ToVersion: "5.2.40-1.fc41"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "noarch package and missing installed entry",
|
||||
input: `bash.x86_64 5.2.40-1.fc41 updates
|
||||
fonts-misc.noarch 1.0.5-2.fc41 updates`,
|
||||
backendID: "dnf",
|
||||
installed: map[string]string{"bash": "5.2.39-1.fc41"},
|
||||
want: []Package{
|
||||
{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf", FromVersion: "5.2.39-1.fc41", ToVersion: "5.2.40-1.fc41"},
|
||||
{Name: "fonts-misc.noarch", Repo: RepoSystem, Backend: "dnf", FromVersion: "", ToVersion: "1.0.5-2.fc41"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "skips header rows",
|
||||
input: `Available
|
||||
Upgrades
|
||||
bash.x86_64 5.2.40-1.fc41 updates`,
|
||||
backendID: "dnf",
|
||||
installed: nil,
|
||||
want: []Package{
|
||||
{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf", FromVersion: "", ToVersion: "5.2.40-1.fc41"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "skips lines with too few fields",
|
||||
input: "incomplete",
|
||||
backendID: "dnf",
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "skips dnf5 banner / column header lines",
|
||||
input: `Updates available
|
||||
Last metadata expiration check: 0:01:23 ago on Tue Apr 29 14:00:00 2026.
|
||||
Package Version Repository Size
|
||||
bash.x86_64 5.2.40-1.fc41 updates`,
|
||||
backendID: "dnf",
|
||||
installed: nil,
|
||||
want: []Package{
|
||||
{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf", FromVersion: "", ToVersion: "5.2.40-1.fc41"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := parseDnfList(tt.input, tt.backendID, tt.installed)
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("parseDnfList() = %#v\nwant %#v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
169
core/internal/server/sysupdate/backend_flatpak.go
Normal file
169
core/internal/server/sysupdate/backend_flatpak.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package sysupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func init() {
|
||||
RegisterOverlayBackend(func() Backend { return &flatpakBackend{} })
|
||||
}
|
||||
|
||||
type flatpakBackend struct{}
|
||||
|
||||
func (flatpakBackend) ID() string { return "flatpak" }
|
||||
func (flatpakBackend) DisplayName() string { return "Flatpak" }
|
||||
func (flatpakBackend) Repo() RepoKind { return RepoFlatpak }
|
||||
func (flatpakBackend) NeedsAuth() bool { return false }
|
||||
func (flatpakBackend) RunsInTerminal() bool { return false }
|
||||
func (flatpakBackend) IsAvailable(_ context.Context) bool { return commandExists("flatpak") }
|
||||
|
||||
func (flatpakBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
|
||||
cmd := exec.CommandContext(ctx, "flatpak", "remote-ls", "--updates", "--columns=application,version,branch,commit,name")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
installed := flatpakInstalled(ctx)
|
||||
return parseFlatpakUpdates(string(out), installed), nil
|
||||
}
|
||||
|
||||
func flatpakInstalled(ctx context.Context) map[string]flatpakInstalledEntry {
|
||||
out, err := exec.CommandContext(ctx, "flatpak", "list", "--columns=application,version,branch,active").Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
m := make(map[string]flatpakInstalledEntry)
|
||||
for line := range strings.SplitSeq(string(out), "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
fields := strings.Split(line, "\t")
|
||||
if len(fields) == 0 || fields[0] == "" {
|
||||
continue
|
||||
}
|
||||
appID := fields[0]
|
||||
entry := flatpakInstalledEntry{}
|
||||
if len(fields) > 1 {
|
||||
entry.version = fields[1]
|
||||
}
|
||||
if len(fields) > 2 {
|
||||
entry.branch = fields[2]
|
||||
}
|
||||
if len(fields) > 3 {
|
||||
entry.commit = fields[3]
|
||||
}
|
||||
key := appID
|
||||
if entry.branch != "" {
|
||||
key = appID + "//" + entry.branch
|
||||
}
|
||||
m[key] = entry
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
type flatpakInstalledEntry struct {
|
||||
version string
|
||||
branch string
|
||||
commit string
|
||||
}
|
||||
|
||||
func (flatpakBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
|
||||
if opts.DryRun {
|
||||
return Run(ctx, []string{"flatpak", "update", "--no-deploy", "-y"}, RunOptions{OnLine: onLine})
|
||||
}
|
||||
refs := flatpakTargetRefs(opts.Targets)
|
||||
if len(refs) == 0 {
|
||||
return nil
|
||||
}
|
||||
argv := append([]string{"flatpak", "update", "-y", "--noninteractive"}, refs...)
|
||||
return Run(ctx, argv, RunOptions{OnLine: onLine})
|
||||
}
|
||||
|
||||
func flatpakTargetRefs(targets []Package) []string {
|
||||
out := make([]string, 0, len(targets))
|
||||
for _, p := range targets {
|
||||
if p.Backend != "flatpak" {
|
||||
continue
|
||||
}
|
||||
ref := p.Ref
|
||||
if ref == "" {
|
||||
ref = p.Name
|
||||
}
|
||||
out = append(out, ref)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseFlatpakUpdates(text string, installed map[string]flatpakInstalledEntry) []Package {
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
var pkgs []Package
|
||||
for line := range strings.SplitSeq(text, "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
fields := strings.Split(line, "\t")
|
||||
if len(fields) == 0 || fields[0] == "" {
|
||||
continue
|
||||
}
|
||||
appID := fields[0]
|
||||
version, branch, commit := "", "", ""
|
||||
if len(fields) > 1 {
|
||||
version = fields[1]
|
||||
}
|
||||
if len(fields) > 2 {
|
||||
branch = fields[2]
|
||||
}
|
||||
if len(fields) > 3 {
|
||||
commit = fields[3]
|
||||
}
|
||||
display := appID
|
||||
if len(fields) > 4 && fields[4] != "" {
|
||||
display = fields[4]
|
||||
}
|
||||
|
||||
key := appID
|
||||
if branch != "" {
|
||||
key = appID + "//" + branch
|
||||
}
|
||||
inst := installed[key]
|
||||
|
||||
if inst.commit != "" && commit != "" && strings.HasPrefix(commit, inst.commit) {
|
||||
continue
|
||||
}
|
||||
|
||||
from, to := flatpakVersionPair(inst.version, inst.commit, version, commit)
|
||||
|
||||
ref := appID
|
||||
if branch != "" {
|
||||
ref = appID + "//" + branch
|
||||
}
|
||||
|
||||
pkgs = append(pkgs, Package{
|
||||
Name: display,
|
||||
Repo: RepoFlatpak,
|
||||
Backend: "flatpak",
|
||||
FromVersion: from,
|
||||
ToVersion: to,
|
||||
Ref: ref,
|
||||
})
|
||||
}
|
||||
return pkgs
|
||||
}
|
||||
|
||||
func flatpakVersionPair(installedVer, installedCommit, remoteVer, remoteCommit string) (from, to string) {
|
||||
if remoteVer != "" {
|
||||
return installedVer, remoteVer
|
||||
}
|
||||
return shortCommit(installedCommit), shortCommit(remoteCommit)
|
||||
}
|
||||
|
||||
func shortCommit(c string) string {
|
||||
if len(c) > 8 {
|
||||
return c[:8]
|
||||
}
|
||||
return c
|
||||
}
|
||||
150
core/internal/server/sysupdate/backend_flatpak_test.go
Normal file
150
core/internal/server/sysupdate/backend_flatpak_test.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package sysupdate
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseFlatpakUpdates(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
installed map[string]flatpakInstalledEntry
|
||||
want []Package
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
input: "",
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "real flathub-style row with empty version, falls back to commit",
|
||||
// columns: application,version,branch,commit,name
|
||||
input: "com.discordapp.Discord\t\tstable\t43a1e5d2d3a446919356fd86d9f984ad7c6a0e20f109250d9d868223f26ca586\tDiscord",
|
||||
installed: map[string]flatpakInstalledEntry{
|
||||
"com.discordapp.Discord//stable": {commit: "8b16fa1a9b2aa189302c2428c8a7bb33dd050faf7e535dd1d975044cb0986855"},
|
||||
},
|
||||
want: []Package{
|
||||
{
|
||||
Name: "Discord",
|
||||
Repo: RepoFlatpak,
|
||||
Backend: "flatpak",
|
||||
FromVersion: "8b16fa1a",
|
||||
ToVersion: "43a1e5d2",
|
||||
Ref: "com.discordapp.Discord//stable",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "remote provides version, installed version known",
|
||||
input: "com.example.App\t1.5.0\tstable\tdeadbeefcafe\tExample App",
|
||||
installed: map[string]flatpakInstalledEntry{
|
||||
"com.example.App//stable": {version: "1.4.2"},
|
||||
},
|
||||
want: []Package{
|
||||
{
|
||||
Name: "Example App",
|
||||
Repo: RepoFlatpak,
|
||||
Backend: "flatpak",
|
||||
FromVersion: "1.4.2",
|
||||
ToVersion: "1.5.0",
|
||||
Ref: "com.example.App//stable",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no installed entry, remote has no version, falls back to commit on both sides",
|
||||
input: "org.gnome.Platform\t\t49\tbadcd4afb1fe\tgnome platform",
|
||||
installed: nil,
|
||||
want: []Package{
|
||||
{
|
||||
Name: "gnome platform",
|
||||
Repo: RepoFlatpak,
|
||||
Backend: "flatpak",
|
||||
FromVersion: "",
|
||||
ToVersion: "badcd4af",
|
||||
Ref: "org.gnome.Platform//49",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing display name falls back to application id",
|
||||
input: "com.example.NoName\t2.0\tstable\tabcdef123456\t",
|
||||
want: []Package{
|
||||
{
|
||||
Name: "com.example.NoName",
|
||||
Repo: RepoFlatpak,
|
||||
Backend: "flatpak",
|
||||
FromVersion: "",
|
||||
ToVersion: "2.0",
|
||||
Ref: "com.example.NoName//stable",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "skips blank lines and rows with empty application id",
|
||||
input: "\n\t\t\t\t\norg.real.App\t1.0\tstable\tdeadbeef\tReal App",
|
||||
want: []Package{
|
||||
{
|
||||
Name: "Real App",
|
||||
Repo: RepoFlatpak,
|
||||
Backend: "flatpak",
|
||||
FromVersion: "",
|
||||
ToVersion: "1.0",
|
||||
Ref: "org.real.App//stable",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "skips phantom updates where remote commit matches installed",
|
||||
input: "com.phantom.App\t\tstable\tabc12345deadbeef\tPhantom",
|
||||
installed: map[string]flatpakInstalledEntry{
|
||||
"com.phantom.App//stable": {commit: "abc12345"},
|
||||
},
|
||||
want: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := parseFlatpakUpdates(tt.input, tt.installed)
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("parseFlatpakUpdates() = %#v\nwant %#v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlatpakVersionPair(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
installedVer, installedCommit, remoteVer, remoteCommit string
|
||||
wantFrom, wantTo string
|
||||
}{
|
||||
{
|
||||
name: "remote has version - prefer versions",
|
||||
installedVer: "1.0.0", remoteVer: "1.1.0",
|
||||
wantFrom: "1.0.0", wantTo: "1.1.0",
|
||||
},
|
||||
{
|
||||
name: "remote has no version - both sides fall to short commit",
|
||||
installedCommit: "8b16fa1a9b2aa189302c2428c8a7bb33dd050faf7e535dd1d975044cb0986855",
|
||||
remoteCommit: "43a1e5d2d3a446919356fd86d9f984ad7c6a0e20f109250d9d868223f26ca586",
|
||||
wantFrom: "8b16fa1a", wantTo: "43a1e5d2",
|
||||
},
|
||||
{
|
||||
name: "short commits left as-is",
|
||||
installedCommit: "abc123", remoteCommit: "def456",
|
||||
wantFrom: "abc123", wantTo: "def456",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
from, to := flatpakVersionPair(tt.installedVer, tt.installedCommit, tt.remoteVer, tt.remoteCommit)
|
||||
if from != tt.wantFrom || to != tt.wantTo {
|
||||
t.Errorf("flatpakVersionPair() = (%q, %q), want (%q, %q)", from, to, tt.wantFrom, tt.wantTo)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
259
core/internal/server/sysupdate/backend_pacman.go
Normal file
259
core/internal/server/sysupdate/backend_pacman.go
Normal file
@@ -0,0 +1,259 @@
|
||||
package sysupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
)
|
||||
|
||||
func init() {
|
||||
RegisterSystemBackend(func() Backend { return &archHelperBackend{id: "paru"} })
|
||||
RegisterSystemBackend(func() Backend { return &archHelperBackend{id: "yay"} })
|
||||
RegisterSystemBackend(func() Backend { return &pacmanBackend{} })
|
||||
}
|
||||
|
||||
var archUpdateLine = regexp.MustCompile(`^(\S+)\s+(\S+)\s+->\s+(\S+)`)
|
||||
|
||||
type pacmanBackend struct{}
|
||||
|
||||
func (pacmanBackend) ID() string { return "pacman" }
|
||||
func (pacmanBackend) DisplayName() string { return "Pacman" }
|
||||
func (pacmanBackend) Repo() RepoKind { return RepoSystem }
|
||||
func (pacmanBackend) NeedsAuth() bool { return true }
|
||||
func (pacmanBackend) RunsInTerminal() bool { return false }
|
||||
func (pacmanBackend) IsAvailable(_ context.Context) bool { return commandExists("pacman") }
|
||||
|
||||
func (b pacmanBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
|
||||
out, err := pacmanRepoUpdates(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseArchUpdates(out, b.ID(), RepoSystem), nil
|
||||
}
|
||||
|
||||
func (b pacmanBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
|
||||
if opts.DryRun {
|
||||
return Run(ctx, []string{"pacman", "-Sup"}, RunOptions{OnLine: onLine})
|
||||
}
|
||||
names := pickTargetNames(opts.Targets, b.ID(), opts.IncludeAUR)
|
||||
if len(names) == 0 {
|
||||
return nil
|
||||
}
|
||||
privesc := privescBin(opts.UseSudo)
|
||||
argv := append([]string{privesc, "pacman", "-Sy", "--noconfirm", "--needed"}, names...)
|
||||
return Run(ctx, argv, RunOptions{OnLine: onLine})
|
||||
}
|
||||
|
||||
type archHelperBackend struct {
|
||||
id string
|
||||
}
|
||||
|
||||
func (b archHelperBackend) ID() string { return b.id }
|
||||
func (b archHelperBackend) Repo() RepoKind { return RepoSystem }
|
||||
func (b archHelperBackend) NeedsAuth() bool { return true }
|
||||
func (b archHelperBackend) RunsInTerminal() bool {
|
||||
return os.Getenv("DMS_FORCE_PKEXEC") != "1"
|
||||
}
|
||||
func (b archHelperBackend) IsAvailable(_ context.Context) bool { return commandExists(b.id) }
|
||||
|
||||
func (b archHelperBackend) DisplayName() string {
|
||||
switch b.id {
|
||||
case "paru":
|
||||
return "Paru (AUR)"
|
||||
case "yay":
|
||||
return "Yay (AUR)"
|
||||
default:
|
||||
return b.id
|
||||
}
|
||||
}
|
||||
|
||||
func (b archHelperBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
|
||||
repoOut, err := pacmanRepoUpdates(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pkgs := parseArchUpdates(repoOut, b.id, RepoSystem)
|
||||
|
||||
aurOut, err := capturePermissive(ctx, b.id, "-Qua")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pkgs = append(pkgs, parseArchUpdates(aurOut, b.id, RepoAUR)...)
|
||||
return pkgs, nil
|
||||
}
|
||||
|
||||
func (b archHelperBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
|
||||
if opts.DryRun {
|
||||
return Run(ctx, []string{b.id, "-Sup"}, RunOptions{OnLine: onLine})
|
||||
}
|
||||
names := pickTargetNames(opts.Targets, b.id, opts.IncludeAUR)
|
||||
if len(names) == 0 {
|
||||
return nil
|
||||
}
|
||||
if os.Getenv("DMS_FORCE_PKEXEC") == "1" {
|
||||
argv := append([]string{"pkexec", b.id, "-Sy", "--noconfirm", "--needed"}, names...)
|
||||
return Run(ctx, argv, RunOptions{OnLine: onLine})
|
||||
}
|
||||
term := findTerminal(opts.Terminal)
|
||||
if term == "" {
|
||||
return fmt.Errorf("no terminal found (pick one in DMS settings, set $TERMINAL, or install kitty/ghostty/foot/alacritty)")
|
||||
}
|
||||
cmd := fmt.Sprintf("%s -Sy --noconfirm --needed %s", b.id, strings.Join(names, " "))
|
||||
title := fmt.Sprintf("DMS — System Update (%s)", b.id)
|
||||
return Run(ctx, wrapInTerminal(term, title, cmd), RunOptions{OnLine: onLine})
|
||||
}
|
||||
|
||||
func pickTargetNames(targets []Package, backendID string, includeAUR bool) []string {
|
||||
out := make([]string, 0, len(targets))
|
||||
for _, p := range targets {
|
||||
if p.Backend != backendID {
|
||||
continue
|
||||
}
|
||||
if !includeAUR && p.Repo == RepoAUR {
|
||||
continue
|
||||
}
|
||||
out = append(out, p.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func pacmanRepoUpdates(ctx context.Context) (string, error) {
|
||||
if commandExists("checkupdates") {
|
||||
return capturePermissive(ctx, "checkupdates")
|
||||
}
|
||||
if commandExists("fakeroot") {
|
||||
out, err := pacmanCheckViaFakeroot(ctx)
|
||||
if err == nil {
|
||||
return out, nil
|
||||
}
|
||||
log.Warnf("[sysupdate] fakeroot db refresh failed, falling back to stale pacman -Qu: %v", err)
|
||||
}
|
||||
return capturePermissive(ctx, "pacman", "-Qu")
|
||||
}
|
||||
|
||||
func pacmanCheckViaFakeroot(ctx context.Context) (string, error) {
|
||||
dir, err := pacmanPrivateDB()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := seedPacmanDB(dir); err != nil {
|
||||
return "", fmt.Errorf("seed sync db: %w", err)
|
||||
}
|
||||
|
||||
refresh := exec.CommandContext(ctx, "fakeroot", "--", "pacman", "-Sy", "--dbpath", dir, "--logfile", "/dev/null", "--disable-sandbox")
|
||||
if out, err := refresh.CombinedOutput(); err != nil {
|
||||
return "", fmt.Errorf("fakeroot pacman -Sy: %w (%s)", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
|
||||
return capturePermissive(ctx, "pacman", "-Qu", "--dbpath", dir)
|
||||
}
|
||||
|
||||
func seedPacmanDB(dir string) error {
|
||||
syncDir := filepath.Join(dir, "sync")
|
||||
if err := os.MkdirAll(syncDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
dbs, err := filepath.Glob("/var/lib/pacman/sync/*.db")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, src := range dbs {
|
||||
if err := copyFile(src, filepath.Join(syncDir, filepath.Base(src))); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
localLink := filepath.Join(dir, "local")
|
||||
if fi, err := os.Lstat(localLink); err == nil {
|
||||
if fi.Mode()&os.ModeSymlink == 0 {
|
||||
if err := os.RemoveAll(localLink); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return os.Symlink("/var/lib/pacman/local", localLink)
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
return err
|
||||
}
|
||||
return out.Sync()
|
||||
}
|
||||
|
||||
func pacmanPrivateDB() (string, error) {
|
||||
tmp := os.Getenv("TMPDIR")
|
||||
if tmp == "" {
|
||||
tmp = "/tmp"
|
||||
}
|
||||
dir := filepath.Join(tmp, fmt.Sprintf("dms-checkup-db-%d", os.Getuid()))
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
func capturePermissive(ctx context.Context, argv ...string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
|
||||
out, err := cmd.Output()
|
||||
if err == nil {
|
||||
return string(out), nil
|
||||
}
|
||||
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
|
||||
switch exitErr.ExitCode() {
|
||||
case 1, 2:
|
||||
return string(out), nil
|
||||
}
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
func parseArchUpdates(text, backendID string, repo RepoKind) []Package {
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
var pkgs []Package
|
||||
for line := range strings.SplitSeq(text, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
m := archUpdateLine.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
p := Package{
|
||||
Name: m[1],
|
||||
Repo: repo,
|
||||
Backend: backendID,
|
||||
FromVersion: m[2],
|
||||
ToVersion: m[3],
|
||||
}
|
||||
if repo == RepoAUR {
|
||||
p.ChangelogURL = "https://aur.archlinux.org/packages/" + p.Name
|
||||
}
|
||||
pkgs = append(pkgs, p)
|
||||
}
|
||||
return pkgs
|
||||
}
|
||||
114
core/internal/server/sysupdate/backend_pacman_test.go
Normal file
114
core/internal/server/sysupdate/backend_pacman_test.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package sysupdate
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseArchUpdates(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
backendID string
|
||||
repo RepoKind
|
||||
want []Package
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
input: "",
|
||||
backendID: "paru",
|
||||
repo: RepoSystem,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "whitespace only",
|
||||
input: " \n\n \n",
|
||||
backendID: "paru",
|
||||
repo: RepoSystem,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "single repo update",
|
||||
input: "bat 0.26.0-1 -> 0.26.1-2",
|
||||
backendID: "paru",
|
||||
repo: RepoSystem,
|
||||
want: []Package{
|
||||
{Name: "bat", Repo: RepoSystem, Backend: "paru", FromVersion: "0.26.0-1", ToVersion: "0.26.1-2"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple updates with epoch versions",
|
||||
input: `cups 2:2.4.18-1 -> 2:2.4.19-1
|
||||
linux 6.18.0-1 -> 6.18.1-1
|
||||
mesa 26.4.0-1 -> 26.4.1-1`,
|
||||
backendID: "paru",
|
||||
repo: RepoSystem,
|
||||
want: []Package{
|
||||
{Name: "cups", Repo: RepoSystem, Backend: "paru", FromVersion: "2:2.4.18-1", ToVersion: "2:2.4.19-1"},
|
||||
{Name: "linux", Repo: RepoSystem, Backend: "paru", FromVersion: "6.18.0-1", ToVersion: "6.18.1-1"},
|
||||
{Name: "mesa", Repo: RepoSystem, Backend: "paru", FromVersion: "26.4.0-1", ToVersion: "26.4.1-1"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "AUR update with changelog url",
|
||||
input: "google-chrome 147.0.7727.116-1 -> 147.0.7727.137-1",
|
||||
backendID: "paru",
|
||||
repo: RepoAUR,
|
||||
want: []Package{
|
||||
{
|
||||
Name: "google-chrome",
|
||||
Repo: RepoAUR,
|
||||
Backend: "paru",
|
||||
FromVersion: "147.0.7727.116-1",
|
||||
ToVersion: "147.0.7727.137-1",
|
||||
ChangelogURL: "https://aur.archlinux.org/packages/google-chrome",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "git package latest-commit marker",
|
||||
input: "niri-git 26.04.r5.ga85b922-1 -> latest-commit",
|
||||
backendID: "yay",
|
||||
repo: RepoAUR,
|
||||
want: []Package{
|
||||
{
|
||||
Name: "niri-git",
|
||||
Repo: RepoAUR,
|
||||
Backend: "yay",
|
||||
FromVersion: "26.04.r5.ga85b922-1",
|
||||
ToVersion: "latest-commit",
|
||||
ChangelogURL: "https://aur.archlinux.org/packages/niri-git",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "skips lines that don't match arrow format",
|
||||
input: `bat 0.26.0-1 -> 0.26.1-2
|
||||
this is not an update line
|
||||
foo`,
|
||||
backendID: "pacman",
|
||||
repo: RepoSystem,
|
||||
want: []Package{
|
||||
{Name: "bat", Repo: RepoSystem, Backend: "pacman", FromVersion: "0.26.0-1", ToVersion: "0.26.1-2"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "extra whitespace tolerated",
|
||||
input: " bat 0.26.0-1 -> 0.26.1-2 ",
|
||||
backendID: "paru",
|
||||
repo: RepoSystem,
|
||||
want: []Package{
|
||||
{Name: "bat", Repo: RepoSystem, Backend: "paru", FromVersion: "0.26.0-1", ToVersion: "0.26.1-2"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := parseArchUpdates(tt.input, tt.backendID, tt.repo)
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("parseArchUpdates() = %#v\nwant %#v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
125
core/internal/server/sysupdate/backend_rpmostree.go
Normal file
125
core/internal/server/sysupdate/backend_rpmostree.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package sysupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
const ostreeExitUpdateAvailable = 77
|
||||
|
||||
func init() {
|
||||
RegisterSystemBackend(func() Backend { return &rpmOstreeBackend{} })
|
||||
}
|
||||
|
||||
type rpmOstreeBackend struct{}
|
||||
|
||||
func (rpmOstreeBackend) ID() string { return "rpm-ostree" }
|
||||
func (rpmOstreeBackend) DisplayName() string { return "rpm-ostree" }
|
||||
func (rpmOstreeBackend) Repo() RepoKind { return RepoOSTree }
|
||||
func (rpmOstreeBackend) NeedsAuth() bool { return true }
|
||||
func (rpmOstreeBackend) RunsInTerminal() bool { return false }
|
||||
|
||||
func (b rpmOstreeBackend) IsAvailable(ctx context.Context) bool {
|
||||
if !commandExists("rpm-ostree") {
|
||||
return false
|
||||
}
|
||||
return ostreeBooted(ctx)
|
||||
}
|
||||
|
||||
type ostreeStatus struct {
|
||||
Deployments []ostreeDeployment `json:"deployments"`
|
||||
CachedUpdate *ostreeCached `json:"cached-update"`
|
||||
}
|
||||
|
||||
type ostreeDeployment struct {
|
||||
Origin string `json:"origin"`
|
||||
Version string `json:"version"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Booted bool `json:"booted"`
|
||||
}
|
||||
|
||||
type ostreeCached struct {
|
||||
Origin string `json:"origin"`
|
||||
Version string `json:"version"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Checksum string `json:"checksum"`
|
||||
}
|
||||
|
||||
func ostreeBooted(ctx context.Context) bool {
|
||||
cmd := exec.CommandContext(ctx, "rpm-ostree", "status", "--json")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
var s ostreeStatus
|
||||
if err := json.Unmarshal(out, &s); err != nil {
|
||||
return false
|
||||
}
|
||||
return len(s.Deployments) > 0
|
||||
}
|
||||
|
||||
func (rpmOstreeBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
|
||||
cmd := exec.CommandContext(ctx, "rpm-ostree", "upgrade", "--check")
|
||||
if err := cmd.Run(); err != nil {
|
||||
exitErr, ok := errors.AsType[*exec.ExitError](err)
|
||||
if !ok || exitErr.ExitCode() != ostreeExitUpdateAvailable {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
statusOut, err := exec.CommandContext(ctx, "rpm-ostree", "status", "--json").Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseRpmOstreeStatus(statusOut)
|
||||
}
|
||||
|
||||
func parseRpmOstreeStatus(statusOut []byte) ([]Package, error) {
|
||||
var s ostreeStatus
|
||||
if err := json.Unmarshal(statusOut, &s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.CachedUpdate == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
booted := bootedDeployment(s.Deployments)
|
||||
from := ""
|
||||
if booted != nil {
|
||||
from = booted.Version
|
||||
}
|
||||
if from == s.CachedUpdate.Version {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
name := s.CachedUpdate.Origin
|
||||
if name == "" {
|
||||
name = "system"
|
||||
}
|
||||
return []Package{{
|
||||
Name: name,
|
||||
Repo: RepoOSTree,
|
||||
Backend: "rpm-ostree",
|
||||
FromVersion: from,
|
||||
ToVersion: s.CachedUpdate.Version,
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func bootedDeployment(deps []ostreeDeployment) *ostreeDeployment {
|
||||
for i := range deps {
|
||||
if deps[i].Booted {
|
||||
return &deps[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rpmOstreeBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
|
||||
argv := []string{"rpm-ostree", "upgrade"}
|
||||
if opts.DryRun {
|
||||
argv = append(argv, "--check")
|
||||
}
|
||||
return Run(ctx, argv, RunOptions{OnLine: onLine})
|
||||
}
|
||||
104
core/internal/server/sysupdate/backend_rpmostree_test.go
Normal file
104
core/internal/server/sysupdate/backend_rpmostree_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package sysupdate
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseRpmOstreeStatus(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want []Package
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "no cached update",
|
||||
input: `{"deployments":[{"version":"39.20240101.0","booted":true}],"cached-update":null}`,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "cached update available, booted version differs",
|
||||
input: `{
|
||||
"deployments": [
|
||||
{"origin": "fedora:fedora/x86_64/silverblue", "version": "39.20240101.0", "booted": true},
|
||||
{"origin": "fedora:fedora/x86_64/silverblue", "version": "39.20231215.0", "booted": false}
|
||||
],
|
||||
"cached-update": {
|
||||
"origin": "fedora:fedora/x86_64/silverblue",
|
||||
"version": "39.20240115.0",
|
||||
"checksum": "abc123"
|
||||
}
|
||||
}`,
|
||||
want: []Package{
|
||||
{
|
||||
Name: "fedora:fedora/x86_64/silverblue",
|
||||
Repo: RepoOSTree,
|
||||
Backend: "rpm-ostree",
|
||||
FromVersion: "39.20240101.0",
|
||||
ToVersion: "39.20240115.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "cached update equals booted version (no real update)",
|
||||
input: `{
|
||||
"deployments": [{"version": "39.20240101.0", "booted": true}],
|
||||
"cached-update": {"origin": "x", "version": "39.20240101.0"}
|
||||
}`,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "no booted deployment falls back to empty from",
|
||||
input: `{
|
||||
"deployments": [{"version": "39.20240101.0", "booted": false}],
|
||||
"cached-update": {"origin": "fedora:silverblue", "version": "39.20240115.0"}
|
||||
}`,
|
||||
want: []Package{
|
||||
{
|
||||
Name: "fedora:silverblue",
|
||||
Repo: RepoOSTree,
|
||||
Backend: "rpm-ostree",
|
||||
FromVersion: "",
|
||||
ToVersion: "39.20240115.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing origin defaults to system",
|
||||
input: `{
|
||||
"deployments": [{"version": "1.0", "booted": true}],
|
||||
"cached-update": {"version": "1.1"}
|
||||
}`,
|
||||
want: []Package{
|
||||
{
|
||||
Name: "system",
|
||||
Repo: RepoOSTree,
|
||||
Backend: "rpm-ostree",
|
||||
FromVersion: "1.0",
|
||||
ToVersion: "1.1",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "malformed JSON",
|
||||
input: `{not json`,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := parseRpmOstreeStatus([]byte(tt.input))
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("parseRpmOstreeStatus() err = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if tt.wantErr {
|
||||
return
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("parseRpmOstreeStatus() = %#v\nwant %#v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
84
core/internal/server/sysupdate/backend_zypper.go
Normal file
84
core/internal/server/sysupdate/backend_zypper.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package sysupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
func init() {
|
||||
RegisterSystemBackend(func() Backend { return &zypperBackend{} })
|
||||
}
|
||||
|
||||
type zypperBackend struct{}
|
||||
|
||||
func (zypperBackend) ID() string { return "zypper" }
|
||||
func (zypperBackend) DisplayName() string { return "Zypper" }
|
||||
func (zypperBackend) Repo() RepoKind { return RepoSystem }
|
||||
func (zypperBackend) NeedsAuth() bool { return true }
|
||||
func (zypperBackend) RunsInTerminal() bool { return false }
|
||||
func (zypperBackend) IsAvailable(_ context.Context) bool { return commandExists("zypper") }
|
||||
|
||||
type zypperUpdateList struct {
|
||||
XMLName xml.Name `xml:"stream"`
|
||||
Updates []zypperUpdate `xml:"update-list>update"`
|
||||
}
|
||||
|
||||
type zypperUpdate struct {
|
||||
Name string `xml:"name,attr"`
|
||||
Edition string `xml:"edition,attr"`
|
||||
EditionOld string `xml:"edition-old,attr"`
|
||||
Kind string `xml:"kind,attr"`
|
||||
}
|
||||
|
||||
func (zypperBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
|
||||
cmd := exec.CommandContext(ctx, "zypper", "--non-interactive", "--xmlout", "list-updates")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
|
||||
switch exitErr.ExitCode() {
|
||||
case 100, 101, 102, 103:
|
||||
err = nil
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return parseZypperXML(out)
|
||||
}
|
||||
|
||||
func parseZypperXML(out []byte) ([]Package, error) {
|
||||
var list zypperUpdateList
|
||||
if err := xml.Unmarshal(out, &list); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pkgs := make([]Package, 0, len(list.Updates))
|
||||
for _, u := range list.Updates {
|
||||
if u.Kind != "" && u.Kind != "package" {
|
||||
continue
|
||||
}
|
||||
pkgs = append(pkgs, Package{
|
||||
Name: u.Name,
|
||||
Repo: RepoSystem,
|
||||
Backend: "zypper",
|
||||
FromVersion: u.EditionOld,
|
||||
ToVersion: u.Edition,
|
||||
})
|
||||
}
|
||||
return pkgs, nil
|
||||
}
|
||||
|
||||
func (zypperBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
|
||||
if opts.DryRun {
|
||||
return Run(ctx, []string{"zypper", "--non-interactive", "--dry-run", "update"}, RunOptions{OnLine: onLine})
|
||||
}
|
||||
names := pickTargetNames(opts.Targets, "zypper", true)
|
||||
if len(names) == 0 {
|
||||
return nil
|
||||
}
|
||||
privesc := privescBin(opts.UseSudo)
|
||||
argv := append([]string{privesc, "zypper", "--non-interactive", "update"}, names...)
|
||||
return Run(ctx, argv, RunOptions{OnLine: onLine})
|
||||
}
|
||||
80
core/internal/server/sysupdate/backend_zypper_test.go
Normal file
80
core/internal/server/sysupdate/backend_zypper_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package sysupdate
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseZypperXML(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want []Package
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "empty stream",
|
||||
input: `<?xml version="1.0"?><stream><update-list></update-list></stream>`,
|
||||
want: []Package{},
|
||||
},
|
||||
{
|
||||
name: "single package update",
|
||||
input: `<?xml version="1.0"?>
|
||||
<stream>
|
||||
<update-list>
|
||||
<update name="zsh" edition="5.9-6" edition-old="5.9-5" kind="package" arch="x86_64">
|
||||
<source url="https://download.opensuse.org/" alias="repo-oss"/>
|
||||
</update>
|
||||
</update-list>
|
||||
</stream>`,
|
||||
want: []Package{
|
||||
{Name: "zsh", Repo: RepoSystem, Backend: "zypper", FromVersion: "5.9-5", ToVersion: "5.9-6"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "skips non-package kinds",
|
||||
input: `<?xml version="1.0"?>
|
||||
<stream>
|
||||
<update-list>
|
||||
<update name="foo" edition="2.0" edition-old="1.0" kind="package"/>
|
||||
<update name="security-patch" edition="1" edition-old="0" kind="patch"/>
|
||||
<update name="bar" edition="3.0" edition-old="2.0" kind="package"/>
|
||||
</update-list>
|
||||
</stream>`,
|
||||
want: []Package{
|
||||
{Name: "foo", Repo: RepoSystem, Backend: "zypper", FromVersion: "1.0", ToVersion: "2.0"},
|
||||
{Name: "bar", Repo: RepoSystem, Backend: "zypper", FromVersion: "2.0", ToVersion: "3.0"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "treats missing kind as package",
|
||||
input: `<?xml version="1.0"?>
|
||||
<stream><update-list>
|
||||
<update name="kernel" edition="6.18.1-1" edition-old="6.18.0-1"/>
|
||||
</update-list></stream>`,
|
||||
want: []Package{
|
||||
{Name: "kernel", Repo: RepoSystem, Backend: "zypper", FromVersion: "6.18.0-1", ToVersion: "6.18.1-1"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "malformed XML returns error",
|
||||
input: `not xml at all`,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := parseZypperXML([]byte(tt.input))
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("parseZypperXML() err = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if tt.wantErr {
|
||||
return
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("parseZypperXML() = %#v\nwant %#v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
139
core/internal/server/sysupdate/executor.go
Normal file
139
core/internal/server/sysupdate/executor.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package sysupdate
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||
)
|
||||
|
||||
type RunOptions struct {
|
||||
Env []string
|
||||
OnLine func(string)
|
||||
}
|
||||
|
||||
func Run(ctx context.Context, argv []string, opts RunOptions) error {
|
||||
if len(argv) == 0 {
|
||||
return fmt.Errorf("sysupdate.Run: empty argv")
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
|
||||
if len(opts.Env) > 0 {
|
||||
cmd.Env = append(cmd.Environ(), opts.Env...)
|
||||
}
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
cmd.Cancel = func() error {
|
||||
if cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
}
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go pump(stdout, opts.OnLine, &wg)
|
||||
go pump(stderr, opts.OnLine, &wg)
|
||||
wg.Wait()
|
||||
|
||||
return cmd.Wait()
|
||||
}
|
||||
|
||||
func pump(r io.Reader, onLine func(string), wg *sync.WaitGroup) {
|
||||
defer wg.Done()
|
||||
if onLine == nil {
|
||||
_, _ = io.Copy(io.Discard, r)
|
||||
return
|
||||
}
|
||||
scanner := bufio.NewScanner(r)
|
||||
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
onLine(scanner.Text())
|
||||
}
|
||||
}
|
||||
|
||||
func Capture(ctx context.Context, argv []string) (string, error) {
|
||||
if len(argv) == 0 {
|
||||
return "", fmt.Errorf("sysupdate.Capture: empty argv")
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
|
||||
out, err := cmd.Output()
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
// privescBin returns the binary to use for privilege escalation.
|
||||
// When useSudo is true it auto-detects the best available tool (sudo/doas/run0).
|
||||
// When false it falls back to pkexec for GUI callers.
|
||||
func privescBin(useSudo bool) string {
|
||||
if useSudo {
|
||||
if t, err := privesc.Detect(); err == nil {
|
||||
return t.Name()
|
||||
}
|
||||
}
|
||||
return "pkexec"
|
||||
}
|
||||
|
||||
func findTerminal(override string) string {
|
||||
if override != "" && commandExists(override) {
|
||||
return override
|
||||
}
|
||||
if t := os.Getenv("TERMINAL"); t != "" && commandExists(t) {
|
||||
return t
|
||||
}
|
||||
for _, t := range []string{"ghostty", "kitty", "foot", "alacritty", "wezterm", "konsole", "gnome-terminal", "xterm"} {
|
||||
if commandExists(t) {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func wrapInTerminal(term, title, shellCmd string) []string {
|
||||
const appID = "dms-sysupdate"
|
||||
banner := fmt.Sprintf(
|
||||
`printf '\033[1;36m=== %s ===\033[0m\n'; printf '\033[2m$ %s\033[0m\n'; printf '\033[33mYou may be prompted for your sudo password to apply system updates.\033[0m\n\n'`,
|
||||
title, shellCmd,
|
||||
)
|
||||
closer := `printf '\n\033[1;32m=== Done. Press Enter to close. ===\033[0m\n'; read`
|
||||
export := `export SUDO_PROMPT="[DMS] sudo password for %u: "; `
|
||||
full := export + banner + "; " + shellCmd + "; " + closer
|
||||
|
||||
switch term {
|
||||
case "kitty":
|
||||
return []string{term, "--class", appID, "-T", title, "-e", "sh", "-c", full}
|
||||
case "alacritty":
|
||||
return []string{term, "--class", appID, "-T", title, "-e", "sh", "-c", full}
|
||||
case "foot":
|
||||
return []string{term, "--app-id=" + appID, "--title=" + title, "-e", "sh", "-c", full}
|
||||
case "ghostty":
|
||||
return []string{term, "--class=" + appID, "--title=" + title, "-e", "sh", "-c", full}
|
||||
case "wezterm":
|
||||
return []string{term, "--class", appID, "-T", title, "-e", "sh", "-c", full}
|
||||
case "xterm":
|
||||
return []string{term, "-class", appID, "-T", title, "-e", "sh", "-c", full}
|
||||
case "konsole":
|
||||
return []string{term, "-p", "tabtitle=" + title, "-e", "sh", "-c", full}
|
||||
case "gnome-terminal":
|
||||
return []string{term, "--title=" + title, "--", "sh", "-c", full}
|
||||
default:
|
||||
return []string{term, "-e", "sh", "-c", full}
|
||||
}
|
||||
}
|
||||
55
core/internal/server/sysupdate/handlers.go
Normal file
55
core/internal/server/sysupdate/handlers.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package sysupdate
|
||||
|
||||
import (
|
||||
"net"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/params"
|
||||
)
|
||||
|
||||
func HandleRequest(conn net.Conn, req models.Request, m *Manager) {
|
||||
switch req.Method {
|
||||
case "sysupdate.getState":
|
||||
models.Respond(conn, req.ID, m.GetState())
|
||||
case "sysupdate.refresh":
|
||||
force := params.BoolOpt(req.Params, "force", false)
|
||||
m.Refresh(RefreshOptions{Force: force})
|
||||
models.Respond(conn, req.ID, m.GetState())
|
||||
case "sysupdate.upgrade":
|
||||
handleUpgrade(conn, req, m)
|
||||
case "sysupdate.cancel":
|
||||
m.Cancel()
|
||||
models.Respond(conn, req.ID, m.GetState())
|
||||
case "sysupdate.acquire":
|
||||
m.Acquire()
|
||||
models.Respond(conn, req.ID, models.SuccessResult{Success: true})
|
||||
case "sysupdate.release":
|
||||
m.Release()
|
||||
models.Respond(conn, req.ID, models.SuccessResult{Success: true})
|
||||
case "sysupdate.setInterval":
|
||||
seconds, err := params.Int(req.Params, "seconds")
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
m.SetInterval(seconds)
|
||||
models.Respond(conn, req.ID, m.GetState())
|
||||
default:
|
||||
models.RespondError(conn, req.ID, "unknown method: "+req.Method)
|
||||
}
|
||||
}
|
||||
|
||||
func handleUpgrade(conn net.Conn, req models.Request, m *Manager) {
|
||||
opts := UpgradeOptions{
|
||||
IncludeFlatpak: params.BoolOpt(req.Params, "includeFlatpak", true),
|
||||
IncludeAUR: params.BoolOpt(req.Params, "includeAUR", true),
|
||||
DryRun: params.BoolOpt(req.Params, "dry", false),
|
||||
CustomCommand: params.StringOpt(req.Params, "customCommand", ""),
|
||||
Terminal: params.StringOpt(req.Params, "terminal", ""),
|
||||
}
|
||||
if err := m.Upgrade(opts); err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
models.Respond(conn, req.ID, m.GetState())
|
||||
}
|
||||
506
core/internal/server/sysupdate/manager.go
Normal file
506
core/internal/server/sysupdate/manager.go
Normal file
@@ -0,0 +1,506 @@
|
||||
package sysupdate
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultIntervalSeconds = 30 * 60
|
||||
minIntervalSeconds = 5 * 60
|
||||
recentLogCapacity = 200
|
||||
checkTimeout = 5 * time.Minute
|
||||
upgradeTimeout = 30 * time.Minute
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
state State
|
||||
subscribers syncmap.Map[string, chan State]
|
||||
|
||||
selection Selection
|
||||
|
||||
notifyDirty chan struct{}
|
||||
stopChan chan struct{}
|
||||
notifierWG sync.WaitGroup
|
||||
schedulerWG sync.WaitGroup
|
||||
|
||||
acquireCount int32
|
||||
wakeSched chan struct{}
|
||||
|
||||
refreshSerial sync.Mutex
|
||||
|
||||
opMu sync.Mutex
|
||||
opCtx context.Context
|
||||
opCancel context.CancelFunc
|
||||
}
|
||||
|
||||
func NewManager() (*Manager, error) {
|
||||
m := &Manager{
|
||||
notifyDirty: make(chan struct{}, 1),
|
||||
stopChan: make(chan struct{}),
|
||||
wakeSched: make(chan struct{}, 1),
|
||||
}
|
||||
m.state = State{
|
||||
Phase: PhaseIdle,
|
||||
IntervalSeconds: defaultIntervalSeconds,
|
||||
Backends: []BackendInfo{},
|
||||
Packages: []Package{},
|
||||
}
|
||||
|
||||
id, pretty := readOSRelease()
|
||||
m.state.Distro = id
|
||||
m.state.DistroPretty = pretty
|
||||
|
||||
m.selection = Select(context.Background())
|
||||
m.state.Backends = m.selection.Info()
|
||||
if len(m.state.Backends) == 0 {
|
||||
m.state.Error = &ErrorInfo{
|
||||
Code: ErrCodeNoBackend,
|
||||
Message: "no supported package manager found",
|
||||
Hint: "install a supported package manager (pacman, dnf, apt, zypper) or flatpak",
|
||||
}
|
||||
}
|
||||
|
||||
m.notifierWG.Add(1)
|
||||
go m.notifier()
|
||||
|
||||
m.schedulerWG.Add(1)
|
||||
go m.scheduler()
|
||||
|
||||
go m.runRefresh(context.Background())
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Manager) GetState() State {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return cloneState(m.state)
|
||||
}
|
||||
|
||||
func (m *Manager) Subscribe(id string) chan State {
|
||||
ch := make(chan State, 16)
|
||||
m.subscribers.Store(id, ch)
|
||||
return ch
|
||||
}
|
||||
|
||||
func (m *Manager) Unsubscribe(id string) {
|
||||
if val, ok := m.subscribers.LoadAndDelete(id); ok {
|
||||
close(val)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Close() {
|
||||
select {
|
||||
case <-m.stopChan:
|
||||
return
|
||||
default:
|
||||
close(m.stopChan)
|
||||
}
|
||||
m.opMu.Lock()
|
||||
if m.opCancel != nil {
|
||||
m.opCancel()
|
||||
}
|
||||
m.opMu.Unlock()
|
||||
select {
|
||||
case m.wakeSched <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
m.schedulerWG.Wait()
|
||||
m.notifierWG.Wait()
|
||||
m.subscribers.Range(func(key string, ch chan State) bool {
|
||||
close(ch)
|
||||
m.subscribers.Delete(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Manager) SetInterval(seconds int) {
|
||||
if seconds < minIntervalSeconds {
|
||||
seconds = minIntervalSeconds
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.state.IntervalSeconds = seconds
|
||||
m.mu.Unlock()
|
||||
m.markDirty()
|
||||
}
|
||||
|
||||
func (m *Manager) Refresh(opts RefreshOptions) {
|
||||
m.mu.RLock()
|
||||
phase := m.state.Phase
|
||||
m.mu.RUnlock()
|
||||
|
||||
switch {
|
||||
case phase == PhaseUpgrading:
|
||||
return
|
||||
case phase == PhaseRefreshing && !opts.Force:
|
||||
m.refreshSerial.Lock()
|
||||
m.refreshSerial.Unlock()
|
||||
return
|
||||
}
|
||||
m.runRefresh(context.Background())
|
||||
}
|
||||
|
||||
func (m *Manager) Upgrade(opts UpgradeOptions) error {
|
||||
if len(m.selection.All()) == 0 {
|
||||
return errors.New("no backend available")
|
||||
}
|
||||
|
||||
m.opMu.Lock()
|
||||
if m.opCancel != nil {
|
||||
m.opMu.Unlock()
|
||||
return errors.New("operation already running")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), upgradeTimeout)
|
||||
m.opCtx = ctx
|
||||
m.opCancel = cancel
|
||||
m.opMu.Unlock()
|
||||
|
||||
go m.runUpgrade(ctx, opts)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Cancel() {
|
||||
m.opMu.Lock()
|
||||
cancel := m.opCancel
|
||||
m.opMu.Unlock()
|
||||
if cancel == nil {
|
||||
return
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
|
||||
func (m *Manager) Acquire() {
|
||||
first := atomic.AddInt32(&m.acquireCount, 1) == 1
|
||||
select {
|
||||
case m.wakeSched <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
if first {
|
||||
go m.runRefresh(context.Background())
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Release() {
|
||||
if atomic.AddInt32(&m.acquireCount, -1) < 0 {
|
||||
atomic.StoreInt32(&m.acquireCount, 0)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) scheduler() {
|
||||
defer m.schedulerWG.Done()
|
||||
for {
|
||||
if atomic.LoadInt32(&m.acquireCount) == 0 {
|
||||
select {
|
||||
case <-m.stopChan:
|
||||
return
|
||||
case <-m.wakeSched:
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
m.mu.RLock()
|
||||
interval := m.state.IntervalSeconds
|
||||
m.mu.RUnlock()
|
||||
if interval < minIntervalSeconds {
|
||||
interval = minIntervalSeconds
|
||||
}
|
||||
t := time.NewTimer(time.Duration(interval) * time.Second)
|
||||
select {
|
||||
case <-m.stopChan:
|
||||
t.Stop()
|
||||
return
|
||||
case <-m.wakeSched:
|
||||
t.Stop()
|
||||
case <-t.C:
|
||||
m.runRefresh(context.Background())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) runRefresh(parent context.Context) {
|
||||
m.refreshSerial.Lock()
|
||||
defer m.refreshSerial.Unlock()
|
||||
|
||||
if len(m.selection.All()) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(parent, checkTimeout)
|
||||
defer cancel()
|
||||
|
||||
m.mu.Lock()
|
||||
if m.state.Phase == PhaseUpgrading {
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
m.state.Phase = PhaseRefreshing
|
||||
m.state.Error = nil
|
||||
m.state.RecentLog = nil
|
||||
m.mu.Unlock()
|
||||
m.markDirty()
|
||||
|
||||
type backendResult struct {
|
||||
pkgs []Package
|
||||
err error
|
||||
}
|
||||
backends := m.selection.All()
|
||||
results := make([]backendResult, len(backends))
|
||||
var wg sync.WaitGroup
|
||||
for i, b := range backends {
|
||||
wg.Add(1)
|
||||
go func(i int, b Backend) {
|
||||
defer wg.Done()
|
||||
pkgs, err := b.CheckUpdates(ctx)
|
||||
results[i] = backendResult{pkgs: pkgs, err: err}
|
||||
}(i, b)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
now := time.Now().Unix()
|
||||
m.mu.Lock()
|
||||
m.state.LastCheckUnix = now
|
||||
m.state.Packages = m.state.Packages[:0]
|
||||
var firstErr error
|
||||
for i, r := range results {
|
||||
if r.err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = fmt.Errorf("%s: %w", backends[i].ID(), r.err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
m.state.Packages = append(m.state.Packages, r.pkgs...)
|
||||
}
|
||||
m.state.Count = len(m.state.Packages)
|
||||
if firstErr != nil {
|
||||
m.state.Phase = PhaseError
|
||||
m.state.Error = &ErrorInfo{Code: ErrCodeBackendFailed, Message: firstErr.Error()}
|
||||
} else {
|
||||
m.state.Phase = PhaseIdle
|
||||
m.state.LastSuccessUnix = now
|
||||
m.state.NextCheckUnix = now + int64(m.state.IntervalSeconds)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
m.markDirty()
|
||||
}
|
||||
|
||||
func (m *Manager) runUpgrade(ctx context.Context, opts UpgradeOptions) {
|
||||
defer func() {
|
||||
m.opMu.Lock()
|
||||
if m.opCancel != nil {
|
||||
m.opCancel = nil
|
||||
m.opCtx = nil
|
||||
}
|
||||
m.opMu.Unlock()
|
||||
}()
|
||||
|
||||
if opts.CustomCommand != "" {
|
||||
m.runCustomUpgrade(ctx, opts.CustomCommand, opts.Terminal)
|
||||
return
|
||||
}
|
||||
|
||||
backends := upgradeBackends(m.selection, opts)
|
||||
if len(backends) == 0 {
|
||||
m.setError(ErrCodeNoBackend, "no backend selected for upgrade")
|
||||
return
|
||||
}
|
||||
|
||||
if len(opts.Targets) == 0 {
|
||||
m.mu.RLock()
|
||||
opts.Targets = append([]Package(nil), m.state.Packages...)
|
||||
m.mu.RUnlock()
|
||||
}
|
||||
|
||||
opID := fmt.Sprintf("op-%d", time.Now().UnixNano())
|
||||
m.mu.Lock()
|
||||
m.state.Phase = PhaseUpgrading
|
||||
m.state.OperationID = opID
|
||||
m.state.OperationStarted = time.Now().Unix()
|
||||
m.state.RecentLog = m.state.RecentLog[:0]
|
||||
m.state.Error = nil
|
||||
m.mu.Unlock()
|
||||
m.markDirty()
|
||||
|
||||
onLine := func(line string) { m.appendLog(line) }
|
||||
for _, b := range backends {
|
||||
m.appendLog(fmt.Sprintf("== %s ==", b.DisplayName()))
|
||||
if err := b.Upgrade(ctx, opts, onLine); err != nil {
|
||||
code := ErrCodeBackendFailed
|
||||
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
code = ErrCodeTimeout
|
||||
} else if errors.Is(ctx.Err(), context.Canceled) {
|
||||
code = ErrCodeCancelled
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.state.Phase = PhaseError
|
||||
m.state.Error = &ErrorInfo{Code: code, Message: fmt.Sprintf("%s: %v", b.ID(), err)}
|
||||
m.mu.Unlock()
|
||||
m.markDirty()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.state.Phase = PhaseIdle
|
||||
m.state.OperationID = ""
|
||||
m.state.OperationStarted = 0
|
||||
m.mu.Unlock()
|
||||
m.markDirty()
|
||||
go m.runRefresh(context.Background())
|
||||
}
|
||||
|
||||
func (m *Manager) runCustomUpgrade(ctx context.Context, command, terminalOverride string) {
|
||||
term := findTerminal(terminalOverride)
|
||||
if term == "" {
|
||||
m.setError(ErrCodeBackendFailed, "no terminal found (pick one in DMS settings, set $TERMINAL, or install kitty/ghostty/foot/alacritty)")
|
||||
return
|
||||
}
|
||||
|
||||
opID := fmt.Sprintf("op-%d", time.Now().UnixNano())
|
||||
m.mu.Lock()
|
||||
m.state.Phase = PhaseUpgrading
|
||||
m.state.OperationID = opID
|
||||
m.state.OperationStarted = time.Now().Unix()
|
||||
m.state.RecentLog = m.state.RecentLog[:0]
|
||||
m.state.Error = nil
|
||||
m.mu.Unlock()
|
||||
m.markDirty()
|
||||
|
||||
onLine := func(line string) { m.appendLog(line) }
|
||||
argv := wrapInTerminal(term, "DMS — System Update (custom)", command)
|
||||
if err := Run(ctx, argv, RunOptions{OnLine: onLine}); err != nil {
|
||||
code := ErrCodeBackendFailed
|
||||
switch {
|
||||
case errors.Is(ctx.Err(), context.DeadlineExceeded):
|
||||
code = ErrCodeTimeout
|
||||
case errors.Is(ctx.Err(), context.Canceled):
|
||||
code = ErrCodeCancelled
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.state.Phase = PhaseError
|
||||
m.state.Error = &ErrorInfo{Code: code, Message: err.Error()}
|
||||
m.mu.Unlock()
|
||||
m.markDirty()
|
||||
return
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.state.Phase = PhaseIdle
|
||||
m.state.OperationID = ""
|
||||
m.state.OperationStarted = 0
|
||||
m.mu.Unlock()
|
||||
m.markDirty()
|
||||
go m.runRefresh(context.Background())
|
||||
}
|
||||
|
||||
func upgradeBackends(sel Selection, opts UpgradeOptions) []Backend {
|
||||
var out []Backend
|
||||
if sel.System != nil {
|
||||
out = append(out, sel.System)
|
||||
}
|
||||
for _, b := range sel.Overlay {
|
||||
switch {
|
||||
case b.Repo() == RepoFlatpak && !opts.IncludeFlatpak:
|
||||
continue
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *Manager) appendLog(line string) {
|
||||
m.mu.Lock()
|
||||
if cap(m.state.RecentLog) == 0 {
|
||||
m.state.RecentLog = make([]string, 0, recentLogCapacity)
|
||||
}
|
||||
if len(m.state.RecentLog) >= recentLogCapacity {
|
||||
copy(m.state.RecentLog, m.state.RecentLog[1:])
|
||||
m.state.RecentLog = m.state.RecentLog[:recentLogCapacity-1]
|
||||
}
|
||||
m.state.RecentLog = append(m.state.RecentLog, line)
|
||||
m.mu.Unlock()
|
||||
m.markDirty()
|
||||
}
|
||||
|
||||
func (m *Manager) setError(code ErrorCode, msg string) {
|
||||
m.mu.Lock()
|
||||
m.state.Phase = PhaseError
|
||||
m.state.Error = &ErrorInfo{Code: code, Message: msg}
|
||||
m.mu.Unlock()
|
||||
m.markDirty()
|
||||
}
|
||||
|
||||
func (m *Manager) markDirty() {
|
||||
select {
|
||||
case m.notifyDirty <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) notifier() {
|
||||
defer m.notifierWG.Done()
|
||||
for {
|
||||
select {
|
||||
case <-m.stopChan:
|
||||
return
|
||||
case <-m.notifyDirty:
|
||||
snap := m.GetState()
|
||||
m.subscribers.Range(func(key string, ch chan State) bool {
|
||||
select {
|
||||
case ch <- snap:
|
||||
default:
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cloneState(s State) State {
|
||||
out := s
|
||||
out.Backends = append([]BackendInfo(nil), s.Backends...)
|
||||
out.Packages = append([]Package(nil), s.Packages...)
|
||||
out.RecentLog = append([]string(nil), s.RecentLog...)
|
||||
if s.Error != nil {
|
||||
errCopy := *s.Error
|
||||
out.Error = &errCopy
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func readOSRelease() (id, pretty string) {
|
||||
f, err := os.Open("/etc/os-release")
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
defer f.Close()
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
k, v, ok := strings.Cut(scanner.Text(), "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
v = strings.Trim(v, "\"")
|
||||
switch k {
|
||||
case "ID":
|
||||
id = v
|
||||
case "PRETTY_NAME":
|
||||
pretty = v
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
log.Debugf("[sysupdate] read os-release: %v", err)
|
||||
}
|
||||
return id, pretty
|
||||
}
|
||||
87
core/internal/server/sysupdate/types.go
Normal file
87
core/internal/server/sysupdate/types.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package sysupdate
|
||||
|
||||
type Phase string
|
||||
|
||||
const (
|
||||
PhaseIdle Phase = "idle"
|
||||
PhaseRefreshing Phase = "refreshing"
|
||||
PhaseUpgrading Phase = "upgrading"
|
||||
PhaseError Phase = "error"
|
||||
)
|
||||
|
||||
type RepoKind string
|
||||
|
||||
const (
|
||||
RepoSystem RepoKind = "system"
|
||||
RepoAUR RepoKind = "aur"
|
||||
RepoFlatpak RepoKind = "flatpak"
|
||||
RepoOSTree RepoKind = "ostree"
|
||||
)
|
||||
|
||||
type ErrorCode string
|
||||
|
||||
const (
|
||||
ErrCodeNone ErrorCode = ""
|
||||
ErrCodeNoBackend ErrorCode = "no-backend"
|
||||
ErrCodeBusy ErrorCode = "busy"
|
||||
ErrCodeBackendFailed ErrorCode = "backend-failed"
|
||||
ErrCodeTimeout ErrorCode = "timeout"
|
||||
ErrCodeCancelled ErrorCode = "cancelled"
|
||||
ErrCodeInvalidRequest ErrorCode = "invalid-request"
|
||||
)
|
||||
|
||||
type Package struct {
|
||||
Name string `json:"name"`
|
||||
Repo RepoKind `json:"repo"`
|
||||
Backend string `json:"backend"`
|
||||
FromVersion string `json:"fromVersion,omitempty"`
|
||||
ToVersion string `json:"toVersion,omitempty"`
|
||||
SizeBytes int64 `json:"sizeBytes,omitempty"`
|
||||
ChangelogURL string `json:"changelogUrl,omitempty"`
|
||||
Ref string `json:"-"`
|
||||
}
|
||||
|
||||
type BackendInfo struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Repo RepoKind `json:"repo"`
|
||||
NeedsAuth bool `json:"needsAuth"`
|
||||
RunsInTerminal bool `json:"runsInTerminal"`
|
||||
}
|
||||
|
||||
type ErrorInfo struct {
|
||||
Code ErrorCode `json:"code,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
}
|
||||
|
||||
type State struct {
|
||||
Phase Phase `json:"phase"`
|
||||
Distro string `json:"distro,omitempty"`
|
||||
DistroPretty string `json:"distroPretty,omitempty"`
|
||||
Backends []BackendInfo `json:"backends"`
|
||||
Packages []Package `json:"packages"`
|
||||
Count int `json:"count"`
|
||||
IntervalSeconds int `json:"intervalSeconds"`
|
||||
LastCheckUnix int64 `json:"lastCheckUnix,omitempty"`
|
||||
LastSuccessUnix int64 `json:"lastSuccessUnix,omitempty"`
|
||||
NextCheckUnix int64 `json:"nextCheckUnix,omitempty"`
|
||||
OperationID string `json:"operationId,omitempty"`
|
||||
OperationStarted int64 `json:"operationStartedUnix,omitempty"`
|
||||
RecentLog []string `json:"recentLog,omitempty"`
|
||||
Error *ErrorInfo `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type UpgradeOptions struct {
|
||||
IncludeFlatpak bool
|
||||
IncludeAUR bool
|
||||
DryRun bool
|
||||
UseSudo bool
|
||||
CustomCommand string
|
||||
Terminal string
|
||||
Targets []Package
|
||||
}
|
||||
|
||||
type RefreshOptions struct {
|
||||
Force bool
|
||||
}
|
||||
135
core/internal/server/tailscale/client.go
Normal file
135
core/internal/server/tailscale/client.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package tailscale
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
// convertStatus converts an ipnstate.Status into our TailscaleState IPC type.
|
||||
func convertStatus(status *ipnstate.Status) *TailscaleState {
|
||||
connected := status.BackendState == "Running"
|
||||
|
||||
state := &TailscaleState{
|
||||
Connected: connected,
|
||||
BackendState: status.BackendState,
|
||||
Version: status.Version,
|
||||
}
|
||||
|
||||
if status.CurrentTailnet != nil {
|
||||
state.TailnetName = status.CurrentTailnet.Name
|
||||
state.MagicDNSSuffix = status.CurrentTailnet.MagicDNSSuffix
|
||||
}
|
||||
|
||||
if !connected {
|
||||
return state
|
||||
}
|
||||
|
||||
users := status.User
|
||||
|
||||
if status.Self != nil {
|
||||
state.Self = convertPeerStatus(status.Self, users)
|
||||
}
|
||||
|
||||
if len(status.Peer) > 0 {
|
||||
peers := make([]Peer, 0, len(status.Peer))
|
||||
for _, ps := range status.Peer {
|
||||
peers = append(peers, convertPeerStatus(ps, users))
|
||||
}
|
||||
sort.Slice(peers, func(i, j int) bool {
|
||||
if peers[i].Online != peers[j].Online {
|
||||
return peers[i].Online
|
||||
}
|
||||
return strings.ToLower(peers[i].Hostname) < strings.ToLower(peers[j].Hostname)
|
||||
})
|
||||
state.Peers = peers
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
// convertPeerStatus converts an ipnstate.PeerStatus into our Peer IPC type.
|
||||
func convertPeerStatus(ps *ipnstate.PeerStatus, users map[tailcfg.UserID]tailcfg.UserProfile) Peer {
|
||||
dnsName := strings.TrimSuffix(ps.DNSName, ".")
|
||||
|
||||
// DNSName first label is unique per node; OS HostName is not.
|
||||
hostname := ps.HostName
|
||||
if dnsName != "" {
|
||||
parts := strings.SplitN(dnsName, ".", 2)
|
||||
if len(parts) > 0 && parts[0] != "" {
|
||||
hostname = parts[0]
|
||||
}
|
||||
}
|
||||
|
||||
peer := Peer{
|
||||
ID: string(ps.ID),
|
||||
Hostname: hostname,
|
||||
DNSName: dnsName,
|
||||
OS: ps.OS,
|
||||
Online: ps.Online,
|
||||
Active: ps.Active,
|
||||
ExitNode: ps.ExitNode,
|
||||
Relay: ps.Relay,
|
||||
RxBytes: ps.RxBytes,
|
||||
TxBytes: ps.TxBytes,
|
||||
}
|
||||
|
||||
for _, ip := range ps.TailscaleIPs {
|
||||
if ip.Is4() {
|
||||
if peer.TailscaleIP == "" {
|
||||
peer.TailscaleIP = ip.String()
|
||||
}
|
||||
} else {
|
||||
if peer.TailscaleIPv6 == "" {
|
||||
peer.TailscaleIPv6 = ip.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ps.Tags != nil {
|
||||
peer.Tags = ps.Tags.AsSlice()
|
||||
}
|
||||
|
||||
if ps.UserID > 0 {
|
||||
if user, ok := users[ps.UserID]; ok {
|
||||
peer.Owner = user.LoginName
|
||||
}
|
||||
}
|
||||
|
||||
if !ps.LastSeen.IsZero() {
|
||||
peer.LastSeen = formatRelativeTime(ps.LastSeen)
|
||||
}
|
||||
|
||||
return peer
|
||||
}
|
||||
|
||||
// formatRelativeTime formats a time as a human-readable relative duration (e.g., "5 minutes ago").
|
||||
func formatRelativeTime(t time.Time) string {
|
||||
d := time.Since(t)
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
return "just now"
|
||||
case d < time.Hour:
|
||||
m := int(d.Minutes())
|
||||
if m == 1 {
|
||||
return "1 minute ago"
|
||||
}
|
||||
return fmt.Sprintf("%d minutes ago", m)
|
||||
case d < 24*time.Hour:
|
||||
h := int(d.Hours())
|
||||
if h == 1 {
|
||||
return "1 hour ago"
|
||||
}
|
||||
return fmt.Sprintf("%d hours ago", h)
|
||||
default:
|
||||
days := int(d.Hours() / 24)
|
||||
if days == 1 {
|
||||
return "1 day ago"
|
||||
}
|
||||
return fmt.Sprintf("%d days ago", days)
|
||||
}
|
||||
}
|
||||
223
core/internal/server/tailscale/client_test.go
Normal file
223
core/internal/server/tailscale/client_test.go
Normal file
@@ -0,0 +1,223 @@
|
||||
package tailscale
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go4.org/mem"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/types/views"
|
||||
)
|
||||
|
||||
func makeTestStatus() *ipnstate.Status {
|
||||
return &ipnstate.Status{
|
||||
Version: "1.94.2",
|
||||
BackendState: "Running",
|
||||
MagicDNSSuffix: "example.ts.net",
|
||||
CurrentTailnet: &ipnstate.TailnetStatus{
|
||||
Name: "user@example.com",
|
||||
MagicDNSSuffix: "example.ts.net",
|
||||
},
|
||||
Self: &ipnstate.PeerStatus{
|
||||
ID: "node1",
|
||||
HostName: "cachyos",
|
||||
DNSName: "cachyos.example.ts.net.",
|
||||
OS: "linux",
|
||||
TailscaleIPs: []netip.Addr{
|
||||
netip.MustParseAddr("100.85.254.40"),
|
||||
netip.MustParseAddr("fd7a:115c:a1e0::1"),
|
||||
},
|
||||
Online: true,
|
||||
UserID: 12345,
|
||||
},
|
||||
Peer: map[key.NodePublic]*ipnstate.PeerStatus{
|
||||
key.NodePublicFromRaw32(mem.B(make([]byte, 32))): {
|
||||
ID: "node2",
|
||||
HostName: "thinkpad-x390",
|
||||
DNSName: "thinkpad-x390.example.ts.net.",
|
||||
OS: "linux",
|
||||
TailscaleIPs: []netip.Addr{
|
||||
netip.MustParseAddr("100.97.21.17"),
|
||||
netip.MustParseAddr("fd7a:115c:a1e0::2"),
|
||||
},
|
||||
Online: true,
|
||||
Active: true,
|
||||
Relay: "fra",
|
||||
RxBytes: 1024,
|
||||
TxBytes: 2048,
|
||||
UserID: 12345,
|
||||
ExitNode: false,
|
||||
LastSeen: time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC),
|
||||
},
|
||||
},
|
||||
User: map[tailcfg.UserID]tailcfg.UserProfile{
|
||||
12345: {
|
||||
ID: 12345,
|
||||
LoginName: "user@example.com",
|
||||
DisplayName: "User",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertStatus_Running(t *testing.T) {
|
||||
status := makeTestStatus()
|
||||
state := convertStatus(status)
|
||||
|
||||
require.NotNil(t, state)
|
||||
assert.True(t, state.Connected)
|
||||
assert.Equal(t, "1.94.2", state.Version)
|
||||
assert.Equal(t, "Running", state.BackendState)
|
||||
assert.Equal(t, "example.ts.net", state.MagicDNSSuffix)
|
||||
assert.Equal(t, "user@example.com", state.TailnetName)
|
||||
|
||||
// Self
|
||||
assert.Equal(t, "cachyos", state.Self.Hostname)
|
||||
assert.Equal(t, "cachyos.example.ts.net", state.Self.DNSName)
|
||||
assert.Equal(t, "100.85.254.40", state.Self.TailscaleIP)
|
||||
assert.Equal(t, "fd7a:115c:a1e0::1", state.Self.TailscaleIPv6)
|
||||
assert.Equal(t, "linux", state.Self.OS)
|
||||
assert.True(t, state.Self.Online)
|
||||
|
||||
// Peers
|
||||
require.Len(t, state.Peers, 1)
|
||||
peer := state.Peers[0]
|
||||
assert.Equal(t, "thinkpad-x390", peer.Hostname)
|
||||
assert.Equal(t, "100.97.21.17", peer.TailscaleIP)
|
||||
assert.Equal(t, "fra", peer.Relay)
|
||||
assert.Equal(t, "user@example.com", peer.Owner)
|
||||
assert.Equal(t, int64(1024), peer.RxBytes)
|
||||
assert.True(t, peer.Online)
|
||||
}
|
||||
|
||||
func TestConvertStatus_NotRunning(t *testing.T) {
|
||||
status := &ipnstate.Status{
|
||||
BackendState: "Stopped",
|
||||
}
|
||||
|
||||
state := convertStatus(status)
|
||||
assert.False(t, state.Connected)
|
||||
assert.Equal(t, "Stopped", state.BackendState)
|
||||
assert.Empty(t, state.Peers)
|
||||
}
|
||||
|
||||
func TestConvertStatus_NilSelf(t *testing.T) {
|
||||
status := &ipnstate.Status{
|
||||
BackendState: "Running",
|
||||
}
|
||||
|
||||
state := convertStatus(status)
|
||||
assert.True(t, state.Connected)
|
||||
assert.Equal(t, Peer{}, state.Self)
|
||||
}
|
||||
|
||||
func TestConvertPeerStatus_Tags(t *testing.T) {
|
||||
tags := views.SliceOf([]string{"tag:k8s", "tag:server"})
|
||||
ps := &ipnstate.PeerStatus{
|
||||
ID: "node3",
|
||||
HostName: "k8s-node",
|
||||
DNSName: "k8s-node.example.ts.net.",
|
||||
OS: "linux",
|
||||
Online: false,
|
||||
Tags: &tags,
|
||||
}
|
||||
users := map[tailcfg.UserID]tailcfg.UserProfile{}
|
||||
|
||||
peer := convertPeerStatus(ps, users)
|
||||
assert.Equal(t, "k8s-node", peer.Hostname)
|
||||
assert.Contains(t, peer.Tags, "tag:k8s")
|
||||
assert.Contains(t, peer.Tags, "tag:server")
|
||||
assert.Equal(t, "", peer.Owner)
|
||||
}
|
||||
|
||||
func TestConvertPeerStatus_HostnameFromDNS(t *testing.T) {
|
||||
// Hostname should always be derived from DNSName, not OS HostName
|
||||
ps := &ipnstate.PeerStatus{
|
||||
HostName: "GL-MT6000",
|
||||
DNSName: "gl-mt6000-2.example.ts.net.",
|
||||
}
|
||||
users := map[tailcfg.UserID]tailcfg.UserProfile{}
|
||||
|
||||
peer := convertPeerStatus(ps, users)
|
||||
assert.Equal(t, "gl-mt6000-2", peer.Hostname)
|
||||
}
|
||||
|
||||
func TestConvertPeerStatus_FallbackToHostName(t *testing.T) {
|
||||
// When DNSName is empty, fall back to OS HostName
|
||||
ps := &ipnstate.PeerStatus{
|
||||
HostName: "my-device",
|
||||
}
|
||||
users := map[tailcfg.UserID]tailcfg.UserProfile{}
|
||||
|
||||
peer := convertPeerStatus(ps, users)
|
||||
assert.Equal(t, "my-device", peer.Hostname)
|
||||
}
|
||||
|
||||
func TestConvertPeerStatus_LastSeen(t *testing.T) {
|
||||
ps := &ipnstate.PeerStatus{
|
||||
HostName: "recent-node",
|
||||
LastSeen: time.Now().Add(-5 * time.Minute),
|
||||
}
|
||||
users := map[tailcfg.UserID]tailcfg.UserProfile{}
|
||||
|
||||
peer := convertPeerStatus(ps, users)
|
||||
assert.NotEmpty(t, peer.LastSeen)
|
||||
assert.Contains(t, peer.LastSeen, "minutes ago")
|
||||
}
|
||||
|
||||
func TestPeerSorting(t *testing.T) {
|
||||
b1 := make([]byte, 32)
|
||||
b2 := make([]byte, 32)
|
||||
b2[0] = 1
|
||||
b3 := make([]byte, 32)
|
||||
b3[0] = 2
|
||||
|
||||
k1 := key.NodePublicFromRaw32(mem.B(b1))
|
||||
k2 := key.NodePublicFromRaw32(mem.B(b2))
|
||||
k3 := key.NodePublicFromRaw32(mem.B(b3))
|
||||
|
||||
status := &ipnstate.Status{
|
||||
BackendState: "Running",
|
||||
Peer: map[key.NodePublic]*ipnstate.PeerStatus{
|
||||
k1: {HostName: "zebra", Online: false},
|
||||
k2: {HostName: "alpha", Online: true},
|
||||
k3: {HostName: "beta", Online: true},
|
||||
},
|
||||
}
|
||||
|
||||
state := convertStatus(status)
|
||||
|
||||
// Online peers first (alpha, beta), then offline (zebra)
|
||||
require.Len(t, state.Peers, 3)
|
||||
assert.True(t, state.Peers[0].Online)
|
||||
assert.True(t, state.Peers[1].Online)
|
||||
assert.False(t, state.Peers[2].Online)
|
||||
assert.Equal(t, "alpha", state.Peers[0].Hostname)
|
||||
assert.Equal(t, "beta", state.Peers[1].Hostname)
|
||||
assert.Equal(t, "zebra", state.Peers[2].Hostname)
|
||||
}
|
||||
|
||||
func TestFormatRelativeTime(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
duration string
|
||||
contains string
|
||||
}{
|
||||
{"minutes", "5m", "minutes ago"},
|
||||
{"hours", "3h", "hours ago"},
|
||||
{"days", "48h", "days ago"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
d, _ := time.ParseDuration(tt.duration)
|
||||
result := formatRelativeTime(time.Now().Add(-d))
|
||||
assert.Contains(t, result, tt.contains)
|
||||
})
|
||||
}
|
||||
}
|
||||
30
core/internal/server/tailscale/handlers.go
Normal file
30
core/internal/server/tailscale/handlers.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package tailscale
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||
)
|
||||
|
||||
// HandleRequest routes an IPC request to the appropriate handler.
|
||||
func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
|
||||
switch req.Method {
|
||||
case "tailscale.getStatus":
|
||||
handleGetStatus(conn, req, manager)
|
||||
case "tailscale.refresh":
|
||||
handleRefresh(conn, req, manager)
|
||||
default:
|
||||
models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
|
||||
}
|
||||
}
|
||||
|
||||
func handleGetStatus(conn net.Conn, req models.Request, manager *Manager) {
|
||||
state := manager.GetState()
|
||||
models.Respond(conn, req.ID, state)
|
||||
}
|
||||
|
||||
func handleRefresh(conn net.Conn, req models.Request, manager *Manager) {
|
||||
manager.RefreshState()
|
||||
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "refreshed"})
|
||||
}
|
||||
97
core/internal/server/tailscale/handlers_test.go
Normal file
97
core/internal/server/tailscale/handlers_test.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package tailscale
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
)
|
||||
|
||||
type mockConn struct {
|
||||
*bytes.Buffer
|
||||
}
|
||||
|
||||
func (m *mockConn) Close() error { return nil }
|
||||
func (m *mockConn) LocalAddr() net.Addr { return nil }
|
||||
func (m *mockConn) RemoteAddr() net.Addr { return nil }
|
||||
func (m *mockConn) SetDeadline(t time.Time) error { return nil }
|
||||
func (m *mockConn) SetReadDeadline(t time.Time) error { return nil }
|
||||
func (m *mockConn) SetWriteDeadline(t time.Time) error { return nil }
|
||||
|
||||
func handlerTestManager() *Manager {
|
||||
client := &mockClient{
|
||||
watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
},
|
||||
statusFn: func(ctx context.Context) (*ipnstate.Status, error) {
|
||||
return runningStatus(), nil
|
||||
},
|
||||
}
|
||||
m := newManager(client)
|
||||
m.RefreshState()
|
||||
return m
|
||||
}
|
||||
|
||||
func TestHandleGetStatus(t *testing.T) {
|
||||
m := handlerTestManager()
|
||||
defer m.Close()
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
conn := &mockConn{Buffer: buf}
|
||||
|
||||
req := models.Request{ID: 1, Method: "tailscale.getStatus"}
|
||||
handleGetStatus(conn, req, m)
|
||||
|
||||
var resp models.Response[TailscaleState]
|
||||
err := json.NewDecoder(buf).Decode(&resp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, resp.ID)
|
||||
assert.NotNil(t, resp.Result)
|
||||
assert.True(t, resp.Result.Connected)
|
||||
assert.Equal(t, "cachyos", resp.Result.Self.Hostname)
|
||||
}
|
||||
|
||||
func TestHandleRefresh(t *testing.T) {
|
||||
m := handlerTestManager()
|
||||
defer m.Close()
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
conn := &mockConn{Buffer: buf}
|
||||
|
||||
req := models.Request{ID: 1, Method: "tailscale.refresh"}
|
||||
handleRefresh(conn, req, m)
|
||||
|
||||
var resp models.Response[models.SuccessResult]
|
||||
err := json.NewDecoder(buf).Decode(&resp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, resp.ID)
|
||||
assert.NotNil(t, resp.Result)
|
||||
assert.True(t, resp.Result.Success)
|
||||
}
|
||||
|
||||
func TestHandleRequest_UnknownMethod(t *testing.T) {
|
||||
m := handlerTestManager()
|
||||
defer m.Close()
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
conn := &mockConn{Buffer: buf}
|
||||
|
||||
req := models.Request{ID: 1, Method: "tailscale.unknownMethod"}
|
||||
HandleRequest(conn, req, m)
|
||||
|
||||
var resp models.Response[any]
|
||||
err := json.NewDecoder(buf).Decode(&resp)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, resp.Result)
|
||||
assert.NotEmpty(t, resp.Error)
|
||||
assert.Contains(t, resp.Error, "unknown method")
|
||||
}
|
||||
277
core/internal/server/tailscale/manager.go
Normal file
277
core/internal/server/tailscale/manager.go
Normal file
@@ -0,0 +1,277 @@
|
||||
package tailscale
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
|
||||
"tailscale.com/client/local"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
)
|
||||
|
||||
const (
|
||||
statusTimeout = 3 * time.Second
|
||||
debounceWindow = 150 * time.Millisecond
|
||||
)
|
||||
|
||||
// tailscaleClient abstracts the Tailscale local API for testing.
|
||||
type tailscaleClient interface {
|
||||
WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error)
|
||||
Status(ctx context.Context) (*ipnstate.Status, error)
|
||||
}
|
||||
|
||||
// ipnBusWatcher abstracts the IPN bus watcher for testing.
|
||||
type ipnBusWatcher interface {
|
||||
Next() (ipn.Notify, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
// localClientWrapper wraps local.Client to satisfy tailscaleClient.
|
||||
type localClientWrapper struct {
|
||||
client *local.Client
|
||||
}
|
||||
|
||||
func (w *localClientWrapper) WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
|
||||
return w.client.WatchIPNBus(ctx, mask)
|
||||
}
|
||||
|
||||
func (w *localClientWrapper) Status(ctx context.Context) (*ipnstate.Status, error) {
|
||||
return w.client.Status(ctx)
|
||||
}
|
||||
|
||||
// Manager manages Tailscale state via IPN bus events and subscriber notifications.
|
||||
type Manager struct {
|
||||
state *TailscaleState
|
||||
stateMutex sync.RWMutex
|
||||
subscribers syncmap.Map[string, chan TailscaleState]
|
||||
client tailscaleClient
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
watchWG sync.WaitGroup
|
||||
closed atomic.Bool
|
||||
dirty chan struct{}
|
||||
available atomic.Bool
|
||||
availabilityCallback atomic.Pointer[func(bool)]
|
||||
}
|
||||
|
||||
// NewManager creates a new Tailscale manager and starts watching the IPN bus.
|
||||
func NewManager(socketPath string) *Manager {
|
||||
lc := &local.Client{Socket: socketPath}
|
||||
return newManager(&localClientWrapper{client: lc})
|
||||
}
|
||||
|
||||
func newManager(client tailscaleClient) *Manager {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
m := &Manager{
|
||||
state: &TailscaleState{},
|
||||
client: client,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
dirty: make(chan struct{}, 1),
|
||||
}
|
||||
|
||||
m.watchWG.Add(2)
|
||||
go m.watchLoop(ctx)
|
||||
go m.debounceLoop(ctx)
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *Manager) watchLoop(ctx context.Context) {
|
||||
defer m.watchWG.Done()
|
||||
|
||||
mask := ipn.NotifyInitialState | ipn.NotifyInitialNetMap | ipn.NotifyRateLimit
|
||||
backoff := time.Second
|
||||
unreachableSent := false
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
watcher, err := m.client.WatchIPNBus(ctx, mask)
|
||||
if err != nil {
|
||||
if !unreachableSent {
|
||||
m.updateState(&TailscaleState{Connected: false, BackendState: "Unreachable"})
|
||||
unreachableSent = true
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
backoff = min(backoff*2, 30*time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
unreachableSent = false
|
||||
backoff = time.Second
|
||||
log.Info("[Tailscale] Connected to IPN bus")
|
||||
m.markAvailable()
|
||||
|
||||
for {
|
||||
notify, err := watcher.Next()
|
||||
if err != nil {
|
||||
log.Warnf("[Tailscale] IPN bus error: %v", err)
|
||||
break
|
||||
}
|
||||
|
||||
if notify.State == nil && notify.NetMap == nil {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case m.dirty <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
watcher.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// debounceLoop coalesces rapid bus notifications into a single Status RPC
|
||||
// per debounceWindow, since NetMap events can fire many times per second
|
||||
// on busy tailnets.
|
||||
func (m *Manager) debounceLoop(ctx context.Context) {
|
||||
defer m.watchWG.Done()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-m.dirty:
|
||||
}
|
||||
|
||||
timer := time.NewTimer(debounceWindow)
|
||||
collecting := true
|
||||
for collecting {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
case <-m.dirty:
|
||||
case <-timer.C:
|
||||
collecting = false
|
||||
}
|
||||
}
|
||||
|
||||
m.fetchAndBroadcast(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) fetchAndBroadcast(ctx context.Context) {
|
||||
statusCtx, cancel := context.WithTimeout(ctx, statusTimeout)
|
||||
defer cancel()
|
||||
|
||||
status, err := m.client.Status(statusCtx)
|
||||
if err != nil {
|
||||
log.Warnf("[Tailscale] Failed to fetch status: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
state := convertStatus(status)
|
||||
m.updateState(state)
|
||||
}
|
||||
|
||||
func (m *Manager) updateState(state *TailscaleState) {
|
||||
m.stateMutex.Lock()
|
||||
m.state = state
|
||||
m.stateMutex.Unlock()
|
||||
|
||||
m.broadcastState(*state)
|
||||
}
|
||||
|
||||
func (m *Manager) broadcastState(state TailscaleState) {
|
||||
if m.closed.Load() {
|
||||
return
|
||||
}
|
||||
m.subscribers.Range(func(key string, ch chan TailscaleState) bool {
|
||||
select {
|
||||
case ch <- state:
|
||||
default:
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// IsAvailable reports whether tailscaled has been reachable via the IPN bus
|
||||
// at least once since the manager started. False means tailscaled appears
|
||||
// to not be installed or has never been running.
|
||||
func (m *Manager) IsAvailable() bool {
|
||||
return m.available.Load()
|
||||
}
|
||||
|
||||
// SetAvailabilityCallback registers a callback fired when the manager
|
||||
// transitions from unavailable to available. Replaces any previously set
|
||||
// callback. Must be set before the manager has a chance to detect tailscaled.
|
||||
func (m *Manager) SetAvailabilityCallback(cb func(bool)) {
|
||||
m.availabilityCallback.Store(&cb)
|
||||
}
|
||||
|
||||
func (m *Manager) markAvailable() {
|
||||
if m.available.Swap(true) {
|
||||
return
|
||||
}
|
||||
if cb := m.availabilityCallback.Load(); cb != nil {
|
||||
(*cb)(true)
|
||||
}
|
||||
}
|
||||
|
||||
// GetState returns a copy of the current Tailscale state.
|
||||
func (m *Manager) GetState() TailscaleState {
|
||||
m.stateMutex.RLock()
|
||||
defer m.stateMutex.RUnlock()
|
||||
|
||||
if m.state == nil {
|
||||
return TailscaleState{}
|
||||
}
|
||||
return *m.state
|
||||
}
|
||||
|
||||
// Subscribe creates a buffered channel for the given client ID.
|
||||
func (m *Manager) Subscribe(clientID string) chan TailscaleState {
|
||||
ch := make(chan TailscaleState, 64)
|
||||
m.subscribers.Store(clientID, ch)
|
||||
return ch
|
||||
}
|
||||
|
||||
// Unsubscribe removes and closes the subscriber channel.
|
||||
func (m *Manager) Unsubscribe(clientID string) {
|
||||
if val, ok := m.subscribers.LoadAndDelete(clientID); ok {
|
||||
close(val)
|
||||
}
|
||||
}
|
||||
|
||||
// Close stops the watch loop and closes all subscriber channels.
|
||||
func (m *Manager) Close() {
|
||||
m.closed.Store(true)
|
||||
m.cancel()
|
||||
m.watchWG.Wait()
|
||||
|
||||
m.subscribers.Range(func(key string, ch chan TailscaleState) bool {
|
||||
close(ch)
|
||||
m.subscribers.Delete(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// RefreshState triggers an immediate status fetch and broadcasts.
|
||||
func (m *Manager) RefreshState() {
|
||||
ctx, cancel := context.WithTimeout(m.ctx, statusTimeout)
|
||||
defer cancel()
|
||||
|
||||
status, err := m.client.Status(ctx)
|
||||
if err != nil {
|
||||
log.Warnf("[Tailscale] Failed to refresh state: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
state := convertStatus(status)
|
||||
m.updateState(state)
|
||||
}
|
||||
307
core/internal/server/tailscale/manager_test.go
Normal file
307
core/internal/server/tailscale/manager_test.go
Normal file
@@ -0,0 +1,307 @@
|
||||
package tailscale
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
)
|
||||
|
||||
// mockWatcher yields canned Notify events, then returns err or blocks until Close/context cancel.
|
||||
type mockWatcher struct {
|
||||
events []ipn.Notify
|
||||
idx int
|
||||
err error
|
||||
done chan struct{}
|
||||
ctx context.Context
|
||||
mu sync.Mutex
|
||||
closed bool
|
||||
}
|
||||
|
||||
func newMockWatcher(ctx context.Context, events []ipn.Notify, err error) *mockWatcher {
|
||||
return &mockWatcher{
|
||||
events: events,
|
||||
err: err,
|
||||
done: make(chan struct{}),
|
||||
ctx: ctx,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *mockWatcher) Next() (ipn.Notify, error) {
|
||||
w.mu.Lock()
|
||||
if w.idx < len(w.events) {
|
||||
n := w.events[w.idx]
|
||||
w.idx++
|
||||
w.mu.Unlock()
|
||||
return n, nil
|
||||
}
|
||||
if w.err != nil {
|
||||
err := w.err
|
||||
w.mu.Unlock()
|
||||
return ipn.Notify{}, err
|
||||
}
|
||||
w.mu.Unlock()
|
||||
select {
|
||||
case <-w.done:
|
||||
return ipn.Notify{}, fmt.Errorf("watcher closed")
|
||||
case <-w.ctx.Done():
|
||||
return ipn.Notify{}, w.ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (w *mockWatcher) Close() error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if !w.closed {
|
||||
w.closed = true
|
||||
close(w.done)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mockClient implements tailscaleClient for testing.
|
||||
type mockClient struct {
|
||||
watchFn func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error)
|
||||
statusFn func(ctx context.Context) (*ipnstate.Status, error)
|
||||
}
|
||||
|
||||
func (c *mockClient) WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
|
||||
return c.watchFn(ctx, mask)
|
||||
}
|
||||
|
||||
func (c *mockClient) Status(ctx context.Context) (*ipnstate.Status, error) {
|
||||
return c.statusFn(ctx)
|
||||
}
|
||||
|
||||
func runningStatus() *ipnstate.Status {
|
||||
return &ipnstate.Status{
|
||||
Version: "1.94.2",
|
||||
BackendState: "Running",
|
||||
MagicDNSSuffix: "example.ts.net",
|
||||
CurrentTailnet: &ipnstate.TailnetStatus{
|
||||
Name: "user@example.com",
|
||||
MagicDNSSuffix: "example.ts.net",
|
||||
},
|
||||
Self: &ipnstate.PeerStatus{
|
||||
HostName: "cachyos",
|
||||
DNSName: "cachyos.example.ts.net.",
|
||||
OS: "linux",
|
||||
Online: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchLoop_StateChange(t *testing.T) {
|
||||
stateVal := ipn.Running
|
||||
statusCalled := make(chan struct{}, 4)
|
||||
var watchCount int32
|
||||
|
||||
client := &mockClient{
|
||||
watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
|
||||
watchCount++
|
||||
if watchCount == 1 {
|
||||
return newMockWatcher(ctx,
|
||||
[]ipn.Notify{{State: &stateVal}},
|
||||
fmt.Errorf("done"),
|
||||
), nil
|
||||
}
|
||||
return newMockWatcher(ctx, nil, nil), nil
|
||||
},
|
||||
statusFn: func(ctx context.Context) (*ipnstate.Status, error) {
|
||||
select {
|
||||
case statusCalled <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return runningStatus(), nil
|
||||
},
|
||||
}
|
||||
|
||||
m := newManager(client)
|
||||
defer m.Close()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return len(statusCalled) > 0
|
||||
}, 2*time.Second, 10*time.Millisecond)
|
||||
|
||||
state := m.GetState()
|
||||
assert.True(t, state.Connected)
|
||||
assert.Equal(t, "Running", state.BackendState)
|
||||
assert.Equal(t, "cachyos", state.Self.Hostname)
|
||||
}
|
||||
|
||||
func TestWatchLoop_CoalescesNotifies(t *testing.T) {
|
||||
stateVal := ipn.Running
|
||||
var statusCalls atomic.Int32
|
||||
|
||||
notifies := make([]ipn.Notify, 0, 20)
|
||||
for range 20 {
|
||||
notifies = append(notifies, ipn.Notify{State: &stateVal})
|
||||
}
|
||||
|
||||
client := &mockClient{
|
||||
watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
|
||||
return newMockWatcher(ctx, notifies, nil), nil
|
||||
},
|
||||
statusFn: func(ctx context.Context) (*ipnstate.Status, error) {
|
||||
statusCalls.Add(1)
|
||||
return runningStatus(), nil
|
||||
},
|
||||
}
|
||||
|
||||
m := newManager(client)
|
||||
defer m.Close()
|
||||
|
||||
// Wait for the debounce window to expire plus margin so the burst settles.
|
||||
time.Sleep(debounceWindow + 100*time.Millisecond)
|
||||
|
||||
calls := statusCalls.Load()
|
||||
assert.Less(t, int(calls), 5,
|
||||
"20 rapid notifies should coalesce to a small number of Status RPCs, got %d", calls)
|
||||
assert.Greater(t, int(calls), 0, "expected at least one Status RPC")
|
||||
}
|
||||
|
||||
func TestWatchLoop_Reconnect(t *testing.T) {
|
||||
watchCalled := make(chan struct{}, 4)
|
||||
|
||||
client := &mockClient{
|
||||
watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
|
||||
select {
|
||||
case watchCalled <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
if len(watchCalled) <= 1 {
|
||||
return nil, fmt.Errorf("connection refused")
|
||||
}
|
||||
return newMockWatcher(ctx, nil, nil), nil
|
||||
},
|
||||
statusFn: func(ctx context.Context) (*ipnstate.Status, error) {
|
||||
return runningStatus(), nil
|
||||
},
|
||||
}
|
||||
|
||||
m := newManager(client)
|
||||
defer m.Close()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
state := m.GetState()
|
||||
return state.BackendState == "Unreachable"
|
||||
}, 2*time.Second, 10*time.Millisecond)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return len(watchCalled) >= 2
|
||||
}, 3*time.Second, 50*time.Millisecond)
|
||||
}
|
||||
|
||||
func TestManager_Subscribe(t *testing.T) {
|
||||
client := &mockClient{
|
||||
watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
},
|
||||
statusFn: func(ctx context.Context) (*ipnstate.Status, error) {
|
||||
return runningStatus(), nil
|
||||
},
|
||||
}
|
||||
|
||||
m := newManager(client)
|
||||
defer m.Close()
|
||||
|
||||
ch := m.Subscribe("test-1")
|
||||
assert.NotNil(t, ch)
|
||||
|
||||
ch2 := m.Subscribe("test-2")
|
||||
assert.NotNil(t, ch2)
|
||||
|
||||
m.Unsubscribe("test-1")
|
||||
m.Unsubscribe("test-2")
|
||||
}
|
||||
|
||||
func TestManager_Close(t *testing.T) {
|
||||
client := &mockClient{
|
||||
watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
},
|
||||
statusFn: func(ctx context.Context) (*ipnstate.Status, error) {
|
||||
return runningStatus(), nil
|
||||
},
|
||||
}
|
||||
|
||||
m := newManager(client)
|
||||
|
||||
ch := m.Subscribe("test")
|
||||
assert.NotNil(t, ch)
|
||||
|
||||
assert.NotPanics(t, func() {
|
||||
m.Close()
|
||||
})
|
||||
}
|
||||
|
||||
func TestManager_Availability(t *testing.T) {
|
||||
var watchAttempts atomic.Int32
|
||||
|
||||
client := &mockClient{
|
||||
watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
|
||||
n := watchAttempts.Add(1)
|
||||
if n == 1 {
|
||||
return nil, fmt.Errorf("tailscaled socket not found")
|
||||
}
|
||||
return newMockWatcher(ctx, nil, nil), nil
|
||||
},
|
||||
statusFn: func(ctx context.Context) (*ipnstate.Status, error) {
|
||||
return runningStatus(), nil
|
||||
},
|
||||
}
|
||||
|
||||
m := newManager(client)
|
||||
defer m.Close()
|
||||
|
||||
cbFired := make(chan bool, 1)
|
||||
m.SetAvailabilityCallback(func(b bool) {
|
||||
select {
|
||||
case cbFired <- b:
|
||||
default:
|
||||
}
|
||||
})
|
||||
|
||||
assert.False(t, m.IsAvailable())
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return m.IsAvailable()
|
||||
}, 3*time.Second, 50*time.Millisecond)
|
||||
|
||||
select {
|
||||
case b := <-cbFired:
|
||||
assert.True(t, b)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("availability callback did not fire")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_RefreshState(t *testing.T) {
|
||||
client := &mockClient{
|
||||
watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
},
|
||||
statusFn: func(ctx context.Context) (*ipnstate.Status, error) {
|
||||
return runningStatus(), nil
|
||||
},
|
||||
}
|
||||
|
||||
m := newManager(client)
|
||||
defer m.Close()
|
||||
|
||||
m.RefreshState()
|
||||
|
||||
state := m.GetState()
|
||||
assert.True(t, state.Connected)
|
||||
assert.Equal(t, "cachyos", state.Self.Hostname)
|
||||
}
|
||||
31
core/internal/server/tailscale/types.go
Normal file
31
core/internal/server/tailscale/types.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package tailscale
|
||||
|
||||
// TailscaleState represents the current state of the Tailscale daemon.
|
||||
type TailscaleState struct {
|
||||
Connected bool `json:"connected"`
|
||||
Version string `json:"version"`
|
||||
BackendState string `json:"backendState"`
|
||||
MagicDNSSuffix string `json:"magicDnsSuffix"`
|
||||
TailnetName string `json:"tailnetName"`
|
||||
Self Peer `json:"self"`
|
||||
Peers []Peer `json:"peers"`
|
||||
}
|
||||
|
||||
// Peer represents a single node in the Tailscale network.
|
||||
type Peer struct {
|
||||
ID string `json:"id"`
|
||||
Hostname string `json:"hostname"`
|
||||
DNSName string `json:"dnsName"`
|
||||
TailscaleIP string `json:"tailscaleIp"`
|
||||
TailscaleIPv6 string `json:"tailscaleIpv6,omitempty"`
|
||||
OS string `json:"os"`
|
||||
Online bool `json:"online"`
|
||||
LastSeen string `json:"lastSeen,omitempty"`
|
||||
ExitNode bool `json:"exitNode"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Owner string `json:"owner"`
|
||||
Relay string `json:"relay,omitempty"`
|
||||
Active bool `json:"active"`
|
||||
RxBytes int64 `json:"rxBytes"`
|
||||
TxBytes int64 `json:"txBytes"`
|
||||
}
|
||||
@@ -111,7 +111,7 @@
|
||||
inherit version;
|
||||
pname = "dms-shell";
|
||||
src = ./core;
|
||||
vendorHash = "sha256-dEk7IOd6aQwaxZruxQclN7TGMyb8EJOl6NBWRsoZ9HQ=";
|
||||
vendorHash = "sha256-nvxFHQhOfBGl3h51fgYDb39K0NCj+H8mAEyKr1qOwJQ=";
|
||||
|
||||
subPackages = [ "cmd/dms" ];
|
||||
|
||||
|
||||
62
quickshell/Common/AnimVariants.qml
Normal file
62
quickshell/Common/AnimVariants.qml
Normal file
@@ -0,0 +1,62 @@
|
||||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Common
|
||||
|
||||
// AnimVariants — central tuning for animation variants (Material/Fluent/Dynamic)
|
||||
// and motion effects (Standard/Directional/Depth). Lookups are indexed by enum
|
||||
// value: animationVariant 0=Material, 1=Fluent, 2=Dynamic; motionEffect
|
||||
// 0=Standard, 1=Directional, 2=Depth.
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property int _variant: (typeof SettingsData === "undefined") ? 0 : SettingsData.animationVariant
|
||||
readonly property int _effect: (typeof SettingsData === "undefined") ? 0 : SettingsData.motionEffect
|
||||
|
||||
readonly property var _enterCurves: [Anims.expressiveDefaultSpatial, Anims.standardDecel, Anims.expressiveFastSpatial]
|
||||
readonly property var _exitCurves: [Anims.emphasized, Anims.standard, Anims.emphasized]
|
||||
readonly property var _directionalExitCurves: [Anims.emphasized, Anims.emphasizedAccel, Anims.emphasizedAccel]
|
||||
readonly property var _enterDurationFactors: [1.0, 0.9, 1.08]
|
||||
readonly property var _exitDurationFactors: [1.0, 0.85, 0.92]
|
||||
readonly property var _cleanupPaddings: [50, 8, 24]
|
||||
readonly property var _effectScaleCollapsed: [0.96, 1.0, 0.88]
|
||||
readonly property var _effectAnimOffsets: [16, 144, 56]
|
||||
|
||||
readonly property list<real> variantEnterCurve: _enterCurves[_variant] || _enterCurves[0]
|
||||
readonly property list<real> variantExitCurve: _exitCurves[_variant] || _exitCurves[0]
|
||||
|
||||
readonly property list<real> variantModalEnterCurve: isDirectionalEffect && _variant !== 0 ? (_enterCurves[_variant] || _enterCurves[0]) : variantEnterCurve
|
||||
readonly property list<real> variantModalExitCurve: isDirectionalEffect ? (_directionalExitCurves[_variant] || _exitCurves[0]) : variantExitCurve
|
||||
|
||||
readonly property list<real> variantPopoutEnterCurve: isDirectionalEffect ? (_variant === 0 ? Anims.standardDecel : (_enterCurves[_variant] || _enterCurves[0])) : variantEnterCurve
|
||||
readonly property list<real> variantPopoutExitCurve: isDirectionalEffect ? (_directionalExitCurves[_variant] || _exitCurves[0]) : variantExitCurve
|
||||
|
||||
readonly property real variantEnterDurationFactor: _enterDurationFactors[_variant] !== undefined ? _enterDurationFactors[_variant] : 1.0
|
||||
readonly property real variantExitDurationFactor: _exitDurationFactors[_variant] !== undefined ? _exitDurationFactors[_variant] : 1.0
|
||||
|
||||
// Fluent: opacity at ~55% of duration; Material/Dynamic: 1:1 with position
|
||||
readonly property real variantOpacityDurationScale: _variant === 1 ? 0.55 : 1.0
|
||||
|
||||
function variantDuration(baseDuration, entering) {
|
||||
const factor = entering ? variantEnterDurationFactor : variantExitDurationFactor;
|
||||
return Math.max(0, Math.round(baseDuration * factor));
|
||||
}
|
||||
|
||||
function variantExitCleanupPadding() {
|
||||
return _cleanupPaddings[_effect] !== undefined ? _cleanupPaddings[_effect] : 50;
|
||||
}
|
||||
|
||||
function variantCloseInterval(baseDuration) {
|
||||
return variantDuration(baseDuration, false) + variantExitCleanupPadding();
|
||||
}
|
||||
|
||||
readonly property bool isDirectionalEffect: isConnectedEffect || _effect === 1
|
||||
readonly property bool isDepthEffect: _effect === 2
|
||||
readonly property bool isConnectedEffect: (typeof SettingsData !== "undefined") && SettingsData.connectedFrameModeActive
|
||||
|
||||
readonly property real effectScaleCollapsed: _effectScaleCollapsed[_effect] !== undefined ? _effectScaleCollapsed[_effect] : 0.96
|
||||
readonly property real effectAnimOffset: _effectAnimOffsets[_effect] !== undefined ? _effectAnimOffsets[_effect] : 16
|
||||
}
|
||||
@@ -22,4 +22,9 @@ Singleton {
|
||||
readonly property var standard: [0.20, 0.00, 0.00, 1.00, 1.00, 1.00]
|
||||
readonly property var standardDecel: [0.00, 0.00, 0.00, 1.00, 1.00, 1.00]
|
||||
readonly property var standardAccel: [0.30, 0.00, 1.00, 1.00, 1.00, 1.00]
|
||||
|
||||
// Used by AnimVariants for variant/effect logic
|
||||
readonly property var expressiveDefaultSpatial: [0.38, 1.21, 0.22, 1, 1, 1]
|
||||
readonly property var expressiveFastSpatial: [0.34, 1.5, 0.2, 1.0, 1.0, 1.0]
|
||||
readonly property var expressiveEffects: [0.34, 0.8, 0.34, 1, 1, 1]
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ import QtCore
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Services
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
readonly property var log: Log.scoped("CacheData")
|
||||
|
||||
readonly property int cacheConfigVersion: 1
|
||||
|
||||
@@ -131,7 +133,7 @@ Singleton {
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("CacheData: Failed to parse cache:", e.message);
|
||||
log.warn("Failed to parse cache:", e.message);
|
||||
} finally {
|
||||
_loading = false;
|
||||
}
|
||||
@@ -149,7 +151,7 @@ Singleton {
|
||||
}
|
||||
|
||||
function migrateFromUndefinedToV1(cache) {
|
||||
console.info("CacheData: Migrating configuration from undefined to version 1");
|
||||
log.info("Migrating configuration from undefined to version 1");
|
||||
}
|
||||
|
||||
function cleanupUnusedKeys() {
|
||||
@@ -164,7 +166,7 @@ Singleton {
|
||||
|
||||
for (const key in cache) {
|
||||
if (!validKeys.includes(key)) {
|
||||
console.log("CacheData: Removing unused key:", key);
|
||||
log.debug("Removing unused key:", key);
|
||||
delete cache[key];
|
||||
needsSave = true;
|
||||
}
|
||||
@@ -174,7 +176,7 @@ Singleton {
|
||||
cacheFile.setText(JSON.stringify(cache, null, 2));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("CacheData: Failed to cleanup unused keys:", e.message);
|
||||
log.warn("Failed to cleanup unused keys:", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,7 +186,7 @@ Singleton {
|
||||
if (content && content.trim())
|
||||
return JSON.parse(content);
|
||||
} catch (e) {
|
||||
console.warn("CacheData: Failed to parse launcher cache:", e.message);
|
||||
log.warn("Failed to parse launcher cache:", e.message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -220,7 +222,7 @@ Singleton {
|
||||
}
|
||||
onLoadFailed: error => {
|
||||
if (!isGreeterMode) {
|
||||
console.info("CacheData: No cache file found, starting fresh");
|
||||
log.info("No cache file found, starting fresh");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
481
quickshell/Common/ConnectedModeState.qml
Normal file
481
quickshell/Common/ConnectedModeState.qml
Normal file
@@ -0,0 +1,481 @@
|
||||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property var emptyDockState: ({
|
||||
"reveal": false,
|
||||
"barSide": "bottom",
|
||||
"bodyX": 0,
|
||||
"bodyY": 0,
|
||||
"bodyW": 0,
|
||||
"bodyH": 0,
|
||||
"slideX": 0,
|
||||
"slideY": 0
|
||||
})
|
||||
|
||||
// Popout state (updated by DankPopout when connectedFrameModeActive)
|
||||
property string popoutOwnerId: ""
|
||||
property bool popoutVisible: false
|
||||
property string popoutBarSide: "top"
|
||||
property real popoutBodyX: 0
|
||||
property real popoutBodyY: 0
|
||||
property real popoutBodyW: 0
|
||||
property real popoutBodyH: 0
|
||||
property real popoutAnimX: 0
|
||||
property real popoutAnimY: 0
|
||||
property string popoutScreen: ""
|
||||
property bool popoutOmitStartConnector: false
|
||||
property bool popoutOmitEndConnector: false
|
||||
|
||||
// Dock state (updated by Dock when connectedFrameModeActive), keyed by screen.name
|
||||
property var dockStates: ({})
|
||||
|
||||
// Dock slide offsets — hot-path updates separated from full geometry state
|
||||
property var dockSlides: ({})
|
||||
|
||||
function _cloneDict(src) {
|
||||
const next = {};
|
||||
for (const k in src)
|
||||
next[k] = src[k];
|
||||
return next;
|
||||
}
|
||||
|
||||
function hasPopoutOwner(claimId) {
|
||||
return !!claimId && popoutOwnerId === claimId;
|
||||
}
|
||||
|
||||
function claimPopout(claimId, state) {
|
||||
if (!claimId)
|
||||
return false;
|
||||
|
||||
popoutOwnerId = claimId;
|
||||
return updatePopout(claimId, state);
|
||||
}
|
||||
|
||||
function updatePopout(claimId, state) {
|
||||
if (!hasPopoutOwner(claimId) || !state)
|
||||
return false;
|
||||
|
||||
if (state.visible !== undefined)
|
||||
popoutVisible = !!state.visible;
|
||||
if (state.barSide !== undefined)
|
||||
popoutBarSide = state.barSide || "top";
|
||||
if (state.bodyX !== undefined)
|
||||
popoutBodyX = Number(state.bodyX);
|
||||
if (state.bodyY !== undefined)
|
||||
popoutBodyY = Number(state.bodyY);
|
||||
if (state.bodyW !== undefined)
|
||||
popoutBodyW = Number(state.bodyW);
|
||||
if (state.bodyH !== undefined)
|
||||
popoutBodyH = Number(state.bodyH);
|
||||
if (state.animX !== undefined)
|
||||
popoutAnimX = Number(state.animX);
|
||||
if (state.animY !== undefined)
|
||||
popoutAnimY = Number(state.animY);
|
||||
if (state.screen !== undefined)
|
||||
popoutScreen = state.screen || "";
|
||||
if (state.omitStartConnector !== undefined)
|
||||
popoutOmitStartConnector = !!state.omitStartConnector;
|
||||
if (state.omitEndConnector !== undefined)
|
||||
popoutOmitEndConnector = !!state.omitEndConnector;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function releasePopout(claimId) {
|
||||
if (!hasPopoutOwner(claimId))
|
||||
return false;
|
||||
|
||||
popoutOwnerId = "";
|
||||
popoutVisible = false;
|
||||
popoutBarSide = "top";
|
||||
popoutBodyX = 0;
|
||||
popoutBodyY = 0;
|
||||
popoutBodyW = 0;
|
||||
popoutBodyH = 0;
|
||||
popoutAnimX = 0;
|
||||
popoutAnimY = 0;
|
||||
popoutScreen = "";
|
||||
popoutOmitStartConnector = false;
|
||||
popoutOmitEndConnector = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function setPopoutAnim(claimId, animX, animY) {
|
||||
if (!hasPopoutOwner(claimId))
|
||||
return false;
|
||||
if (animX !== undefined) {
|
||||
const nextX = Number(animX);
|
||||
if (!isNaN(nextX) && popoutAnimX !== nextX)
|
||||
popoutAnimX = nextX;
|
||||
}
|
||||
if (animY !== undefined) {
|
||||
const nextY = Number(animY);
|
||||
if (!isNaN(nextY) && popoutAnimY !== nextY)
|
||||
popoutAnimY = nextY;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function setPopoutBody(claimId, bodyX, bodyY, bodyW, bodyH) {
|
||||
if (!hasPopoutOwner(claimId))
|
||||
return false;
|
||||
if (bodyX !== undefined) {
|
||||
const nextX = Number(bodyX);
|
||||
if (!isNaN(nextX) && popoutBodyX !== nextX)
|
||||
popoutBodyX = nextX;
|
||||
}
|
||||
if (bodyY !== undefined) {
|
||||
const nextY = Number(bodyY);
|
||||
if (!isNaN(nextY) && popoutBodyY !== nextY)
|
||||
popoutBodyY = nextY;
|
||||
}
|
||||
if (bodyW !== undefined) {
|
||||
const nextW = Number(bodyW);
|
||||
if (!isNaN(nextW) && popoutBodyW !== nextW)
|
||||
popoutBodyW = nextW;
|
||||
}
|
||||
if (bodyH !== undefined) {
|
||||
const nextH = Number(bodyH);
|
||||
if (!isNaN(nextH) && popoutBodyH !== nextH)
|
||||
popoutBodyH = nextH;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function _normalizeDockState(state) {
|
||||
return {
|
||||
"reveal": !!(state && state.reveal),
|
||||
"barSide": state && state.barSide ? state.barSide : "bottom",
|
||||
"bodyX": Number(state && state.bodyX !== undefined ? state.bodyX : 0),
|
||||
"bodyY": Number(state && state.bodyY !== undefined ? state.bodyY : 0),
|
||||
"bodyW": Number(state && state.bodyW !== undefined ? state.bodyW : 0),
|
||||
"bodyH": Number(state && state.bodyH !== undefined ? state.bodyH : 0),
|
||||
"slideX": Number(state && state.slideX !== undefined ? state.slideX : 0),
|
||||
"slideY": Number(state && state.slideY !== undefined ? state.slideY : 0)
|
||||
};
|
||||
}
|
||||
|
||||
function _sameDockState(a, b) {
|
||||
if (!a || !b)
|
||||
return false;
|
||||
return a.reveal === b.reveal && a.barSide === b.barSide && Math.abs(a.bodyX - b.bodyX) < 0.5 && Math.abs(a.bodyY - b.bodyY) < 0.5 && Math.abs(a.bodyW - b.bodyW) < 0.5 && Math.abs(a.bodyH - b.bodyH) < 0.5 && Math.abs(a.slideX - b.slideX) < 0.5 && Math.abs(a.slideY - b.slideY) < 0.5;
|
||||
}
|
||||
|
||||
function setDockState(screenName, state) {
|
||||
if (!screenName || !state)
|
||||
return false;
|
||||
|
||||
const normalized = _normalizeDockState(state);
|
||||
if (_sameDockState(dockStates[screenName], normalized))
|
||||
return true;
|
||||
|
||||
const next = _cloneDict(dockStates);
|
||||
next[screenName] = normalized;
|
||||
dockStates = next;
|
||||
return true;
|
||||
}
|
||||
|
||||
function clearDockState(screenName) {
|
||||
if (!screenName || !dockStates[screenName])
|
||||
return false;
|
||||
|
||||
const next = _cloneDict(dockStates);
|
||||
delete next[screenName];
|
||||
dockStates = next;
|
||||
|
||||
// Also clear corresponding slide
|
||||
if (dockSlides[screenName]) {
|
||||
const nextSlides = _cloneDict(dockSlides);
|
||||
delete nextSlides[screenName];
|
||||
dockSlides = nextSlides;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function setDockSlide(screenName, x, y) {
|
||||
if (!screenName)
|
||||
return false;
|
||||
const numX = Number(x);
|
||||
const numY = Number(y);
|
||||
const cur = dockSlides[screenName];
|
||||
if (cur && Math.abs(cur.x - numX) < 0.5 && Math.abs(cur.y - numY) < 0.5)
|
||||
return true;
|
||||
const next = _cloneDict(dockSlides);
|
||||
next[screenName] = {
|
||||
"x": numX,
|
||||
"y": numY
|
||||
};
|
||||
dockSlides = next;
|
||||
return true;
|
||||
}
|
||||
|
||||
readonly property var emptyNotificationState: ({
|
||||
"visible": false,
|
||||
"barSide": "top",
|
||||
"bodyX": 0,
|
||||
"bodyY": 0,
|
||||
"bodyW": 0,
|
||||
"bodyH": 0,
|
||||
"omitStartConnector": false,
|
||||
"omitEndConnector": false
|
||||
})
|
||||
|
||||
property var notificationStates: ({})
|
||||
|
||||
function _normalizeNotificationState(state) {
|
||||
return {
|
||||
"visible": !!(state && state.visible),
|
||||
"barSide": state && state.barSide ? state.barSide : "top",
|
||||
"bodyX": Number(state && state.bodyX !== undefined ? state.bodyX : 0),
|
||||
"bodyY": Number(state && state.bodyY !== undefined ? state.bodyY : 0),
|
||||
"bodyW": Number(state && state.bodyW !== undefined ? state.bodyW : 0),
|
||||
"bodyH": Number(state && state.bodyH !== undefined ? state.bodyH : 0),
|
||||
"omitStartConnector": !!(state && state.omitStartConnector),
|
||||
"omitEndConnector": !!(state && state.omitEndConnector)
|
||||
};
|
||||
}
|
||||
|
||||
function _sameNotificationGeometry(a, b) {
|
||||
if (!a || !b)
|
||||
return false;
|
||||
return Math.abs(Number(a.bodyX) - Number(b.bodyX)) < 0.5 && Math.abs(Number(a.bodyY) - Number(b.bodyY)) < 0.5 && Math.abs(Number(a.bodyW) - Number(b.bodyW)) < 0.5 && Math.abs(Number(a.bodyH) - Number(b.bodyH)) < 0.5;
|
||||
}
|
||||
|
||||
function _sameNotificationState(a, b) {
|
||||
if (!a || !b)
|
||||
return false;
|
||||
return a.visible === b.visible && a.barSide === b.barSide && a.omitStartConnector === b.omitStartConnector && a.omitEndConnector === b.omitEndConnector && _sameNotificationGeometry(a, b);
|
||||
}
|
||||
|
||||
function setNotificationState(screenName, state) {
|
||||
if (!screenName || !state)
|
||||
return false;
|
||||
|
||||
const normalized = _normalizeNotificationState(state);
|
||||
if (_sameNotificationState(notificationStates[screenName], normalized))
|
||||
return true;
|
||||
|
||||
const next = _cloneDict(notificationStates);
|
||||
next[screenName] = normalized;
|
||||
notificationStates = next;
|
||||
return true;
|
||||
}
|
||||
|
||||
function clearNotificationState(screenName) {
|
||||
if (!screenName || !notificationStates[screenName])
|
||||
return false;
|
||||
|
||||
const next = _cloneDict(notificationStates);
|
||||
delete next[screenName];
|
||||
notificationStates = next;
|
||||
return true;
|
||||
}
|
||||
|
||||
// DankModal / DankLauncherV2Modal State
|
||||
readonly property var emptyModalState: ({
|
||||
"visible": false,
|
||||
"barSide": "bottom",
|
||||
"bodyX": 0,
|
||||
"bodyY": 0,
|
||||
"bodyW": 0,
|
||||
"bodyH": 0,
|
||||
"animX": 0,
|
||||
"animY": 0,
|
||||
"omitStartConnector": false,
|
||||
"omitEndConnector": false
|
||||
})
|
||||
|
||||
property var modalStates: ({})
|
||||
|
||||
function _normalizeModalState(state) {
|
||||
return {
|
||||
"visible": !!(state && state.visible),
|
||||
"barSide": state && state.barSide ? state.barSide : "bottom",
|
||||
"bodyX": Number(state && state.bodyX !== undefined ? state.bodyX : 0),
|
||||
"bodyY": Number(state && state.bodyY !== undefined ? state.bodyY : 0),
|
||||
"bodyW": Number(state && state.bodyW !== undefined ? state.bodyW : 0),
|
||||
"bodyH": Number(state && state.bodyH !== undefined ? state.bodyH : 0),
|
||||
"animX": Number(state && state.animX !== undefined ? state.animX : 0),
|
||||
"animY": Number(state && state.animY !== undefined ? state.animY : 0),
|
||||
"omitStartConnector": !!(state && state.omitStartConnector),
|
||||
"omitEndConnector": !!(state && state.omitEndConnector)
|
||||
};
|
||||
}
|
||||
|
||||
function _sameModalGeometry(a, b) {
|
||||
if (!a || !b)
|
||||
return false;
|
||||
return Math.abs(Number(a.bodyX) - Number(b.bodyX)) < 0.5 && Math.abs(Number(a.bodyY) - Number(b.bodyY)) < 0.5 && Math.abs(Number(a.bodyW) - Number(b.bodyW)) < 0.5 && Math.abs(Number(a.bodyH) - Number(b.bodyH)) < 0.5 && Math.abs(Number(a.animX) - Number(b.animX)) < 0.5 && Math.abs(Number(a.animY) - Number(b.animY)) < 0.5;
|
||||
}
|
||||
|
||||
function _sameModalState(a, b) {
|
||||
if (!a || !b)
|
||||
return false;
|
||||
return a.visible === b.visible && a.barSide === b.barSide && a.omitStartConnector === b.omitStartConnector && a.omitEndConnector === b.omitEndConnector && _sameModalGeometry(a, b);
|
||||
}
|
||||
|
||||
function setModalState(screenName, state) {
|
||||
if (!screenName || !state)
|
||||
return false;
|
||||
|
||||
const normalized = _normalizeModalState(state);
|
||||
if (_sameModalState(modalStates[screenName], normalized))
|
||||
return true;
|
||||
|
||||
const next = _cloneDict(modalStates);
|
||||
next[screenName] = normalized;
|
||||
modalStates = next;
|
||||
return true;
|
||||
}
|
||||
|
||||
function clearModalState(screenName) {
|
||||
if (!screenName || !modalStates[screenName])
|
||||
return false;
|
||||
|
||||
const next = _cloneDict(modalStates);
|
||||
delete next[screenName];
|
||||
modalStates = next;
|
||||
return true;
|
||||
}
|
||||
|
||||
function setModalAnim(screenName, animX, animY) {
|
||||
const cur = screenName ? modalStates[screenName] : null;
|
||||
if (!cur)
|
||||
return false;
|
||||
const nax = animX !== undefined ? Number(animX) : cur.animX;
|
||||
const nay = animY !== undefined ? Number(animY) : cur.animY;
|
||||
if (Math.abs(nax - cur.animX) < 0.5 && Math.abs(nay - cur.animY) < 0.5)
|
||||
return false;
|
||||
const next = _cloneDict(modalStates);
|
||||
next[screenName] = Object.assign({}, cur, {
|
||||
"animX": nax,
|
||||
"animY": nay
|
||||
});
|
||||
modalStates = next;
|
||||
return true;
|
||||
}
|
||||
|
||||
function setModalBody(screenName, bodyX, bodyY, bodyW, bodyH) {
|
||||
const cur = screenName ? modalStates[screenName] : null;
|
||||
if (!cur)
|
||||
return false;
|
||||
const nx = bodyX !== undefined ? Number(bodyX) : cur.bodyX;
|
||||
const ny = bodyY !== undefined ? Number(bodyY) : cur.bodyY;
|
||||
const nw = bodyW !== undefined ? Number(bodyW) : cur.bodyW;
|
||||
const nh = bodyH !== undefined ? Number(bodyH) : cur.bodyH;
|
||||
if (Math.abs(nx - cur.bodyX) < 0.5 && Math.abs(ny - cur.bodyY) < 0.5 && Math.abs(nw - cur.bodyW) < 0.5 && Math.abs(nh - cur.bodyH) < 0.5)
|
||||
return false;
|
||||
const next = _cloneDict(modalStates);
|
||||
next[screenName] = Object.assign({}, cur, {
|
||||
"bodyX": nx,
|
||||
"bodyY": ny,
|
||||
"bodyW": nw,
|
||||
"bodyH": nh
|
||||
});
|
||||
modalStates = next;
|
||||
return true;
|
||||
}
|
||||
|
||||
property var dockRetractRequests: ({})
|
||||
|
||||
function requestDockRetract(requesterId, screenName, side) {
|
||||
if (!requesterId || !screenName || !side)
|
||||
return false;
|
||||
const existing = dockRetractRequests[requesterId];
|
||||
if (existing && existing.screenName === screenName && existing.side === side)
|
||||
return true;
|
||||
const next = _cloneDict(dockRetractRequests);
|
||||
next[requesterId] = {
|
||||
"screenName": screenName,
|
||||
"side": side
|
||||
};
|
||||
dockRetractRequests = next;
|
||||
return true;
|
||||
}
|
||||
|
||||
function releaseDockRetract(requesterId) {
|
||||
if (!requesterId || !dockRetractRequests[requesterId])
|
||||
return false;
|
||||
const next = _cloneDict(dockRetractRequests);
|
||||
delete next[requesterId];
|
||||
dockRetractRequests = next;
|
||||
return true;
|
||||
}
|
||||
|
||||
function dockRetractActiveForSide(screenName, side) {
|
||||
if (!screenName || !side)
|
||||
return false;
|
||||
for (const k in dockRetractRequests) {
|
||||
const r = dockRetractRequests[k];
|
||||
if (r && r.screenName === screenName && r.side === side)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Prune state for screens that are no longer connected. Stale entries
|
||||
// accumulate across hotplug cycles otherwise — Frame's per-screen
|
||||
// FrameInstance doesn't notice when its peer dicts go orphan.
|
||||
function _pruneToLiveScreens() {
|
||||
const live = {};
|
||||
const screens = Quickshell.screens || [];
|
||||
for (let i = 0; i < screens.length; i++) {
|
||||
const s = screens[i];
|
||||
if (s && s.name)
|
||||
live[s.name] = true;
|
||||
}
|
||||
|
||||
function pruneKeyed(dict) {
|
||||
let changed = false;
|
||||
const next = {};
|
||||
for (const k in dict) {
|
||||
if (live[k])
|
||||
next[k] = dict[k];
|
||||
else
|
||||
changed = true;
|
||||
}
|
||||
return changed ? next : null;
|
||||
}
|
||||
|
||||
const nextDock = pruneKeyed(dockStates);
|
||||
if (nextDock !== null)
|
||||
dockStates = nextDock;
|
||||
const nextSlides = pruneKeyed(dockSlides);
|
||||
if (nextSlides !== null)
|
||||
dockSlides = nextSlides;
|
||||
const nextNotif = pruneKeyed(notificationStates);
|
||||
if (nextNotif !== null)
|
||||
notificationStates = nextNotif;
|
||||
const nextModal = pruneKeyed(modalStates);
|
||||
if (nextModal !== null)
|
||||
modalStates = nextModal;
|
||||
|
||||
let retractChanged = false;
|
||||
const nextRetract = {};
|
||||
for (const k in dockRetractRequests) {
|
||||
const r = dockRetractRequests[k];
|
||||
if (r && live[r.screenName])
|
||||
nextRetract[k] = r;
|
||||
else
|
||||
retractChanged = true;
|
||||
}
|
||||
if (retractChanged)
|
||||
dockRetractRequests = nextRetract;
|
||||
|
||||
if (popoutOwnerId && popoutScreen && !live[popoutScreen])
|
||||
releasePopout(popoutOwnerId);
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: Quickshell
|
||||
function onScreensChanged() {
|
||||
root._pruneToLiveScreens();
|
||||
}
|
||||
}
|
||||
}
|
||||
68
quickshell/Common/ConnectorGeometry.js
Normal file
68
quickshell/Common/ConnectorGeometry.js
Normal file
@@ -0,0 +1,68 @@
|
||||
.pragma library
|
||||
|
||||
// Geometry for connected-frame arc connectors.
|
||||
// `barSide` is one of "top" | "bottom" | "left" | "right" — the edge where the
|
||||
// host bar/dock sits. `placement` is "left" (start) or "right" (end) of the
|
||||
// body's far edge. `radius` is the connector's arc radius. `spacing` is the
|
||||
// gap between the host edge and the body.
|
||||
|
||||
function isVertical(barSide) {
|
||||
return barSide === "left" || barSide === "right";
|
||||
}
|
||||
|
||||
function isHorizontal(barSide) {
|
||||
return barSide === "top" || barSide === "bottom";
|
||||
}
|
||||
|
||||
function connectorWidth(barSide, spacing, radius) {
|
||||
return isVertical(barSide) ? (spacing + radius) : radius;
|
||||
}
|
||||
|
||||
function connectorHeight(barSide, spacing, radius) {
|
||||
return isVertical(barSide) ? radius : (spacing + radius);
|
||||
}
|
||||
|
||||
function seamX(barSide, baseX, bodyWidth, placement) {
|
||||
if (!isVertical(barSide))
|
||||
return placement === "left" ? baseX : baseX + bodyWidth;
|
||||
return barSide === "left" ? baseX : baseX + bodyWidth;
|
||||
}
|
||||
|
||||
function seamY(barSide, baseY, bodyHeight, placement) {
|
||||
if (barSide === "top")
|
||||
return baseY;
|
||||
if (barSide === "bottom")
|
||||
return baseY + bodyHeight;
|
||||
return placement === "left" ? baseY : baseY + bodyHeight;
|
||||
}
|
||||
|
||||
function connectorX(barSide, baseX, bodyWidth, placement, spacing, radius) {
|
||||
var s = seamX(barSide, baseX, bodyWidth, placement);
|
||||
var w = connectorWidth(barSide, spacing, radius);
|
||||
if (!isVertical(barSide))
|
||||
return placement === "left" ? s - w : s;
|
||||
return barSide === "left" ? s : s - w;
|
||||
}
|
||||
|
||||
function connectorY(barSide, baseY, bodyHeight, placement, spacing, radius) {
|
||||
var s = seamY(barSide, baseY, bodyHeight, placement);
|
||||
var h = connectorHeight(barSide, spacing, radius);
|
||||
if (barSide === "top")
|
||||
return s;
|
||||
if (barSide === "bottom")
|
||||
return s - h;
|
||||
return placement === "left" ? s - h : s;
|
||||
}
|
||||
|
||||
// Which corner of the connector's bounding rect hosts the concave arc that
|
||||
// carves into the body. Used for arc-sweep orientation.
|
||||
function arcCorner(barSide, placement) {
|
||||
var left = placement === "left";
|
||||
if (barSide === "top")
|
||||
return left ? "bottomLeft" : "bottomRight";
|
||||
if (barSide === "bottom")
|
||||
return left ? "topLeft" : "topRight";
|
||||
if (barSide === "left")
|
||||
return left ? "topRight" : "bottomRight";
|
||||
return left ? "topLeft" : "bottomLeft";
|
||||
}
|
||||
55
quickshell/Common/DeferredAction.qml
Normal file
55
quickshell/Common/DeferredAction.qml
Normal file
@@ -0,0 +1,55 @@
|
||||
import QtQuick
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
visible: false
|
||||
width: 0
|
||||
height: 0
|
||||
|
||||
property int interval: 0
|
||||
property bool pending: false
|
||||
|
||||
signal triggered
|
||||
|
||||
function schedule() {
|
||||
if (!root.enabled || root.pending)
|
||||
return;
|
||||
root.pending = true;
|
||||
deferTimer.restart();
|
||||
}
|
||||
|
||||
function restart() {
|
||||
if (!root.enabled)
|
||||
return;
|
||||
root.pending = true;
|
||||
deferTimer.restart();
|
||||
}
|
||||
|
||||
function flush() {
|
||||
if (!root.pending)
|
||||
return;
|
||||
deferTimer.stop();
|
||||
root.pending = false;
|
||||
root.triggered();
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
deferTimer.stop();
|
||||
root.pending = false;
|
||||
}
|
||||
|
||||
onEnabledChanged: {
|
||||
if (!enabled)
|
||||
cancel();
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: deferTimer
|
||||
interval: root.interval
|
||||
repeat: false
|
||||
onTriggered: root.flush()
|
||||
}
|
||||
|
||||
Component.onDestruction: cancel()
|
||||
}
|
||||
@@ -13,8 +13,13 @@ Item {
|
||||
|
||||
property color targetColor: "white"
|
||||
property real targetRadius: Theme.cornerRadius
|
||||
property real topLeftRadius: targetRadius
|
||||
property real topRightRadius: targetRadius
|
||||
property real bottomLeftRadius: targetRadius
|
||||
property real bottomRightRadius: targetRadius
|
||||
property color borderColor: "transparent"
|
||||
property real borderWidth: 0
|
||||
property bool useCustomSource: false
|
||||
|
||||
property bool shadowEnabled: Theme.elevationEnabled
|
||||
property real shadowBlurPx: level && level.blurPx !== undefined ? level.blurPx : 0
|
||||
@@ -46,7 +51,11 @@ Item {
|
||||
Rectangle {
|
||||
id: sourceRect
|
||||
anchors.fill: parent
|
||||
radius: root.targetRadius
|
||||
visible: !root.useCustomSource
|
||||
topLeftRadius: root.topLeftRadius
|
||||
topRightRadius: root.topRightRadius
|
||||
bottomLeftRadius: root.bottomLeftRadius
|
||||
bottomRightRadius: root.bottomRightRadius
|
||||
color: root.targetColor
|
||||
border.color: root.borderColor
|
||||
border.width: root.borderWidth
|
||||
|
||||
@@ -5,9 +5,11 @@ import QtQuick
|
||||
import Qt.labs.folderlistmodel
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Services
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
readonly property var log: Log.scoped("I18n")
|
||||
|
||||
property string _resolvedLocale: "en"
|
||||
|
||||
@@ -54,15 +56,15 @@ Singleton {
|
||||
try {
|
||||
root.translations = JSON.parse(text());
|
||||
root.translationsLoaded = true;
|
||||
console.info(`I18n: Loaded translations for '${root._resolvedLocale}' (${Object.keys(root.translations).length} contexts)`);
|
||||
log.info(`I18n: Loaded translations for '${root._resolvedLocale}' (${Object.keys(root.translations).length} contexts)`);
|
||||
} catch (e) {
|
||||
console.warn(`I18n: Error parsing '${root._resolvedLocale}':`, e, "- falling back to English");
|
||||
log.warn(`I18n: Error parsing '${root._resolvedLocale}':`, e, "- falling back to English");
|
||||
root._fallbackToEnglish();
|
||||
}
|
||||
}
|
||||
|
||||
onLoadFailed: error => {
|
||||
console.warn(`I18n: Failed to load '${root._resolvedLocale}' (${error}), ` + "falling back to English");
|
||||
log.warn(`I18n: Failed to load '${root._resolvedLocale}' (${error}), ` + "falling back to English");
|
||||
root._fallbackToEnglish();
|
||||
}
|
||||
}
|
||||
@@ -105,14 +107,14 @@ Singleton {
|
||||
_selectedPath = fileUrl;
|
||||
translationsLoaded = false;
|
||||
translations = ({});
|
||||
console.info(`I18n: Using locale '${localeTag}' from ${fileUrl}`);
|
||||
log.info(`I18n: Using locale '${localeTag}' from ${fileUrl}`);
|
||||
}
|
||||
|
||||
function _fallbackToEnglish() {
|
||||
_selectedPath = "";
|
||||
translationsLoaded = false;
|
||||
translations = ({});
|
||||
console.warn("I18n: Falling back to built-in English strings");
|
||||
log.warn("Falling back to built-in English strings");
|
||||
}
|
||||
|
||||
function tr(term, context) {
|
||||
|
||||
@@ -3,9 +3,11 @@ pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Services
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
readonly property var log: Log.scoped("Proc")
|
||||
|
||||
readonly property int noTimeout: -1
|
||||
property int defaultDebounceMs: 50
|
||||
@@ -112,7 +114,7 @@ Singleton {
|
||||
const safeExitCode = exitCodeValue !== null && exitCodeValue !== undefined ? exitCodeValue : -1;
|
||||
entry.callback(safeOutput, safeExitCode);
|
||||
} catch (e) {
|
||||
console.warn("runCommand callback error for command:", entry.command, "Error:", e);
|
||||
log.warn("runCommand callback error for command:", entry.command, "Error:", e);
|
||||
}
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -12,6 +12,7 @@ import "settings/SessionStore.js" as Store
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
readonly property var log: Log.scoped("SessionData")
|
||||
|
||||
readonly property int sessionConfigVersion: 3
|
||||
|
||||
@@ -30,9 +31,36 @@ Singleton {
|
||||
property bool isLightMode: false
|
||||
property bool doNotDisturb: false
|
||||
property real doNotDisturbUntil: 0
|
||||
property string terminalOverride: ""
|
||||
property bool isSwitchingMode: false
|
||||
property bool suppressOSD: true
|
||||
|
||||
readonly property var terminalOptions: ["ghostty", "kitty", "foot", "alacritty", "wezterm", "konsole", "gnome-terminal", "xterm"]
|
||||
property var installedTerminals: []
|
||||
|
||||
function resolveTerminal() {
|
||||
if (terminalOverride && terminalOverride.length > 0) {
|
||||
return terminalOverride;
|
||||
}
|
||||
const env = Quickshell.env("TERMINAL");
|
||||
if (env && env.length > 0) {
|
||||
return env;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
Process {
|
||||
id: terminalProbe
|
||||
running: true
|
||||
command: ["sh", "-c", "for t in ghostty kitty foot alacritty wezterm konsole gnome-terminal xterm; do command -v \"$t\" >/dev/null 2>&1 && echo \"$t\"; done"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
const found = text.trim().split("\n").filter(line => line.length > 0);
|
||||
root.installedTerminals = found;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: dndExpireTimer
|
||||
repeat: false
|
||||
@@ -230,7 +258,7 @@ Singleton {
|
||||
} catch (e) {
|
||||
_parseError = true;
|
||||
const msg = e.message;
|
||||
console.error("SessionData: Failed to parse session.json - file will not be overwritten.");
|
||||
log.error("Failed to parse session.json - file will not be overwritten.");
|
||||
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse session.json"), msg));
|
||||
}
|
||||
}
|
||||
@@ -310,7 +338,7 @@ Singleton {
|
||||
} catch (e) {
|
||||
_parseError = true;
|
||||
const msg = e.message;
|
||||
console.error("SessionData: Failed to parse session.json - file will not be overwritten.");
|
||||
log.error("Failed to parse session.json - file will not be overwritten.");
|
||||
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse session.json"), msg));
|
||||
}
|
||||
}
|
||||
@@ -525,7 +553,7 @@ Singleton {
|
||||
}
|
||||
|
||||
if (!screen) {
|
||||
console.warn("SessionData: Screen not found");
|
||||
log.warn("Screen not found");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -622,7 +650,7 @@ Singleton {
|
||||
}
|
||||
|
||||
if (!screen) {
|
||||
console.warn("SessionData: Screen not found");
|
||||
log.warn("Screen not found");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -653,7 +681,7 @@ Singleton {
|
||||
}
|
||||
|
||||
if (!screen) {
|
||||
console.warn("SessionData: Screen not found");
|
||||
log.warn("Screen not found");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -684,7 +712,7 @@ Singleton {
|
||||
}
|
||||
|
||||
if (!screen) {
|
||||
console.warn("SessionData: Screen not found");
|
||||
log.warn("Screen not found");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -715,7 +743,7 @@ Singleton {
|
||||
}
|
||||
|
||||
if (!screen) {
|
||||
console.warn("SessionData: Screen not found");
|
||||
log.warn("Screen not found");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,8 +13,9 @@ import "settings/SettingsStore.js" as Store
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
readonly property var log: Log.scoped("SettingsData")
|
||||
|
||||
readonly property int settingsConfigVersion: 5
|
||||
readonly property int settingsConfigVersion: 11
|
||||
|
||||
readonly property bool isGreeterMode: Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true"
|
||||
|
||||
@@ -37,6 +38,18 @@ Singleton {
|
||||
Custom
|
||||
}
|
||||
|
||||
enum AnimationVariant {
|
||||
Material,
|
||||
Fluent,
|
||||
Dynamic
|
||||
}
|
||||
|
||||
enum AnimationEffect {
|
||||
Standard, // 0 — M3: scale-in, rises from below
|
||||
Directional, // 1 — pure large slide, no scale
|
||||
Depth // 2 — medium slide with deep depth scale pop
|
||||
}
|
||||
|
||||
enum SuspendBehavior {
|
||||
Suspend,
|
||||
Hibernate,
|
||||
@@ -168,6 +181,10 @@ Singleton {
|
||||
property int modalCustomAnimationDuration: 150
|
||||
property bool enableRippleEffects: true
|
||||
onEnableRippleEffectsChanged: saveSettings()
|
||||
property int animationVariant: SettingsData.AnimationVariant.Material
|
||||
onAnimationVariantChanged: saveSettings()
|
||||
property int motionEffect: SettingsData.AnimationEffect.Standard
|
||||
onMotionEffectChanged: saveSettings()
|
||||
property bool m3ElevationEnabled: true
|
||||
onM3ElevationEnabledChanged: saveSettings()
|
||||
property int m3ElevationIntensity: 12
|
||||
@@ -186,6 +203,7 @@ Singleton {
|
||||
onPopoutElevationEnabledChanged: saveSettings()
|
||||
property bool barElevationEnabled: true
|
||||
onBarElevationEnabledChanged: saveSettings()
|
||||
|
||||
property bool blurEnabled: false
|
||||
onBlurEnabledChanged: saveSettings()
|
||||
property bool blurForegroundLayers: true
|
||||
@@ -202,6 +220,55 @@ Singleton {
|
||||
property bool blurredWallpaperLayer: false
|
||||
property bool blurWallpaperOnOverview: false
|
||||
|
||||
property bool frameEnabled: false
|
||||
onFrameEnabledChanged: saveSettings()
|
||||
property real frameThickness: 16
|
||||
onFrameThicknessChanged: saveSettings()
|
||||
property real frameRounding: 23
|
||||
onFrameRoundingChanged: saveSettings()
|
||||
property string frameColor: ""
|
||||
onFrameColorChanged: saveSettings()
|
||||
property real frameOpacity: 1.0
|
||||
onFrameOpacityChanged: saveSettings()
|
||||
property var frameScreenPreferences: ["all"]
|
||||
onFrameScreenPreferencesChanged: saveSettings()
|
||||
property real frameBarSize: 40
|
||||
onFrameBarSizeChanged: saveSettings()
|
||||
property bool frameShowOnOverview: false
|
||||
onFrameShowOnOverviewChanged: saveSettings()
|
||||
property bool frameBlurEnabled: true
|
||||
onFrameBlurEnabledChanged: saveSettings()
|
||||
property bool frameCloseGaps: true
|
||||
onFrameCloseGapsChanged: saveSettings()
|
||||
property string frameLauncherEmergeSide: "bottom"
|
||||
onFrameLauncherEmergeSideChanged: saveSettings()
|
||||
property bool frameLauncherArcExtender: false
|
||||
onFrameLauncherArcExtenderChanged: saveSettings()
|
||||
readonly property string frameModalEmergeSide: frameLauncherEmergeSide === "top" ? "bottom" : "top"
|
||||
property string frameMode: "separate"
|
||||
onFrameModeChanged: saveSettings()
|
||||
property var connectedFrameBarStyleBackups: ({})
|
||||
onConnectedFrameBarStyleBackupsChanged: saveSettings()
|
||||
property var connectedFrameModalDarkenBackup: null
|
||||
onConnectedFrameModalDarkenBackupChanged: saveSettings()
|
||||
readonly property bool connectedFrameModeActive: frameEnabled && frameMode === "connected"
|
||||
onConnectedFrameModeActiveChanged: {
|
||||
if (_loading)
|
||||
return;
|
||||
_reconcileConnectedFrameBarStyles();
|
||||
}
|
||||
|
||||
readonly property color effectiveFrameColor: {
|
||||
const fc = frameColor;
|
||||
if (!fc || fc === "default")
|
||||
return Theme.surfaceContainer;
|
||||
if (fc === "primary")
|
||||
return Theme.primary;
|
||||
if (fc === "surface")
|
||||
return Theme.surface;
|
||||
return fc;
|
||||
}
|
||||
|
||||
property bool showLauncherButton: true
|
||||
property bool showWorkspaceSwitcher: true
|
||||
property bool showFocusedWindow: true
|
||||
@@ -493,6 +560,7 @@ Singleton {
|
||||
property bool matugenTemplatePywalfox: true
|
||||
property bool matugenTemplateZenBrowser: true
|
||||
property bool matugenTemplateVesktop: true
|
||||
property bool matugenTemplateVencord: true
|
||||
property bool matugenTemplateEquibop: true
|
||||
property bool matugenTemplateGhostty: true
|
||||
property bool matugenTemplateKitty: true
|
||||
@@ -521,6 +589,7 @@ Singleton {
|
||||
property bool showDock: false
|
||||
property bool dockAutoHide: false
|
||||
property bool dockSmartAutoHide: false
|
||||
property bool dockHideOnFullscreen: true
|
||||
property bool dockGroupByApp: false
|
||||
property bool dockRestoreSpecialWorkspaceOnClick: false
|
||||
property bool dockOpenOnOverview: false
|
||||
@@ -640,6 +709,9 @@ Singleton {
|
||||
property bool updaterUseCustomCommand: false
|
||||
property string updaterCustomCommand: ""
|
||||
property string updaterTerminalAdditionalParams: ""
|
||||
property int updaterIntervalSeconds: 1800
|
||||
property bool updaterIncludeFlatpak: true
|
||||
property bool updaterAllowAUR: true
|
||||
|
||||
property string displayNameMode: "system"
|
||||
property var screenPreferences: ({})
|
||||
@@ -1271,6 +1343,9 @@ Singleton {
|
||||
|
||||
Store.parse(root, obj);
|
||||
|
||||
if (obj?.directionalAnimationMode === 3 && frameMode !== "connected")
|
||||
frameMode = "connected";
|
||||
|
||||
if (obj?.weatherLocation !== undefined)
|
||||
_legacyWeatherLocation = obj.weatherLocation;
|
||||
if (obj?.weatherCoordinates !== undefined)
|
||||
@@ -1291,13 +1366,14 @@ Singleton {
|
||||
} catch (e) {
|
||||
_parseError = true;
|
||||
const msg = e.message;
|
||||
console.error("SettingsData: Failed to parse settings.json - file will not be overwritten. Error:", msg);
|
||||
log.error("Failed to parse settings.json - file will not be overwritten. Error:", msg);
|
||||
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse settings.json"), msg));
|
||||
applyStoredTheme();
|
||||
} finally {
|
||||
_loading = false;
|
||||
}
|
||||
loadPluginSettings();
|
||||
Qt.callLater(() => _reconcileConnectedFrameBarStyles());
|
||||
}
|
||||
|
||||
property var _pendingMigration: null
|
||||
@@ -1312,12 +1388,12 @@ Singleton {
|
||||
if (_isReadOnly) {
|
||||
_hasUnsavedChanges = _checkForUnsavedChanges();
|
||||
if (!wasReadOnly)
|
||||
console.info("SettingsData: settings.json is now read-only");
|
||||
log.info("settings.json is now read-only");
|
||||
} else {
|
||||
_loadedSettingsSnapshot = JSON.stringify(Store.toJson(root));
|
||||
_hasUnsavedChanges = false;
|
||||
if (wasReadOnly)
|
||||
console.info("SettingsData: settings.json is now writable");
|
||||
log.info("settings.json is now writable");
|
||||
if (_pendingMigration)
|
||||
settingsFile.setText(JSON.stringify(_pendingMigration, null, 2));
|
||||
}
|
||||
@@ -1371,7 +1447,7 @@ Singleton {
|
||||
} catch (e) {
|
||||
const msg = e.message || String(e);
|
||||
if (!_isMissingPluginSettingsError(e))
|
||||
console.warn("SettingsData: Failed to load plugin_settings.json. Error:", msg);
|
||||
log.warn("Failed to load plugin_settings.json. Error:", msg);
|
||||
_resetPluginSettings();
|
||||
}
|
||||
}
|
||||
@@ -1388,7 +1464,7 @@ Singleton {
|
||||
} catch (e) {
|
||||
_pluginParseError = true;
|
||||
const msg = e.message;
|
||||
console.error("SettingsData: Failed to parse plugin_settings.json - file will not be overwritten. Error:", msg);
|
||||
log.error("Failed to parse plugin_settings.json - file will not be overwritten. Error:", msg);
|
||||
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse plugin_settings.json"), msg));
|
||||
pluginSettings = {};
|
||||
} finally {
|
||||
@@ -1411,6 +1487,149 @@ Singleton {
|
||||
pluginSettingsFile.setText(JSON.stringify(pluginSettings, null, 2));
|
||||
}
|
||||
|
||||
function _connectedFrameBarStyleSnapshot(config) {
|
||||
return {
|
||||
"shadowIntensity": config?.shadowIntensity ?? 0,
|
||||
"squareCorners": config?.squareCorners ?? false,
|
||||
"gothCornersEnabled": config?.gothCornersEnabled ?? false,
|
||||
"borderEnabled": config?.borderEnabled ?? false
|
||||
};
|
||||
}
|
||||
|
||||
function _hasConnectedFrameBarStyleBackups() {
|
||||
return connectedFrameBarStyleBackups && Object.keys(connectedFrameBarStyleBackups).length > 0;
|
||||
}
|
||||
|
||||
function _captureConnectedFrameBarStyleBackups(configs, overwriteExisting) {
|
||||
if (!Array.isArray(configs))
|
||||
return;
|
||||
|
||||
const nextBackups = JSON.parse(JSON.stringify(connectedFrameBarStyleBackups || {}));
|
||||
const validIds = {};
|
||||
let changed = false;
|
||||
|
||||
for (let i = 0; i < configs.length; i++) {
|
||||
const config = configs[i];
|
||||
if (!config?.id)
|
||||
continue;
|
||||
validIds[config.id] = true;
|
||||
|
||||
if (!overwriteExisting && nextBackups[config.id] !== undefined)
|
||||
continue;
|
||||
|
||||
const snapshot = _connectedFrameBarStyleSnapshot(config);
|
||||
if (JSON.stringify(nextBackups[config.id]) !== JSON.stringify(snapshot)) {
|
||||
nextBackups[config.id] = snapshot;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (overwriteExisting) {
|
||||
for (const barId in nextBackups) {
|
||||
if (validIds[barId])
|
||||
continue;
|
||||
delete nextBackups[barId];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed)
|
||||
connectedFrameBarStyleBackups = nextBackups;
|
||||
}
|
||||
|
||||
function _restoreConnectedFrameBarStyleBackups() {
|
||||
if (!_hasConnectedFrameBarStyleBackups())
|
||||
return;
|
||||
|
||||
const backups = connectedFrameBarStyleBackups || {};
|
||||
const configs = JSON.parse(JSON.stringify(barConfigs));
|
||||
let changed = false;
|
||||
|
||||
for (let i = 0; i < configs.length; i++) {
|
||||
const backup = backups[configs[i].id];
|
||||
if (!backup)
|
||||
continue;
|
||||
for (const key in backup) {
|
||||
if (configs[i][key] === backup[key])
|
||||
continue;
|
||||
configs[i][key] = backup[key];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed)
|
||||
barConfigs = configs;
|
||||
connectedFrameBarStyleBackups = ({});
|
||||
if (changed)
|
||||
updateBarConfigs();
|
||||
}
|
||||
|
||||
// Zeroes out connected-mode-hostile fields (shadow, square/goth corners, border).
|
||||
// Returns { configs, changed } — `configs` is the same ref when no change.
|
||||
function _sanitizeBarConfigsForConnectedFrame(configs) {
|
||||
if (!connectedFrameModeActive || !Array.isArray(configs))
|
||||
return {
|
||||
"configs": configs,
|
||||
"changed": false
|
||||
};
|
||||
|
||||
let anyChanged = false;
|
||||
const out = configs.map(cfg => {
|
||||
if (!cfg)
|
||||
return cfg;
|
||||
let dirty = false;
|
||||
const s = Object.assign({}, cfg);
|
||||
if ((s.shadowIntensity ?? 0) !== 0) {
|
||||
s.shadowIntensity = 0;
|
||||
dirty = true;
|
||||
}
|
||||
if (s.squareCorners ?? false) {
|
||||
s.squareCorners = false;
|
||||
dirty = true;
|
||||
}
|
||||
if (s.gothCornersEnabled ?? false) {
|
||||
s.gothCornersEnabled = false;
|
||||
dirty = true;
|
||||
}
|
||||
if (s.borderEnabled ?? false) {
|
||||
s.borderEnabled = false;
|
||||
dirty = true;
|
||||
}
|
||||
if (dirty)
|
||||
anyChanged = true;
|
||||
return dirty ? s : cfg;
|
||||
});
|
||||
return {
|
||||
"configs": anyChanged ? out : configs,
|
||||
"changed": anyChanged
|
||||
};
|
||||
}
|
||||
|
||||
// Single entry point for connected-mode settings state.
|
||||
// !active → restore backups
|
||||
function _reconcileConnectedFrameBarStyles() {
|
||||
if (!connectedFrameModeActive) {
|
||||
_restoreConnectedFrameBarStyleBackups();
|
||||
if (connectedFrameModalDarkenBackup === true) {
|
||||
connectedFrameModalDarkenBackup = null;
|
||||
set("modalDarkenBackground", true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!_hasConnectedFrameBarStyleBackups())
|
||||
_captureConnectedFrameBarStyleBackups(barConfigs, true);
|
||||
const result = _sanitizeBarConfigsForConnectedFrame(barConfigs);
|
||||
if (result.changed) {
|
||||
barConfigs = result.configs;
|
||||
updateBarConfigs();
|
||||
}
|
||||
// Force modalDarkenBackground off; capture backup if not already set
|
||||
if (modalDarkenBackground) {
|
||||
connectedFrameModalDarkenBackup = true;
|
||||
set("modalDarkenBackground", false);
|
||||
}
|
||||
}
|
||||
|
||||
function detectAvailableIconThemes() {
|
||||
const xdgDataDirs = Quickshell.env("XDG_DATA_DIRS") || "";
|
||||
const localData = Paths.strip(StandardPaths.writableLocation(StandardPaths.GenericDataLocation));
|
||||
@@ -1558,35 +1777,37 @@ Singleton {
|
||||
const spacing = barSpacing !== undefined ? barSpacing : (defaultBar?.spacing ?? 4);
|
||||
const position = barPosition !== undefined ? barPosition : (defaultBar?.position ?? SettingsData.Position.Top);
|
||||
const rawBottomGap = barConfig ? (barConfig.bottomGap !== undefined ? barConfig.bottomGap : (defaultBar?.bottomGap ?? 0)) : (defaultBar?.bottomGap ?? 0);
|
||||
const bottomGap = Math.max(0, rawBottomGap);
|
||||
const isConnected = connectedFrameModeActive;
|
||||
const bottomGap = isConnected ? 0 : Math.max(0, rawBottomGap);
|
||||
|
||||
const useAutoGaps = (barConfig && barConfig.popupGapsAuto !== undefined) ? barConfig.popupGapsAuto : (defaultBar?.popupGapsAuto ?? true);
|
||||
const manualGapValue = (barConfig && barConfig.popupGapsManual !== undefined) ? barConfig.popupGapsManual : (defaultBar?.popupGapsManual ?? 4);
|
||||
const popupGap = useAutoGaps ? Math.max(4, spacing) : manualGapValue;
|
||||
const popupGap = isConnected ? 0 : (useAutoGaps ? Math.max(4, spacing) : manualGapValue);
|
||||
const edgeSpacing = isConnected ? 0 : spacing;
|
||||
|
||||
switch (position) {
|
||||
case SettingsData.Position.Left:
|
||||
return {
|
||||
"x": barThickness + spacing + popupGap,
|
||||
"x": barThickness + edgeSpacing + popupGap,
|
||||
"y": relativeY,
|
||||
"width": widgetWidth
|
||||
};
|
||||
case SettingsData.Position.Right:
|
||||
return {
|
||||
"x": (screen?.width || 0) - (barThickness + spacing + popupGap),
|
||||
"x": (screen?.width || 0) - (barThickness + edgeSpacing + popupGap),
|
||||
"y": relativeY,
|
||||
"width": widgetWidth
|
||||
};
|
||||
case SettingsData.Position.Bottom:
|
||||
return {
|
||||
"x": relativeX,
|
||||
"y": (screen?.height || 0) - (barThickness + spacing + bottomGap + popupGap),
|
||||
"y": (screen?.height || 0) - (barThickness + edgeSpacing + bottomGap + popupGap),
|
||||
"width": widgetWidth
|
||||
};
|
||||
default:
|
||||
return {
|
||||
"x": relativeX,
|
||||
"y": barThickness + spacing + bottomGap + popupGap,
|
||||
"y": barThickness + edgeSpacing + bottomGap + popupGap,
|
||||
"width": widgetWidth
|
||||
};
|
||||
}
|
||||
@@ -1680,7 +1901,9 @@ Singleton {
|
||||
const screenWidth = screen.width;
|
||||
const screenHeight = screen.height;
|
||||
const position = barPosition !== undefined ? barPosition : (defaultBar?.position ?? SettingsData.Position.Top);
|
||||
const bottomGap = barConfig ? (barConfig.bottomGap !== undefined ? barConfig.bottomGap : (defaultBar?.bottomGap ?? 0)) : (defaultBar?.bottomGap ?? 0);
|
||||
const isConnected = connectedFrameModeActive;
|
||||
const rawBottomGap = barConfig ? (barConfig.bottomGap !== undefined ? barConfig.bottomGap : (defaultBar?.bottomGap ?? 0)) : (defaultBar?.bottomGap ?? 0);
|
||||
const bottomGap = isConnected ? 0 : rawBottomGap;
|
||||
|
||||
let topOffset = 0;
|
||||
let bottomOffset = 0;
|
||||
@@ -1702,7 +1925,7 @@ Singleton {
|
||||
const otherSpacing = other.spacing !== undefined ? other.spacing : (defaultBar?.spacing ?? 4);
|
||||
const otherPadding = other.innerPadding !== undefined ? other.innerPadding : (defaultBar?.innerPadding ?? 4);
|
||||
const otherThickness = Math.max(26 + otherPadding * 0.6, Theme.barHeight - 4 - (8 - otherPadding)) + otherSpacing + wingSize;
|
||||
const otherBottomGap = other.bottomGap !== undefined ? other.bottomGap : (defaultBar?.bottomGap ?? 0);
|
||||
const otherBottomGap = isConnected ? 0 : (other.bottomGap !== undefined ? other.bottomGap : (defaultBar?.bottomGap ?? 0));
|
||||
|
||||
switch (other.position) {
|
||||
case SettingsData.Position.Top:
|
||||
@@ -1793,7 +2016,9 @@ Singleton {
|
||||
function addBarConfig(config) {
|
||||
const configs = JSON.parse(JSON.stringify(barConfigs));
|
||||
configs.push(config);
|
||||
barConfigs = configs;
|
||||
if (connectedFrameModeActive)
|
||||
_captureConnectedFrameBarStyleBackups(configs, false);
|
||||
barConfigs = _sanitizeBarConfigsForConnectedFrame(configs).configs;
|
||||
updateBarConfigs();
|
||||
}
|
||||
|
||||
@@ -1805,7 +2030,7 @@ Singleton {
|
||||
const positionChanged = updates.position !== undefined && configs[index].position !== updates.position;
|
||||
|
||||
Object.assign(configs[index], updates);
|
||||
barConfigs = configs;
|
||||
barConfigs = _sanitizeBarConfigsForConnectedFrame(configs).configs;
|
||||
updateBarConfigs();
|
||||
|
||||
if (positionChanged) {
|
||||
@@ -1859,6 +2084,11 @@ Singleton {
|
||||
return;
|
||||
const configs = barConfigs.filter(cfg => cfg.id !== barId);
|
||||
barConfigs = configs;
|
||||
if (connectedFrameBarStyleBackups?.[barId] !== undefined) {
|
||||
const nextBackups = JSON.parse(JSON.stringify(connectedFrameBarStyleBackups || {}));
|
||||
delete nextBackups[barId];
|
||||
connectedFrameBarStyleBackups = nextBackups;
|
||||
}
|
||||
updateBarConfigs();
|
||||
}
|
||||
|
||||
@@ -1953,6 +2183,95 @@ Singleton {
|
||||
return filtered;
|
||||
}
|
||||
|
||||
function getFrameFilteredScreens() {
|
||||
var prefs = frameScreenPreferences || ["all"];
|
||||
if (!prefs || prefs.length === 0 || prefs.includes("all")) {
|
||||
return Quickshell.screens;
|
||||
}
|
||||
return Quickshell.screens.filter(screen => isScreenInPreferences(screen, prefs));
|
||||
}
|
||||
|
||||
function getActiveBarEdgeForScreen(screen) {
|
||||
if (!screen)
|
||||
return "";
|
||||
for (var i = 0; i < barConfigs.length; i++) {
|
||||
var bc = barConfigs[i];
|
||||
if (!bc.enabled)
|
||||
continue;
|
||||
var prefs = bc.screenPreferences || ["all"];
|
||||
if (!prefs.includes("all") && !isScreenInPreferences(screen, prefs))
|
||||
continue;
|
||||
switch (bc.position ?? 0) {
|
||||
case SettingsData.Position.Top:
|
||||
return "top";
|
||||
case SettingsData.Position.Bottom:
|
||||
return "bottom";
|
||||
case SettingsData.Position.Left:
|
||||
return "left";
|
||||
case SettingsData.Position.Right:
|
||||
return "right";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function getActiveBarEdgesForScreen(screen) {
|
||||
if (!screen)
|
||||
return [];
|
||||
var edges = [];
|
||||
for (var i = 0; i < barConfigs.length; i++) {
|
||||
var bc = barConfigs[i];
|
||||
if (!bc.enabled)
|
||||
continue;
|
||||
var prefs = bc.screenPreferences || ["all"];
|
||||
if (!prefs.includes("all") && !isScreenInPreferences(screen, prefs))
|
||||
continue;
|
||||
switch (bc.position ?? 0) {
|
||||
case SettingsData.Position.Top:
|
||||
edges.push("top");
|
||||
break;
|
||||
case SettingsData.Position.Bottom:
|
||||
edges.push("bottom");
|
||||
break;
|
||||
case SettingsData.Position.Left:
|
||||
edges.push("left");
|
||||
break;
|
||||
case SettingsData.Position.Right:
|
||||
edges.push("right");
|
||||
break;
|
||||
}
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
|
||||
function frameEdgeInsetForSide(screen, side) {
|
||||
if (!frameEnabled || !screen)
|
||||
return 0;
|
||||
const edges = getActiveBarEdgesForScreen(screen);
|
||||
return edges.includes(side) ? frameBarSize : frameThickness;
|
||||
}
|
||||
|
||||
function getActiveBarThicknessForScreen(screen) {
|
||||
if (frameEnabled)
|
||||
return frameBarSize;
|
||||
if (!screen)
|
||||
return frameThickness;
|
||||
for (var i = 0; i < barConfigs.length; i++) {
|
||||
var bc = barConfigs[i];
|
||||
if (!bc.enabled)
|
||||
continue;
|
||||
var prefs = bc.screenPreferences || ["all"];
|
||||
if (!prefs.includes("all") && !isScreenInPreferences(screen, prefs))
|
||||
continue;
|
||||
const innerPadding = bc.innerPadding ?? 4;
|
||||
const barT = Math.max(26 + innerPadding * 0.6, Theme.barHeight - 4 - (8 - innerPadding));
|
||||
const spacing = bc.spacing ?? 4;
|
||||
const bottomGap = bc.bottomGap ?? 0;
|
||||
return barT + spacing + bottomGap;
|
||||
}
|
||||
return frameThickness;
|
||||
}
|
||||
|
||||
function sendTestNotifications() {
|
||||
NotificationService.dismissAllPopups();
|
||||
sendTestNotification(0);
|
||||
@@ -2791,7 +3110,7 @@ Singleton {
|
||||
} catch (e) {
|
||||
_parseError = true;
|
||||
const msg = e.message;
|
||||
console.error("SettingsData: Failed to reload settings.json - file will not be overwritten. Error:", msg);
|
||||
log.error("Failed to reload settings.json - file will not be overwritten. Error:", msg);
|
||||
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse settings.json"), msg));
|
||||
} finally {
|
||||
_loading = false;
|
||||
@@ -2826,7 +3145,7 @@ Singleton {
|
||||
if (!isGreeterMode) {
|
||||
const msg = String(error || "");
|
||||
if (!_isMissingPluginSettingsError(error))
|
||||
console.warn("SettingsData: Failed to load plugin_settings.json. Error:", msg);
|
||||
log.warn("Failed to load plugin_settings.json. Error:", msg);
|
||||
_resetPluginSettings();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import "StockThemes.js" as StockThemes
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
readonly property var log: Log.scoped("Theme")
|
||||
|
||||
readonly property string stateDir: Paths.strip(StandardPaths.writableLocation(StandardPaths.GenericCacheLocation).toString()) + "/DankMaterialShell"
|
||||
readonly property bool envDisableMatugen: Quickshell.env("DMS_DISABLE_MATUGEN") === "1" || Quickshell.env("DMS_DISABLE_MATUGEN") === "true"
|
||||
@@ -148,7 +149,7 @@ Singleton {
|
||||
}
|
||||
|
||||
if (colorsFileLoadFailed && currentTheme === dynamic && rawWallpaperPath) {
|
||||
console.info("Theme: Matugen now available, regenerating colors for dynamic theme");
|
||||
log.info("Matugen now available, regenerating colors for dynamic theme");
|
||||
const isLight = (typeof SessionData !== "undefined" && SessionData.isLightMode);
|
||||
const iconTheme = (typeof SettingsData !== "undefined" && SettingsData.iconTheme) ? SettingsData.iconTheme : "System Default";
|
||||
const selectedMatugenType = (typeof SettingsData !== "undefined" && SettingsData.matugenScheme) ? SettingsData.matugenScheme : "scheme-tonal-spot";
|
||||
@@ -376,7 +377,7 @@ Singleton {
|
||||
"use": true
|
||||
}, response => {
|
||||
if (!response.error) {
|
||||
console.info("Theme automation: IP location enabled after connection");
|
||||
log.info("Theme automation: IP location enabled after connection");
|
||||
}
|
||||
});
|
||||
} else if (SessionData.latitude !== 0.0 && SessionData.longitude !== 0.0) {
|
||||
@@ -389,13 +390,13 @@ Singleton {
|
||||
"longitude": SessionData.longitude
|
||||
}, locationResponse => {
|
||||
if (locationResponse?.error) {
|
||||
console.warn("Theme automation: Failed to set location", locationResponse.error);
|
||||
log.warn("Theme automation: Failed to set location", locationResponse.error);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.warn("Theme automation: No location configured");
|
||||
log.warn("Theme automation: No location configured");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -985,6 +986,46 @@ Singleton {
|
||||
"expressiveEffects": [0.34, 0.8, 0.34, 1, 1, 1]
|
||||
}
|
||||
|
||||
// Theme is the canonical access point for animation variant state. The
|
||||
// aliases below forward to AnimVariants.qml so consumers don't need two
|
||||
// imports. ~200 call sites read through Theme.variantEnterCurve /
|
||||
// Theme.isConnectedEffect / etc. — do NOT migrate to AnimVariants directly.
|
||||
readonly property list<real> variantEnterCurve: AnimVariants.variantEnterCurve
|
||||
readonly property list<real> variantExitCurve: AnimVariants.variantExitCurve
|
||||
readonly property list<real> variantModalEnterCurve: AnimVariants.variantModalEnterCurve
|
||||
readonly property list<real> variantModalExitCurve: AnimVariants.variantModalExitCurve
|
||||
readonly property list<real> variantPopoutEnterCurve: AnimVariants.variantPopoutEnterCurve
|
||||
readonly property list<real> variantPopoutExitCurve: AnimVariants.variantPopoutExitCurve
|
||||
readonly property real variantEnterDurationFactor: AnimVariants.variantEnterDurationFactor
|
||||
readonly property real variantExitDurationFactor: AnimVariants.variantExitDurationFactor
|
||||
readonly property real variantOpacityDurationScale: AnimVariants.variantOpacityDurationScale
|
||||
readonly property bool isDirectionalEffect: AnimVariants.isDirectionalEffect
|
||||
readonly property bool isDepthEffect: AnimVariants.isDepthEffect
|
||||
readonly property bool isConnectedEffect: AnimVariants.isConnectedEffect
|
||||
readonly property real connectedCornerRadius: {
|
||||
if (typeof SettingsData === "undefined")
|
||||
return 12;
|
||||
return SettingsData.connectedFrameModeActive ? SettingsData.frameRounding : cornerRadius;
|
||||
}
|
||||
readonly property color connectedSurfaceColor: {
|
||||
if (typeof SettingsData === "undefined")
|
||||
return withAlpha(surfaceContainer, popupTransparency);
|
||||
return isConnectedEffect ? Qt.rgba(SettingsData.effectiveFrameColor.r, SettingsData.effectiveFrameColor.g, SettingsData.effectiveFrameColor.b, SettingsData.frameOpacity) : withAlpha(surfaceContainer, popupTransparency);
|
||||
}
|
||||
readonly property real connectedSurfaceRadius: isConnectedEffect ? connectedCornerRadius : cornerRadius
|
||||
readonly property bool connectedSurfaceBlurEnabled: (typeof SettingsData === "undefined") ? true : (!isConnectedEffect || SettingsData.frameBlurEnabled)
|
||||
readonly property real effectScaleCollapsed: AnimVariants.effectScaleCollapsed
|
||||
readonly property real effectAnimOffset: AnimVariants.effectAnimOffset
|
||||
function variantDuration(baseDuration, entering) {
|
||||
return AnimVariants.variantDuration(baseDuration, entering);
|
||||
}
|
||||
function variantExitCleanupPadding() {
|
||||
return AnimVariants.variantExitCleanupPadding();
|
||||
}
|
||||
function variantCloseInterval(baseDuration) {
|
||||
return AnimVariants.variantCloseInterval(baseDuration);
|
||||
}
|
||||
|
||||
readonly property var animationPresetDurations: {
|
||||
"none": 0,
|
||||
"short": 250,
|
||||
@@ -1060,6 +1101,9 @@ Singleton {
|
||||
return base === 0 ? 0 : Math.round(base * 0.85);
|
||||
}
|
||||
|
||||
readonly property int notificationInlineExpandDuration: notificationAnimationBaseDuration === 0 ? 0 : 185
|
||||
readonly property int notificationInlineCollapseDuration: notificationAnimationBaseDuration === 0 ? 0 : 150
|
||||
|
||||
readonly property real notificationIconSizeNormal: 56
|
||||
readonly property real notificationIconSizeCompact: 48
|
||||
readonly property real notificationExpandedIconSizeNormal: 48
|
||||
@@ -1150,7 +1194,13 @@ Singleton {
|
||||
property real iconSizeLarge: 32
|
||||
|
||||
property real panelTransparency: 0.85
|
||||
property real popupTransparency: typeof SettingsData !== "undefined" && SettingsData.popupTransparency !== undefined ? SettingsData.popupTransparency : 1.0
|
||||
property real popupTransparency: {
|
||||
if (typeof SettingsData === "undefined")
|
||||
return 1.0;
|
||||
if (isConnectedEffect)
|
||||
return SettingsData.frameOpacity !== undefined ? SettingsData.frameOpacity : 1.0;
|
||||
return SettingsData.popupTransparency !== undefined ? SettingsData.popupTransparency : 1.0;
|
||||
}
|
||||
|
||||
function screenTransition() {
|
||||
if (CompositorService.isNiri) {
|
||||
@@ -1525,12 +1575,12 @@ Singleton {
|
||||
|
||||
function setDesiredTheme(kind, value, isLight, iconTheme, matugenType, stockColors) {
|
||||
if (!matugenAvailable) {
|
||||
console.warn("Theme: matugen not available or disabled - cannot set system theme");
|
||||
log.warn("matugen not available or disabled - cannot set system theme");
|
||||
return;
|
||||
}
|
||||
|
||||
if (workerRunning) {
|
||||
console.info("Theme: Worker already running, queueing request");
|
||||
log.info("Worker already running, queueing request");
|
||||
pendingThemeRequest = {
|
||||
kind,
|
||||
value,
|
||||
@@ -1542,7 +1592,7 @@ Singleton {
|
||||
return;
|
||||
}
|
||||
|
||||
console.info("Theme: Setting desired theme -", kind, "mode:", isLight ? "light" : "dark", stockColors ? "(stock colors)" : "(dynamic)");
|
||||
log.info("Setting desired theme -", kind, "mode:", isLight ? "light" : "dark", stockColors ? "(stock colors)" : "(dynamic)");
|
||||
|
||||
if (typeof NiriService !== "undefined" && CompositorService.isNiri) {
|
||||
NiriService.suppressNextToast();
|
||||
@@ -1557,7 +1607,7 @@ Singleton {
|
||||
"runUserTemplates": (typeof SettingsData !== "undefined") ? SettingsData.runUserMatugenTemplates : true
|
||||
};
|
||||
|
||||
console.log("Theme: Starting matugen worker");
|
||||
log.debug("Starting matugen worker");
|
||||
workerRunning = true;
|
||||
|
||||
const args = ["dms", "matugen", "queue", "--state-dir", stateDir, "--shell-dir", shellDir, "--config-dir", configDir, "--kind", desired.kind, "--value", desired.value, "--mode", desired.mode, "--icon-theme", desired.iconTheme, "--matugen-type", desired.matugenType,];
|
||||
@@ -1581,7 +1631,7 @@ Singleton {
|
||||
if (typeof SettingsData !== "undefined") {
|
||||
const skipTemplates = [];
|
||||
if (!SettingsData.runDmsMatugenTemplates) {
|
||||
skipTemplates.push("gtk", "nvim", "niri", "qt5ct", "qt6ct", "firefox", "pywalfox", "zenbrowser", "vesktop", "equibop", "ghostty", "kitty", "foot", "alacritty", "wezterm", "dgop", "kcolorscheme", "vscode", "emacs", "zed");
|
||||
skipTemplates.push("gtk", "nvim", "niri", "qt5ct", "qt6ct", "firefox", "pywalfox", "zenbrowser", "vesktop", "vencord", "equibop", "ghostty", "kitty", "foot", "alacritty", "wezterm", "dgop", "kcolorscheme", "vscode", "emacs", "zed");
|
||||
} else {
|
||||
if (!SettingsData.matugenTemplateGtk)
|
||||
skipTemplates.push("gtk");
|
||||
@@ -1603,6 +1653,8 @@ Singleton {
|
||||
skipTemplates.push("zenbrowser");
|
||||
if (!SettingsData.matugenTemplateVesktop)
|
||||
skipTemplates.push("vesktop");
|
||||
if (!SettingsData.matugenTemplateVencord)
|
||||
skipTemplates.push("vencord");
|
||||
if (!SettingsData.matugenTemplateEquibop)
|
||||
skipTemplates.push("equibop");
|
||||
if (!SettingsData.matugenTemplateGhostty)
|
||||
@@ -1715,7 +1767,7 @@ Singleton {
|
||||
}
|
||||
|
||||
if (!darkTheme || !darkTheme.primary) {
|
||||
console.warn("Theme data not available for:", currentTheme);
|
||||
log.warn("Theme data not available for:", currentTheme);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1849,6 +1901,12 @@ Singleton {
|
||||
return Qt.rgba(c.r, c.g, c.b, a);
|
||||
}
|
||||
|
||||
function popupLayerColor(baseColor) {
|
||||
if (isConnectedEffect)
|
||||
return connectedSurfaceColor;
|
||||
return withAlpha(baseColor, popupTransparency);
|
||||
}
|
||||
|
||||
function blendAlpha(c, a) {
|
||||
return Qt.rgba(c.r, c.g, c.b, c.a * a);
|
||||
}
|
||||
@@ -1953,10 +2011,10 @@ Singleton {
|
||||
id: systemThemeGenerator
|
||||
running: false
|
||||
stdout: SplitParser {
|
||||
onRead: data => console.info("Theme worker:", data)
|
||||
onRead: data => log.info("Theme worker:", data)
|
||||
}
|
||||
stderr: SplitParser {
|
||||
onRead: data => console.warn("Theme worker:", data)
|
||||
onRead: data => log.warn("Theme worker:", data)
|
||||
}
|
||||
|
||||
onExited: exitCode => {
|
||||
@@ -1965,18 +2023,18 @@ Singleton {
|
||||
|
||||
switch (exitCode) {
|
||||
case 0:
|
||||
console.info("Theme: Matugen worker completed successfully");
|
||||
log.info("Matugen worker completed successfully");
|
||||
root.matugenCompleted(currentMode, "success");
|
||||
break;
|
||||
case 2:
|
||||
console.log("Theme: Matugen worker completed with code 2 (no changes needed)");
|
||||
log.debug("Matugen worker completed with code 2 (no changes needed)");
|
||||
root.matugenCompleted(currentMode, "no-changes");
|
||||
break;
|
||||
default:
|
||||
if (typeof ToastService !== "undefined") {
|
||||
ToastService.showError("Theme worker failed (" + exitCode + ")");
|
||||
}
|
||||
console.warn("Theme: Matugen worker failed with exit code:", exitCode);
|
||||
log.warn("Matugen worker failed with exit code:", exitCode);
|
||||
root.matugenCompleted(currentMode, "error");
|
||||
}
|
||||
|
||||
@@ -1985,7 +2043,7 @@ Singleton {
|
||||
|
||||
const req = pendingThemeRequest;
|
||||
pendingThemeRequest = null;
|
||||
console.info("Theme: Processing queued theme request");
|
||||
log.info("Processing queued theme request");
|
||||
setDesiredTheme(req.kind, req.value, req.isLight, req.iconTheme, req.matugenType, req.stockColors);
|
||||
}
|
||||
}
|
||||
@@ -2039,7 +2097,7 @@ Singleton {
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Theme: Failed to parse dynamic colors:", e);
|
||||
log.error("Failed to parse dynamic colors:", e);
|
||||
if (typeof ToastService !== "undefined") {
|
||||
ToastService.wallpaperErrorStatus = "error";
|
||||
ToastService.showError("Dynamic colors parse error: " + e.message);
|
||||
@@ -2059,11 +2117,11 @@ Singleton {
|
||||
|
||||
onLoadFailed: function (error) {
|
||||
if (currentTheme === dynamic) {
|
||||
console.warn("Theme: Dynamic colors file load failed, marking for regeneration");
|
||||
log.warn("Dynamic colors file load failed, marking for regeneration");
|
||||
colorsFileLoadFailed = true;
|
||||
const isGreeterMode = (typeof SessionData !== "undefined" && SessionData.isGreeterMode);
|
||||
if (!isGreeterMode && matugenAvailable && rawWallpaperPath) {
|
||||
console.log("Theme: Matugen available, triggering immediate regeneration");
|
||||
log.debug("Matugen available, triggering immediate regeneration");
|
||||
generateSystemThemesFromCurrentTheme();
|
||||
}
|
||||
}
|
||||
@@ -2187,7 +2245,7 @@ Singleton {
|
||||
"endMinute": endMinute
|
||||
}, response => {
|
||||
if (response && response.error) {
|
||||
console.error("Theme automation: Failed to sync time schedule:", response.error);
|
||||
log.error("Theme automation: Failed to sync time schedule:", response.error);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2280,9 +2338,9 @@ Singleton {
|
||||
|
||||
if (root.themeModeAutomationActive) {
|
||||
if (SessionData.nightModeUseIPLocation) {
|
||||
console.warn("Theme automation: Waiting for IP location from backend");
|
||||
log.warn("Theme automation: Waiting for IP location from backend");
|
||||
} else {
|
||||
console.warn("Theme automation: Location mode requires coordinates");
|
||||
log.warn("Theme automation: Location mode requires coordinates");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2364,7 +2422,7 @@ Singleton {
|
||||
"use": true
|
||||
}, response => {
|
||||
if (response?.error) {
|
||||
console.warn("Theme automation: Failed to enable IP location", response.error);
|
||||
log.warn("Theme automation: Failed to enable IP location", response.error);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
@@ -2378,7 +2436,7 @@ Singleton {
|
||||
"longitude": SessionData.longitude
|
||||
}, locResp => {
|
||||
if (locResp?.error) {
|
||||
console.warn("Theme automation: Failed to set location", locResp.error);
|
||||
log.warn("Theme automation: Failed to set location", locResp.error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ var SPEC = {
|
||||
isLightMode: { def: false },
|
||||
doNotDisturb: { def: false },
|
||||
doNotDisturbUntil: { def: 0 },
|
||||
terminalOverride: { def: "" },
|
||||
|
||||
wallpaperPath: { def: "" },
|
||||
perMonitorWallpaper: { def: false },
|
||||
|
||||
@@ -49,6 +49,8 @@ var SPEC = {
|
||||
modalAnimationSpeed: { def: 1 },
|
||||
modalCustomAnimationDuration: { def: 150 },
|
||||
enableRippleEffects: { def: true },
|
||||
animationVariant: { def: 0 },
|
||||
motionEffect: { def: 0 },
|
||||
m3ElevationEnabled: { def: true },
|
||||
m3ElevationIntensity: { def: 12 },
|
||||
m3ElevationOpacity: { def: 30 },
|
||||
@@ -302,6 +304,7 @@ var SPEC = {
|
||||
matugenTemplatePywalfox: { def: true },
|
||||
matugenTemplateZenBrowser: { def: true },
|
||||
matugenTemplateVesktop: { def: true },
|
||||
matugenTemplateVencord: { def: true },
|
||||
matugenTemplateEquibop: { def: true },
|
||||
matugenTemplateGhostty: { def: true },
|
||||
matugenTemplateKitty: { def: true },
|
||||
@@ -326,6 +329,7 @@ var SPEC = {
|
||||
showDock: { def: false },
|
||||
dockAutoHide: { def: false },
|
||||
dockSmartAutoHide: { def: false },
|
||||
dockHideOnFullscreen: { def: true },
|
||||
dockGroupByApp: { def: false },
|
||||
dockRestoreSpecialWorkspaceOnClick: { def: false },
|
||||
dockOpenOnOverview: { def: false },
|
||||
@@ -428,6 +432,9 @@ var SPEC = {
|
||||
updaterUseCustomCommand: { def: false },
|
||||
updaterCustomCommand: { def: "" },
|
||||
updaterTerminalAdditionalParams: { def: "" },
|
||||
updaterIntervalSeconds: { def: 1800 },
|
||||
updaterIncludeFlatpak: { def: true },
|
||||
updaterAllowAUR: { def: true },
|
||||
|
||||
displayNameMode: { def: "system" },
|
||||
screenPreferences: { def: {} },
|
||||
@@ -439,6 +446,8 @@ var SPEC = {
|
||||
displayProfileAutoSelect: { def: false },
|
||||
displayShowDisconnected: { def: false },
|
||||
displaySnapToEdge: { def: true },
|
||||
connectedFrameBarStyleBackups: { def: {} },
|
||||
connectedFrameModalDarkenBackup: { def: null },
|
||||
|
||||
barConfigs: {
|
||||
def: [{
|
||||
@@ -483,6 +492,7 @@ var SPEC = {
|
||||
popupGapsAuto: true,
|
||||
popupGapsManual: 4,
|
||||
maximizeDetection: true,
|
||||
fullscreenDetection: true,
|
||||
scrollEnabled: true,
|
||||
scrollXBehavior: "column",
|
||||
scrollYBehavior: "workspace",
|
||||
@@ -545,7 +555,21 @@ var SPEC = {
|
||||
clipboardEnterToPaste: { def: false },
|
||||
|
||||
launcherPluginVisibility: { def: {} },
|
||||
launcherPluginOrder: { def: [] }
|
||||
launcherPluginOrder: { def: [] },
|
||||
|
||||
frameEnabled: { def: false },
|
||||
frameThickness: { def: 16 },
|
||||
frameRounding: { def: 23 },
|
||||
frameColor: { def: "" },
|
||||
frameOpacity: { def: 1.0 },
|
||||
frameScreenPreferences: { def: ["all"] },
|
||||
frameBarSize: { def: 40 },
|
||||
frameShowOnOverview: { def: false },
|
||||
frameBlurEnabled: { def: true },
|
||||
frameCloseGaps: { def: true },
|
||||
frameLauncherEmergeSide: { def: "bottom" },
|
||||
frameLauncherArcExtender: { def: false },
|
||||
frameMode: { def: "separate" }
|
||||
};
|
||||
|
||||
function getValidKeys() {
|
||||
|
||||
@@ -248,6 +248,10 @@ function migrateToVersion(obj, targetVersion) {
|
||||
settings.configVersion = 6;
|
||||
}
|
||||
|
||||
if (currentVersion < 11) {
|
||||
settings.configVersion = 11;
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,11 +22,14 @@ import qs.Modules.OSD
|
||||
import qs.Modules.ProcessList
|
||||
import qs.Modules.DankBar
|
||||
import qs.Modules.DankBar.Popouts
|
||||
import qs.Modules.Frame
|
||||
import qs.Modules.WorkspaceOverlays
|
||||
import qs.Modules.Settings.DisplayConfig
|
||||
import qs.Services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
readonly property var log: Log.scoped("DMSShell")
|
||||
|
||||
property bool osdSurfacesLoaded: true
|
||||
property int pendingOsdResumeReloads: 0
|
||||
@@ -54,7 +57,7 @@ Item {
|
||||
item.popoutService = PopoutService;
|
||||
}
|
||||
item.pluginId = pluginId;
|
||||
console.info("Daemon plugin loaded:", pluginId);
|
||||
log.info("Daemon plugin loaded:", pluginId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,7 +96,7 @@ Item {
|
||||
}
|
||||
|
||||
onFadeCancelled: {
|
||||
console.log("Fade to lock cancelled by user on screen:", fadeWindowLoader.modelData.name);
|
||||
log.debug("Fade to lock cancelled by user on screen:", fadeWindowLoader.modelData.name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,7 +136,7 @@ Item {
|
||||
}
|
||||
|
||||
onFadeCancelled: {
|
||||
console.log("Fade to DPMS cancelled by user on screen:", fadeDpmsWindowLoader.modelData.name);
|
||||
log.debug("Fade to DPMS cancelled by user on screen:", fadeDpmsWindowLoader.modelData.name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +165,22 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
property bool barSurfacesLoaded: true
|
||||
|
||||
function recreateBarSurfaces() {
|
||||
if (barSurfacesLoaded)
|
||||
barSurfacesLoaded = false;
|
||||
barSurfaceReloadAction.schedule();
|
||||
}
|
||||
|
||||
DeferredAction {
|
||||
id: barSurfaceReloadAction
|
||||
onTriggered: root.barSurfacesLoaded = true
|
||||
}
|
||||
|
||||
property string _barLayoutStateJson: {
|
||||
if (!barSurfacesLoaded)
|
||||
return "[]";
|
||||
const configs = SettingsData.barConfigs;
|
||||
const mapped = configs.map(c => ({
|
||||
id: c.id,
|
||||
@@ -186,6 +204,21 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: SettingsData
|
||||
function onFrameEnabledChanged() {
|
||||
root.recreateBarSurfaces();
|
||||
}
|
||||
function onConnectedFrameModeActiveChanged() {
|
||||
root.recreateBarSurfaces();
|
||||
}
|
||||
function onForceDankBarLayoutRefresh() {
|
||||
root.recreateBarSurfaces();
|
||||
}
|
||||
}
|
||||
|
||||
Frame {}
|
||||
|
||||
Repeater {
|
||||
id: dankBarRepeater
|
||||
model: ScriptModel {
|
||||
@@ -199,7 +232,7 @@ Item {
|
||||
id: barLoader
|
||||
required property var modelData
|
||||
property var barConfig: SettingsData.barConfigs.find(cfg => cfg.id === modelData.id) || null
|
||||
active: barConfig?.enabled ?? false
|
||||
active: root.barSurfacesLoaded && (barConfig?.enabled ?? false)
|
||||
asynchronous: false
|
||||
|
||||
sourceComponent: DankBar {
|
||||
@@ -272,6 +305,8 @@ Item {
|
||||
dockRecreateDebounce.start();
|
||||
// Force PolkitService singleton to initialize
|
||||
PolkitService.polkitAvailable;
|
||||
// Force DisplayConfigState singleton to initialize so auto-config runs at startup
|
||||
DisplayConfigState.hasOutputBackend;
|
||||
loginSoundTimer.start();
|
||||
}
|
||||
|
||||
@@ -330,7 +365,6 @@ Item {
|
||||
sourceComponent: Component {
|
||||
DankDashPopout {
|
||||
id: dankDashPopout
|
||||
onPopoutClosed: PopoutService.unloadDankDash()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -773,7 +807,7 @@ Item {
|
||||
cmd += " " + escapedPath;
|
||||
}
|
||||
|
||||
console.log("FilePicker: Launching", cmd);
|
||||
log.debug("FilePicker: Launching", cmd);
|
||||
|
||||
Quickshell.execDetached({
|
||||
command: ["sh", "-c", cmd]
|
||||
@@ -805,10 +839,10 @@ Item {
|
||||
}
|
||||
|
||||
function onAppPickerRequested(data) {
|
||||
console.log("DMSShell: App picker requested with data:", JSON.stringify(data));
|
||||
log.debug("App picker requested with data:", JSON.stringify(data));
|
||||
|
||||
if (!data || !data.target) {
|
||||
console.warn("DMSShell: Invalid app picker request data");
|
||||
log.warn("Invalid app picker request data");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -877,10 +911,19 @@ Item {
|
||||
|
||||
ProcessListModal {
|
||||
id: processListModal
|
||||
property bool wasShown: false
|
||||
|
||||
Component.onCompleted: {
|
||||
PopoutService.processListModal = processListModal;
|
||||
}
|
||||
|
||||
onVisibleChanged: {
|
||||
if (visible) {
|
||||
wasShown = true;
|
||||
} else if (wasShown) {
|
||||
PopoutService.unloadProcessListModal();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -895,7 +938,12 @@ Item {
|
||||
|
||||
SystemUpdatePopout {
|
||||
id: systemUpdatePopout
|
||||
onPopoutClosed: PopoutService.unloadSystemUpdate()
|
||||
onPopoutClosed: {
|
||||
if (systemUpdatePopout._reopenAfterUpgrade) {
|
||||
return;
|
||||
}
|
||||
PopoutService.unloadSystemUpdate();
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
PopoutService.systemUpdatePopout = systemUpdatePopout;
|
||||
@@ -1092,12 +1140,6 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: powerProfileWatcherLoader
|
||||
active: SettingsData.osdPowerProfileEnabled
|
||||
source: "Services/PowerProfileWatcher.qml"
|
||||
}
|
||||
|
||||
LazyLoader {
|
||||
id: hyprlandOverviewLoader
|
||||
active: CompositorService.isHyprland
|
||||
|
||||
@@ -9,6 +9,7 @@ import qs.Modules.Settings.DisplayConfig
|
||||
|
||||
Item {
|
||||
id: root
|
||||
readonly property var log: Log.scoped("DMSShellIPC")
|
||||
|
||||
required property var powerMenuModalLoader
|
||||
required property var processListModalLoader
|
||||
@@ -161,37 +162,36 @@ Item {
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
function resolveTabIndex(tab: string): int {
|
||||
switch ((tab || "").toLowerCase()) {
|
||||
case "media":
|
||||
return 1;
|
||||
case "wallpaper":
|
||||
return 2;
|
||||
case "weather":
|
||||
return SettingsData.weatherEnabled ? 3 : 0;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function open(tab: string): string {
|
||||
const bar = root.getPreferredBar("clockButtonRef");
|
||||
if (!bar)
|
||||
return "DASH_OPEN_FAILED";
|
||||
|
||||
const tabIndex = resolveTabIndex(tab);
|
||||
const dash = root.dankDashPopoutLoader.item;
|
||||
const onSameScreen = dash && dash.shouldBeVisible && dash.triggerScreen?.name === bar.screen?.name;
|
||||
|
||||
if (!onSameScreen) {
|
||||
bar.triggerWallpaperBrowser();
|
||||
if (dash && dash.shouldBeVisible && dash.triggerScreen?.name === bar.screen?.name) {
|
||||
dash.currentTabIndex = tabIndex;
|
||||
if (dash.updateSurfacePosition)
|
||||
dash.updateSurfacePosition();
|
||||
return "DASH_OPEN_SUCCESS";
|
||||
}
|
||||
|
||||
if (!root.dankDashPopoutLoader.item)
|
||||
if (!bar.triggerDashTab(tabIndex))
|
||||
return "DASH_OPEN_FAILED";
|
||||
|
||||
switch (tab.toLowerCase()) {
|
||||
case "media":
|
||||
root.dankDashPopoutLoader.item.currentTabIndex = 1;
|
||||
break;
|
||||
case "wallpaper":
|
||||
root.dankDashPopoutLoader.item.currentTabIndex = 2;
|
||||
break;
|
||||
case "weather":
|
||||
root.dankDashPopoutLoader.item.currentTabIndex = SettingsData.weatherEnabled ? 3 : 0;
|
||||
break;
|
||||
default:
|
||||
root.dankDashPopoutLoader.item.currentTabIndex = 0;
|
||||
break;
|
||||
}
|
||||
|
||||
root.dankDashPopoutLoader.item.dashVisible = true;
|
||||
return "DASH_OPEN_SUCCESS";
|
||||
}
|
||||
|
||||
@@ -211,23 +211,8 @@ Item {
|
||||
|
||||
const bar = root.getPreferredBar("clockButtonRef");
|
||||
if (bar) {
|
||||
bar.triggerWallpaperBrowser();
|
||||
if (root.dankDashPopoutLoader.item) {
|
||||
switch (tab.toLowerCase()) {
|
||||
case "media":
|
||||
root.dankDashPopoutLoader.item.currentTabIndex = 1;
|
||||
break;
|
||||
case "wallpaper":
|
||||
root.dankDashPopoutLoader.item.currentTabIndex = 2;
|
||||
break;
|
||||
case "weather":
|
||||
root.dankDashPopoutLoader.item.currentTabIndex = SettingsData.weatherEnabled ? 3 : 0;
|
||||
break;
|
||||
default:
|
||||
root.dankDashPopoutLoader.item.currentTabIndex = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!bar.triggerDashTab(resolveTabIndex(tab)))
|
||||
return "DASH_TOGGLE_FAILED";
|
||||
return "DASH_TOGGLE_SUCCESS";
|
||||
}
|
||||
return "DASH_TOGGLE_FAILED";
|
||||
@@ -861,7 +846,7 @@ Item {
|
||||
|
||||
function set(key: string, value: string): string {
|
||||
if (!(key in SettingsData)) {
|
||||
console.warn("Cannot set property, not found:", key);
|
||||
log.warn("Cannot set property, not found:", key);
|
||||
return "SETTINGS_INVALID_KEY";
|
||||
}
|
||||
|
||||
@@ -894,12 +879,12 @@ Item {
|
||||
throw "Unsupported type";
|
||||
}
|
||||
|
||||
console.warn("Setting:", key, value);
|
||||
log.warn("Setting:", key, value);
|
||||
SettingsData[key] = value;
|
||||
SettingsData.saveSettings();
|
||||
return "SETTINGS_SET_SUCCESS";
|
||||
} catch (e) {
|
||||
console.warn("Failed to set property:", key, "error:", e);
|
||||
log.warn("Failed to set property:", key, "error:", e);
|
||||
return "SETTINGS_SET_FAILURE";
|
||||
}
|
||||
}
|
||||
@@ -1637,13 +1622,15 @@ Item {
|
||||
|
||||
for (const id in profiles) {
|
||||
const p = profiles[id];
|
||||
if (!p.name)
|
||||
continue;
|
||||
const flags = [];
|
||||
if (id === activeId)
|
||||
flags.push("active");
|
||||
if (id === matchedId)
|
||||
flags.push("matched");
|
||||
const flagStr = flags.length > 0 ? " [" + flags.join(",") + "]" : "";
|
||||
lines.push(p.name + flagStr + " -> " + JSON.stringify(p.outputSet));
|
||||
lines.push(p.name + flagStr + " -> " + JSON.stringify(Object.keys(p.outputs)));
|
||||
}
|
||||
|
||||
if (lines.length === 0)
|
||||
@@ -1675,13 +1662,16 @@ Item {
|
||||
return `PROFILE_SET_SUCCESS: ${profileName}`;
|
||||
}
|
||||
|
||||
// ! TODO - auto profile switching is buggy on niri and other compositors
|
||||
function toggleAuto(): string {
|
||||
return "ERROR: Auto profile selection is temporarily disabled due to compositor bugs";
|
||||
SettingsData.displayProfileAutoSelect = !SettingsData.displayProfileAutoSelect;
|
||||
SettingsData.saveSettings();
|
||||
if (SettingsData.displayProfileAutoSelect)
|
||||
DisplayConfigState.applyAutoConfig();
|
||||
return `Auto profile selection: ${SettingsData.displayProfileAutoSelect ? "enabled" : "disabled"}`;
|
||||
}
|
||||
|
||||
function status(): string {
|
||||
const auto = "off"; // disabled for now
|
||||
const auto = SettingsData.displayProfileAutoSelect ? "on" : "off";
|
||||
const activeId = SettingsData.getActiveDisplayProfile(CompositorService.compositor);
|
||||
const matchedId = DisplayConfigState.matchedProfile;
|
||||
const profiles = DisplayConfigState.validatedProfiles;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Common
|
||||
import qs.Modals.Common
|
||||
import qs.Widgets
|
||||
@@ -7,6 +6,7 @@ import qs.Services
|
||||
|
||||
DankModal {
|
||||
id: root
|
||||
readonly property var log: Log.scoped("AppPickerModal")
|
||||
|
||||
property string title: I18n.tr("Select Application")
|
||||
property string targetData: ""
|
||||
@@ -30,52 +30,52 @@ DankModal {
|
||||
onBackgroundClicked: close()
|
||||
|
||||
onDialogClosed: {
|
||||
searchQuery = ""
|
||||
selectedIndex = 0
|
||||
keyboardNavigationActive = false
|
||||
searchQuery = "";
|
||||
selectedIndex = 0;
|
||||
keyboardNavigationActive = false;
|
||||
}
|
||||
|
||||
onOpened: {
|
||||
searchQuery = ""
|
||||
updateApplicationList()
|
||||
selectedIndex = 0
|
||||
searchQuery = "";
|
||||
updateApplicationList();
|
||||
selectedIndex = 0;
|
||||
Qt.callLater(() => {
|
||||
if (contentLoader.item && contentLoader.item.searchField) {
|
||||
contentLoader.item.searchField.text = ""
|
||||
contentLoader.item.searchField.forceActiveFocus()
|
||||
contentLoader.item.searchField.text = "";
|
||||
contentLoader.item.searchField.forceActiveFocus();
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
function updateApplicationList() {
|
||||
applicationsModel.clear()
|
||||
const apps = AppSearchService.applications
|
||||
const usageHistory = usageHistoryKey && SettingsData[usageHistoryKey] ? SettingsData[usageHistoryKey] : {}
|
||||
let filteredApps = []
|
||||
applicationsModel.clear();
|
||||
const apps = AppSearchService.applications;
|
||||
const usageHistory = usageHistoryKey && SettingsData[usageHistoryKey] ? SettingsData[usageHistoryKey] : {};
|
||||
let filteredApps = [];
|
||||
|
||||
for (const app of apps) {
|
||||
if (!app || !app.categories) continue
|
||||
|
||||
let matchesCategory = categoryFilter.length === 0
|
||||
if (!app || !app.categories)
|
||||
continue;
|
||||
let matchesCategory = categoryFilter.length === 0;
|
||||
|
||||
if (categoryFilter.length > 0) {
|
||||
try {
|
||||
for (const cat of app.categories) {
|
||||
if (categoryFilter.includes(cat)) {
|
||||
matchesCategory = true
|
||||
break
|
||||
matchesCategory = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("AppPicker: Error iterating categories for", app.name, ":", e)
|
||||
continue
|
||||
log.warn("AppPicker: Error iterating categories for", app.name, ":", e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (matchesCategory) {
|
||||
const name = app.name || ""
|
||||
const lowerName = name.toLowerCase()
|
||||
const lowerQuery = searchQuery.toLowerCase()
|
||||
const name = app.name || "";
|
||||
const lowerName = name.toLowerCase();
|
||||
const lowerQuery = searchQuery.toLowerCase();
|
||||
|
||||
if (searchQuery === "" || lowerName.includes(lowerQuery)) {
|
||||
filteredApps.push({
|
||||
@@ -84,21 +84,21 @@ DankModal {
|
||||
exec: app.exec || app.execString || "",
|
||||
startupClass: app.startupWMClass || "",
|
||||
appData: app
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
filteredApps.sort((a, b) => {
|
||||
const aId = a.appData.id || a.appData.execString || a.appData.exec || ""
|
||||
const bId = b.appData.id || b.appData.execString || b.appData.exec || ""
|
||||
const aUsage = usageHistory[aId] ? usageHistory[aId].count : 0
|
||||
const bUsage = usageHistory[bId] ? usageHistory[bId].count : 0
|
||||
const aId = a.appData.id || a.appData.execString || a.appData.exec || "";
|
||||
const bId = b.appData.id || b.appData.execString || b.appData.exec || "";
|
||||
const aUsage = usageHistory[aId] ? usageHistory[aId].count : 0;
|
||||
const bUsage = usageHistory[bId] ? usageHistory[bId].count : 0;
|
||||
if (aUsage !== bUsage) {
|
||||
return bUsage - aUsage
|
||||
return bUsage - aUsage;
|
||||
}
|
||||
return (a.name || "").localeCompare(b.name || "")
|
||||
})
|
||||
return (a.name || "").localeCompare(b.name || "");
|
||||
});
|
||||
|
||||
filteredApps.forEach(app => {
|
||||
applicationsModel.append({
|
||||
@@ -107,10 +107,10 @@ DankModal {
|
||||
exec: app.exec,
|
||||
startupClass: app.startupClass,
|
||||
appId: app.appData.id || app.appData.execString || app.appData.exec || ""
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
console.log("AppPicker: Found " + filteredApps.length + " applications")
|
||||
log.debug("AppPicker: Found " + filteredApps.length + " applications");
|
||||
}
|
||||
|
||||
onSearchQueryChanged: updateApplicationList()
|
||||
@@ -129,56 +129,57 @@ DankModal {
|
||||
focus: true
|
||||
|
||||
Keys.onEscapePressed: event => {
|
||||
root.close()
|
||||
event.accepted = true
|
||||
root.close();
|
||||
event.accepted = true;
|
||||
}
|
||||
|
||||
Keys.onPressed: event => {
|
||||
if (applicationsModel.count === 0) return
|
||||
if (applicationsModel.count === 0)
|
||||
return;
|
||||
|
||||
// Toggle view mode with Tab key
|
||||
if (event.key === Qt.Key_Tab) {
|
||||
root.viewMode = root.viewMode === "grid" ? "list" : "grid"
|
||||
event.accepted = true
|
||||
return
|
||||
root.viewMode = root.viewMode === "grid" ? "list" : "grid";
|
||||
event.accepted = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (root.viewMode === "grid") {
|
||||
if (event.key === Qt.Key_Left) {
|
||||
root.keyboardNavigationActive = true
|
||||
root.selectedIndex = Math.max(0, root.selectedIndex - 1)
|
||||
event.accepted = true
|
||||
root.keyboardNavigationActive = true;
|
||||
root.selectedIndex = Math.max(0, root.selectedIndex - 1);
|
||||
event.accepted = true;
|
||||
} else if (event.key === Qt.Key_Right) {
|
||||
root.keyboardNavigationActive = true
|
||||
root.selectedIndex = Math.min(applicationsModel.count - 1, root.selectedIndex + 1)
|
||||
event.accepted = true
|
||||
root.keyboardNavigationActive = true;
|
||||
root.selectedIndex = Math.min(applicationsModel.count - 1, root.selectedIndex + 1);
|
||||
event.accepted = true;
|
||||
} else if (event.key === Qt.Key_Up) {
|
||||
root.keyboardNavigationActive = true
|
||||
root.selectedIndex = Math.max(0, root.selectedIndex - root.gridColumns)
|
||||
event.accepted = true
|
||||
root.keyboardNavigationActive = true;
|
||||
root.selectedIndex = Math.max(0, root.selectedIndex - root.gridColumns);
|
||||
event.accepted = true;
|
||||
} else if (event.key === Qt.Key_Down) {
|
||||
root.keyboardNavigationActive = true
|
||||
root.selectedIndex = Math.min(applicationsModel.count - 1, root.selectedIndex + root.gridColumns)
|
||||
event.accepted = true
|
||||
root.keyboardNavigationActive = true;
|
||||
root.selectedIndex = Math.min(applicationsModel.count - 1, root.selectedIndex + root.gridColumns);
|
||||
event.accepted = true;
|
||||
}
|
||||
} else {
|
||||
if (event.key === Qt.Key_Up) {
|
||||
root.keyboardNavigationActive = true
|
||||
root.selectedIndex = Math.max(0, root.selectedIndex - 1)
|
||||
event.accepted = true
|
||||
root.keyboardNavigationActive = true;
|
||||
root.selectedIndex = Math.max(0, root.selectedIndex - 1);
|
||||
event.accepted = true;
|
||||
} else if (event.key === Qt.Key_Down) {
|
||||
root.keyboardNavigationActive = true
|
||||
root.selectedIndex = Math.min(applicationsModel.count - 1, root.selectedIndex + 1)
|
||||
event.accepted = true
|
||||
root.keyboardNavigationActive = true;
|
||||
root.selectedIndex = Math.min(applicationsModel.count - 1, root.selectedIndex + 1);
|
||||
event.accepted = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
|
||||
if (root.selectedIndex >= 0 && root.selectedIndex < applicationsModel.count) {
|
||||
const app = applicationsModel.get(root.selectedIndex)
|
||||
launchApplication(app)
|
||||
const app = applicationsModel.get(root.selectedIndex);
|
||||
launchApplication(app);
|
||||
}
|
||||
event.accepted = true
|
||||
event.accepted = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,7 +218,7 @@ DankModal {
|
||||
iconColor: root.viewMode === "list" ? Theme.primary : Theme.surfaceText
|
||||
backgroundColor: root.viewMode === "list" ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
|
||||
onClicked: {
|
||||
root.viewMode = "list"
|
||||
root.viewMode = "list";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,7 +230,7 @@ DankModal {
|
||||
iconColor: root.viewMode === "grid" ? Theme.primary : Theme.surfaceText
|
||||
backgroundColor: root.viewMode === "grid" ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
|
||||
onClicked: {
|
||||
root.viewMode = "grid"
|
||||
root.viewMode = "grid";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -257,42 +258,42 @@ DankModal {
|
||||
keyForwardTargets: [appContent]
|
||||
|
||||
onTextEdited: {
|
||||
root.searchQuery = text
|
||||
root.searchQuery = text;
|
||||
}
|
||||
|
||||
Keys.onPressed: function (event) {
|
||||
if (event.key === Qt.Key_Escape) {
|
||||
root.close()
|
||||
event.accepted = true
|
||||
return
|
||||
root.close();
|
||||
event.accepted = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const isEnterKey = [Qt.Key_Return, Qt.Key_Enter].includes(event.key)
|
||||
const hasText = text.length > 0
|
||||
const isEnterKey = [Qt.Key_Return, Qt.Key_Enter].includes(event.key);
|
||||
const hasText = text.length > 0;
|
||||
|
||||
if (isEnterKey && hasText) {
|
||||
if (root.keyboardNavigationActive && applicationsModel.count > 0) {
|
||||
const app = applicationsModel.get(root.selectedIndex)
|
||||
launchApplication(app)
|
||||
const app = applicationsModel.get(root.selectedIndex);
|
||||
launchApplication(app);
|
||||
} else if (applicationsModel.count > 0) {
|
||||
const app = applicationsModel.get(0)
|
||||
launchApplication(app)
|
||||
const app = applicationsModel.get(0);
|
||||
launchApplication(app);
|
||||
}
|
||||
event.accepted = true
|
||||
return
|
||||
event.accepted = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const navigationKeys = [Qt.Key_Down, Qt.Key_Up, Qt.Key_Left, Qt.Key_Right, Qt.Key_Tab, Qt.Key_Backtab]
|
||||
const isNavigationKey = navigationKeys.includes(event.key)
|
||||
const isEmptyEnter = isEnterKey && !hasText
|
||||
const navigationKeys = [Qt.Key_Down, Qt.Key_Up, Qt.Key_Left, Qt.Key_Right, Qt.Key_Tab, Qt.Key_Backtab];
|
||||
const isNavigationKey = navigationKeys.includes(event.key);
|
||||
const isEmptyEnter = isEnterKey && !hasText;
|
||||
|
||||
event.accepted = !(isNavigationKey || isEmptyEnter)
|
||||
event.accepted = !(isNavigationKey || isEmptyEnter);
|
||||
}
|
||||
|
||||
Connections {
|
||||
function onShouldBeVisibleChanged() {
|
||||
if (!root.shouldBeVisible) {
|
||||
searchField.focus = false
|
||||
searchField.focus = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,12 +304,12 @@ DankModal {
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: {
|
||||
let usedHeight = 40 + Theme.spacingS
|
||||
usedHeight += 52 + Theme.spacingS
|
||||
let usedHeight = 40 + Theme.spacingS;
|
||||
usedHeight += 52 + Theme.spacingS;
|
||||
if (root.showTargetData) {
|
||||
usedHeight += 36 + Theme.spacingS
|
||||
usedHeight += 36 + Theme.spacingS;
|
||||
}
|
||||
return parent.height - usedHeight
|
||||
return parent.height - usedHeight;
|
||||
}
|
||||
radius: Theme.cornerRadius
|
||||
color: "transparent"
|
||||
@@ -320,14 +321,14 @@ DankModal {
|
||||
property int itemSpacing: Theme.spacingS
|
||||
|
||||
function ensureVisible(index) {
|
||||
if (index < 0 || index >= count) return
|
||||
|
||||
const itemY = index * (itemHeight + itemSpacing)
|
||||
const itemBottom = itemY + itemHeight
|
||||
if (index < 0 || index >= count)
|
||||
return;
|
||||
const itemY = index * (itemHeight + itemSpacing);
|
||||
const itemBottom = itemY + itemHeight;
|
||||
if (itemY < contentY) {
|
||||
contentY = itemY
|
||||
contentY = itemY;
|
||||
} else if (itemBottom > contentY + height) {
|
||||
contentY = itemBottom - height
|
||||
contentY = itemBottom - height;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,9 +344,9 @@ DankModal {
|
||||
spacing: itemSpacing
|
||||
|
||||
onCurrentIndexChanged: {
|
||||
root.selectedIndex = currentIndex
|
||||
root.selectedIndex = currentIndex;
|
||||
if (root.keyboardNavigationActive) {
|
||||
ensureVisible(currentIndex)
|
||||
ensureVisible(currentIndex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,11 +361,11 @@ DankModal {
|
||||
hoverUpdatesSelection: true
|
||||
|
||||
onItemClicked: (idx, modelData) => {
|
||||
launchApplication(modelData)
|
||||
launchApplication(modelData);
|
||||
}
|
||||
|
||||
onKeyboardNavigationReset: {
|
||||
root.keyboardNavigationActive = false
|
||||
root.keyboardNavigationActive = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -373,14 +374,14 @@ DankModal {
|
||||
id: appGrid
|
||||
|
||||
function ensureVisible(index) {
|
||||
if (index < 0 || index >= count) return
|
||||
|
||||
const itemY = Math.floor(index / root.gridColumns) * cellHeight
|
||||
const itemBottom = itemY + cellHeight
|
||||
if (index < 0 || index >= count)
|
||||
return;
|
||||
const itemY = Math.floor(index / root.gridColumns) * cellHeight;
|
||||
const itemBottom = itemY + cellHeight;
|
||||
if (itemY < contentY) {
|
||||
contentY = itemY
|
||||
contentY = itemY;
|
||||
} else if (itemBottom > contentY + height) {
|
||||
contentY = itemBottom - height
|
||||
contentY = itemBottom - height;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,9 +398,9 @@ DankModal {
|
||||
currentIndex: root.selectedIndex
|
||||
|
||||
onCurrentIndexChanged: {
|
||||
root.selectedIndex = currentIndex
|
||||
root.selectedIndex = currentIndex;
|
||||
if (root.keyboardNavigationActive) {
|
||||
ensureVisible(currentIndex)
|
||||
ensureVisible(currentIndex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,11 +414,11 @@ DankModal {
|
||||
hoverUpdatesSelection: true
|
||||
|
||||
onItemClicked: (idx, modelData) => {
|
||||
launchApplication(modelData)
|
||||
launchApplication(modelData);
|
||||
}
|
||||
|
||||
onKeyboardNavigationReset: {
|
||||
root.keyboardNavigationActive = false
|
||||
root.keyboardNavigationActive = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -449,22 +450,22 @@ DankModal {
|
||||
}
|
||||
|
||||
function launchApplication(app) {
|
||||
if (!app) return
|
||||
|
||||
root.applicationSelected(app, root.targetData)
|
||||
if (!app)
|
||||
return;
|
||||
root.applicationSelected(app, root.targetData);
|
||||
|
||||
if (usageHistoryKey && app.appId) {
|
||||
const usageHistory = SettingsData[usageHistoryKey] || {}
|
||||
const currentCount = usageHistory[app.appId] ? usageHistory[app.appId].count : 0
|
||||
const usageHistory = SettingsData[usageHistoryKey] || {};
|
||||
const currentCount = usageHistory[app.appId] ? usageHistory[app.appId].count : 0;
|
||||
usageHistory[app.appId] = {
|
||||
count: currentCount + 1,
|
||||
lastUsed: Date.now(),
|
||||
name: app.name
|
||||
}
|
||||
SettingsData.set(usageHistoryKey, usageHistory)
|
||||
};
|
||||
SettingsData.set(usageHistoryKey, usageHistory);
|
||||
}
|
||||
|
||||
root.close()
|
||||
root.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import qs.Widgets
|
||||
|
||||
DankModal {
|
||||
id: root
|
||||
readonly property var log: Log.scoped("BluetoothPairingModal")
|
||||
|
||||
layerNamespace: "dms:bluetooth-pairing"
|
||||
|
||||
@@ -24,7 +25,7 @@ DankModal {
|
||||
property string passkeyInput: ""
|
||||
|
||||
function show(pairingData) {
|
||||
console.log("BluetoothPairingModal.show() called:", JSON.stringify(pairingData));
|
||||
log.debug("BluetoothPairingModal.show() called:", JSON.stringify(pairingData));
|
||||
token = pairingData.token || "";
|
||||
deviceName = pairingData.deviceName || "";
|
||||
deviceAddress = pairingData.deviceAddr || "";
|
||||
@@ -33,7 +34,7 @@ DankModal {
|
||||
pinInput = "";
|
||||
passkeyInput = "";
|
||||
|
||||
console.log("BluetoothPairingModal: Calling open()");
|
||||
log.debug("Calling open()");
|
||||
open();
|
||||
Qt.callLater(() => {
|
||||
if (contentLoader.item) {
|
||||
|
||||
@@ -2,9 +2,11 @@ import QtQuick
|
||||
import Quickshell
|
||||
import qs.Common
|
||||
import qs.Modals
|
||||
import qs.Services
|
||||
|
||||
AppPickerModal {
|
||||
id: root
|
||||
readonly property var log: Log.scoped("BrowserPickerModal")
|
||||
|
||||
property string url: ""
|
||||
|
||||
@@ -17,35 +19,44 @@ AppPickerModal {
|
||||
showTargetData: true
|
||||
|
||||
function shellEscape(str) {
|
||||
return "'" + str.replace(/'/g, "'\\''") + "'"
|
||||
return "'" + str.replace(/'/g, "'\\''") + "'";
|
||||
}
|
||||
|
||||
onApplicationSelected: (app, url) => {
|
||||
if (!app) return
|
||||
if (!app)
|
||||
return;
|
||||
let cmd = app.exec || "";
|
||||
const escapedUrl = shellEscape(url);
|
||||
|
||||
let cmd = app.exec || ""
|
||||
const escapedUrl = shellEscape(url)
|
||||
|
||||
let hasField = false
|
||||
if (cmd.includes("%u")) { cmd = cmd.replace("%u", escapedUrl); hasField = true }
|
||||
else if (cmd.includes("%U")) { cmd = cmd.replace("%U", escapedUrl); hasField = true }
|
||||
else if (cmd.includes("%f")) { cmd = cmd.replace("%f", escapedUrl); hasField = true }
|
||||
else if (cmd.includes("%F")) { cmd = cmd.replace("%F", escapedUrl); hasField = true }
|
||||
|
||||
cmd = cmd.replace(/%[ikc]/g, "")
|
||||
|
||||
if (!hasField) {
|
||||
cmd += " " + escapedUrl
|
||||
let hasField = false;
|
||||
if (cmd.includes("%u")) {
|
||||
cmd = cmd.replace("%u", escapedUrl);
|
||||
hasField = true;
|
||||
} else if (cmd.includes("%U")) {
|
||||
cmd = cmd.replace("%U", escapedUrl);
|
||||
hasField = true;
|
||||
} else if (cmd.includes("%f")) {
|
||||
cmd = cmd.replace("%f", escapedUrl);
|
||||
hasField = true;
|
||||
} else if (cmd.includes("%F")) {
|
||||
cmd = cmd.replace("%F", escapedUrl);
|
||||
hasField = true;
|
||||
}
|
||||
|
||||
console.log("BrowserPicker: Launching", cmd)
|
||||
cmd = cmd.replace(/%[ikc]/g, "");
|
||||
|
||||
if (!hasField) {
|
||||
cmd += " " + escapedUrl;
|
||||
}
|
||||
|
||||
log.debug("BrowserPicker: Launching", cmd);
|
||||
|
||||
Quickshell.execDetached({
|
||||
command: ["sh", "-c", cmd]
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
onViewModeChanged: {
|
||||
SettingsData.set("browserPickerViewMode", viewMode)
|
||||
SettingsData.set("browserPickerViewMode", viewMode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,11 +64,19 @@ DankModal {
|
||||
activeImageLoads = 0;
|
||||
shouldHaveFocus = true;
|
||||
ClipboardService.reset();
|
||||
if (clipboardAvailable)
|
||||
ClipboardService.refresh();
|
||||
keyboardController.reset();
|
||||
|
||||
Qt.callLater(function () {
|
||||
if (clipboardAvailable) {
|
||||
if (Theme.isConnectedEffect) {
|
||||
Qt.callLater(() => {
|
||||
if (clipboardHistoryModal.shouldBeVisible)
|
||||
ClipboardService.refresh();
|
||||
});
|
||||
} else {
|
||||
ClipboardService.refresh();
|
||||
}
|
||||
}
|
||||
if (contentLoader.item?.searchField) {
|
||||
contentLoader.item.searchField.text = "";
|
||||
contentLoader.item.searchField.forceActiveFocus();
|
||||
|
||||
@@ -53,8 +53,6 @@ DankPopout {
|
||||
open();
|
||||
activeImageLoads = 0;
|
||||
ClipboardService.reset();
|
||||
if (clipboardAvailable)
|
||||
ClipboardService.refresh();
|
||||
keyboardController.reset();
|
||||
|
||||
Qt.callLater(function () {
|
||||
@@ -121,8 +119,16 @@ DankPopout {
|
||||
onShouldBeVisibleChanged: {
|
||||
if (!shouldBeVisible)
|
||||
return;
|
||||
if (clipboardAvailable)
|
||||
ClipboardService.refresh();
|
||||
if (clipboardAvailable) {
|
||||
if (Theme.isConnectedEffect) {
|
||||
Qt.callLater(() => {
|
||||
if (root.shouldBeVisible)
|
||||
ClipboardService.refresh();
|
||||
});
|
||||
} else {
|
||||
ClipboardService.refresh();
|
||||
}
|
||||
}
|
||||
keyboardController.reset();
|
||||
Qt.callLater(function () {
|
||||
if (contentLoader.item?.searchField) {
|
||||
|
||||
@@ -26,9 +26,7 @@ Rectangle {
|
||||
spacing: 2
|
||||
|
||||
StyledText {
|
||||
text: keyboardHints.enterToPaste
|
||||
? I18n.tr("↑/↓: Navigate • Enter: Paste • Del: Delete • F10: Help", "Keyboard hints when enter-to-paste is enabled")
|
||||
: I18n.tr("↑/↓: Navigate • Enter/Ctrl+C: Copy • Del: Delete • F10: Help")
|
||||
text: keyboardHints.enterToPaste ? I18n.tr("↑/↓: Navigate • Enter: Paste • Del: Delete • F10: Help", "Keyboard hints when enter-to-paste is enabled") : I18n.tr("↑/↓: Navigate • Enter/Ctrl+C: Copy • Del: Delete • F10: Help")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceText
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
|
||||
@@ -6,6 +6,7 @@ import qs.Widgets
|
||||
|
||||
Item {
|
||||
id: thumbnail
|
||||
readonly property var log: Log.scoped("ClipboardThumbnail")
|
||||
|
||||
required property var entry
|
||||
required property string entryType
|
||||
@@ -52,7 +53,7 @@ Item {
|
||||
modal.activeImageLoads--;
|
||||
}
|
||||
if (response.error) {
|
||||
console.warn("ClipboardThumbnail: Failed to load image:", entry.id);
|
||||
log.warn("Failed to load image:", entry.id);
|
||||
return;
|
||||
}
|
||||
const data = response.result?.data;
|
||||
|
||||
@@ -1,24 +1,18 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
readonly property var log: Log.scoped("DankModal")
|
||||
|
||||
readonly property bool useHyprlandFocusGrab: CompositorService.useHyprlandFocusGrab
|
||||
property string layerNamespace: "dms:modal"
|
||||
property alias content: contentLoader.sourceComponent
|
||||
property alias contentLoader: contentLoader
|
||||
property Component content: null
|
||||
property Item directContent: null
|
||||
property real modalWidth: 400
|
||||
property real modalHeight: 300
|
||||
property var targetScreen
|
||||
readonly property var effectiveScreen: contentWindow.screen ?? targetScreen
|
||||
readonly property real screenWidth: effectiveScreen?.width ?? 1920
|
||||
readonly property real screenHeight: effectiveScreen?.height ?? 1080
|
||||
readonly property real dpr: effectiveScreen ? CompositorService.getScreenScale(effectiveScreen) : 1
|
||||
property bool showBackground: true
|
||||
property real backgroundOpacity: 0.5
|
||||
property string positioning: "center"
|
||||
@@ -36,7 +30,6 @@ Item {
|
||||
property real borderWidth: 0
|
||||
property real cornerRadius: Theme.cornerRadius
|
||||
property bool enableShadow: true
|
||||
property alias modalFocusScope: focusScope
|
||||
property bool shouldBeVisible: false
|
||||
property bool shouldHaveFocus: shouldBeVisible
|
||||
property bool allowFocusOverride: false
|
||||
@@ -45,452 +38,170 @@ Item {
|
||||
property bool keepPopoutsOpen: false
|
||||
property var customKeyboardFocus: null
|
||||
property bool useOverlayLayer: false
|
||||
readonly property alias contentWindow: contentWindow
|
||||
readonly property alias clickCatcher: clickCatcher
|
||||
readonly property bool useHyprlandFocusGrab: CompositorService.useHyprlandFocusGrab
|
||||
readonly property bool useBackground: showBackground && SettingsData.modalDarkenBackground
|
||||
readonly property bool useSingleWindow: CompositorService.isHyprland || useBackground
|
||||
|
||||
signal opened
|
||||
signal dialogClosed
|
||||
signal backgroundClicked
|
||||
|
||||
property bool animationsEnabled: true
|
||||
readonly property var contentLoader: impl.item ? impl.item.contentLoader : null
|
||||
readonly property alias modalFocusScope: _modalFocusScope
|
||||
|
||||
FocusScope {
|
||||
id: _modalFocusScope
|
||||
objectName: "modalFocusScope"
|
||||
focus: true
|
||||
anchors.fill: parent
|
||||
}
|
||||
readonly property var contentWindow: impl.item ? impl.item.contentWindow : null
|
||||
readonly property var clickCatcher: impl.item ? impl.item.clickCatcher : null
|
||||
readonly property var effectiveScreen: impl.item ? impl.item.effectiveScreen : null
|
||||
readonly property real screenWidth: impl.item ? impl.item.screenWidth : 1920
|
||||
readonly property real screenHeight: impl.item ? impl.item.screenHeight : 1080
|
||||
readonly property real dpr: impl.item ? impl.item.dpr : 1
|
||||
readonly property bool isClosing: impl.item ? (impl.item.isClosing ?? false) : false
|
||||
readonly property real alignedX: impl.item ? impl.item.alignedX : 0
|
||||
readonly property real alignedY: impl.item ? impl.item.alignedY : 0
|
||||
readonly property real alignedWidth: impl.item ? impl.item.alignedWidth : 0
|
||||
readonly property real alignedHeight: impl.item ? impl.item.alignedHeight : 0
|
||||
|
||||
function open() {
|
||||
closeTimer.stop();
|
||||
const focusedScreen = CompositorService.getFocusedScreen();
|
||||
const screenChanged = focusedScreen && contentWindow.screen !== focusedScreen;
|
||||
if (focusedScreen) {
|
||||
if (screenChanged)
|
||||
contentWindow.visible = false;
|
||||
contentWindow.screen = focusedScreen;
|
||||
if (!useSingleWindow) {
|
||||
if (screenChanged)
|
||||
clickCatcher.visible = false;
|
||||
clickCatcher.screen = focusedScreen;
|
||||
}
|
||||
}
|
||||
if (screenChanged) {
|
||||
Qt.callLater(() => root._finishOpen());
|
||||
} else {
|
||||
_finishOpen();
|
||||
}
|
||||
}
|
||||
|
||||
function _finishOpen() {
|
||||
ModalManager.openModal(root);
|
||||
shouldBeVisible = true;
|
||||
if (!useSingleWindow)
|
||||
clickCatcher.visible = true;
|
||||
contentWindow.visible = true;
|
||||
shouldHaveFocus = false;
|
||||
Qt.callLater(() => shouldHaveFocus = Qt.binding(() => shouldBeVisible));
|
||||
if (impl.item)
|
||||
impl.item.open();
|
||||
}
|
||||
|
||||
function close() {
|
||||
shouldBeVisible = false;
|
||||
shouldHaveFocus = false;
|
||||
ModalManager.closeModal(root);
|
||||
closeTimer.restart();
|
||||
if (impl.item)
|
||||
impl.item.close();
|
||||
}
|
||||
|
||||
function instantClose() {
|
||||
animationsEnabled = false;
|
||||
shouldBeVisible = false;
|
||||
shouldHaveFocus = false;
|
||||
ModalManager.closeModal(root);
|
||||
closeTimer.stop();
|
||||
contentWindow.visible = false;
|
||||
if (!useSingleWindow)
|
||||
clickCatcher.visible = false;
|
||||
dialogClosed();
|
||||
Qt.callLater(() => animationsEnabled = true);
|
||||
if (impl.item && typeof impl.item.instantClose === "function")
|
||||
impl.item.instantClose();
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
shouldBeVisible ? close() : open();
|
||||
if (impl.item)
|
||||
impl.item.toggle();
|
||||
}
|
||||
|
||||
readonly property var _desiredBackend: SettingsData.connectedFrameModeActive ? connectedComp : standaloneComp
|
||||
property var _resolvedBackend: null
|
||||
|
||||
Component.onCompleted: _resolvedBackend = _desiredBackend
|
||||
|
||||
Connections {
|
||||
target: SettingsData
|
||||
function onConnectedFrameModeActiveChanged() {
|
||||
root._maybeResolveBackend();
|
||||
}
|
||||
}
|
||||
|
||||
// Defer Loader source-component swap until impl is fully closed; avoids
|
||||
// tearing down a modal mid-animation when frame mode is toggled.
|
||||
function _maybeResolveBackend() {
|
||||
if (_resolvedBackend === _desiredBackend)
|
||||
return;
|
||||
if (impl.item && (impl.item.shouldBeVisible || impl.item.isClosing))
|
||||
return;
|
||||
_resolvedBackend = _desiredBackend;
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: impl
|
||||
sourceComponent: root._resolvedBackend
|
||||
onItemChanged: if (item)
|
||||
root._wireBackend(item)
|
||||
}
|
||||
|
||||
Component {
|
||||
id: standaloneComp
|
||||
DankModalStandalone {}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: connectedComp
|
||||
DankModalConnected {}
|
||||
}
|
||||
|
||||
function _wireBackend(it) {
|
||||
if (!it)
|
||||
return;
|
||||
|
||||
it.modalHandle = root;
|
||||
it.layerNamespace = Qt.binding(() => root.layerNamespace);
|
||||
it.content = Qt.binding(() => root.content);
|
||||
it.directContent = Qt.binding(() => root.directContent);
|
||||
it.modalWidth = Qt.binding(() => root.modalWidth);
|
||||
it.modalHeight = Qt.binding(() => root.modalHeight);
|
||||
it.targetScreen = Qt.binding(() => root.targetScreen);
|
||||
it.showBackground = Qt.binding(() => root.showBackground);
|
||||
it.backgroundOpacity = Qt.binding(() => root.backgroundOpacity);
|
||||
it.positioning = Qt.binding(() => root.positioning);
|
||||
it.customPosition = Qt.binding(() => root.customPosition);
|
||||
it.closeOnEscapeKey = Qt.binding(() => root.closeOnEscapeKey);
|
||||
it.closeOnBackgroundClick = Qt.binding(() => root.closeOnBackgroundClick);
|
||||
it.animationType = Qt.binding(() => root.animationType);
|
||||
it.animationDuration = Qt.binding(() => root.animationDuration);
|
||||
it.animationScaleCollapsed = Qt.binding(() => root.animationScaleCollapsed);
|
||||
it.animationOffset = Qt.binding(() => root.animationOffset);
|
||||
it.animationEnterCurve = Qt.binding(() => root.animationEnterCurve);
|
||||
it.animationExitCurve = Qt.binding(() => root.animationExitCurve);
|
||||
it.backgroundColor = Qt.binding(() => root.backgroundColor);
|
||||
it.borderColor = Qt.binding(() => root.borderColor);
|
||||
it.borderWidth = Qt.binding(() => root.borderWidth);
|
||||
it.cornerRadius = Qt.binding(() => root.cornerRadius);
|
||||
it.enableShadow = Qt.binding(() => root.enableShadow);
|
||||
it.allowFocusOverride = Qt.binding(() => root.allowFocusOverride);
|
||||
it.allowStacking = Qt.binding(() => root.allowStacking);
|
||||
it.keepContentLoaded = Qt.binding(() => root.keepContentLoaded);
|
||||
it.keepPopoutsOpen = Qt.binding(() => root.keepPopoutsOpen);
|
||||
it.customKeyboardFocus = Qt.binding(() => root.customKeyboardFocus);
|
||||
it.useOverlayLayer = Qt.binding(() => root.useOverlayLayer);
|
||||
|
||||
it.shouldBeVisible = root.shouldBeVisible;
|
||||
it.shouldHaveFocus = root.shouldHaveFocus;
|
||||
|
||||
if (it.modalFocusScope)
|
||||
_modalFocusScope.parent = it.modalFocusScope;
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: ModalManager
|
||||
function onCloseAllModalsExcept(excludedModal) {
|
||||
if (excludedModal !== root && !allowStacking && shouldBeVisible)
|
||||
close();
|
||||
target: root
|
||||
function onShouldBeVisibleChanged() {
|
||||
if (impl.item && impl.item.shouldBeVisible !== root.shouldBeVisible)
|
||||
impl.item.shouldBeVisible = root.shouldBeVisible;
|
||||
}
|
||||
function onShouldHaveFocusChanged() {
|
||||
if (impl.item && impl.item.shouldHaveFocus !== root.shouldHaveFocus)
|
||||
impl.item.shouldHaveFocus = root.shouldHaveFocus;
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: Quickshell
|
||||
function onScreensChanged() {
|
||||
if (!contentWindow.screen)
|
||||
return;
|
||||
const currentScreenName = contentWindow.screen.name;
|
||||
let screenStillExists = false;
|
||||
for (let i = 0; i < Quickshell.screens.length; i++) {
|
||||
if (Quickshell.screens[i].name === currentScreenName) {
|
||||
screenStillExists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (screenStillExists)
|
||||
return;
|
||||
const newScreen = CompositorService.getFocusedScreen();
|
||||
if (newScreen) {
|
||||
contentWindow.screen = newScreen;
|
||||
if (!useSingleWindow)
|
||||
clickCatcher.screen = newScreen;
|
||||
}
|
||||
}
|
||||
}
|
||||
target: impl.item
|
||||
ignoreUnknownSignals: true
|
||||
|
||||
Timer {
|
||||
id: closeTimer
|
||||
interval: animationDuration + 50
|
||||
onTriggered: {
|
||||
if (shouldBeVisible)
|
||||
return;
|
||||
contentWindow.visible = false;
|
||||
if (!useSingleWindow)
|
||||
clickCatcher.visible = false;
|
||||
dialogClosed();
|
||||
}
|
||||
}
|
||||
|
||||
readonly property var shadowLevel: Theme.elevationLevel3
|
||||
readonly property real shadowFallbackOffset: 6
|
||||
readonly property real shadowRenderPadding: (root.enableShadow && Theme.elevationEnabled && SettingsData.modalElevationEnabled) ? Theme.elevationRenderPadding(shadowLevel, Theme.elevationLightDirection, shadowFallbackOffset, 8, 16) : 0
|
||||
readonly property real shadowMotionPadding: animationType === "slide" ? 30 : Math.max(0, animationOffset)
|
||||
readonly property real shadowBuffer: Theme.snap(shadowRenderPadding + shadowMotionPadding, dpr)
|
||||
readonly property real alignedWidth: Theme.px(modalWidth, dpr)
|
||||
readonly property real alignedHeight: Theme.px(modalHeight, dpr)
|
||||
|
||||
readonly property real alignedX: Theme.snap((() => {
|
||||
switch (positioning) {
|
||||
case "center":
|
||||
return (screenWidth - alignedWidth) / 2;
|
||||
case "top-right":
|
||||
return Math.max(Theme.spacingL, screenWidth - alignedWidth - Theme.spacingL);
|
||||
case "custom":
|
||||
return customPosition.x;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
})(), dpr)
|
||||
|
||||
readonly property real alignedY: Theme.snap((() => {
|
||||
switch (positioning) {
|
||||
case "center":
|
||||
return (screenHeight - alignedHeight) / 2;
|
||||
case "top-right":
|
||||
return Theme.barHeight + Theme.spacingXS;
|
||||
case "custom":
|
||||
return customPosition.y;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
})(), dpr)
|
||||
|
||||
PanelWindow {
|
||||
id: clickCatcher
|
||||
visible: false
|
||||
color: "transparent"
|
||||
|
||||
WlrLayershell.namespace: root.layerNamespace + ":clickcatcher"
|
||||
WlrLayershell.layer: WlrLayershell.Top
|
||||
WlrLayershell.exclusiveZone: -1
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
||||
|
||||
anchors {
|
||||
top: true
|
||||
left: true
|
||||
right: true
|
||||
bottom: true
|
||||
function onShouldBeVisibleChanged() {
|
||||
if (impl.item && root.shouldBeVisible !== impl.item.shouldBeVisible)
|
||||
root.shouldBeVisible = impl.item.shouldBeVisible;
|
||||
}
|
||||
|
||||
mask: Region {
|
||||
item: Rectangle {
|
||||
x: root.alignedX
|
||||
y: root.alignedY
|
||||
width: root.alignedWidth
|
||||
height: root.alignedHeight
|
||||
}
|
||||
intersection: Intersection.Xor
|
||||
function onShouldHaveFocusChanged() {
|
||||
if (impl.item && root.shouldHaveFocus !== impl.item.shouldHaveFocus)
|
||||
root.shouldHaveFocus = impl.item.shouldHaveFocus;
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
enabled: root.closeOnBackgroundClick && root.shouldBeVisible
|
||||
onClicked: root.backgroundClicked()
|
||||
}
|
||||
}
|
||||
|
||||
PanelWindow {
|
||||
id: contentWindow
|
||||
visible: false
|
||||
color: "transparent"
|
||||
|
||||
WindowBlur {
|
||||
targetWindow: contentWindow
|
||||
readonly property real s: Math.min(1, modalContainer.scaleValue)
|
||||
blurX: modalContainer.x + modalContainer.width * (1 - s) * 0.5 + Theme.snap(modalContainer.animX, root.dpr)
|
||||
blurY: modalContainer.y + modalContainer.height * (1 - s) * 0.5 + Theme.snap(modalContainer.animY, root.dpr)
|
||||
blurWidth: (shouldBeVisible && animatedContent.opacity > 0) ? modalContainer.width * s : 0
|
||||
blurHeight: (shouldBeVisible && animatedContent.opacity > 0) ? modalContainer.height * s : 0
|
||||
blurRadius: root.cornerRadius
|
||||
function onOpened() {
|
||||
root.opened();
|
||||
}
|
||||
|
||||
WlrLayershell.namespace: root.layerNamespace
|
||||
WlrLayershell.layer: {
|
||||
if (root.useOverlayLayer)
|
||||
return WlrLayershell.Overlay;
|
||||
switch (Quickshell.env("DMS_MODAL_LAYER")) {
|
||||
case "bottom":
|
||||
console.error("DankModal: 'bottom' layer is not valid for modals. Defaulting to 'top' layer.");
|
||||
return WlrLayershell.Top;
|
||||
case "background":
|
||||
console.error("DankModal: 'background' layer is not valid for modals. Defaulting to 'top' layer.");
|
||||
return WlrLayershell.Top;
|
||||
case "overlay":
|
||||
return WlrLayershell.Overlay;
|
||||
default:
|
||||
return WlrLayershell.Top;
|
||||
}
|
||||
}
|
||||
WlrLayershell.exclusiveZone: -1
|
||||
WlrLayershell.keyboardFocus: {
|
||||
if (customKeyboardFocus !== null)
|
||||
return customKeyboardFocus;
|
||||
if (!shouldHaveFocus)
|
||||
return WlrKeyboardFocus.None;
|
||||
if (root.useHyprlandFocusGrab)
|
||||
return WlrKeyboardFocus.OnDemand;
|
||||
return WlrKeyboardFocus.Exclusive;
|
||||
function onDialogClosed() {
|
||||
root.dialogClosed();
|
||||
root._maybeResolveBackend();
|
||||
}
|
||||
|
||||
anchors {
|
||||
left: true
|
||||
top: true
|
||||
right: root.useSingleWindow
|
||||
bottom: root.useSingleWindow
|
||||
}
|
||||
|
||||
WlrLayershell.margins {
|
||||
left: root.useSingleWindow ? 0 : Math.max(0, Theme.snap(root.alignedX - shadowBuffer, dpr))
|
||||
top: root.useSingleWindow ? 0 : Math.max(0, Theme.snap(root.alignedY - shadowBuffer, dpr))
|
||||
right: 0
|
||||
bottom: 0
|
||||
}
|
||||
|
||||
implicitWidth: root.useSingleWindow ? 0 : root.alignedWidth + (shadowBuffer * 2)
|
||||
implicitHeight: root.useSingleWindow ? 0 : root.alignedHeight + (shadowBuffer * 2)
|
||||
|
||||
onVisibleChanged: {
|
||||
if (visible) {
|
||||
opened();
|
||||
} else {
|
||||
if (Qt.inputMethod) {
|
||||
Qt.inputMethod.hide();
|
||||
Qt.inputMethod.reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
enabled: root.useSingleWindow && root.closeOnBackgroundClick && root.shouldBeVisible
|
||||
z: -2
|
||||
onClicked: root.backgroundClicked()
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
z: -1
|
||||
color: "black"
|
||||
opacity: root.useBackground ? (root.shouldBeVisible ? root.backgroundOpacity : 0) : 0
|
||||
visible: root.useBackground
|
||||
|
||||
Behavior on opacity {
|
||||
enabled: root.animationsEnabled
|
||||
DankAnim {
|
||||
duration: root.animationDuration
|
||||
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: modalContainer
|
||||
x: root.useSingleWindow ? root.alignedX : shadowBuffer
|
||||
y: root.useSingleWindow ? root.alignedY : shadowBuffer
|
||||
|
||||
width: root.alignedWidth
|
||||
height: root.alignedHeight
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
enabled: root.useSingleWindow && root.shouldBeVisible
|
||||
hoverEnabled: false
|
||||
acceptedButtons: Qt.AllButtons
|
||||
onPressed: mouse.accepted = true
|
||||
onClicked: mouse.accepted = true
|
||||
z: -1
|
||||
}
|
||||
|
||||
readonly property bool slide: root.animationType === "slide"
|
||||
readonly property real offsetX: slide ? 15 : 0
|
||||
readonly property real offsetY: slide ? -30 : root.animationOffset
|
||||
|
||||
property real animX: 0
|
||||
property real animY: 0
|
||||
property real scaleValue: root.animationScaleCollapsed
|
||||
|
||||
onOffsetXChanged: animX = Theme.snap(root.shouldBeVisible ? 0 : offsetX, root.dpr)
|
||||
onOffsetYChanged: animY = Theme.snap(root.shouldBeVisible ? 0 : offsetY, root.dpr)
|
||||
|
||||
Connections {
|
||||
target: root
|
||||
function onShouldBeVisibleChanged() {
|
||||
modalContainer.animX = Theme.snap(root.shouldBeVisible ? 0 : modalContainer.offsetX, root.dpr);
|
||||
modalContainer.animY = Theme.snap(root.shouldBeVisible ? 0 : modalContainer.offsetY, root.dpr);
|
||||
modalContainer.scaleValue = root.shouldBeVisible ? 1.0 : root.animationScaleCollapsed;
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on animX {
|
||||
enabled: root.animationsEnabled
|
||||
DankAnim {
|
||||
duration: root.animationDuration
|
||||
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on animY {
|
||||
enabled: root.animationsEnabled
|
||||
DankAnim {
|
||||
duration: root.animationDuration
|
||||
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on scaleValue {
|
||||
enabled: root.animationsEnabled
|
||||
DankAnim {
|
||||
duration: root.animationDuration
|
||||
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: contentContainer
|
||||
anchors.centerIn: parent
|
||||
width: parent.width
|
||||
height: parent.height
|
||||
clip: false
|
||||
|
||||
Item {
|
||||
id: animatedContent
|
||||
anchors.fill: parent
|
||||
clip: false
|
||||
opacity: root.shouldBeVisible ? 1 : 0
|
||||
scale: modalContainer.scaleValue
|
||||
x: Theme.snap(modalContainer.animX, root.dpr) + (parent.width - width) * (1 - modalContainer.scaleValue) * 0.5
|
||||
y: Theme.snap(modalContainer.animY, root.dpr) + (parent.height - height) * (1 - modalContainer.scaleValue) * 0.5
|
||||
|
||||
Behavior on opacity {
|
||||
enabled: root.animationsEnabled
|
||||
NumberAnimation {
|
||||
duration: animationDuration
|
||||
easing.type: Easing.BezierSpline
|
||||
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
|
||||
}
|
||||
}
|
||||
|
||||
ElevationShadow {
|
||||
id: modalShadowLayer
|
||||
anchors.fill: parent
|
||||
level: root.shadowLevel
|
||||
fallbackOffset: root.shadowFallbackOffset
|
||||
targetRadius: root.cornerRadius
|
||||
targetColor: root.backgroundColor
|
||||
borderColor: root.borderColor
|
||||
borderWidth: root.borderWidth
|
||||
shadowEnabled: root.enableShadow && Theme.elevationEnabled && SettingsData.modalElevationEnabled && Quickshell.env("DMS_DISABLE_LAYER") !== "true" && Quickshell.env("DMS_DISABLE_LAYER") !== "1" && !BlurService.enabled
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: root.cornerRadius
|
||||
color: "transparent"
|
||||
border.color: BlurService.borderColor
|
||||
border.width: BlurService.borderWidth
|
||||
z: 100
|
||||
}
|
||||
|
||||
FocusScope {
|
||||
anchors.fill: parent
|
||||
focus: root.shouldBeVisible
|
||||
clip: false
|
||||
|
||||
Item {
|
||||
id: directContentWrapper
|
||||
anchors.fill: parent
|
||||
visible: root.directContent !== null
|
||||
focus: true
|
||||
clip: false
|
||||
|
||||
Component.onCompleted: {
|
||||
if (root.directContent) {
|
||||
root.directContent.parent = directContentWrapper;
|
||||
root.directContent.anchors.fill = directContentWrapper;
|
||||
Qt.callLater(() => root.directContent.forceActiveFocus());
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: root
|
||||
function onDirectContentChanged() {
|
||||
if (root.directContent) {
|
||||
root.directContent.parent = directContentWrapper;
|
||||
root.directContent.anchors.fill = directContentWrapper;
|
||||
Qt.callLater(() => root.directContent.forceActiveFocus());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: contentLoader
|
||||
anchors.fill: parent
|
||||
active: root.directContent === null && (root.keepContentLoaded || root.shouldBeVisible || contentWindow.visible)
|
||||
asynchronous: false
|
||||
focus: true
|
||||
clip: false
|
||||
visible: root.directContent === null
|
||||
|
||||
onLoaded: {
|
||||
if (item) {
|
||||
Qt.callLater(() => item.forceActiveFocus());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FocusScope {
|
||||
id: focusScope
|
||||
objectName: "modalFocusScope"
|
||||
anchors.fill: parent
|
||||
visible: root.shouldBeVisible || contentWindow.visible
|
||||
focus: root.shouldBeVisible
|
||||
Keys.onEscapePressed: event => {
|
||||
if (root.closeOnEscapeKey && shouldHaveFocus) {
|
||||
root.close();
|
||||
event.accepted = true;
|
||||
}
|
||||
}
|
||||
function onBackgroundClicked() {
|
||||
root.backgroundClicked();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
838
quickshell/Modals/Common/DankModalConnected.qml
Normal file
838
quickshell/Modals/Common/DankModalConnected.qml
Normal file
@@ -0,0 +1,838 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
readonly property var log: Log.scoped("DankModalConnected")
|
||||
|
||||
property var modalHandle: root
|
||||
property string layerNamespace: "dms:modal"
|
||||
property alias content: contentLoader.sourceComponent
|
||||
property alias contentLoader: contentLoader
|
||||
property Item directContent: null
|
||||
property real modalWidth: 400
|
||||
property real modalHeight: 300
|
||||
property var targetScreen
|
||||
readonly property var effectiveScreen: contentWindow.screen ?? targetScreen
|
||||
readonly property real screenWidth: effectiveScreen?.width ?? 1920
|
||||
readonly property real screenHeight: effectiveScreen?.height ?? 1080
|
||||
readonly property real dpr: effectiveScreen ? CompositorService.getScreenScale(effectiveScreen) : 1
|
||||
property bool showBackground: true
|
||||
property real backgroundOpacity: 0.5
|
||||
property string positioning: "center"
|
||||
property point customPosition: Qt.point(0, 0)
|
||||
property bool closeOnEscapeKey: true
|
||||
property bool closeOnBackgroundClick: true
|
||||
property string animationType: "scale"
|
||||
|
||||
// Opposite side from the launcher by default; subclasses may override
|
||||
property string preferredConnectedBarSide: SettingsData.frameModalEmergeSide
|
||||
|
||||
readonly property bool frameConnectedMode: SettingsData.frameEnabled && Theme.isConnectedEffect && !!effectiveScreen && SettingsData.isScreenInPreferences(effectiveScreen, SettingsData.frameScreenPreferences)
|
||||
|
||||
readonly property string resolvedConnectedBarSide: frameConnectedMode ? preferredConnectedBarSide : ""
|
||||
|
||||
readonly property bool frameOwnsConnectedChrome: frameConnectedMode && resolvedConnectedBarSide !== "" && !allowStacking
|
||||
|
||||
function _dockOccupiesSide(side) {
|
||||
if (!SettingsData.showDock)
|
||||
return false;
|
||||
switch (side) {
|
||||
case "top":
|
||||
return SettingsData.dockPosition === SettingsData.Position.Top;
|
||||
case "bottom":
|
||||
return SettingsData.dockPosition === SettingsData.Position.Bottom;
|
||||
case "left":
|
||||
return SettingsData.dockPosition === SettingsData.Position.Left;
|
||||
case "right":
|
||||
return SettingsData.dockPosition === SettingsData.Position.Right;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
readonly property bool _dockBlocksEmergence: frameOwnsConnectedChrome && _dockOccupiesSide(resolvedConnectedBarSide)
|
||||
|
||||
readonly property bool connectedMotionParity: Theme.isConnectedEffect
|
||||
property int animationDuration: connectedMotionParity ? Theme.popoutAnimationDuration : Theme.modalAnimationDuration
|
||||
property real animationScaleCollapsed: Theme.effectScaleCollapsed
|
||||
property real animationOffset: Theme.effectAnimOffset
|
||||
property list<real> animationEnterCurve: connectedMotionParity ? Theme.variantPopoutEnterCurve : Theme.variantModalEnterCurve
|
||||
property list<real> animationExitCurve: connectedMotionParity ? Theme.variantPopoutExitCurve : Theme.variantModalExitCurve
|
||||
property color backgroundColor: Theme.surfaceContainer
|
||||
property color borderColor: Theme.outlineMedium
|
||||
property real borderWidth: 0
|
||||
property real cornerRadius: Theme.cornerRadius
|
||||
readonly property bool connectedSurfaceOverride: Theme.isConnectedEffect
|
||||
readonly property color effectiveBackgroundColor: connectedSurfaceOverride ? Theme.connectedSurfaceColor : backgroundColor
|
||||
readonly property color effectiveBorderColor: connectedSurfaceOverride ? "transparent" : borderColor
|
||||
readonly property real effectiveBorderWidth: connectedSurfaceOverride ? 0 : borderWidth
|
||||
readonly property real effectiveCornerRadius: connectedSurfaceOverride ? Theme.connectedSurfaceRadius : cornerRadius
|
||||
readonly property bool effectiveBlurEnabled: Theme.connectedSurfaceBlurEnabled
|
||||
property bool enableShadow: true
|
||||
property alias modalFocusScope: focusScope
|
||||
property bool shouldBeVisible: false
|
||||
property bool shouldHaveFocus: shouldBeVisible
|
||||
property bool allowFocusOverride: false
|
||||
property bool allowStacking: false
|
||||
property bool keepContentLoaded: false
|
||||
property bool keepPopoutsOpen: false
|
||||
property var customKeyboardFocus: null
|
||||
property bool useOverlayLayer: false
|
||||
property real frozenMotionOffsetX: 0
|
||||
property real frozenMotionOffsetY: 0
|
||||
readonly property alias contentWindow: contentWindow
|
||||
readonly property alias clickCatcher: clickCatcher
|
||||
readonly property bool useHyprlandFocusGrab: CompositorService.useHyprlandFocusGrab
|
||||
readonly property bool useBackground: false
|
||||
readonly property bool useSingleWindow: CompositorService.isHyprland
|
||||
|
||||
signal opened
|
||||
signal dialogClosed
|
||||
signal backgroundClicked
|
||||
|
||||
// Coalesce per-channel dirty bits; one ConnectedModeState write per tick.
|
||||
Timer {
|
||||
id: _syncTimer
|
||||
interval: 0
|
||||
onTriggered: root._flushSync()
|
||||
}
|
||||
|
||||
property bool animationsEnabled: true
|
||||
|
||||
property string _chromeClaimId: ""
|
||||
property bool _fullSyncPending: false
|
||||
|
||||
function _nextChromeClaimId() {
|
||||
return layerNamespace + ":modal:" + (new Date()).getTime() + ":" + Math.floor(Math.random() * 1000);
|
||||
}
|
||||
|
||||
function _currentScreenName() {
|
||||
return effectiveScreen ? effectiveScreen.name : "";
|
||||
}
|
||||
|
||||
function _publishModalChromeState() {
|
||||
const screenName = _currentScreenName();
|
||||
if (!screenName)
|
||||
return;
|
||||
ConnectedModeState.setModalState(screenName, {
|
||||
"visible": shouldBeVisible || contentWindow.visible,
|
||||
"barSide": resolvedConnectedBarSide,
|
||||
"bodyX": alignedX,
|
||||
"bodyY": alignedY,
|
||||
"bodyW": alignedWidth,
|
||||
"bodyH": alignedHeight,
|
||||
"animX": modalContainer ? modalContainer.animX : 0,
|
||||
"animY": modalContainer ? modalContainer.animY : 0,
|
||||
"omitStartConnector": false,
|
||||
"omitEndConnector": false
|
||||
});
|
||||
}
|
||||
|
||||
function _syncModalChromeState() {
|
||||
if (!frameOwnsConnectedChrome) {
|
||||
_releaseModalChrome();
|
||||
return;
|
||||
}
|
||||
if (!_chromeClaimId)
|
||||
_chromeClaimId = _nextChromeClaimId();
|
||||
_publishModalChromeState();
|
||||
if (_dockBlocksEmergence && (shouldBeVisible || contentWindow.visible))
|
||||
ConnectedModeState.requestDockRetract(_chromeClaimId, _currentScreenName(), resolvedConnectedBarSide);
|
||||
else
|
||||
ConnectedModeState.releaseDockRetract(_chromeClaimId);
|
||||
}
|
||||
|
||||
property bool _animSyncQueued: false
|
||||
property bool _bodySyncQueued: false
|
||||
|
||||
function _queueFullSync() {
|
||||
_fullSyncPending = true;
|
||||
if (!_syncTimer.running)
|
||||
_syncTimer.restart();
|
||||
}
|
||||
function _queueAnimSync() {
|
||||
_animSyncQueued = true;
|
||||
if (!_syncTimer.running)
|
||||
_syncTimer.restart();
|
||||
}
|
||||
function _queueBodySync() {
|
||||
_bodySyncQueued = true;
|
||||
if (!_syncTimer.running)
|
||||
_syncTimer.restart();
|
||||
}
|
||||
function _flushSync() {
|
||||
const fullDirty = _fullSyncPending;
|
||||
const animDirty = _animSyncQueued;
|
||||
const bodyDirty = _bodySyncQueued;
|
||||
_fullSyncPending = false;
|
||||
_animSyncQueued = false;
|
||||
_bodySyncQueued = false;
|
||||
if (fullDirty)
|
||||
_syncModalChromeState();
|
||||
if (animDirty)
|
||||
_syncModalAnim();
|
||||
if (bodyDirty)
|
||||
_syncModalBody();
|
||||
}
|
||||
|
||||
function _syncModalAnim() {
|
||||
if (!frameOwnsConnectedChrome || !_chromeClaimId)
|
||||
return;
|
||||
const screenName = _currentScreenName();
|
||||
if (!screenName || !modalContainer)
|
||||
return;
|
||||
ConnectedModeState.setModalAnim(screenName, modalContainer.animX, modalContainer.animY);
|
||||
}
|
||||
|
||||
function _syncModalBody() {
|
||||
if (!frameOwnsConnectedChrome || !_chromeClaimId)
|
||||
return;
|
||||
const screenName = _currentScreenName();
|
||||
if (!screenName)
|
||||
return;
|
||||
ConnectedModeState.setModalBody(screenName, alignedX, alignedY, alignedWidth, alignedHeight);
|
||||
}
|
||||
|
||||
function _releaseModalChrome() {
|
||||
if (!_chromeClaimId)
|
||||
return;
|
||||
ConnectedModeState.releaseDockRetract(_chromeClaimId);
|
||||
_chromeClaimId = "";
|
||||
const screenName = _currentScreenName();
|
||||
if (screenName)
|
||||
ConnectedModeState.clearModalState(screenName);
|
||||
}
|
||||
|
||||
onFrameOwnsConnectedChromeChanged: _syncModalChromeState()
|
||||
onResolvedConnectedBarSideChanged: _queueFullSync()
|
||||
onShouldBeVisibleChanged: _queueFullSync()
|
||||
onAlignedXChanged: _queueBodySync()
|
||||
onAlignedYChanged: _queueBodySync()
|
||||
onAlignedWidthChanged: _queueBodySync()
|
||||
onAlignedHeightChanged: _queueBodySync()
|
||||
|
||||
Component.onDestruction: _releaseModalChrome()
|
||||
|
||||
Connections {
|
||||
target: contentWindow
|
||||
function onVisibleChanged() {
|
||||
if (contentWindow.visible)
|
||||
root._syncModalChromeState();
|
||||
else
|
||||
root._releaseModalChrome();
|
||||
}
|
||||
}
|
||||
|
||||
function open() {
|
||||
closeTimer.stop();
|
||||
animationsEnabled = false;
|
||||
frozenMotionOffsetX = modalContainer ? modalContainer.offsetX : 0;
|
||||
frozenMotionOffsetY = modalContainer ? modalContainer.offsetY : animationOffset;
|
||||
|
||||
const focusedScreen = CompositorService.getFocusedScreen();
|
||||
if (focusedScreen) {
|
||||
contentWindow.screen = focusedScreen;
|
||||
if (!useSingleWindow)
|
||||
clickCatcher.screen = focusedScreen;
|
||||
}
|
||||
|
||||
if (Theme.isDirectionalEffect || root.useBackground) {
|
||||
if (!useSingleWindow)
|
||||
clickCatcher.visible = true;
|
||||
contentWindow.visible = true;
|
||||
}
|
||||
ModalManager.openModal(modalHandle);
|
||||
|
||||
Qt.callLater(() => {
|
||||
animationsEnabled = true;
|
||||
shouldBeVisible = true;
|
||||
if (!useSingleWindow && !clickCatcher.visible)
|
||||
clickCatcher.visible = true;
|
||||
if (!contentWindow.visible)
|
||||
contentWindow.visible = true;
|
||||
shouldHaveFocus = false;
|
||||
Qt.callLater(() => shouldHaveFocus = Qt.binding(() => shouldBeVisible));
|
||||
});
|
||||
}
|
||||
|
||||
function close() {
|
||||
shouldBeVisible = false;
|
||||
shouldHaveFocus = false;
|
||||
ModalManager.closeModal(modalHandle);
|
||||
closeTimer.restart();
|
||||
}
|
||||
|
||||
function instantClose() {
|
||||
animationsEnabled = false;
|
||||
shouldBeVisible = false;
|
||||
shouldHaveFocus = false;
|
||||
ModalManager.closeModal(modalHandle);
|
||||
closeTimer.stop();
|
||||
contentWindow.visible = false;
|
||||
if (!useSingleWindow)
|
||||
clickCatcher.visible = false;
|
||||
dialogClosed();
|
||||
Qt.callLater(() => animationsEnabled = true);
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
shouldBeVisible ? close() : open();
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: ModalManager
|
||||
function onCloseAllModalsExcept(excludedModal) {
|
||||
if (excludedModal !== modalHandle && !allowStacking && shouldBeVisible)
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: Quickshell
|
||||
function onScreensChanged() {
|
||||
if (!contentWindow.screen)
|
||||
return;
|
||||
const currentScreenName = contentWindow.screen.name;
|
||||
let screenStillExists = false;
|
||||
for (let i = 0; i < Quickshell.screens.length; i++) {
|
||||
if (Quickshell.screens[i].name === currentScreenName) {
|
||||
screenStillExists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (screenStillExists)
|
||||
return;
|
||||
const newScreen = CompositorService.getFocusedScreen();
|
||||
if (newScreen) {
|
||||
contentWindow.screen = newScreen;
|
||||
if (!useSingleWindow)
|
||||
clickCatcher.screen = newScreen;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: closeTimer
|
||||
interval: Theme.variantCloseInterval(animationDuration)
|
||||
onTriggered: {
|
||||
if (shouldBeVisible)
|
||||
return;
|
||||
contentWindow.visible = false;
|
||||
if (!useSingleWindow)
|
||||
clickCatcher.visible = false;
|
||||
dialogClosed();
|
||||
}
|
||||
}
|
||||
|
||||
// shadowRenderPadding is zeroed when frame owns the chrome
|
||||
// Wayland then clips any content translating past
|
||||
readonly property var shadowLevel: Theme.elevationLevel3
|
||||
readonly property real shadowFallbackOffset: 6
|
||||
readonly property real shadowRenderPadding: (!frameOwnsConnectedChrome && root.enableShadow && Theme.elevationEnabled && SettingsData.modalElevationEnabled) ? Theme.elevationRenderPadding(shadowLevel, Theme.elevationLightDirection, shadowFallbackOffset, 8, 16) : 0
|
||||
readonly property real shadowMotionPadding: {
|
||||
if (Theme.isConnectedEffect)
|
||||
return 0;
|
||||
if (animationType === "slide")
|
||||
return 30;
|
||||
if (Theme.isDirectionalEffect)
|
||||
return Math.max(Math.max(0, animationOffset), Math.max(alignedWidth, alignedHeight) * 0.9);
|
||||
if (Theme.isDepthEffect)
|
||||
return Math.max(Math.max(0, animationOffset), Math.max(alignedWidth, alignedHeight) * 0.35);
|
||||
return Math.max(0, animationOffset);
|
||||
}
|
||||
readonly property real shadowBuffer: Theme.snap(shadowRenderPadding + shadowMotionPadding, dpr)
|
||||
readonly property real alignedWidth: Theme.px(modalWidth, dpr)
|
||||
readonly property real alignedHeight: Theme.px(modalHeight, dpr)
|
||||
|
||||
function _frameEdgeInset(side) {
|
||||
if (!effectiveScreen)
|
||||
return 0;
|
||||
return SettingsData.frameEdgeInsetForSide(effectiveScreen, side);
|
||||
}
|
||||
|
||||
// frameEdgeInsetForSide is the full inset; do not add frameBarSize
|
||||
readonly property real _connectedAlignedX: {
|
||||
switch (resolvedConnectedBarSide) {
|
||||
case "top":
|
||||
case "bottom":
|
||||
{
|
||||
const insetL = _frameEdgeInset("left");
|
||||
const insetR = _frameEdgeInset("right");
|
||||
const usable = Math.max(0, screenWidth - insetL - insetR);
|
||||
return insetL + Math.max(0, (usable - alignedWidth) / 2);
|
||||
}
|
||||
case "left":
|
||||
return _frameEdgeInset("left");
|
||||
case "right":
|
||||
return screenWidth - alignedWidth - _frameEdgeInset("right");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
readonly property real _connectedAlignedY: {
|
||||
switch (resolvedConnectedBarSide) {
|
||||
case "top":
|
||||
return _frameEdgeInset("top");
|
||||
case "bottom":
|
||||
return screenHeight - alignedHeight - _frameEdgeInset("bottom");
|
||||
case "left":
|
||||
case "right":
|
||||
{
|
||||
const insetT = _frameEdgeInset("top");
|
||||
const insetB = _frameEdgeInset("bottom");
|
||||
const usable = Math.max(0, screenHeight - insetT - insetB);
|
||||
return insetT + Math.max(0, (usable - alignedHeight) / 2);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
readonly property real alignedX: Theme.snap(frameOwnsConnectedChrome ? _connectedAlignedX : (() => {
|
||||
switch (positioning) {
|
||||
case "center":
|
||||
return (screenWidth - alignedWidth) / 2;
|
||||
case "top-right":
|
||||
return Math.max(Theme.spacingL, screenWidth - alignedWidth - Theme.spacingL);
|
||||
case "custom":
|
||||
return customPosition.x;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
})(), dpr)
|
||||
|
||||
readonly property real alignedY: Theme.snap(frameOwnsConnectedChrome ? _connectedAlignedY : (() => {
|
||||
switch (positioning) {
|
||||
case "center":
|
||||
return (screenHeight - alignedHeight) / 2;
|
||||
case "top-right":
|
||||
return Theme.barHeight + Theme.spacingXS;
|
||||
case "custom":
|
||||
return customPosition.y;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
})(), dpr)
|
||||
|
||||
PanelWindow {
|
||||
id: clickCatcher
|
||||
visible: false
|
||||
color: "transparent"
|
||||
|
||||
WlrLayershell.namespace: root.layerNamespace + ":clickcatcher"
|
||||
WlrLayershell.layer: WlrLayershell.Top
|
||||
WlrLayershell.exclusiveZone: -1
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
||||
|
||||
anchors {
|
||||
top: true
|
||||
left: true
|
||||
right: true
|
||||
bottom: true
|
||||
}
|
||||
|
||||
mask: Region {
|
||||
item: Rectangle {
|
||||
x: root.alignedX
|
||||
y: root.alignedY
|
||||
width: root.alignedWidth
|
||||
height: root.alignedHeight
|
||||
}
|
||||
intersection: Intersection.Xor
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
enabled: !root.useSingleWindow && root.closeOnBackgroundClick && root.shouldBeVisible
|
||||
onClicked: root.backgroundClicked()
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
z: -1
|
||||
color: "black"
|
||||
opacity: (!root.useSingleWindow && root.useBackground) ? (root.shouldBeVisible ? root.backgroundOpacity : 0) : 0
|
||||
visible: opacity > 0
|
||||
|
||||
Behavior on opacity {
|
||||
enabled: root.animationsEnabled && (!Theme.isDirectionalEffect || Theme.isConnectedEffect)
|
||||
NumberAnimation {
|
||||
duration: Math.round(Theme.variantDuration(root.animationDuration, root.shouldBeVisible) * Theme.variantOpacityDurationScale)
|
||||
easing.type: Easing.BezierSpline
|
||||
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PanelWindow {
|
||||
id: contentWindow
|
||||
visible: false
|
||||
color: "transparent"
|
||||
|
||||
WindowBlur {
|
||||
targetWindow: contentWindow
|
||||
blurEnabled: root.effectiveBlurEnabled && !root.frameOwnsConnectedChrome
|
||||
readonly property real s: Math.min(1, modalContainer.scaleValue)
|
||||
blurX: modalContainer.x + modalContainer.width * (1 - s) * 0.5 + Theme.snap(modalContainer.animX, root.dpr)
|
||||
blurY: modalContainer.y + modalContainer.height * (1 - s) * 0.5 + Theme.snap(modalContainer.animY, root.dpr)
|
||||
blurWidth: (root.shouldBeVisible && !root.frameOwnsConnectedChrome) ? modalContainer.width * s : 0
|
||||
blurHeight: (root.shouldBeVisible && !root.frameOwnsConnectedChrome) ? modalContainer.height * s : 0
|
||||
blurRadius: root.effectiveCornerRadius
|
||||
}
|
||||
|
||||
WlrLayershell.namespace: root.layerNamespace
|
||||
WlrLayershell.layer: {
|
||||
if (root.useOverlayLayer)
|
||||
return WlrLayershell.Overlay;
|
||||
switch (Quickshell.env("DMS_MODAL_LAYER")) {
|
||||
case "bottom":
|
||||
log.error("'bottom' layer is not valid for modals. Defaulting to 'top' layer.");
|
||||
return WlrLayershell.Top;
|
||||
case "background":
|
||||
log.error("'background' layer is not valid for modals. Defaulting to 'top' layer.");
|
||||
return WlrLayershell.Top;
|
||||
case "overlay":
|
||||
return WlrLayershell.Overlay;
|
||||
default:
|
||||
return WlrLayershell.Top;
|
||||
}
|
||||
}
|
||||
WlrLayershell.exclusiveZone: -1
|
||||
WlrLayershell.keyboardFocus: {
|
||||
if (customKeyboardFocus !== null)
|
||||
return customKeyboardFocus;
|
||||
if (!shouldHaveFocus)
|
||||
return WlrKeyboardFocus.None;
|
||||
if (root.useHyprlandFocusGrab)
|
||||
return WlrKeyboardFocus.OnDemand;
|
||||
return WlrKeyboardFocus.Exclusive;
|
||||
}
|
||||
|
||||
anchors {
|
||||
left: true
|
||||
top: true
|
||||
right: root.useSingleWindow
|
||||
bottom: root.useSingleWindow
|
||||
}
|
||||
|
||||
readonly property real actualMarginLeft: root.useSingleWindow ? 0 : Math.max(0, Theme.snap(root.alignedX - shadowBuffer, dpr))
|
||||
readonly property real actualMarginTop: root.useSingleWindow ? 0 : Math.max(0, Theme.snap(root.alignedY - shadowBuffer, dpr))
|
||||
|
||||
WlrLayershell.margins {
|
||||
left: actualMarginLeft
|
||||
top: actualMarginTop
|
||||
right: 0
|
||||
bottom: 0
|
||||
}
|
||||
|
||||
implicitWidth: root.useSingleWindow ? 0 : root.alignedWidth + (shadowBuffer * 2)
|
||||
implicitHeight: root.useSingleWindow ? 0 : root.alignedHeight + (shadowBuffer * 2)
|
||||
|
||||
onVisibleChanged: {
|
||||
if (visible) {
|
||||
opened();
|
||||
} else {
|
||||
if (Qt.inputMethod) {
|
||||
Qt.inputMethod.hide();
|
||||
Qt.inputMethod.reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
enabled: root.useSingleWindow && root.closeOnBackgroundClick && root.shouldBeVisible
|
||||
z: -2
|
||||
onClicked: root.backgroundClicked()
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
z: -1
|
||||
color: "black"
|
||||
opacity: (root.useSingleWindow && root.useBackground) ? (root.shouldBeVisible ? root.backgroundOpacity : 0) : 0
|
||||
visible: opacity > 0
|
||||
|
||||
Behavior on opacity {
|
||||
enabled: root.animationsEnabled && (!Theme.isDirectionalEffect || Theme.isConnectedEffect)
|
||||
NumberAnimation {
|
||||
duration: Math.round(Theme.variantDuration(root.animationDuration, root.shouldBeVisible) * Theme.variantOpacityDurationScale)
|
||||
easing.type: Easing.BezierSpline
|
||||
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: modalContainer
|
||||
x: (root.useSingleWindow ? root.alignedX : (root.alignedX - contentWindow.actualMarginLeft)) + Theme.snap(animX, root.dpr)
|
||||
y: (root.useSingleWindow ? root.alignedY : (root.alignedY - contentWindow.actualMarginTop)) + Theme.snap(animY, root.dpr)
|
||||
|
||||
width: root.alignedWidth
|
||||
height: root.alignedHeight
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
enabled: root.useSingleWindow && root.shouldBeVisible
|
||||
hoverEnabled: false
|
||||
acceptedButtons: Qt.AllButtons
|
||||
onPressed: mouse.accepted = true
|
||||
onClicked: mouse.accepted = true
|
||||
z: -1
|
||||
}
|
||||
|
||||
readonly property bool slide: root.animationType === "slide"
|
||||
readonly property bool directionalEffect: Theme.isDirectionalEffect
|
||||
readonly property bool depthEffect: Theme.isDepthEffect
|
||||
readonly property real directionalTravel: Math.max(root.animationOffset, Math.max(root.alignedWidth, root.alignedHeight) * 0.8)
|
||||
readonly property real depthTravel: Math.max(root.animationOffset * 0.8, 36)
|
||||
readonly property real customAnchorX: root.alignedX + root.alignedWidth * 0.5
|
||||
readonly property real customAnchorY: root.alignedY + root.alignedHeight * 0.5
|
||||
readonly property real customDistLeft: customAnchorX
|
||||
readonly property real customDistRight: root.screenWidth - customAnchorX
|
||||
readonly property real customDistTop: customAnchorY
|
||||
readonly property real customDistBottom: root.screenHeight - customAnchorY
|
||||
// Connected emergence: travel from the resolved bar edge, matching DankPopout cadence.
|
||||
readonly property real connectedEmergenceTravelX: Math.max(root.animationOffset, root.alignedWidth + Theme.spacingL)
|
||||
readonly property real connectedEmergenceTravelY: Math.max(root.animationOffset, root.alignedHeight + Theme.spacingL)
|
||||
readonly property real offsetX: {
|
||||
if (root.frameOwnsConnectedChrome) {
|
||||
switch (root.resolvedConnectedBarSide) {
|
||||
case "left":
|
||||
return -connectedEmergenceTravelX;
|
||||
case "right":
|
||||
return connectedEmergenceTravelX;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (slide && !directionalEffect && !depthEffect)
|
||||
return 15;
|
||||
if (directionalEffect) {
|
||||
switch (root.positioning) {
|
||||
case "top-right":
|
||||
return 0;
|
||||
case "custom":
|
||||
if (customDistLeft <= customDistRight && customDistLeft <= customDistTop && customDistLeft <= customDistBottom)
|
||||
return -directionalTravel;
|
||||
if (customDistRight <= customDistTop && customDistRight <= customDistBottom)
|
||||
return directionalTravel;
|
||||
return 0;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
if (depthEffect) {
|
||||
switch (root.positioning) {
|
||||
case "top-right":
|
||||
return 0;
|
||||
case "custom":
|
||||
if (customDistLeft <= customDistRight && customDistLeft <= customDistTop && customDistLeft <= customDistBottom)
|
||||
return -depthTravel;
|
||||
if (customDistRight <= customDistTop && customDistRight <= customDistBottom)
|
||||
return depthTravel;
|
||||
return 0;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
readonly property real offsetY: {
|
||||
if (root.frameOwnsConnectedChrome) {
|
||||
switch (root.resolvedConnectedBarSide) {
|
||||
case "top":
|
||||
return -connectedEmergenceTravelY;
|
||||
case "bottom":
|
||||
return connectedEmergenceTravelY;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (slide && !directionalEffect && !depthEffect)
|
||||
return -30;
|
||||
if (directionalEffect) {
|
||||
switch (root.positioning) {
|
||||
case "top-right":
|
||||
return -Math.max(directionalTravel * 0.65, 96);
|
||||
case "custom":
|
||||
if (customDistTop <= customDistBottom && customDistTop <= customDistLeft && customDistTop <= customDistRight)
|
||||
return -directionalTravel;
|
||||
if (customDistBottom <= customDistLeft && customDistBottom <= customDistRight)
|
||||
return directionalTravel;
|
||||
return 0;
|
||||
default:
|
||||
// Default to sliding down from top when centered
|
||||
return -Math.max(directionalTravel, root.screenHeight * 0.24);
|
||||
}
|
||||
}
|
||||
if (depthEffect) {
|
||||
switch (root.positioning) {
|
||||
case "top-right":
|
||||
return -depthTravel * 0.75;
|
||||
case "custom":
|
||||
if (customDistTop <= customDistBottom && customDistTop <= customDistLeft && customDistTop <= customDistRight)
|
||||
return -depthTravel;
|
||||
if (customDistBottom <= customDistLeft && customDistBottom <= customDistRight)
|
||||
return depthTravel;
|
||||
return depthTravel * 0.45;
|
||||
default:
|
||||
return -depthTravel;
|
||||
}
|
||||
}
|
||||
return root.animationOffset;
|
||||
}
|
||||
|
||||
readonly property real computedScaleCollapsed: root.animationScaleCollapsed
|
||||
|
||||
// openProgress: 0 = closed (at frozenMotionOffset, scaleCollapsed), 1 = open (at 0, scale 1).
|
||||
QtObject {
|
||||
id: morph
|
||||
property real openProgress: root.shouldBeVisible ? 1 : 0
|
||||
Behavior on openProgress {
|
||||
enabled: root.animationsEnabled
|
||||
NumberAnimation {
|
||||
duration: Theme.variantDuration(root.animationDuration, root.shouldBeVisible)
|
||||
easing.type: Easing.BezierSpline
|
||||
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
readonly property real animX: root.frozenMotionOffsetX * (1 - morph.openProgress)
|
||||
readonly property real animY: root.frozenMotionOffsetY * (1 - morph.openProgress)
|
||||
readonly property real scaleValue: computedScaleCollapsed + (1.0 - computedScaleCollapsed) * morph.openProgress
|
||||
|
||||
onAnimXChanged: if (root.frameOwnsConnectedChrome)
|
||||
root._queueAnimSync()
|
||||
onAnimYChanged: if (root.frameOwnsConnectedChrome)
|
||||
root._queueAnimSync()
|
||||
|
||||
Item {
|
||||
id: contentContainer
|
||||
anchors.centerIn: parent
|
||||
width: parent.width
|
||||
height: parent.height
|
||||
clip: false
|
||||
|
||||
Item {
|
||||
id: animatedContent
|
||||
anchors.fill: parent
|
||||
clip: false
|
||||
|
||||
property real publishedOpacity: (Theme.isDirectionalEffect && !Theme.isConnectedEffect) ? 1 : (root.shouldBeVisible ? 1 : 0)
|
||||
|
||||
opacity: (Theme.isDirectionalEffect && !Theme.isConnectedEffect) ? 1 : (root.shouldBeVisible ? 1 : 0)
|
||||
scale: modalContainer.scaleValue
|
||||
transformOrigin: Item.Center
|
||||
|
||||
Behavior on opacity {
|
||||
enabled: root.animationsEnabled && (!Theme.isDirectionalEffect || Theme.isConnectedEffect)
|
||||
NumberAnimation {
|
||||
duration: Math.round(Theme.variantDuration(animationDuration, root.shouldBeVisible) * Theme.variantOpacityDurationScale)
|
||||
easing.type: Easing.BezierSpline
|
||||
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on publishedOpacity {
|
||||
enabled: root.animationsEnabled && (!Theme.isDirectionalEffect || Theme.isConnectedEffect)
|
||||
NumberAnimation {
|
||||
duration: Math.round(Theme.variantDuration(animationDuration, root.shouldBeVisible) * Theme.variantOpacityDurationScale)
|
||||
easing.type: Easing.BezierSpline
|
||||
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
|
||||
}
|
||||
}
|
||||
|
||||
ElevationShadow {
|
||||
id: modalShadowLayer
|
||||
anchors.fill: parent
|
||||
level: root.shadowLevel
|
||||
fallbackOffset: root.shadowFallbackOffset
|
||||
targetRadius: root.effectiveCornerRadius
|
||||
targetColor: root.frameOwnsConnectedChrome ? "transparent" : root.effectiveBackgroundColor
|
||||
borderColor: root.frameOwnsConnectedChrome ? "transparent" : root.effectiveBorderColor
|
||||
borderWidth: root.frameOwnsConnectedChrome ? 0 : root.effectiveBorderWidth
|
||||
shadowEnabled: !root.frameOwnsConnectedChrome && root.enableShadow && Theme.elevationEnabled && SettingsData.modalElevationEnabled && Quickshell.env("DMS_DISABLE_LAYER") !== "true" && Quickshell.env("DMS_DISABLE_LAYER") !== "1"
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: root.effectiveCornerRadius
|
||||
color: "transparent"
|
||||
border.color: (root.connectedSurfaceOverride || root.frameOwnsConnectedChrome) ? "transparent" : BlurService.borderColor
|
||||
border.width: (root.connectedSurfaceOverride || root.frameOwnsConnectedChrome) ? 0 : BlurService.borderWidth
|
||||
z: 100
|
||||
}
|
||||
|
||||
FocusScope {
|
||||
anchors.fill: parent
|
||||
focus: root.shouldBeVisible
|
||||
clip: false
|
||||
|
||||
Item {
|
||||
id: directContentWrapper
|
||||
anchors.fill: parent
|
||||
visible: root.directContent !== null
|
||||
focus: true
|
||||
clip: false
|
||||
|
||||
Component.onCompleted: {
|
||||
if (root.directContent) {
|
||||
root.directContent.parent = directContentWrapper;
|
||||
root.directContent.anchors.fill = directContentWrapper;
|
||||
Qt.callLater(() => root.directContent.forceActiveFocus());
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: root
|
||||
function onDirectContentChanged() {
|
||||
if (root.directContent) {
|
||||
root.directContent.parent = directContentWrapper;
|
||||
root.directContent.anchors.fill = directContentWrapper;
|
||||
Qt.callLater(() => root.directContent.forceActiveFocus());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: contentLoader
|
||||
anchors.fill: parent
|
||||
active: root.directContent === null && (root.keepContentLoaded || root.shouldBeVisible || contentWindow.visible)
|
||||
asynchronous: false
|
||||
focus: true
|
||||
clip: false
|
||||
visible: root.directContent === null
|
||||
|
||||
onLoaded: {
|
||||
if (item) {
|
||||
Qt.callLater(() => item.forceActiveFocus());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FocusScope {
|
||||
id: focusScope
|
||||
objectName: "modalFocusScope"
|
||||
anchors.fill: parent
|
||||
visible: root.shouldBeVisible || contentWindow.visible
|
||||
focus: root.shouldBeVisible
|
||||
Keys.onEscapePressed: event => {
|
||||
if (root.closeOnEscapeKey && shouldHaveFocus) {
|
||||
root.close();
|
||||
event.accepted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
484
quickshell/Modals/Common/DankModalStandalone.qml
Normal file
484
quickshell/Modals/Common/DankModalStandalone.qml
Normal file
@@ -0,0 +1,484 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
readonly property var log: Log.scoped("DankModalStandalone")
|
||||
|
||||
property var modalHandle: root
|
||||
property string layerNamespace: "dms:modal"
|
||||
property alias content: contentLoader.sourceComponent
|
||||
property alias contentLoader: contentLoader
|
||||
property Item directContent: null
|
||||
property real modalWidth: 400
|
||||
property real modalHeight: 300
|
||||
property var targetScreen
|
||||
readonly property var effectiveScreen: contentWindow.screen ?? targetScreen
|
||||
readonly property real screenWidth: effectiveScreen?.width ?? 1920
|
||||
readonly property real screenHeight: effectiveScreen?.height ?? 1080
|
||||
readonly property real dpr: effectiveScreen ? CompositorService.getScreenScale(effectiveScreen) : 1
|
||||
property bool showBackground: true
|
||||
property real backgroundOpacity: 0.5
|
||||
property string positioning: "center"
|
||||
property point customPosition: Qt.point(0, 0)
|
||||
property bool closeOnEscapeKey: true
|
||||
property bool closeOnBackgroundClick: true
|
||||
property string animationType: "scale"
|
||||
property int animationDuration: Theme.modalAnimationDuration
|
||||
property real animationScaleCollapsed: 0.96
|
||||
property real animationOffset: Theme.spacingL
|
||||
property list<real> animationEnterCurve: Theme.expressiveCurves.expressiveDefaultSpatial
|
||||
property list<real> animationExitCurve: Theme.expressiveCurves.emphasized
|
||||
property color backgroundColor: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
|
||||
property color borderColor: Theme.outlineMedium
|
||||
property real borderWidth: 0
|
||||
property real cornerRadius: Theme.cornerRadius
|
||||
property bool enableShadow: true
|
||||
property alias modalFocusScope: focusScope
|
||||
property bool shouldBeVisible: false
|
||||
property bool isClosing: false
|
||||
property bool shouldHaveFocus: shouldBeVisible
|
||||
property bool allowFocusOverride: false
|
||||
property bool allowStacking: false
|
||||
property bool keepContentLoaded: false
|
||||
property bool keepPopoutsOpen: false
|
||||
property var customKeyboardFocus: null
|
||||
property bool useOverlayLayer: false
|
||||
readonly property alias contentWindow: contentWindow
|
||||
readonly property alias clickCatcher: clickCatcher
|
||||
readonly property bool useHyprlandFocusGrab: CompositorService.useHyprlandFocusGrab
|
||||
readonly property bool useBackground: showBackground && SettingsData.modalDarkenBackground
|
||||
readonly property bool useSingleWindow: CompositorService.isHyprland || useBackground
|
||||
|
||||
signal opened
|
||||
signal dialogClosed
|
||||
signal backgroundClicked
|
||||
|
||||
property bool animationsEnabled: true
|
||||
|
||||
function open() {
|
||||
closeTimer.stop();
|
||||
isClosing = false;
|
||||
const focusedScreen = CompositorService.getFocusedScreen();
|
||||
const screenChanged = focusedScreen && contentWindow.screen !== focusedScreen;
|
||||
if (focusedScreen) {
|
||||
if (screenChanged)
|
||||
contentWindow.visible = false;
|
||||
contentWindow.screen = focusedScreen;
|
||||
if (!useSingleWindow) {
|
||||
if (screenChanged)
|
||||
clickCatcher.visible = false;
|
||||
clickCatcher.screen = focusedScreen;
|
||||
}
|
||||
}
|
||||
if (screenChanged) {
|
||||
Qt.callLater(() => root._finishOpen());
|
||||
} else {
|
||||
_finishOpen();
|
||||
}
|
||||
}
|
||||
|
||||
function _finishOpen() {
|
||||
ModalManager.openModal(modalHandle);
|
||||
shouldBeVisible = true;
|
||||
if (!useSingleWindow)
|
||||
clickCatcher.visible = true;
|
||||
contentWindow.visible = true;
|
||||
shouldHaveFocus = false;
|
||||
Qt.callLater(() => shouldHaveFocus = Qt.binding(() => shouldBeVisible));
|
||||
}
|
||||
|
||||
function close() {
|
||||
isClosing = true;
|
||||
shouldBeVisible = false;
|
||||
shouldHaveFocus = false;
|
||||
ModalManager.closeModal(modalHandle);
|
||||
closeTimer.restart();
|
||||
}
|
||||
|
||||
function instantClose() {
|
||||
animationsEnabled = false;
|
||||
isClosing = false;
|
||||
shouldBeVisible = false;
|
||||
shouldHaveFocus = false;
|
||||
ModalManager.closeModal(modalHandle);
|
||||
closeTimer.stop();
|
||||
contentWindow.visible = false;
|
||||
if (!useSingleWindow)
|
||||
clickCatcher.visible = false;
|
||||
dialogClosed();
|
||||
Qt.callLater(() => animationsEnabled = true);
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
shouldBeVisible ? close() : open();
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: ModalManager
|
||||
function onCloseAllModalsExcept(excludedModal) {
|
||||
if (excludedModal !== modalHandle && !allowStacking && shouldBeVisible)
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: Quickshell
|
||||
function onScreensChanged() {
|
||||
if (!contentWindow.screen)
|
||||
return;
|
||||
const currentScreenName = contentWindow.screen.name;
|
||||
let screenStillExists = false;
|
||||
for (let i = 0; i < Quickshell.screens.length; i++) {
|
||||
if (Quickshell.screens[i].name === currentScreenName) {
|
||||
screenStillExists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (screenStillExists)
|
||||
return;
|
||||
const newScreen = CompositorService.getFocusedScreen();
|
||||
if (newScreen) {
|
||||
contentWindow.screen = newScreen;
|
||||
if (!useSingleWindow)
|
||||
clickCatcher.screen = newScreen;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: closeTimer
|
||||
interval: animationDuration + 50
|
||||
onTriggered: {
|
||||
if (shouldBeVisible)
|
||||
return;
|
||||
isClosing = false;
|
||||
contentWindow.visible = false;
|
||||
if (!useSingleWindow)
|
||||
clickCatcher.visible = false;
|
||||
dialogClosed();
|
||||
}
|
||||
}
|
||||
|
||||
readonly property var shadowLevel: Theme.elevationLevel3
|
||||
readonly property real shadowFallbackOffset: 6
|
||||
readonly property real shadowRenderPadding: (root.enableShadow && Theme.elevationEnabled && SettingsData.modalElevationEnabled) ? Theme.elevationRenderPadding(shadowLevel, Theme.elevationLightDirection, shadowFallbackOffset, 8, 16) : 0
|
||||
readonly property real shadowMotionPadding: animationType === "slide" ? 30 : Math.max(0, animationOffset)
|
||||
readonly property real shadowBuffer: Theme.snap(shadowRenderPadding + shadowMotionPadding, dpr)
|
||||
readonly property real alignedWidth: Theme.px(modalWidth, dpr)
|
||||
readonly property real alignedHeight: Theme.px(modalHeight, dpr)
|
||||
|
||||
readonly property real alignedX: Theme.snap((() => {
|
||||
switch (positioning) {
|
||||
case "center":
|
||||
return (screenWidth - alignedWidth) / 2;
|
||||
case "top-right":
|
||||
return Math.max(Theme.spacingL, screenWidth - alignedWidth - Theme.spacingL);
|
||||
case "custom":
|
||||
return customPosition.x;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
})(), dpr)
|
||||
|
||||
readonly property real alignedY: Theme.snap((() => {
|
||||
switch (positioning) {
|
||||
case "center":
|
||||
return (screenHeight - alignedHeight) / 2;
|
||||
case "top-right":
|
||||
return Theme.barHeight + Theme.spacingXS;
|
||||
case "custom":
|
||||
return customPosition.y;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
})(), dpr)
|
||||
|
||||
PanelWindow {
|
||||
id: clickCatcher
|
||||
visible: false
|
||||
color: "transparent"
|
||||
|
||||
WlrLayershell.namespace: root.layerNamespace + ":clickcatcher"
|
||||
WlrLayershell.layer: WlrLayershell.Top
|
||||
WlrLayershell.exclusiveZone: -1
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
||||
|
||||
anchors {
|
||||
top: true
|
||||
left: true
|
||||
right: true
|
||||
bottom: true
|
||||
}
|
||||
|
||||
mask: Region {
|
||||
item: Rectangle {
|
||||
x: root.alignedX
|
||||
y: root.alignedY
|
||||
width: root.alignedWidth
|
||||
height: root.alignedHeight
|
||||
}
|
||||
intersection: Intersection.Xor
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
enabled: root.closeOnBackgroundClick && root.shouldBeVisible
|
||||
onClicked: root.backgroundClicked()
|
||||
}
|
||||
}
|
||||
|
||||
PanelWindow {
|
||||
id: contentWindow
|
||||
visible: false
|
||||
color: "transparent"
|
||||
|
||||
WindowBlur {
|
||||
targetWindow: contentWindow
|
||||
readonly property real s: Math.min(1, modalContainer.scaleValue)
|
||||
blurX: modalContainer.x + modalContainer.width * (1 - s) * 0.5 + Theme.snap(modalContainer.animX, root.dpr)
|
||||
blurY: modalContainer.y + modalContainer.height * (1 - s) * 0.5 + Theme.snap(modalContainer.animY, root.dpr)
|
||||
blurWidth: shouldBeVisible ? modalContainer.width * s : 0
|
||||
blurHeight: shouldBeVisible ? modalContainer.height * s : 0
|
||||
blurRadius: root.cornerRadius
|
||||
}
|
||||
|
||||
WlrLayershell.namespace: root.layerNamespace
|
||||
WlrLayershell.layer: {
|
||||
if (root.useOverlayLayer)
|
||||
return WlrLayershell.Overlay;
|
||||
switch (Quickshell.env("DMS_MODAL_LAYER")) {
|
||||
case "bottom":
|
||||
log.error("'bottom' layer is not valid for modals. Defaulting to 'top' layer.");
|
||||
return WlrLayershell.Top;
|
||||
case "background":
|
||||
log.error("'background' layer is not valid for modals. Defaulting to 'top' layer.");
|
||||
return WlrLayershell.Top;
|
||||
case "overlay":
|
||||
return WlrLayershell.Overlay;
|
||||
default:
|
||||
return WlrLayershell.Top;
|
||||
}
|
||||
}
|
||||
WlrLayershell.exclusiveZone: -1
|
||||
WlrLayershell.keyboardFocus: {
|
||||
if (customKeyboardFocus !== null)
|
||||
return customKeyboardFocus;
|
||||
if (!shouldHaveFocus)
|
||||
return WlrKeyboardFocus.None;
|
||||
if (root.useHyprlandFocusGrab)
|
||||
return WlrKeyboardFocus.OnDemand;
|
||||
return WlrKeyboardFocus.Exclusive;
|
||||
}
|
||||
|
||||
anchors {
|
||||
left: true
|
||||
top: true
|
||||
right: root.useSingleWindow
|
||||
bottom: root.useSingleWindow
|
||||
}
|
||||
|
||||
WlrLayershell.margins {
|
||||
left: root.useSingleWindow ? 0 : Math.max(0, Theme.snap(root.alignedX - shadowBuffer, dpr))
|
||||
top: root.useSingleWindow ? 0 : Math.max(0, Theme.snap(root.alignedY - shadowBuffer, dpr))
|
||||
right: 0
|
||||
bottom: 0
|
||||
}
|
||||
|
||||
implicitWidth: root.useSingleWindow ? 0 : root.alignedWidth + (shadowBuffer * 2)
|
||||
implicitHeight: root.useSingleWindow ? 0 : root.alignedHeight + (shadowBuffer * 2)
|
||||
|
||||
onVisibleChanged: {
|
||||
if (visible) {
|
||||
opened();
|
||||
} else {
|
||||
if (Qt.inputMethod) {
|
||||
Qt.inputMethod.hide();
|
||||
Qt.inputMethod.reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
enabled: root.useSingleWindow && root.closeOnBackgroundClick && root.shouldBeVisible
|
||||
z: -2
|
||||
onClicked: root.backgroundClicked()
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
z: -1
|
||||
color: "black"
|
||||
opacity: root.useBackground ? (root.shouldBeVisible ? root.backgroundOpacity : 0) : 0
|
||||
visible: root.useBackground
|
||||
|
||||
Behavior on opacity {
|
||||
enabled: root.animationsEnabled
|
||||
NumberAnimation {
|
||||
easing.type: Easing.BezierSpline
|
||||
duration: root.animationDuration
|
||||
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: modalContainer
|
||||
x: root.useSingleWindow ? root.alignedX : shadowBuffer
|
||||
y: root.useSingleWindow ? root.alignedY : shadowBuffer
|
||||
|
||||
width: root.alignedWidth
|
||||
height: root.alignedHeight
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
enabled: root.useSingleWindow && root.shouldBeVisible
|
||||
hoverEnabled: false
|
||||
acceptedButtons: Qt.AllButtons
|
||||
onPressed: mouse.accepted = true
|
||||
onClicked: mouse.accepted = true
|
||||
z: -1
|
||||
}
|
||||
|
||||
readonly property bool slide: root.animationType === "slide"
|
||||
readonly property real offsetX: slide ? 15 : 0
|
||||
readonly property real offsetY: slide ? -30 : root.animationOffset
|
||||
|
||||
// openProgress: 0 = closed (at offset, scaleCollapsed), 1 = open (at 0, scale 1).
|
||||
QtObject {
|
||||
id: morph
|
||||
property real openProgress: root.shouldBeVisible ? 1 : 0
|
||||
Behavior on openProgress {
|
||||
enabled: root.animationsEnabled
|
||||
DankAnim {
|
||||
duration: root.animationDuration
|
||||
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
readonly property real animX: modalContainer.offsetX * (1 - morph.openProgress)
|
||||
readonly property real animY: modalContainer.offsetY * (1 - morph.openProgress)
|
||||
readonly property real scaleValue: root.animationScaleCollapsed + (1.0 - root.animationScaleCollapsed) * morph.openProgress
|
||||
|
||||
Item {
|
||||
id: contentContainer
|
||||
anchors.centerIn: parent
|
||||
width: parent.width
|
||||
height: parent.height
|
||||
clip: false
|
||||
|
||||
Item {
|
||||
id: animatedContent
|
||||
anchors.fill: parent
|
||||
clip: false
|
||||
|
||||
opacity: root.shouldBeVisible ? 1 : 0
|
||||
scale: modalContainer.scaleValue
|
||||
x: Theme.snap(modalContainer.animX, root.dpr) + (parent.width - width) * (1 - modalContainer.scaleValue) * 0.5
|
||||
y: Theme.snap(modalContainer.animY, root.dpr) + (parent.height - height) * (1 - modalContainer.scaleValue) * 0.5
|
||||
|
||||
Behavior on opacity {
|
||||
enabled: root.animationsEnabled
|
||||
NumberAnimation {
|
||||
duration: animationDuration
|
||||
easing.type: Easing.BezierSpline
|
||||
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
|
||||
}
|
||||
}
|
||||
|
||||
ElevationShadow {
|
||||
id: modalShadowLayer
|
||||
anchors.fill: parent
|
||||
level: root.shadowLevel
|
||||
fallbackOffset: root.shadowFallbackOffset
|
||||
targetRadius: root.cornerRadius
|
||||
targetColor: root.backgroundColor
|
||||
borderColor: root.borderColor
|
||||
borderWidth: root.borderWidth
|
||||
shadowEnabled: root.enableShadow && Theme.elevationEnabled && SettingsData.modalElevationEnabled && Quickshell.env("DMS_DISABLE_LAYER") !== "true" && Quickshell.env("DMS_DISABLE_LAYER") !== "1"
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: root.cornerRadius
|
||||
color: "transparent"
|
||||
border.color: BlurService.borderColor
|
||||
border.width: BlurService.borderWidth
|
||||
z: 100
|
||||
}
|
||||
|
||||
FocusScope {
|
||||
anchors.fill: parent
|
||||
focus: root.shouldBeVisible
|
||||
clip: false
|
||||
|
||||
Item {
|
||||
id: directContentWrapper
|
||||
anchors.fill: parent
|
||||
visible: root.directContent !== null
|
||||
focus: true
|
||||
clip: false
|
||||
|
||||
Component.onCompleted: {
|
||||
if (root.directContent) {
|
||||
root.directContent.parent = directContentWrapper;
|
||||
root.directContent.anchors.fill = directContentWrapper;
|
||||
Qt.callLater(() => root.directContent.forceActiveFocus());
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: root
|
||||
function onDirectContentChanged() {
|
||||
if (root.directContent) {
|
||||
root.directContent.parent = directContentWrapper;
|
||||
root.directContent.anchors.fill = directContentWrapper;
|
||||
Qt.callLater(() => root.directContent.forceActiveFocus());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: contentLoader
|
||||
anchors.fill: parent
|
||||
active: root.directContent === null && (root.keepContentLoaded || root.shouldBeVisible || contentWindow.visible)
|
||||
asynchronous: false
|
||||
focus: true
|
||||
clip: false
|
||||
visible: root.directContent === null
|
||||
|
||||
onLoaded: {
|
||||
if (item) {
|
||||
Qt.callLater(() => item.forceActiveFocus());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FocusScope {
|
||||
id: focusScope
|
||||
objectName: "modalFocusScope"
|
||||
anchors.fill: parent
|
||||
visible: root.shouldBeVisible || contentWindow.visible
|
||||
focus: root.shouldBeVisible
|
||||
Keys.onEscapePressed: event => {
|
||||
if (root.closeOnEscapeKey && shouldHaveFocus) {
|
||||
root.close();
|
||||
event.accepted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import qs.Widgets
|
||||
|
||||
DankModal {
|
||||
id: root
|
||||
readonly property var log: Log.scoped("DankColorPickerModal")
|
||||
|
||||
layerNamespace: "dms:color-picker"
|
||||
|
||||
@@ -50,17 +51,17 @@ DankModal {
|
||||
|
||||
function toggle() {
|
||||
if (shouldBeVisible) {
|
||||
hide();
|
||||
hide();
|
||||
} else {
|
||||
show();
|
||||
show();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleInstant() {
|
||||
if (shouldBeVisible) {
|
||||
hideInstant();
|
||||
hideInstant();
|
||||
} else {
|
||||
show();
|
||||
show();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +112,7 @@ DankModal {
|
||||
hideInstant();
|
||||
Proc.runCommand("dms-color-pick", ["dms", "color", "pick", "--json"], (output, exitCode) => {
|
||||
if (exitCode !== 0) {
|
||||
console.warn("dms color pick exited with code:", exitCode);
|
||||
log.warn("dms color pick exited with code:", exitCode);
|
||||
root.show();
|
||||
return;
|
||||
}
|
||||
@@ -120,11 +121,11 @@ DankModal {
|
||||
if (result.hex) {
|
||||
applyPickedColor(result.hex);
|
||||
} else {
|
||||
console.warn("Failed to parse dms color pick output: missing hex");
|
||||
log.warn("Failed to parse dms color pick output: missing hex");
|
||||
root.show();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to parse dms color pick JSON:", e);
|
||||
log.warn("Failed to parse dms color pick JSON:", e);
|
||||
root.show();
|
||||
}
|
||||
}, 0, Proc.noTimeout);
|
||||
@@ -142,39 +143,39 @@ DankModal {
|
||||
onBackgroundClicked: hide()
|
||||
|
||||
IpcHandler {
|
||||
function open(): string {
|
||||
root.show();
|
||||
return "COLOR_PICKER_MODAL_OPEN_SUCCESS";
|
||||
}
|
||||
function open(): string {
|
||||
root.show();
|
||||
return "COLOR_PICKER_MODAL_OPEN_SUCCESS";
|
||||
}
|
||||
|
||||
function openColor(color: string): string {
|
||||
root.selectedColor = Qt.color(color);
|
||||
root.currentColor = Qt.color(color);
|
||||
root.updateFromColor(Qt.color(color));
|
||||
return open();
|
||||
}
|
||||
function openColor(color: string): string {
|
||||
root.selectedColor = Qt.color(color);
|
||||
root.currentColor = Qt.color(color);
|
||||
root.updateFromColor(Qt.color(color));
|
||||
return open();
|
||||
}
|
||||
|
||||
function close(): string {
|
||||
root.hide();
|
||||
return "COLOR_PICKER_MODAL_CLOSE_SUCCESS";
|
||||
}
|
||||
function close(): string {
|
||||
root.hide();
|
||||
return "COLOR_PICKER_MODAL_CLOSE_SUCCESS";
|
||||
}
|
||||
|
||||
function closeInstant(): string {
|
||||
root.hideInstant();
|
||||
return "COLOR_PICKER_MODAL_CLOSE_INSTANT_SUCCESS";
|
||||
}
|
||||
function closeInstant(): string {
|
||||
root.hideInstant();
|
||||
return "COLOR_PICKER_MODAL_CLOSE_INSTANT_SUCCESS";
|
||||
}
|
||||
|
||||
function toggle(): string {
|
||||
root.toggle();
|
||||
return "COLOR_PICKER_MODAL_TOGGLE_SUCCESS";
|
||||
}
|
||||
function toggle(): string {
|
||||
root.toggle();
|
||||
return "COLOR_PICKER_MODAL_TOGGLE_SUCCESS";
|
||||
}
|
||||
|
||||
function toggleInstant(): string {
|
||||
root.toggleInstant();
|
||||
return "COLOR_PICKER_MODAL_TOGGLE_INSTANT_SUCCESS";
|
||||
}
|
||||
function toggleInstant(): string {
|
||||
root.toggleInstant();
|
||||
return "COLOR_PICKER_MODAL_TOGGLE_INSTANT_SUCCESS";
|
||||
}
|
||||
|
||||
target: "color-picker"
|
||||
target: "color-picker"
|
||||
}
|
||||
|
||||
content: Component {
|
||||
|
||||
@@ -1881,7 +1881,7 @@ Item {
|
||||
function openTerminal(path) {
|
||||
if (!path)
|
||||
return;
|
||||
var terminal = Quickshell.env("TERMINAL") || "xterm";
|
||||
var terminal = SessionData.resolveTerminal() || "xterm";
|
||||
Quickshell.execDetached({
|
||||
command: [terminal],
|
||||
workingDirectory: path
|
||||
|
||||
@@ -1,465 +1,118 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import Quickshell.Hyprland
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
readonly property var log: Log.scoped("DankLauncherV2Modal")
|
||||
|
||||
visible: false
|
||||
|
||||
property bool spotlightOpen: false
|
||||
property bool keyboardActive: false
|
||||
property bool contentVisible: false
|
||||
property var spotlightContent: launcherContentLoader.item
|
||||
property bool openedFromOverview: false
|
||||
property bool isClosing: false
|
||||
property bool _pendingInitialize: false
|
||||
property string _pendingQuery: ""
|
||||
property string _pendingMode: ""
|
||||
readonly property bool unloadContentOnClose: SettingsData.dankLauncherV2UnloadOnClose
|
||||
|
||||
readonly property bool useHyprlandFocusGrab: CompositorService.useHyprlandFocusGrab
|
||||
readonly property var effectiveScreen: launcherWindow.screen
|
||||
readonly property real screenWidth: effectiveScreen?.width ?? 1920
|
||||
readonly property real screenHeight: effectiveScreen?.height ?? 1080
|
||||
readonly property real dpr: effectiveScreen ? CompositorService.getScreenScale(effectiveScreen) : 1
|
||||
|
||||
readonly property int baseWidth: {
|
||||
switch (SettingsData.dankLauncherV2Size) {
|
||||
case "micro":
|
||||
return 500;
|
||||
case "medium":
|
||||
return 720;
|
||||
case "large":
|
||||
return 860;
|
||||
default:
|
||||
return 620;
|
||||
}
|
||||
}
|
||||
readonly property int baseHeight: {
|
||||
switch (SettingsData.dankLauncherV2Size) {
|
||||
case "micro":
|
||||
return 480;
|
||||
case "medium":
|
||||
return 720;
|
||||
case "large":
|
||||
return 860;
|
||||
default:
|
||||
return 600;
|
||||
}
|
||||
}
|
||||
readonly property int modalWidth: Math.min(baseWidth, screenWidth - 100)
|
||||
readonly property int modalHeight: Math.min(baseHeight, screenHeight - 100)
|
||||
readonly property real modalX: (screenWidth - modalWidth) / 2
|
||||
readonly property real modalY: (screenHeight - modalHeight) / 2
|
||||
|
||||
readonly property color backgroundColor: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
|
||||
readonly property real cornerRadius: Theme.cornerRadius
|
||||
readonly property color borderColor: {
|
||||
if (!SettingsData.dankLauncherV2BorderEnabled)
|
||||
return Theme.outlineMedium;
|
||||
switch (SettingsData.dankLauncherV2BorderColor) {
|
||||
case "primary":
|
||||
return Theme.primary;
|
||||
case "secondary":
|
||||
return Theme.secondary;
|
||||
case "outline":
|
||||
return Theme.outline;
|
||||
case "surfaceText":
|
||||
return Theme.surfaceText;
|
||||
default:
|
||||
return Theme.primary;
|
||||
}
|
||||
}
|
||||
readonly property int borderWidth: SettingsData.dankLauncherV2BorderEnabled ? SettingsData.dankLauncherV2BorderThickness : 0
|
||||
readonly property bool spotlightOpen: impl.item ? impl.item.spotlightOpen : false
|
||||
readonly property bool isClosing: impl.item ? impl.item.isClosing : false
|
||||
readonly property bool keyboardActive: impl.item ? impl.item.keyboardActive : false
|
||||
readonly property bool contentVisible: impl.item ? impl.item.contentVisible : false
|
||||
readonly property var spotlightContent: impl.item ? impl.item.spotlightContent : null
|
||||
readonly property bool openedFromOverview: impl.item ? impl.item.openedFromOverview : false
|
||||
readonly property var effectiveScreen: impl.item ? impl.item.effectiveScreen : null
|
||||
readonly property real screenWidth: impl.item ? impl.item.screenWidth : 1920
|
||||
readonly property real screenHeight: impl.item ? impl.item.screenHeight : 1080
|
||||
readonly property real dpr: impl.item ? impl.item.dpr : 1
|
||||
readonly property int modalWidth: impl.item ? impl.item.modalWidth : 620
|
||||
readonly property int modalHeight: impl.item ? impl.item.modalHeight : 600
|
||||
readonly property real modalX: impl.item ? impl.item.modalX : 0
|
||||
readonly property real modalY: impl.item ? impl.item.modalY : 0
|
||||
readonly property bool frameOwnsConnectedChrome: impl.item ? (impl.item.frameOwnsConnectedChrome ?? false) : false
|
||||
readonly property string resolvedConnectedBarSide: impl.item ? (impl.item.resolvedConnectedBarSide ?? "") : ""
|
||||
readonly property bool launcherArcExtenderActive: impl.item ? (impl.item.launcherArcExtenderActive ?? false) : false
|
||||
|
||||
signal dialogClosed
|
||||
|
||||
function _ensureContentLoadedAndInitialize(query, mode) {
|
||||
_pendingQuery = query || "";
|
||||
_pendingMode = mode || "";
|
||||
_pendingInitialize = true;
|
||||
contentVisible = true;
|
||||
launcherContentLoader.active = true;
|
||||
|
||||
if (spotlightContent) {
|
||||
_initializeAndShow(_pendingQuery, _pendingMode);
|
||||
_pendingInitialize = false;
|
||||
}
|
||||
}
|
||||
|
||||
function _initializeAndShow(query, mode) {
|
||||
if (!spotlightContent)
|
||||
return;
|
||||
contentVisible = true;
|
||||
spotlightContent.searchField.forceActiveFocus();
|
||||
|
||||
var targetQuery = "";
|
||||
|
||||
if (query) {
|
||||
targetQuery = query;
|
||||
} else if (SettingsData.rememberLastQuery) {
|
||||
targetQuery = SessionData.launcherLastQuery || "";
|
||||
}
|
||||
|
||||
if (spotlightContent.searchField) {
|
||||
spotlightContent.searchField.text = targetQuery;
|
||||
}
|
||||
if (spotlightContent.controller) {
|
||||
var targetMode = mode || SessionData.launcherLastMode || "all";
|
||||
spotlightContent.controller.searchMode = targetMode;
|
||||
spotlightContent.controller.activePluginId = "";
|
||||
spotlightContent.controller.activePluginName = "";
|
||||
spotlightContent.controller.pluginFilter = "";
|
||||
spotlightContent.controller.fileSearchType = "all";
|
||||
spotlightContent.controller.fileSearchExt = "";
|
||||
spotlightContent.controller.fileSearchFolder = "";
|
||||
spotlightContent.controller.fileSearchSort = "score";
|
||||
spotlightContent.controller.collapsedSections = {};
|
||||
spotlightContent.controller.selectedFlatIndex = 0;
|
||||
spotlightContent.controller.selectedItem = null;
|
||||
spotlightContent.controller.historyIndex = -1;
|
||||
spotlightContent.controller.searchQuery = targetQuery;
|
||||
|
||||
spotlightContent.controller.performSearch();
|
||||
}
|
||||
if (spotlightContent.resetScroll) {
|
||||
spotlightContent.resetScroll();
|
||||
}
|
||||
if (spotlightContent.actionPanel) {
|
||||
spotlightContent.actionPanel.hide();
|
||||
}
|
||||
}
|
||||
|
||||
function _finishShow(query, mode) {
|
||||
spotlightOpen = true;
|
||||
isClosing = false;
|
||||
openedFromOverview = false;
|
||||
|
||||
keyboardActive = true;
|
||||
ModalManager.openModal(root);
|
||||
if (useHyprlandFocusGrab)
|
||||
focusGrab.active = true;
|
||||
|
||||
_ensureContentLoadedAndInitialize(query || "", mode || "");
|
||||
}
|
||||
|
||||
function show() {
|
||||
closeCleanupTimer.stop();
|
||||
|
||||
var focusedScreen = CompositorService.getFocusedScreen();
|
||||
if (focusedScreen && launcherWindow.screen !== focusedScreen) {
|
||||
spotlightOpen = false;
|
||||
isClosing = false;
|
||||
launcherWindow.screen = focusedScreen;
|
||||
Qt.callLater(() => root._finishShow("", ""));
|
||||
return;
|
||||
}
|
||||
|
||||
_finishShow("", "");
|
||||
if (impl.item)
|
||||
impl.item.show();
|
||||
}
|
||||
|
||||
function showWithQuery(query) {
|
||||
closeCleanupTimer.stop();
|
||||
|
||||
var focusedScreen = CompositorService.getFocusedScreen();
|
||||
if (focusedScreen && launcherWindow.screen !== focusedScreen) {
|
||||
spotlightOpen = false;
|
||||
isClosing = false;
|
||||
launcherWindow.screen = focusedScreen;
|
||||
Qt.callLater(() => root._finishShow(query, ""));
|
||||
return;
|
||||
}
|
||||
|
||||
_finishShow(query, "");
|
||||
}
|
||||
|
||||
function hide() {
|
||||
if (!spotlightOpen)
|
||||
return;
|
||||
openedFromOverview = false;
|
||||
isClosing = true;
|
||||
contentVisible = false;
|
||||
|
||||
keyboardActive = false;
|
||||
spotlightOpen = false;
|
||||
focusGrab.active = false;
|
||||
ModalManager.closeModal(root);
|
||||
|
||||
closeCleanupTimer.start();
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
spotlightOpen ? hide() : show();
|
||||
if (impl.item)
|
||||
impl.item.showWithQuery(query);
|
||||
}
|
||||
|
||||
function showWithMode(mode) {
|
||||
closeCleanupTimer.stop();
|
||||
|
||||
var focusedScreen = CompositorService.getFocusedScreen();
|
||||
if (focusedScreen && launcherWindow.screen !== focusedScreen) {
|
||||
spotlightOpen = false;
|
||||
isClosing = false;
|
||||
launcherWindow.screen = focusedScreen;
|
||||
Qt.callLater(() => root._finishShow("", mode));
|
||||
return;
|
||||
}
|
||||
|
||||
spotlightOpen = true;
|
||||
isClosing = false;
|
||||
openedFromOverview = false;
|
||||
|
||||
keyboardActive = true;
|
||||
ModalManager.openModal(root);
|
||||
if (useHyprlandFocusGrab)
|
||||
focusGrab.active = true;
|
||||
|
||||
_ensureContentLoadedAndInitialize("", mode);
|
||||
if (impl.item)
|
||||
impl.item.showWithMode(mode);
|
||||
}
|
||||
|
||||
function toggleWithMode(mode) {
|
||||
if (spotlightOpen) {
|
||||
hide();
|
||||
} else {
|
||||
showWithMode(mode);
|
||||
}
|
||||
function hide() {
|
||||
if (impl.item)
|
||||
impl.item.hide();
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (impl.item)
|
||||
impl.item.toggle();
|
||||
}
|
||||
|
||||
function toggleWithQuery(query) {
|
||||
if (spotlightOpen) {
|
||||
hide();
|
||||
} else {
|
||||
showWithQuery(query);
|
||||
if (impl.item)
|
||||
impl.item.toggleWithQuery(query);
|
||||
}
|
||||
|
||||
function toggleWithMode(mode) {
|
||||
if (impl.item)
|
||||
impl.item.toggleWithMode(mode);
|
||||
}
|
||||
|
||||
readonly property var _desiredBackend: SettingsData.connectedFrameModeActive ? connectedComp : standaloneComp
|
||||
property var _resolvedBackend: null
|
||||
|
||||
Component.onCompleted: _resolvedBackend = _desiredBackend
|
||||
|
||||
Connections {
|
||||
target: SettingsData
|
||||
function onConnectedFrameModeActiveChanged() {
|
||||
root._maybeResolveBackend();
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: closeCleanupTimer
|
||||
interval: Theme.modalAnimationDuration + 50
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
isClosing = false;
|
||||
if (root.unloadContentOnClose)
|
||||
launcherContentLoader.active = false;
|
||||
dialogClosed();
|
||||
}
|
||||
// Defer Loader source-component swap until impl is fully closed; avoids
|
||||
// tearing down the launcher mid-animation when frame mode is toggled.
|
||||
function _maybeResolveBackend() {
|
||||
if (_resolvedBackend === _desiredBackend)
|
||||
return;
|
||||
if (impl.item && (impl.item.spotlightOpen || impl.item.isClosing))
|
||||
return;
|
||||
_resolvedBackend = _desiredBackend;
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: impl
|
||||
sourceComponent: root._resolvedBackend
|
||||
onItemChanged: if (item)
|
||||
root._wireBackend(item)
|
||||
}
|
||||
|
||||
Component {
|
||||
id: standaloneComp
|
||||
DankLauncherV2ModalStandalone {}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: connectedComp
|
||||
DankLauncherV2ModalConnected {}
|
||||
}
|
||||
|
||||
function _wireBackend(it) {
|
||||
if (!it)
|
||||
return;
|
||||
it.modalHandle = root;
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: spotlightContent?.controller ?? null
|
||||
target: impl.item
|
||||
ignoreUnknownSignals: true
|
||||
|
||||
function onModeChanged(mode) {
|
||||
if (spotlightContent.controller.autoSwitchedToFiles)
|
||||
return;
|
||||
SessionData.setLauncherLastMode(mode);
|
||||
}
|
||||
}
|
||||
|
||||
HyprlandFocusGrab {
|
||||
id: focusGrab
|
||||
windows: [launcherWindow]
|
||||
active: false
|
||||
|
||||
onCleared: {
|
||||
if (spotlightOpen) {
|
||||
hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: ModalManager
|
||||
function onCloseAllModalsExcept(excludedModal) {
|
||||
if (excludedModal !== root && spotlightOpen) {
|
||||
hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: Quickshell
|
||||
function onScreensChanged() {
|
||||
if (Quickshell.screens.length === 0)
|
||||
return;
|
||||
|
||||
const screenName = launcherWindow.screen?.name;
|
||||
if (screenName) {
|
||||
for (let i = 0; i < Quickshell.screens.length; i++) {
|
||||
if (Quickshell.screens[i].name === screenName)
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (spotlightOpen)
|
||||
hide();
|
||||
|
||||
const newScreen = CompositorService.getFocusedScreen() ?? Quickshell.screens[0];
|
||||
if (newScreen)
|
||||
launcherWindow.screen = newScreen;
|
||||
}
|
||||
}
|
||||
|
||||
PanelWindow {
|
||||
id: launcherWindow
|
||||
visible: spotlightOpen || isClosing
|
||||
color: "transparent"
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
|
||||
WindowBlur {
|
||||
targetWindow: launcherWindow
|
||||
readonly property real s: Math.min(1, modalContainer.scale)
|
||||
blurX: root.modalX + root.modalWidth * (1 - s) * 0.5
|
||||
blurY: root.modalY + root.modalHeight * (1 - s) * 0.5
|
||||
blurWidth: (contentVisible && modalContainer.opacity > 0) ? root.modalWidth * s : 0
|
||||
blurHeight: (contentVisible && modalContainer.opacity > 0) ? root.modalHeight * s : 0
|
||||
blurRadius: root.cornerRadius
|
||||
}
|
||||
|
||||
WlrLayershell.namespace: "dms:spotlight"
|
||||
WlrLayershell.layer: {
|
||||
switch (Quickshell.env("DMS_MODAL_LAYER")) {
|
||||
case "bottom":
|
||||
console.error("DankModal: 'bottom' layer is not valid for modals. Defaulting to 'top' layer.");
|
||||
return WlrLayershell.Top;
|
||||
case "background":
|
||||
console.error("DankModal: 'background' layer is not valid for modals. Defaulting to 'top' layer.");
|
||||
return WlrLayershell.Top;
|
||||
case "overlay":
|
||||
return WlrLayershell.Overlay;
|
||||
default:
|
||||
return WlrLayershell.Top;
|
||||
}
|
||||
}
|
||||
WlrLayershell.keyboardFocus: keyboardActive ? (root.useHyprlandFocusGrab ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) : WlrKeyboardFocus.None
|
||||
|
||||
anchors {
|
||||
top: true
|
||||
bottom: true
|
||||
left: true
|
||||
right: true
|
||||
}
|
||||
|
||||
mask: Region {
|
||||
item: spotlightOpen ? fullScreenMask : null
|
||||
}
|
||||
|
||||
Item {
|
||||
id: fullScreenMask
|
||||
anchors.fill: parent
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: backgroundDarken
|
||||
anchors.fill: parent
|
||||
color: "black"
|
||||
opacity: contentVisible && SettingsData.modalDarkenBackground ? 0.5 : 0
|
||||
visible: contentVisible || opacity > 0
|
||||
|
||||
Behavior on opacity {
|
||||
DankAnim {
|
||||
duration: Theme.modalAnimationDuration
|
||||
easing.bezierCurve: contentVisible ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
enabled: spotlightOpen
|
||||
onClicked: mouse => {
|
||||
var contentX = modalContainer.x;
|
||||
var contentY = modalContainer.y;
|
||||
var contentW = modalContainer.width;
|
||||
var contentH = modalContainer.height;
|
||||
|
||||
if (mouse.x < contentX || mouse.x > contentX + contentW || mouse.y < contentY || mouse.y > contentY + contentH) {
|
||||
root.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: modalContainer
|
||||
x: root.modalX
|
||||
y: root.modalY
|
||||
width: root.modalWidth
|
||||
height: root.modalHeight
|
||||
visible: contentVisible || opacity > 0
|
||||
|
||||
opacity: contentVisible ? 1 : 0
|
||||
scale: contentVisible ? 1 : 0.96
|
||||
transformOrigin: Item.Center
|
||||
|
||||
Behavior on opacity {
|
||||
DankAnim {
|
||||
duration: Theme.modalAnimationDuration
|
||||
easing.bezierCurve: contentVisible ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on scale {
|
||||
DankAnim {
|
||||
duration: Theme.modalAnimationDuration
|
||||
easing.bezierCurve: contentVisible ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized
|
||||
}
|
||||
}
|
||||
|
||||
ElevationShadow {
|
||||
id: launcherShadowLayer
|
||||
anchors.fill: parent
|
||||
level: Theme.elevationLevel3
|
||||
fallbackOffset: 6
|
||||
targetColor: root.backgroundColor
|
||||
borderColor: root.borderColor
|
||||
borderWidth: root.borderWidth
|
||||
targetRadius: root.cornerRadius
|
||||
shadowEnabled: Theme.elevationEnabled && SettingsData.modalElevationEnabled && Quickshell.env("DMS_DISABLE_LAYER") !== "true" && Quickshell.env("DMS_DISABLE_LAYER") !== "1" && !BlurService.enabled
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onPressed: mouse => mouse.accepted = true
|
||||
}
|
||||
|
||||
FocusScope {
|
||||
anchors.fill: parent
|
||||
focus: keyboardActive
|
||||
|
||||
Loader {
|
||||
id: launcherContentLoader
|
||||
anchors.fill: parent
|
||||
active: !root.unloadContentOnClose || root.spotlightOpen || root.isClosing || root.contentVisible || root._pendingInitialize
|
||||
asynchronous: false
|
||||
sourceComponent: LauncherContent {
|
||||
focus: true
|
||||
parentModal: root
|
||||
}
|
||||
|
||||
onLoaded: {
|
||||
if (root._pendingInitialize) {
|
||||
root._initializeAndShow(root._pendingQuery, root._pendingMode);
|
||||
root._pendingInitialize = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Keys.onEscapePressed: event => {
|
||||
root.hide();
|
||||
event.accepted = true;
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: root.cornerRadius
|
||||
color: "transparent"
|
||||
border.color: BlurService.borderColor
|
||||
border.width: BlurService.borderWidth
|
||||
}
|
||||
function onDialogClosed() {
|
||||
root.dialogClosed();
|
||||
root._maybeResolveBackend();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,913 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import Quickshell.Hyprland
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
readonly property var log: Log.scoped("DankLauncherV2ModalConnected")
|
||||
|
||||
property var modalHandle: root
|
||||
|
||||
visible: false
|
||||
|
||||
property bool spotlightOpen: false
|
||||
property bool keyboardActive: false
|
||||
property bool contentVisible: false
|
||||
readonly property bool launcherMotionVisible: Theme.isConnectedEffect ? _motionActive : (Theme.isDirectionalEffect ? spotlightOpen : _motionActive)
|
||||
property var spotlightContent: launcherContentLoader.item
|
||||
property bool openedFromOverview: false
|
||||
property bool isClosing: false
|
||||
property bool _windowEnabled: true
|
||||
property bool _pendingInitialize: false
|
||||
property string _pendingQuery: ""
|
||||
property string _pendingMode: ""
|
||||
readonly property bool unloadContentOnClose: SettingsData.dankLauncherV2UnloadOnClose
|
||||
|
||||
// Animation state — matches DankPopout/DankModal pattern
|
||||
property bool animationsEnabled: true
|
||||
property bool _motionActive: false
|
||||
property real _frozenMotionX: 0
|
||||
property real _frozenMotionY: 0
|
||||
|
||||
readonly property bool useHyprlandFocusGrab: CompositorService.useHyprlandFocusGrab
|
||||
readonly property var effectiveScreen: contentWindow.screen
|
||||
readonly property real screenWidth: effectiveScreen?.width ?? 1920
|
||||
readonly property real screenHeight: effectiveScreen?.height ?? 1080
|
||||
readonly property real dpr: effectiveScreen ? CompositorService.getScreenScale(effectiveScreen) : 1
|
||||
|
||||
readonly property int baseWidth: {
|
||||
switch (SettingsData.dankLauncherV2Size) {
|
||||
case "micro":
|
||||
return 500;
|
||||
case "medium":
|
||||
return 720;
|
||||
case "large":
|
||||
return 860;
|
||||
default:
|
||||
return 620;
|
||||
}
|
||||
}
|
||||
readonly property int baseHeight: {
|
||||
switch (SettingsData.dankLauncherV2Size) {
|
||||
case "micro":
|
||||
return 480;
|
||||
case "medium":
|
||||
return 720;
|
||||
case "large":
|
||||
return 860;
|
||||
default:
|
||||
return 600;
|
||||
}
|
||||
}
|
||||
readonly property int modalWidth: Math.min(baseWidth, screenWidth - 100)
|
||||
readonly property int modalHeight: Math.min(baseHeight, screenHeight - 100)
|
||||
|
||||
readonly property string preferredConnectedBarSide: SettingsData.frameLauncherEmergeSide
|
||||
|
||||
readonly property bool frameConnectedMode: SettingsData.frameEnabled && Theme.isConnectedEffect && !!effectiveScreen && SettingsData.isScreenInPreferences(effectiveScreen, SettingsData.frameScreenPreferences)
|
||||
|
||||
readonly property string resolvedConnectedBarSide: frameConnectedMode ? preferredConnectedBarSide : ""
|
||||
|
||||
readonly property bool frameOwnsConnectedChrome: frameConnectedMode && resolvedConnectedBarSide !== ""
|
||||
readonly property bool launcherArcExtenderActive: frameOwnsConnectedChrome && SettingsData.frameLauncherArcExtender && (resolvedConnectedBarSide === "top" || resolvedConnectedBarSide === "bottom")
|
||||
|
||||
function _dockOccupiesSide(side) {
|
||||
if (!SettingsData.showDock)
|
||||
return false;
|
||||
switch (side) {
|
||||
case "top":
|
||||
return SettingsData.dockPosition === SettingsData.Position.Top;
|
||||
case "bottom":
|
||||
return SettingsData.dockPosition === SettingsData.Position.Bottom;
|
||||
case "left":
|
||||
return SettingsData.dockPosition === SettingsData.Position.Left;
|
||||
case "right":
|
||||
return SettingsData.dockPosition === SettingsData.Position.Right;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
readonly property bool _dockBlocksEmergence: frameOwnsConnectedChrome && _dockOccupiesSide(resolvedConnectedBarSide)
|
||||
|
||||
function _frameEdgeInset(side) {
|
||||
if (!effectiveScreen)
|
||||
return 0;
|
||||
return SettingsData.frameEdgeInsetForSide(effectiveScreen, side);
|
||||
}
|
||||
|
||||
// frameEdgeInsetForSide is the full inset; do not add frameBarSize.
|
||||
// Positions the modal flush to the emerge side, centered on the cross axis.
|
||||
readonly property var _connectedModalPos: {
|
||||
const fallback = {
|
||||
"x": (screenWidth - modalWidth) / 2,
|
||||
"y": (screenHeight - modalHeight) / 2
|
||||
};
|
||||
switch (resolvedConnectedBarSide) {
|
||||
case "top":
|
||||
case "bottom":
|
||||
{
|
||||
const insetL = _frameEdgeInset("left");
|
||||
const insetR = _frameEdgeInset("right");
|
||||
const insetT = _frameEdgeInset("top");
|
||||
const insetB = _frameEdgeInset("bottom");
|
||||
const usable = Math.max(0, screenWidth - insetL - insetR);
|
||||
const usableH = Math.max(0, screenHeight - insetT - insetB);
|
||||
return {
|
||||
"x": insetL + Math.max(0, (usable - modalWidth) / 2),
|
||||
"y": launcherArcExtenderActive ? insetT + Math.max(0, (usableH - modalHeight) / 2) : (resolvedConnectedBarSide === "top" ? insetT : screenHeight - modalHeight - insetB)
|
||||
};
|
||||
}
|
||||
case "left":
|
||||
case "right":
|
||||
{
|
||||
const insetT = _frameEdgeInset("top");
|
||||
const insetB = _frameEdgeInset("bottom");
|
||||
const usable = Math.max(0, screenHeight - insetT - insetB);
|
||||
return {
|
||||
"x": resolvedConnectedBarSide === "left" ? _frameEdgeInset("left") : screenWidth - modalWidth - _frameEdgeInset("right"),
|
||||
"y": insetT + Math.max(0, (usable - modalHeight) / 2)
|
||||
};
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
readonly property real modalX: frameOwnsConnectedChrome ? _connectedModalPos.x : ((screenWidth - modalWidth) / 2)
|
||||
readonly property real modalY: frameOwnsConnectedChrome ? _connectedModalPos.y : ((screenHeight - modalHeight) / 2)
|
||||
|
||||
readonly property bool connectedSurfaceOverride: Theme.isConnectedEffect
|
||||
readonly property int launcherAnimationDuration: Theme.isConnectedEffect ? Theme.popoutAnimationDuration : Theme.modalAnimationDuration
|
||||
readonly property list<real> launcherEnterCurve: Theme.isConnectedEffect ? Theme.variantPopoutEnterCurve : Theme.variantModalEnterCurve
|
||||
readonly property list<real> launcherExitCurve: Theme.isConnectedEffect ? Theme.variantPopoutExitCurve : Theme.variantModalExitCurve
|
||||
readonly property color backgroundColor: connectedSurfaceOverride ? Theme.connectedSurfaceColor : Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
|
||||
readonly property real cornerRadius: connectedSurfaceOverride ? Theme.connectedSurfaceRadius : Theme.cornerRadius
|
||||
readonly property color borderColor: {
|
||||
if (!SettingsData.dankLauncherV2BorderEnabled)
|
||||
return Theme.outlineMedium;
|
||||
switch (SettingsData.dankLauncherV2BorderColor) {
|
||||
case "primary":
|
||||
return Theme.primary;
|
||||
case "secondary":
|
||||
return Theme.secondary;
|
||||
case "outline":
|
||||
return Theme.outline;
|
||||
case "surfaceText":
|
||||
return Theme.surfaceText;
|
||||
default:
|
||||
return Theme.primary;
|
||||
}
|
||||
}
|
||||
readonly property int borderWidth: SettingsData.dankLauncherV2BorderEnabled ? SettingsData.dankLauncherV2BorderThickness : 0
|
||||
readonly property color effectiveBorderColor: connectedSurfaceOverride ? "transparent" : borderColor
|
||||
readonly property int effectiveBorderWidth: connectedSurfaceOverride ? 0 : borderWidth
|
||||
readonly property bool effectiveBlurEnabled: Theme.connectedSurfaceBlurEnabled
|
||||
|
||||
// Shadow padding for the content window (render padding only, no motion padding).
|
||||
// Zeroed when frame owns the chrome and Wayland clips past the bar edge
|
||||
readonly property var shadowLevel: Theme.elevationLevel3
|
||||
readonly property real shadowFallbackOffset: 6
|
||||
readonly property real shadowRenderPadding: (!frameOwnsConnectedChrome && Theme.elevationEnabled && SettingsData.modalElevationEnabled) ? Theme.elevationRenderPadding(shadowLevel, Theme.elevationLightDirection, shadowFallbackOffset, 8, 16) : 0
|
||||
readonly property real shadowPad: Theme.snap(shadowRenderPadding, dpr)
|
||||
readonly property real alignedWidth: Theme.px(modalWidth, dpr)
|
||||
readonly property real alignedHeight: Theme.px(modalHeight, dpr)
|
||||
readonly property real alignedX: Theme.snap(modalX, dpr)
|
||||
readonly property real alignedY: Theme.snap(modalY, dpr)
|
||||
readonly property real _connectedChromeX: alignedX
|
||||
readonly property real _connectedChromeY: {
|
||||
if (!launcherArcExtenderActive)
|
||||
return alignedY;
|
||||
return resolvedConnectedBarSide === "top" ? Theme.snap(_frameEdgeInset("top"), dpr) : alignedY;
|
||||
}
|
||||
readonly property real _connectedChromeWidth: alignedWidth
|
||||
readonly property real _connectedChromeHeight: {
|
||||
if (!launcherArcExtenderActive)
|
||||
return alignedHeight;
|
||||
if (resolvedConnectedBarSide === "top")
|
||||
return Theme.snap(Math.max(alignedHeight, alignedY + alignedHeight - _frameEdgeInset("top")), dpr);
|
||||
if (resolvedConnectedBarSide === "bottom")
|
||||
return Theme.snap(Math.max(alignedHeight, screenHeight - _frameEdgeInset("bottom") - alignedY), dpr);
|
||||
return alignedHeight;
|
||||
}
|
||||
readonly property real contentSurfaceHeight: launcherArcExtenderActive ? _connectedChromeHeight : alignedHeight
|
||||
|
||||
// For directional/depth: window extends from screen top (content slides within)
|
||||
// For standard: small window tightly around the modal + shadow padding
|
||||
readonly property bool _needsExtendedWindow: (Theme.isDirectionalEffect && !Theme.isConnectedEffect) || Theme.isDepthEffect
|
||||
// Content window geometry
|
||||
readonly property real _cwMarginLeft: Theme.snap(alignedX - shadowPad, dpr)
|
||||
readonly property real _cwMarginTop: launcherArcExtenderActive ? _connectedChromeY : (_needsExtendedWindow ? 0 : Theme.snap(alignedY - shadowPad, dpr))
|
||||
readonly property real _cwWidth: alignedWidth + shadowPad * 2
|
||||
readonly property real _cwHeight: {
|
||||
if (launcherArcExtenderActive)
|
||||
return _connectedChromeHeight;
|
||||
if (Theme.isDirectionalEffect && !Theme.isConnectedEffect)
|
||||
return screenHeight + shadowPad;
|
||||
if (Theme.isDepthEffect)
|
||||
return alignedY + alignedHeight + shadowPad;
|
||||
return alignedHeight + shadowPad * 2;
|
||||
}
|
||||
// Where the content container sits inside the content window
|
||||
readonly property real _ccX: shadowPad
|
||||
readonly property real _ccY: launcherArcExtenderActive ? 0 : (_needsExtendedWindow ? alignedY : shadowPad)
|
||||
|
||||
signal dialogClosed
|
||||
|
||||
// Coalesce per-channel dirty bits; one ConnectedModeState write per tick.
|
||||
Timer {
|
||||
id: _syncTimer
|
||||
interval: 0
|
||||
onTriggered: root._flushSync()
|
||||
}
|
||||
|
||||
property string _chromeClaimId: ""
|
||||
property bool _fullSyncPending: false
|
||||
|
||||
function _nextChromeClaimId() {
|
||||
return "dms:launcher-v2:" + (new Date()).getTime() + ":" + Math.floor(Math.random() * 1000);
|
||||
}
|
||||
|
||||
function _currentScreenName() {
|
||||
return effectiveScreen ? effectiveScreen.name : "";
|
||||
}
|
||||
|
||||
function _publishModalChromeState() {
|
||||
const screenName = _currentScreenName();
|
||||
if (!screenName)
|
||||
return;
|
||||
ConnectedModeState.setModalState(screenName, {
|
||||
"visible": spotlightOpen || contentWindow.visible,
|
||||
"barSide": resolvedConnectedBarSide,
|
||||
"bodyX": _connectedChromeX,
|
||||
"bodyY": _connectedChromeY,
|
||||
"bodyW": _connectedChromeWidth,
|
||||
"bodyH": _connectedChromeHeight,
|
||||
"animX": contentContainer ? contentContainer.animX : 0,
|
||||
"animY": contentContainer ? contentContainer.animY : 0,
|
||||
"omitStartConnector": false,
|
||||
"omitEndConnector": false
|
||||
});
|
||||
}
|
||||
|
||||
function _syncModalChromeState() {
|
||||
if (!frameOwnsConnectedChrome) {
|
||||
_releaseModalChrome();
|
||||
return;
|
||||
}
|
||||
if (!_chromeClaimId)
|
||||
_chromeClaimId = _nextChromeClaimId();
|
||||
_publishModalChromeState();
|
||||
if (_dockBlocksEmergence && (spotlightOpen || contentWindow.visible))
|
||||
ConnectedModeState.requestDockRetract(_chromeClaimId, _currentScreenName(), resolvedConnectedBarSide);
|
||||
else
|
||||
ConnectedModeState.releaseDockRetract(_chromeClaimId);
|
||||
}
|
||||
|
||||
property bool _animSyncQueued: false
|
||||
property bool _bodySyncQueued: false
|
||||
|
||||
function _queueFullSync() {
|
||||
_fullSyncPending = true;
|
||||
if (!_syncTimer.running)
|
||||
_syncTimer.restart();
|
||||
}
|
||||
function _queueAnimSync() {
|
||||
_animSyncQueued = true;
|
||||
if (!_syncTimer.running)
|
||||
_syncTimer.restart();
|
||||
}
|
||||
function _queueBodySync() {
|
||||
_bodySyncQueued = true;
|
||||
if (!_syncTimer.running)
|
||||
_syncTimer.restart();
|
||||
}
|
||||
function _flushSync() {
|
||||
const fullDirty = _fullSyncPending;
|
||||
const animDirty = _animSyncQueued;
|
||||
const bodyDirty = _bodySyncQueued;
|
||||
_fullSyncPending = false;
|
||||
_animSyncQueued = false;
|
||||
_bodySyncQueued = false;
|
||||
if (fullDirty)
|
||||
_syncModalChromeState();
|
||||
if (animDirty)
|
||||
_syncModalAnim();
|
||||
if (bodyDirty)
|
||||
_syncModalBody();
|
||||
}
|
||||
|
||||
function _syncModalAnim() {
|
||||
if (!frameOwnsConnectedChrome || !_chromeClaimId)
|
||||
return;
|
||||
const screenName = _currentScreenName();
|
||||
if (!screenName || !contentContainer)
|
||||
return;
|
||||
ConnectedModeState.setModalAnim(screenName, contentContainer.animX, contentContainer.animY);
|
||||
}
|
||||
|
||||
function _syncModalBody() {
|
||||
if (!frameOwnsConnectedChrome || !_chromeClaimId)
|
||||
return;
|
||||
const screenName = _currentScreenName();
|
||||
if (!screenName)
|
||||
return;
|
||||
ConnectedModeState.setModalBody(screenName, _connectedChromeX, _connectedChromeY, _connectedChromeWidth, _connectedChromeHeight);
|
||||
}
|
||||
|
||||
function _releaseModalChrome() {
|
||||
if (_chromeClaimId) {
|
||||
ConnectedModeState.releaseDockRetract(_chromeClaimId);
|
||||
_chromeClaimId = "";
|
||||
}
|
||||
const screenName = _currentScreenName();
|
||||
if (screenName)
|
||||
ConnectedModeState.clearModalState(screenName);
|
||||
}
|
||||
|
||||
onFrameOwnsConnectedChromeChanged: _syncModalChromeState()
|
||||
onLauncherArcExtenderActiveChanged: _queueFullSync()
|
||||
onResolvedConnectedBarSideChanged: _queueFullSync()
|
||||
onSpotlightOpenChanged: _queueFullSync()
|
||||
onAlignedXChanged: _queueBodySync()
|
||||
onAlignedYChanged: _queueBodySync()
|
||||
onAlignedWidthChanged: _queueBodySync()
|
||||
onAlignedHeightChanged: _queueBodySync()
|
||||
|
||||
Component.onDestruction: _releaseModalChrome()
|
||||
|
||||
Connections {
|
||||
target: contentWindow
|
||||
function onVisibleChanged() {
|
||||
if (contentWindow.visible)
|
||||
root._syncModalChromeState();
|
||||
else
|
||||
root._releaseModalChrome();
|
||||
}
|
||||
}
|
||||
|
||||
function _ensureContentLoadedAndInitialize(query, mode) {
|
||||
_pendingQuery = query || "";
|
||||
_pendingMode = mode || "";
|
||||
_pendingInitialize = true;
|
||||
contentVisible = true;
|
||||
launcherContentLoader.active = true;
|
||||
|
||||
if (spotlightContent) {
|
||||
_initializeAndShow(_pendingQuery, _pendingMode);
|
||||
_pendingInitialize = false;
|
||||
}
|
||||
}
|
||||
|
||||
function _initializeAndShow(query, mode) {
|
||||
if (!spotlightContent)
|
||||
return;
|
||||
contentVisible = true;
|
||||
// NOTE: forceActiveFocus() is deliberately NOT called here.
|
||||
// It is deferred to after animation starts to avoid compositor IPC stalls.
|
||||
|
||||
if (spotlightContent.searchField) {
|
||||
spotlightContent.searchField.text = query;
|
||||
}
|
||||
if (spotlightContent.controller) {
|
||||
var targetMode = mode || SessionData.launcherLastMode || "all";
|
||||
spotlightContent.controller.searchMode = targetMode;
|
||||
spotlightContent.controller.activePluginId = "";
|
||||
spotlightContent.controller.activePluginName = "";
|
||||
spotlightContent.controller.pluginFilter = "";
|
||||
spotlightContent.controller.fileSearchType = "all";
|
||||
spotlightContent.controller.fileSearchExt = "";
|
||||
spotlightContent.controller.fileSearchFolder = "";
|
||||
spotlightContent.controller.fileSearchSort = "score";
|
||||
spotlightContent.controller.collapsedSections = {};
|
||||
spotlightContent.controller.selectedFlatIndex = 0;
|
||||
spotlightContent.controller.selectedItem = null;
|
||||
if (query) {
|
||||
spotlightContent.controller.setSearchQuery(query);
|
||||
} else {
|
||||
spotlightContent.controller.searchQuery = "";
|
||||
spotlightContent.controller.performSearch();
|
||||
}
|
||||
}
|
||||
if (spotlightContent.resetScroll) {
|
||||
spotlightContent.resetScroll();
|
||||
}
|
||||
if (spotlightContent.actionPanel) {
|
||||
spotlightContent.actionPanel.hide();
|
||||
}
|
||||
}
|
||||
|
||||
function _openCommon(query, mode) {
|
||||
closeCleanupTimer.stop();
|
||||
isClosing = false;
|
||||
openedFromOverview = false;
|
||||
|
||||
// Disable animations so the snap is instant
|
||||
animationsEnabled = false;
|
||||
|
||||
// Freeze the collapsed offsets (they depend on height which could change)
|
||||
_frozenMotionX = contentContainer ? contentContainer.collapsedMotionX : 0;
|
||||
_frozenMotionY = contentContainer ? contentContainer.collapsedMotionY : (Theme.isDirectionalEffect ? Math.max(root.screenHeight - root._ccY + root.shadowPad, Theme.effectAnimOffset * 1.1) : -Theme.effectAnimOffset);
|
||||
|
||||
var focusedScreen = CompositorService.getFocusedScreen();
|
||||
if (focusedScreen) {
|
||||
backgroundWindow.screen = focusedScreen;
|
||||
contentWindow.screen = focusedScreen;
|
||||
}
|
||||
|
||||
// _motionActive = false ensures motionX/Y snap to frozen collapsed position
|
||||
_motionActive = false;
|
||||
|
||||
// Make windows visible but do NOT request keyboard focus yet
|
||||
ModalManager.openModal(modalHandle);
|
||||
spotlightOpen = true;
|
||||
backgroundWindow.visible = true;
|
||||
contentWindow.visible = true;
|
||||
if (useHyprlandFocusGrab)
|
||||
focusGrab.active = true;
|
||||
|
||||
// Load content and initialize (but no forceActiveFocus — that's deferred)
|
||||
_ensureContentLoadedAndInitialize(query || "", mode || "");
|
||||
|
||||
// Frame 1: enable animations and trigger enter motion
|
||||
Qt.callLater(() => {
|
||||
root.animationsEnabled = true;
|
||||
root._motionActive = true;
|
||||
|
||||
// Frame 2: request keyboard focus + activate search field
|
||||
// Double-deferred to avoid compositor IPC competing with animation frames
|
||||
Qt.callLater(() => {
|
||||
root.keyboardActive = true;
|
||||
if (root.spotlightContent && root.spotlightContent.searchField)
|
||||
root.spotlightContent.searchField.forceActiveFocus();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function show() {
|
||||
_openCommon("", "");
|
||||
}
|
||||
|
||||
function showWithQuery(query) {
|
||||
_openCommon(query, "");
|
||||
}
|
||||
|
||||
function hide() {
|
||||
if (!spotlightOpen)
|
||||
return;
|
||||
openedFromOverview = false;
|
||||
isClosing = true;
|
||||
// For directional effects, defer contentVisible=false so content stays rendered during exit slide
|
||||
if (!Theme.isDirectionalEffect)
|
||||
contentVisible = false;
|
||||
|
||||
// Trigger exit animation — Behaviors will animate motionX/Y to frozen collapsed position
|
||||
_motionActive = false;
|
||||
|
||||
keyboardActive = false;
|
||||
spotlightOpen = false;
|
||||
focusGrab.active = false;
|
||||
ModalManager.closeModal(modalHandle);
|
||||
closeCleanupTimer.start();
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
spotlightOpen ? hide() : show();
|
||||
}
|
||||
|
||||
function showWithMode(mode) {
|
||||
_openCommon("", mode);
|
||||
}
|
||||
|
||||
function toggleWithMode(mode) {
|
||||
if (spotlightOpen) {
|
||||
hide();
|
||||
} else {
|
||||
showWithMode(mode);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleWithQuery(query) {
|
||||
if (spotlightOpen) {
|
||||
hide();
|
||||
} else {
|
||||
showWithQuery(query);
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: closeCleanupTimer
|
||||
interval: Theme.variantCloseInterval(root.launcherAnimationDuration)
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
isClosing = false;
|
||||
contentVisible = false;
|
||||
contentWindow.visible = false;
|
||||
backgroundWindow.visible = false;
|
||||
if (root.unloadContentOnClose)
|
||||
launcherContentLoader.active = false;
|
||||
dialogClosed();
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: spotlightContent?.controller ?? null
|
||||
function onModeChanged(mode) {
|
||||
if (spotlightContent.controller.autoSwitchedToFiles)
|
||||
return;
|
||||
SessionData.setLauncherLastMode(mode);
|
||||
}
|
||||
}
|
||||
|
||||
HyprlandFocusGrab {
|
||||
id: focusGrab
|
||||
windows: [contentWindow]
|
||||
active: false
|
||||
|
||||
onCleared: {
|
||||
if (spotlightOpen) {
|
||||
hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: ModalManager
|
||||
function onCloseAllModalsExcept(excludedModal) {
|
||||
if (excludedModal !== modalHandle && spotlightOpen) {
|
||||
hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: Quickshell
|
||||
function onScreensChanged() {
|
||||
if (Quickshell.screens.length === 0)
|
||||
return;
|
||||
|
||||
const screen = contentWindow.screen;
|
||||
const screenName = screen?.name;
|
||||
|
||||
let needsReset = !screen || !screenName;
|
||||
if (!needsReset) {
|
||||
needsReset = true;
|
||||
for (let i = 0; i < Quickshell.screens.length; i++) {
|
||||
if (Quickshell.screens[i].name === screenName) {
|
||||
needsReset = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!needsReset)
|
||||
return;
|
||||
|
||||
const newScreen = CompositorService.getFocusedScreen() ?? Quickshell.screens[0];
|
||||
if (!newScreen)
|
||||
return;
|
||||
|
||||
root._windowEnabled = false;
|
||||
backgroundWindow.screen = newScreen;
|
||||
contentWindow.screen = newScreen;
|
||||
Qt.callLater(() => {
|
||||
root._windowEnabled = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
PanelWindow {
|
||||
id: backgroundWindow
|
||||
visible: false
|
||||
color: "transparent"
|
||||
|
||||
readonly property real _topMargin: contentContainer.dockTop ? contentContainer.dockThickness : (typeof SettingsData !== "undefined" && SettingsData.barPosition === 0 ? Theme.px(42, root.dpr) : 0)
|
||||
readonly property real _bottomMargin: contentContainer.dockBottom ? contentContainer.dockThickness : (typeof SettingsData !== "undefined" && SettingsData.barPosition === 1 ? Theme.px(42, root.dpr) : 0)
|
||||
readonly property real _leftMargin: contentContainer.dockLeft ? contentContainer.dockThickness : (typeof SettingsData !== "undefined" && SettingsData.barPosition === 2 ? Theme.px(42, root.dpr) : 0)
|
||||
readonly property real _rightMargin: contentContainer.dockRight ? contentContainer.dockThickness : (typeof SettingsData !== "undefined" && SettingsData.barPosition === 3 ? Theme.px(42, root.dpr) : 0)
|
||||
|
||||
WlrLayershell.namespace: "dms:spotlight:bg"
|
||||
WlrLayershell.layer: WlrLayershell.Top
|
||||
WlrLayershell.exclusiveZone: -1
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
||||
|
||||
WlrLayershell.margins {
|
||||
top: backgroundWindow._topMargin
|
||||
bottom: backgroundWindow._bottomMargin
|
||||
left: backgroundWindow._leftMargin
|
||||
right: backgroundWindow._rightMargin
|
||||
}
|
||||
|
||||
anchors {
|
||||
top: true
|
||||
bottom: true
|
||||
left: true
|
||||
right: true
|
||||
}
|
||||
|
||||
mask: Region {
|
||||
item: (spotlightOpen || isClosing) ? bgFullScreenMask : null
|
||||
|
||||
Region {
|
||||
item: bgContentHole
|
||||
intersection: Intersection.Subtract
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: bgFullScreenMask
|
||||
anchors.fill: parent
|
||||
}
|
||||
|
||||
Item {
|
||||
id: bgContentHole
|
||||
visible: false
|
||||
x: root._cwMarginLeft + contentContainer.x - backgroundWindow._leftMargin
|
||||
y: root._cwMarginTop + contentContainer.y - backgroundWindow._topMargin
|
||||
width: root.alignedWidth
|
||||
height: root.contentSurfaceHeight
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: backgroundDarken
|
||||
anchors.fill: parent
|
||||
color: "black"
|
||||
opacity: 0
|
||||
visible: false
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
enabled: spotlightOpen
|
||||
onClicked: root.hide()
|
||||
}
|
||||
}
|
||||
|
||||
PanelWindow {
|
||||
id: contentWindow
|
||||
visible: false
|
||||
color: "transparent"
|
||||
|
||||
WindowBlur {
|
||||
targetWindow: contentWindow
|
||||
blurEnabled: root.effectiveBlurEnabled && !root.frameOwnsConnectedChrome
|
||||
readonly property real s: Math.min(1, contentContainer.scaleValue)
|
||||
blurX: root._ccX + root.alignedWidth * (1 - s) * 0.5 + Theme.snap(contentContainer.animX, root.dpr)
|
||||
blurY: root._ccY + root.alignedHeight * (1 - s) * 0.5 + Theme.snap(contentContainer.animY, root.dpr)
|
||||
blurWidth: (root.spotlightOpen || root.isClosing) && !root.frameOwnsConnectedChrome ? root.alignedWidth * s : 0
|
||||
blurHeight: (root.spotlightOpen || root.isClosing) && !root.frameOwnsConnectedChrome ? root.alignedHeight * s : 0
|
||||
blurRadius: root.cornerRadius
|
||||
}
|
||||
|
||||
WlrLayershell.namespace: "dms:spotlight"
|
||||
WlrLayershell.layer: {
|
||||
switch (Quickshell.env("DMS_MODAL_LAYER")) {
|
||||
case "bottom":
|
||||
log.error("'bottom' layer is not valid for modals. Defaulting to 'top' layer.");
|
||||
return WlrLayershell.Top;
|
||||
case "background":
|
||||
log.error("'background' layer is not valid for modals. Defaulting to 'top' layer.");
|
||||
return WlrLayershell.Top;
|
||||
case "overlay":
|
||||
return WlrLayershell.Overlay;
|
||||
default:
|
||||
return WlrLayershell.Top;
|
||||
}
|
||||
}
|
||||
WlrLayershell.exclusiveZone: -1
|
||||
WlrLayershell.keyboardFocus: keyboardActive ? (root.useHyprlandFocusGrab ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) : WlrKeyboardFocus.None
|
||||
|
||||
anchors {
|
||||
left: true
|
||||
top: true
|
||||
}
|
||||
|
||||
WlrLayershell.margins {
|
||||
left: root._cwMarginLeft
|
||||
top: root._cwMarginTop
|
||||
}
|
||||
|
||||
implicitWidth: root._cwWidth
|
||||
implicitHeight: root._cwHeight
|
||||
|
||||
mask: Region {
|
||||
item: contentInputMask
|
||||
}
|
||||
|
||||
Item {
|
||||
id: contentInputMask
|
||||
visible: false
|
||||
x: contentContainer.x
|
||||
y: contentContainer.y
|
||||
width: root.alignedWidth
|
||||
height: root.contentSurfaceHeight
|
||||
}
|
||||
|
||||
Item {
|
||||
id: contentContainer
|
||||
|
||||
// For directional/depth: contentContainer is at alignedY from window top (window starts at screen top)
|
||||
// For standard: contentContainer is at shadowPad from window top (window starts near modal)
|
||||
x: root._ccX
|
||||
y: root._ccY
|
||||
width: root.alignedWidth
|
||||
height: root.contentSurfaceHeight
|
||||
|
||||
readonly property int dockEdge: typeof SettingsData !== "undefined" ? SettingsData.dockPosition : 1
|
||||
readonly property bool dockTop: dockEdge === 0
|
||||
readonly property bool dockBottom: dockEdge === 1
|
||||
readonly property bool dockLeft: dockEdge === 2
|
||||
readonly property bool dockRight: dockEdge === 3
|
||||
|
||||
readonly property real dockThickness: typeof SettingsData !== "undefined" && SettingsData.showDock ? Theme.px(SettingsData.dockIconSize + (SettingsData.dockMargin * 2) + SettingsData.dockSpacing + 8, root.dpr) : Theme.px(60, root.dpr)
|
||||
|
||||
readonly property bool directionalEffect: Theme.isDirectionalEffect
|
||||
readonly property bool depthEffect: Theme.isDepthEffect
|
||||
readonly property real _connectedTravelX: Math.max(Theme.effectAnimOffset, root.alignedWidth + Theme.spacingL)
|
||||
readonly property real _connectedTravelY: root.launcherArcExtenderActive ? root._connectedChromeHeight : Math.max(Theme.effectAnimOffset, root.alignedHeight + Theme.spacingL)
|
||||
readonly property real collapsedMotionX: {
|
||||
if (root.frameOwnsConnectedChrome) {
|
||||
switch (root.resolvedConnectedBarSide) {
|
||||
case "left":
|
||||
return -_connectedTravelX;
|
||||
case "right":
|
||||
return _connectedTravelX;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (directionalEffect) {
|
||||
if (dockLeft)
|
||||
return -(root._ccX + root.alignedWidth + Theme.effectAnimOffset);
|
||||
if (dockRight)
|
||||
return root.screenWidth - root._ccX + Theme.effectAnimOffset;
|
||||
}
|
||||
if (depthEffect)
|
||||
return Theme.effectAnimOffset * 0.25;
|
||||
return 0;
|
||||
}
|
||||
readonly property real collapsedMotionY: {
|
||||
if (root.frameOwnsConnectedChrome) {
|
||||
switch (root.resolvedConnectedBarSide) {
|
||||
case "top":
|
||||
return -_connectedTravelY;
|
||||
case "bottom":
|
||||
return _connectedTravelY;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (directionalEffect) {
|
||||
if (dockTop)
|
||||
return -(root._ccY + root.alignedHeight + Theme.effectAnimOffset);
|
||||
if (dockBottom)
|
||||
return root.screenHeight - root._ccY + root.shadowPad + Theme.effectAnimOffset;
|
||||
return 0;
|
||||
}
|
||||
if (depthEffect)
|
||||
return -Math.max(Theme.effectAnimOffset * 0.85, 34);
|
||||
return -Math.max((root.shadowPad || 0) + Theme.effectAnimOffset, 40);
|
||||
}
|
||||
|
||||
// openProgress: 0 = closed (at frozenMotion, scaleCollapsed), 1 = open (at 0, scale 1).
|
||||
QtObject {
|
||||
id: morph
|
||||
property real openProgress: root._motionActive ? 1 : 0
|
||||
Behavior on openProgress {
|
||||
enabled: root.animationsEnabled
|
||||
DankAnim {
|
||||
duration: Theme.variantDuration(root.launcherAnimationDuration, root._motionActive)
|
||||
easing.bezierCurve: root._motionActive ? root.launcherEnterCurve : root.launcherExitCurve
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
readonly property real animX: root._frozenMotionX * (1 - morph.openProgress)
|
||||
readonly property real animY: root._frozenMotionY * (1 - morph.openProgress)
|
||||
readonly property real scaleValue: Theme.effectScaleCollapsed + (1.0 - Theme.effectScaleCollapsed) * morph.openProgress
|
||||
|
||||
onAnimXChanged: if (root.frameOwnsConnectedChrome)
|
||||
root._queueAnimSync()
|
||||
onAnimYChanged: if (root.frameOwnsConnectedChrome)
|
||||
root._queueAnimSync()
|
||||
|
||||
Item {
|
||||
id: directionalClipMask
|
||||
readonly property bool shouldClip: Theme.isDirectionalEffect
|
||||
readonly property real clipOversize: 2000
|
||||
|
||||
clip: shouldClip
|
||||
|
||||
x: shouldClip ? (contentContainer.dockRight ? -clipOversize : (contentContainer.dockLeft ? contentContainer.dockThickness - root._ccX : -clipOversize)) : 0
|
||||
y: shouldClip ? (contentContainer.dockBottom ? -clipOversize : (contentContainer.dockTop ? contentContainer.dockThickness - root._ccY : -clipOversize)) : 0
|
||||
|
||||
width: shouldClip ? parent.width + clipOversize + (contentContainer.dockRight ? (root.screenWidth - contentContainer.dockThickness - root._ccX - parent.width) : (contentContainer.dockLeft ? clipOversize : clipOversize)) : parent.width
|
||||
height: shouldClip ? parent.height + clipOversize + (contentContainer.dockBottom ? (root.screenHeight - contentContainer.dockThickness - root._ccY - parent.height) : (contentContainer.dockTop ? clipOversize : clipOversize)) : parent.height
|
||||
|
||||
Item {
|
||||
id: aligner
|
||||
x: directionalClipMask.x !== 0 ? -directionalClipMask.x : 0
|
||||
y: directionalClipMask.y !== 0 ? -directionalClipMask.y : 0
|
||||
width: contentContainer.width
|
||||
height: contentContainer.height
|
||||
|
||||
// Shadow mirrors contentWrapper position/scale/opacity
|
||||
ElevationShadow {
|
||||
id: launcherShadowLayer
|
||||
width: parent.width
|
||||
height: parent.height
|
||||
opacity: contentWrapper.publishedOpacity
|
||||
scale: contentWrapper.scale
|
||||
x: contentWrapper.x
|
||||
y: contentWrapper.y
|
||||
level: root.shadowLevel
|
||||
fallbackOffset: root.shadowFallbackOffset
|
||||
targetColor: root.frameOwnsConnectedChrome ? "transparent" : root.backgroundColor
|
||||
borderColor: root.frameOwnsConnectedChrome ? "transparent" : root.effectiveBorderColor
|
||||
borderWidth: root.frameOwnsConnectedChrome ? 0 : root.effectiveBorderWidth
|
||||
targetRadius: root.cornerRadius
|
||||
shadowEnabled: !root.frameOwnsConnectedChrome && Theme.elevationEnabled && SettingsData.modalElevationEnabled && Quickshell.env("DMS_DISABLE_LAYER") !== "true" && Quickshell.env("DMS_DISABLE_LAYER") !== "1"
|
||||
}
|
||||
|
||||
// contentWrapper moves inside static contentContainer — DankPopout pattern
|
||||
Item {
|
||||
id: contentWrapper
|
||||
width: parent.width
|
||||
height: parent.height
|
||||
|
||||
property bool _renderActive: (Theme.isDirectionalEffect && !Theme.isConnectedEffect) || launcherMotionVisible
|
||||
property real publishedOpacity: (Theme.isDirectionalEffect && !Theme.isConnectedEffect) ? 1 : (launcherMotionVisible ? 1 : 0)
|
||||
|
||||
opacity: (Theme.isDirectionalEffect && !Theme.isConnectedEffect) ? 1 : (launcherMotionVisible ? 1 : 0)
|
||||
visible: _renderActive
|
||||
scale: contentContainer.scaleValue
|
||||
x: Theme.snap(contentContainer.animX + (parent.width - width) * (1 - contentContainer.scaleValue) * 0.5, root.dpr)
|
||||
y: Theme.snap(contentContainer.animY + (parent.height - height) * (1 - contentContainer.scaleValue) * 0.5, root.dpr)
|
||||
|
||||
Behavior on opacity {
|
||||
enabled: root.animationsEnabled && (!Theme.isDirectionalEffect || Theme.isConnectedEffect)
|
||||
NumberAnimation {
|
||||
easing.type: Easing.BezierSpline
|
||||
duration: Math.round(Theme.variantDuration(root.launcherAnimationDuration, launcherMotionVisible) * Theme.variantOpacityDurationScale)
|
||||
easing.bezierCurve: launcherMotionVisible ? root.launcherEnterCurve : root.launcherExitCurve
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on publishedOpacity {
|
||||
enabled: root.animationsEnabled && (!Theme.isDirectionalEffect || Theme.isConnectedEffect)
|
||||
NumberAnimation {
|
||||
easing.type: Easing.BezierSpline
|
||||
duration: Math.round(Theme.variantDuration(root.launcherAnimationDuration, launcherMotionVisible) * Theme.variantOpacityDurationScale)
|
||||
easing.bezierCurve: launcherMotionVisible ? root.launcherEnterCurve : root.launcherExitCurve
|
||||
onRunningChanged: if (!running && contentWrapper.publishedOpacity === 0)
|
||||
contentWrapper._renderActive = false
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: root
|
||||
function onLauncherMotionVisibleChanged() {
|
||||
if (root.launcherMotionVisible)
|
||||
contentWrapper._renderActive = true;
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onPressed: mouse => mouse.accepted = true
|
||||
}
|
||||
|
||||
FocusScope {
|
||||
anchors.fill: parent
|
||||
focus: keyboardActive
|
||||
|
||||
Loader {
|
||||
id: launcherContentLoader
|
||||
anchors.fill: parent
|
||||
active: !root.unloadContentOnClose || root.spotlightOpen || root.isClosing || root.contentVisible || root._pendingInitialize
|
||||
asynchronous: false
|
||||
sourceComponent: LauncherContent {
|
||||
focus: true
|
||||
parentModal: root
|
||||
}
|
||||
|
||||
onLoaded: {
|
||||
if (root._pendingInitialize) {
|
||||
root._initializeAndShow(root._pendingQuery, root._pendingMode);
|
||||
root._pendingInitialize = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Keys.onEscapePressed: event => {
|
||||
root.hide();
|
||||
event.accepted = true;
|
||||
}
|
||||
}
|
||||
} // contentWrapper
|
||||
} // aligner
|
||||
} // directionalClipMask
|
||||
} // contentContainer
|
||||
} // PanelWindow
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import Quickshell.Hyprland
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
readonly property var log: Log.scoped("DankLauncherV2ModalStandalone")
|
||||
|
||||
property var modalHandle: root
|
||||
|
||||
visible: false
|
||||
|
||||
property bool spotlightOpen: false
|
||||
property bool keyboardActive: false
|
||||
property bool contentVisible: false
|
||||
property var spotlightContent: launcherContentLoader.item
|
||||
property bool openedFromOverview: false
|
||||
property bool isClosing: false
|
||||
property bool _pendingInitialize: false
|
||||
property string _pendingQuery: ""
|
||||
property string _pendingMode: ""
|
||||
readonly property bool unloadContentOnClose: SettingsData.dankLauncherV2UnloadOnClose
|
||||
|
||||
readonly property bool useHyprlandFocusGrab: CompositorService.useHyprlandFocusGrab
|
||||
readonly property var effectiveScreen: launcherWindow.screen
|
||||
readonly property real screenWidth: effectiveScreen?.width ?? 1920
|
||||
readonly property real screenHeight: effectiveScreen?.height ?? 1080
|
||||
readonly property real dpr: effectiveScreen ? CompositorService.getScreenScale(effectiveScreen) : 1
|
||||
|
||||
readonly property bool frameOwnsConnectedChrome: SettingsData.connectedFrameModeActive && !!effectiveScreen && SettingsData.isScreenInPreferences(effectiveScreen, SettingsData.frameScreenPreferences)
|
||||
readonly property string resolvedConnectedBarSide: frameOwnsConnectedChrome ? (SettingsData.frameLauncherEmergeSide || "bottom") : ""
|
||||
|
||||
readonly property int baseWidth: {
|
||||
switch (SettingsData.dankLauncherV2Size) {
|
||||
case "micro":
|
||||
return 500;
|
||||
case "medium":
|
||||
return 720;
|
||||
case "large":
|
||||
return 860;
|
||||
default:
|
||||
return 620;
|
||||
}
|
||||
}
|
||||
readonly property int baseHeight: {
|
||||
switch (SettingsData.dankLauncherV2Size) {
|
||||
case "micro":
|
||||
return 480;
|
||||
case "medium":
|
||||
return 720;
|
||||
case "large":
|
||||
return 860;
|
||||
default:
|
||||
return 600;
|
||||
}
|
||||
}
|
||||
readonly property int modalWidth: Math.min(baseWidth, screenWidth - 100)
|
||||
readonly property int modalHeight: Math.min(baseHeight, screenHeight - 100)
|
||||
readonly property real modalX: (screenWidth - modalWidth) / 2
|
||||
readonly property real modalY: (screenHeight - modalHeight) / 2
|
||||
readonly property var shadowLevel: Theme.elevationLevel3
|
||||
readonly property real shadowFallbackOffset: 6
|
||||
readonly property real shadowRenderPadding: (Theme.elevationEnabled && SettingsData.modalElevationEnabled) ? Theme.elevationRenderPadding(shadowLevel, Theme.elevationLightDirection, shadowFallbackOffset, 8, 16) : 0
|
||||
readonly property real shadowPad: Theme.snap(shadowRenderPadding, dpr)
|
||||
readonly property real alignedWidth: Theme.px(modalWidth, dpr)
|
||||
readonly property real alignedHeight: Theme.px(modalHeight, dpr)
|
||||
readonly property real alignedX: Theme.snap(modalX, dpr)
|
||||
readonly property real alignedY: Theme.snap(modalY, dpr)
|
||||
readonly property real windowX: Math.max(0, Theme.snap(alignedX - shadowPad, dpr))
|
||||
readonly property real windowY: Math.max(0, Theme.snap(alignedY - shadowPad, dpr))
|
||||
readonly property real contentX: Theme.snap(alignedX - windowX, dpr)
|
||||
readonly property real contentY: Theme.snap(alignedY - windowY, dpr)
|
||||
readonly property real windowWidth: alignedWidth + contentX + shadowPad
|
||||
readonly property real windowHeight: alignedHeight + contentY + shadowPad
|
||||
|
||||
readonly property color backgroundColor: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
|
||||
readonly property real cornerRadius: Theme.cornerRadius
|
||||
readonly property color borderColor: {
|
||||
if (!SettingsData.dankLauncherV2BorderEnabled)
|
||||
return Theme.outlineMedium;
|
||||
switch (SettingsData.dankLauncherV2BorderColor) {
|
||||
case "primary":
|
||||
return Theme.primary;
|
||||
case "secondary":
|
||||
return Theme.secondary;
|
||||
case "outline":
|
||||
return Theme.outline;
|
||||
case "surfaceText":
|
||||
return Theme.surfaceText;
|
||||
default:
|
||||
return Theme.primary;
|
||||
}
|
||||
}
|
||||
readonly property int borderWidth: SettingsData.dankLauncherV2BorderEnabled ? SettingsData.dankLauncherV2BorderThickness : 0
|
||||
|
||||
signal dialogClosed
|
||||
|
||||
function _ensureContentLoadedAndInitialize(query, mode) {
|
||||
_pendingQuery = query || "";
|
||||
_pendingMode = mode || "";
|
||||
_pendingInitialize = true;
|
||||
contentVisible = true;
|
||||
launcherContentLoader.active = true;
|
||||
|
||||
if (spotlightContent) {
|
||||
_initializeAndShow(_pendingQuery, _pendingMode);
|
||||
_pendingInitialize = false;
|
||||
}
|
||||
}
|
||||
|
||||
function _initializeAndShow(query, mode) {
|
||||
if (!spotlightContent)
|
||||
return;
|
||||
contentVisible = true;
|
||||
spotlightContent.searchField.forceActiveFocus();
|
||||
|
||||
var targetQuery = "";
|
||||
|
||||
if (query) {
|
||||
targetQuery = query;
|
||||
} else if (SettingsData.rememberLastQuery) {
|
||||
targetQuery = SessionData.launcherLastQuery || "";
|
||||
}
|
||||
|
||||
if (spotlightContent.searchField) {
|
||||
spotlightContent.searchField.text = targetQuery;
|
||||
}
|
||||
if (spotlightContent.controller) {
|
||||
var targetMode = mode || SessionData.launcherLastMode || "all";
|
||||
spotlightContent.controller.searchMode = targetMode;
|
||||
spotlightContent.controller.activePluginId = "";
|
||||
spotlightContent.controller.activePluginName = "";
|
||||
spotlightContent.controller.pluginFilter = "";
|
||||
spotlightContent.controller.fileSearchType = "all";
|
||||
spotlightContent.controller.fileSearchExt = "";
|
||||
spotlightContent.controller.fileSearchFolder = "";
|
||||
spotlightContent.controller.fileSearchSort = "score";
|
||||
spotlightContent.controller.collapsedSections = {};
|
||||
spotlightContent.controller.selectedFlatIndex = 0;
|
||||
spotlightContent.controller.selectedItem = null;
|
||||
spotlightContent.controller.historyIndex = -1;
|
||||
spotlightContent.controller.searchQuery = targetQuery;
|
||||
|
||||
spotlightContent.controller.performSearch();
|
||||
}
|
||||
if (spotlightContent.resetScroll) {
|
||||
spotlightContent.resetScroll();
|
||||
}
|
||||
if (spotlightContent.actionPanel) {
|
||||
spotlightContent.actionPanel.hide();
|
||||
}
|
||||
}
|
||||
|
||||
function _finishShow(query, mode) {
|
||||
spotlightOpen = true;
|
||||
isClosing = false;
|
||||
openedFromOverview = false;
|
||||
|
||||
keyboardActive = true;
|
||||
ModalManager.openModal(modalHandle);
|
||||
if (useHyprlandFocusGrab)
|
||||
focusGrab.active = true;
|
||||
|
||||
_ensureContentLoadedAndInitialize(query || "", mode || "");
|
||||
}
|
||||
|
||||
function _openCommon(query, mode) {
|
||||
closeCleanupTimer.stop();
|
||||
const focusedScreen = CompositorService.getFocusedScreen();
|
||||
if (focusedScreen && launcherWindow.screen !== focusedScreen) {
|
||||
spotlightOpen = false;
|
||||
isClosing = false;
|
||||
launcherWindow.screen = focusedScreen;
|
||||
Qt.callLater(() => root._finishShow(query, mode));
|
||||
return;
|
||||
}
|
||||
_finishShow(query, mode);
|
||||
}
|
||||
|
||||
function show() {
|
||||
_openCommon("", "");
|
||||
}
|
||||
function showWithQuery(query) {
|
||||
_openCommon(query, "");
|
||||
}
|
||||
function showWithMode(mode) {
|
||||
_openCommon("", mode);
|
||||
}
|
||||
|
||||
function hide() {
|
||||
if (!spotlightOpen)
|
||||
return;
|
||||
openedFromOverview = false;
|
||||
isClosing = true;
|
||||
contentVisible = false;
|
||||
|
||||
keyboardActive = false;
|
||||
spotlightOpen = false;
|
||||
focusGrab.active = false;
|
||||
ModalManager.closeModal(modalHandle);
|
||||
|
||||
closeCleanupTimer.start();
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
spotlightOpen ? hide() : show();
|
||||
}
|
||||
|
||||
function toggleWithMode(mode) {
|
||||
if (spotlightOpen) {
|
||||
hide();
|
||||
} else {
|
||||
showWithMode(mode);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleWithQuery(query) {
|
||||
if (spotlightOpen) {
|
||||
hide();
|
||||
} else {
|
||||
showWithQuery(query);
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: closeCleanupTimer
|
||||
interval: Theme.modalAnimationDuration + 50
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
isClosing = false;
|
||||
if (root.unloadContentOnClose)
|
||||
launcherContentLoader.active = false;
|
||||
dialogClosed();
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: spotlightContent?.controller ?? null
|
||||
|
||||
function onModeChanged(mode) {
|
||||
if (spotlightContent.controller.autoSwitchedToFiles)
|
||||
return;
|
||||
SessionData.setLauncherLastMode(mode);
|
||||
}
|
||||
}
|
||||
|
||||
HyprlandFocusGrab {
|
||||
id: focusGrab
|
||||
windows: [launcherWindow]
|
||||
active: false
|
||||
|
||||
onCleared: {
|
||||
if (spotlightOpen) {
|
||||
hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: ModalManager
|
||||
function onCloseAllModalsExcept(excludedModal) {
|
||||
if (excludedModal !== modalHandle && spotlightOpen) {
|
||||
hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: Quickshell
|
||||
function onScreensChanged() {
|
||||
if (Quickshell.screens.length === 0)
|
||||
return;
|
||||
|
||||
const screenName = launcherWindow.screen?.name;
|
||||
if (screenName) {
|
||||
for (let i = 0; i < Quickshell.screens.length; i++) {
|
||||
if (Quickshell.screens[i].name === screenName)
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (spotlightOpen)
|
||||
hide();
|
||||
|
||||
const newScreen = CompositorService.getFocusedScreen() ?? Quickshell.screens[0];
|
||||
if (newScreen)
|
||||
launcherWindow.screen = newScreen;
|
||||
}
|
||||
}
|
||||
|
||||
PanelWindow {
|
||||
id: clickCatcher
|
||||
screen: launcherWindow.screen
|
||||
visible: spotlightOpen
|
||||
color: "transparent"
|
||||
updatesEnabled: false
|
||||
|
||||
WlrLayershell.namespace: "dms:spotlight:clickcatcher"
|
||||
WlrLayershell.layer: WlrLayershell.Top
|
||||
WlrLayershell.exclusiveZone: -1
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
||||
|
||||
anchors {
|
||||
top: true
|
||||
bottom: true
|
||||
left: true
|
||||
right: true
|
||||
}
|
||||
|
||||
mask: Region {
|
||||
item: outsideClickMask
|
||||
|
||||
Region {
|
||||
item: outsideClickHole
|
||||
intersection: Intersection.Subtract
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: outsideClickMask
|
||||
visible: false
|
||||
anchors.fill: parent
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: outsideClickHole
|
||||
visible: false
|
||||
color: "transparent"
|
||||
x: root.alignedX
|
||||
y: root.alignedY
|
||||
width: root.alignedWidth
|
||||
height: root.alignedHeight
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
enabled: spotlightOpen
|
||||
onClicked: root.hide()
|
||||
}
|
||||
}
|
||||
|
||||
PanelWindow {
|
||||
id: launcherWindow
|
||||
visible: spotlightOpen || isClosing
|
||||
color: "transparent"
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
|
||||
WindowBlur {
|
||||
targetWindow: launcherWindow
|
||||
readonly property real s: Math.min(1, modalContainer.publishedScale)
|
||||
blurX: modalContainer.x + modalContainer.width * (1 - s) * 0.5
|
||||
blurY: modalContainer.y + modalContainer.height * (1 - s) * 0.5
|
||||
blurWidth: contentVisible ? modalContainer.width * s : 0
|
||||
blurHeight: contentVisible ? modalContainer.height * s : 0
|
||||
blurRadius: root.cornerRadius
|
||||
}
|
||||
|
||||
WlrLayershell.namespace: "dms:spotlight"
|
||||
WlrLayershell.layer: {
|
||||
switch (Quickshell.env("DMS_MODAL_LAYER")) {
|
||||
case "bottom":
|
||||
log.error("'bottom' layer is not valid for modals. Defaulting to 'top' layer.");
|
||||
return WlrLayershell.Top;
|
||||
case "background":
|
||||
log.error("'background' layer is not valid for modals. Defaulting to 'top' layer.");
|
||||
return WlrLayershell.Top;
|
||||
case "overlay":
|
||||
return WlrLayershell.Overlay;
|
||||
default:
|
||||
return WlrLayershell.Top;
|
||||
}
|
||||
}
|
||||
WlrLayershell.exclusiveZone: -1
|
||||
WlrLayershell.keyboardFocus: keyboardActive ? (root.useHyprlandFocusGrab ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) : WlrKeyboardFocus.None
|
||||
|
||||
anchors {
|
||||
top: true
|
||||
left: true
|
||||
}
|
||||
|
||||
WlrLayershell.margins {
|
||||
left: root.windowX
|
||||
top: root.windowY
|
||||
right: 0
|
||||
bottom: 0
|
||||
}
|
||||
|
||||
implicitWidth: root.windowWidth
|
||||
implicitHeight: root.windowHeight
|
||||
|
||||
mask: Region {
|
||||
item: launcherInputMask
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: launcherInputMask
|
||||
visible: false
|
||||
color: "transparent"
|
||||
x: modalContainer.x
|
||||
y: modalContainer.y
|
||||
width: modalContainer.width
|
||||
height: modalContainer.height
|
||||
}
|
||||
|
||||
Item {
|
||||
id: modalContainer
|
||||
x: root.contentX
|
||||
y: root.contentY
|
||||
width: root.alignedWidth
|
||||
height: root.alignedHeight
|
||||
visible: _renderActive
|
||||
|
||||
property bool _renderActive: contentVisible
|
||||
property real publishedScale: contentVisible ? 1 : 0.96
|
||||
|
||||
opacity: contentVisible ? 1 : 0
|
||||
scale: contentVisible ? 1 : 0.96
|
||||
transformOrigin: Item.Center
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
easing.type: Easing.BezierSpline
|
||||
duration: Theme.modalAnimationDuration
|
||||
easing.bezierCurve: contentVisible ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized
|
||||
onRunningChanged: if (!running && !root.contentVisible)
|
||||
modalContainer._renderActive = false
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on scale {
|
||||
NumberAnimation {
|
||||
easing.type: Easing.BezierSpline
|
||||
duration: Theme.modalAnimationDuration
|
||||
easing.bezierCurve: contentVisible ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on publishedScale {
|
||||
NumberAnimation {
|
||||
easing.type: Easing.BezierSpline
|
||||
duration: Theme.modalAnimationDuration
|
||||
easing.bezierCurve: contentVisible ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: root
|
||||
function onContentVisibleChanged() {
|
||||
if (root.contentVisible)
|
||||
modalContainer._renderActive = true;
|
||||
}
|
||||
}
|
||||
|
||||
ElevationShadow {
|
||||
id: launcherShadowLayer
|
||||
anchors.fill: parent
|
||||
level: root.shadowLevel
|
||||
fallbackOffset: root.shadowFallbackOffset
|
||||
targetColor: root.backgroundColor
|
||||
borderColor: root.borderColor
|
||||
borderWidth: root.borderWidth
|
||||
targetRadius: root.cornerRadius
|
||||
shadowEnabled: Theme.elevationEnabled && SettingsData.modalElevationEnabled && Quickshell.env("DMS_DISABLE_LAYER") !== "true" && Quickshell.env("DMS_DISABLE_LAYER") !== "1"
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onPressed: mouse => mouse.accepted = true
|
||||
}
|
||||
|
||||
FocusScope {
|
||||
anchors.fill: parent
|
||||
focus: keyboardActive
|
||||
|
||||
Loader {
|
||||
id: launcherContentLoader
|
||||
anchors.fill: parent
|
||||
active: !root.unloadContentOnClose || root.spotlightOpen || root.isClosing || root.contentVisible || root._pendingInitialize
|
||||
asynchronous: false
|
||||
sourceComponent: LauncherContent {
|
||||
focus: true
|
||||
parentModal: root
|
||||
}
|
||||
|
||||
onLoaded: {
|
||||
if (root._pendingInitialize) {
|
||||
root._initializeAndShow(root._pendingQuery, root._pendingMode);
|
||||
root._pendingInitialize = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Keys.onEscapePressed: event => {
|
||||
root.hide();
|
||||
event.accepted = true;
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: root.cornerRadius
|
||||
color: "transparent"
|
||||
border.color: BlurService.borderColor
|
||||
border.width: BlurService.borderWidth
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,6 @@ FocusScope {
|
||||
editCommentField.text = existing?.comment || "";
|
||||
editEnvVarsField.text = existing?.envVars || "";
|
||||
editExtraFlagsField.text = existing?.extraFlags || "";
|
||||
editDgpuToggle.checked = existing?.launchOnDgpu || false;
|
||||
editMode = true;
|
||||
Qt.callLater(() => editNameField.forceActiveFocus());
|
||||
}
|
||||
@@ -65,8 +64,6 @@ FocusScope {
|
||||
override.envVars = editEnvVarsField.text.trim();
|
||||
if (editExtraFlagsField.text.trim())
|
||||
override.extraFlags = editExtraFlagsField.text.trim();
|
||||
if (editDgpuToggle.checked)
|
||||
override.launchOnDgpu = true;
|
||||
SessionData.setAppOverride(editAppId, override);
|
||||
closeEditMode();
|
||||
}
|
||||
@@ -89,7 +86,7 @@ FocusScope {
|
||||
|
||||
Controller {
|
||||
id: controller
|
||||
active: root.parentModal?.spotlightOpen ?? true
|
||||
active: root.parentModal ? (root.parentModal.spotlightOpen || root.parentModal.isClosing) : true
|
||||
viewModeContext: root.viewModeContext
|
||||
|
||||
onItemExecuted: {
|
||||
@@ -149,18 +146,10 @@ FocusScope {
|
||||
event.accepted = false;
|
||||
return;
|
||||
case Qt.Key_Down:
|
||||
if (hasCtrl) {
|
||||
controller.navigateHistory("down");
|
||||
} else {
|
||||
controller.selectNext();
|
||||
}
|
||||
controller.selectNext();
|
||||
return;
|
||||
case Qt.Key_Up:
|
||||
if (hasCtrl) {
|
||||
controller.navigateHistory("up");
|
||||
} else {
|
||||
controller.selectPrevious();
|
||||
}
|
||||
controller.selectPrevious();
|
||||
return;
|
||||
case Qt.Key_PageDown:
|
||||
controller.selectPageDown(8);
|
||||
@@ -169,10 +158,6 @@ FocusScope {
|
||||
controller.selectPageUp(8);
|
||||
return;
|
||||
case Qt.Key_Right:
|
||||
if (hasCtrl) {
|
||||
controller.cycleMode();
|
||||
return;
|
||||
}
|
||||
if (controller.getCurrentSectionViewMode() !== "list") {
|
||||
controller.selectRight();
|
||||
return;
|
||||
@@ -180,25 +165,12 @@ FocusScope {
|
||||
event.accepted = false;
|
||||
return;
|
||||
case Qt.Key_Left:
|
||||
if (hasCtrl) {
|
||||
const reverse = true;
|
||||
controller.cycleMode(reverse);
|
||||
return;
|
||||
}
|
||||
if (controller.getCurrentSectionViewMode() !== "list") {
|
||||
controller.selectLeft();
|
||||
return;
|
||||
}
|
||||
event.accepted = false;
|
||||
return;
|
||||
case Qt.Key_H:
|
||||
if (hasCtrl) {
|
||||
const reverse = true;
|
||||
controller.cycleMode(reverse);
|
||||
return;
|
||||
}
|
||||
event.accepted = false;
|
||||
return;
|
||||
case Qt.Key_J:
|
||||
if (hasCtrl) {
|
||||
controller.selectNext();
|
||||
@@ -213,13 +185,6 @@ FocusScope {
|
||||
}
|
||||
event.accepted = false;
|
||||
return;
|
||||
case Qt.Key_L:
|
||||
if (hasCtrl) {
|
||||
controller.cycleMode();
|
||||
return;
|
||||
}
|
||||
event.accepted = false;
|
||||
return;
|
||||
case Qt.Key_N:
|
||||
if (hasCtrl) {
|
||||
controller.selectNextSection();
|
||||
@@ -235,19 +200,13 @@ FocusScope {
|
||||
event.accepted = false;
|
||||
return;
|
||||
case Qt.Key_Tab:
|
||||
if (hasCtrl && actionPanel.hasActions) {
|
||||
if (actionPanel.hasActions) {
|
||||
actionPanel.expanded ? actionPanel.cycleAction() : actionPanel.show();
|
||||
return;
|
||||
}
|
||||
controller.selectNext();
|
||||
return;
|
||||
case Qt.Key_Backtab:
|
||||
if (hasCtrl && actionPanel.expanded) {
|
||||
const reverse = true;
|
||||
actionPanel.expanded ? actionPanel.cycleAction(reverse) : actionPanel.show();
|
||||
return;
|
||||
}
|
||||
controller.selectPrevious();
|
||||
if (actionPanel.expanded)
|
||||
actionPanel.hide();
|
||||
return;
|
||||
case Qt.Key_Return:
|
||||
case Qt.Key_Enter:
|
||||
@@ -311,24 +270,29 @@ FocusScope {
|
||||
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
visible: !editMode && !(root.parentModal?.isClosing ?? false)
|
||||
visible: !editMode
|
||||
|
||||
Item {
|
||||
id: footerBar
|
||||
readonly property bool _connectedBottomEmerge: (root.parentModal?.frameOwnsConnectedChrome ?? false) && (root.parentModal?.resolvedConnectedBarSide === "bottom")
|
||||
readonly property bool _connectedArcAtFooter: _connectedBottomEmerge && !(root.parentModal?.launcherArcExtenderActive ?? false)
|
||||
readonly property bool showFooter: SettingsData.dankLauncherV2Size !== "micro" && SettingsData.dankLauncherV2ShowFooter
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.leftMargin: root.parentModal?.borderWidth ?? 1
|
||||
anchors.rightMargin: root.parentModal?.borderWidth ?? 1
|
||||
anchors.bottomMargin: root.parentModal?.borderWidth ?? 1
|
||||
readonly property bool showFooter: SettingsData.dankLauncherV2Size !== "micro" && SettingsData.dankLauncherV2ShowFooter
|
||||
height: showFooter ? 36 : 0
|
||||
anchors.bottomMargin: _connectedBottomEmerge ? Theme.spacingM : (root.parentModal?.borderWidth ?? 1)
|
||||
height: showFooter ? (_connectedArcAtFooter ? 76 : 36) : 0
|
||||
visible: showFooter
|
||||
clip: true
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
anchors.topMargin: -Theme.cornerRadius
|
||||
// In connected mode the launcher provides the surface so update the toolbar for arcs
|
||||
visible: !(root.parentModal?.frameOwnsConnectedChrome ?? false)
|
||||
color: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
|
||||
radius: Theme.cornerRadius
|
||||
}
|
||||
@@ -336,7 +300,7 @@ FocusScope {
|
||||
Row {
|
||||
id: modeButtonsRow
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Theme.spacingXS
|
||||
anchors.leftMargin: Theme.spacingM
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
layoutDirection: I18n.isRtl ? Qt.RightToLeft : Qt.LeftToRight
|
||||
spacing: 2
|
||||
@@ -408,7 +372,7 @@ FocusScope {
|
||||
Row {
|
||||
id: hintsRow
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: Theme.spacingS
|
||||
anchors.rightMargin: Theme.spacingM
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
layoutDirection: I18n.isRtl ? Qt.RightToLeft : Qt.LeftToRight
|
||||
spacing: Theme.spacingM
|
||||
@@ -429,7 +393,7 @@ FocusScope {
|
||||
|
||||
StyledText {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Ctrl-Tab " + I18n.tr("actions")
|
||||
text: "Tab " + I18n.tr("actions")
|
||||
font.pixelSize: Theme.fontSizeSmall - 1
|
||||
color: Theme.surfaceVariantText
|
||||
visible: actionPanel.hasActions
|
||||
@@ -503,7 +467,7 @@ FocusScope {
|
||||
showClearButton: true
|
||||
textColor: Theme.surfaceText
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
enabled: root.parentModal ? root.parentModal.spotlightOpen : true
|
||||
enabled: root.parentModal ? (root.parentModal.spotlightOpen || root.parentModal.isClosing) : true
|
||||
placeholderText: ""
|
||||
ignoreUpDownKeys: true
|
||||
ignoreTabKeys: true
|
||||
@@ -737,6 +701,14 @@ FocusScope {
|
||||
Item {
|
||||
width: parent.width
|
||||
height: parent.height - searchField.height - categoryRow.height - fileFilterRow.height - actionPanel.height - Theme.spacingXS * ((categoryRow.visible ? 1 : 0) + (fileFilterRow.visible ? 1 : 0) + 2)
|
||||
opacity: {
|
||||
if (!root.parentModal)
|
||||
return 1;
|
||||
if (Theme.isDirectionalEffect && root.parentModal.isClosing)
|
||||
return 1;
|
||||
return root.parentModal.isClosing ? 0 : 1;
|
||||
}
|
||||
|
||||
ResultsList {
|
||||
id: resultsList
|
||||
anchors.fill: parent
|
||||
@@ -769,7 +741,6 @@ FocusScope {
|
||||
}
|
||||
function onSearchQueryRequested(query) {
|
||||
searchField.text = query;
|
||||
searchField.cursorPosition = query.length;
|
||||
}
|
||||
function onModeChanged() {
|
||||
extFilterField.text = "";
|
||||
@@ -980,15 +951,6 @@ FocusScope {
|
||||
keyNavigationBacktab: editEnvVarsField
|
||||
}
|
||||
}
|
||||
|
||||
DankToggle {
|
||||
id: editDgpuToggle
|
||||
width: parent.width
|
||||
text: I18n.tr("Launch on dGPU by default")
|
||||
visible: SessionService.nvidiaCommand.length > 0
|
||||
checked: false
|
||||
onToggled: checked => editDgpuToggle.checked = checked
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import QtQuick
|
||||
import QtCore
|
||||
import Quickshell.Io
|
||||
import qs.Common
|
||||
import qs.Widgets
|
||||
@@ -22,9 +21,9 @@ Rectangle {
|
||||
|
||||
onShowFileInfoChanged: {
|
||||
if (showFileInfo && currentFileName && currentPath) {
|
||||
const fullPath = currentPath + "/" + currentFileName
|
||||
fileStatProcess.selectedFilePath = fullPath
|
||||
fileStatProcess.running = true
|
||||
const fullPath = currentPath + "/" + currentFileName;
|
||||
fileStatProcess.selectedFilePath = fullPath;
|
||||
fileStatProcess.running = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,14 +37,14 @@ Rectangle {
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
if (text && text.trim()) {
|
||||
const parts = text.trim().split('|')
|
||||
const parts = text.trim().split('|');
|
||||
if (parts.length >= 4) {
|
||||
fileStatProcess.fileStats = {
|
||||
"modifiedTime": parts[0],
|
||||
"permissions": parts[1],
|
||||
"size": parseInt(parts[2]) || 0,
|
||||
"fullPath": parts[3]
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,31 +59,31 @@ Rectangle {
|
||||
|
||||
onCurrentFileNameChanged: {
|
||||
if (showFileInfo && currentFileName && currentPath) {
|
||||
const fullPath = currentPath + "/" + currentFileName
|
||||
const fullPath = currentPath + "/" + currentFileName;
|
||||
if (fullPath !== fileStatProcess.selectedFilePath) {
|
||||
fileStatProcess.selectedFilePath = fullPath
|
||||
fileStatProcess.running = true
|
||||
fileStatProcess.selectedFilePath = fullPath;
|
||||
fileStatProcess.running = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateFileInfo(filePath, fileName, isDirectory) {
|
||||
if (filePath && filePath !== fileStatProcess.selectedFilePath) {
|
||||
fileStatProcess.selectedFilePath = filePath
|
||||
currentFileName = fileName || ""
|
||||
currentFileIsDir = isDirectory || false
|
||||
fileStatProcess.selectedFilePath = filePath;
|
||||
currentFileName = fileName || "";
|
||||
currentFileIsDir = isDirectory || false;
|
||||
|
||||
let ext = ""
|
||||
let ext = "";
|
||||
if (!isDirectory && fileName) {
|
||||
const lastDot = fileName.lastIndexOf('.')
|
||||
const lastDot = fileName.lastIndexOf('.');
|
||||
if (lastDot > 0) {
|
||||
ext = fileName.substring(lastDot + 1).toLowerCase()
|
||||
ext = fileName.substring(lastDot + 1).toLowerCase();
|
||||
}
|
||||
}
|
||||
currentFileExtension = ext
|
||||
currentFileExtension = ext;
|
||||
|
||||
if (showFileInfo) {
|
||||
fileStatProcess.running = true
|
||||
fileStatProcess.running = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,10 +99,10 @@ Rectangle {
|
||||
"permissions": "",
|
||||
"extension": "",
|
||||
"position": "N/A"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const hasValidFile = currentFileName !== ""
|
||||
const hasValidFile = currentFileName !== "";
|
||||
return {
|
||||
"exists": hasValidFile,
|
||||
"name": hasValidFile ? currentFileName : "Loading...",
|
||||
@@ -113,7 +112,7 @@ Rectangle {
|
||||
"permissions": fileStatProcess.fileStats ? fileStatProcess.fileStats.permissions : "Loading...",
|
||||
"extension": currentFileExtension,
|
||||
"position": sourceFolderModel ? ((selectedIndex + 1) + " of " + sourceFolderModel.count) : "N/A"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Column {
|
||||
@@ -209,23 +208,23 @@ Rectangle {
|
||||
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes === 0 || !bytes) {
|
||||
return "0 B"
|
||||
return "0 B";
|
||||
}
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function formatDateTime(dateTimeString) {
|
||||
if (!dateTimeString) {
|
||||
return "Unknown"
|
||||
return "Unknown";
|
||||
}
|
||||
const parts = dateTimeString.split(' ')
|
||||
const parts = dateTimeString.split(' ');
|
||||
if (parts.length >= 2) {
|
||||
return parts[0] + " " + parts[1].split('.')[0]
|
||||
return parts[0] + " " + parts[1].split('.')[0];
|
||||
}
|
||||
return dateTimeString
|
||||
return dateTimeString;
|
||||
}
|
||||
|
||||
Behavior on opacity {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import QtQuick
|
||||
import Quickshell.Io
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
readonly property var log: Log.scoped("GreeterDoctorPage")
|
||||
|
||||
property bool isRunning: false
|
||||
property bool hasRun: false
|
||||
@@ -59,26 +61,22 @@ Item {
|
||||
border.color: Theme.primary
|
||||
opacity: 0
|
||||
|
||||
SequentialAnimation on opacity {
|
||||
OpacityAnimator on opacity {
|
||||
running: root.isRunning
|
||||
loops: Animation.Infinite
|
||||
NumberAnimation {
|
||||
from: 0.8
|
||||
to: 0
|
||||
duration: 1500
|
||||
easing.type: Easing.OutQuad
|
||||
}
|
||||
from: 0.8
|
||||
to: 0
|
||||
duration: 1500
|
||||
easing.type: Easing.OutQuad
|
||||
}
|
||||
|
||||
SequentialAnimation on scale {
|
||||
ScaleAnimator on scale {
|
||||
running: root.isRunning
|
||||
loops: Animation.Infinite
|
||||
NumberAnimation {
|
||||
from: 0.5
|
||||
to: 1.5
|
||||
duration: 1500
|
||||
easing.type: Easing.OutQuad
|
||||
}
|
||||
from: 0.5
|
||||
to: 1.5
|
||||
duration: 1500
|
||||
easing.type: Easing.OutQuad
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,26 +91,22 @@ Item {
|
||||
border.color: Theme.secondary
|
||||
opacity: 0
|
||||
|
||||
SequentialAnimation on opacity {
|
||||
OpacityAnimator on opacity {
|
||||
running: root.isRunning
|
||||
loops: Animation.Infinite
|
||||
NumberAnimation {
|
||||
from: 0.8
|
||||
to: 0
|
||||
duration: 1500
|
||||
easing.type: Easing.OutQuad
|
||||
}
|
||||
from: 0.8
|
||||
to: 0
|
||||
duration: 1500
|
||||
easing.type: Easing.OutQuad
|
||||
}
|
||||
|
||||
SequentialAnimation on scale {
|
||||
ScaleAnimator on scale {
|
||||
running: root.isRunning
|
||||
loops: Animation.Infinite
|
||||
NumberAnimation {
|
||||
from: 0.3
|
||||
to: 1.3
|
||||
duration: 1500
|
||||
easing.type: Easing.OutQuad
|
||||
}
|
||||
from: 0.3
|
||||
to: 1.3
|
||||
duration: 1500
|
||||
easing.type: Easing.OutQuad
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,9 +222,7 @@ Item {
|
||||
text: {
|
||||
if (root.errorCount === 0)
|
||||
return I18n.tr("All checks passed", "greeter doctor page success");
|
||||
return root.errorCount === 1
|
||||
? I18n.tr("%1 issue found", "greeter doctor page error count").arg(root.errorCount)
|
||||
: I18n.tr("%1 issues found", "greeter doctor page error count").arg(root.errorCount);
|
||||
return root.errorCount === 1 ? I18n.tr("%1 issue found", "greeter doctor page error count").arg(root.errorCount) : I18n.tr("%1 issues found", "greeter doctor page error count").arg(root.errorCount);
|
||||
}
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: root.errorCount > 0 ? Theme.error : Theme.surfaceVariantText
|
||||
@@ -412,7 +404,7 @@ Item {
|
||||
else
|
||||
root.selectedFilter = "ok";
|
||||
} catch (e) {
|
||||
console.error("GreeterDoctorPage: Failed to parse doctor output:", e);
|
||||
log.error("Failed to parse doctor output:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import qs.Widgets
|
||||
|
||||
FloatingWindow {
|
||||
id: root
|
||||
readonly property var log: Log.scoped("GreeterModal")
|
||||
|
||||
property bool disablePopupTransparency: true
|
||||
property int currentPage: 0
|
||||
@@ -105,7 +106,7 @@ FloatingWindow {
|
||||
root.cheatsheetData = JSON.parse(trimmed);
|
||||
root.cheatsheetLoaded = true;
|
||||
} catch (e) {
|
||||
console.warn("Greeter: Failed to parse cheatsheet:", e);
|
||||
log.warn("Greeter: Failed to parse cheatsheet:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,20 +259,24 @@ DankModal {
|
||||
cancelHold();
|
||||
close();
|
||||
}
|
||||
onOpened: () => {
|
||||
onShouldBeVisibleChanged: {
|
||||
if (!shouldBeVisible)
|
||||
return;
|
||||
holdAction = "";
|
||||
holdActionIndex = -1;
|
||||
holdProgress = 0;
|
||||
showHoldHint = false;
|
||||
updateVisibleActions();
|
||||
const defaultIndex = getDefaultActionIndex();
|
||||
selectedIndex = defaultIndex;
|
||||
if (SettingsData.powerMenuGridLayout) {
|
||||
selectedRow = Math.floor(defaultIndex / gridColumns);
|
||||
selectedCol = defaultIndex % gridColumns;
|
||||
selectedIndex = defaultIndex;
|
||||
} else {
|
||||
selectedIndex = defaultIndex;
|
||||
}
|
||||
}
|
||||
onShouldHaveFocusChanged: {
|
||||
if (!shouldHaveFocus)
|
||||
return;
|
||||
Qt.callLater(() => modalFocusScope.forceActiveFocus());
|
||||
}
|
||||
onDialogClosed: () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import qs.Widgets
|
||||
|
||||
FloatingWindow {
|
||||
id: processListModal
|
||||
readonly property var log: Log.scoped("ProcessListModal")
|
||||
|
||||
property bool disablePopupTransparency: true
|
||||
property int currentTab: 0
|
||||
@@ -22,7 +23,7 @@ FloatingWindow {
|
||||
|
||||
function show() {
|
||||
if (!DgopService.dgopAvailable) {
|
||||
console.warn("ProcessListModal: dgop is not available");
|
||||
log.warn("dgop is not available");
|
||||
return;
|
||||
}
|
||||
visible = true;
|
||||
@@ -36,7 +37,7 @@ FloatingWindow {
|
||||
|
||||
function toggle() {
|
||||
if (!DgopService.dgopAvailable) {
|
||||
console.warn("ProcessListModal: dgop is not available");
|
||||
log.warn("dgop is not available");
|
||||
return;
|
||||
}
|
||||
visible = !visible;
|
||||
@@ -44,7 +45,7 @@ FloatingWindow {
|
||||
|
||||
function focusOrToggle() {
|
||||
if (!DgopService.dgopAvailable) {
|
||||
console.warn("ProcessListModal: dgop is not available");
|
||||
log.warn("dgop is not available");
|
||||
return;
|
||||
}
|
||||
if (visible) {
|
||||
|
||||
@@ -112,7 +112,9 @@ FocusScope {
|
||||
focus: active
|
||||
|
||||
sourceComponent: Component {
|
||||
DockTab {}
|
||||
DockTab {
|
||||
parentModal: root.parentModal
|
||||
}
|
||||
}
|
||||
|
||||
onActiveChanged: {
|
||||
@@ -218,7 +220,9 @@ FocusScope {
|
||||
visible: active
|
||||
focus: active
|
||||
|
||||
sourceComponent: ThemeColorsTab {}
|
||||
sourceComponent: ThemeColorsTab {
|
||||
parentModal: root.parentModal
|
||||
}
|
||||
|
||||
onActiveChanged: {
|
||||
if (active && item)
|
||||
@@ -518,5 +522,20 @@ FocusScope {
|
||||
Qt.callLater(() => item.forceActiveFocus());
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: frameLoader
|
||||
anchors.fill: parent
|
||||
active: root.currentIndex === 33
|
||||
visible: active
|
||||
focus: active
|
||||
|
||||
sourceComponent: FrameTab {}
|
||||
|
||||
onActiveChanged: {
|
||||
if (active && item)
|
||||
Qt.callLater(() => item.forceActiveFocus());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +120,12 @@ Rectangle {
|
||||
"text": I18n.tr("Widgets"),
|
||||
"icon": "widgets",
|
||||
"tabIndex": 22
|
||||
},
|
||||
{
|
||||
"id": "frame",
|
||||
"text": I18n.tr("Frame"),
|
||||
"icon": "frame_source",
|
||||
"tabIndex": 33
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -164,7 +170,8 @@ Rectangle {
|
||||
"id": "updater",
|
||||
"text": I18n.tr("System Updater"),
|
||||
"icon": "refresh",
|
||||
"tabIndex": 20
|
||||
"tabIndex": 20,
|
||||
"updaterOnly": true
|
||||
},
|
||||
{
|
||||
"id": "desktop_widgets",
|
||||
@@ -340,6 +347,8 @@ Rectangle {
|
||||
return false;
|
||||
if (item.clipboardOnly && (!DMSService.isConnected || DMSService.apiVersion < 23))
|
||||
return false;
|
||||
if (item.updaterOnly && !SystemUpdateService.sysupdateAvailable)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import qs.Widgets
|
||||
|
||||
FloatingWindow {
|
||||
id: root
|
||||
readonly property var log: Log.scoped("WorkspaceRenameModal")
|
||||
|
||||
property bool disablePopupTransparency: true
|
||||
readonly property int inputFieldHeight: Theme.fontSizeMedium + Theme.spacingL * 2
|
||||
@@ -39,7 +40,7 @@ FloatingWindow {
|
||||
} else if (CompositorService.isHyprland) {
|
||||
HyprlandService.renameWorkspace(name);
|
||||
} else {
|
||||
console.warn("WorkspaceRenameModal: rename not supported for this compositor");
|
||||
log.warn("rename not supported for this compositor");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,9 +8,6 @@ DankPopout {
|
||||
|
||||
layerNamespace: "dms:app-launcher"
|
||||
|
||||
readonly property real screenWidth: screen?.width ?? 1920
|
||||
readonly property real screenHeight: screen?.height ?? 1080
|
||||
|
||||
property string _pendingMode: ""
|
||||
property string _pendingQuery: ""
|
||||
|
||||
@@ -44,35 +41,8 @@ DankPopout {
|
||||
openWithQuery(query);
|
||||
}
|
||||
|
||||
readonly property int _baseWidth: {
|
||||
switch (SettingsData.dankLauncherV2Size) {
|
||||
case "micro":
|
||||
return 500;
|
||||
case "medium":
|
||||
return 720;
|
||||
case "large":
|
||||
return 860;
|
||||
default:
|
||||
return 620;
|
||||
}
|
||||
}
|
||||
|
||||
readonly property int _baseHeight: {
|
||||
switch (SettingsData.dankLauncherV2Size) {
|
||||
case "micro":
|
||||
return 480;
|
||||
case "medium":
|
||||
return 720;
|
||||
case "large":
|
||||
return 860;
|
||||
default:
|
||||
return 600;
|
||||
}
|
||||
}
|
||||
|
||||
popupWidth: Math.min(_baseWidth, screenWidth - 100)
|
||||
popupHeight: Math.min(_baseHeight, screenHeight - 100)
|
||||
|
||||
popupWidth: 560
|
||||
popupHeight: 640
|
||||
triggerWidth: 40
|
||||
positioning: ""
|
||||
contentHandlesKeys: contentLoader.item?.launcherContent?.editMode ?? false
|
||||
@@ -90,7 +60,7 @@ DankPopout {
|
||||
if (!lc)
|
||||
return;
|
||||
|
||||
const query = _pendingQuery || (SettingsData.rememberLastQuery ? SessionData.launcherLastQuery : "") || "";
|
||||
const query = _pendingQuery;
|
||||
const mode = _pendingMode || SessionData.appDrawerLastMode || "apps";
|
||||
_pendingMode = "";
|
||||
_pendingQuery = "";
|
||||
@@ -102,9 +72,12 @@ DankPopout {
|
||||
if (lc.controller) {
|
||||
lc.controller.searchMode = mode;
|
||||
lc.controller.pluginFilter = "";
|
||||
lc.controller.searchQuery = query;
|
||||
|
||||
lc.controller.performSearch();
|
||||
lc.controller.searchQuery = "";
|
||||
if (query) {
|
||||
lc.controller.setSearchQuery(query);
|
||||
} else {
|
||||
lc.controller.performSearch();
|
||||
}
|
||||
}
|
||||
lc.resetScroll?.();
|
||||
lc.actionPanel?.hide();
|
||||
@@ -133,7 +106,7 @@ DankPopout {
|
||||
QtObject {
|
||||
id: modalAdapter
|
||||
property bool spotlightOpen: appDrawerPopout.shouldBeVisible
|
||||
readonly property bool isClosing: !appDrawerPopout.shouldBeVisible
|
||||
property bool isClosing: appDrawerPopout.isClosing
|
||||
|
||||
function hide() {
|
||||
appDrawerPopout.close();
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
import qs.Modules.Plugins
|
||||
|
||||
PluginComponent {
|
||||
id: root
|
||||
|
||||
Ref {
|
||||
service: TailscaleService
|
||||
}
|
||||
|
||||
ccWidgetIcon: "device_hub"
|
||||
ccWidgetPrimaryText: I18n.tr("Tailscale", "Tailscale mesh VPN widget title")
|
||||
ccWidgetSecondaryText: {
|
||||
if (!TailscaleService.available)
|
||||
return I18n.tr("Not available", "Tailscale service not available");
|
||||
if (!TailscaleService.connected)
|
||||
return I18n.tr("Disconnected", "Tailscale disconnected status");
|
||||
const count = TailscaleService.onlinePeerCount;
|
||||
return I18n.tr("%1 online", "Number of online Tailscale peers").arg(count);
|
||||
}
|
||||
ccWidgetIsActive: TailscaleService.connected
|
||||
|
||||
onCcWidgetToggled: {}
|
||||
|
||||
ccDetailContent: Component {
|
||||
Rectangle {
|
||||
id: detailRoot
|
||||
|
||||
property string searchQuery: ""
|
||||
property int filterIndex: 0 // 0=My Online, 1=All Online, 2=All
|
||||
property string expandedHostname: ""
|
||||
|
||||
implicitHeight: detailColumn.implicitHeight + Theme.spacingM * 2
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.surfaceContainerHigh
|
||||
|
||||
Column {
|
||||
id: detailColumn
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.spacingM
|
||||
spacing: Theme.spacingS
|
||||
|
||||
// Not available state
|
||||
Column {
|
||||
visible: !TailscaleService.available
|
||||
width: parent.width
|
||||
spacing: Theme.spacingS
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 80
|
||||
|
||||
Column {
|
||||
anchors.centerIn: parent
|
||||
spacing: Theme.spacingS
|
||||
|
||||
DankIcon {
|
||||
name: "vpn_key_off"
|
||||
size: 36
|
||||
color: Theme.surfaceVariantText
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Tailscale not available", "Warning when Tailscale service is not running")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: Theme.surfaceVariantText
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Connected content
|
||||
Item {
|
||||
visible: TailscaleService.available
|
||||
width: parent.width
|
||||
height: parent.height - (parent.visibleChildren[0] === this ? 0 : y)
|
||||
clip: true
|
||||
|
||||
Column {
|
||||
id: headerColumn
|
||||
width: parent.width
|
||||
spacing: Theme.spacingS
|
||||
|
||||
// Search bar + refresh button
|
||||
RowLayout {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingS
|
||||
|
||||
DankTextField {
|
||||
Layout.fillWidth: true
|
||||
placeholderText: I18n.tr("Search devices...", "Tailscale device search placeholder")
|
||||
leftIconName: "search"
|
||||
showClearButton: true
|
||||
text: detailRoot.searchQuery
|
||||
onTextEdited: detailRoot.searchQuery = text
|
||||
}
|
||||
|
||||
DankActionButton {
|
||||
iconName: "sync"
|
||||
buttonSize: 28
|
||||
iconSize: 16
|
||||
iconColor: Theme.surfaceVariantText
|
||||
tooltipText: I18n.tr("Refresh", "Refresh Tailscale device status")
|
||||
onClicked: TailscaleService.refresh(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter chips
|
||||
DankFilterChips {
|
||||
width: parent.width
|
||||
currentIndex: detailRoot.filterIndex
|
||||
showCounts: true
|
||||
chipHeight: 26
|
||||
model: [
|
||||
{
|
||||
"label": I18n.tr("My Online", "Tailscale filter: my online devices"),
|
||||
"count": TailscaleService.myOnlinePeers.length
|
||||
},
|
||||
{
|
||||
"label": I18n.tr("Online", "Tailscale filter: all online devices"),
|
||||
"count": TailscaleService.onlinePeers.length
|
||||
},
|
||||
{
|
||||
"label": I18n.tr("All", "Tailscale filter: all devices"),
|
||||
"count": TailscaleService.allPeersList.length
|
||||
}
|
||||
]
|
||||
onSelectionChanged: index => {
|
||||
detailRoot.filterIndex = index;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scrollable peer list — fills remaining space below header
|
||||
DankFlickable {
|
||||
anchors.top: headerColumn.bottom
|
||||
anchors.topMargin: Theme.spacingS
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
contentHeight: peerListColumn.implicitHeight
|
||||
clip: true
|
||||
|
||||
Column {
|
||||
id: peerListColumn
|
||||
width: parent.width
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
property var filteredPeers: {
|
||||
let base;
|
||||
switch (detailRoot.filterIndex) {
|
||||
case 0:
|
||||
base = TailscaleService.myOnlinePeers;
|
||||
break;
|
||||
case 1:
|
||||
base = TailscaleService.onlinePeers;
|
||||
break;
|
||||
case 2:
|
||||
base = TailscaleService.allPeersList;
|
||||
break;
|
||||
default:
|
||||
base = [];
|
||||
}
|
||||
if (detailRoot.searchQuery.length > 0)
|
||||
return TailscaleService.searchPeers(detailRoot.searchQuery, base);
|
||||
return base;
|
||||
}
|
||||
|
||||
// Empty state
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 60
|
||||
visible: peerListColumn.filteredPeers.length === 0
|
||||
|
||||
Column {
|
||||
anchors.centerIn: parent
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
DankIcon {
|
||||
name: "devices"
|
||||
size: 28
|
||||
color: Theme.surfaceVariantText
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: detailRoot.searchQuery.length > 0 ? I18n.tr("No matching devices", "No Tailscale devices match search") : I18n.tr("No peers found", "No Tailscale peers found")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Peer cards
|
||||
Repeater {
|
||||
model: peerListColumn.filteredPeers
|
||||
|
||||
delegate: Rectangle {
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
width: peerListColumn.width
|
||||
height: peerCardColumn.implicitHeight + Theme.spacingS * 2
|
||||
radius: Theme.cornerRadius
|
||||
color: modelData.hostname === (TailscaleService.selfNode ? TailscaleService.selfNode.hostname : "") ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : Theme.surfaceContainerHighest
|
||||
|
||||
property bool isSelf: modelData.hostname === (TailscaleService.selfNode ? TailscaleService.selfNode.hostname : "")
|
||||
property bool isExpanded: detailRoot.expandedHostname === modelData.hostname
|
||||
|
||||
Column {
|
||||
id: peerCardColumn
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.margins: Theme.spacingS
|
||||
spacing: 2
|
||||
|
||||
RowLayout {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingS
|
||||
|
||||
Rectangle {
|
||||
width: 8
|
||||
height: 8
|
||||
radius: 4
|
||||
color: modelData.online ? "#4caf50" : Theme.surfaceVariantText
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: modelData.hostname || ""
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Bold
|
||||
color: Theme.surfaceText
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
StyledText {
|
||||
visible: isSelf
|
||||
text: I18n.tr("This device", "Label for the user's own device in Tailscale")
|
||||
font.pixelSize: 10
|
||||
color: Theme.primary
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
StyledText {
|
||||
text: modelData.tailscaleIp || ""
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceTextMedium
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
DankActionButton {
|
||||
iconName: "content_copy"
|
||||
buttonSize: 20
|
||||
iconSize: 11
|
||||
iconColor: Theme.surfaceVariantText
|
||||
tooltipText: I18n.tr("Copy", "Copy to clipboard")
|
||||
onClicked: Quickshell.execDetached(["dms", "cl", "copy", modelData.tailscaleIp])
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: {
|
||||
const parts = [];
|
||||
if (modelData.os)
|
||||
parts.push(modelData.os);
|
||||
if (modelData.online) {
|
||||
parts.push(modelData.relay ? I18n.tr("relay: %1", "Tailscale relay server name").arg(modelData.relay) : I18n.tr("direct", "Tailscale direct connection"));
|
||||
} else if (modelData.lastSeen) {
|
||||
parts.push(I18n.tr("last seen %1", "Tailscale peer last seen time").arg(modelData.lastSeen));
|
||||
}
|
||||
return parts.join(" \u2022 ");
|
||||
}
|
||||
font.pixelSize: 10
|
||||
color: Theme.surfaceVariantText
|
||||
width: parent.width
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
// Expanded: DNS name + copy, tags, owner
|
||||
Column {
|
||||
visible: isExpanded
|
||||
width: parent.width
|
||||
spacing: 2
|
||||
topPadding: 4
|
||||
|
||||
RowLayout {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingXS
|
||||
visible: (modelData.dnsName || "").length > 0
|
||||
|
||||
StyledText {
|
||||
text: modelData.dnsName || ""
|
||||
font.pixelSize: 10
|
||||
color: Theme.surfaceVariantText
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
DankActionButton {
|
||||
iconName: "content_copy"
|
||||
buttonSize: 20
|
||||
iconSize: 11
|
||||
iconColor: Theme.surfaceVariantText
|
||||
onClicked: Quickshell.execDetached(["dms", "cl", "copy", modelData.dnsName])
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
visible: (modelData.tags || []).length > 0
|
||||
text: I18n.tr("Tags: %1", "Tailscale device tags").arg((modelData.tags || []).join(", "))
|
||||
font.pixelSize: 10
|
||||
color: Theme.surfaceVariantText
|
||||
}
|
||||
|
||||
StyledText {
|
||||
visible: (modelData.owner || "").length > 0
|
||||
text: I18n.tr("Owner: %1", "Tailscale device owner").arg(modelData.owner || "")
|
||||
font.pixelSize: 10
|
||||
color: Theme.surfaceVariantText
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
z: -1
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: detailRoot.expandedHostname = (detailRoot.expandedHostname === modelData.hostname) ? "" : modelData.hostname
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ Item {
|
||||
case section === "wifi":
|
||||
case section === "bluetooth":
|
||||
case section === "builtin_vpn":
|
||||
case section === "builtin_tailscale":
|
||||
return Math.min(350, maxAvailableHeight);
|
||||
case section.startsWith("brightnessSlider_"):
|
||||
return Math.min(400, maxAvailableHeight);
|
||||
@@ -37,7 +38,7 @@ Item {
|
||||
Loader {
|
||||
id: pluginDetailLoader
|
||||
width: parent.width
|
||||
height: parent.height - Theme.spacingS
|
||||
height: Math.max(0, parent.height - Theme.spacingS)
|
||||
y: Theme.spacingS
|
||||
active: false
|
||||
sourceComponent: null
|
||||
@@ -46,7 +47,7 @@ Item {
|
||||
Loader {
|
||||
id: coreDetailLoader
|
||||
width: parent.width
|
||||
height: parent.height - Theme.spacingS
|
||||
height: Math.max(0, parent.height - Theme.spacingS)
|
||||
y: Theme.spacingS
|
||||
active: false
|
||||
sourceComponent: null
|
||||
@@ -128,13 +129,19 @@ Item {
|
||||
}
|
||||
builtinInstance = widgetModel.cupsBuiltinInstance;
|
||||
}
|
||||
if (builtinId === "builtin_tailscale") {
|
||||
if (widgetModel?.tailscaleLoader) {
|
||||
widgetModel.tailscaleLoader.active = true;
|
||||
}
|
||||
builtinInstance = widgetModel.tailscaleBuiltinInstance;
|
||||
}
|
||||
|
||||
if (!builtinInstance || !builtinInstance.ccDetailContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
pluginDetailLoader.sourceComponent = builtinInstance.ccDetailContent;
|
||||
pluginDetailLoader.active = parent.height > 0;
|
||||
pluginDetailLoader.active = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -155,19 +162,19 @@ Item {
|
||||
}
|
||||
|
||||
pluginDetailLoader.sourceComponent = pluginDetailInstance.ccDetailContent;
|
||||
pluginDetailLoader.active = parent.height > 0;
|
||||
pluginDetailLoader.active = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (root.expandedSection.startsWith("diskUsage_")) {
|
||||
coreDetailLoader.sourceComponent = diskUsageDetailComponent;
|
||||
coreDetailLoader.active = parent.height > 0;
|
||||
coreDetailLoader.active = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (root.expandedSection.startsWith("brightnessSlider_")) {
|
||||
coreDetailLoader.sourceComponent = brightnessDetailComponent;
|
||||
coreDetailLoader.active = parent.height > 0;
|
||||
coreDetailLoader.active = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -195,7 +202,7 @@ Item {
|
||||
return;
|
||||
}
|
||||
|
||||
coreDetailLoader.active = parent.height > 0;
|
||||
coreDetailLoader.active = true;
|
||||
}
|
||||
|
||||
Component {
|
||||
|
||||
@@ -7,6 +7,7 @@ import "../utils/layout.js" as LayoutUtils
|
||||
|
||||
Column {
|
||||
id: root
|
||||
readonly property var log: Log.scoped("DragDropGrid")
|
||||
|
||||
property bool editMode: false
|
||||
property string expandedSection: ""
|
||||
@@ -51,6 +52,35 @@ Column {
|
||||
return Math.max(100, maxPopoutHeight - totalRowHeight - rowSpacing);
|
||||
}
|
||||
|
||||
readonly property real targetImplicitHeight: {
|
||||
const rows = layoutResult.rows;
|
||||
let totalHeight = 0;
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const widgets = rows[i] || [];
|
||||
const sliderOnly = widgets.length > 0 && widgets.every(w => {
|
||||
const id = w.id || "";
|
||||
return id === "volumeSlider" || id === "brightnessSlider" || id === "inputVolumeSlider";
|
||||
});
|
||||
totalHeight += sliderOnly ? (editMode ? 56 : 36) : 60;
|
||||
if (expandedSection !== "" && i === expandedRowIndex)
|
||||
totalHeight += detailHeightForSection(expandedSection) + Theme.spacingS;
|
||||
}
|
||||
totalHeight += Math.max(0, rows.length - 1) * spacing;
|
||||
return totalHeight;
|
||||
}
|
||||
|
||||
function detailHeightForSection(section) {
|
||||
if (!section)
|
||||
return 0;
|
||||
if (section === "wifi" || section === "bluetooth" || section === "builtin_vpn")
|
||||
return Math.min(350, _maxDetailHeight);
|
||||
if (section.startsWith("brightnessSlider_"))
|
||||
return Math.min(400, _maxDetailHeight);
|
||||
if (section.startsWith("plugin_"))
|
||||
return Math.min(250, _maxDetailHeight);
|
||||
return Math.min(250, _maxDetailHeight);
|
||||
}
|
||||
|
||||
function calculateRowsAndWidgets() {
|
||||
return LayoutUtils.calculateRowsAndWidgets(root, expandedSection, expandedWidgetIndex);
|
||||
}
|
||||
@@ -181,7 +211,10 @@ Column {
|
||||
id: detailHost
|
||||
width: parent.width
|
||||
maxAvailableHeight: root._maxDetailHeight
|
||||
height: active ? (getDetailHeight(root.expandedSection) + Theme.spacingS) : 0
|
||||
height: active ? (root.detailHeightForSection(root.expandedSection) + Theme.spacingS) : 0
|
||||
clip: true
|
||||
property string retainedSection: ""
|
||||
property var retainedWidgetData: null
|
||||
property bool active: {
|
||||
if (root.expandedSection === "")
|
||||
return false;
|
||||
@@ -198,14 +231,48 @@ Column {
|
||||
|
||||
return rowIndex === root.expandedRowIndex;
|
||||
}
|
||||
visible: active
|
||||
expandedSection: root.expandedSection
|
||||
expandedWidgetData: root.expandedWidgetData
|
||||
visible: active || height > 0.5
|
||||
expandedSection: active ? root.expandedSection : retainedSection
|
||||
expandedWidgetData: active ? root.expandedWidgetData : retainedWidgetData
|
||||
bluetoothCodecSelector: root.bluetoothCodecSelector
|
||||
widgetModel: root.model
|
||||
collapseCallback: root.requestCollapse
|
||||
screenName: root.screenName
|
||||
screenModel: root.screenModel
|
||||
|
||||
function retainActiveDetail() {
|
||||
if (!active || !root.expandedSection)
|
||||
return;
|
||||
retainedSection = root.expandedSection;
|
||||
retainedWidgetData = root.expandedWidgetData;
|
||||
}
|
||||
|
||||
onActiveChanged: retainActiveDetail()
|
||||
onHeightChanged: {
|
||||
if (!active && height <= 0.5) {
|
||||
retainedSection = "";
|
||||
retainedWidgetData = null;
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: root
|
||||
function onExpandedSectionChanged() {
|
||||
detailHost.retainActiveDetail();
|
||||
}
|
||||
function onExpandedWidgetDataChanged() {
|
||||
detailHost.retainActiveDetail();
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on height {
|
||||
enabled: true
|
||||
NumberAnimation {
|
||||
duration: Theme.variantDuration(Theme.popoutAnimationDuration, detailHost.active)
|
||||
easing.type: Easing.BezierSpline
|
||||
easing.bezierCurve: detailHost.active ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -851,6 +918,12 @@ Column {
|
||||
}
|
||||
builtinInstance = Qt.binding(() => root.model?.cupsBuiltinInstance);
|
||||
}
|
||||
if (id === "builtin_tailscale") {
|
||||
if (root.model?.tailscaleLoader) {
|
||||
root.model.tailscaleLoader.active = true;
|
||||
}
|
||||
builtinInstance = Qt.binding(() => root.model?.tailscaleBuiltinInstance);
|
||||
}
|
||||
}
|
||||
|
||||
sourceComponent: {
|
||||
@@ -988,7 +1061,7 @@ Column {
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("DragDropGrid: stale plugin component for", pluginId, "- reloading");
|
||||
log.warn("stale plugin component for", pluginId, "- reloading");
|
||||
PluginService.reloadPlugin(pluginId);
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -23,11 +23,15 @@ Item {
|
||||
signal toggleWidgetSize(int index)
|
||||
|
||||
width: {
|
||||
const widgetWidth = widgetData?.width || 50
|
||||
if (widgetWidth <= 25) return gridCellWidth
|
||||
else if (widgetWidth <= 50) return gridCellWidth * 2
|
||||
else if (widgetWidth <= 75) return gridCellWidth * 3
|
||||
else return gridCellWidth * 4
|
||||
const widgetWidth = widgetData?.width || 50;
|
||||
if (widgetWidth <= 25)
|
||||
return gridCellWidth;
|
||||
else if (widgetWidth <= 50)
|
||||
return gridCellWidth * 2;
|
||||
else if (widgetWidth <= 75)
|
||||
return gridCellWidth * 3;
|
||||
else
|
||||
return gridCellWidth * 4;
|
||||
}
|
||||
height: isSlider ? 16 : gridCellHeight
|
||||
|
||||
@@ -42,10 +46,14 @@ Item {
|
||||
z: dragArea.drag.active ? 10000 : 1
|
||||
|
||||
Behavior on border.width {
|
||||
NumberAnimation { duration: 150 }
|
||||
NumberAnimation {
|
||||
duration: 150
|
||||
}
|
||||
}
|
||||
Behavior on opacity {
|
||||
NumberAnimation { duration: 150 }
|
||||
NumberAnimation {
|
||||
duration: 150
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,14 +66,17 @@ Item {
|
||||
property int globalWidgetIndex: root.widgetIndex
|
||||
property int widgetWidth: root.widgetData?.width || 50
|
||||
|
||||
|
||||
MouseArea {
|
||||
id: editModeBlocker
|
||||
anchors.fill: parent
|
||||
enabled: root.editMode
|
||||
acceptedButtons: Qt.AllButtons
|
||||
onPressed: function(mouse) { mouse.accepted = true }
|
||||
onWheel: function(wheel) { wheel.accepted = true }
|
||||
onPressed: function (mouse) {
|
||||
mouse.accepted = true;
|
||||
}
|
||||
onWheel: function (wheel) {
|
||||
wheel.accepted = true;
|
||||
}
|
||||
z: 100
|
||||
}
|
||||
}
|
||||
@@ -79,19 +90,19 @@ Item {
|
||||
drag.axis: Drag.XAndYAxis
|
||||
drag.smoothed: true
|
||||
|
||||
onPressed: function(mouse) {
|
||||
onPressed: function (mouse) {
|
||||
if (editMode) {
|
||||
cursorShape = Qt.ClosedHandCursor
|
||||
cursorShape = Qt.ClosedHandCursor;
|
||||
if (root.gridLayout && root.gridLayout.moveToTop) {
|
||||
root.gridLayout.moveToTop(root)
|
||||
root.gridLayout.moveToTop(root);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onReleased: function(mouse) {
|
||||
onReleased: function (mouse) {
|
||||
if (editMode) {
|
||||
cursorShape = Qt.OpenHandCursor
|
||||
root.snapToGrid()
|
||||
cursorShape = Qt.OpenHandCursor;
|
||||
root.snapToGrid();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,9 +112,11 @@ Item {
|
||||
Drag.hotSpot.y: height / 2
|
||||
|
||||
function swapIndices(i, j) {
|
||||
if (i === j) return;
|
||||
if (i === j)
|
||||
return;
|
||||
const arr = SettingsData.controlCenterWidgets;
|
||||
if (!arr || i < 0 || j < 0 || i >= arr.length || j >= arr.length) return;
|
||||
if (!arr || i < 0 || j < 0 || i >= arr.length || j >= arr.length)
|
||||
return;
|
||||
|
||||
const copy = arr.slice();
|
||||
const tmp = copy[i];
|
||||
@@ -114,37 +127,41 @@ Item {
|
||||
}
|
||||
|
||||
function snapToGrid() {
|
||||
if (!editMode || !gridLayout) return
|
||||
if (!editMode || !gridLayout)
|
||||
return;
|
||||
const globalPos = root.mapToItem(gridLayout, 0, 0);
|
||||
const cellWidth = gridLayout.width / gridColumns;
|
||||
const cellHeight = gridCellHeight + Theme.spacingS;
|
||||
|
||||
const globalPos = root.mapToItem(gridLayout, 0, 0)
|
||||
const cellWidth = gridLayout.width / gridColumns
|
||||
const cellHeight = gridCellHeight + Theme.spacingS
|
||||
const centerX = globalPos.x + (root.width / 2);
|
||||
const centerY = globalPos.y + (root.height / 2);
|
||||
|
||||
const centerX = globalPos.x + (root.width / 2)
|
||||
const centerY = globalPos.y + (root.height / 2)
|
||||
let targetCol = Math.max(0, Math.floor(centerX / cellWidth));
|
||||
let targetRow = Math.max(0, Math.floor(centerY / cellHeight));
|
||||
|
||||
let targetCol = Math.max(0, Math.floor(centerX / cellWidth))
|
||||
let targetRow = Math.max(0, Math.floor(centerY / cellHeight))
|
||||
targetCol = Math.min(targetCol, gridColumns - 1);
|
||||
|
||||
targetCol = Math.min(targetCol, gridColumns - 1)
|
||||
|
||||
const newIndex = findBestInsertionIndex(targetRow, targetCol)
|
||||
const newIndex = findBestInsertionIndex(targetRow, targetCol);
|
||||
|
||||
if (newIndex !== widgetIndex && newIndex >= 0 && newIndex < (SettingsData.controlCenterWidgets?.length || 0)) {
|
||||
swapIndices(widgetIndex, newIndex)
|
||||
swapIndices(widgetIndex, newIndex);
|
||||
}
|
||||
}
|
||||
|
||||
function findBestInsertionIndex(targetRow, targetCol) {
|
||||
const widgets = SettingsData.controlCenterWidgets || [];
|
||||
const n = widgets.length;
|
||||
if (!n || widgetIndex < 0 || widgetIndex >= n) return -1;
|
||||
if (!n || widgetIndex < 0 || widgetIndex >= n)
|
||||
return -1;
|
||||
|
||||
function spanFor(width) {
|
||||
const w = width ?? 50;
|
||||
if (w <= 25) return 1;
|
||||
if (w <= 50) return 2;
|
||||
if (w <= 75) return 3;
|
||||
if (w <= 25)
|
||||
return 1;
|
||||
if (w <= 50)
|
||||
return 2;
|
||||
if (w <= 75)
|
||||
return 3;
|
||||
return 4;
|
||||
}
|
||||
|
||||
@@ -169,7 +186,13 @@ Item {
|
||||
if (i === widgetIndex) {
|
||||
draggedOrigKey = centerKey;
|
||||
} else {
|
||||
pos.push({ index: i, row, startCol, span, centerKey });
|
||||
pos.push({
|
||||
index: i,
|
||||
row,
|
||||
startCol,
|
||||
span,
|
||||
centerKey
|
||||
});
|
||||
}
|
||||
|
||||
col += span;
|
||||
@@ -179,7 +202,8 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
if (pos.length === 0) return -1;
|
||||
if (pos.length === 0)
|
||||
return -1;
|
||||
|
||||
const centerColCoord = targetCol + 0.5;
|
||||
const targetKey = targetRow * cols + centerColCoord;
|
||||
@@ -192,15 +216,20 @@ Item {
|
||||
}
|
||||
|
||||
let lo = 0, hi = pos.length - 1;
|
||||
if (targetKey <= pos[0].centerKey) return pos[0].index;
|
||||
if (targetKey >= pos[hi].centerKey) return pos[hi].index;
|
||||
if (targetKey <= pos[0].centerKey)
|
||||
return pos[0].index;
|
||||
if (targetKey >= pos[hi].centerKey)
|
||||
return pos[hi].index;
|
||||
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
const mk = pos[mid].centerKey;
|
||||
if (targetKey < mk) hi = mid - 1;
|
||||
else if (targetKey > mk) lo = mid + 1;
|
||||
else return pos[mid].index;
|
||||
if (targetKey < mk)
|
||||
hi = mid - 1;
|
||||
else if (targetKey > mk)
|
||||
lo = mid + 1;
|
||||
else
|
||||
return pos[mid].index;
|
||||
}
|
||||
const movingUp = (draggedOrigKey != null) ? (targetKey < draggedOrigKey) : false;
|
||||
return (movingUp ? pos[lo].index : pos[hi].index);
|
||||
@@ -240,11 +269,11 @@ Item {
|
||||
currentSize: root.widgetData?.width || 50
|
||||
isSlider: root.isSlider
|
||||
widgetIndex: root.widgetIndex
|
||||
onSizeChanged: (newSize) => {
|
||||
var widgets = SettingsData.controlCenterWidgets.slice()
|
||||
onSizeChanged: newSize => {
|
||||
var widgets = SettingsData.controlCenterWidgets.slice();
|
||||
if (widgetIndex >= 0 && widgetIndex < widgets.length) {
|
||||
widgets[widgetIndex].width = newSize
|
||||
SettingsData.set("controlCenterWidgets", widgets)
|
||||
widgets[widgetIndex].width = newSize;
|
||||
SettingsData.set("controlCenterWidgets", widgets);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -270,7 +299,9 @@ Item {
|
||||
}
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation { duration: 150 }
|
||||
NumberAnimation {
|
||||
duration: 150
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,7 +314,9 @@ Item {
|
||||
z: -1
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation { duration: Theme.shortDuration }
|
||||
ColorAnimation {
|
||||
duration: Theme.shortDuration
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,19 +20,53 @@ DankPopout {
|
||||
property int expandedWidgetIndex: -1
|
||||
property var expandedWidgetData: null
|
||||
property bool powerMenuOpen: powerMenuModalLoader?.item?.shouldBeVisible ?? false
|
||||
property real targetPopupHeight: 400
|
||||
property bool _heightUpdatePending: false
|
||||
|
||||
signal lockRequested
|
||||
|
||||
function _maxPopupHeight() {
|
||||
const screenHeight = (triggerScreen?.height ?? 1080);
|
||||
return screenHeight - 100;
|
||||
}
|
||||
|
||||
function _contentTargetHeight() {
|
||||
const item = contentLoader.item;
|
||||
if (!item)
|
||||
return 400;
|
||||
const naturalHeight = item.targetImplicitHeight !== undefined ? item.targetImplicitHeight : item.implicitHeight;
|
||||
return Math.max(300, naturalHeight + 20);
|
||||
}
|
||||
|
||||
function updateTargetPopupHeight() {
|
||||
const target = Math.min(_maxPopupHeight(), _contentTargetHeight());
|
||||
if (Math.abs(targetPopupHeight - target) < 0.5)
|
||||
return;
|
||||
targetPopupHeight = target;
|
||||
}
|
||||
|
||||
function queueTargetPopupHeightUpdate() {
|
||||
if (_heightUpdatePending)
|
||||
return;
|
||||
_heightUpdatePending = true;
|
||||
Qt.callLater(() => {
|
||||
_heightUpdatePending = false;
|
||||
updateTargetPopupHeight();
|
||||
});
|
||||
}
|
||||
|
||||
function collapseAll() {
|
||||
expandedSection = "";
|
||||
expandedWidgetIndex = -1;
|
||||
expandedWidgetData = null;
|
||||
queueTargetPopupHeightUpdate();
|
||||
}
|
||||
|
||||
onEditModeChanged: {
|
||||
if (editMode) {
|
||||
collapseAll();
|
||||
}
|
||||
queueTargetPopupHeightUpdate();
|
||||
}
|
||||
|
||||
onVisibleChanged: {
|
||||
@@ -52,12 +86,7 @@ DankPopout {
|
||||
}
|
||||
|
||||
popupWidth: 550
|
||||
popupHeight: {
|
||||
const screenHeight = (triggerScreen?.height ?? 1080);
|
||||
const maxHeight = screenHeight - 100;
|
||||
const contentHeight = contentLoader.item && contentLoader.item.implicitHeight > 0 ? contentLoader.item.implicitHeight + 20 : 400;
|
||||
return Math.min(maxHeight, contentHeight);
|
||||
}
|
||||
popupHeight: targetPopupHeight
|
||||
triggerWidth: 80
|
||||
positioning: ""
|
||||
screen: triggerScreen
|
||||
@@ -95,6 +124,7 @@ DankPopout {
|
||||
onShouldBeVisibleChanged: {
|
||||
if (shouldBeVisible) {
|
||||
collapseAll();
|
||||
queueTargetPopupHeightUpdate();
|
||||
Qt.callLater(() => {
|
||||
if (NetworkService.activeService)
|
||||
NetworkService.activeService.autoRefreshEnabled = NetworkService.wifiEnabled;
|
||||
@@ -111,6 +141,28 @@ DankPopout {
|
||||
}
|
||||
}
|
||||
|
||||
onExpandedSectionChanged: queueTargetPopupHeightUpdate()
|
||||
onExpandedWidgetIndexChanged: queueTargetPopupHeightUpdate()
|
||||
onTriggerScreenChanged: queueTargetPopupHeightUpdate()
|
||||
|
||||
Connections {
|
||||
target: contentLoader
|
||||
function onLoaded() {
|
||||
root.queueTargetPopupHeightUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: contentLoader.item
|
||||
ignoreUnknownSignals: true
|
||||
function onTargetImplicitHeightChanged() {
|
||||
root.queueTargetPopupHeightUpdate();
|
||||
}
|
||||
function onImplicitHeightChanged() {
|
||||
root.queueTargetPopupHeightUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
WidgetModel {
|
||||
id: widgetModel
|
||||
}
|
||||
@@ -122,7 +174,13 @@ DankPopout {
|
||||
LayoutMirroring.enabled: I18n.isRtl
|
||||
LayoutMirroring.childrenInherit: true
|
||||
|
||||
implicitHeight: mainColumn.implicitHeight + Theme.spacingM
|
||||
readonly property real targetImplicitHeight: {
|
||||
let total = headerPane.implicitHeight + Theme.spacingS + widgetGrid.targetImplicitHeight;
|
||||
if (editControls.visible)
|
||||
total += Theme.spacingS + editControls.height;
|
||||
return total + Theme.spacingM;
|
||||
}
|
||||
implicitHeight: targetImplicitHeight
|
||||
property alias bluetoothCodecSelector: bluetoothCodecSelector
|
||||
|
||||
color: "transparent"
|
||||
@@ -136,95 +194,107 @@ DankPopout {
|
||||
z: 5000
|
||||
|
||||
Behavior on opacity {
|
||||
enabled: !Theme.isDirectionalEffect
|
||||
NumberAnimation {
|
||||
duration: 200
|
||||
easing.type: Easing.OutCubic
|
||||
duration: Theme.shortDuration
|
||||
easing.type: Easing.BezierSpline
|
||||
easing.bezierCurve: root.shouldBeVisible ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
id: mainColumn
|
||||
width: parent.width - Theme.spacingL * 2
|
||||
x: Theme.spacingL
|
||||
y: Theme.spacingL
|
||||
spacing: Theme.spacingS
|
||||
DankFlickable {
|
||||
id: contentFlickable
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: Math.max(height, mainColumn.implicitHeight + Theme.spacingM)
|
||||
interactive: contentHeight > height
|
||||
|
||||
HeaderPane {
|
||||
id: headerPane
|
||||
width: parent.width
|
||||
editMode: root.editMode
|
||||
onEditModeToggled: root.editMode = !root.editMode
|
||||
onPowerButtonClicked: {
|
||||
if (powerMenuModalLoader) {
|
||||
powerMenuModalLoader.active = true;
|
||||
if (powerMenuModalLoader.item) {
|
||||
const bounds = Qt.rect(root.alignedX, root.alignedY, root.popupWidth, root.popupHeight);
|
||||
powerMenuModalLoader.item.openFromControlCenter(bounds, root.screen);
|
||||
Column {
|
||||
id: mainColumn
|
||||
width: contentFlickable.width - Theme.spacingL * 2
|
||||
x: Theme.spacingL
|
||||
y: Theme.spacingL
|
||||
spacing: Theme.spacingS
|
||||
|
||||
HeaderPane {
|
||||
id: headerPane
|
||||
width: parent.width
|
||||
editMode: root.editMode
|
||||
onEditModeToggled: root.editMode = !root.editMode
|
||||
onPowerButtonClicked: {
|
||||
if (powerMenuModalLoader) {
|
||||
powerMenuModalLoader.active = true;
|
||||
if (powerMenuModalLoader.item) {
|
||||
const bounds = Qt.rect(root.alignedX, root.alignedY, root.popupWidth, root.popupHeight);
|
||||
powerMenuModalLoader.item.openFromControlCenter(bounds, root.screen);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
onLockRequested: {
|
||||
root.close();
|
||||
root.lockRequested();
|
||||
}
|
||||
onSettingsButtonClicked: {
|
||||
root.close();
|
||||
}
|
||||
}
|
||||
|
||||
DragDropGrid {
|
||||
id: widgetGrid
|
||||
width: parent.width
|
||||
editMode: root.editMode
|
||||
maxPopoutHeight: {
|
||||
const screenHeight = (root.triggerScreen?.height ?? 1080);
|
||||
return screenHeight - 100 - Theme.spacingL - headerPane.height - Theme.spacingS;
|
||||
}
|
||||
expandedSection: root.expandedSection
|
||||
expandedWidgetIndex: root.expandedWidgetIndex
|
||||
expandedWidgetData: root.expandedWidgetData
|
||||
model: widgetModel
|
||||
bluetoothCodecSelector: bluetoothCodecSelector
|
||||
colorPickerModal: root.colorPickerModal
|
||||
screenName: root.triggerScreen?.name || ""
|
||||
screenModel: root.triggerScreen?.model || ""
|
||||
parentScreen: root.triggerScreen
|
||||
onExpandClicked: (widgetData, globalIndex) => {
|
||||
root.expandedWidgetIndex = globalIndex;
|
||||
root.expandedWidgetData = widgetData;
|
||||
if (widgetData.id === "diskUsage") {
|
||||
root.toggleSection("diskUsage_" + (widgetData.instanceId || "default"));
|
||||
} else if (widgetData.id === "brightnessSlider") {
|
||||
root.toggleSection("brightnessSlider_" + (widgetData.instanceId || "default"));
|
||||
} else {
|
||||
root.toggleSection(widgetData.id);
|
||||
onLockRequested: {
|
||||
root.close();
|
||||
root.lockRequested();
|
||||
}
|
||||
onSettingsButtonClicked: {
|
||||
root.close();
|
||||
}
|
||||
}
|
||||
onRemoveWidget: index => widgetModel.removeWidget(index)
|
||||
onMoveWidget: (fromIndex, toIndex) => widgetModel.moveWidget(fromIndex, toIndex)
|
||||
onToggleWidgetSize: index => widgetModel.toggleWidgetSize(index)
|
||||
onCollapseRequested: root.collapseAll()
|
||||
}
|
||||
|
||||
EditControls {
|
||||
width: parent.width
|
||||
visible: editMode
|
||||
popupScreen: root.screen
|
||||
popoutX: root.alignedX
|
||||
popoutY: root.alignedY
|
||||
popoutWidth: root.alignedWidth
|
||||
popoutHeight: root.alignedHeight
|
||||
availableWidgets: {
|
||||
if (!editMode)
|
||||
return [];
|
||||
const existingIds = (SettingsData.controlCenterWidgets || []).map(w => w.id);
|
||||
const allWidgets = widgetModel.baseWidgetDefinitions.concat(widgetModel.getPluginWidgets());
|
||||
return allWidgets.filter(w => w.allowMultiple || !existingIds.includes(w.id));
|
||||
DragDropGrid {
|
||||
id: widgetGrid
|
||||
width: parent.width
|
||||
editMode: root.editMode
|
||||
maxPopoutHeight: {
|
||||
const screenHeight = (root.triggerScreen?.height ?? 1080);
|
||||
return screenHeight - 100 - Theme.spacingL - headerPane.implicitHeight - Theme.spacingS;
|
||||
}
|
||||
expandedSection: root.expandedSection
|
||||
expandedWidgetIndex: root.expandedWidgetIndex
|
||||
expandedWidgetData: root.expandedWidgetData
|
||||
model: widgetModel
|
||||
bluetoothCodecSelector: bluetoothCodecSelector
|
||||
colorPickerModal: root.colorPickerModal
|
||||
screenName: root.triggerScreen?.name || ""
|
||||
screenModel: root.triggerScreen?.model || ""
|
||||
parentScreen: root.triggerScreen
|
||||
onExpandClicked: (widgetData, globalIndex) => {
|
||||
root.expandedWidgetIndex = globalIndex;
|
||||
root.expandedWidgetData = widgetData;
|
||||
if (widgetData.id === "diskUsage") {
|
||||
root.toggleSection("diskUsage_" + (widgetData.instanceId || "default"));
|
||||
} else if (widgetData.id === "brightnessSlider") {
|
||||
root.toggleSection("brightnessSlider_" + (widgetData.instanceId || "default"));
|
||||
} else {
|
||||
root.toggleSection(widgetData.id);
|
||||
}
|
||||
}
|
||||
onRemoveWidget: index => widgetModel.removeWidget(index)
|
||||
onMoveWidget: (fromIndex, toIndex) => widgetModel.moveWidget(fromIndex, toIndex)
|
||||
onToggleWidgetSize: index => widgetModel.toggleWidgetSize(index)
|
||||
onCollapseRequested: root.collapseAll()
|
||||
}
|
||||
|
||||
EditControls {
|
||||
id: editControls
|
||||
width: parent.width
|
||||
visible: editMode
|
||||
popupScreen: root.screen
|
||||
popoutX: root.alignedX
|
||||
popoutY: root.alignedY
|
||||
popoutWidth: root.alignedWidth
|
||||
popoutHeight: root.alignedHeight
|
||||
availableWidgets: {
|
||||
if (!editMode)
|
||||
return [];
|
||||
const existingIds = (SettingsData.controlCenterWidgets || []).map(w => w.id);
|
||||
const allWidgets = widgetModel.baseWidgetDefinitions.concat(widgetModel.getPluginWidgets());
|
||||
return allWidgets.filter(w => w.allowMultiple || !existingIds.includes(w.id));
|
||||
}
|
||||
onAddWidget: widgetId => widgetModel.addWidget(widgetId)
|
||||
onResetToDefault: () => widgetModel.resetToDefault()
|
||||
onClearAll: () => widgetModel.clearAll()
|
||||
}
|
||||
onAddWidget: widgetId => widgetModel.addWidget(widgetId)
|
||||
onResetToDefault: () => widgetModel.resetToDefault()
|
||||
onClearAll: () => widgetModel.clearAll()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -449,7 +449,7 @@ Rectangle {
|
||||
size: 24
|
||||
color: Qt.rgba(Theme.surfaceText.r || 0.8, Theme.surfaceText.g || 0.8, Theme.surfaceText.b || 0.8, 0.4)
|
||||
|
||||
RotationAnimation on rotation {
|
||||
RotationAnimator on rotation {
|
||||
running: parent.visible
|
||||
loops: Animation.Infinite
|
||||
from: 0
|
||||
|
||||
@@ -178,7 +178,7 @@ Rectangle {
|
||||
size: 32
|
||||
color: Theme.primary
|
||||
|
||||
RotationAnimation on rotation {
|
||||
RotationAnimator on rotation {
|
||||
running: NetworkService.wifiToggling
|
||||
loops: Animation.Infinite
|
||||
from: 0
|
||||
@@ -494,7 +494,7 @@ Rectangle {
|
||||
size: 48
|
||||
color: Qt.rgba(Theme.surfaceText.r || 0.8, Theme.surfaceText.g || 0.8, Theme.surfaceText.b || 0.8, 0.3)
|
||||
|
||||
RotationAnimation on rotation {
|
||||
RotationAnimator on rotation {
|
||||
running: wifiScanningOverlay.visible
|
||||
loops: Animation.Infinite
|
||||
from: 0
|
||||
|
||||
@@ -6,9 +6,11 @@ import "../utils/widgets.js" as WidgetUtils
|
||||
|
||||
QtObject {
|
||||
id: root
|
||||
readonly property var log: Log.scoped("WidgetModel")
|
||||
|
||||
property var vpnBuiltinInstance: null
|
||||
property var cupsBuiltinInstance: null
|
||||
property var tailscaleBuiltinInstance: null
|
||||
|
||||
property var vpnLoader: Loader {
|
||||
active: false
|
||||
@@ -26,7 +28,7 @@ QtObject {
|
||||
const widgets = SettingsData.controlCenterWidgets || [];
|
||||
const hasVpnWidget = widgets.some(w => w.id === "builtin_vpn");
|
||||
if (!hasVpnWidget && vpnLoader.active) {
|
||||
console.log("VpnWidget: No VPN widget in control center, deactivating loader");
|
||||
log.debug("VpnWidget: No VPN widget in control center, deactivating loader");
|
||||
vpnLoader.active = false;
|
||||
}
|
||||
}
|
||||
@@ -55,13 +57,42 @@ QtObject {
|
||||
const widgets = SettingsData.controlCenterWidgets || [];
|
||||
const hasCupsWidget = widgets.some(w => w.id === "builtin_cups");
|
||||
if (!hasCupsWidget && cupsLoader.active) {
|
||||
console.log("CupsWidget: No CUPS widget in control center, deactivating loader");
|
||||
log.debug("CupsWidget: No CUPS widget in control center, deactivating loader");
|
||||
cupsLoader.active = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
property var tailscaleLoader: Loader {
|
||||
active: false
|
||||
sourceComponent: Component {
|
||||
TailscaleWidget {}
|
||||
}
|
||||
|
||||
onItemChanged: {
|
||||
root.tailscaleBuiltinInstance = item;
|
||||
}
|
||||
|
||||
onActiveChanged: {
|
||||
if (!active) {
|
||||
root.tailscaleBuiltinInstance = null;
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: SettingsData
|
||||
function onControlCenterWidgetsChanged() {
|
||||
const widgets = SettingsData.controlCenterWidgets || [];
|
||||
const hasTailscaleWidget = widgets.some(w => w.id === "builtin_tailscale");
|
||||
if (!hasTailscaleWidget && tailscaleLoader.active) {
|
||||
root.log.debug("No Tailscale widget in control center, deactivating loader");
|
||||
tailscaleLoader.active = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
readonly property var coreWidgetDefinitions: [
|
||||
{
|
||||
"id": "nightMode",
|
||||
@@ -201,6 +232,16 @@ QtObject {
|
||||
"enabled": CupsService.available,
|
||||
"warning": !CupsService.available ? I18n.tr("CUPS not available") : undefined,
|
||||
"isBuiltinPlugin": true
|
||||
},
|
||||
{
|
||||
"id": "builtin_tailscale",
|
||||
"text": I18n.tr("Tailscale", "Tailscale mesh VPN widget title"),
|
||||
"description": I18n.tr("Tailscale Network", "Tailscale control center widget description"),
|
||||
"icon": "device_hub",
|
||||
"type": "builtin_plugin",
|
||||
"enabled": TailscaleService.available,
|
||||
"warning": !TailscaleService.available ? I18n.tr("Tailscale not available", "Warning when Tailscale service is not running") : undefined,
|
||||
"isBuiltinPlugin": true
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Modules.ControlCenter.Widgets
|
||||
import qs.Services
|
||||
|
||||
CompoundPill {
|
||||
id: root
|
||||
readonly property var log: Log.scoped("ColorPickerPill")
|
||||
|
||||
property var colorPickerModal: null
|
||||
|
||||
@@ -14,14 +16,14 @@ CompoundPill {
|
||||
secondaryText: I18n.tr("Choose a color")
|
||||
|
||||
onToggled: {
|
||||
console.log("ColorPickerPill toggled, modal:", colorPickerModal);
|
||||
log.debug("ColorPickerPill toggled, modal:", colorPickerModal);
|
||||
if (colorPickerModal) {
|
||||
colorPickerModal.show();
|
||||
}
|
||||
}
|
||||
|
||||
onExpandClicked: {
|
||||
console.log("ColorPickerPill expandClicked, modal:", colorPickerModal);
|
||||
log.debug("ColorPickerPill expandClicked, modal:", colorPickerModal);
|
||||
if (colorPickerModal) {
|
||||
colorPickerModal.show();
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ Item {
|
||||
required property var axis
|
||||
required property var barConfig
|
||||
|
||||
visible: !SettingsData.frameEnabled
|
||||
|
||||
anchors.fill: parent
|
||||
|
||||
anchors.left: parent.left
|
||||
@@ -37,6 +39,8 @@ Item {
|
||||
}
|
||||
|
||||
property real rt: {
|
||||
if (SettingsData.frameEnabled)
|
||||
return SettingsData.frameRounding;
|
||||
if (barConfig?.squareCorners ?? false)
|
||||
return 0;
|
||||
if (barWindow.hasMaximizedToplevel)
|
||||
@@ -255,11 +259,12 @@ Item {
|
||||
h = h - wing;
|
||||
const r = wing;
|
||||
const cr = rt;
|
||||
const crE = SettingsData.frameEnabled ? 0 : cr;
|
||||
|
||||
let d = `M ${cr} 0`;
|
||||
d += ` L ${w - cr} 0`;
|
||||
if (cr > 0)
|
||||
d += ` A ${cr} ${cr} 0 0 1 ${w} ${cr}`;
|
||||
let d = `M ${crE} 0`;
|
||||
d += ` L ${w - crE} 0`;
|
||||
if (crE > 0)
|
||||
d += ` A ${crE} ${crE} 0 0 1 ${w} ${crE}`;
|
||||
if (r > 0) {
|
||||
d += ` L ${w} ${h + r}`;
|
||||
d += ` A ${r} ${r} 0 0 0 ${w - r} ${h}`;
|
||||
@@ -273,9 +278,9 @@ Item {
|
||||
if (cr > 0)
|
||||
d += ` A ${cr} ${cr} 0 0 1 0 ${h - cr}`;
|
||||
}
|
||||
d += ` L 0 ${cr}`;
|
||||
if (cr > 0)
|
||||
d += ` A ${cr} ${cr} 0 0 1 ${cr} 0`;
|
||||
d += ` L 0 ${crE}`;
|
||||
if (crE > 0)
|
||||
d += ` A ${crE} ${crE} 0 0 1 ${crE} 0`;
|
||||
d += " Z";
|
||||
return d;
|
||||
}
|
||||
@@ -285,11 +290,12 @@ Item {
|
||||
h = h - wing;
|
||||
const r = wing;
|
||||
const cr = rt;
|
||||
const crE = SettingsData.frameEnabled ? 0 : cr;
|
||||
|
||||
let d = `M ${cr} ${fullH}`;
|
||||
d += ` L ${w - cr} ${fullH}`;
|
||||
if (cr > 0)
|
||||
d += ` A ${cr} ${cr} 0 0 0 ${w} ${fullH - cr}`;
|
||||
let d = `M ${crE} ${fullH}`;
|
||||
d += ` L ${w - crE} ${fullH}`;
|
||||
if (crE > 0)
|
||||
d += ` A ${crE} ${crE} 0 0 0 ${w} ${fullH - crE}`;
|
||||
if (r > 0) {
|
||||
d += ` L ${w} 0`;
|
||||
d += ` A ${r} ${r} 0 0 1 ${w - r} ${r}`;
|
||||
@@ -303,9 +309,9 @@ Item {
|
||||
if (cr > 0)
|
||||
d += ` A ${cr} ${cr} 0 0 0 0 ${cr}`;
|
||||
}
|
||||
d += ` L 0 ${fullH - cr}`;
|
||||
if (cr > 0)
|
||||
d += ` A ${cr} ${cr} 0 0 0 ${cr} ${fullH}`;
|
||||
d += ` L 0 ${fullH - crE}`;
|
||||
if (crE > 0)
|
||||
d += ` A ${crE} ${crE} 0 0 0 ${crE} ${fullH}`;
|
||||
d += " Z";
|
||||
return d;
|
||||
}
|
||||
@@ -314,11 +320,12 @@ Item {
|
||||
w = w - wing;
|
||||
const r = wing;
|
||||
const cr = rt;
|
||||
const crE = SettingsData.frameEnabled ? 0 : cr;
|
||||
|
||||
let d = `M 0 ${cr}`;
|
||||
d += ` L 0 ${h - cr}`;
|
||||
if (cr > 0)
|
||||
d += ` A ${cr} ${cr} 0 0 0 ${cr} ${h}`;
|
||||
let d = `M 0 ${crE}`;
|
||||
d += ` L 0 ${h - crE}`;
|
||||
if (crE > 0)
|
||||
d += ` A ${crE} ${crE} 0 0 0 ${crE} ${h}`;
|
||||
if (r > 0) {
|
||||
d += ` L ${w + r} ${h}`;
|
||||
d += ` A ${r} ${r} 0 0 1 ${w} ${h - r}`;
|
||||
@@ -332,9 +339,9 @@ Item {
|
||||
if (cr > 0)
|
||||
d += ` A ${cr} ${cr} 0 0 0 ${w - cr} 0`;
|
||||
}
|
||||
d += ` L ${cr} 0`;
|
||||
if (cr > 0)
|
||||
d += ` A ${cr} ${cr} 0 0 0 0 ${cr}`;
|
||||
d += ` L ${crE} 0`;
|
||||
if (crE > 0)
|
||||
d += ` A ${crE} ${crE} 0 0 0 0 ${crE}`;
|
||||
d += " Z";
|
||||
return d;
|
||||
}
|
||||
@@ -344,11 +351,12 @@ Item {
|
||||
w = w - wing;
|
||||
const r = wing;
|
||||
const cr = rt;
|
||||
const crE = SettingsData.frameEnabled ? 0 : cr;
|
||||
|
||||
let d = `M ${fullW} ${cr}`;
|
||||
d += ` L ${fullW} ${h - cr}`;
|
||||
if (cr > 0)
|
||||
d += ` A ${cr} ${cr} 0 0 1 ${fullW - cr} ${h}`;
|
||||
let d = `M ${fullW} ${crE}`;
|
||||
d += ` L ${fullW} ${h - crE}`;
|
||||
if (crE > 0)
|
||||
d += ` A ${crE} ${crE} 0 0 1 ${fullW - crE} ${h}`;
|
||||
if (r > 0) {
|
||||
d += ` L 0 ${h}`;
|
||||
d += ` A ${r} ${r} 0 0 0 ${r} ${h - r}`;
|
||||
@@ -362,9 +370,9 @@ Item {
|
||||
if (cr > 0)
|
||||
d += ` A ${cr} ${cr} 0 0 1 ${cr} 0`;
|
||||
}
|
||||
d += ` L ${fullW - cr} 0`;
|
||||
if (cr > 0)
|
||||
d += ` A ${cr} ${cr} 0 0 1 ${fullW} ${cr}`;
|
||||
d += ` L ${fullW - crE} 0`;
|
||||
if (crE > 0)
|
||||
d += ` A ${crE} ${crE} 0 0 1 ${fullW} ${crE}`;
|
||||
d += " Z";
|
||||
return d;
|
||||
}
|
||||
|
||||
@@ -19,9 +19,53 @@ Item {
|
||||
property var leftWidgetsModel
|
||||
property var centerWidgetsModel
|
||||
property var rightWidgetsModel
|
||||
property bool _animateFrameInsets: false
|
||||
|
||||
readonly property real innerPadding: barConfig?.innerPadding ?? 4
|
||||
readonly property real outlineThickness: (barConfig?.widgetOutlineEnabled ?? false) ? (barConfig?.widgetOutlineThickness ?? 1) : 0
|
||||
readonly property real _edgeBaseMargin: Math.max(Theme.spacingXS, innerPadding * 0.8)
|
||||
readonly property real _frameEdgeFloorInset: SettingsData.frameEnabled ? Math.max(0, SettingsData.frameThickness - _edgeBaseMargin) : 0
|
||||
readonly property bool _hasBarWindow: barWindow !== undefined && barWindow !== null
|
||||
readonly property bool _barIsVertical: _hasBarWindow ? barWindow.isVertical : false
|
||||
readonly property string _barScreenName: _hasBarWindow ? (barWindow.screenName || "") : ""
|
||||
readonly property bool hasAdjacentTopBarLive: _hasBarWindow && barWindow.hasAdjacentTopBar
|
||||
readonly property bool hasAdjacentBottomBarLive: _hasBarWindow && barWindow.hasAdjacentBottomBar
|
||||
readonly property bool hasAdjacentLeftBarLive: _hasBarWindow && barWindow.hasAdjacentLeftBar
|
||||
readonly property bool hasAdjacentRightBarLive: _hasBarWindow && barWindow.hasAdjacentRightBar
|
||||
property bool _hadAdjacentTopBar: false
|
||||
property bool _hadAdjacentBottomBar: false
|
||||
property bool _hadAdjacentLeftBar: false
|
||||
property bool _hadAdjacentRightBar: false
|
||||
|
||||
onHasAdjacentTopBarLiveChanged: if (hasAdjacentTopBarLive) _hadAdjacentTopBar = true
|
||||
onHasAdjacentBottomBarLiveChanged: if (hasAdjacentBottomBarLive) _hadAdjacentBottomBar = true
|
||||
onHasAdjacentLeftBarLiveChanged: if (hasAdjacentLeftBarLive) _hadAdjacentLeftBar = true
|
||||
onHasAdjacentRightBarLiveChanged: if (hasAdjacentRightBarLive) _hadAdjacentRightBar = true
|
||||
|
||||
readonly property real _frameLeftInset: {
|
||||
if (!_hasBarWindow || !SettingsData.frameEnabled || _barIsVertical) return 0
|
||||
return hasAdjacentLeftBarLive
|
||||
? SettingsData.frameBarSize
|
||||
: (_hadAdjacentLeftBar ? _frameEdgeFloorInset : 0)
|
||||
}
|
||||
readonly property real _frameRightInset: {
|
||||
if (!_hasBarWindow || !SettingsData.frameEnabled || _barIsVertical) return 0
|
||||
return hasAdjacentRightBarLive
|
||||
? SettingsData.frameBarSize
|
||||
: (_hadAdjacentRightBar ? _frameEdgeFloorInset : 0)
|
||||
}
|
||||
readonly property real _frameTopInset: {
|
||||
if (!_hasBarWindow || !SettingsData.frameEnabled || !_barIsVertical) return 0
|
||||
return hasAdjacentTopBarLive
|
||||
? SettingsData.frameThickness
|
||||
: (_hadAdjacentTopBar ? _frameEdgeFloorInset : 0)
|
||||
}
|
||||
readonly property real _frameBottomInset: {
|
||||
if (!_hasBarWindow || !SettingsData.frameEnabled || !_barIsVertical) return 0
|
||||
return hasAdjacentBottomBarLive
|
||||
? SettingsData.frameThickness
|
||||
: (_hadAdjacentBottomBar ? _frameEdgeFloorInset : 0)
|
||||
}
|
||||
|
||||
property alias hLeftSection: hLeftSection
|
||||
property alias hCenterSection: hCenterSection
|
||||
@@ -31,12 +75,61 @@ Item {
|
||||
property alias vRightSection: vRightSection
|
||||
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: Math.max(Theme.spacingXS, innerPadding * 0.8)
|
||||
anchors.rightMargin: Math.max(Theme.spacingXS, innerPadding * 0.8)
|
||||
anchors.topMargin: barWindow.isVertical ? (barWindow.hasAdjacentTopBar ? outlineThickness : Theme.spacingXS) : 0
|
||||
anchors.bottomMargin: barWindow.isVertical ? (barWindow.hasAdjacentBottomBar ? outlineThickness : Theme.spacingXS) : 0
|
||||
anchors.leftMargin: _edgeBaseMargin + _frameLeftInset
|
||||
anchors.rightMargin: _edgeBaseMargin + _frameRightInset
|
||||
anchors.topMargin: (_barIsVertical
|
||||
? (hasAdjacentTopBarLive ? outlineThickness : Theme.spacingXS)
|
||||
: 0) + _frameTopInset
|
||||
anchors.bottomMargin: (_barIsVertical
|
||||
? (hasAdjacentBottomBarLive ? outlineThickness : Theme.spacingXS)
|
||||
: 0) + _frameBottomInset
|
||||
clip: false
|
||||
|
||||
DeferredAction {
|
||||
id: enableFrameInsetAnimation
|
||||
onTriggered: topBarContent._animateFrameInsets = true
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
_hadAdjacentTopBar = hasAdjacentTopBarLive;
|
||||
_hadAdjacentBottomBar = hasAdjacentBottomBarLive;
|
||||
_hadAdjacentLeftBar = hasAdjacentLeftBarLive;
|
||||
_hadAdjacentRightBar = hasAdjacentRightBarLive;
|
||||
enableFrameInsetAnimation.schedule();
|
||||
}
|
||||
|
||||
Behavior on anchors.leftMargin {
|
||||
enabled: _animateFrameInsets && SettingsData.frameEnabled
|
||||
NumberAnimation {
|
||||
duration: Theme.shortDuration
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on anchors.rightMargin {
|
||||
enabled: _animateFrameInsets && SettingsData.frameEnabled
|
||||
NumberAnimation {
|
||||
duration: Theme.shortDuration
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on anchors.topMargin {
|
||||
enabled: _animateFrameInsets && SettingsData.frameEnabled
|
||||
NumberAnimation {
|
||||
duration: Theme.shortDuration
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on anchors.bottomMargin {
|
||||
enabled: _animateFrameInsets && SettingsData.frameEnabled
|
||||
NumberAnimation {
|
||||
duration: Theme.shortDuration
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
|
||||
property int componentMapRevision: 0
|
||||
|
||||
function updateComponentMap() {
|
||||
@@ -44,10 +137,14 @@ Item {
|
||||
}
|
||||
|
||||
readonly property var sortedToplevels: {
|
||||
return CompositorService.filterCurrentWorkspace(CompositorService.sortedToplevels, barWindow.screenName);
|
||||
if (!_hasBarWindow) {
|
||||
return [];
|
||||
}
|
||||
return CompositorService.filterCurrentWorkspace(CompositorService.sortedToplevels, _barScreenName);
|
||||
}
|
||||
|
||||
function getRealWorkspaces() {
|
||||
const screenName = _barScreenName;
|
||||
if (CompositorService.isNiri) {
|
||||
const fallbackWorkspaces = [
|
||||
{
|
||||
@@ -61,16 +158,16 @@ Item {
|
||||
"name": ""
|
||||
}
|
||||
];
|
||||
if (!barWindow.screenName || SettingsData.workspaceFollowFocus) {
|
||||
if (!screenName || SettingsData.workspaceFollowFocus) {
|
||||
const currentWorkspaces = NiriService.getCurrentOutputWorkspaces();
|
||||
return currentWorkspaces.length > 0 ? currentWorkspaces : fallbackWorkspaces;
|
||||
}
|
||||
const workspaces = NiriService.allWorkspaces.filter(ws => ws.output === barWindow.screenName);
|
||||
const workspaces = NiriService.allWorkspaces.filter(ws => ws.output === screenName);
|
||||
return workspaces.length > 0 ? workspaces : fallbackWorkspaces;
|
||||
} else if (CompositorService.isHyprland) {
|
||||
const workspaces = Hyprland.workspaces?.values || [];
|
||||
|
||||
if (!barWindow.screenName || SettingsData.workspaceFollowFocus) {
|
||||
if (!screenName || SettingsData.workspaceFollowFocus) {
|
||||
const sorted = workspaces.slice().sort((a, b) => a.id - b.id);
|
||||
const filtered = sorted.filter(ws => ws.id > -1);
|
||||
return filtered.length > 0 ? filtered : [
|
||||
@@ -82,7 +179,7 @@ Item {
|
||||
}
|
||||
|
||||
const monitorWorkspaces = workspaces.filter(ws => {
|
||||
return ws.lastIpcObject && ws.lastIpcObject.monitor === barWindow.screenName && ws.id > -1;
|
||||
return ws.lastIpcObject && ws.lastIpcObject.monitor === screenName && ws.id > -1;
|
||||
});
|
||||
|
||||
if (monitorWorkspaces.length === 0) {
|
||||
@@ -104,7 +201,7 @@ Item {
|
||||
length: DwlService.tagCount
|
||||
}, (_, i) => i);
|
||||
}
|
||||
return DwlService.getVisibleTags(barWindow.screenName);
|
||||
return DwlService.getVisibleTags(screenName);
|
||||
} else if (CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle) {
|
||||
const workspaces = I3.workspaces?.values || [];
|
||||
if (workspaces.length === 0)
|
||||
@@ -114,11 +211,11 @@ Item {
|
||||
}
|
||||
];
|
||||
|
||||
if (!barWindow.screenName || SettingsData.workspaceFollowFocus) {
|
||||
if (!screenName || SettingsData.workspaceFollowFocus) {
|
||||
return workspaces.slice().sort((a, b) => a.num - b.num);
|
||||
}
|
||||
|
||||
const monitorWorkspaces = workspaces.filter(ws => ws.monitor?.name === barWindow.screenName);
|
||||
const monitorWorkspaces = workspaces.filter(ws => ws.monitor?.name === screenName);
|
||||
return monitorWorkspaces.length > 0 ? monitorWorkspaces.sort((a, b) => a.num - b.num) : [
|
||||
{
|
||||
"num": 1
|
||||
@@ -129,31 +226,32 @@ Item {
|
||||
}
|
||||
|
||||
function getCurrentWorkspace() {
|
||||
const screenName = _barScreenName;
|
||||
if (CompositorService.isNiri) {
|
||||
if (!barWindow.screenName || SettingsData.workspaceFollowFocus) {
|
||||
if (!screenName || SettingsData.workspaceFollowFocus) {
|
||||
return NiriService.getCurrentWorkspaceNumber();
|
||||
}
|
||||
const activeWs = NiriService.allWorkspaces.find(ws => ws.output === barWindow.screenName && ws.is_active);
|
||||
const activeWs = NiriService.allWorkspaces.find(ws => ws.output === screenName && ws.is_active);
|
||||
return activeWs ? activeWs.idx : 1;
|
||||
} else if (CompositorService.isHyprland) {
|
||||
const monitors = Hyprland.monitors?.values || [];
|
||||
const currentMonitor = monitors.find(monitor => monitor.name === barWindow.screenName);
|
||||
const currentMonitor = monitors.find(monitor => monitor.name === screenName);
|
||||
return currentMonitor?.activeWorkspace?.id ?? 1;
|
||||
} else if (CompositorService.isDwl) {
|
||||
if (!DwlService.dwlAvailable)
|
||||
return 0;
|
||||
const outputState = DwlService.getOutputState(barWindow.screenName);
|
||||
const outputState = DwlService.getOutputState(screenName);
|
||||
if (!outputState || !outputState.tags)
|
||||
return 0;
|
||||
const activeTags = DwlService.getActiveTags(barWindow.screenName);
|
||||
const activeTags = DwlService.getActiveTags(screenName);
|
||||
return activeTags.length > 0 ? activeTags[0] : 0;
|
||||
} else if (CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle) {
|
||||
if (!barWindow.screenName || SettingsData.workspaceFollowFocus) {
|
||||
if (!screenName || SettingsData.workspaceFollowFocus) {
|
||||
const focusedWs = I3.workspaces?.values?.find(ws => ws.focused === true);
|
||||
return focusedWs ? focusedWs.num : 1;
|
||||
}
|
||||
|
||||
const focusedWs = I3.workspaces?.values?.find(ws => ws.monitor?.name === barWindow.screenName && ws.focused === true);
|
||||
const focusedWs = I3.workspaces?.values?.find(ws => ws.monitor?.name === screenName && ws.focused === true);
|
||||
return focusedWs ? focusedWs.num : 1;
|
||||
}
|
||||
return 1;
|
||||
@@ -194,7 +292,7 @@ Item {
|
||||
const nextIndex = direction > 0 ? Math.min(validIndex + 1, realWorkspaces.length - 1) : Math.max(validIndex - 1, 0);
|
||||
|
||||
if (nextIndex !== validIndex) {
|
||||
DwlService.switchToTag(barWindow.screenName, realWorkspaces[nextIndex]);
|
||||
DwlService.switchToTag(_barScreenName, realWorkspaces[nextIndex]);
|
||||
}
|
||||
} else if (CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle) {
|
||||
const currentWs = getCurrentWorkspace();
|
||||
@@ -261,8 +359,8 @@ Item {
|
||||
readonly property int leftToMediaGap: mediaMaxWidth > 0 ? Math.max(0, mediaLeftEdge - leftSectionRightEdge) : leftToClockGap
|
||||
readonly property int mediaToClockGap: mediaMaxWidth > 0 ? Theme.spacingS : 0
|
||||
readonly property int clockToRightGap: validLayout ? Math.max(0, rightSectionLeftEdge - clockRightEdge) : 1000
|
||||
readonly property bool spacingTight: !barWindow.isVertical && validLayout && (leftToMediaGap < 150 || clockToRightGap < 100)
|
||||
readonly property bool overlapping: !barWindow.isVertical && validLayout && (leftToMediaGap < 100 || clockToRightGap < 50)
|
||||
readonly property bool spacingTight: !_barIsVertical && validLayout && (leftToMediaGap < 150 || clockToRightGap < 100)
|
||||
readonly property bool overlapping: !_barIsVertical && validLayout && (leftToMediaGap < 100 || clockToRightGap < 50)
|
||||
|
||||
function getWidgetEnabled(enabled) {
|
||||
return enabled !== false;
|
||||
@@ -732,7 +830,7 @@ Item {
|
||||
|
||||
WorkspaceSwitcher {
|
||||
axis: barWindow.axis
|
||||
screenName: barWindow.screenName
|
||||
screenName: _barScreenName
|
||||
widgetHeight: barWindow.widgetThickness
|
||||
barThickness: barWindow.effectiveBarThickness
|
||||
parentScreen: barWindow.screen
|
||||
@@ -1156,6 +1254,7 @@ Item {
|
||||
if (!notificationCenterLoader.item) {
|
||||
return;
|
||||
}
|
||||
notificationCenterLoader.item.triggerScreen = barWindow.screen;
|
||||
const effectiveBarConfig = topBarContent.barConfig;
|
||||
const barPosition = barWindow.axis?.edge === "left" ? 2 : (barWindow.axis?.edge === "right" ? 3 : (barWindow.axis?.edge === "top" ? 0 : 1));
|
||||
if (notificationCenterLoader.item.setBarContext) {
|
||||
@@ -1348,8 +1447,8 @@ Item {
|
||||
id: spacerComponent
|
||||
|
||||
Item {
|
||||
width: barWindow.isVertical ? barWindow.widgetThickness : (parent.spacerSize || 20)
|
||||
height: barWindow.isVertical ? (parent.spacerSize || 20) : barWindow.widgetThickness
|
||||
width: _barIsVertical ? barWindow.widgetThickness : (parent.spacerSize || 20)
|
||||
height: _barIsVertical ? (parent.spacerSize || 20) : barWindow.widgetThickness
|
||||
implicitWidth: width
|
||||
implicitHeight: height
|
||||
|
||||
@@ -1378,14 +1477,14 @@ Item {
|
||||
id: separatorComponent
|
||||
|
||||
Item {
|
||||
width: barWindow.isVertical ? parent.barThickness : 1
|
||||
height: barWindow.isVertical ? 1 : parent.barThickness
|
||||
width: _barIsVertical ? parent.barThickness : 1
|
||||
height: _barIsVertical ? 1 : parent.barThickness
|
||||
implicitWidth: width
|
||||
implicitHeight: height
|
||||
|
||||
Rectangle {
|
||||
width: barWindow.isVertical ? parent.width * 0.6 : 1
|
||||
height: barWindow.isVertical ? 1 : parent.height * 0.6
|
||||
width: _barIsVertical ? parent.width * 0.6 : 1
|
||||
height: _barIsVertical ? 1 : parent.height * 0.6
|
||||
anchors.centerIn: parent
|
||||
color: Theme.outline
|
||||
opacity: 0.3
|
||||
|
||||
@@ -7,6 +7,7 @@ import qs.Services
|
||||
|
||||
PanelWindow {
|
||||
id: barWindow
|
||||
readonly property var log: Log.scoped("DankBarWindow")
|
||||
|
||||
required property var rootWindow
|
||||
required property var barConfig
|
||||
@@ -42,10 +43,10 @@ PanelWindow {
|
||||
}
|
||||
}
|
||||
|
||||
function triggerWallpaperBrowser() {
|
||||
function triggerDashTab(tabIndex) {
|
||||
dankDashPopoutLoader.active = true;
|
||||
if (!dankDashPopoutLoader.item) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
let section = "center";
|
||||
@@ -81,7 +82,12 @@ PanelWindow {
|
||||
dankDashPopoutLoader.item.triggerScreen = barWindow.screen;
|
||||
}
|
||||
|
||||
PopoutManager.requestPopout(dankDashPopoutLoader.item, 2, (barConfig?.id ?? "default") + "-" + section + "-2");
|
||||
PopoutManager.requestPopout(dankDashPopoutLoader.item, tabIndex, (barConfig?.id ?? "default") + "-" + section + "-" + tabIndex);
|
||||
return true;
|
||||
}
|
||||
|
||||
function triggerWallpaperBrowser() {
|
||||
triggerDashTab(2);
|
||||
}
|
||||
|
||||
readonly property var dBarLayer: {
|
||||
@@ -91,9 +97,14 @@ PanelWindow {
|
||||
case "overlay":
|
||||
return WlrLayer.Overlay;
|
||||
case "background":
|
||||
return WlrLayer.background;
|
||||
default:
|
||||
return WlrLayer.Background;
|
||||
case "top":
|
||||
return WlrLayer.Top;
|
||||
default:
|
||||
// Elevate to Overlay when Frame is enabled so the bar stays above
|
||||
// the FrameWindow (WlrLayer.Top) when it is re-mapped on mode switch,
|
||||
// but drop back to Top while a true fullscreen app owns this screen.
|
||||
return SettingsData.frameEnabled && !barWindow.hasFullscreenToplevel ? WlrLayer.Overlay : WlrLayer.Top;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +144,11 @@ PanelWindow {
|
||||
teardown();
|
||||
if (!BlurService.enabled || !BlurService.available)
|
||||
return;
|
||||
// In frame mode, FrameWindow owns the blur region for the entire screen edge
|
||||
// (including the bar area). The bar must not set its own competing blur region
|
||||
// so that frameBlurEnabled acts as the single control for all blur in frame mode.
|
||||
if (SettingsData.frameEnabled)
|
||||
return;
|
||||
|
||||
const widgets = barWindow._blurWidgetItems.filter(w => w && w.visible && w.width > 0 && w.height > 0);
|
||||
const hasBar = barHasTransparency;
|
||||
@@ -164,7 +180,7 @@ PanelWindow {
|
||||
barWindow.BackgroundEffect.blurRegion = region;
|
||||
barWindow.blurRegion = region;
|
||||
} catch (e) {
|
||||
console.warn("BarBlur: Failed to create blur region:", e);
|
||||
log.warn("BarBlur: Failed to create blur region:", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,6 +203,13 @@ PanelWindow {
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: SettingsData
|
||||
function onFrameEnabledChanged() {
|
||||
barBlur.rebuild();
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: topBarSlide
|
||||
function onXChanged() {
|
||||
@@ -238,7 +261,7 @@ PanelWindow {
|
||||
readonly property color _surfaceContainer: Theme.surfaceContainer
|
||||
readonly property string _barId: barConfig?.id ?? "default"
|
||||
property real _backgroundAlpha: barConfig?.transparency ?? 1.0
|
||||
readonly property color _bgColor: Theme.withAlpha(_surfaceContainer, _backgroundAlpha)
|
||||
readonly property color _bgColor: SettingsData.frameEnabled ? Qt.rgba(SettingsData.effectiveFrameColor.r, SettingsData.effectiveFrameColor.g, SettingsData.effectiveFrameColor.b, SettingsData.frameOpacity) : Theme.withAlpha(_surfaceContainer, _backgroundAlpha)
|
||||
|
||||
function _updateBackgroundAlpha() {
|
||||
const live = SettingsData.barConfigs.find(c => c.id === _barId);
|
||||
@@ -287,22 +310,11 @@ PanelWindow {
|
||||
}
|
||||
|
||||
function _updateHasFullscreenToplevel() {
|
||||
if (!CompositorService.isHyprland) {
|
||||
if (!(barConfig?.fullscreenDetection ?? true)) {
|
||||
hasFullscreenToplevel = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const filtered = CompositorService.filterCurrentWorkspace(CompositorService.sortedToplevels, screenName);
|
||||
for (let i = 0; i < filtered.length; i++) {
|
||||
if (filtered[i]?.fullscreen) {
|
||||
// On niri, fullscreen windows in inactive columns should not hide the bar
|
||||
if (CompositorService.isNiri && !filtered[i]?.activated)
|
||||
continue;
|
||||
hasFullscreenToplevel = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
hasFullscreenToplevel = false;
|
||||
hasFullscreenToplevel = CompositorService.hasFullscreenToplevelOnScreen(screenName);
|
||||
}
|
||||
|
||||
function _updateShouldHideForWindows() {
|
||||
@@ -384,7 +396,7 @@ PanelWindow {
|
||||
shouldHideForWindows = filtered.length > 0;
|
||||
}
|
||||
|
||||
property real effectiveSpacing: hasMaximizedToplevel ? 0 : (barConfig?.spacing ?? 4)
|
||||
property real effectiveSpacing: SettingsData.frameEnabled ? 0 : (hasMaximizedToplevel ? 0 : (barConfig?.spacing ?? 4))
|
||||
|
||||
Behavior on effectiveSpacing {
|
||||
enabled: barWindow.visible
|
||||
@@ -395,7 +407,8 @@ PanelWindow {
|
||||
}
|
||||
|
||||
readonly property int notificationCount: NotificationService.notifications.length
|
||||
readonly property real effectiveBarThickness: Theme.snap(Math.max(barWindow.widgetThickness + (barConfig?.innerPadding ?? 4) + 4, Theme.barHeight - 4 - (8 - (barConfig?.innerPadding ?? 4))), _dpr)
|
||||
readonly property real effectiveBarThickness: SettingsData.frameEnabled ? SettingsData.frameBarSize : Theme.snap(Math.max(barWindow.widgetThickness + (barConfig?.innerPadding ?? 4) + 4, Theme.barHeight - 4 - (8 - (barConfig?.innerPadding ?? 4))), _dpr)
|
||||
readonly property bool effectiveOpenOnOverview: SettingsData.frameEnabled ? SettingsData.frameShowOnOverview : (barConfig?.openOnOverview ?? false)
|
||||
readonly property real widgetThickness: Theme.snap(Math.max(20, 26 + (barConfig?.innerPadding ?? 4) * 0.6), _dpr)
|
||||
|
||||
readonly property bool hasAdjacentTopBar: {
|
||||
@@ -501,20 +514,10 @@ PanelWindow {
|
||||
property var nativeInhibitor: null
|
||||
|
||||
Component.onCompleted: {
|
||||
if (SettingsData.forceStatusBarLayoutRefresh) {
|
||||
SettingsData.forceStatusBarLayoutRefresh.connect(() => {
|
||||
Qt.callLater(() => {
|
||||
stackContainer.visible = false;
|
||||
Qt.callLater(() => {
|
||||
stackContainer.visible = true;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
updateGpuTempConfig();
|
||||
_updateBackgroundAlpha();
|
||||
_updateHasMaximizedToplevel();
|
||||
_updateHasFullscreenToplevel();
|
||||
_updateShouldHideForWindows();
|
||||
|
||||
inhibitorInitTimer.start();
|
||||
@@ -534,11 +537,11 @@ PanelWindow {
|
||||
Connections {
|
||||
target: PluginService
|
||||
function onPluginLoaded(pluginId) {
|
||||
console.info("DankBar: Plugin loaded:", pluginId);
|
||||
log.info("DankBar: Plugin loaded:", pluginId);
|
||||
SettingsData.widgetDataChanged();
|
||||
}
|
||||
function onPluginUnloaded(pluginId) {
|
||||
console.info("DankBar: Plugin unloaded:", pluginId);
|
||||
log.info("DankBar: Plugin unloaded:", pluginId);
|
||||
SettingsData.widgetDataChanged();
|
||||
}
|
||||
}
|
||||
@@ -594,6 +597,7 @@ PanelWindow {
|
||||
barWindow.updateGpuTempConfig();
|
||||
barWindow._updateBackgroundAlpha();
|
||||
barWindow._updateHasMaximizedToplevel();
|
||||
barWindow._updateHasFullscreenToplevel();
|
||||
barWindow._updateShouldHideForWindows();
|
||||
}
|
||||
|
||||
@@ -625,6 +629,13 @@ PanelWindow {
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: ToplevelManager
|
||||
function onActiveToplevelChanged() {
|
||||
barWindow._updateHasFullscreenToplevel();
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
function onNvidiaGpuTempEnabledChanged() {
|
||||
barWindow.updateGpuTempConfig();
|
||||
@@ -644,18 +655,18 @@ PanelWindow {
|
||||
anchors.left: !isVertical ? true : (barPos === SettingsData.Position.Left)
|
||||
anchors.right: !isVertical ? true : (barPos === SettingsData.Position.Right)
|
||||
|
||||
exclusiveZone: (!(barConfig?.visible ?? true) || topBarCore.autoHide) ? -1 : (barWindow.effectiveBarThickness + effectiveSpacing + (barConfig?.bottomGap ?? 0))
|
||||
exclusiveZone: (barWindow.hasFullscreenToplevel || !(barConfig?.visible ?? true) || topBarCore.autoHide) ? -1 : (barWindow.effectiveBarThickness + effectiveSpacing + (Theme.isConnectedEffect ? 0 : (barConfig?.bottomGap ?? 0)))
|
||||
|
||||
Item {
|
||||
id: inputMask
|
||||
|
||||
readonly property int barThickness: Theme.px(barWindow.effectiveBarThickness + barWindow.effectiveSpacing, barWindow._dpr)
|
||||
|
||||
readonly property bool inOverviewWithShow: CompositorService.isNiri && NiriService.inOverview && (barConfig?.openOnOverview ?? false)
|
||||
readonly property bool inOverviewWithShow: CompositorService.isNiri && NiriService.inOverview && barWindow.effectiveOpenOnOverview
|
||||
readonly property bool effectiveVisible: (barConfig?.visible ?? true) || inOverviewWithShow
|
||||
readonly property bool showing: effectiveVisible && (topBarCore.reveal || inOverviewWithShow || !topBarCore.autoHide)
|
||||
readonly property bool showing: effectiveVisible && !barWindow.hasFullscreenToplevel && (topBarCore.reveal || inOverviewWithShow || !topBarCore.autoHide)
|
||||
|
||||
readonly property int maskThickness: showing ? barThickness : 1
|
||||
readonly property int maskThickness: barWindow.hasFullscreenToplevel ? 0 : (showing ? barThickness : 1)
|
||||
|
||||
x: {
|
||||
if (!axis.isVertical) {
|
||||
@@ -792,13 +803,13 @@ PanelWindow {
|
||||
}
|
||||
|
||||
property bool reveal: {
|
||||
const inOverviewWithShow = CompositorService.isNiri && NiriService.inOverview && (barConfig?.openOnOverview ?? false);
|
||||
if (inOverviewWithShow)
|
||||
return true;
|
||||
|
||||
if (barWindow.hasFullscreenToplevel)
|
||||
return false;
|
||||
|
||||
const inOverviewWithShow = CompositorService.isNiri && NiriService.inOverview && barWindow.effectiveOpenOnOverview;
|
||||
if (inOverviewWithShow)
|
||||
return true;
|
||||
|
||||
const showOnWindowsSetting = barConfig?.showOnWindowsOpen ?? false;
|
||||
if (showOnWindowsSetting && autoHide && (CompositorService.isNiri || CompositorService.isHyprland)) {
|
||||
if (barWindow.shouldHideForWindows)
|
||||
@@ -889,10 +900,10 @@ PanelWindow {
|
||||
top: barWindow.isVertical ? parent.top : undefined
|
||||
bottom: barWindow.isVertical ? parent.bottom : undefined
|
||||
}
|
||||
readonly property bool inOverview: CompositorService.isNiri && NiriService.inOverview && (barConfig?.openOnOverview ?? false)
|
||||
hoverEnabled: (barConfig?.autoHide ?? false) && !inOverview && !topBarCore.hasActivePopout
|
||||
readonly property bool inOverview: CompositorService.isNiri && NiriService.inOverview && barWindow.effectiveOpenOnOverview
|
||||
hoverEnabled: (barConfig?.autoHide ?? false) && !inOverview && !barWindow.hasFullscreenToplevel && !topBarCore.hasActivePopout
|
||||
acceptedButtons: Qt.NoButton
|
||||
enabled: (barConfig?.autoHide ?? false) && !inOverview
|
||||
enabled: (barConfig?.autoHide ?? false) && !inOverview && !barWindow.hasFullscreenToplevel
|
||||
|
||||
Item {
|
||||
id: topBarContainer
|
||||
@@ -934,6 +945,17 @@ PanelWindow {
|
||||
barConfig: barWindow.barConfig
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
z: -2
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
|
||||
onClicked: {
|
||||
const screenName = barWindow.screen?.name;
|
||||
if (screenName && PopoutManager.currentPopoutsByScreen[screenName])
|
||||
PopoutManager.closeAllPopouts();
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: scrollArea
|
||||
anchors.fill: parent
|
||||
|
||||
470
quickshell/Modules/DankBar/Popouts/SystemUpdatePopout.qml
Normal file
470
quickshell/Modules/DankBar/Popouts/SystemUpdatePopout.qml
Normal file
@@ -0,0 +1,470 @@
|
||||
import QtQuick
|
||||
import Quickshell.Wayland
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
|
||||
DankPopout {
|
||||
id: systemUpdatePopout
|
||||
|
||||
layerNamespace: "dms:system-update"
|
||||
|
||||
property var parentWidget: null
|
||||
property var triggerScreen: null
|
||||
|
||||
Ref {
|
||||
service: SystemUpdateService
|
||||
}
|
||||
|
||||
property bool _reopenAfterUpgrade: false
|
||||
|
||||
readonly property bool polkitModalOpen: PopoutService.polkitAuthModal?.visible ?? false
|
||||
readonly property bool anyModalOpen: polkitModalOpen
|
||||
|
||||
backgroundInteractive: !anyModalOpen
|
||||
|
||||
customKeyboardFocus: {
|
||||
if (!shouldBeVisible)
|
||||
return WlrKeyboardFocus.None;
|
||||
if (anyModalOpen)
|
||||
return WlrKeyboardFocus.None;
|
||||
if (CompositorService.useHyprlandFocusGrab)
|
||||
return WlrKeyboardFocus.OnDemand;
|
||||
return WlrKeyboardFocus.Exclusive;
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: SystemUpdateService
|
||||
function onIsUpgradingChanged() {
|
||||
if (SystemUpdateService.isUpgrading) {
|
||||
return;
|
||||
}
|
||||
if (!systemUpdatePopout._reopenAfterUpgrade) {
|
||||
return;
|
||||
}
|
||||
systemUpdatePopout._reopenAfterUpgrade = false;
|
||||
systemUpdatePopout.open();
|
||||
}
|
||||
}
|
||||
|
||||
popupWidth: 440
|
||||
popupHeight: 560
|
||||
triggerWidth: 55
|
||||
positioning: ""
|
||||
screen: triggerScreen
|
||||
shouldBeVisible: false
|
||||
|
||||
onBackgroundClicked: {
|
||||
if (anyModalOpen)
|
||||
return;
|
||||
close();
|
||||
}
|
||||
|
||||
onShouldBeVisibleChanged: {
|
||||
if (!shouldBeVisible) {
|
||||
return;
|
||||
}
|
||||
const stale = !SystemUpdateService.lastCheckUnix || (Date.now() / 1000 - SystemUpdateService.lastCheckUnix) > 300;
|
||||
if (stale && !SystemUpdateService.isChecking && !SystemUpdateService.isUpgrading) {
|
||||
SystemUpdateService.checkForUpdates();
|
||||
}
|
||||
}
|
||||
|
||||
content: Component {
|
||||
Rectangle {
|
||||
id: updaterPanel
|
||||
|
||||
color: "transparent"
|
||||
focus: true
|
||||
|
||||
readonly property bool hasTerminalBackend: (SystemUpdateService.backends || []).some(b => b.runsInTerminal === true)
|
||||
|
||||
Keys.onPressed: event => {
|
||||
if (event.key === Qt.Key_Escape) {
|
||||
systemUpdatePopout.close();
|
||||
event.accepted = true;
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
if (systemUpdatePopout.shouldBeVisible) {
|
||||
forceActiveFocus();
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: header
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.leftMargin: Theme.spacingL
|
||||
anchors.rightMargin: Theme.spacingL
|
||||
anchors.topMargin: Theme.spacingL
|
||||
height: 40
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("System Updates")
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
color: Theme.surfaceText
|
||||
font.weight: Font.Medium
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Theme.spacingS
|
||||
|
||||
StyledText {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: {
|
||||
switch (true) {
|
||||
case SystemUpdateService.isUpgrading:
|
||||
return I18n.tr("Upgrading...");
|
||||
case SystemUpdateService.isChecking:
|
||||
return I18n.tr("Checking...");
|
||||
case SystemUpdateService.hasError:
|
||||
return I18n.tr("Error");
|
||||
case SystemUpdateService.updateCount === 0:
|
||||
return I18n.tr("Up to date");
|
||||
case SystemUpdateService.updateCount === 1:
|
||||
return I18n.tr("%1 update").arg(SystemUpdateService.updateCount);
|
||||
default:
|
||||
return I18n.tr("%1 updates").arg(SystemUpdateService.updateCount);
|
||||
}
|
||||
}
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: SystemUpdateService.hasError ? Theme.error : Theme.surfaceVariantText
|
||||
}
|
||||
|
||||
DankActionButton {
|
||||
id: refreshButton
|
||||
buttonSize: 28
|
||||
iconName: "refresh"
|
||||
iconSize: 18
|
||||
iconColor: Theme.surfaceText
|
||||
enabled: !SystemUpdateService.isChecking && !SystemUpdateService.isUpgrading
|
||||
opacity: enabled ? 1.0 : 0.5
|
||||
onClicked: SystemUpdateService.checkForUpdates()
|
||||
|
||||
RotationAnimator on rotation {
|
||||
from: 0
|
||||
to: 360
|
||||
duration: 1000
|
||||
loops: Animation.Infinite
|
||||
running: SystemUpdateService.isChecking
|
||||
|
||||
onRunningChanged: {
|
||||
if (!running)
|
||||
refreshButton.rotation = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
id: backendsRow
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: header.bottom
|
||||
anchors.leftMargin: Theme.spacingL
|
||||
anchors.rightMargin: Theme.spacingL
|
||||
anchors.topMargin: Theme.spacingS
|
||||
visible: SystemUpdateService.backends.length > 0 && !SystemUpdateService.isUpgrading
|
||||
text: {
|
||||
const names = (SystemUpdateService.backends || []).map(b => b.displayName).join(", ");
|
||||
return I18n.tr("Backends: %1").arg(names);
|
||||
}
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
wrapMode: Text.WordWrap
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Row {
|
||||
id: buttonsRow
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.leftMargin: Theme.spacingL
|
||||
anchors.rightMargin: Theme.spacingL
|
||||
anchors.bottomMargin: Theme.spacingL
|
||||
spacing: Theme.spacingM
|
||||
height: 44
|
||||
|
||||
Rectangle {
|
||||
width: (parent.width - Theme.spacingM) / 2
|
||||
height: parent.height
|
||||
radius: Theme.cornerRadius
|
||||
color: primaryMouseArea.containsMouse && primaryMouseArea.enabled ? Theme.primaryHover : Theme.secondaryHover
|
||||
opacity: primaryMouseArea.enabled ? 1.0 : 0.5
|
||||
|
||||
StyledText {
|
||||
anchors.centerIn: parent
|
||||
text: SystemUpdateService.isUpgrading ? I18n.tr("Cancel") : I18n.tr("Update All")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
font.weight: Font.Medium
|
||||
color: Theme.primary
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: primaryMouseArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
enabled: SystemUpdateService.isUpgrading || SystemUpdateService.updateCount > 0
|
||||
onClicked: {
|
||||
if (SystemUpdateService.isUpgrading) {
|
||||
SystemUpdateService.cancelUpdates();
|
||||
return;
|
||||
}
|
||||
const opts = {
|
||||
includeFlatpak: SettingsData.updaterIncludeFlatpak,
|
||||
includeAUR: SettingsData.updaterAllowAUR,
|
||||
terminal: SessionData.terminalOverride
|
||||
};
|
||||
if (updaterPanel.hasTerminalBackend) {
|
||||
systemUpdatePopout._reopenAfterUpgrade = true;
|
||||
SystemUpdateService.runUpdates(opts);
|
||||
systemUpdatePopout.close();
|
||||
return;
|
||||
}
|
||||
SystemUpdateService.runUpdates(opts);
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Theme.shortDuration
|
||||
easing.type: Theme.standardEasing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: (parent.width - Theme.spacingM) / 2
|
||||
height: parent.height
|
||||
radius: Theme.cornerRadius
|
||||
color: closeMouseArea.containsMouse ? Theme.errorPressed : Theme.secondaryHover
|
||||
|
||||
StyledText {
|
||||
anchors.centerIn: parent
|
||||
text: I18n.tr("Close")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: closeMouseArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: systemUpdatePopout.close()
|
||||
}
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Theme.shortDuration
|
||||
easing.type: Theme.standardEasing
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: bodyArea
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: backendsRow.visible ? backendsRow.bottom : header.bottom
|
||||
anchors.bottom: buttonsRow.top
|
||||
anchors.leftMargin: Theme.spacingL
|
||||
anchors.rightMargin: Theme.spacingL
|
||||
anchors.topMargin: Theme.spacingM
|
||||
anchors.bottomMargin: Theme.spacingM
|
||||
radius: Theme.cornerRadius
|
||||
color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.1)
|
||||
|
||||
StyledText {
|
||||
id: statusText
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.spacingM
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
visible: !SystemUpdateService.isUpgrading && (SystemUpdateService.updateCount === 0 || SystemUpdateService.hasError || SystemUpdateService.isChecking)
|
||||
text: {
|
||||
switch (true) {
|
||||
case SystemUpdateService.hasError:
|
||||
return I18n.tr("Failed: %1").arg(SystemUpdateService.errorMessage);
|
||||
case !SystemUpdateService.helperAvailable:
|
||||
return I18n.tr("No supported package manager found.");
|
||||
case SystemUpdateService.isChecking:
|
||||
return I18n.tr("Checking for updates...");
|
||||
default:
|
||||
return I18n.tr("Your system is up to date!");
|
||||
}
|
||||
}
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: SystemUpdateService.hasError ? Theme.error : Theme.surfaceText
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
DankListView {
|
||||
id: packagesList
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.spacingS
|
||||
visible: !SystemUpdateService.isUpgrading && SystemUpdateService.updateCount > 0 && !SystemUpdateService.hasError && !SystemUpdateService.isChecking
|
||||
clip: true
|
||||
spacing: Theme.spacingXS
|
||||
model: SystemUpdateService.availableUpdates
|
||||
|
||||
delegate: Rectangle {
|
||||
width: ListView.view.width
|
||||
height: 48
|
||||
radius: Theme.cornerRadius
|
||||
color: packageMouseArea.containsMouse ? Theme.primaryHoverLight : "transparent"
|
||||
|
||||
required property var modelData
|
||||
|
||||
Row {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.leftMargin: Theme.spacingM
|
||||
anchors.rightMargin: Theme.spacingM
|
||||
spacing: Theme.spacingS
|
||||
|
||||
Rectangle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 64
|
||||
height: 18
|
||||
radius: 9
|
||||
color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.18)
|
||||
|
||||
StyledText {
|
||||
anchors.centerIn: parent
|
||||
text: modelData.repo || ""
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.primary
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: parent.width - 64 - Theme.spacingS
|
||||
spacing: 2
|
||||
|
||||
StyledText {
|
||||
width: parent.width
|
||||
text: modelData.name || ""
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: Theme.surfaceText
|
||||
font.weight: Font.Medium
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: 4
|
||||
|
||||
StyledText {
|
||||
text: {
|
||||
const from = modelData.fromVersion || "";
|
||||
const to = modelData.toVersion || "";
|
||||
if (from && to)
|
||||
return `${from} →`;
|
||||
return "";
|
||||
}
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
visible: text !== ""
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: modelData.toVersion || modelData.fromVersion || ""
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.primary
|
||||
font.weight: Font.Medium
|
||||
elide: Text.ElideRight
|
||||
width: parent.width - (parent.children[0].visible ? parent.children[0].implicitWidth + 4 : 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: packageMouseArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: modelData.changelogUrl ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: {
|
||||
if (modelData.changelogUrl) {
|
||||
Qt.openUrlExternally(modelData.changelogUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.spacingM
|
||||
spacing: Theme.spacingS
|
||||
visible: SystemUpdateService.isUpgrading && updaterPanel.hasTerminalBackend
|
||||
|
||||
DankIcon {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
name: "terminal"
|
||||
size: 32
|
||||
color: Theme.primary
|
||||
}
|
||||
|
||||
StyledText {
|
||||
width: parent.width
|
||||
text: I18n.tr("Running in terminal")
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
|
||||
StyledText {
|
||||
width: parent.width
|
||||
text: I18n.tr("AUR helpers are interactive — see the terminal window for prompts. This popout will return to idle when the upgrade exits.")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
wrapMode: Text.WordWrap
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
}
|
||||
|
||||
DankFlickable {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.spacingM
|
||||
visible: SystemUpdateService.isUpgrading && !updaterPanel.hasTerminalBackend
|
||||
contentWidth: width
|
||||
contentHeight: logText.implicitHeight
|
||||
clip: true
|
||||
|
||||
onContentHeightChanged: {
|
||||
if (contentHeight > height) {
|
||||
contentY = contentHeight - height;
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
id: logText
|
||||
width: parent.width
|
||||
text: (SystemUpdateService.recentLog || []).join("\n")
|
||||
font.family: Theme.monoFontFamily || "monospace"
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceText
|
||||
wrapMode: Text.NoWrap
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import qs.Widgets
|
||||
|
||||
BasePill {
|
||||
id: root
|
||||
readonly property var log: Log.scoped("AppsDock")
|
||||
|
||||
enableBackgroundHover: false
|
||||
enableCursor: false
|
||||
@@ -550,9 +551,9 @@ BasePill {
|
||||
showBadge: root.showOverflowBadge
|
||||
z: 10
|
||||
onClicked: {
|
||||
console.log("Overflow button clicked! Current state:", root.overflowExpanded);
|
||||
log.debug("Overflow button clicked! Current state:", root.overflowExpanded);
|
||||
root.overflowExpanded = !root.overflowExpanded;
|
||||
console.log("New state:", root.overflowExpanded);
|
||||
log.debug("New state:", root.overflowExpanded);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import qs.Widgets
|
||||
|
||||
BasePill {
|
||||
id: battery
|
||||
readonly property var log: Log.scoped("Battery")
|
||||
|
||||
property bool batteryPopupVisible: false
|
||||
property var popoutTarget: null
|
||||
@@ -130,13 +131,13 @@ BasePill {
|
||||
// Check if this is a touchpad
|
||||
if (delta !== 120 && delta !== -120) {
|
||||
touchpadAccumulator += delta;
|
||||
console.info("Acc: "+touchpadAccumulator);
|
||||
log.info("Acc: " + touchpadAccumulator);
|
||||
if (Math.abs(touchpadAccumulator) < 500)
|
||||
return;
|
||||
delta = touchpadAccumulator;
|
||||
touchpadAccumulator = 0;
|
||||
}
|
||||
console.info("Trigger! Delta: "+delta)
|
||||
log.info("Trigger! Delta: " + delta);
|
||||
|
||||
// This is after the other delta checks so it only shows on valid Y scroll
|
||||
if (typeof PowerProfiles === "undefined") {
|
||||
@@ -149,11 +150,14 @@ BasePill {
|
||||
var index = profiles.findIndex(profile => PowerProfiles.profile === profile);
|
||||
|
||||
// Step once based on mouse wheel direction
|
||||
if (delta > 0) index += 1;
|
||||
else index -= 1;
|
||||
if (delta > 0)
|
||||
index += 1;
|
||||
else
|
||||
index -= 1;
|
||||
|
||||
// Already at end of list, can't go further
|
||||
if (index < 0 || index >= profiles.length) return;
|
||||
if (index < 0 || index >= profiles.length)
|
||||
return;
|
||||
|
||||
// Set new profile
|
||||
PowerProfiles.profile = profiles[index];
|
||||
|
||||
@@ -10,6 +10,36 @@ BasePill {
|
||||
|
||||
readonly property MprisPlayer activePlayer: MprisController.activePlayer
|
||||
readonly property bool playerAvailable: activePlayer !== null
|
||||
readonly property bool _hoverPreview: MprisController.isFirefoxYoutubeHoverPreview(activePlayer)
|
||||
readonly property bool _isPlaying: !!activePlayer && activePlayer.playbackState === 1 && !_hoverPreview
|
||||
|
||||
property string _stableTitle: ""
|
||||
property string _stableArtist: ""
|
||||
|
||||
Connections {
|
||||
target: root.activePlayer
|
||||
function onTrackTitleChanged() {
|
||||
root._syncMeta();
|
||||
}
|
||||
function onTrackArtistChanged() {
|
||||
root._syncMeta();
|
||||
}
|
||||
}
|
||||
|
||||
onActivePlayerChanged: _syncMeta()
|
||||
|
||||
function _syncMeta() {
|
||||
if (!activePlayer) {
|
||||
_stableTitle = "";
|
||||
_stableArtist = "";
|
||||
return;
|
||||
}
|
||||
if (MprisController.isFirefoxYoutubeHoverPreview(activePlayer))
|
||||
return;
|
||||
_stableTitle = activePlayer.trackTitle || "";
|
||||
_stableArtist = activePlayer.trackArtist || "";
|
||||
}
|
||||
|
||||
readonly property bool __isChromeBrowser: {
|
||||
if (!activePlayer?.identity)
|
||||
return false;
|
||||
@@ -212,15 +242,15 @@ BasePill {
|
||||
height: 24
|
||||
radius: 12
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
color: activePlayer && activePlayer.playbackState === 1 ? Theme.primary : Theme.primaryHover
|
||||
color: root._isPlaying ? Theme.primary : Theme.primaryHover
|
||||
visible: root.playerAvailable
|
||||
opacity: activePlayer ? 1 : 0.3
|
||||
|
||||
DankIcon {
|
||||
anchors.centerIn: parent
|
||||
name: activePlayer && activePlayer.playbackState === 1 ? "pause" : "play_arrow"
|
||||
name: root._isPlaying ? "pause" : "play_arrow"
|
||||
size: 14
|
||||
color: activePlayer && activePlayer.playbackState === 1 ? Theme.background : Theme.primary
|
||||
color: root._isPlaying ? Theme.background : Theme.primary
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
@@ -279,12 +309,10 @@ BasePill {
|
||||
readonly property bool isWebMedia: lowerIdentity.includes("firefox") || lowerIdentity.includes("chrome") || lowerIdentity.includes("chromium") || lowerIdentity.includes("edge") || lowerIdentity.includes("safari")
|
||||
|
||||
property string displayText: {
|
||||
if (!activePlayer || !activePlayer.trackTitle) {
|
||||
if (!activePlayer || !root._stableTitle)
|
||||
return "";
|
||||
}
|
||||
|
||||
const title = isWebMedia ? activePlayer.trackTitle : (activePlayer.trackTitle || "Unknown Track");
|
||||
const subtitle = isWebMedia ? (activePlayer.trackArtist || cachedIdentity) : (activePlayer.trackArtist || "");
|
||||
const title = isWebMedia ? root._stableTitle : (root._stableTitle || "Unknown Track");
|
||||
const subtitle = isWebMedia ? (root._stableArtist || cachedIdentity) : (root._stableArtist || "");
|
||||
return subtitle.length > 0 ? title + " • " + subtitle : title;
|
||||
}
|
||||
|
||||
@@ -444,15 +472,15 @@ BasePill {
|
||||
height: 24
|
||||
radius: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: activePlayer && activePlayer.playbackState === 1 ? Theme.primary : Theme.primaryHover
|
||||
color: root._isPlaying ? Theme.primary : Theme.primaryHover
|
||||
visible: root.playerAvailable
|
||||
opacity: activePlayer ? 1 : 0.3
|
||||
|
||||
DankIcon {
|
||||
anchors.centerIn: parent
|
||||
name: activePlayer && activePlayer.playbackState === 1 ? "pause" : "play_arrow"
|
||||
name: root._isPlaying ? "pause" : "play_arrow"
|
||||
size: 14
|
||||
color: activePlayer && activePlayer.playbackState === 1 ? Theme.background : Theme.primary
|
||||
color: root._isPlaying ? Theme.background : Theme.primary
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user