diff --git a/core/cmd/dms/commands_common.go b/core/cmd/dms/commands_common.go index 6557860d..e0d4a324 100644 --- a/core/cmd/dms/commands_common.go +++ b/core/cmd/dms/commands_common.go @@ -526,5 +526,6 @@ func getCommonCommands() []*cobra.Command { dlCmd, randrCmd, blurCmd, + trashCmd, } } diff --git a/core/cmd/dms/commands_trash.go b/core/cmd/dms/commands_trash.go new file mode 100644 index 00000000..a0cf5a28 --- /dev/null +++ b/core/cmd/dms/commands_trash.go @@ -0,0 +1,122 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/trash" + "github.com/spf13/cobra" +) + +var trashCmd = &cobra.Command{ + Use: "trash", + Short: "Manage the user's trash (XDG Trash spec 1.0)", +} + +var trashPutCmd = &cobra.Command{ + Use: "put ", + Short: "Move files or directories into the trash", + Args: cobra.MinimumNArgs(1), + Run: runTrashPut, +} + +var trashListCmd = &cobra.Command{ + Use: "list", + Short: "List trashed items across all known trash directories", + Run: runTrashList, +} + +var trashCountCmd = &cobra.Command{ + Use: "count", + Short: "Print the total number of trashed items", + Run: runTrashCount, +} + +var trashEmptyCmd = &cobra.Command{ + Use: "empty", + Short: "Permanently delete every trashed item", + Run: runTrashEmpty, +} + +var trashRestoreCmd = &cobra.Command{ + Use: "restore ", + Short: "Restore a trashed item to its original location", + Args: cobra.ExactArgs(1), + Run: runTrashRestore, +} + +var ( + trashJSONOutput bool + trashRestoreDir string +) + +func init() { + trashListCmd.Flags().BoolVar(&trashJSONOutput, "json", false, "Output as JSON") + trashRestoreCmd.Flags().StringVar(&trashRestoreDir, "trash-dir", "", "Trash directory containing the item (default: home trash)") + trashCmd.AddCommand(trashPutCmd, trashListCmd, trashCountCmd, trashEmptyCmd, trashRestoreCmd) +} + +func runTrashPut(cmd *cobra.Command, args []string) { + var failed int + for _, p := range args { + if _, err := trash.Put(p); err != nil { + log.Errorf("trash %s: %v", p, err) + failed++ + continue + } + fmt.Println(p) + } + if failed > 0 { + os.Exit(1) + } +} + +func runTrashList(cmd *cobra.Command, args []string) { + entries, err := trash.List() + if err != nil { + log.Fatalf("list trash: %v", err) + } + + if trashJSONOutput { + if entries == nil { + entries = []trash.Entry{} + } + out, _ := json.MarshalIndent(entries, "", " ") + fmt.Println(string(out)) + return + } + + if len(entries) == 0 { + fmt.Println("Trash is empty") + return + } + for _, e := range entries { + marker := "F" + if e.IsDir { + marker = "D" + } + fmt.Printf("%s %s %s %s\n", marker, e.DeletionDate, e.Name, e.OriginalPath) + } +} + +func runTrashCount(cmd *cobra.Command, args []string) { + n, err := trash.Count() + if err != nil { + log.Fatalf("count trash: %v", err) + } + fmt.Println(n) +} + +func runTrashEmpty(cmd *cobra.Command, args []string) { + if err := trash.Empty(); err != nil { + log.Fatalf("empty trash: %v", err) + } +} + +func runTrashRestore(cmd *cobra.Command, args []string) { + if err := trash.Restore(args[0], trashRestoreDir); err != nil { + log.Fatalf("restore: %v", err) + } +} diff --git a/core/internal/trash/trash.go b/core/internal/trash/trash.go new file mode 100644 index 00000000..ddbc9e73 --- /dev/null +++ b/core/internal/trash/trash.go @@ -0,0 +1,455 @@ +// Package trash implements the FreeDesktop.org Trash specification 1.0. +// See: https://specifications.freedesktop.org/trash-spec/trashspec-1.0.html +package trash + +import ( + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" +) + +const trashInfoExt = ".trashinfo" + +type Entry struct { + Name string `json:"name"` + OriginalPath string `json:"originalPath"` + DeletionDate string `json:"deletionDate"` + TrashDir string `json:"trashDir"` + FilesPath string `json:"filesPath"` + InfoPath string `json:"infoPath"` + Size int64 `json:"size"` + IsDir bool `json:"isDir"` +} + +func homeTrashDir() (string, error) { + xdg := os.Getenv("XDG_DATA_HOME") + if xdg == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + xdg = filepath.Join(home, ".local", "share") + } + return filepath.Join(xdg, "Trash"), nil +} + +func ensureTrashDirs(trashDir string) error { + if err := os.MkdirAll(filepath.Join(trashDir, "files"), 0o700); err != nil { + return err + } + return os.MkdirAll(filepath.Join(trashDir, "info"), 0o700) +} + +func fsDevice(path string) (uint64, error) { + var st syscall.Stat_t + if err := syscall.Lstat(path, &st); err != nil { + return 0, err + } + return uint64(st.Dev), nil +} + +func fsDeviceWalkUp(start string) (uint64, error) { + cur := start + for { + if dev, err := fsDevice(cur); err == nil { + return dev, nil + } + parent := filepath.Dir(cur) + if parent == cur { + return 0, fmt.Errorf("no existing ancestor for %s", start) + } + cur = parent + } +} + +func findTopDir(path string) (string, error) { + abs, err := filepath.Abs(path) + if err != nil { + return "", err + } + dev, err := fsDevice(abs) + if err != nil { + return "", err + } + cur := abs + for { + parent := filepath.Dir(cur) + if parent == cur { + return cur, nil + } + pdev, err := fsDevice(parent) + if err != nil { + return cur, nil + } + if pdev != dev { + return cur, nil + } + cur = parent + } +} + +// isValidSharedTrash enforces the spec's checks on $topdir/.Trash: +// must exist, must be a directory, must not be a symlink, must have sticky bit. +func isValidSharedTrash(p string) bool { + info, err := os.Lstat(p) + if err != nil { + return false + } + if info.Mode()&os.ModeSymlink != 0 { + return false + } + if !info.IsDir() { + return false + } + return info.Mode()&os.ModeSticky != 0 +} + +// trashDirForPath chooses the correct trash dir per spec and returns the value +// to store in the .trashinfo Path field (absolute for home, relative-to-topdir +// for per-mountpoint trash). +func trashDirForPath(absPath string) (trashDir string, storedPath string, err error) { + home, err := homeTrashDir() + if err != nil { + return "", "", err + } + + pathDev, err := fsDevice(absPath) + if err != nil { + return "", "", err + } + homeDev, err := fsDeviceWalkUp(home) + if err != nil { + return "", "", err + } + + if pathDev == homeDev { + return home, absPath, nil + } + + topDir, err := findTopDir(absPath) + if err != nil { + return "", "", err + } + + uid := strconv.Itoa(os.Getuid()) + stored, rerr := filepath.Rel(topDir, absPath) + if rerr != nil || strings.HasPrefix(stored, "..") { + stored = absPath + } + + shared := filepath.Join(topDir, ".Trash") + if isValidSharedTrash(shared) { + return filepath.Join(shared, uid), stored, nil + } + return filepath.Join(topDir, ".Trash-"+uid), stored, nil +} + +// uniqueName returns a basename in trashDir that does not collide with an +// existing entry in either files/ or info/. +func uniqueName(trashDir, basename string) (string, error) { + filesDir := filepath.Join(trashDir, "files") + infoDir := filepath.Join(trashDir, "info") + if !exists(filepath.Join(filesDir, basename)) && !exists(filepath.Join(infoDir, basename+trashInfoExt)) { + return basename, nil + } + ext := filepath.Ext(basename) + stem := strings.TrimSuffix(basename, ext) + for i := 2; i < 100000; i++ { + candidate := fmt.Sprintf("%s.%d%s", stem, i, ext) + if !exists(filepath.Join(filesDir, candidate)) && !exists(filepath.Join(infoDir, candidate+trashInfoExt)) { + return candidate, nil + } + } + return "", errors.New("could not find unique trash name") +} + +func exists(p string) bool { + _, err := os.Lstat(p) + return err == nil +} + +// pathEncode percent-escapes a POSIX path per RFC 2396, preserving "/". +func pathEncode(p string) string { + parts := strings.Split(p, "/") + for i, seg := range parts { + parts[i] = url.PathEscape(seg) + } + return strings.Join(parts, "/") +} + +func pathDecode(p string) string { + if d, err := url.PathUnescape(p); err == nil { + return d + } + return p +} + +func writeTrashInfo(infoPath, storedPath string, when time.Time) error { + body := "[Trash Info]\nPath=" + pathEncode(storedPath) + + "\nDeletionDate=" + when.Format("2006-01-02T15:04:05") + "\n" + f, err := os.OpenFile(infoPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return err + } + defer f.Close() + _, err = f.WriteString(body) + return err +} + +// Put trashes a single file or directory. +func Put(path string) (Entry, error) { + abs, err := filepath.Abs(path) + if err != nil { + return Entry{}, err + } + info, err := os.Lstat(abs) + if err != nil { + return Entry{}, err + } + + trashDir, storedPath, err := trashDirForPath(abs) + if err != nil { + return Entry{}, err + } + if err := ensureTrashDirs(trashDir); err != nil { + return Entry{}, fmt.Errorf("create trash dir %s: %w", trashDir, err) + } + + name, err := uniqueName(trashDir, filepath.Base(abs)) + if err != nil { + return Entry{}, err + } + + infoPath := filepath.Join(trashDir, "info", name+trashInfoExt) + when := time.Now() + if err := writeTrashInfo(infoPath, storedPath, when); err != nil { + return Entry{}, err + } + + target := filepath.Join(trashDir, "files", name) + if err := os.Rename(abs, target); err != nil { + os.Remove(infoPath) + return Entry{}, err + } + + return Entry{ + Name: name, + OriginalPath: storedPath, + DeletionDate: when.Format("2006-01-02T15:04:05"), + TrashDir: trashDir, + FilesPath: target, + InfoPath: infoPath, + Size: info.Size(), + IsDir: info.IsDir(), + }, nil +} + +// allTrashDirs returns the home trash plus every per-mountpoint trash dir +// that exists (and passes the spec's safety checks for $topdir/.Trash). +func allTrashDirs() []string { + var dirs []string + if h, err := homeTrashDir(); err == nil { + dirs = append(dirs, h) + } + + uid := strconv.Itoa(os.Getuid()) + for _, mount := range readMountPoints() { + shared := filepath.Join(mount, ".Trash") + if isValidSharedTrash(shared) { + candidate := filepath.Join(shared, uid) + if info, err := os.Stat(candidate); err == nil && info.IsDir() { + dirs = append(dirs, candidate) + } + } + candidate := filepath.Join(mount, ".Trash-"+uid) + if info, err := os.Lstat(candidate); err == nil && info.IsDir() && info.Mode()&os.ModeSymlink == 0 { + dirs = append(dirs, candidate) + } + } + return dirs +} + +// readMountPoints returns user-visible mount points from /proc/self/mountinfo, +// skipping pseudo and system filesystems. +func readMountPoints() []string { + data, err := os.ReadFile("/proc/self/mountinfo") + if err != nil { + return nil + } + skipPrefixes := []string{"/proc", "/sys", "/dev"} + var out []string + seen := map[string]bool{} + for line := range strings.SplitSeq(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) < 5 { + continue + } + mp := fields[4] + if mp == "/" { + continue + } + skip := false + for _, p := range skipPrefixes { + if mp == p || strings.HasPrefix(mp, p+"/") { + skip = true + break + } + } + if skip || seen[mp] { + continue + } + seen[mp] = true + out = append(out, mp) + } + return out +} + +func List() ([]Entry, error) { + var entries []Entry + for _, d := range allTrashDirs() { + es, _ := listOne(d) + entries = append(entries, es...) + } + return entries, nil +} + +func listOne(trashDir string) ([]Entry, error) { + infoDir := filepath.Join(trashDir, "info") + filesDir := filepath.Join(trashDir, "files") + dir, err := os.ReadDir(infoDir) + if err != nil { + return nil, err + } + var entries []Entry + for _, ent := range dir { + if !strings.HasSuffix(ent.Name(), trashInfoExt) { + continue + } + name := strings.TrimSuffix(ent.Name(), trashInfoExt) + infoPath := filepath.Join(infoDir, ent.Name()) + filesPath := filepath.Join(filesDir, name) + + body, err := os.ReadFile(infoPath) + if err != nil { + continue + } + + e := Entry{Name: name, TrashDir: trashDir, InfoPath: infoPath, FilesPath: filesPath} + for line := range strings.SplitSeq(string(body), "\n") { + if v, ok := strings.CutPrefix(line, "Path="); ok { + e.OriginalPath = pathDecode(v) + continue + } + if v, ok := strings.CutPrefix(line, "DeletionDate="); ok { + e.DeletionDate = v + } + } + if info, err := os.Lstat(filesPath); err == nil { + e.Size = info.Size() + e.IsDir = info.IsDir() + } + entries = append(entries, e) + } + return entries, nil +} + +func Count() (int, error) { + n := 0 + for _, d := range allTrashDirs() { + ents, err := os.ReadDir(filepath.Join(d, "info")) + if err != nil { + continue + } + for _, e := range ents { + if strings.HasSuffix(e.Name(), trashInfoExt) { + n++ + } + } + } + return n, nil +} + +func Empty() error { + var firstErr error + for _, d := range allTrashDirs() { + if err := emptyOne(d); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr +} + +func emptyOne(trashDir string) error { + var firstErr error + for _, sub := range []string{"files", "info"} { + path := filepath.Join(trashDir, sub) + ents, err := os.ReadDir(path) + if err != nil { + continue + } + for _, e := range ents { + if err := os.RemoveAll(filepath.Join(path, e.Name())); err != nil && firstErr == nil { + firstErr = err + } + } + } + os.Remove(filepath.Join(trashDir, "directorysizes")) + return firstErr +} + +// Restore returns a trashed item to its original location. +func Restore(name, trashDir string) error { + if trashDir == "" { + h, err := homeTrashDir() + if err != nil { + return err + } + trashDir = h + } + + infoPath := filepath.Join(trashDir, "info", name+trashInfoExt) + filesPath := filepath.Join(trashDir, "files", name) + + body, err := os.ReadFile(infoPath) + if err != nil { + return err + } + + var stored string + for line := range strings.SplitSeq(string(body), "\n") { + if v, ok := strings.CutPrefix(line, "Path="); ok { + stored = pathDecode(v) + break + } + } + if stored == "" { + return errors.New("invalid .trashinfo: missing Path") + } + + target := stored + if !filepath.IsAbs(stored) { + topDir := filepath.Dir(trashDir) + if filepath.Base(topDir) == ".Trash" { + topDir = filepath.Dir(topDir) + } + target = filepath.Join(topDir, stored) + } + + if exists(target) { + return fmt.Errorf("restore target already exists: %s", target) + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + if err := os.Rename(filesPath, target); err != nil { + return err + } + os.Remove(infoPath) + return nil +} diff --git a/core/internal/trash/trash_test.go b/core/internal/trash/trash_test.go new file mode 100644 index 00000000..fc533564 --- /dev/null +++ b/core/internal/trash/trash_test.go @@ -0,0 +1,315 @@ +package trash + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func setupHomeTrash(t *testing.T) (homeRoot string, trashDir string) { + t.Helper() + homeRoot = t.TempDir() + xdg := filepath.Join(homeRoot, ".local", "share") + if err := os.MkdirAll(xdg, 0o700); err != nil { + t.Fatalf("mkdir xdg: %v", err) + } + t.Setenv("XDG_DATA_HOME", xdg) + t.Setenv("HOME", homeRoot) + trashDir = filepath.Join(xdg, "Trash") + return homeRoot, trashDir +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +func TestPutHomeTrashAbsolutePath(t *testing.T) { + homeRoot, trashDir := setupHomeTrash(t) + + src := filepath.Join(homeRoot, "doc.txt") + writeFile(t, src, "hi") + + entry, err := Put(src) + if err != nil { + t.Fatalf("Put: %v", err) + } + + if entry.Name != "doc.txt" { + t.Errorf("name = %q, want doc.txt", entry.Name) + } + if entry.OriginalPath != src { + t.Errorf("originalPath = %q, want %q", entry.OriginalPath, src) + } + if entry.TrashDir != trashDir { + t.Errorf("trashDir = %q, want %q", entry.TrashDir, trashDir) + } + if _, err := os.Stat(src); !os.IsNotExist(err) { + t.Errorf("source still exists: %v", err) + } + + body, err := os.ReadFile(filepath.Join(trashDir, "info", "doc.txt.trashinfo")) + if err != nil { + t.Fatalf("read trashinfo: %v", err) + } + if !strings.HasPrefix(string(body), "[Trash Info]\n") { + t.Errorf("trashinfo missing header: %q", body) + } + if !strings.Contains(string(body), "Path="+src+"\n") { + t.Errorf("Path key missing or wrong: %q", body) + } + if !strings.Contains(string(body), "DeletionDate=") { + t.Errorf("DeletionDate missing: %q", body) + } +} + +func TestPutPercentEncodesPath(t *testing.T) { + homeRoot, trashDir := setupHomeTrash(t) + + name := "spaces & %.txt" + src := filepath.Join(homeRoot, name) + writeFile(t, src, "x") + + if _, err := Put(src); err != nil { + t.Fatalf("Put: %v", err) + } + + body, err := os.ReadFile(filepath.Join(trashDir, "info", name+".trashinfo")) + if err != nil { + t.Fatalf("read: %v", err) + } + want := "Path=" + filepath.Dir(src) + "/spaces%20&%20%25.txt" + if !strings.Contains(string(body), want) { + t.Errorf("expected %q in %q", want, body) + } +} + +func TestPutCollisionGetsUniqueName(t *testing.T) { + homeRoot, trashDir := setupHomeTrash(t) + + for i := range 3 { + src := filepath.Join(homeRoot, "dup.txt") + writeFile(t, src, "x") + if _, err := Put(src); err != nil { + t.Fatalf("Put #%d: %v", i, err) + } + } + + want := []string{"dup.txt", "dup.2.txt", "dup.3.txt"} + for _, n := range want { + if _, err := os.Stat(filepath.Join(trashDir, "files", n)); err != nil { + t.Errorf("expected %s in trash: %v", n, err) + } + if _, err := os.Stat(filepath.Join(trashDir, "info", n+".trashinfo")); err != nil { + t.Errorf("expected %s.trashinfo: %v", n, err) + } + } +} + +func TestListAndCount(t *testing.T) { + homeRoot, _ := setupHomeTrash(t) + + if n, _ := Count(); n != 0 { + t.Errorf("initial count = %d, want 0", n) + } + entries, _ := List() + if len(entries) != 0 { + t.Errorf("initial list len = %d, want 0", len(entries)) + } + + for _, n := range []string{"a.txt", "b.txt", "c.log"} { + src := filepath.Join(homeRoot, n) + writeFile(t, src, n) + if _, err := Put(src); err != nil { + t.Fatalf("Put %s: %v", n, err) + } + } + + got, _ := Count() + if got != 3 { + t.Errorf("count = %d, want 3", got) + } + entries, _ = List() + if len(entries) != 3 { + t.Errorf("list len = %d, want 3", len(entries)) + } + for _, e := range entries { + if e.OriginalPath == "" { + t.Errorf("entry %s: empty OriginalPath", e.Name) + } + if _, err := time.Parse("2006-01-02T15:04:05", e.DeletionDate); err != nil { + t.Errorf("entry %s: bad DeletionDate %q: %v", e.Name, e.DeletionDate, err) + } + } +} + +func TestEmptyClearsAll(t *testing.T) { + homeRoot, trashDir := setupHomeTrash(t) + + for _, n := range []string{"x", "y", "z"} { + src := filepath.Join(homeRoot, n) + writeFile(t, src, n) + if _, err := Put(src); err != nil { + t.Fatalf("Put: %v", err) + } + } + if n, _ := Count(); n != 3 { + t.Fatalf("pre-empty count = %d", n) + } + + if err := Empty(); err != nil { + t.Fatalf("Empty: %v", err) + } + + if n, _ := Count(); n != 0 { + t.Errorf("post-empty count = %d, want 0", n) + } + for _, sub := range []string{"files", "info"} { + ents, err := os.ReadDir(filepath.Join(trashDir, sub)) + if err != nil { + t.Fatalf("readdir %s: %v", sub, err) + } + if len(ents) != 0 { + t.Errorf("%s/ has %d entries, want 0", sub, len(ents)) + } + } +} + +func TestRestoreToOriginalPath(t *testing.T) { + homeRoot, trashDir := setupHomeTrash(t) + + src := filepath.Join(homeRoot, "sub", "dir", "thing.txt") + writeFile(t, src, "payload") + + entry, err := Put(src) + if err != nil { + t.Fatalf("Put: %v", err) + } + + os.RemoveAll(filepath.Join(homeRoot, "sub")) + + if err := Restore(entry.Name, trashDir); err != nil { + t.Fatalf("Restore: %v", err) + } + + body, err := os.ReadFile(src) + if err != nil { + t.Fatalf("read restored: %v", err) + } + if string(body) != "payload" { + t.Errorf("restored content = %q, want %q", body, "payload") + } + + if _, err := os.Stat(entry.InfoPath); !os.IsNotExist(err) { + t.Errorf("info file still present: %v", err) + } + if _, err := os.Stat(entry.FilesPath); !os.IsNotExist(err) { + t.Errorf("files entry still present: %v", err) + } +} + +func TestRestoreRefusesToOverwrite(t *testing.T) { + homeRoot, trashDir := setupHomeTrash(t) + + src := filepath.Join(homeRoot, "keep.txt") + writeFile(t, src, "v1") + + entry, err := Put(src) + if err != nil { + t.Fatalf("Put: %v", err) + } + + writeFile(t, src, "v2-blocking") + + err = Restore(entry.Name, trashDir) + if err == nil { + t.Fatalf("expected error on conflicting restore, got nil") + } + if !strings.Contains(err.Error(), "exists") { + t.Errorf("error %q does not mention conflict", err) + } + + body, _ := os.ReadFile(src) + if string(body) != "v2-blocking" { + t.Errorf("blocking file altered: %q", body) + } +} + +func TestPutDirectory(t *testing.T) { + homeRoot, trashDir := setupHomeTrash(t) + + dir := filepath.Join(homeRoot, "myfolder") + writeFile(t, filepath.Join(dir, "child.txt"), "inside") + + entry, err := Put(dir) + if err != nil { + t.Fatalf("Put dir: %v", err) + } + if !entry.IsDir { + t.Errorf("IsDir = false, want true") + } + + moved := filepath.Join(trashDir, "files", "myfolder", "child.txt") + body, err := os.ReadFile(moved) + if err != nil { + t.Fatalf("read moved child: %v", err) + } + if string(body) != "inside" { + t.Errorf("child content = %q", body) + } +} + +func TestIsValidSharedTrashRejectsSymlink(t *testing.T) { + tmp := t.TempDir() + target := filepath.Join(tmp, "real") + if err := os.MkdirAll(target, os.ModeSticky|0o777); err != nil { + t.Fatalf("mkdir target: %v", err) + } + + link := filepath.Join(tmp, ".Trash") + if err := os.Symlink(target, link); err != nil { + t.Fatalf("symlink: %v", err) + } + if isValidSharedTrash(link) { + t.Errorf("symlinked .Trash accepted; spec requires rejection") + } +} + +func TestIsValidSharedTrashRequiresStickyBit(t *testing.T) { + tmp := t.TempDir() + dir := filepath.Join(tmp, ".Trash") + if err := os.MkdirAll(dir, 0o777); err != nil { + t.Fatalf("mkdir: %v", err) + } + if isValidSharedTrash(dir) { + t.Errorf(".Trash without sticky bit accepted; spec requires rejection") + } + + if err := os.Chmod(dir, os.ModeSticky|0o777); err != nil { + t.Fatalf("chmod: %v", err) + } + if !isValidSharedTrash(dir) { + t.Errorf(".Trash with sticky bit rejected; spec accepts it") + } +} + +func TestPathEncodeRoundTrip(t *testing.T) { + cases := []string{ + "/home/u/file.txt", + "/path with spaces/and-symbols & %.txt", + "relative/path/é unicode.md", + } + for _, in := range cases { + got := pathDecode(pathEncode(in)) + if got != in { + t.Errorf("round-trip %q -> %q", in, got) + } + } +} diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index 6fe0cb52..bf5ebde5 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -546,7 +546,7 @@ Singleton { property int dockMaxVisibleRunningApps: 0 property bool dockShowOverflowBadge: true property bool dockShowTrash: false - property string dockTrashFileManager: "nautilus" + property string dockTrashFileManager: "default" property string dockTrashCustomCommand: "" property bool notificationOverlayEnabled: false diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index b7d81ef0..2bb88a6d 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -351,7 +351,7 @@ var SPEC = { dockMaxVisibleRunningApps: { def: 0 }, dockShowOverflowBadge: { def: true }, dockShowTrash: { def: false }, - dockTrashFileManager: { def: "nautilus" }, + dockTrashFileManager: { def: "default" }, dockTrashCustomCommand: { def: "" }, notificationOverlayEnabled: { def: false }, diff --git a/quickshell/Modals/FileBrowser/FileBrowserContent.qml b/quickshell/Modals/FileBrowser/FileBrowserContent.qml index 8b96742d..ff10bfa6 100644 --- a/quickshell/Modals/FileBrowser/FileBrowserContent.qml +++ b/quickshell/Modals/FileBrowser/FileBrowserContent.qml @@ -158,6 +158,13 @@ FocusScope { selectedFileIsDir = isDir; } + function openItemContextMenu(sender, localX, localY, path, name, isDir) { + if (!sender) + return; + const pos = sender.mapToItem(root, localX, localY); + itemContextMenu.showAt(root, pos.x, pos.y, path, name, isDir); + } + function navigateUp() { const path = currentPath; if (path === homeDir) @@ -759,6 +766,9 @@ FocusScope { onItemSelected: (index, path, name, isDir) => { setSelectedFileData(path, name, isDir); } + onItemContextMenuRequested: (sender, localX, localY, path, name, isDir) => { + root.openItemContextMenu(sender, localX, localY, path, name, isDir); + } Connections { function onKeyboardSelectionRequestedChanged() { @@ -817,6 +827,9 @@ FocusScope { onItemSelected: (index, path, name, isDir) => { setSelectedFileData(path, name, isDir); } + onItemContextMenuRequested: (sender, localX, localY, path, name, isDir) => { + root.openItemContextMenu(sender, localX, localY, path, name, isDir); + } Connections { function onKeyboardSelectionRequestedChanged() { @@ -917,4 +930,9 @@ FocusScope { } } } + + FileBrowserItemContextMenu { + id: itemContextMenu + parentFocusItem: root + } } diff --git a/quickshell/Modals/FileBrowser/FileBrowserGridDelegate.qml b/quickshell/Modals/FileBrowser/FileBrowserGridDelegate.qml index effa9bd7..8ecd3abe 100644 --- a/quickshell/Modals/FileBrowser/FileBrowserGridDelegate.qml +++ b/quickshell/Modals/FileBrowser/FileBrowserGridDelegate.qml @@ -19,6 +19,7 @@ StyledRect { signal itemClicked(int index, string path, string name, bool isDir) signal itemSelected(int index, string path, string name, bool isDir) + signal itemContextMenuRequested(var sender, real localX, real localY, string path, string name, bool isDir) function getFileExtension(fileName) { const parts = fileName.split('.'); @@ -107,11 +108,11 @@ StyledRect { const size = _thumbnailPx; const fp = delegateRoot.filePath; Paths.mkdir(thumbDir); - Proc.runCommand(null, ["test", "-f", thumbPath], function(output, exitCode) { + Proc.runCommand(null, ["test", "-f", thumbPath], function (output, exitCode) { if (exitCode === 0) { _videoThumb = thumbPath; } else { - Proc.runCommand(null, ["ffmpegthumbnailer", "-i", fp, "-o", thumbPath, "-s", String(size), "-f"], function(output, exitCode) { + Proc.runCommand(null, ["ffmpegthumbnailer", "-i", fp, "-o", thumbPath, "-s", String(size), "-f"], function (output, exitCode) { if (exitCode === 0) _videoThumb = thumbPath; }); @@ -246,8 +247,16 @@ StyledRect { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor - onClicked: { - itemClicked(delegateRoot.index, delegateRoot.filePath, delegateRoot.fileName, delegateRoot.fileIsDir); + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: mouse => { + switch (mouse.button) { + case Qt.LeftButton: + itemClicked(delegateRoot.index, delegateRoot.filePath, delegateRoot.fileName, delegateRoot.fileIsDir); + break; + case Qt.RightButton: + itemContextMenuRequested(delegateRoot, mouse.x, mouse.y, delegateRoot.filePath, delegateRoot.fileName, delegateRoot.fileIsDir); + break; + } } } } diff --git a/quickshell/Modals/FileBrowser/FileBrowserItemContextMenu.qml b/quickshell/Modals/FileBrowser/FileBrowserItemContextMenu.qml new file mode 100644 index 00000000..13e3dae5 --- /dev/null +++ b/quickshell/Modals/FileBrowser/FileBrowserItemContextMenu.qml @@ -0,0 +1,153 @@ +import QtQuick +import QtQuick.Controls +import Quickshell +import qs.Common +import qs.Services +import qs.Widgets + +Popup { + id: root + + property string filePath: "" + property string fileName: "" + property bool fileIsDir: false + property var parentFocusItem: null + + signal trashed + signal menuClosed + + readonly property var menuItems: [ + { + text: I18n.tr("Move to Trash"), + icon: "delete", + action: trashItem, + enabled: filePath.length > 0, + dangerous: true + }, + { + text: I18n.tr("Copy Path"), + icon: "content_copy", + action: copyPath, + enabled: filePath.length > 0 + } + ] + + function showAt(parentItem, localX, localY, path, name, isDir) { + if (!parentItem) + return; + parent = parentItem; + filePath = path || ""; + fileName = name || ""; + fileIsDir = !!isDir; + x = Math.max(0, Math.min(parentItem.width - width, localX)); + y = Math.max(0, Math.min(parentItem.height - height, localY)); + open(); + } + + function trashItem() { + if (!filePath) + return; + TrashService.trashPath(filePath, ok => { + if (ok) + root.trashed(); + }); + close(); + } + + function copyPath() { + if (!filePath) + return; + Quickshell.execDetached(["dms", "cl", "copy", filePath]); + close(); + } + + width: 220 + height: menuColumn.implicitHeight + Theme.spacingS * 2 + padding: 0 + modal: false + closePolicy: Popup.CloseOnEscape + + onClosed: { + closePolicy = Popup.CloseOnEscape; + menuClosed(); + if (parentFocusItem) + Qt.callLater(() => parentFocusItem.forceActiveFocus()); + } + + onOpened: outsideClickTimer.start() + + Timer { + id: outsideClickTimer + interval: 100 + onTriggered: root.closePolicy = Popup.CloseOnEscape | Popup.CloseOnPressOutside + } + + background: Rectangle { + color: "transparent" + } + + contentItem: Rectangle { + color: Theme.floatingSurface + radius: Theme.cornerRadius + border.color: BlurService.enabled ? BlurService.borderColor : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08) + border.width: BlurService.enabled ? BlurService.borderWidth : 1 + + Column { + id: menuColumn + anchors.fill: parent + anchors.margins: Theme.spacingS + spacing: 1 + + Repeater { + model: root.menuItems + + Rectangle { + width: parent.width + height: 32 + radius: Theme.cornerRadius + opacity: modelData.enabled ? 1 : 0.5 + color: { + if (!modelData.enabled || !area.containsMouse) + return "transparent"; + if (modelData.dangerous) + return Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.12); + return BlurService.hoverColor(Theme.widgetBaseHoverColor); + } + + Row { + anchors.left: parent.left + anchors.leftMargin: Theme.spacingS + anchors.right: parent.right + anchors.rightMargin: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingS + + DankIcon { + anchors.verticalCenter: parent.verticalCenter + name: modelData.icon + size: 16 + color: modelData.dangerous && area.containsMouse && modelData.enabled ? Theme.error : Theme.surfaceText + } + + StyledText { + anchors.verticalCenter: parent.verticalCenter + text: modelData.text + font.pixelSize: Theme.fontSizeSmall + color: modelData.dangerous && area.containsMouse && modelData.enabled ? Theme.error : Theme.surfaceText + elide: Text.ElideRight + } + } + + MouseArea { + id: area + anchors.fill: parent + hoverEnabled: true + enabled: modelData.enabled + cursorShape: Qt.PointingHandCursor + onClicked: modelData.action() + } + } + } + } + } +} diff --git a/quickshell/Modals/FileBrowser/FileBrowserListDelegate.qml b/quickshell/Modals/FileBrowser/FileBrowserListDelegate.qml index 2f452b45..63415419 100644 --- a/quickshell/Modals/FileBrowser/FileBrowserListDelegate.qml +++ b/quickshell/Modals/FileBrowser/FileBrowserListDelegate.qml @@ -18,6 +18,7 @@ StyledRect { signal itemClicked(int index, string path, string name, bool isDir) signal itemSelected(int index, string path, string name, bool isDir) + signal itemContextMenuRequested(var sender, real localX, real localY, string path, string name, bool isDir) function getFileExtension(fileName) { const parts = fileName.split('.'); @@ -102,11 +103,11 @@ StyledRect { const thumbPath = videoThumbnailPath; const fp = listDelegateRoot.filePath; Paths.mkdir(_xdgCacheHome + "/thumbnails/normal"); - Proc.runCommand(null, ["test", "-f", thumbPath], function(output, exitCode) { + Proc.runCommand(null, ["test", "-f", thumbPath], function (output, exitCode) { if (exitCode === 0) { _videoThumb = thumbPath; } else { - Proc.runCommand(null, ["ffmpegthumbnailer", "-i", fp, "-o", thumbPath, "-s", "128", "-f"], function(output, exitCode) { + Proc.runCommand(null, ["ffmpegthumbnailer", "-i", fp, "-o", thumbPath, "-s", "128", "-f"], function (output, exitCode) { if (exitCode === 0) _videoThumb = thumbPath; }); @@ -251,8 +252,16 @@ StyledRect { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor - onClicked: { - itemClicked(listDelegateRoot.index, listDelegateRoot.filePath, listDelegateRoot.fileName, listDelegateRoot.fileIsDir); + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: mouse => { + switch (mouse.button) { + case Qt.LeftButton: + itemClicked(listDelegateRoot.index, listDelegateRoot.filePath, listDelegateRoot.fileName, listDelegateRoot.fileIsDir); + break; + case Qt.RightButton: + itemContextMenuRequested(listDelegateRoot, mouse.x, mouse.y, listDelegateRoot.filePath, listDelegateRoot.fileName, listDelegateRoot.fileIsDir); + break; + } } } } diff --git a/quickshell/Modules/Dock/DockApps.qml b/quickshell/Modules/Dock/DockApps.qml index eb152848..e11296c8 100644 --- a/quickshell/Modules/Dock/DockApps.qml +++ b/quickshell/Modules/Dock/DockApps.qml @@ -479,14 +479,33 @@ Item { delegate: Item { id: delegateItem - property var dockButton: itemData.type === "launcher" ? launcherButton : (itemData.type === "trash" ? trashButton : button) + property var dockButton: { + switch (itemData.type) { + case "launcher": + return launcherButton; + case "trash": + return trashButton; + default: + return button; + } + } property var itemData: modelData readonly property bool isOverflowToggle: itemData.type === "overflow-toggle" readonly property bool isTrash: itemData.type === "trash" readonly property bool isInOverflow: itemData.isInOverflow === true + readonly property bool isDragging: { + switch (itemData.type) { + case "launcher": + return launcherButton.dragging; + case "trash": + return false; + default: + return button.dragging; + } + } clip: false - z: (itemData.type === "launcher" ? launcherButton.dragging : (itemData.type === "trash" ? false : button.dragging)) ? 100 : 0 + z: isDragging ? 100 : 0 visible: !isInOverflow || root.overflowExpanded opacity: (isInOverflow && !root.overflowExpanded) ? 0 : 1 scale: (isInOverflow && !root.overflowExpanded) ? 0.8 : 1 diff --git a/quickshell/Modules/Dock/DockContextMenu.qml b/quickshell/Modules/Dock/DockContextMenu.qml index 61b4bbc9..717066b3 100644 --- a/quickshell/Modules/Dock/DockContextMenu.qml +++ b/quickshell/Modules/Dock/DockContextMenu.qml @@ -1,585 +1,390 @@ import QtQuick -import Quickshell -import Quickshell.Wayland import Quickshell.Widgets import qs.Common import qs.Services import qs.Widgets -PanelWindow { +DockContextMenuBase { id: root - WindowBlur { - targetWindow: root - blurX: menuContainer.x - blurY: menuContainer.y - blurWidth: root.visible ? menuContainer.width : 0 - blurHeight: root.visible ? menuContainer.height : 0 - blurRadius: Theme.cornerRadius - } - - WlrLayershell.namespace: "dms:dock-context-menu" - property var appData: null - property var anchorItem: null - property real dockVisibleHeight: 40 - property int margin: 10 property bool hidePin: false property var desktopEntry: null - property bool isDmsWindow: appData?.appId === "org.quickshell" || appData?.appId === "com.danklinux.dms" property var dockApps: null + readonly property bool isDmsWindow: appData?.appId === "org.quickshell" || appData?.appId === "com.danklinux.dms" + + layerNamespace: "dms:dock-context-menu" function showForButton(button, data, dockHeight, hidePinOption, entry, dockScreen, parentDockApps) { - if (dockScreen) { - root.screen = dockScreen; - } - - anchorItem = button; appData = data; - dockVisibleHeight = dockHeight || 40; hidePin = hidePinOption || false; desktopEntry = entry || null; dockApps = parentDockApps || null; - - visible = true; - } - function close() { - visible = false; + show(button, dockHeight, dockScreen); } - screen: null - visible: false - WlrLayershell.layer: WlrLayershell.Overlay - WlrLayershell.exclusiveZone: -1 - WlrLayershell.keyboardFocus: WlrKeyboardFocus.None - color: "transparent" - anchors { - top: true - left: true - right: true - bottom: true - } + Repeater { + model: { + if (!root.appData || root.appData.type !== "grouped") + return []; - property point anchorPos: Qt.point(screen.width / 2, screen.height - 100) - - onAnchorItemChanged: updatePosition() - onVisibleChanged: { - if (visible) { - updatePosition(); - } - } - - function updatePosition() { - if (!anchorItem) { - anchorPos = Qt.point(screen.width / 2, screen.height - 100); - return; - } - - const dockWindow = anchorItem.Window.window; - if (!dockWindow) { - anchorPos = Qt.point(screen.width / 2, screen.height - 100); - return; - } - - const buttonPosInDock = anchorItem.mapToItem(dockWindow.contentItem, 0, 0); - let actualDockHeight = root.dockVisibleHeight; - - function findDockBackground(item) { - if (item.objectName === "dockBackground") { - return item; - } - for (var i = 0; i < item.children.length; i++) { - const found = findDockBackground(item.children[i]); - if (found) { - return found; + const toplevels = []; + const allToplevels = ToplevelManager.toplevels.values; + for (let i = 0; i < allToplevels.length; i++) { + const toplevel = allToplevels[i]; + if (toplevel.appId === root.appData.appId) { + toplevels.push(toplevel); } } - return null; - } - - const dockBackground = findDockBackground(dockWindow.contentItem); - let actualDockWidth = dockWindow.width; - if (dockBackground) { - actualDockHeight = dockBackground.height; - actualDockWidth = dockBackground.width; - } - - const isVertical = SettingsData.dockPosition === SettingsData.Position.Left || SettingsData.dockPosition === SettingsData.Position.Right; - const dockMargin = SettingsData.dockMargin + 16; - let buttonScreenX, buttonScreenY; - - if (isVertical) { - const dockContentHeight = dockWindow.height; - const screenHeight = root.screen.height; - const dockTopMargin = Math.round((screenHeight - dockContentHeight) / 2); - buttonScreenY = dockTopMargin + buttonPosInDock.y + anchorItem.height / 2; - - if (SettingsData.dockPosition === SettingsData.Position.Right) { - buttonScreenX = root.screen.width - actualDockWidth - dockMargin - 20; - } else { - buttonScreenX = actualDockWidth + dockMargin + 20; - } - } else { - const isDockAtBottom = SettingsData.dockPosition === SettingsData.Position.Bottom; - - if (isDockAtBottom) { - buttonScreenY = root.screen.height - actualDockHeight - dockMargin - 20; - } else { - buttonScreenY = actualDockHeight + dockMargin + 20; - } - - const dockContentWidth = dockWindow.width; - const screenWidth = root.screen.width; - const dockLeftMargin = Math.round((screenWidth - dockContentWidth) / 2); - buttonScreenX = dockLeftMargin + buttonPosInDock.x + anchorItem.width / 2; - } - - anchorPos = Qt.point(buttonScreenX, buttonScreenY); - } - - Rectangle { - id: menuContainer - - x: { - const isVertical = SettingsData.dockPosition === SettingsData.Position.Left || SettingsData.dockPosition === SettingsData.Position.Right; - if (isVertical) { - const isDockAtRight = SettingsData.dockPosition === SettingsData.Position.Right; - if (isDockAtRight) { - return Math.max(10, root.anchorPos.x - width + 30); - } else { - return Math.min(root.width - width - 10, root.anchorPos.x - 30); - } - } else { - const left = 10; - const right = root.width - width - 10; - const want = root.anchorPos.x - width / 2; - return Math.max(left, Math.min(right, want)); - } - } - y: { - const isVertical = SettingsData.dockPosition === SettingsData.Position.Left || SettingsData.dockPosition === SettingsData.Position.Right; - if (isVertical) { - const top = 10; - const bottom = root.height - height - 10; - const want = root.anchorPos.y - height / 2; - return Math.max(top, Math.min(bottom, want)); - } else { - const isDockAtBottom = SettingsData.dockPosition === SettingsData.Position.Bottom; - if (isDockAtBottom) { - return Math.max(10, root.anchorPos.y - height + 30); - } else { - return Math.min(root.height - height - 10, root.anchorPos.y - 30); - } - } - } - - width: Math.min(400, Math.max(180, menuColumn.implicitWidth + Theme.spacingS * 2)) - height: menuColumn.implicitHeight + Theme.spacingS * 2 - color: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency) - radius: Theme.cornerRadius - border.color: BlurService.enabled ? BlurService.borderColor : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08) - border.width: BlurService.enabled ? BlurService.borderWidth : 1 - - opacity: root.visible ? 1 : 0 - visible: opacity > 0 - - Behavior on opacity { - NumberAnimation { - duration: Theme.shortDuration - easing.type: Theme.emphasizedEasing - } + return toplevels; } Rectangle { - anchors.fill: parent - anchors.topMargin: 4 - anchors.leftMargin: 2 - anchors.rightMargin: -2 - anchors.bottomMargin: -4 - radius: parent.radius - color: Qt.rgba(0, 0, 0, 0.15) - z: -1 - } + width: parent.width + height: 28 + radius: Theme.cornerRadius + color: windowArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent" - Column { - id: menuColumn - width: parent.width - Theme.spacingS * 2 - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: parent.top - anchors.topMargin: Theme.spacingS - spacing: 1 - - // Window list for grouped apps - Repeater { - model: { - if (!root.appData || root.appData.type !== "grouped") - return []; - - const toplevels = []; - const allToplevels = ToplevelManager.toplevels.values; - for (let i = 0; i < allToplevels.length; i++) { - const toplevel = allToplevels[i]; - if (toplevel.appId === root.appData.appId) { - toplevels.push(toplevel); - } - } - return toplevels; - } - - Rectangle { - width: parent.width - height: 28 - radius: Theme.cornerRadius - color: windowArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent" - - StyledText { - anchors.left: parent.left - anchors.leftMargin: Theme.spacingS - anchors.right: closeButton.left - anchors.rightMargin: Theme.spacingXS - anchors.verticalCenter: parent.verticalCenter - text: (modelData && modelData.title) ? modelData.title : I18n.tr("(Unnamed)") - font.pixelSize: Theme.fontSizeSmall - color: Theme.surfaceText - font.weight: Font.Normal - elide: Text.ElideRight - wrapMode: Text.NoWrap - } - - Rectangle { - id: closeButton - anchors.right: parent.right - anchors.rightMargin: Theme.spacingXS - anchors.verticalCenter: parent.verticalCenter - width: 20 - height: 20 - radius: 10 - color: closeMouseArea.containsMouse ? Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.2) : "transparent" - - DankIcon { - anchors.centerIn: parent - name: "close" - size: 12 - color: closeMouseArea.containsMouse ? Theme.error : Theme.surfaceText - } - - MouseArea { - id: closeMouseArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - if (modelData && modelData.close) { - modelData.close(); - } - root.close(); - } - } - } - - DankRipple { - id: windowRipple - rippleColor: Theme.surfaceText - cornerRadius: Theme.cornerRadius - } - - MouseArea { - id: windowArea - anchors.fill: parent - anchors.rightMargin: 24 - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onPressed: mouse => windowRipple.trigger(mouse.x, mouse.y) - onClicked: { - if (modelData && modelData.activate) { - modelData.activate(); - } - root.close(); - } - } - } + StyledText { + anchors.left: parent.left + anchors.leftMargin: Theme.spacingS + anchors.right: closeButton.left + anchors.rightMargin: Theme.spacingXS + anchors.verticalCenter: parent.verticalCenter + text: (modelData && modelData.title) ? modelData.title : I18n.tr("(Unnamed)") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + font.weight: Font.Normal + elide: Text.ElideRight + wrapMode: Text.NoWrap } Rectangle { - visible: { - if (!root.appData) - return false; - if (root.appData.type !== "grouped") - return false; - return root.appData.windowCount > 0; - } - width: parent.width - height: 1 - color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2) - } + id: closeButton + anchors.right: parent.right + anchors.rightMargin: Theme.spacingXS + anchors.verticalCenter: parent.verticalCenter + width: 20 + height: 20 + radius: 10 + color: closeMouseArea.containsMouse ? Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.2) : "transparent" - Repeater { - model: root.desktopEntry && root.desktopEntry.actions ? root.desktopEntry.actions : [] - - Rectangle { - width: parent.width - height: 28 - radius: Theme.cornerRadius - color: actionArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent" - - Row { - anchors.left: parent.left - anchors.leftMargin: Theme.spacingS - anchors.right: parent.right - anchors.rightMargin: Theme.spacingS - anchors.verticalCenter: parent.verticalCenter - spacing: Theme.spacingXS - - Item { - anchors.verticalCenter: parent.verticalCenter - width: 16 - height: 16 - visible: modelData.icon && modelData.icon !== "" - - IconImage { - anchors.fill: parent - source: modelData.icon ? Paths.resolveIconPath(modelData.icon) : "" - smooth: true - asynchronous: true - visible: status === Image.Ready - } - } - - StyledText { - anchors.verticalCenter: parent.verticalCenter - text: modelData.name || "" - font.pixelSize: Theme.fontSizeSmall - color: Theme.surfaceText - font.weight: Font.Normal - elide: Text.ElideRight - wrapMode: Text.NoWrap - } - } - - DankRipple { - id: actionRipple - rippleColor: Theme.surfaceText - cornerRadius: Theme.cornerRadius - } - - MouseArea { - id: actionArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onPressed: mouse => actionRipple.trigger(mouse.x, mouse.y) - onClicked: { - if (modelData) { - SessionService.launchDesktopAction(root.desktopEntry, modelData); - } - root.close(); - } - } - } - } - - Rectangle { - visible: { - if (!root.desktopEntry?.actions || root.desktopEntry.actions.length === 0) { - return false; - } - return !root.hidePin || (!root.isDmsWindow && root.desktopEntry && SessionService.nvidiaCommand); - } - width: parent.width - height: 1 - color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2) - } - - Rectangle { - visible: !root.hidePin - width: parent.width - height: 28 - radius: Theme.cornerRadius - color: pinArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent" - - Row { - anchors.left: parent.left - anchors.leftMargin: Theme.spacingS - anchors.right: parent.right - anchors.rightMargin: Theme.spacingS - anchors.verticalCenter: parent.verticalCenter - spacing: Theme.spacingXS - - DankIcon { - anchors.verticalCenter: parent.verticalCenter - name: root.appData && root.appData.isPinned ? "keep_off" : "push_pin" - size: 14 - color: Theme.surfaceText - opacity: 0.7 - } - - StyledText { - anchors.verticalCenter: parent.verticalCenter - text: root.appData && root.appData.isPinned ? I18n.tr("Unpin from Dock") : I18n.tr("Pin to Dock") - font.pixelSize: Theme.fontSizeSmall - color: Theme.surfaceText - font.weight: Font.Normal - elide: Text.ElideRight - wrapMode: Text.NoWrap - } - } - - DankRipple { - id: pinRipple - rippleColor: Theme.surfaceText - cornerRadius: Theme.cornerRadius + DankIcon { + anchors.centerIn: parent + name: "close" + size: 12 + color: closeMouseArea.containsMouse ? Theme.error : Theme.surfaceText } MouseArea { - id: pinArea + id: closeMouseArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor - onPressed: mouse => pinRipple.trigger(mouse.x, mouse.y) onClicked: { - if (!root.appData) - return; - - if (root.appData.isPinned) { - SessionData.removePinnedApp(root.appData.appId); - } else { - SessionData.addPinnedApp(root.appData.appId); + if (modelData && modelData.close) { + modelData.close(); } root.close(); } } } - Rectangle { - visible: { - const hasNvidia = !root.isDmsWindow && root.desktopEntry && SessionService.nvidiaCommand; - const hasWindow = root.appData && (root.appData.type === "window" || (root.appData.type === "grouped" && root.appData.windowCount > 0)); - const hasPinOption = !root.hidePin; - const hasContentAbove = hasPinOption || hasNvidia; - return hasContentAbove && hasWindow; - } - width: parent.width - height: 1 - color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2) + DankRipple { + id: windowRipple + rippleColor: Theme.surfaceText + cornerRadius: Theme.cornerRadius } - Rectangle { - visible: !root.isDmsWindow && root.desktopEntry && SessionService.nvidiaCommand - width: parent.width - height: 28 - radius: Theme.cornerRadius - color: nvidiaArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent" - - Row { - anchors.left: parent.left - anchors.leftMargin: Theme.spacingS - anchors.right: parent.right - anchors.rightMargin: Theme.spacingS - anchors.verticalCenter: parent.verticalCenter - spacing: Theme.spacingXS - - DankIcon { - anchors.verticalCenter: parent.verticalCenter - name: "memory" - size: 14 - color: Theme.surfaceText - opacity: 0.7 - } - - StyledText { - anchors.verticalCenter: parent.verticalCenter - text: I18n.tr("Launch on dGPU") - font.pixelSize: Theme.fontSizeSmall - color: Theme.surfaceText - font.weight: Font.Normal - elide: Text.ElideRight - wrapMode: Text.NoWrap - } - } - - DankRipple { - id: nvidiaRipple - rippleColor: Theme.surfaceText - cornerRadius: Theme.cornerRadius - } - - MouseArea { - id: nvidiaArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onPressed: mouse => nvidiaRipple.trigger(mouse.x, mouse.y) - onClicked: { - if (root.desktopEntry) { - SessionService.launchDesktopEntry(root.desktopEntry, true); - } - root.close(); - } - } - } - - Rectangle { - visible: root.appData && (root.appData.type === "window" || (root.appData.type === "grouped" && root.appData.windowCount > 0)) - width: parent.width - height: 28 - radius: Theme.cornerRadius - color: closeArea.containsMouse ? Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.12) : "transparent" - - Row { - anchors.left: parent.left - anchors.leftMargin: Theme.spacingS - anchors.right: parent.right - anchors.rightMargin: Theme.spacingS - anchors.verticalCenter: parent.verticalCenter - spacing: Theme.spacingXS - - DankIcon { - anchors.verticalCenter: parent.verticalCenter - name: "close" - size: 14 - color: closeArea.containsMouse ? Theme.error : Theme.surfaceText - opacity: 0.7 - } - - StyledText { - anchors.verticalCenter: parent.verticalCenter - text: root.appData && root.appData.type === "grouped" ? I18n.tr("Close All Windows") : I18n.tr("Close Window") - font.pixelSize: Theme.fontSizeSmall - color: closeArea.containsMouse ? Theme.error : Theme.surfaceText - font.weight: Font.Normal - elide: Text.ElideRight - wrapMode: Text.NoWrap - } - } - - DankRipple { - id: closeRipple - rippleColor: Theme.error - cornerRadius: Theme.cornerRadius - } - - MouseArea { - id: closeArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onPressed: mouse => closeRipple.trigger(mouse.x, mouse.y) - onClicked: { - if (root.appData?.type === "window") { - root.appData?.toplevel?.close(); - } else if (root.appData?.type === "grouped") { - root.appData?.allWindows?.forEach(window => window.toplevel?.close()); - } - root.close(); + MouseArea { + id: windowArea + anchors.fill: parent + anchors.rightMargin: 24 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onPressed: mouse => windowRipple.trigger(mouse.x, mouse.y) + onClicked: { + if (modelData && modelData.activate) { + modelData.activate(); } + root.close(); } } } } - MouseArea { - anchors.fill: parent - z: -1 - onClicked: root.close() + Rectangle { + visible: { + if (!root.appData) + return false; + if (root.appData.type !== "grouped") + return false; + return root.appData.windowCount > 0; + } + width: parent.width + height: 1 + color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2) + } + + Repeater { + model: root.desktopEntry && root.desktopEntry.actions ? root.desktopEntry.actions : [] + + Rectangle { + width: parent.width + height: 28 + radius: Theme.cornerRadius + color: actionArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent" + + Row { + anchors.left: parent.left + anchors.leftMargin: Theme.spacingS + anchors.right: parent.right + anchors.rightMargin: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingXS + + Item { + anchors.verticalCenter: parent.verticalCenter + width: 16 + height: 16 + visible: modelData.icon && modelData.icon !== "" + + IconImage { + anchors.fill: parent + source: modelData.icon ? Paths.resolveIconPath(modelData.icon) : "" + smooth: true + asynchronous: true + visible: status === Image.Ready + } + } + + StyledText { + anchors.verticalCenter: parent.verticalCenter + text: modelData.name || "" + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + font.weight: Font.Normal + elide: Text.ElideRight + wrapMode: Text.NoWrap + } + } + + DankRipple { + id: actionRipple + rippleColor: Theme.surfaceText + cornerRadius: Theme.cornerRadius + } + + MouseArea { + id: actionArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onPressed: mouse => actionRipple.trigger(mouse.x, mouse.y) + onClicked: { + if (modelData) { + SessionService.launchDesktopAction(root.desktopEntry, modelData); + } + root.close(); + } + } + } + } + + Rectangle { + visible: { + if (!root.desktopEntry?.actions || root.desktopEntry.actions.length === 0) { + return false; + } + return !root.hidePin || (!root.isDmsWindow && root.desktopEntry && SessionService.nvidiaCommand); + } + width: parent.width + height: 1 + color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2) + } + + Rectangle { + visible: !root.hidePin + width: parent.width + height: 28 + radius: Theme.cornerRadius + color: pinArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent" + + Row { + anchors.left: parent.left + anchors.leftMargin: Theme.spacingS + anchors.right: parent.right + anchors.rightMargin: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingXS + + DankIcon { + anchors.verticalCenter: parent.verticalCenter + name: root.appData && root.appData.isPinned ? "keep_off" : "push_pin" + size: 14 + color: Theme.surfaceText + opacity: 0.7 + } + + StyledText { + anchors.verticalCenter: parent.verticalCenter + text: root.appData && root.appData.isPinned ? I18n.tr("Unpin from Dock") : I18n.tr("Pin to Dock") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + font.weight: Font.Normal + elide: Text.ElideRight + wrapMode: Text.NoWrap + } + } + + DankRipple { + id: pinRipple + rippleColor: Theme.surfaceText + cornerRadius: Theme.cornerRadius + } + + MouseArea { + id: pinArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onPressed: mouse => pinRipple.trigger(mouse.x, mouse.y) + onClicked: { + if (!root.appData) + return; + + if (root.appData.isPinned) { + SessionData.removePinnedApp(root.appData.appId); + } else { + SessionData.addPinnedApp(root.appData.appId); + } + root.close(); + } + } + } + + Rectangle { + visible: { + const hasNvidia = !root.isDmsWindow && root.desktopEntry && SessionService.nvidiaCommand; + const hasWindow = root.appData && (root.appData.type === "window" || (root.appData.type === "grouped" && root.appData.windowCount > 0)); + const hasPinOption = !root.hidePin; + const hasContentAbove = hasPinOption || hasNvidia; + return hasContentAbove && hasWindow; + } + width: parent.width + height: 1 + color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2) + } + + Rectangle { + visible: !root.isDmsWindow && root.desktopEntry && SessionService.nvidiaCommand + width: parent.width + height: 28 + radius: Theme.cornerRadius + color: nvidiaArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent" + + Row { + anchors.left: parent.left + anchors.leftMargin: Theme.spacingS + anchors.right: parent.right + anchors.rightMargin: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingXS + + DankIcon { + anchors.verticalCenter: parent.verticalCenter + name: "memory" + size: 14 + color: Theme.surfaceText + opacity: 0.7 + } + + StyledText { + anchors.verticalCenter: parent.verticalCenter + text: I18n.tr("Launch on dGPU") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + font.weight: Font.Normal + elide: Text.ElideRight + wrapMode: Text.NoWrap + } + } + + DankRipple { + id: nvidiaRipple + rippleColor: Theme.surfaceText + cornerRadius: Theme.cornerRadius + } + + MouseArea { + id: nvidiaArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onPressed: mouse => nvidiaRipple.trigger(mouse.x, mouse.y) + onClicked: { + if (root.desktopEntry) { + SessionService.launchDesktopEntry(root.desktopEntry, true); + } + root.close(); + } + } + } + + Rectangle { + visible: root.appData && (root.appData.type === "window" || (root.appData.type === "grouped" && root.appData.windowCount > 0)) + width: parent.width + height: 28 + radius: Theme.cornerRadius + color: closeArea.containsMouse ? Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.12) : "transparent" + + Row { + anchors.left: parent.left + anchors.leftMargin: Theme.spacingS + anchors.right: parent.right + anchors.rightMargin: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingXS + + DankIcon { + anchors.verticalCenter: parent.verticalCenter + name: "close" + size: 14 + color: closeArea.containsMouse ? Theme.error : Theme.surfaceText + opacity: 0.7 + } + + StyledText { + anchors.verticalCenter: parent.verticalCenter + text: root.appData && root.appData.type === "grouped" ? I18n.tr("Close All Windows") : I18n.tr("Close Window") + font.pixelSize: Theme.fontSizeSmall + color: closeArea.containsMouse ? Theme.error : Theme.surfaceText + font.weight: Font.Normal + elide: Text.ElideRight + wrapMode: Text.NoWrap + } + } + + DankRipple { + id: closeRipple + rippleColor: Theme.error + cornerRadius: Theme.cornerRadius + } + + MouseArea { + id: closeArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onPressed: mouse => closeRipple.trigger(mouse.x, mouse.y) + onClicked: { + if (root.appData?.type === "window") { + root.appData?.toplevel?.close(); + } else if (root.appData?.type === "grouped") { + root.appData?.allWindows?.forEach(window => window.toplevel?.close()); + } + root.close(); + } + } } } diff --git a/quickshell/Modules/Dock/DockContextMenuBase.qml b/quickshell/Modules/Dock/DockContextMenuBase.qml new file mode 100644 index 00000000..1b66e08b --- /dev/null +++ b/quickshell/Modules/Dock/DockContextMenuBase.qml @@ -0,0 +1,199 @@ +import QtQuick +import Quickshell +import Quickshell.Wayland +import qs.Common +import qs.Services +import qs.Widgets + +PanelWindow { + id: root + + default property alias content: menuColumn.children + + property var anchorItem: null + property real dockVisibleHeight: 40 + property int margin: 10 + property string layerNamespace: "dms:dock-context-menu" + property real menuMaxWidth: 400 + property real menuMinWidth: 180 + + property point anchorPos: Qt.point(screen ? screen.width / 2 : 0, screen ? screen.height - 100 : 0) + + function show(button, dockHeight, dockScreen) { + if (dockScreen) + screen = dockScreen; + anchorItem = button; + dockVisibleHeight = dockHeight || 40; + visible = true; + } + + function close() { + visible = false; + } + + function findDockBackground(item) { + if (!item) + return null; + if (item.objectName === "dockBackground") + return item; + for (let i = 0; i < item.children.length; i++) { + const found = findDockBackground(item.children[i]); + if (found) + return found; + } + return null; + } + + function updatePosition() { + if (!anchorItem || !screen) { + anchorPos = Qt.point(screen ? screen.width / 2 : 0, screen ? screen.height - 100 : 0); + return; + } + + const dockWindow = anchorItem.Window.window; + if (!dockWindow) { + anchorPos = Qt.point(screen.width / 2, screen.height - 100); + return; + } + + const buttonPosInDock = anchorItem.mapToItem(dockWindow.contentItem, 0, 0); + const dockBackground = findDockBackground(dockWindow.contentItem); + const actualDockHeight = dockBackground ? dockBackground.height : root.dockVisibleHeight; + const actualDockWidth = dockBackground ? dockBackground.width : dockWindow.width; + const dockMargin = SettingsData.dockMargin + 16; + let x = 0; + let y = 0; + + switch (SettingsData.dockPosition) { + case SettingsData.Position.Left: + { + const dockTopMargin = Math.round((screen.height - dockWindow.height) / 2); + x = actualDockWidth + dockMargin + 20; + y = dockTopMargin + buttonPosInDock.y + anchorItem.height / 2; + break; + } + case SettingsData.Position.Right: + { + const dockTopMargin = Math.round((screen.height - dockWindow.height) / 2); + x = screen.width - actualDockWidth - dockMargin - 20; + y = dockTopMargin + buttonPosInDock.y + anchorItem.height / 2; + break; + } + case SettingsData.Position.Top: + { + const dockLeftMargin = Math.round((screen.width - dockWindow.width) / 2); + x = dockLeftMargin + buttonPosInDock.x + anchorItem.width / 2; + y = actualDockHeight + dockMargin + 20; + break; + } + case SettingsData.Position.Bottom: + default: + { + const dockLeftMargin = Math.round((screen.width - dockWindow.width) / 2); + x = dockLeftMargin + buttonPosInDock.x + anchorItem.width / 2; + y = screen.height - actualDockHeight - dockMargin - 20; + break; + } + } + + anchorPos = Qt.point(x, y); + } + + onAnchorItemChanged: updatePosition() + onVisibleChanged: { + if (visible) + updatePosition(); + } + + WindowBlur { + targetWindow: root + blurX: menuContainer.x + blurY: menuContainer.y + blurWidth: root.visible ? menuContainer.width : 0 + blurHeight: root.visible ? menuContainer.height : 0 + blurRadius: Theme.cornerRadius + } + + WlrLayershell.namespace: root.layerNamespace + WlrLayershell.layer: WlrLayershell.Overlay + WlrLayershell.exclusiveZone: -1 + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + + screen: null + visible: false + color: "transparent" + anchors { + top: true + left: true + right: true + bottom: true + } + + Rectangle { + id: menuContainer + + readonly property bool isVertical: SettingsData.dockPosition === SettingsData.Position.Left || SettingsData.dockPosition === SettingsData.Position.Right + + x: { + if (!isVertical) { + const want = root.anchorPos.x - width / 2; + return Math.max(10, Math.min(root.width - width - 10, want)); + } + if (SettingsData.dockPosition === SettingsData.Position.Right) + return Math.max(10, root.anchorPos.x - width + 30); + return Math.min(root.width - width - 10, root.anchorPos.x - 30); + } + y: { + if (isVertical) { + const want = root.anchorPos.y - height / 2; + return Math.max(10, Math.min(root.height - height - 10, want)); + } + if (SettingsData.dockPosition === SettingsData.Position.Bottom) + return Math.max(10, root.anchorPos.y - height + 30); + return Math.min(root.height - height - 10, root.anchorPos.y - 30); + } + + width: Math.min(root.menuMaxWidth, Math.max(root.menuMinWidth, menuColumn.implicitWidth + Theme.spacingS * 2)) + height: menuColumn.implicitHeight + Theme.spacingS * 2 + color: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency) + radius: Theme.cornerRadius + border.color: BlurService.enabled ? BlurService.borderColor : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08) + border.width: BlurService.enabled ? BlurService.borderWidth : 1 + + opacity: root.visible ? 1 : 0 + visible: opacity > 0 + + Behavior on opacity { + NumberAnimation { + duration: Theme.shortDuration + easing.type: Theme.emphasizedEasing + } + } + + Rectangle { + anchors.fill: parent + anchors.topMargin: 4 + anchors.leftMargin: 2 + anchors.rightMargin: -2 + anchors.bottomMargin: -4 + radius: parent.radius + color: Qt.rgba(0, 0, 0, 0.15) + z: -1 + } + + Column { + id: menuColumn + width: parent.width - Theme.spacingS * 2 + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + anchors.topMargin: Theme.spacingS + spacing: 1 + } + } + + MouseArea { + anchors.fill: parent + z: -1 + onClicked: root.close() + } +} diff --git a/quickshell/Modules/Dock/DockTrashButton.qml b/quickshell/Modules/Dock/DockTrashButton.qml index df99f1b8..98619765 100644 --- a/quickshell/Modules/Dock/DockTrashButton.qml +++ b/quickshell/Modules/Dock/DockTrashButton.qml @@ -1,5 +1,4 @@ import QtQuick -import Quickshell import Quickshell.Widgets import qs.Common import qs.Services @@ -16,40 +15,39 @@ Item { property real actualIconSize: 40 property real hoverAnimOffset: 0 - property bool isHovered: mouseArea.containsMouse - property bool showTooltip: mouseArea.containsMouse + readonly property bool isHovered: mouseArea.containsMouse + readonly property bool showTooltip: mouseArea.containsMouse readonly property string tooltipText: TrashService.isEmpty ? I18n.tr("Trash") : (I18n.tr("Trash") + " (" + TrashService.count + ")") readonly property bool isVertical: SettingsData.dockPosition === SettingsData.Position.Left || SettingsData.dockPosition === SettingsData.Position.Right readonly property real animationDistance: actualIconSize readonly property real animationDirection: { - if (SettingsData.dockPosition === SettingsData.Position.Bottom) - return -1; - if (SettingsData.dockPosition === SettingsData.Position.Top) + switch (SettingsData.dockPosition) { + case SettingsData.Position.Top: + case SettingsData.Position.Left: return 1; - if (SettingsData.dockPosition === SettingsData.Position.Right) + case SettingsData.Position.Bottom: + case SettingsData.Position.Right: + default: return -1; - if (SettingsData.dockPosition === SettingsData.Position.Left) - return 1; - return -1; + } } onIsHoveredChanged: { if (mouseArea.pressed) return; - if (isHovered) { - exitAnimation.stop(); - if (!bounceAnimation.running) - bounceAnimation.restart(); - } else { + if (!isHovered) { bounceAnimation.stop(); exitAnimation.restart(); + return; } + exitAnimation.stop(); + if (!bounceAnimation.running) + bounceAnimation.restart(); } SequentialAnimation { id: bounceAnimation - running: false NumberAnimation { @@ -73,7 +71,6 @@ Item { NumberAnimation { id: exitAnimation - running: false target: root property: "hoverAnimOffset" @@ -85,52 +82,56 @@ Item { MouseArea { id: mouseArea - anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor acceptedButtons: Qt.LeftButton | Qt.RightButton onClicked: mouse => { - if (mouse.button === Qt.LeftButton) { + switch (mouse.button) { + case Qt.LeftButton: TrashService.openTrash(); - } else if (mouse.button === Qt.RightButton) { - if (contextMenu) { + break; + case Qt.RightButton: + if (contextMenu) contextMenu.showForButton(root, root.height, parentDockScreen, dockApps); - } + break; } } } Item { - id: visualContent anchors.fill: parent transform: Translate { - x: !isVertical ? 0 : hoverAnimOffset - y: !isVertical ? hoverAnimOffset : 0 + x: isVertical ? hoverAnimOffset : 0 + y: isVertical ? 0 : hoverAnimOffset } Item { anchors.centerIn: parent - width: actualIconSize - height: actualIconSize + width: actualIconSize - 4 + height: actualIconSize - 4 + + readonly property string iconPath: Paths.resolveIconPath(TrashService.isEmpty ? "user-trash" : "user-trash-full") IconImage { id: trashIcon - anchors.centerIn: parent - width: actualIconSize - 4 - height: actualIconSize - 4 + anchors.fill: parent + source: parent.iconPath + backer.sourceSize: Qt.size(parent.width * 2, parent.height * 2) smooth: true + mipmap: true asynchronous: true - source: Quickshell.iconPath(TrashService.isEmpty ? "user-trash" : "user-trash-full", "user-trash") + visible: status === Image.Ready + } - Behavior on opacity { - NumberAnimation { - duration: Theme.shortDuration - easing.type: Easing.OutCubic - } - } + DankIcon { + anchors.centerIn: parent + visible: parent.iconPath === "" || trashIcon.status !== Image.Ready + name: "delete" + size: actualIconSize - 8 + color: TrashService.isEmpty ? Theme.surfaceText : Theme.primary } } } diff --git a/quickshell/Modules/Dock/DockTrashContextMenu.qml b/quickshell/Modules/Dock/DockTrashContextMenu.qml index 056ece36..452bfa08 100644 --- a/quickshell/Modules/Dock/DockTrashContextMenu.qml +++ b/quickshell/Modules/Dock/DockTrashContextMenu.qml @@ -1,377 +1,55 @@ import QtQuick -import Quickshell -import Quickshell.Wayland -import Quickshell.Widgets import qs.Common import qs.Services -import qs.Widgets -PanelWindow { +DockContextMenuBase { id: root - WindowBlur { - targetWindow: root - blurX: menuContainer.x - blurY: menuContainer.y - blurWidth: root.visible ? menuContainer.width : 0 - blurHeight: root.visible ? menuContainer.height : 0 - blurRadius: Theme.cornerRadius - } - - WlrLayershell.namespace: "dms:dock-trash-context-menu" - - property var anchorItem: null - property real dockVisibleHeight: 40 - property int margin: 10 property var dockApps: null + layerNamespace: "dms:dock-trash-context-menu" + function showForButton(button, dockHeight, dockScreen, parentDockApps) { - if (dockScreen) { - root.screen = dockScreen; - } - - anchorItem = button; - dockVisibleHeight = dockHeight || 40; dockApps = parentDockApps || null; - - visible = true; - } - function close() { - visible = false; + show(button, dockHeight, dockScreen); } - screen: null - visible: false - WlrLayershell.layer: WlrLayershell.Overlay - WlrLayershell.exclusiveZone: -1 - WlrLayershell.keyboardFocus: WlrKeyboardFocus.None - color: "transparent" - anchors { - top: true - left: true - right: true - bottom: true - } - - property point anchorPos: Qt.point(screen ? screen.width / 2 : 0, screen ? screen.height - 100 : 0) - - onAnchorItemChanged: updatePosition() - onVisibleChanged: { - if (visible) { - updatePosition(); + DockTrashMenuItem { + width: parent.width + iconName: "folder_open" + text: I18n.tr("Open Trash") + onTriggered: { + TrashService.openTrash(); + root.close(); } } - function updatePosition() { - if (!anchorItem || !screen) { - anchorPos = Qt.point(screen ? screen.width / 2 : 0, screen ? screen.height - 100 : 0); - return; + DockTrashMenuItem { + width: parent.width + iconName: "delete_forever" + isDestructive: true + enabled: !TrashService.isEmpty + text: TrashService.isEmpty ? I18n.tr("Empty Trash") : I18n.tr("Empty Trash (%1)").arg(TrashService.count) + onTriggered: { + TrashService.requestEmptyTrash(); + root.close(); } - - const dockWindow = anchorItem.Window.window; - if (!dockWindow) { - anchorPos = Qt.point(screen.width / 2, screen.height - 100); - return; - } - - const buttonPosInDock = anchorItem.mapToItem(dockWindow.contentItem, 0, 0); - let actualDockHeight = root.dockVisibleHeight; - - function findDockBackground(item) { - if (item.objectName === "dockBackground") { - return item; - } - for (var i = 0; i < item.children.length; i++) { - const found = findDockBackground(item.children[i]); - if (found) { - return found; - } - } - return null; - } - - const dockBackground = findDockBackground(dockWindow.contentItem); - let actualDockWidth = dockWindow.width; - if (dockBackground) { - actualDockHeight = dockBackground.height; - actualDockWidth = dockBackground.width; - } - - const isVertical = SettingsData.dockPosition === SettingsData.Position.Left || SettingsData.dockPosition === SettingsData.Position.Right; - const dockMargin = SettingsData.dockMargin + 16; - let buttonScreenX, buttonScreenY; - - if (isVertical) { - const dockContentHeight = dockWindow.height; - const screenHeight = root.screen.height; - const dockTopMargin = Math.round((screenHeight - dockContentHeight) / 2); - buttonScreenY = dockTopMargin + buttonPosInDock.y + anchorItem.height / 2; - - if (SettingsData.dockPosition === SettingsData.Position.Right) { - buttonScreenX = root.screen.width - actualDockWidth - dockMargin - 20; - } else { - buttonScreenX = actualDockWidth + dockMargin + 20; - } - } else { - const isDockAtBottom = SettingsData.dockPosition === SettingsData.Position.Bottom; - - if (isDockAtBottom) { - buttonScreenY = root.screen.height - actualDockHeight - dockMargin - 20; - } else { - buttonScreenY = actualDockHeight + dockMargin + 20; - } - - const dockContentWidth = dockWindow.width; - const screenWidth = root.screen.width; - const dockLeftMargin = Math.round((screenWidth - dockContentWidth) / 2); - buttonScreenX = dockLeftMargin + buttonPosInDock.x + anchorItem.width / 2; - } - - anchorPos = Qt.point(buttonScreenX, buttonScreenY); } Rectangle { - id: menuContainer - - x: { - const isVertical = SettingsData.dockPosition === SettingsData.Position.Left || SettingsData.dockPosition === SettingsData.Position.Right; - if (isVertical) { - const isDockAtRight = SettingsData.dockPosition === SettingsData.Position.Right; - if (isDockAtRight) { - return Math.max(10, root.anchorPos.x - width + 30); - } else { - return Math.min(root.width - width - 10, root.anchorPos.x - 30); - } - } else { - const left = 10; - const right = root.width - width - 10; - const want = root.anchorPos.x - width / 2; - return Math.max(left, Math.min(right, want)); - } - } - y: { - const isVertical = SettingsData.dockPosition === SettingsData.Position.Left || SettingsData.dockPosition === SettingsData.Position.Right; - if (isVertical) { - const top = 10; - const bottom = root.height - height - 10; - const want = root.anchorPos.y - height / 2; - return Math.max(top, Math.min(bottom, want)); - } else { - const isDockAtBottom = SettingsData.dockPosition === SettingsData.Position.Bottom; - if (isDockAtBottom) { - return Math.max(10, root.anchorPos.y - height + 30); - } else { - return Math.min(root.height - height - 10, root.anchorPos.y - 30); - } - } - } - - width: Math.min(400, Math.max(180, menuColumn.implicitWidth + Theme.spacingS * 2)) - height: menuColumn.implicitHeight + Theme.spacingS * 2 - color: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency) - radius: Theme.cornerRadius - border.color: BlurService.enabled ? BlurService.borderColor : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08) - border.width: BlurService.enabled ? BlurService.borderWidth : 1 - - opacity: root.visible ? 1 : 0 - visible: opacity > 0 - - Behavior on opacity { - NumberAnimation { - duration: Theme.shortDuration - easing.type: Theme.emphasizedEasing - } - } - - Rectangle { - anchors.fill: parent - anchors.topMargin: 4 - anchors.leftMargin: 2 - anchors.rightMargin: -2 - anchors.bottomMargin: -4 - radius: parent.radius - color: Qt.rgba(0, 0, 0, 0.15) - z: -1 - } - - Column { - id: menuColumn - width: parent.width - Theme.spacingS * 2 - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: parent.top - anchors.topMargin: Theme.spacingS - spacing: 1 - - Rectangle { - width: parent.width - height: 28 - radius: Theme.cornerRadius - color: openArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent" - - Row { - anchors.left: parent.left - anchors.leftMargin: Theme.spacingS - anchors.right: parent.right - anchors.rightMargin: Theme.spacingS - anchors.verticalCenter: parent.verticalCenter - spacing: Theme.spacingXS - - DankIcon { - anchors.verticalCenter: parent.verticalCenter - name: "folder_open" - size: 14 - color: Theme.surfaceText - opacity: 0.7 - } - - StyledText { - anchors.verticalCenter: parent.verticalCenter - text: I18n.tr("Open Trash") - font.pixelSize: Theme.fontSizeSmall - color: Theme.surfaceText - font.weight: Font.Normal - elide: Text.ElideRight - wrapMode: Text.NoWrap - } - } - - DankRipple { - id: openRipple - rippleColor: Theme.surfaceText - cornerRadius: Theme.cornerRadius - } - - MouseArea { - id: openArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onPressed: mouse => openRipple.trigger(mouse.x, mouse.y) - onClicked: { - TrashService.openTrash(); - root.close(); - } - } - } - - Rectangle { - width: parent.width - height: 28 - radius: Theme.cornerRadius - enabled: !TrashService.isEmpty - opacity: enabled ? 1 : 0.4 - color: emptyArea.containsMouse && enabled ? Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.12) : "transparent" - - Row { - anchors.left: parent.left - anchors.leftMargin: Theme.spacingS - anchors.right: parent.right - anchors.rightMargin: Theme.spacingS - anchors.verticalCenter: parent.verticalCenter - spacing: Theme.spacingXS - - DankIcon { - anchors.verticalCenter: parent.verticalCenter - name: "delete_forever" - size: 14 - color: emptyArea.containsMouse && parent.parent.enabled ? Theme.error : Theme.surfaceText - opacity: 0.7 - } - - StyledText { - anchors.verticalCenter: parent.verticalCenter - text: TrashService.isEmpty ? I18n.tr("Empty Trash") : I18n.tr("Empty Trash (%1)").arg(TrashService.count) - font.pixelSize: Theme.fontSizeSmall - color: emptyArea.containsMouse && parent.parent.enabled ? Theme.error : Theme.surfaceText - font.weight: Font.Normal - elide: Text.ElideRight - wrapMode: Text.NoWrap - } - } - - DankRipple { - id: emptyRipple - rippleColor: Theme.error - cornerRadius: Theme.cornerRadius - } - - MouseArea { - id: emptyArea - anchors.fill: parent - hoverEnabled: true - enabled: parent.enabled - cursorShape: Qt.PointingHandCursor - onPressed: mouse => emptyRipple.trigger(mouse.x, mouse.y) - onClicked: { - TrashService.requestEmptyTrash(); - root.close(); - } - } - } - - Rectangle { - width: parent.width - height: 1 - color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2) - } - - Rectangle { - width: parent.width - height: 28 - radius: Theme.cornerRadius - color: settingsArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent" - - Row { - anchors.left: parent.left - anchors.leftMargin: Theme.spacingS - anchors.right: parent.right - anchors.rightMargin: Theme.spacingS - anchors.verticalCenter: parent.verticalCenter - spacing: Theme.spacingXS - - DankIcon { - anchors.verticalCenter: parent.verticalCenter - name: "settings" - size: 14 - color: Theme.surfaceText - opacity: 0.7 - } - - StyledText { - anchors.verticalCenter: parent.verticalCenter - text: I18n.tr("Settings") - font.pixelSize: Theme.fontSizeSmall - color: Theme.surfaceText - font.weight: Font.Normal - elide: Text.ElideRight - wrapMode: Text.NoWrap - } - } - - DankRipple { - id: settingsRipple - rippleColor: Theme.surfaceText - cornerRadius: Theme.cornerRadius - } - - MouseArea { - id: settingsArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onPressed: mouse => settingsRipple.trigger(mouse.x, mouse.y) - onClicked: { - PopoutService.focusOrToggleSettingsWithTab("dock"); - root.close(); - } - } - } - } + width: parent.width + height: 1 + color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2) } - MouseArea { - anchors.fill: parent - z: -1 - onClicked: root.close() + DockTrashMenuItem { + width: parent.width + iconName: "settings" + text: I18n.tr("Settings") + onTriggered: { + SettingsSearchService.navigateToSection("dockTrash"); + PopoutService.openSettingsWithTab("dock"); + root.close(); + } } } diff --git a/quickshell/Modules/Dock/DockTrashMenuItem.qml b/quickshell/Modules/Dock/DockTrashMenuItem.qml new file mode 100644 index 00000000..6574a972 --- /dev/null +++ b/quickshell/Modules/Dock/DockTrashMenuItem.qml @@ -0,0 +1,69 @@ +import QtQuick +import qs.Common +import qs.Services +import qs.Widgets + +Rectangle { + id: root + + property string iconName: "" + property string text: "" + property bool isDestructive: false + property bool enabled: true + + signal triggered + + height: 28 + radius: Theme.cornerRadius + opacity: enabled ? 1 : 0.4 + color: { + if (!area.containsMouse || !enabled) + return "transparent"; + if (isDestructive) + return Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.12); + return BlurService.hoverColor(Theme.widgetBaseHoverColor); + } + + Row { + anchors.left: parent.left + anchors.leftMargin: Theme.spacingS + anchors.right: parent.right + anchors.rightMargin: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingXS + + DankIcon { + anchors.verticalCenter: parent.verticalCenter + name: root.iconName + size: 14 + color: root.isDestructive && area.containsMouse && root.enabled ? Theme.error : Theme.surfaceText + opacity: 0.7 + } + + StyledText { + anchors.verticalCenter: parent.verticalCenter + text: root.text + font.pixelSize: Theme.fontSizeSmall + color: root.isDestructive && area.containsMouse && root.enabled ? Theme.error : Theme.surfaceText + font.weight: Font.Normal + elide: Text.ElideRight + wrapMode: Text.NoWrap + } + } + + DankRipple { + id: ripple + rippleColor: root.isDestructive ? Theme.error : Theme.surfaceText + cornerRadius: Theme.cornerRadius + } + + MouseArea { + id: area + anchors.fill: parent + hoverEnabled: true + enabled: root.enabled + cursorShape: Qt.PointingHandCursor + onPressed: mouse => ripple.trigger(mouse.x, mouse.y) + onClicked: root.triggered() + } +} diff --git a/quickshell/Modules/Settings/DockTab.qml b/quickshell/Modules/Settings/DockTab.qml index 78a765b6..ef029801 100644 --- a/quickshell/Modules/Settings/DockTab.qml +++ b/quickshell/Modules/Settings/DockTab.qml @@ -558,22 +558,9 @@ Item { backgroundColor: Theme.surfaceContainerHighest normalBorderColor: Theme.outlineMedium focusedBorderColor: Theme.primary - - Component.onCompleted: { - if (SettingsData.dockTrashCustomCommand) { - text = SettingsData.dockTrashCustomCommand; - } - } + text: SettingsData.dockTrashCustomCommand onTextEdited: SettingsData.set("dockTrashCustomCommand", text.trim()) - - MouseArea { - anchors.fill: parent - onPressed: mouse => { - trashCustomCommandField.forceActiveFocus(); - mouse.accepted = false; - } - } } } } diff --git a/quickshell/Services/TrashService.qml b/quickshell/Services/TrashService.qml index 1d866655..967f386d 100644 --- a/quickshell/Services/TrashService.qml +++ b/quickshell/Services/TrashService.qml @@ -1,4 +1,5 @@ pragma Singleton +pragma ComponentBehavior: Bound import QtQuick import Qt.labs.folderlistmodel @@ -13,15 +14,16 @@ Singleton { readonly property string _xdgDataHome: Quickshell.env("XDG_DATA_HOME") || (_homeDir + "/.local/share") readonly property string trashFilesDir: _xdgDataHome + "/Trash/files" - readonly property int count: trashModel.count + property int count: 0 readonly property bool isEmpty: count === 0 - property var availableFileManagers: [] + property var availableFileManagers: ["default"] + property string defaultFileManagerLabel: "default (xdg-open)" signal emptyTrashConfirmRequested(int itemCount) FolderListModel { - id: trashModel + id: homeTrashModel folder: "file://" + root.trashFilesDir showDirs: true showFiles: true @@ -31,53 +33,85 @@ Singleton { nameFilters: ["*"] } + Connections { + target: homeTrashModel + function onCountChanged() { + root.refreshCount(); + } + } + Process { id: detectProc running: false - command: ["sh", "-c", "for fm in nautilus thunar dolphin; do command -v $fm >/dev/null 2>&1 && echo $fm; done"] + command: ["sh", "-c", "for fm in nautilus thunar dolphin nemo caja pcmanfm pcmanfm-qt krusader; do command -v $fm >/dev/null 2>&1 && echo $fm; done"] stdout: StdioCollector { onStreamFinished: { const detected = (text || "").split("\n").map(s => s.trim()).filter(s => s.length > 0); - detected.push("custom"); - root.availableFileManagers = detected; + root.availableFileManagers = ["default"].concat(detected).concat(["custom"]); } } } Component.onCompleted: { detectProc.running = true; + refreshCount(); + } + + function refreshCount() { + Proc.runCommand("trash-count", ["dms", "trash", "count"], (output, exitCode) => { + if (exitCode !== 0) { + root.count = homeTrashModel.count; + return; + } + const n = parseInt((output || "").trim(), 10); + root.count = isNaN(n) ? homeTrashModel.count : n; + }); + } + + function trashPath(path, callback) { + if (!path) { + if (callback) + callback(false, "empty path"); + return; + } + Proc.runCommand(null, ["dms", "trash", "put", path], (output, exitCode) => { + const ok = exitCode === 0; + if (!ok) + ToastService.showError(I18n.tr("Failed to move to trash"), path); + refreshCount(); + if (callback) + callback(ok, output); + }); } function openTrash() { - const choice = SettingsData.dockTrashFileManager || "nautilus"; - if (choice === "custom") { - const cmd = (SettingsData.dockTrashCustomCommand || "").trim(); - if (!cmd) { - ToastService.showInfo(I18n.tr("Cannot open trash: no custom command set"), I18n.tr("Configure one in Settings → Dock → Trash.")); - return; - } - Proc.runCommand(null, ["sh", "-c", cmd], (output, exitCode) => { - if (exitCode !== 0) { - ToastService.showError(I18n.tr("Trash command failed (exit %1)").arg(exitCode), I18n.tr("Check your custom command in Settings → Dock → Trash.")); - } - }, 0, Proc.noTimeout); + const choice = SettingsData.dockTrashFileManager || "default"; + switch (choice) { + case "default": + Quickshell.execDetached(["xdg-open", "trash:///"]); + return; + case "custom": + openCustom(); return; } if (availableFileManagers.indexOf(choice) < 0) { ToastService.showInfo(I18n.tr("Cannot open trash: '%1' is not installed").arg(choice), I18n.tr("Pick a different file manager in Settings → Dock → Trash.")); return; } - switch (choice) { - case "nautilus": - Quickshell.execDetached(["nautilus", "trash:///"]); - break; - case "thunar": - Quickshell.execDetached(["thunar", "trash:///"]); - break; - case "dolphin": - Quickshell.execDetached(["dolphin", "trash:///"]); - break; + Quickshell.execDetached([choice, "trash:///"]); + } + + function openCustom() { + const cmd = (SettingsData.dockTrashCustomCommand || "").trim(); + if (!cmd) { + ToastService.showInfo(I18n.tr("Cannot open trash: no custom command set"), I18n.tr("Configure one in Settings → Dock → Trash.")); + return; } + Proc.runCommand(null, ["sh", "-c", cmd], (output, exitCode) => { + if (exitCode !== 0) { + ToastService.showError(I18n.tr("Trash command failed (exit %1)").arg(exitCode), I18n.tr("Check your custom command in Settings → Dock → Trash.")); + } + }, 0, Proc.noTimeout); } function requestEmptyTrash() { @@ -87,6 +121,10 @@ Singleton { } function emptyTrash() { - Quickshell.execDetached(["gio", "trash", "--empty"]); + Proc.runCommand("trash-empty", ["dms", "trash", "empty"], (output, exitCode) => { + if (exitCode !== 0) + ToastService.showError(I18n.tr("Failed to empty trash"), output || ""); + refreshCount(); + }); } }