1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-04-30 01:22:06 -04:00

dock: add trash CLI, refine implementation

This commit is contained in:
bbedward
2026-04-27 11:14:22 -04:00
parent c9b38023d5
commit 36a7692da7
18 changed files with 1855 additions and 977 deletions

View File

@@ -526,5 +526,6 @@ func getCommonCommands() []*cobra.Command {
dlCmd,
randrCmd,
blurCmd,
trashCmd,
}
}

View File

@@ -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 <path...>",
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 <name>",
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)
}
}

View File

@@ -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
}

View File

@@ -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)
}
}
}

View File

@@ -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

View File

@@ -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 },

View File

@@ -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
}
}

View File

@@ -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;
}
}
}
}

View File

@@ -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()
}
}
}
}
}
}

View File

@@ -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;
}
}
}
}

View File

@@ -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

View File

@@ -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();
}
}
}
}

View File

@@ -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()
}
}

View File

@@ -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
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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()
}
}

View File

@@ -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;
}
}
}
}
}

View File

@@ -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();
});
}
}