mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-05-02 10:32:07 -04:00
Compare commits
6 Commits
28f68ac702
...
dc5636bed5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc5636bed5 | ||
|
|
36a7692da7 | ||
|
|
c9b38023d5 | ||
|
|
536e654b5e | ||
|
|
e805f6b5ac | ||
|
|
94f4b6d4a9 |
24
.github/workflows/nix-pr-check.yml
vendored
24
.github/workflows/nix-pr-check.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Check nix flake
|
||||
name: Nix flake and NixOS tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -9,6 +9,7 @@ on:
|
||||
jobs:
|
||||
check-flake:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
|
||||
- name: Checkout
|
||||
@@ -18,6 +19,25 @@ jobs:
|
||||
|
||||
- name: Install Nix
|
||||
uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
enable_kvm: true
|
||||
extra_nix_config: |
|
||||
system-features = nixos-test benchmark big-parallel kvm
|
||||
|
||||
- name: Check the flake
|
||||
run: nix flake check
|
||||
run: nix flake check -L
|
||||
|
||||
- name: Run NixOS module test
|
||||
run: nix build .#nixosTests.x86_64-linux.nixos-module -L
|
||||
|
||||
- name: Run NixOS service start test
|
||||
run: nix build .#nixosTests.x86_64-linux.nixos-service-start-module -L
|
||||
|
||||
- name: Run greeter niri test
|
||||
run: nix build .#nixosTests.x86_64-linux.greeter-niri-module -L
|
||||
|
||||
- name: Run home-manager module test
|
||||
run: nix build .#nixosTests.x86_64-linux.home-manager-module -L
|
||||
|
||||
- name: Run niri home-manager module test
|
||||
run: nix build .#nixosTests.x86_64-linux.niri-home-module -L
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
52
distro/nix/tests/default.nix
Normal file
52
distro/nix/tests/default.nix
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
self,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
rec {
|
||||
all = pkgs.symlinkJoin {
|
||||
name = "dms-nixos-tests";
|
||||
paths = [
|
||||
nixos-module
|
||||
nixos-service-start-module
|
||||
greeter-niri-module
|
||||
niri-home-module
|
||||
home-manager-module
|
||||
];
|
||||
};
|
||||
|
||||
nixos-module = import ./nixos-module.nix {
|
||||
inherit
|
||||
self
|
||||
pkgs
|
||||
;
|
||||
};
|
||||
|
||||
nixos-service-start-module = import ./nixos-service-start-module.nix {
|
||||
inherit
|
||||
self
|
||||
pkgs
|
||||
;
|
||||
};
|
||||
|
||||
greeter-niri-module = import ./greeter-niri-module.nix {
|
||||
inherit
|
||||
self
|
||||
pkgs
|
||||
;
|
||||
};
|
||||
|
||||
niri-home-module = import ./niri-home-module.nix {
|
||||
inherit
|
||||
self
|
||||
pkgs
|
||||
;
|
||||
};
|
||||
|
||||
home-manager-module = import ./home-manager-module.nix {
|
||||
inherit
|
||||
self
|
||||
pkgs
|
||||
;
|
||||
};
|
||||
}
|
||||
60
distro/nix/tests/greeter-niri-module.nix
Normal file
60
distro/nix/tests/greeter-niri-module.nix
Normal file
@@ -0,0 +1,60 @@
|
||||
{
|
||||
self,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
pkgs.testers.runNixOSTest {
|
||||
name = "dms-greeter-niri-module";
|
||||
|
||||
nodes.machine = {
|
||||
imports = [
|
||||
self.nixosModules.greeter
|
||||
];
|
||||
|
||||
users.groups.greeter = { };
|
||||
users.users.greeter = {
|
||||
isSystemUser = true;
|
||||
group = "greeter";
|
||||
};
|
||||
|
||||
services.greetd.settings.default_session.user = "greeter";
|
||||
|
||||
programs.niri.enable = true;
|
||||
|
||||
programs.dank-material-shell.greeter = {
|
||||
enable = true;
|
||||
compositor.name = "niri";
|
||||
};
|
||||
|
||||
system.stateVersion = "25.11";
|
||||
};
|
||||
|
||||
testScript = ''
|
||||
import re
|
||||
|
||||
machine.wait_for_unit("multi-user.target")
|
||||
machine.wait_for_unit("greetd.service")
|
||||
|
||||
machine.succeed("systemctl is-enabled greetd.service")
|
||||
machine.succeed("systemctl is-active greetd.service")
|
||||
|
||||
greetd_unit = machine.succeed("cat /etc/systemd/system/greetd.service")
|
||||
config_match = re.search(r'--config (/nix/store[^ ]+-greetd.toml)', greetd_unit)
|
||||
if config_match is None:
|
||||
raise AssertionError(greetd_unit)
|
||||
|
||||
greetd_config_path = config_match.group(1)
|
||||
greetd_config = machine.succeed(f"cat {greetd_config_path}")
|
||||
t.assertIn("dms-greeter", greetd_config)
|
||||
|
||||
script_match = re.search(r'command\s*=\s*"([^"]+/bin/dms-greeter)"', greetd_config)
|
||||
if script_match is None:
|
||||
raise AssertionError(greetd_config)
|
||||
|
||||
script_path = script_match.group(1)
|
||||
script = machine.succeed(f"cat {script_path}")
|
||||
t.assertIn("--command", script)
|
||||
t.assertIn("niri", script)
|
||||
t.assertIn("/share/quickshell/dms", script)
|
||||
'';
|
||||
}
|
||||
107
distro/nix/tests/home-manager-module.nix
Normal file
107
distro/nix/tests/home-manager-module.nix
Normal file
@@ -0,0 +1,107 @@
|
||||
{
|
||||
self,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
homeManagerNixosModule =
|
||||
(fetchTarball {
|
||||
url = "https://github.com/nix-community/home-manager/archive/e82d4a4ecd18363aa2054cbaa3e32e4134c3dbf4.tar.gz";
|
||||
sha256 = "sha256-ZTYDofOM3/PJhRF1EuBh6uibm+DmkhU7Wor6mMN7YTc=";
|
||||
})
|
||||
+ "/nixos";
|
||||
in
|
||||
pkgs.testers.runNixOSTest {
|
||||
name = "dms-home-manager-module";
|
||||
|
||||
nodes.machine = {
|
||||
...
|
||||
}: {
|
||||
imports = [
|
||||
homeManagerNixosModule
|
||||
];
|
||||
|
||||
users.users.danklinux = {
|
||||
isNormalUser = true;
|
||||
createHome = true;
|
||||
home = "/home/danklinux";
|
||||
extraGroups = [ "wheel" ];
|
||||
};
|
||||
|
||||
home-manager.useGlobalPkgs = true;
|
||||
home-manager.useUserPackages = true;
|
||||
|
||||
home-manager.users.danklinux = {
|
||||
pkgs,
|
||||
...
|
||||
}: {
|
||||
imports = [
|
||||
self.homeModules.dank-material-shell
|
||||
];
|
||||
|
||||
home.username = "danklinux";
|
||||
home.homeDirectory = "/home/danklinux";
|
||||
home.stateVersion = "25.11";
|
||||
|
||||
programs.dank-material-shell = {
|
||||
enable = true;
|
||||
systemd = {
|
||||
enable = true;
|
||||
target = "default.target";
|
||||
};
|
||||
|
||||
settings = {
|
||||
theme = "integration-test";
|
||||
};
|
||||
|
||||
clipboardSettings = {
|
||||
maxItems = 10;
|
||||
};
|
||||
|
||||
session = {
|
||||
startedFrom = "nixos-test";
|
||||
};
|
||||
|
||||
plugins.TestPlugin = {
|
||||
enable = true;
|
||||
src = pkgs.runCommand "dms-test-plugin" { } ''
|
||||
mkdir -p "$out"
|
||||
echo plugin > "$out/plugin.txt"
|
||||
'';
|
||||
settings = {
|
||||
enabled = true;
|
||||
source = "test";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
system.stateVersion = "25.11";
|
||||
};
|
||||
|
||||
testScript = ''
|
||||
import json
|
||||
|
||||
machine.wait_for_unit("multi-user.target")
|
||||
|
||||
machine.succeed("su -- danklinux -c 'command -v dms'")
|
||||
machine.succeed("su -- danklinux -c 'test -f ~/.config/DankMaterialShell/settings.json'")
|
||||
machine.succeed("su -- danklinux -c 'test -f ~/.config/DankMaterialShell/clsettings.json'")
|
||||
machine.succeed("su -- danklinux -c 'test -f ~/.config/DankMaterialShell/plugin_settings.json'")
|
||||
machine.succeed("su -- danklinux -c 'test -e ~/.config/DankMaterialShell/plugins/TestPlugin'")
|
||||
machine.succeed("su -- danklinux -c 'test -f ~/.local/state/DankMaterialShell/session.json'")
|
||||
|
||||
settings = json.loads(machine.succeed("su -- danklinux -c 'cat ~/.config/DankMaterialShell/settings.json'"))
|
||||
clipboard = json.loads(machine.succeed("su -- danklinux -c 'cat ~/.config/DankMaterialShell/clsettings.json'"))
|
||||
session = json.loads(machine.succeed("su -- danklinux -c 'cat ~/.local/state/DankMaterialShell/session.json'"))
|
||||
plugins = json.loads(machine.succeed("su -- danklinux -c 'cat ~/.config/DankMaterialShell/plugin_settings.json'"))
|
||||
doctor = json.loads(machine.succeed("su -- danklinux -c 'dms doctor --json'"))
|
||||
|
||||
t.assertEqual(settings["theme"], "integration-test")
|
||||
t.assertEqual(clipboard["maxItems"], 10)
|
||||
t.assertEqual(session["startedFrom"], "nixos-test")
|
||||
t.assertTrue(plugins["TestPlugin"]["enabled"])
|
||||
t.assertEqual(plugins["TestPlugin"]["source"], "test")
|
||||
t.assertIsInstance(doctor.get("results"), list)
|
||||
'';
|
||||
}
|
||||
84
distro/nix/tests/niri-home-module.nix
Normal file
84
distro/nix/tests/niri-home-module.nix
Normal file
@@ -0,0 +1,84 @@
|
||||
{
|
||||
self,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
homeManagerNixosModule =
|
||||
(fetchTarball {
|
||||
url = "https://github.com/nix-community/home-manager/archive/e82d4a4ecd18363aa2054cbaa3e32e4134c3dbf4.tar.gz";
|
||||
sha256 = "sha256-ZTYDofOM3/PJhRF1EuBh6uibm+DmkhU7Wor6mMN7YTc=";
|
||||
})
|
||||
+ "/nixos";
|
||||
|
||||
niriFlake = builtins.getFlake "github:sodiboo/niri-flake/2bb22af2985e5f3cfd051b3d977ebfbf81126280?narHash=sha256-ooPmu%2B8tqOGh4kozPW4rJC7Y7WM/FHtEY3OK1PoNW7g%3D";
|
||||
|
||||
fakeNiri = (pkgs.writeScriptBin "niri" "") // {
|
||||
cargoBuildNoDefaultFeatures = false;
|
||||
};
|
||||
in
|
||||
pkgs.testers.runNixOSTest {
|
||||
name = "dms-niri-home-module";
|
||||
|
||||
nodes.machine = {
|
||||
...
|
||||
}: {
|
||||
imports = [
|
||||
homeManagerNixosModule
|
||||
];
|
||||
|
||||
users.users.danklinux = {
|
||||
isNormalUser = true;
|
||||
createHome = true;
|
||||
home = "/home/danklinux";
|
||||
extraGroups = [ "wheel" ];
|
||||
};
|
||||
|
||||
home-manager.useGlobalPkgs = true;
|
||||
home-manager.useUserPackages = true;
|
||||
|
||||
environment.pathsToLink = [
|
||||
"/share/applications"
|
||||
"/share/xdg-desktop-portal"
|
||||
];
|
||||
|
||||
home-manager.users.danklinux = {
|
||||
...
|
||||
}: {
|
||||
imports = [
|
||||
self.homeModules.dank-material-shell
|
||||
niriFlake.homeModules.niri
|
||||
self.homeModules.niri
|
||||
];
|
||||
|
||||
home.username = "danklinux";
|
||||
home.homeDirectory = "/home/danklinux";
|
||||
home.stateVersion = "25.11";
|
||||
|
||||
programs.niri = {
|
||||
enable = true;
|
||||
package = fakeNiri; # avoids niri from being compiled in the CI
|
||||
};
|
||||
|
||||
programs.dank-material-shell = {
|
||||
enable = true;
|
||||
niri = {
|
||||
enableKeybinds = false;
|
||||
enableSpawn = true;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
system.stateVersion = "25.11";
|
||||
};
|
||||
|
||||
testScript = ''
|
||||
machine.wait_for_unit("multi-user.target")
|
||||
|
||||
machine.succeed("su -- danklinux -c 'test -f ~/.config/niri/config.kdl'")
|
||||
machine.succeed("su -- danklinux -c 'grep -F \"include \\\"dms/binds.kdl\\\"\" ~/.config/niri/config.kdl'")
|
||||
machine.succeed("su -- danklinux -c 'grep -F \"include \\\"hm.kdl\\\"\" ~/.config/niri/config.kdl'")
|
||||
machine.succeed("su -- danklinux -c 'grep -F \"spawn-at-startup\" ~/.config/niri/hm.kdl'")
|
||||
machine.succeed("su -- danklinux -c 'grep -F \"\\\"dms\\\" \\\"run\\\"\" ~/.config/niri/hm.kdl'")
|
||||
'';
|
||||
}
|
||||
47
distro/nix/tests/nixos-module.nix
Normal file
47
distro/nix/tests/nixos-module.nix
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
self,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
pkgs.testers.runNixOSTest {
|
||||
name = "dms-nixos-module";
|
||||
|
||||
nodes.machine = {
|
||||
imports = [
|
||||
self.nixosModules.dank-material-shell
|
||||
];
|
||||
|
||||
users.users.danklinux = {
|
||||
isNormalUser = true;
|
||||
extraGroups = [ "wheel" ];
|
||||
};
|
||||
|
||||
programs.dank-material-shell = {
|
||||
enable = true;
|
||||
systemd.enable = true;
|
||||
plugins = {
|
||||
TestPlugin = {
|
||||
src = pkgs.emptyDirectory;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
system.stateVersion = "25.11";
|
||||
};
|
||||
|
||||
testScript = ''
|
||||
import json
|
||||
|
||||
machine.wait_for_unit("multi-user.target")
|
||||
|
||||
machine.succeed("command -v dms")
|
||||
machine.succeed("command -v quickshell")
|
||||
machine.succeed("su -- danklinux -c 'dms --help >/dev/null'")
|
||||
machine.succeed("test -d /etc/xdg/quickshell/dms-plugins")
|
||||
machine.succeed("test -f /run/current-system/sw/lib/systemd/user/dms.service")
|
||||
|
||||
payload = json.loads(machine.succeed("su -- danklinux -c 'dms doctor --json'"))
|
||||
t.assertIn("summary", payload)
|
||||
t.assertIsInstance(payload.get("results"), list)
|
||||
'';
|
||||
}
|
||||
48
distro/nix/tests/nixos-service-start-module.nix
Normal file
48
distro/nix/tests/nixos-service-start-module.nix
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
self,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
fakeDms = pkgs.writeShellScriptBin "dms" ''
|
||||
printf '%s\n' "$@" > /tmp/dms-service-args
|
||||
exec ${pkgs.coreutils}/bin/sleep 300
|
||||
'';
|
||||
in
|
||||
pkgs.testers.runNixOSTest {
|
||||
name = "dms-nixos-service-start-module";
|
||||
|
||||
nodes.machine = {
|
||||
imports = [
|
||||
self.nixosModules.dank-material-shell
|
||||
];
|
||||
|
||||
users.users.danklinux = {
|
||||
isNormalUser = true;
|
||||
linger = true;
|
||||
extraGroups = [ "wheel" ];
|
||||
};
|
||||
|
||||
programs.dank-material-shell = {
|
||||
enable = true;
|
||||
package = fakeDms;
|
||||
systemd = {
|
||||
enable = true;
|
||||
target = "default.target";
|
||||
};
|
||||
};
|
||||
|
||||
system.stateVersion = "25.11";
|
||||
};
|
||||
|
||||
testScript = ''
|
||||
machine.wait_for_unit("multi-user.target")
|
||||
machine.wait_for_unit("user@1000.service")
|
||||
|
||||
machine.succeed("systemctl --machine=danklinux@ --user start dms.service")
|
||||
machine.wait_until_succeeds("systemctl --machine=danklinux@ --user is-active dms.service")
|
||||
machine.wait_until_succeeds("test -f /tmp/dms-service-args")
|
||||
machine.succeed("grep -Fx run /tmp/dms-service-args")
|
||||
machine.succeed("grep -Fx -- --session /tmp/dms-service-args")
|
||||
'';
|
||||
}
|
||||
170
flake.nix
170
flake.nix
@@ -45,10 +45,12 @@
|
||||
nixpkgs.lib.genAttrs [ "aarch64-darwin" "aarch64-linux" "x86_64-darwin" "x86_64-linux" ] (
|
||||
system: fn system nixpkgs.legacyPackages.${system}
|
||||
);
|
||||
buildDmsPkgs = pkgs: {
|
||||
dms-shell = self.packages.${pkgs.stdenv.hostPlatform.system}.default;
|
||||
quickshell = quickshell.packages.${pkgs.stdenv.hostPlatform.system}.default;
|
||||
};
|
||||
forEachLinuxSystem =
|
||||
fn:
|
||||
nixpkgs.lib.genAttrs [ "aarch64-linux" "x86_64-linux" ] (
|
||||
system: fn system nixpkgs.legacyPackages.${system}
|
||||
);
|
||||
|
||||
mkModuleWithDmsPkgs =
|
||||
modulePath:
|
||||
args@{ pkgs, ... }:
|
||||
@@ -57,6 +59,7 @@
|
||||
(import modulePath (args // { dmsPkgs = buildDmsPkgs pkgs; }))
|
||||
];
|
||||
};
|
||||
|
||||
mkQmlImportPath =
|
||||
pkgs: qmlPkgs:
|
||||
pkgs.lib.concatStringsSep ":" (map (o: "${o}/${pkgs.qt6.qtbase.qtQmlPrefix}") qmlPkgs);
|
||||
@@ -73,10 +76,11 @@
|
||||
qtimageformats
|
||||
kimageformats
|
||||
];
|
||||
in
|
||||
{
|
||||
packages = forEachSystem (
|
||||
system: pkgs:
|
||||
|
||||
# Allows downstream modules to provide their own 'pkgs' (with overlays)
|
||||
# instead of being forced to use the flake's locked nixpkgs.
|
||||
mkDmsShell =
|
||||
pkgs:
|
||||
let
|
||||
mkDate =
|
||||
longDate:
|
||||
@@ -94,89 +98,96 @@
|
||||
in
|
||||
"${cleanVersion}${dateSuffix}${revSuffix}";
|
||||
in
|
||||
{
|
||||
dms-shell = pkgs.lib.makeOverridable (
|
||||
pkgs.lib.makeOverridable (
|
||||
{
|
||||
extraQtPackages ? [ ],
|
||||
}:
|
||||
(pkgs.buildGoModule.override { go = goForPkgs pkgs; }) (
|
||||
let
|
||||
rootSrc = ./.;
|
||||
qtPackages = (qmlPkgs pkgs) ++ extraQtPackages;
|
||||
in
|
||||
{
|
||||
extraQtPackages ? [ ],
|
||||
}:
|
||||
(pkgs.buildGoModule.override { go = goForPkgs pkgs; }) (
|
||||
let
|
||||
rootSrc = ./.;
|
||||
qtPackages = (qmlPkgs pkgs) ++ extraQtPackages;
|
||||
in
|
||||
{
|
||||
inherit version;
|
||||
pname = "dms-shell";
|
||||
src = ./core;
|
||||
vendorHash = "sha256-dEk7IOd6aQwaxZruxQclN7TGMyb8EJOl6NBWRsoZ9HQ=";
|
||||
inherit version;
|
||||
pname = "dms-shell";
|
||||
src = ./core;
|
||||
vendorHash = "sha256-dEk7IOd6aQwaxZruxQclN7TGMyb8EJOl6NBWRsoZ9HQ=";
|
||||
|
||||
subPackages = [ "cmd/dms" ];
|
||||
subPackages = [ "cmd/dms" ];
|
||||
|
||||
ldflags = [
|
||||
"-s"
|
||||
"-w"
|
||||
"-X 'main.Version=${version}'"
|
||||
];
|
||||
ldflags = [
|
||||
"-s"
|
||||
"-w"
|
||||
"-X 'main.Version=${version}'"
|
||||
];
|
||||
|
||||
nativeBuildInputs = with pkgs; [
|
||||
installShellFiles
|
||||
makeWrapper
|
||||
];
|
||||
nativeBuildInputs = with pkgs; [
|
||||
installShellFiles
|
||||
makeWrapper
|
||||
];
|
||||
|
||||
postInstall = ''
|
||||
mkdir -p $out/share/quickshell/dms
|
||||
cp -r ${rootSrc}/quickshell/. $out/share/quickshell/dms/
|
||||
postInstall = ''
|
||||
mkdir -p $out/share/quickshell/dms
|
||||
cp -r ${rootSrc}/quickshell/. $out/share/quickshell/dms/
|
||||
|
||||
chmod u+w $out/share/quickshell/dms/VERSION
|
||||
echo "${version}" > $out/share/quickshell/dms/VERSION
|
||||
chmod u+w $out/share/quickshell/dms/VERSION
|
||||
echo "${version}" > $out/share/quickshell/dms/VERSION
|
||||
|
||||
# Install desktop file and icon
|
||||
install -D ${rootSrc}/assets/dms-open.desktop \
|
||||
$out/share/applications/dms-open.desktop
|
||||
install -D ${rootSrc}/core/assets/danklogo.svg \
|
||||
$out/share/hicolor/scalable/apps/danklogo.svg
|
||||
# Install desktop file and icon
|
||||
install -D ${rootSrc}/assets/dms-open.desktop \
|
||||
$out/share/applications/dms-open.desktop
|
||||
install -D ${rootSrc}/core/assets/danklogo.svg \
|
||||
$out/share/hicolor/scalable/apps/danklogo.svg
|
||||
|
||||
wrapProgram $out/bin/dms \
|
||||
--add-flags "-c $out/share/quickshell/dms" \
|
||||
--prefix "NIXPKGS_QT6_QML_IMPORT_PATH" ":" "${mkQmlImportPath pkgs qtPackages}" \
|
||||
--prefix "QT_PLUGIN_PATH" ":" "${mkQtPluginPath pkgs qtPackages}"
|
||||
wrapProgram $out/bin/dms \
|
||||
--add-flags "-c $out/share/quickshell/dms" \
|
||||
--prefix "NIXPKGS_QT6_QML_IMPORT_PATH" ":" "${mkQmlImportPath pkgs qtPackages}" \
|
||||
--prefix "QT_PLUGIN_PATH" ":" "${mkQtPluginPath pkgs qtPackages}"
|
||||
|
||||
install -Dm644 ${rootSrc}/assets/systemd/dms.service \
|
||||
$out/lib/systemd/user/dms.service
|
||||
install -Dm644 ${rootSrc}/assets/systemd/dms.service \
|
||||
$out/lib/systemd/user/dms.service
|
||||
|
||||
substituteInPlace $out/lib/systemd/user/dms.service \
|
||||
--replace-fail /usr/bin/dms $out/bin/dms \
|
||||
--replace-fail /usr/bin/pkill ${pkgs.procps}/bin/pkill
|
||||
substituteInPlace $out/lib/systemd/user/dms.service \
|
||||
--replace-fail /usr/bin/dms $out/bin/dms \
|
||||
--replace-fail /usr/bin/pkill ${pkgs.procps}/bin/pkill
|
||||
|
||||
substituteInPlace $out/share/quickshell/dms/Modules/Greetd/assets/dms-greeter \
|
||||
--replace-fail /bin/bash ${pkgs.bashInteractive}/bin/bash
|
||||
substituteInPlace $out/share/quickshell/dms/Modules/Greetd/assets/dms-greeter \
|
||||
--replace-fail /bin/bash ${pkgs.bashInteractive}/bin/bash
|
||||
|
||||
substituteInPlace $out/share/quickshell/dms/assets/pam/fprint \
|
||||
--replace-fail pam_fprintd.so ${pkgs.fprintd}/lib/security/pam_fprintd.so
|
||||
substituteInPlace $out/share/quickshell/dms/assets/pam/fprint \
|
||||
--replace-fail pam_fprintd.so ${pkgs.fprintd}/lib/security/pam_fprintd.so
|
||||
|
||||
substituteInPlace $out/share/quickshell/dms/assets/pam/u2f \
|
||||
--replace-fail pam_u2f.so ${pkgs.pam_u2f}/lib/security/pam_u2f.so
|
||||
substituteInPlace $out/share/quickshell/dms/assets/pam/u2f \
|
||||
--replace-fail pam_u2f.so ${pkgs.pam_u2f}/lib/security/pam_u2f.so
|
||||
|
||||
installShellCompletion --cmd dms \
|
||||
--bash <($out/bin/dms completion bash) \
|
||||
--fish <($out/bin/dms completion fish) \
|
||||
--zsh <($out/bin/dms completion zsh)
|
||||
'';
|
||||
installShellCompletion --cmd dms \
|
||||
--bash <($out/bin/dms completion bash) \
|
||||
--fish <($out/bin/dms completion fish) \
|
||||
--zsh <($out/bin/dms completion zsh)
|
||||
'';
|
||||
|
||||
meta = {
|
||||
description = "Desktop shell for wayland compositors built with Quickshell & GO";
|
||||
homepage = "https://danklinux.com";
|
||||
changelog = "https://github.com/AvengeMedia/DankMaterialShell/releases/tag/v${version}";
|
||||
license = pkgs.lib.licenses.mit;
|
||||
mainProgram = "dms";
|
||||
platforms = pkgs.lib.platforms.linux;
|
||||
};
|
||||
}
|
||||
)
|
||||
) { };
|
||||
meta = {
|
||||
description = "Desktop shell for wayland compositors built with Quickshell & GO";
|
||||
homepage = "https://danklinux.com";
|
||||
changelog = "https://github.com/AvengeMedia/DankMaterialShell/releases/tag/v${version}";
|
||||
license = pkgs.lib.licenses.mit;
|
||||
mainProgram = "dms";
|
||||
platforms = pkgs.lib.platforms.linux;
|
||||
};
|
||||
}
|
||||
)
|
||||
) { };
|
||||
|
||||
buildDmsPkgs = pkgs: {
|
||||
dms-shell = mkDmsShell pkgs;
|
||||
quickshell = quickshell.packages.${pkgs.stdenv.hostPlatform.system}.default;
|
||||
};
|
||||
in
|
||||
{
|
||||
packages = forEachSystem (
|
||||
system: pkgs: {
|
||||
dms-shell = mkDmsShell pkgs;
|
||||
quickshell = quickshell.packages.${system}.default;
|
||||
|
||||
default = self.packages.${system}.dms-shell;
|
||||
}
|
||||
);
|
||||
@@ -240,5 +251,16 @@
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
nixosTests = forEachLinuxSystem (
|
||||
system: pkgs:
|
||||
import ./distro/nix/tests {
|
||||
inherit
|
||||
self
|
||||
pkgs
|
||||
;
|
||||
lib = pkgs.lib;
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -545,6 +545,9 @@ Singleton {
|
||||
property int dockMaxVisibleApps: 0
|
||||
property int dockMaxVisibleRunningApps: 0
|
||||
property bool dockShowOverflowBadge: true
|
||||
property bool dockShowTrash: false
|
||||
property string dockTrashFileManager: "default"
|
||||
property string dockTrashCustomCommand: ""
|
||||
|
||||
property bool notificationOverlayEnabled: false
|
||||
property bool notificationPopupShadowEnabled: true
|
||||
|
||||
@@ -350,6 +350,9 @@ var SPEC = {
|
||||
dockMaxVisibleApps: { def: 0 },
|
||||
dockMaxVisibleRunningApps: { def: 0 },
|
||||
dockShowOverflowBadge: { def: true },
|
||||
dockShowTrash: { def: false },
|
||||
dockTrashFileManager: { def: "default" },
|
||||
dockTrashCustomCommand: { def: "" },
|
||||
|
||||
notificationOverlayEnabled: { def: false },
|
||||
notificationPopupShadowEnabled: { def: true },
|
||||
|
||||
@@ -4,6 +4,7 @@ import qs.Common
|
||||
import qs.Modals
|
||||
import qs.Modals.Changelog
|
||||
import qs.Modals.Clipboard
|
||||
import qs.Modals.Common
|
||||
import qs.Modals.Greeter
|
||||
import qs.Modals.Settings
|
||||
import qs.Modals.DankLauncherV2
|
||||
@@ -284,11 +285,15 @@ Item {
|
||||
|
||||
sourceComponent: Dock {
|
||||
contextMenu: dockContextMenuLoader.item ? dockContextMenuLoader.item : null
|
||||
trashContextMenu: dockTrashContextMenuLoader.item ? dockTrashContextMenuLoader.item : null
|
||||
}
|
||||
|
||||
onLoaded: {
|
||||
if (item) {
|
||||
dockContextMenuLoader.active = true;
|
||||
if (SettingsData.dockShowTrash) {
|
||||
dockTrashContextMenuLoader.active = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,6 +345,43 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
LazyLoader {
|
||||
id: dockTrashContextMenuLoader
|
||||
|
||||
active: false
|
||||
|
||||
DockTrashContextMenu {
|
||||
id: dockTrashContextMenu
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: SettingsData
|
||||
function onDockShowTrashChanged() {
|
||||
if (SettingsData.dockShowTrash) {
|
||||
dockTrashContextMenuLoader.active = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ConfirmModal {
|
||||
id: emptyTrashConfirm
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: TrashService
|
||||
function onEmptyTrashConfirmRequested(itemCount) {
|
||||
emptyTrashConfirm.showWithOptions({
|
||||
title: I18n.tr("Empty Trash?"),
|
||||
message: I18n.tr("Permanently delete %1 item(s)? This cannot be undone.").arg(itemCount),
|
||||
confirmText: I18n.tr("Empty"),
|
||||
cancelText: I18n.tr("Cancel"),
|
||||
confirmColor: Theme.error,
|
||||
onConfirm: () => TrashService.emptyTrash()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
LazyLoader {
|
||||
id: notificationCenterLoader
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ Variants {
|
||||
model: SettingsData.getFilteredScreens("dock")
|
||||
|
||||
property var contextMenu
|
||||
property var trashContextMenu
|
||||
|
||||
delegate: PanelWindow {
|
||||
id: dock
|
||||
@@ -120,7 +121,7 @@ Variants {
|
||||
return Math.round(v * _dpr) / _dpr;
|
||||
}
|
||||
|
||||
property bool contextMenuOpen: (dockVariants.contextMenu && dockVariants.contextMenu.visible && dockVariants.contextMenu.screen === modelData)
|
||||
property bool contextMenuOpen: (dockVariants.contextMenu && dockVariants.contextMenu.visible && dockVariants.contextMenu.screen === modelData) || (dockVariants.trashContextMenu && dockVariants.trashContextMenu.visible && dockVariants.trashContextMenu.screen === modelData)
|
||||
property bool revealSticky: false
|
||||
|
||||
readonly property bool shouldHideForWindows: {
|
||||
@@ -659,6 +660,7 @@ Variants {
|
||||
anchors.rightMargin: dock.isVertical ? SettingsData.dockSpacing : 0
|
||||
|
||||
contextMenu: dockVariants.contextMenu
|
||||
trashContextMenu: dockVariants.trashContextMenu
|
||||
groupByApp: dock.groupByApp
|
||||
isVertical: dock.isVertical
|
||||
dockScreen: dock.screen
|
||||
|
||||
@@ -8,6 +8,7 @@ Item {
|
||||
id: root
|
||||
|
||||
property var contextMenu: null
|
||||
property var trashContextMenu: null
|
||||
property bool requestDockShow: false
|
||||
property int pinnedAppCount: 0
|
||||
property bool groupByApp: false
|
||||
@@ -460,19 +461,51 @@ Item {
|
||||
|
||||
function updateModel() {
|
||||
const baseResult = buildBaseItems();
|
||||
dockItems = applyOverflow(baseResult);
|
||||
let finalItems = applyOverflow(baseResult);
|
||||
if (SettingsData.dockShowTrash) {
|
||||
finalItems.push({
|
||||
uniqueKey: "trash_button",
|
||||
type: "trash",
|
||||
appId: "__TRASH__",
|
||||
toplevel: null,
|
||||
isPinned: false,
|
||||
isRunning: false,
|
||||
isInOverflow: false
|
||||
});
|
||||
}
|
||||
dockItems = finalItems;
|
||||
}
|
||||
|
||||
delegate: Item {
|
||||
id: delegateItem
|
||||
|
||||
property var dockButton: itemData.type === "launcher" ? launcherButton : 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 : 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
|
||||
@@ -568,9 +601,21 @@ Item {
|
||||
index: model.index
|
||||
}
|
||||
|
||||
DockTrashButton {
|
||||
id: trashButton
|
||||
visible: itemData.type === "trash"
|
||||
anchors.centerIn: parent
|
||||
width: delegateItem.width
|
||||
height: delegateItem.height
|
||||
actualIconSize: root.iconSize
|
||||
dockApps: root
|
||||
contextMenu: root.trashContextMenu
|
||||
parentDockScreen: root.dockScreen
|
||||
}
|
||||
|
||||
DockAppButton {
|
||||
id: button
|
||||
visible: !isOverflowToggle && itemData.type !== "separator" && itemData.type !== "launcher"
|
||||
visible: !isOverflowToggle && itemData.type !== "separator" && itemData.type !== "launcher" && itemData.type !== "trash"
|
||||
anchors.centerIn: parent
|
||||
width: delegateItem.width
|
||||
height: delegateItem.height
|
||||
@@ -640,6 +685,9 @@ Item {
|
||||
function onDockMaxVisibleRunningAppsChanged() {
|
||||
repeater.updateModel();
|
||||
}
|
||||
function onDockShowTrashChanged() {
|
||||
repeater.updateModel();
|
||||
}
|
||||
}
|
||||
|
||||
onGroupByAppChanged: repeater.updateModel()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
138
quickshell/Modules/Dock/DockTrashButton.qml
Normal file
138
quickshell/Modules/Dock/DockTrashButton.qml
Normal file
@@ -0,0 +1,138 @@
|
||||
import QtQuick
|
||||
import Quickshell.Widgets
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
clip: false
|
||||
|
||||
property var dockApps: null
|
||||
property var contextMenu: null
|
||||
property var parentDockScreen: null
|
||||
property real actualIconSize: 40
|
||||
property real hoverAnimOffset: 0
|
||||
|
||||
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: {
|
||||
switch (SettingsData.dockPosition) {
|
||||
case SettingsData.Position.Top:
|
||||
case SettingsData.Position.Left:
|
||||
return 1;
|
||||
case SettingsData.Position.Bottom:
|
||||
case SettingsData.Position.Right:
|
||||
default:
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
onIsHoveredChanged: {
|
||||
if (mouseArea.pressed)
|
||||
return;
|
||||
if (!isHovered) {
|
||||
bounceAnimation.stop();
|
||||
exitAnimation.restart();
|
||||
return;
|
||||
}
|
||||
exitAnimation.stop();
|
||||
if (!bounceAnimation.running)
|
||||
bounceAnimation.restart();
|
||||
}
|
||||
|
||||
SequentialAnimation {
|
||||
id: bounceAnimation
|
||||
running: false
|
||||
|
||||
NumberAnimation {
|
||||
target: root
|
||||
property: "hoverAnimOffset"
|
||||
to: animationDirection * animationDistance * 0.25
|
||||
duration: Anims.durShort
|
||||
easing.type: Easing.BezierSpline
|
||||
easing.bezierCurve: Anims.emphasizedAccel
|
||||
}
|
||||
|
||||
NumberAnimation {
|
||||
target: root
|
||||
property: "hoverAnimOffset"
|
||||
to: animationDirection * animationDistance * 0.2
|
||||
duration: Anims.durShort
|
||||
easing.type: Easing.BezierSpline
|
||||
easing.bezierCurve: Anims.emphasizedDecel
|
||||
}
|
||||
}
|
||||
|
||||
NumberAnimation {
|
||||
id: exitAnimation
|
||||
running: false
|
||||
target: root
|
||||
property: "hoverAnimOffset"
|
||||
to: 0
|
||||
duration: Anims.durShort
|
||||
easing.type: Easing.BezierSpline
|
||||
easing.bezierCurve: Anims.emphasizedDecel
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: mouseArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
|
||||
onClicked: mouse => {
|
||||
switch (mouse.button) {
|
||||
case Qt.LeftButton:
|
||||
TrashService.openTrash();
|
||||
break;
|
||||
case Qt.RightButton:
|
||||
if (contextMenu)
|
||||
contextMenu.showForButton(root, root.height, parentDockScreen, dockApps);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
|
||||
transform: Translate {
|
||||
x: isVertical ? hoverAnimOffset : 0
|
||||
y: isVertical ? 0 : hoverAnimOffset
|
||||
}
|
||||
|
||||
Item {
|
||||
anchors.centerIn: parent
|
||||
width: actualIconSize - 4
|
||||
height: actualIconSize - 4
|
||||
|
||||
readonly property string iconPath: Paths.resolveIconPath(TrashService.isEmpty ? "user-trash" : "user-trash-full")
|
||||
|
||||
IconImage {
|
||||
id: trashIcon
|
||||
anchors.fill: parent
|
||||
source: parent.iconPath
|
||||
backer.sourceSize: Qt.size(parent.width * 2, parent.height * 2)
|
||||
smooth: true
|
||||
mipmap: true
|
||||
asynchronous: true
|
||||
visible: status === Image.Ready
|
||||
}
|
||||
|
||||
DankIcon {
|
||||
anchors.centerIn: parent
|
||||
visible: parent.iconPath === "" || trashIcon.status !== Image.Ready
|
||||
name: "delete"
|
||||
size: actualIconSize - 8
|
||||
color: TrashService.isEmpty ? Theme.surfaceText : Theme.primary
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
55
quickshell/Modules/Dock/DockTrashContextMenu.qml
Normal file
55
quickshell/Modules/Dock/DockTrashContextMenu.qml
Normal file
@@ -0,0 +1,55 @@
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
|
||||
DockContextMenuBase {
|
||||
id: root
|
||||
|
||||
property var dockApps: null
|
||||
|
||||
layerNamespace: "dms:dock-trash-context-menu"
|
||||
|
||||
function showForButton(button, dockHeight, dockScreen, parentDockApps) {
|
||||
dockApps = parentDockApps || null;
|
||||
show(button, dockHeight, dockScreen);
|
||||
}
|
||||
|
||||
DockTrashMenuItem {
|
||||
width: parent.width
|
||||
iconName: "folder_open"
|
||||
text: I18n.tr("Open Trash")
|
||||
onTriggered: {
|
||||
TrashService.openTrash();
|
||||
root.close();
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -727,8 +727,10 @@ Rectangle {
|
||||
onEntered: parent.isHovered = true
|
||||
onExited: parent.isHovered = false
|
||||
onClicked: {
|
||||
if (modelData && modelData.invoke)
|
||||
if (modelData && modelData.invoke) {
|
||||
modelData.invoke();
|
||||
PopoutService.closeNotificationCenter();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -866,6 +868,7 @@ Rectangle {
|
||||
onClicked: {
|
||||
if (modelData && modelData.invoke) {
|
||||
modelData.invoke();
|
||||
PopoutService.closeNotificationCenter();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +137,8 @@ DankPopout {
|
||||
onDprChanged: updateStablePopupHeight()
|
||||
|
||||
onShouldBeVisibleChanged: {
|
||||
notificationHistoryVisible = shouldBeVisible;
|
||||
|
||||
if (shouldBeVisible) {
|
||||
NotificationService.onOverlayOpen();
|
||||
updateStablePopupHeight();
|
||||
|
||||
@@ -211,6 +211,7 @@ Item {
|
||||
property real minWidth: contentLoader.item?.minWidth ?? 100
|
||||
property real minHeight: contentLoader.item?.minHeight ?? 100
|
||||
property bool forceSquare: contentLoader.item?.forceSquare ?? false
|
||||
property bool acceptsKeyboardFocus: contentLoader.item?.acceptsKeyboardFocus ?? false
|
||||
property bool isInteracting: dragArea.pressed || resizeArea.pressed
|
||||
|
||||
property var _gridSettingsTrigger: SettingsData.desktopWidgetGridSettings
|
||||
@@ -299,11 +300,14 @@ Item {
|
||||
}
|
||||
WlrLayershell.exclusionMode: ExclusionMode.Ignore
|
||||
WlrLayershell.keyboardFocus: {
|
||||
if (!root.isInteracting)
|
||||
return WlrKeyboardFocus.None;
|
||||
if (CompositorService.useHyprlandFocusGrab)
|
||||
if (root.isInteracting) {
|
||||
if (CompositorService.useHyprlandFocusGrab)
|
||||
return WlrKeyboardFocus.OnDemand;
|
||||
return WlrKeyboardFocus.Exclusive;
|
||||
}
|
||||
if (root.acceptsKeyboardFocus)
|
||||
return WlrKeyboardFocus.OnDemand;
|
||||
return WlrKeyboardFocus.Exclusive;
|
||||
return WlrKeyboardFocus.None;
|
||||
}
|
||||
|
||||
HyprlandFocusGrab {
|
||||
|
||||
@@ -506,6 +506,66 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
width: parent.width
|
||||
iconName: "delete"
|
||||
title: I18n.tr("Trash")
|
||||
settingKey: "dockTrash"
|
||||
|
||||
SettingsToggleRow {
|
||||
settingKey: "dockShowTrash"
|
||||
tags: ["dock", "trash", "bin", "recycle"]
|
||||
text: I18n.tr("Show Trash in Dock")
|
||||
description: I18n.tr("Place a trash bin at the end of the dock")
|
||||
checked: SettingsData.dockShowTrash
|
||||
onToggled: checked => SettingsData.set("dockShowTrash", checked)
|
||||
}
|
||||
|
||||
SettingsDropdownRow {
|
||||
id: trashFmDropdown
|
||||
settingKey: "dockTrashFileManager"
|
||||
tags: ["dock", "trash", "file", "manager", "nautilus", "thunar", "dolphin", "custom"]
|
||||
text: I18n.tr("Open Trash With")
|
||||
description: I18n.tr("File manager used to open the trash. Pick \"custom\" to enter your own command.")
|
||||
visible: SettingsData.dockShowTrash
|
||||
currentValue: SettingsData.dockTrashFileManager
|
||||
options: TrashService.availableFileManagers || []
|
||||
onValueChanged: value => SettingsData.set("dockTrashFileManager", value)
|
||||
}
|
||||
|
||||
FocusScope {
|
||||
width: parent.width - Theme.spacingM * 2
|
||||
height: visible ? trashCustomCommandColumn.implicitHeight : 0
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Theme.spacingM
|
||||
visible: SettingsData.dockShowTrash && SettingsData.dockTrashFileManager === "custom"
|
||||
|
||||
Column {
|
||||
id: trashCustomCommandColumn
|
||||
width: parent.width
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Custom open-trash command")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
}
|
||||
|
||||
DankTextField {
|
||||
id: trashCustomCommandField
|
||||
width: parent.width
|
||||
placeholderText: "pcmanfm trash:///"
|
||||
backgroundColor: Theme.surfaceContainerHighest
|
||||
normalBorderColor: Theme.outlineMedium
|
||||
focusedBorderColor: Theme.primary
|
||||
text: SettingsData.dockTrashCustomCommand
|
||||
|
||||
onTextEdited: SettingsData.set("dockTrashCustomCommand", text.trim())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
width: parent.width
|
||||
iconName: "photo_size_select_large"
|
||||
|
||||
130
quickshell/Services/TrashService.qml
Normal file
130
quickshell/Services/TrashService.qml
Normal file
@@ -0,0 +1,130 @@
|
||||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Qt.labs.folderlistmodel
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Common
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property string _homeDir: Quickshell.env("HOME") || ""
|
||||
readonly property string _xdgDataHome: Quickshell.env("XDG_DATA_HOME") || (_homeDir + "/.local/share")
|
||||
readonly property string trashFilesDir: _xdgDataHome + "/Trash/files"
|
||||
|
||||
property int count: 0
|
||||
readonly property bool isEmpty: count === 0
|
||||
|
||||
property var availableFileManagers: ["default"]
|
||||
property string defaultFileManagerLabel: "default (xdg-open)"
|
||||
|
||||
signal emptyTrashConfirmRequested(int itemCount)
|
||||
|
||||
FolderListModel {
|
||||
id: homeTrashModel
|
||||
folder: "file://" + root.trashFilesDir
|
||||
showDirs: true
|
||||
showFiles: true
|
||||
showHidden: true
|
||||
showDotAndDotDot: false
|
||||
sortField: FolderListModel.Name
|
||||
nameFilters: ["*"]
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: homeTrashModel
|
||||
function onCountChanged() {
|
||||
root.refreshCount();
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: detectProc
|
||||
running: false
|
||||
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);
|
||||
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 || "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;
|
||||
}
|
||||
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() {
|
||||
if (isEmpty)
|
||||
return;
|
||||
emptyTrashConfirmRequested(count);
|
||||
}
|
||||
|
||||
function emptyTrash() {
|
||||
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