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:
@@ -526,5 +526,6 @@ func getCommonCommands() []*cobra.Command {
|
||||
dlCmd,
|
||||
randrCmd,
|
||||
blurCmd,
|
||||
trashCmd,
|
||||
}
|
||||
}
|
||||
|
||||
122
core/cmd/dms/commands_trash.go
Normal file
122
core/cmd/dms/commands_trash.go
Normal 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)
|
||||
}
|
||||
}
|
||||
455
core/internal/trash/trash.go
Normal file
455
core/internal/trash/trash.go
Normal 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
|
||||
}
|
||||
315
core/internal/trash/trash_test.go
Normal file
315
core/internal/trash/trash_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
153
quickshell/Modals/FileBrowser/FileBrowserItemContextMenu.qml
Normal file
153
quickshell/Modals/FileBrowser/FileBrowserItemContextMenu.qml
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
199
quickshell/Modules/Dock/DockContextMenuBase.qml
Normal file
199
quickshell/Modules/Dock/DockContextMenuBase.qml
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
69
quickshell/Modules/Dock/DockTrashMenuItem.qml
Normal file
69
quickshell/Modules/Dock/DockTrashMenuItem.qml
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user