mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-05-13 07:42:46 -04:00
Compare commits
40 Commits
blur
..
e6ed6a1cc2
| Author | SHA1 | Date | |
|---|---|---|---|
| e6ed6a1cc2 | |||
| ca18174da5 | |||
| 976b231b93 | |||
| 3d75a51378 | |||
| dc4b1529e6 | |||
| f61438e11f | |||
| 8f78163941 | |||
| f894d338fc | |||
| f2df53afcd | |||
| 4179fcee83 | |||
| a0c9af1ee7 | |||
| 049266271a | |||
| 0eabda3164 | |||
| 32c063aab8 | |||
| 37f92677cf | |||
| 13e8130858 | |||
| f6e590a518 | |||
| 3194fc3fbe | |||
| 3318864ece | |||
| e224417593 | |||
| 3f7f6c5d2c | |||
| 0b88055742 | |||
| 2b0826e397 | |||
| 7db04c9660 | |||
| 14d1e1d985 | |||
| 903ab1e61d | |||
| 5982655539 | |||
| 1021a210cf | |||
| e34edb15bb | |||
| 61ee5f4336 | |||
| ce2a92ec27 | |||
| 66ce79b9bf | |||
| 30dd640314 | |||
| 28f9aabcd9 | |||
| 3d9bd73336 | |||
| 3497d5f523 | |||
| 8ef1d95e65 | |||
| e9aeb9ac60 | |||
| fb02f7294d | |||
| f15d49d80a |
+41
-1
@@ -10,7 +10,7 @@ Go-based backend for DankMaterialShell providing system integration, IPC, and in
|
|||||||
Command-line interface and daemon for shell management and system control.
|
Command-line interface and daemon for shell management and system control.
|
||||||
|
|
||||||
**dankinstall**
|
**dankinstall**
|
||||||
Distribution-aware installer with TUI for deploying DMS and compositor configurations on Arch, Fedora, Debian, Ubuntu, openSUSE, and Gentoo.
|
Distribution-aware installer for deploying DMS and compositor configurations on Arch, Fedora, Debian, Ubuntu, openSUSE, and Gentoo. Supports both an interactive TUI and a headless (unattended) mode via CLI flags.
|
||||||
|
|
||||||
## System Integration
|
## System Integration
|
||||||
|
|
||||||
@@ -147,10 +147,50 @@ go-wayland-scanner -i internal/proto/xml/wlr-gamma-control-unstable-v1.xml \
|
|||||||
|
|
||||||
## Installation via dankinstall
|
## Installation via dankinstall
|
||||||
|
|
||||||
|
**Interactive (TUI):**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://install.danklinux.com | sh
|
curl -fsSL https://install.danklinux.com | sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Headless (unattended):**
|
||||||
|
|
||||||
|
Headless mode requires cached sudo credentials. Run `sudo -v` first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -v && curl -fsSL https://install.danklinux.com | sh -s -- -c niri -t ghostty -y
|
||||||
|
sudo -v && curl -fsSL https://install.danklinux.com | sh -s -- -c hyprland -t kitty --include-deps dms-greeter -y
|
||||||
|
```
|
||||||
|
|
||||||
|
| Flag | Short | Description |
|
||||||
|
|------|-------|-------------|
|
||||||
|
| `--compositor <niri|hyprland>` | `-c` | Compositor/WM to install (required for headless) |
|
||||||
|
| `--term <ghostty|kitty|alacritty>` | `-t` | Terminal emulator (required for headless) |
|
||||||
|
| `--include-deps <name,...>` | | Enable optional dependencies (e.g. `dms-greeter`) |
|
||||||
|
| `--exclude-deps <name,...>` | | Skip specific dependencies |
|
||||||
|
| `--replace-configs <name,...>` | | Replace specific configuration files (mutually exclusive with `--replace-configs-all`) |
|
||||||
|
| `--replace-configs-all` | | Replace all configuration files (mutually exclusive with `--replace-configs`) |
|
||||||
|
| `--yes` | `-y` | Required for headless mode — confirms installation without interactive prompts |
|
||||||
|
|
||||||
|
Headless mode requires `--yes` to proceed; without it, the installer exits with an error.
|
||||||
|
Configuration files are not replaced by default unless `--replace-configs` or `--replace-configs-all` is specified.
|
||||||
|
`dms-greeter` is disabled by default; use `--include-deps dms-greeter` to enable it.
|
||||||
|
|
||||||
|
When no flags are provided, `dankinstall` launches the interactive TUI.
|
||||||
|
|
||||||
|
### Headless mode validation rules
|
||||||
|
|
||||||
|
Headless mode activates when `--compositor` or `--term` is provided.
|
||||||
|
|
||||||
|
- Both `--compositor` and `--term` are required; providing only one results in an error.
|
||||||
|
- Headless-only flags (`--include-deps`, `--exclude-deps`, `--replace-configs`, `--replace-configs-all`, `--yes`) are rejected in TUI mode.
|
||||||
|
- Positional arguments are not accepted.
|
||||||
|
|
||||||
|
### Log file location
|
||||||
|
|
||||||
|
`dankinstall` writes logs to `/tmp` by default.
|
||||||
|
Set the `DANKINSTALL_LOG_DIR` environment variable to override the log directory.
|
||||||
|
|
||||||
## Supported Distributions
|
## Supported Distributions
|
||||||
|
|
||||||
Arch, Fedora, Debian, Ubuntu, openSUSE, Gentoo (and derivatives)
|
Arch, Fedora, Debian, Ubuntu, openSUSE, Gentoo (and derivatives)
|
||||||
|
|||||||
@@ -3,20 +3,152 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/headless"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/tui"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/tui"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
var Version = "dev"
|
var Version = "dev"
|
||||||
|
|
||||||
|
// Flag variables bound via pflag
|
||||||
|
var (
|
||||||
|
compositor string
|
||||||
|
term string
|
||||||
|
includeDeps []string
|
||||||
|
excludeDeps []string
|
||||||
|
replaceConfigs []string
|
||||||
|
replaceConfigsAll bool
|
||||||
|
yes bool
|
||||||
|
)
|
||||||
|
|
||||||
|
var rootCmd = &cobra.Command{
|
||||||
|
Use: "dankinstall",
|
||||||
|
Short: "Install DankMaterialShell and its dependencies",
|
||||||
|
Long: `dankinstall sets up DankMaterialShell with your chosen compositor and terminal.
|
||||||
|
|
||||||
|
Without flags, it launches an interactive TUI. Providing either --compositor
|
||||||
|
or --term activates headless (unattended) mode, which requires both flags.
|
||||||
|
|
||||||
|
Headless mode requires cached sudo credentials. Run 'sudo -v' beforehand, or
|
||||||
|
configure passwordless sudo for your user.`,
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: runDankinstall,
|
||||||
|
SilenceErrors: true,
|
||||||
|
SilenceUsage: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.Flags().StringVarP(&compositor, "compositor", "c", "", "Compositor/WM to install: niri or hyprland (enables headless mode)")
|
||||||
|
rootCmd.Flags().StringVarP(&term, "term", "t", "", "Terminal emulator to install: ghostty, kitty, or alacritty (enables headless mode)")
|
||||||
|
rootCmd.Flags().StringSliceVar(&includeDeps, "include-deps", []string{}, "Optional deps to enable (e.g. dms-greeter)")
|
||||||
|
rootCmd.Flags().StringSliceVar(&excludeDeps, "exclude-deps", []string{}, "Deps to skip during installation")
|
||||||
|
rootCmd.Flags().StringSliceVar(&replaceConfigs, "replace-configs", []string{}, "Deploy only named configs (e.g. niri,ghostty)")
|
||||||
|
rootCmd.Flags().BoolVar(&replaceConfigsAll, "replace-configs-all", false, "Deploy and replace all configurations")
|
||||||
|
rootCmd.Flags().BoolVarP(&yes, "yes", "y", false, "Auto-confirm all prompts")
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if os.Getuid() == 0 {
|
if os.Getuid() == 0 {
|
||||||
fmt.Fprintln(os.Stderr, "Error: dankinstall must not be run as root")
|
fmt.Fprintln(os.Stderr, "Error: dankinstall must not be run as root")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDankinstall(cmd *cobra.Command, args []string) error {
|
||||||
|
headlessMode := compositor != "" || term != ""
|
||||||
|
|
||||||
|
if !headlessMode {
|
||||||
|
// Reject headless-only flags when running in TUI mode.
|
||||||
|
headlessOnly := []string{
|
||||||
|
"include-deps",
|
||||||
|
"exclude-deps",
|
||||||
|
"replace-configs",
|
||||||
|
"replace-configs-all",
|
||||||
|
"yes",
|
||||||
|
}
|
||||||
|
var set []string
|
||||||
|
for _, name := range headlessOnly {
|
||||||
|
if cmd.Flags().Changed(name) {
|
||||||
|
set = append(set, "--"+name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(set) > 0 {
|
||||||
|
return fmt.Errorf("flags %s are only valid in headless mode (requires both --compositor and --term)", strings.Join(set, ", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if headlessMode {
|
||||||
|
return runHeadless()
|
||||||
|
}
|
||||||
|
return runTUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
func runHeadless() error {
|
||||||
|
// Validate required flags
|
||||||
|
if compositor == "" {
|
||||||
|
return fmt.Errorf("--compositor is required for headless mode (niri or hyprland)")
|
||||||
|
}
|
||||||
|
if term == "" {
|
||||||
|
return fmt.Errorf("--term is required for headless mode (ghostty, kitty, or alacritty)")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := headless.Config{
|
||||||
|
Compositor: compositor,
|
||||||
|
Terminal: term,
|
||||||
|
IncludeDeps: includeDeps,
|
||||||
|
ExcludeDeps: excludeDeps,
|
||||||
|
ReplaceConfigs: replaceConfigs,
|
||||||
|
ReplaceConfigsAll: replaceConfigsAll,
|
||||||
|
Yes: yes,
|
||||||
|
}
|
||||||
|
|
||||||
|
runner := headless.NewRunner(cfg)
|
||||||
|
|
||||||
|
// Set up file logging
|
||||||
|
fileLogger, err := log.NewFileLogger()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: Failed to create log file: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if fileLogger != nil {
|
||||||
|
fmt.Printf("Logging to: %s\n", fileLogger.GetLogPath())
|
||||||
|
fileLogger.StartListening(runner.GetLogChan())
|
||||||
|
defer func() {
|
||||||
|
if err := fileLogger.Close(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: Failed to close log file: %v\n", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
} else {
|
||||||
|
// Drain the log channel to prevent blocking sends from deadlocking
|
||||||
|
// downstream components (distros, config deployer) that write to it.
|
||||||
|
// Use an explicit stop signal because this code does not own the
|
||||||
|
// runner log channel and cannot assume it will be closed.
|
||||||
|
defer drainLogChan(runner.GetLogChan())()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := runner.Run(); err != nil {
|
||||||
|
if fileLogger != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "\nFull logs are available at: %s\n", fileLogger.GetLogPath())
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if fileLogger != nil {
|
||||||
|
fmt.Printf("\nFull logs are available at: %s\n", fileLogger.GetLogPath())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runTUI() error {
|
||||||
fileLogger, err := log.NewFileLogger()
|
fileLogger, err := log.NewFileLogger()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Warning: Failed to create log file: %v\n", err)
|
fmt.Printf("Warning: Failed to create log file: %v\n", err)
|
||||||
@@ -38,18 +170,50 @@ func main() {
|
|||||||
|
|
||||||
if fileLogger != nil {
|
if fileLogger != nil {
|
||||||
fileLogger.StartListening(model.GetLogChan())
|
fileLogger.StartListening(model.GetLogChan())
|
||||||
|
} else {
|
||||||
|
// Drain the log channel to prevent blocking sends from deadlocking
|
||||||
|
// downstream components (distros, config deployer) that write to it.
|
||||||
|
// Use an explicit stop signal because this code does not own the
|
||||||
|
// model log channel and cannot assume it will be closed.
|
||||||
|
defer drainLogChan(model.GetLogChan())()
|
||||||
}
|
}
|
||||||
|
|
||||||
p := tea.NewProgram(model, tea.WithAltScreen())
|
p := tea.NewProgram(model, tea.WithAltScreen())
|
||||||
if _, err := p.Run(); err != nil {
|
if _, err := p.Run(); err != nil {
|
||||||
fmt.Printf("Error running program: %v\n", err)
|
|
||||||
if logFilePath != "" {
|
if logFilePath != "" {
|
||||||
fmt.Printf("\nFull logs are available at: %s\n", logFilePath)
|
fmt.Fprintf(os.Stderr, "\nFull logs are available at: %s\n", logFilePath)
|
||||||
}
|
}
|
||||||
os.Exit(1)
|
return fmt.Errorf("error running program: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if logFilePath != "" {
|
if logFilePath != "" {
|
||||||
fmt.Printf("\nFull logs are available at: %s\n", logFilePath)
|
fmt.Printf("\nFull logs are available at: %s\n", logFilePath)
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// drainLogChan starts a goroutine that discards all messages from logCh,
|
||||||
|
// preventing blocking sends from deadlocking downstream components. It returns
|
||||||
|
// a cleanup function that signals the goroutine to stop and waits for it to
|
||||||
|
// exit. Callers should defer the returned function.
|
||||||
|
func drainLogChan(logCh <-chan string) func() {
|
||||||
|
drainStop := make(chan struct{})
|
||||||
|
drainDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(drainDone)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-drainStop:
|
||||||
|
return
|
||||||
|
case _, ok := <-logCh:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return func() {
|
||||||
|
close(drainStop)
|
||||||
|
<-drainDone
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/blur"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var blurCmd = &cobra.Command{
|
||||||
|
Use: "blur",
|
||||||
|
Short: "Background blur utilities",
|
||||||
|
}
|
||||||
|
|
||||||
|
var blurCheckCmd = &cobra.Command{
|
||||||
|
Use: "check",
|
||||||
|
Short: "Check if the compositor supports background blur (ext-background-effect-v1)",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
Run: runBlurCheck,
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
blurCmd.AddCommand(blurCheckCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runBlurCheck(cmd *cobra.Command, args []string) {
|
||||||
|
supported, err := blur.ProbeSupport()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch supported {
|
||||||
|
case true:
|
||||||
|
fmt.Println("supported")
|
||||||
|
default:
|
||||||
|
fmt.Println("unsupported")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -525,5 +525,6 @@ func getCommonCommands() []*cobra.Command {
|
|||||||
configCmd,
|
configCmd,
|
||||||
dlCmd,
|
dlCmd,
|
||||||
randrCmd,
|
randrCmd,
|
||||||
|
blurCmd,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ func (ds *DoctorStatus) OKCount() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
quickshellVersionRegex = regexp.MustCompile(`quickshell (\d+\.\d+\.\d+)`)
|
quickshellVersionRegex = regexp.MustCompile(`(?i)quickshell (\d+\.\d+\.\d+)`)
|
||||||
hyprlandVersionRegex = regexp.MustCompile(`v?(\d+\.\d+\.\d+)`)
|
hyprlandVersionRegex = regexp.MustCompile(`v?(\d+\.\d+\.\d+)`)
|
||||||
niriVersionRegex = regexp.MustCompile(`niri (\d+\.\d+)`)
|
niriVersionRegex = regexp.MustCompile(`niri (\d+\.\d+)`)
|
||||||
swayVersionRegex = regexp.MustCompile(`sway version (\d+\.\d+)`)
|
swayVersionRegex = regexp.MustCompile(`sway version (\d+\.\d+)`)
|
||||||
@@ -820,10 +820,14 @@ func checkOptionalDependencies() []checkResult {
|
|||||||
results = append(results, checkImageFormatPlugins()...)
|
results = append(results, checkImageFormatPlugins()...)
|
||||||
|
|
||||||
terminals := []string{"ghostty", "kitty", "alacritty", "foot", "wezterm"}
|
terminals := []string{"ghostty", "kitty", "alacritty", "foot", "wezterm"}
|
||||||
if idx := slices.IndexFunc(terminals, utils.CommandExists); idx >= 0 {
|
terminals = slices.DeleteFunc(terminals, func(t string) bool {
|
||||||
results = append(results, checkResult{catOptionalFeatures, "Terminal", statusOK, terminals[idx], "", optionalFeaturesURL})
|
return !utils.CommandExists(t)
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(terminals) > 0 {
|
||||||
|
results = append(results, checkResult{catOptionalFeatures, "Terminal", statusOK, strings.Join(terminals, ", "), "", optionalFeaturesURL})
|
||||||
} else {
|
} else {
|
||||||
results = append(results, checkResult{catOptionalFeatures, "Terminal", statusWarn, "None found", "Install ghostty, kitty, or alacritty", optionalFeaturesURL})
|
results = append(results, checkResult{catOptionalFeatures, "Terminal", statusWarn, "None found", "Install ghostty, kitty, foot or alacritty", optionalFeaturesURL})
|
||||||
}
|
}
|
||||||
|
|
||||||
networkResult, err := network.DetectNetworkStack()
|
networkResult, err := network.DetectNetworkStack()
|
||||||
|
|||||||
@@ -109,16 +109,41 @@ func updateArchLinux() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var packageName string
|
var packageName string
|
||||||
if isArchPackageInstalled("dms-shell-bin") {
|
var isAUR bool
|
||||||
packageName = "dms-shell-bin"
|
if isArchPackageInstalled("dms-shell") {
|
||||||
|
packageName = "dms-shell"
|
||||||
} else if isArchPackageInstalled("dms-shell-git") {
|
} else if isArchPackageInstalled("dms-shell-git") {
|
||||||
packageName = "dms-shell-git"
|
packageName = "dms-shell-git"
|
||||||
|
isAUR = true
|
||||||
|
} else if isArchPackageInstalled("dms-shell-bin") {
|
||||||
|
packageName = "dms-shell-bin"
|
||||||
|
isAUR = true
|
||||||
} else {
|
} else {
|
||||||
fmt.Println("Info: Neither dms-shell-bin nor dms-shell-git package found.")
|
fmt.Println("Info: No dms-shell package found.")
|
||||||
fmt.Println("Info: Falling back to git-based update method...")
|
fmt.Println("Info: Falling back to git-based update method...")
|
||||||
return updateOtherDistros()
|
return updateOtherDistros()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !isAUR {
|
||||||
|
fmt.Printf("This will update %s using pacman.\n", packageName)
|
||||||
|
if !confirmUpdate() {
|
||||||
|
return errdefs.ErrUpdateCancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\nRunning: sudo pacman -S %s\n", packageName)
|
||||||
|
cmd := exec.Command("sudo", "pacman", "-S", "--noconfirm", packageName)
|
||||||
|
cmd.Stdin = os.Stdin
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
fmt.Printf("Error: Failed to update using pacman: %v\n", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("dms successfully updated")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
var helper string
|
var helper string
|
||||||
var updateCmd *exec.Cmd
|
var updateCmd *exec.Cmd
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,7 +31,9 @@ func init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if os.Geteuid() == 0 {
|
clipboard.MaybeServeAndExit()
|
||||||
|
|
||||||
|
if os.Geteuid() == 0 && !isReadOnlyCommand(os.Args) {
|
||||||
log.Fatal("This program should not be run as root. Exiting.")
|
log.Fatal("This program should not be run as root. Exiting.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,7 +28,9 @@ func init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if os.Geteuid() == 0 {
|
clipboard.MaybeServeAndExit()
|
||||||
|
|
||||||
|
if os.Geteuid() == 0 && !isReadOnlyCommand(os.Args) {
|
||||||
log.Fatal("This program should not be run as root. Exiting.")
|
log.Fatal("This program should not be run as root. Exiting.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,22 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// isReadOnlyCommand returns true if the CLI args indicate a command that is
|
||||||
|
// safe to run as root (e.g. shell completion, help).
|
||||||
|
func isReadOnlyCommand(args []string) bool {
|
||||||
|
for _, arg := range args[1:] {
|
||||||
|
if strings.HasPrefix(arg, "-") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch arg {
|
||||||
|
case "completion", "help", "__complete":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func isArchPackageInstalled(packageName string) bool {
|
func isArchPackageInstalled(packageName string) bool {
|
||||||
cmd := exec.Command("pacman", "-Q", packageName)
|
cmd := exec.Command("pacman", "-Q", packageName)
|
||||||
err := cmd.Run()
|
err := cmd.Run()
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package blur
|
||||||
|
|
||||||
|
import (
|
||||||
|
wlhelpers "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/client"
|
||||||
|
client "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
const extBackgroundEffectInterface = "ext_background_effect_manager_v1"
|
||||||
|
|
||||||
|
func ProbeSupport() (bool, error) {
|
||||||
|
display, err := client.Connect("")
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer display.Context().Close()
|
||||||
|
|
||||||
|
registry, err := display.GetRegistry()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
found := false
|
||||||
|
registry.SetGlobalHandler(func(e client.RegistryGlobalEvent) {
|
||||||
|
switch e.Interface {
|
||||||
|
case extBackgroundEffectInterface:
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := wlhelpers.Roundtrip(display, display.Context()); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return found, nil
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package clipboard
|
package clipboard
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
@@ -13,66 +12,142 @@ import (
|
|||||||
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const envServe = "_DMS_CLIPBOARD_SERVE"
|
||||||
|
const envMime = "_DMS_CLIPBOARD_MIME"
|
||||||
|
const envPasteOnce = "_DMS_CLIPBOARD_PASTE_ONCE"
|
||||||
|
const envCacheFile = "_DMS_CLIPBOARD_CACHE"
|
||||||
|
|
||||||
|
// MaybeServeAndExit intercepts before cobra when re-exec'd as a clipboard
|
||||||
|
// child. Reads source data into memory, deletes any cache file, then serves.
|
||||||
|
func MaybeServeAndExit() {
|
||||||
|
if os.Getenv(envServe) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mimeType := os.Getenv(envMime)
|
||||||
|
pasteOnce := os.Getenv(envPasteOnce) == "1"
|
||||||
|
cachePath := os.Getenv(envCacheFile)
|
||||||
|
|
||||||
|
var data []byte
|
||||||
|
var err error
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case cachePath != "":
|
||||||
|
data, err = os.ReadFile(cachePath)
|
||||||
|
os.Remove(cachePath)
|
||||||
|
default:
|
||||||
|
data, err = io.ReadAll(os.Stdin)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "clipboard: read source: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := serveClipboard(data, mimeType, pasteOnce); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "clipboard: serve: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
func Copy(data []byte, mimeType string) error {
|
func Copy(data []byte, mimeType string) error {
|
||||||
return CopyReader(bytes.NewReader(data), mimeType, false, false)
|
return copyForkCached(data, mimeType, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func CopyOpts(data []byte, mimeType string, foreground, pasteOnce bool) error {
|
func CopyOpts(data []byte, mimeType string, foreground, pasteOnce bool) error {
|
||||||
if foreground {
|
if foreground {
|
||||||
return copyServeWithWriter(func(writer io.Writer) error {
|
return serveClipboard(data, mimeType, pasteOnce)
|
||||||
total := 0
|
|
||||||
for total < len(data) {
|
|
||||||
n, err := writer.Write(data[total:])
|
|
||||||
total += n
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if total != len(data) {
|
|
||||||
return io.ErrShortWrite
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}, mimeType, pasteOnce)
|
|
||||||
}
|
}
|
||||||
return CopyReader(bytes.NewReader(data), mimeType, foreground, pasteOnce)
|
return copyForkCached(data, mimeType, pasteOnce)
|
||||||
}
|
}
|
||||||
|
|
||||||
func CopyReader(data io.Reader, mimeType string, foreground, pasteOnce bool) error {
|
func CopyReader(data io.Reader, mimeType string, foreground, pasteOnce bool) error {
|
||||||
if !foreground {
|
if foreground {
|
||||||
return copyFork(data, mimeType, pasteOnce)
|
buf, err := io.ReadAll(data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read source: %w", err)
|
||||||
|
}
|
||||||
|
return serveClipboard(buf, mimeType, pasteOnce)
|
||||||
}
|
}
|
||||||
return copyServeReader(data, mimeType, pasteOnce)
|
return copyFork(data, mimeType, pasteOnce)
|
||||||
}
|
}
|
||||||
|
|
||||||
func copyFork(data io.Reader, mimeType string, pasteOnce bool) error {
|
func newForkCmd(mimeType string, pasteOnce bool, extra ...string) *exec.Cmd {
|
||||||
args := []string{os.Args[0], "cl", "copy", "--foreground"}
|
cmd := exec.Command(os.Args[0])
|
||||||
if pasteOnce {
|
|
||||||
args = append(args, "--paste-once")
|
|
||||||
}
|
|
||||||
args = append(args, "--type", mimeType)
|
|
||||||
|
|
||||||
cmd := exec.Command(args[0], args[1:]...)
|
|
||||||
cmd.Stderr = nil
|
cmd.Stderr = nil
|
||||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||||
cmd.Env = append(os.Environ(), "DMS_CLIP_FORKED=1")
|
cmd.Env = append(os.Environ(),
|
||||||
|
envServe+"=1",
|
||||||
|
envMime+"="+mimeType,
|
||||||
|
)
|
||||||
|
if pasteOnce {
|
||||||
|
cmd.Env = append(cmd.Env, envPasteOnce+"=1")
|
||||||
|
}
|
||||||
|
cmd.Env = append(cmd.Env, extra...)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitReady(cmd *exec.Cmd) error {
|
||||||
stdout, err := cmd.StdoutPipe()
|
stdout, err := cmd.StdoutPipe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("stdout pipe: %w", err)
|
return fmt.Errorf("stdout pipe: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return fmt.Errorf("start: %w", err)
|
||||||
|
}
|
||||||
|
var buf [1]byte
|
||||||
|
if _, err := stdout.Read(buf[:]); err != nil {
|
||||||
|
return fmt.Errorf("waiting for clipboard ready: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyForkCached(data []byte, mimeType string, pasteOnce bool) error {
|
||||||
|
cacheFile, err := createClipboardCacheFile()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create cache file: %w", err)
|
||||||
|
}
|
||||||
|
cachePath := cacheFile.Name()
|
||||||
|
|
||||||
|
if _, err := cacheFile.Write(data); err != nil {
|
||||||
|
cacheFile.Close()
|
||||||
|
os.Remove(cachePath)
|
||||||
|
return fmt.Errorf("write cache file: %w", err)
|
||||||
|
}
|
||||||
|
if err := cacheFile.Close(); err != nil {
|
||||||
|
os.Remove(cachePath)
|
||||||
|
return fmt.Errorf("close cache file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := newForkCmd(mimeType, pasteOnce, envCacheFile+"="+cachePath)
|
||||||
|
cmd.Stdin = nil
|
||||||
|
if err := waitReady(cmd); err != nil {
|
||||||
|
os.Remove(cachePath)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyFork(data io.Reader, mimeType string, pasteOnce bool) error {
|
||||||
|
cmd := newForkCmd(mimeType, pasteOnce)
|
||||||
|
|
||||||
switch src := data.(type) {
|
switch src := data.(type) {
|
||||||
case *os.File:
|
case *os.File:
|
||||||
cmd.Stdin = src
|
cmd.Stdin = src
|
||||||
if err := cmd.Start(); err != nil {
|
return waitReady(cmd)
|
||||||
return fmt.Errorf("start: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
stdin, err := cmd.StdinPipe()
|
stdin, err := cmd.StdinPipe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("stdin pipe: %w", err)
|
return fmt.Errorf("stdin pipe: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("stdout pipe: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
return fmt.Errorf("start: %w", err)
|
return fmt.Errorf("start: %w", err)
|
||||||
}
|
}
|
||||||
@@ -83,50 +158,22 @@ func copyFork(data io.Reader, mimeType string, pasteOnce bool) error {
|
|||||||
if err := stdin.Close(); err != nil {
|
if err := stdin.Close(); err != nil {
|
||||||
return fmt.Errorf("close stdin: %w", err)
|
return fmt.Errorf("close stdin: %w", err)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
var buf [1]byte
|
var buf [1]byte
|
||||||
if _, err := stdout.Read(buf[:]); err != nil {
|
if _, err := stdout.Read(buf[:]); err != nil {
|
||||||
return fmt.Errorf("waiting for clipboard ready: %w", err)
|
return fmt.Errorf("waiting for clipboard ready: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func signalReady() {
|
func signalReady() {
|
||||||
if os.Getenv("DMS_CLIP_FORKED") == "" {
|
if os.Getenv(envServe) == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
os.Stdout.Write([]byte{1})
|
os.Stdout.Write([]byte{1})
|
||||||
}
|
}
|
||||||
|
|
||||||
func copyServeReader(data io.Reader, mimeType string, pasteOnce bool) error {
|
|
||||||
cachedData, err := createClipboardCacheFile()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("create clipboard cache file: %w", err)
|
|
||||||
}
|
|
||||||
defer os.Remove(cachedData.Name())
|
|
||||||
|
|
||||||
if _, err := io.Copy(cachedData, data); err != nil {
|
|
||||||
return fmt.Errorf("cache clipboard data: %w", err)
|
|
||||||
}
|
|
||||||
if err := cachedData.Close(); err != nil {
|
|
||||||
return fmt.Errorf("close temp cache file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return copyServeWithWriter(func(writer io.Writer) error {
|
|
||||||
cachedFile, err := os.Open(cachedData.Name())
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("open temp cache file: %w", err)
|
|
||||||
}
|
|
||||||
defer cachedFile.Close()
|
|
||||||
|
|
||||||
if _, err := io.Copy(writer, cachedFile); err != nil {
|
|
||||||
return fmt.Errorf("write clipboard data: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}, mimeType, pasteOnce)
|
|
||||||
}
|
|
||||||
|
|
||||||
func createClipboardCacheFile() (*os.File, error) {
|
func createClipboardCacheFile() (*os.File, error) {
|
||||||
preferredDirs := []string{}
|
preferredDirs := []string{}
|
||||||
|
|
||||||
@@ -147,7 +194,7 @@ func createClipboardCacheFile() (*os.File, error) {
|
|||||||
return os.CreateTemp("", "dms-clipboard-*")
|
return os.CreateTemp("", "dms-clipboard-*")
|
||||||
}
|
}
|
||||||
|
|
||||||
func copyServeWithWriter(writeTo func(io.Writer) error, mimeType string, pasteOnce bool) error {
|
func serveClipboard(data []byte, mimeType string, pasteOnce bool) error {
|
||||||
display, err := wlclient.Connect("")
|
display, err := wlclient.Connect("")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("wayland connect: %w", err)
|
return fmt.Errorf("wayland connect: %w", err)
|
||||||
@@ -189,12 +236,10 @@ func copyServeWithWriter(writeTo func(io.Writer) error, mimeType string, pasteOn
|
|||||||
if bindErr != nil {
|
if bindErr != nil {
|
||||||
return fmt.Errorf("registry bind: %w", bindErr)
|
return fmt.Errorf("registry bind: %w", bindErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
if dataControlMgr == nil {
|
if dataControlMgr == nil {
|
||||||
return fmt.Errorf("compositor does not support ext_data_control_manager_v1")
|
return fmt.Errorf("compositor does not support ext_data_control_manager_v1")
|
||||||
}
|
}
|
||||||
defer dataControlMgr.Destroy()
|
defer dataControlMgr.Destroy()
|
||||||
|
|
||||||
if seat == nil {
|
if seat == nil {
|
||||||
return fmt.Errorf("no seat available")
|
return fmt.Errorf("no seat available")
|
||||||
}
|
}
|
||||||
@@ -233,18 +278,12 @@ func copyServeWithWriter(writeTo func(io.Writer) error, mimeType string, pasteOn
|
|||||||
|
|
||||||
cancelled := make(chan struct{})
|
cancelled := make(chan struct{})
|
||||||
pasted := make(chan struct{}, 1)
|
pasted := make(chan struct{}, 1)
|
||||||
sendErr := make(chan error, 1)
|
|
||||||
|
|
||||||
source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) {
|
source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) {
|
||||||
defer syscall.Close(e.Fd)
|
_ = syscall.SetNonblock(e.Fd, false)
|
||||||
file := os.NewFile(uintptr(e.Fd), "pipe")
|
file := os.NewFile(uintptr(e.Fd), "pipe")
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
if err := writeTo(file); err != nil {
|
_, _ = file.Write(data)
|
||||||
select {
|
|
||||||
case sendErr <- err:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
select {
|
select {
|
||||||
case pasted <- struct{}{}:
|
case pasted <- struct{}{}:
|
||||||
default:
|
default:
|
||||||
@@ -266,8 +305,6 @@ func copyServeWithWriter(writeTo func(io.Writer) error, mimeType string, pasteOn
|
|||||||
select {
|
select {
|
||||||
case <-cancelled:
|
case <-cancelled:
|
||||||
return nil
|
return nil
|
||||||
case err := <-sendErr:
|
|
||||||
return err
|
|
||||||
case <-pasted:
|
case <-pasted:
|
||||||
if pasteOnce {
|
if pasteOnce {
|
||||||
return nil
|
return nil
|
||||||
@@ -521,12 +558,10 @@ func copyMultiServe(offers []Offer, pasteOnce bool) error {
|
|||||||
if bindErr != nil {
|
if bindErr != nil {
|
||||||
return fmt.Errorf("registry bind: %w", bindErr)
|
return fmt.Errorf("registry bind: %w", bindErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
if dataControlMgr == nil {
|
if dataControlMgr == nil {
|
||||||
return fmt.Errorf("compositor does not support ext_data_control_manager_v1")
|
return fmt.Errorf("compositor does not support ext_data_control_manager_v1")
|
||||||
}
|
}
|
||||||
defer dataControlMgr.Destroy()
|
defer dataControlMgr.Destroy()
|
||||||
|
|
||||||
if seat == nil {
|
if seat == nil {
|
||||||
return fmt.Errorf("no seat available")
|
return fmt.Errorf("no seat available")
|
||||||
}
|
}
|
||||||
@@ -554,12 +589,12 @@ func copyMultiServe(offers []Offer, pasteOnce bool) error {
|
|||||||
pasted := make(chan struct{}, 1)
|
pasted := make(chan struct{}, 1)
|
||||||
|
|
||||||
source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) {
|
source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) {
|
||||||
defer syscall.Close(e.Fd)
|
_ = syscall.SetNonblock(e.Fd, false)
|
||||||
file := os.NewFile(uintptr(e.Fd), "pipe")
|
file := os.NewFile(uintptr(e.Fd), "pipe")
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
if data, ok := offerMap[e.MimeType]; ok {
|
if data, ok := offerMap[e.MimeType]; ok {
|
||||||
file.Write(data)
|
_, _ = file.Write(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
|
|||||||
@@ -39,11 +39,10 @@ type LayerSurface struct {
|
|||||||
wlSurface *client.Surface
|
wlSurface *client.Surface
|
||||||
layerSurf *wlr_layer_shell.ZwlrLayerSurfaceV1
|
layerSurf *wlr_layer_shell.ZwlrLayerSurfaceV1
|
||||||
viewport *wp_viewporter.WpViewport
|
viewport *wp_viewporter.WpViewport
|
||||||
wlPool *client.ShmPool
|
wlPools [2]*client.ShmPool
|
||||||
wlBuffer *client.Buffer
|
wlBuffers [2]*client.Buffer
|
||||||
bufferBusy bool
|
slotBusy [2]bool
|
||||||
oldPool *client.ShmPool
|
needsRedraw bool
|
||||||
oldBuffer *client.Buffer
|
|
||||||
scopyBuffer *client.Buffer
|
scopyBuffer *client.Buffer
|
||||||
configured bool
|
configured bool
|
||||||
hidden bool
|
hidden bool
|
||||||
@@ -136,6 +135,7 @@ func (p *Picker) Run() (*Color, error) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
p.flushRedraws()
|
||||||
p.checkDone()
|
p.checkDone()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,6 +164,15 @@ func (p *Picker) checkDone() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *Picker) flushRedraws() {
|
||||||
|
for _, ls := range p.surfaces {
|
||||||
|
if !ls.needsRedraw {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p.redrawSurface(ls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (p *Picker) connect() error {
|
func (p *Picker) connect() error {
|
||||||
display, err := client.Connect("")
|
display, err := client.Connect("")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -507,47 +516,45 @@ func (p *Picker) captureForSurface(ls *LayerSurface) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *Picker) redrawSurface(ls *LayerSurface) {
|
func (p *Picker) redrawSurface(ls *LayerSurface) {
|
||||||
|
slot := ls.state.FrontIndex()
|
||||||
|
if ls.slotBusy[slot] {
|
||||||
|
ls.needsRedraw = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var renderBuf *ShmBuffer
|
var renderBuf *ShmBuffer
|
||||||
if ls.hidden {
|
switch {
|
||||||
|
case ls.hidden:
|
||||||
renderBuf = ls.state.RedrawScreenOnly()
|
renderBuf = ls.state.RedrawScreenOnly()
|
||||||
} else {
|
default:
|
||||||
renderBuf = ls.state.Redraw()
|
renderBuf = ls.state.Redraw()
|
||||||
}
|
}
|
||||||
if renderBuf == nil {
|
if renderBuf == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if ls.oldBuffer != nil {
|
ls.needsRedraw = false
|
||||||
ls.oldBuffer.Destroy()
|
|
||||||
ls.oldBuffer = nil
|
if ls.wlPools[slot] == nil {
|
||||||
}
|
pool, err := p.shm.CreatePool(renderBuf.Fd(), int32(renderBuf.Size()))
|
||||||
if ls.oldPool != nil {
|
if err != nil {
|
||||||
ls.oldPool.Destroy()
|
return
|
||||||
ls.oldPool = nil
|
}
|
||||||
|
ls.wlPools[slot] = pool
|
||||||
|
|
||||||
|
wlBuffer, err := pool.CreateBuffer(0, int32(renderBuf.Width), int32(renderBuf.Height), int32(renderBuf.Stride), uint32(ls.state.ScreenFormat()))
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ls.wlBuffers[slot] = wlBuffer
|
||||||
|
|
||||||
|
s := slot
|
||||||
|
wlBuffer.SetReleaseHandler(func(e client.BufferReleaseEvent) {
|
||||||
|
ls.slotBusy[s] = false
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
ls.oldPool = ls.wlPool
|
ls.slotBusy[slot] = true
|
||||||
ls.oldBuffer = ls.wlBuffer
|
|
||||||
ls.wlPool = nil
|
|
||||||
ls.wlBuffer = nil
|
|
||||||
|
|
||||||
pool, err := p.shm.CreatePool(renderBuf.Fd(), int32(renderBuf.Size()))
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ls.wlPool = pool
|
|
||||||
|
|
||||||
wlBuffer, err := pool.CreateBuffer(0, int32(renderBuf.Width), int32(renderBuf.Height), int32(renderBuf.Stride), uint32(ls.state.ScreenFormat()))
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ls.wlBuffer = wlBuffer
|
|
||||||
|
|
||||||
lsRef := ls
|
|
||||||
wlBuffer.SetReleaseHandler(func(e client.BufferReleaseEvent) {
|
|
||||||
lsRef.bufferBusy = false
|
|
||||||
})
|
|
||||||
ls.bufferBusy = true
|
|
||||||
|
|
||||||
logicalW, logicalH := ls.state.LogicalSize()
|
logicalW, logicalH := ls.state.LogicalSize()
|
||||||
if logicalW == 0 || logicalH == 0 {
|
if logicalW == 0 || logicalH == 0 {
|
||||||
@@ -566,7 +573,7 @@ func (p *Picker) redrawSurface(ls *LayerSurface) {
|
|||||||
}
|
}
|
||||||
_ = ls.wlSurface.SetBufferScale(bufferScale)
|
_ = ls.wlSurface.SetBufferScale(bufferScale)
|
||||||
}
|
}
|
||||||
_ = ls.wlSurface.Attach(wlBuffer, 0, 0)
|
_ = ls.wlSurface.Attach(ls.wlBuffers[slot], 0, 0)
|
||||||
_ = ls.wlSurface.Damage(0, 0, int32(logicalW), int32(logicalH))
|
_ = ls.wlSurface.Damage(0, 0, int32(logicalW), int32(logicalH))
|
||||||
_ = ls.wlSurface.Commit()
|
_ = ls.wlSurface.Commit()
|
||||||
|
|
||||||
@@ -634,7 +641,7 @@ func (p *Picker) setupPointerHandlers() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
p.activeSurface.state.OnPointerMotion(e.SurfaceX, e.SurfaceY)
|
p.activeSurface.state.OnPointerMotion(e.SurfaceX, e.SurfaceY)
|
||||||
p.redrawSurface(p.activeSurface)
|
p.activeSurface.needsRedraw = true
|
||||||
})
|
})
|
||||||
|
|
||||||
p.pointer.SetLeaveHandler(func(e client.PointerLeaveEvent) {
|
p.pointer.SetLeaveHandler(func(e client.PointerLeaveEvent) {
|
||||||
@@ -655,7 +662,7 @@ func (p *Picker) setupPointerHandlers() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
p.activeSurface.state.OnPointerMotion(e.SurfaceX, e.SurfaceY)
|
p.activeSurface.state.OnPointerMotion(e.SurfaceX, e.SurfaceY)
|
||||||
p.redrawSurface(p.activeSurface)
|
p.activeSurface.needsRedraw = true
|
||||||
})
|
})
|
||||||
|
|
||||||
p.pointer.SetButtonHandler(func(e client.PointerButtonEvent) {
|
p.pointer.SetButtonHandler(func(e client.PointerButtonEvent) {
|
||||||
@@ -679,17 +686,13 @@ func (p *Picker) cleanup() {
|
|||||||
if ls.scopyBuffer != nil {
|
if ls.scopyBuffer != nil {
|
||||||
ls.scopyBuffer.Destroy()
|
ls.scopyBuffer.Destroy()
|
||||||
}
|
}
|
||||||
if ls.oldBuffer != nil {
|
for i := range ls.wlBuffers {
|
||||||
ls.oldBuffer.Destroy()
|
if ls.wlBuffers[i] != nil {
|
||||||
}
|
ls.wlBuffers[i].Destroy()
|
||||||
if ls.oldPool != nil {
|
}
|
||||||
ls.oldPool.Destroy()
|
if ls.wlPools[i] != nil {
|
||||||
}
|
ls.wlPools[i].Destroy()
|
||||||
if ls.wlBuffer != nil {
|
}
|
||||||
ls.wlBuffer.Destroy()
|
|
||||||
}
|
|
||||||
if ls.wlPool != nil {
|
|
||||||
ls.wlPool.Destroy()
|
|
||||||
}
|
}
|
||||||
if ls.viewport != nil {
|
if ls.viewport != nil {
|
||||||
ls.viewport.Destroy()
|
ls.viewport.Destroy()
|
||||||
|
|||||||
@@ -274,6 +274,12 @@ func (s *SurfaceState) FrontRenderBuffer() *ShmBuffer {
|
|||||||
return s.renderBufs[s.front]
|
return s.renderBufs[s.front]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SurfaceState) FrontIndex() int {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.front
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SurfaceState) SwapBuffers() {
|
func (s *SurfaceState) SwapBuffers() {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.front ^= 1
|
s.front ^= 1
|
||||||
|
|||||||
@@ -62,12 +62,31 @@ func (cd *ConfigDeployer) DeployConfigurationsSelectiveWithReinstalls(ctx contex
|
|||||||
func (cd *ConfigDeployer) deployConfigurationsInternal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal, installedDeps []deps.Dependency, replaceConfigs map[string]bool, reinstallItems map[string]bool, useSystemd bool) ([]DeploymentResult, error) {
|
func (cd *ConfigDeployer) deployConfigurationsInternal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal, installedDeps []deps.Dependency, replaceConfigs map[string]bool, reinstallItems map[string]bool, useSystemd bool) ([]DeploymentResult, error) {
|
||||||
var results []DeploymentResult
|
var results []DeploymentResult
|
||||||
|
|
||||||
|
// Primary config file paths used to detect fresh installs.
|
||||||
|
configPrimaryPaths := map[string]string{
|
||||||
|
"Niri": filepath.Join(os.Getenv("HOME"), ".config", "niri", "config.kdl"),
|
||||||
|
"Hyprland": filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf"),
|
||||||
|
"Ghostty": filepath.Join(os.Getenv("HOME"), ".config", "ghostty", "config"),
|
||||||
|
"Kitty": filepath.Join(os.Getenv("HOME"), ".config", "kitty", "kitty.conf"),
|
||||||
|
"Alacritty": filepath.Join(os.Getenv("HOME"), ".config", "alacritty", "alacritty.toml"),
|
||||||
|
}
|
||||||
|
|
||||||
shouldReplaceConfig := func(configType string) bool {
|
shouldReplaceConfig := func(configType string) bool {
|
||||||
if replaceConfigs == nil {
|
if replaceConfigs == nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
replace, exists := replaceConfigs[configType]
|
replace, exists := replaceConfigs[configType]
|
||||||
return !exists || replace
|
if !exists || replace {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Config is explicitly set to "don't replace" — but still deploy
|
||||||
|
// if the config file doesn't exist yet (fresh install scenario).
|
||||||
|
if primaryPath, ok := configPrimaryPaths[configType]; ok {
|
||||||
|
if _, err := os.Stat(primaryPath); os.IsNotExist(err) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
switch wm {
|
switch wm {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -624,3 +625,168 @@ func TestAlacrittyConfigDeployment(t *testing.T) {
|
|||||||
assert.Contains(t, string(newContent), "decorations = \"None\"")
|
assert.Contains(t, string(newContent), "decorations = \"None\"")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestShouldReplaceConfigDeployIfMissing(t *testing.T) {
|
||||||
|
allFalse := map[string]bool{
|
||||||
|
"Niri": false,
|
||||||
|
"Hyprland": false,
|
||||||
|
"Ghostty": false,
|
||||||
|
"Kitty": false,
|
||||||
|
"Alacritty": false,
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("replaceConfigs nil deploys config", func(t *testing.T) {
|
||||||
|
tempDir, err := os.MkdirTemp("", "dankinstall-replace-nil-test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
originalHome := os.Getenv("HOME")
|
||||||
|
os.Setenv("HOME", tempDir)
|
||||||
|
defer os.Setenv("HOME", originalHome)
|
||||||
|
|
||||||
|
logChan := make(chan string, 100)
|
||||||
|
cd := NewConfigDeployer(logChan)
|
||||||
|
|
||||||
|
results, err := cd.DeployConfigurationsSelectiveWithReinstalls(
|
||||||
|
context.Background(),
|
||||||
|
deps.WindowManagerNiri,
|
||||||
|
deps.TerminalGhostty,
|
||||||
|
nil, // installedDeps
|
||||||
|
nil, // replaceConfigs
|
||||||
|
nil, // reinstallItems
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// With replaceConfigs=nil, all configs should be deployed
|
||||||
|
hasDeployed := false
|
||||||
|
for _, r := range results {
|
||||||
|
if r.Deployed {
|
||||||
|
hasDeployed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.True(t, hasDeployed, "expected at least one config to be deployed when replaceConfigs is nil")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("replaceConfigs all false and config missing deploys config", func(t *testing.T) {
|
||||||
|
tempDir, err := os.MkdirTemp("", "dankinstall-replace-missing-test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
originalHome := os.Getenv("HOME")
|
||||||
|
os.Setenv("HOME", tempDir)
|
||||||
|
defer os.Setenv("HOME", originalHome)
|
||||||
|
|
||||||
|
logChan := make(chan string, 100)
|
||||||
|
cd := NewConfigDeployer(logChan)
|
||||||
|
|
||||||
|
results, err := cd.DeployConfigurationsSelectiveWithReinstalls(
|
||||||
|
context.Background(),
|
||||||
|
deps.WindowManagerNiri,
|
||||||
|
deps.TerminalGhostty,
|
||||||
|
nil, // installedDeps
|
||||||
|
allFalse, // replaceConfigs — all false
|
||||||
|
nil, // reinstallItems
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Config files don't exist on disk, so they should still be deployed
|
||||||
|
hasDeployed := false
|
||||||
|
for _, r := range results {
|
||||||
|
if r.Deployed {
|
||||||
|
hasDeployed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.True(t, hasDeployed, "expected configs to be deployed when files are missing, even with replaceConfigs all false")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("replaceConfigs false and config exists skips config", func(t *testing.T) {
|
||||||
|
tempDir, err := os.MkdirTemp("", "dankinstall-replace-exists-test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
originalHome := os.Getenv("HOME")
|
||||||
|
os.Setenv("HOME", tempDir)
|
||||||
|
defer os.Setenv("HOME", originalHome)
|
||||||
|
|
||||||
|
// Create the Ghostty primary config file so shouldReplaceConfig returns false
|
||||||
|
ghosttyPath := filepath.Join(tempDir, ".config", "ghostty", "config")
|
||||||
|
err = os.MkdirAll(filepath.Dir(ghosttyPath), 0o755)
|
||||||
|
require.NoError(t, err)
|
||||||
|
err = os.WriteFile(ghosttyPath, []byte("# existing ghostty config\n"), 0o644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Also create the Niri primary config file
|
||||||
|
niriPath := filepath.Join(tempDir, ".config", "niri", "config.kdl")
|
||||||
|
err = os.MkdirAll(filepath.Dir(niriPath), 0o755)
|
||||||
|
require.NoError(t, err)
|
||||||
|
err = os.WriteFile(niriPath, []byte("// existing niri config\n"), 0o644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
logChan := make(chan string, 100)
|
||||||
|
cd := NewConfigDeployer(logChan)
|
||||||
|
|
||||||
|
results, err := cd.DeployConfigurationsSelectiveWithReinstalls(
|
||||||
|
context.Background(),
|
||||||
|
deps.WindowManagerNiri,
|
||||||
|
deps.TerminalGhostty,
|
||||||
|
nil, // installedDeps
|
||||||
|
allFalse, // replaceConfigs — all false
|
||||||
|
nil, // reinstallItems
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Both Niri and Ghostty config files exist, so with all false they should be skipped
|
||||||
|
for _, r := range results {
|
||||||
|
assert.Fail(t, "expected no configs to be deployed", "got deployed config: %s", r.ConfigType)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("replaceConfigs true and config exists deploys config", func(t *testing.T) {
|
||||||
|
tempDir, err := os.MkdirTemp("", "dankinstall-replace-true-test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
originalHome := os.Getenv("HOME")
|
||||||
|
os.Setenv("HOME", tempDir)
|
||||||
|
defer os.Setenv("HOME", originalHome)
|
||||||
|
|
||||||
|
// Create the Ghostty primary config file
|
||||||
|
ghosttyPath := filepath.Join(tempDir, ".config", "ghostty", "config")
|
||||||
|
err = os.MkdirAll(filepath.Dir(ghosttyPath), 0o755)
|
||||||
|
require.NoError(t, err)
|
||||||
|
err = os.WriteFile(ghosttyPath, []byte("# existing ghostty config\n"), 0o644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
logChan := make(chan string, 100)
|
||||||
|
cd := NewConfigDeployer(logChan)
|
||||||
|
|
||||||
|
replaceConfigs := map[string]bool{
|
||||||
|
"Niri": false,
|
||||||
|
"Hyprland": false,
|
||||||
|
"Ghostty": true, // explicitly true
|
||||||
|
"Kitty": false,
|
||||||
|
"Alacritty": false,
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := cd.DeployConfigurationsSelectiveWithReinstalls(
|
||||||
|
context.Background(),
|
||||||
|
deps.WindowManagerNiri,
|
||||||
|
deps.TerminalGhostty,
|
||||||
|
nil, // installedDeps
|
||||||
|
replaceConfigs, // Ghostty=true, rest=false
|
||||||
|
nil, // reinstallItems
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Ghostty should be deployed because replaceConfigs["Ghostty"]=true
|
||||||
|
foundGhostty := false
|
||||||
|
for _, r := range results {
|
||||||
|
if r.ConfigType == "Ghostty" && r.Deployed {
|
||||||
|
foundGhostty = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.True(t, foundGhostty, "expected Ghostty config to be deployed when replaceConfigs is true")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ bind = SUPER, bracketright, layoutmsg, preselect r
|
|||||||
|
|
||||||
# === Sizing & Layout ===
|
# === Sizing & Layout ===
|
||||||
bind = SUPER, R, layoutmsg, togglesplit
|
bind = SUPER, R, layoutmsg, togglesplit
|
||||||
bind = SUPER CTRL, F, resizeactive, exact 100%
|
bind = SUPER CTRL, F, resizeactive, exact 100% 100%
|
||||||
|
|
||||||
# === Move/resize windows with mainMod + LMB/RMB and dragging ===
|
# === Move/resize windows with mainMod + LMB/RMB and dragging ===
|
||||||
bindmd = SUPER, mouse:272, Move window, movewindow
|
bindmd = SUPER, mouse:272, Move window, movewindow
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ windowrule = tile on, match:class ^(gnome-control-center)$
|
|||||||
windowrule = tile on, match:class ^(pavucontrol)$
|
windowrule = tile on, match:class ^(pavucontrol)$
|
||||||
windowrule = tile on, match:class ^(nm-connection-editor)$
|
windowrule = tile on, match:class ^(nm-connection-editor)$
|
||||||
|
|
||||||
|
windowrule = float on, match:class ^(org\.gnome\.Calculator)$
|
||||||
windowrule = float on, match:class ^(gnome-calculator)$
|
windowrule = float on, match:class ^(gnome-calculator)$
|
||||||
windowrule = float on, match:class ^(galculator)$
|
windowrule = float on, match:class ^(galculator)$
|
||||||
windowrule = float on, match:class ^(blueman-manager)$
|
windowrule = float on, match:class ^(blueman-manager)$
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ window-rule {
|
|||||||
open-floating false
|
open-floating false
|
||||||
}
|
}
|
||||||
window-rule {
|
window-rule {
|
||||||
|
match app-id=r#"^org\.gnome\.Calculator$"#
|
||||||
match app-id=r#"^gnome-calculator$"#
|
match app-id=r#"^gnome-calculator$"#
|
||||||
match app-id=r#"^galculator$"#
|
match app-id=r#"^galculator$"#
|
||||||
match app-id=r#"^blueman-manager$"#
|
match app-id=r#"^blueman-manager$"#
|
||||||
|
|||||||
@@ -242,11 +242,7 @@ func (a *ArchDistribution) getDMSMapping(variant deps.PackageVariant) PackageMap
|
|||||||
return PackageMapping{Name: "dms-shell-git", Repository: RepoTypeAUR}
|
return PackageMapping{Name: "dms-shell-git", Repository: RepoTypeAUR}
|
||||||
}
|
}
|
||||||
|
|
||||||
if a.packageInstalled("dms-shell-bin") {
|
return PackageMapping{Name: "dms-shell", Repository: RepoTypeSystem}
|
||||||
return PackageMapping{Name: "dms-shell-bin", Repository: RepoTypeAUR}
|
|
||||||
}
|
|
||||||
|
|
||||||
return PackageMapping{Name: "dms-shell-bin", Repository: RepoTypeAUR}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *ArchDistribution) detectXwaylandSatellite() deps.Dependency {
|
func (a *ArchDistribution) detectXwaylandSatellite() deps.Dependency {
|
||||||
@@ -328,6 +324,13 @@ func (a *ArchDistribution) InstallPackages(ctx context.Context, dependencies []d
|
|||||||
|
|
||||||
systemPkgs, aurPkgs, manualPkgs, variantMap := a.categorizePackages(dependencies, wm, reinstallFlags, disabledFlags)
|
systemPkgs, aurPkgs, manualPkgs, variantMap := a.categorizePackages(dependencies, wm, reinstallFlags, disabledFlags)
|
||||||
|
|
||||||
|
if slices.Contains(aurPkgs, "quickshell-git") && slices.Contains(systemPkgs, "dms-shell") {
|
||||||
|
if err := a.preinstallQuickshellGit(ctx, sudoPassword, progressChan); err != nil {
|
||||||
|
return fmt.Errorf("failed to preinstall quickshell-git: %w", err)
|
||||||
|
}
|
||||||
|
aurPkgs = slices.DeleteFunc(aurPkgs, func(p string) bool { return p == "quickshell-git" })
|
||||||
|
}
|
||||||
|
|
||||||
// Phase 3: System Packages
|
// Phase 3: System Packages
|
||||||
if len(systemPkgs) > 0 {
|
if len(systemPkgs) > 0 {
|
||||||
progressChan <- InstallProgressMsg{
|
progressChan <- InstallProgressMsg{
|
||||||
@@ -445,6 +448,37 @@ func (a *ArchDistribution) categorizePackages(dependencies []deps.Dependency, wm
|
|||||||
return systemPkgs, aurPkgs, manualPkgs, variantMap
|
return systemPkgs, aurPkgs, manualPkgs, variantMap
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *ArchDistribution) preinstallQuickshellGit(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||||
|
if a.packageInstalled("quickshell-git") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.packageInstalled("quickshell") {
|
||||||
|
progressChan <- InstallProgressMsg{
|
||||||
|
Phase: PhaseAURPackages,
|
||||||
|
Progress: 0.15,
|
||||||
|
Step: "Removing stable quickshell...",
|
||||||
|
IsComplete: false,
|
||||||
|
NeedsSudo: true,
|
||||||
|
CommandInfo: "sudo pacman -Rdd --noconfirm quickshell",
|
||||||
|
LogOutput: "Removing stable quickshell so quickshell-git can be installed",
|
||||||
|
}
|
||||||
|
cmd := ExecSudoCommand(ctx, sudoPassword, "pacman -Rdd --noconfirm quickshell")
|
||||||
|
if err := a.runWithProgress(cmd, progressChan, PhaseAURPackages, 0.15, 0.18); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove stable quickshell: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
progressChan <- InstallProgressMsg{
|
||||||
|
Phase: PhaseAURPackages,
|
||||||
|
Progress: 0.18,
|
||||||
|
Step: "Building quickshell-git before system packages...",
|
||||||
|
IsComplete: false,
|
||||||
|
CommandInfo: "Installing quickshell-git ahead of dms-shell to avoid conflict",
|
||||||
|
}
|
||||||
|
return a.installSingleAURPackage(ctx, "quickshell-git", sudoPassword, progressChan, 0.18, 0.32)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *ArchDistribution) installSystemPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
func (a *ArchDistribution) installSystemPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||||
if len(packages) == 0 {
|
if len(packages) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -453,6 +487,9 @@ func (a *ArchDistribution) installSystemPackages(ctx context.Context, packages [
|
|||||||
a.log(fmt.Sprintf("Installing system packages: %s", strings.Join(packages, ", ")))
|
a.log(fmt.Sprintf("Installing system packages: %s", strings.Join(packages, ", ")))
|
||||||
|
|
||||||
args := []string{"pacman", "-S", "--needed", "--noconfirm"}
|
args := []string{"pacman", "-S", "--needed", "--noconfirm"}
|
||||||
|
if slices.Contains(packages, "dms-shell") {
|
||||||
|
args = append(args, "--assume-installed", "dms-shell-compositor=1")
|
||||||
|
}
|
||||||
args = append(args, packages...)
|
args = append(args, packages...)
|
||||||
|
|
||||||
progressChan <- InstallProgressMsg{
|
progressChan <- InstallProgressMsg{
|
||||||
@@ -540,7 +577,7 @@ func (a *ArchDistribution) reorderAURPackages(packages []string) []string {
|
|||||||
var dmsShell []string
|
var dmsShell []string
|
||||||
|
|
||||||
for _, pkg := range packages {
|
for _, pkg := range packages {
|
||||||
if pkg == "dms-shell-git" || pkg == "dms-shell-bin" {
|
if pkg == "dms-shell-git" {
|
||||||
dmsShell = append(dmsShell, pkg)
|
dmsShell = append(dmsShell, pkg)
|
||||||
} else {
|
} else {
|
||||||
isDep := false
|
isDep := false
|
||||||
@@ -621,7 +658,7 @@ func (a *ArchDistribution) installSingleAURPackageInternal(ctx context.Context,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if pkg == "dms-shell-git" || pkg == "dms-shell-bin" {
|
if pkg == "dms-shell-git" {
|
||||||
srcinfoPath := filepath.Join(packageDir, ".SRCINFO")
|
srcinfoPath := filepath.Join(packageDir, ".SRCINFO")
|
||||||
depsToRemove := []string{
|
depsToRemove := []string{
|
||||||
"depends = quickshell",
|
"depends = quickshell",
|
||||||
@@ -644,15 +681,7 @@ func (a *ArchDistribution) installSingleAURPackageInternal(ctx context.Context,
|
|||||||
}
|
}
|
||||||
|
|
||||||
srcinfoPath = filepath.Join(packageDir, ".SRCINFO")
|
srcinfoPath = filepath.Join(packageDir, ".SRCINFO")
|
||||||
if pkg == "dms-shell-bin" {
|
{
|
||||||
progressChan <- InstallProgressMsg{
|
|
||||||
Phase: PhaseAURPackages,
|
|
||||||
Progress: startProgress + 0.35*(endProgress-startProgress),
|
|
||||||
Step: fmt.Sprintf("Skipping dependency installation for %s (manually managed)...", pkg),
|
|
||||||
IsComplete: false,
|
|
||||||
LogOutput: fmt.Sprintf("Dependencies for %s are installed separately", pkg),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
progressChan <- InstallProgressMsg{
|
progressChan <- InstallProgressMsg{
|
||||||
Phase: PhaseAURPackages,
|
Phase: PhaseAURPackages,
|
||||||
Progress: startProgress + 0.3*(endProgress-startProgress),
|
Progress: startProgress + 0.3*(endProgress-startProgress),
|
||||||
@@ -739,42 +768,9 @@ func (a *ArchDistribution) installSingleAURPackageInternal(ctx context.Context,
|
|||||||
CommandInfo: "sudo pacman -U built-package",
|
CommandInfo: "sudo pacman -U built-package",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find .pkg.tar* files - for split packages, install the base and any installed compositor variants
|
|
||||||
var files []string
|
var files []string
|
||||||
if pkg == "dms-shell-git" || pkg == "dms-shell-bin" {
|
matches, _ := filepath.Glob(filepath.Join(packageDir, "*.pkg.tar*"))
|
||||||
// For DMS split packages, install base package
|
files = matches
|
||||||
pattern := filepath.Join(packageDir, fmt.Sprintf("%s-%s*.pkg.tar*", pkg, "*"))
|
|
||||||
matches, err := filepath.Glob(pattern)
|
|
||||||
if err == nil {
|
|
||||||
for _, match := range matches {
|
|
||||||
basename := filepath.Base(match)
|
|
||||||
// Always include base package
|
|
||||||
if !strings.Contains(basename, "hyprland") && !strings.Contains(basename, "niri") {
|
|
||||||
files = append(files, match)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also update compositor-specific packages if they're installed
|
|
||||||
if strings.HasSuffix(pkg, "-git") {
|
|
||||||
if a.packageInstalled("dms-shell-hyprland-git") {
|
|
||||||
hyprlandPattern := filepath.Join(packageDir, "dms-shell-hyprland-git-*.pkg.tar*")
|
|
||||||
if hyprlandMatches, err := filepath.Glob(hyprlandPattern); err == nil && len(hyprlandMatches) > 0 {
|
|
||||||
files = append(files, hyprlandMatches[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if a.packageInstalled("dms-shell-niri-git") {
|
|
||||||
niriPattern := filepath.Join(packageDir, "dms-shell-niri-git-*.pkg.tar*")
|
|
||||||
if niriMatches, err := filepath.Glob(niriPattern); err == nil && len(niriMatches) > 0 {
|
|
||||||
files = append(files, niriMatches[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// For other packages, install all built packages
|
|
||||||
matches, _ := filepath.Glob(filepath.Join(packageDir, "*.pkg.tar*"))
|
|
||||||
files = matches
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(files) == 0 {
|
if len(files) == 0 {
|
||||||
return fmt.Errorf("no package files found after building %s", pkg)
|
return fmt.Errorf("no package files found after building %s", pkg)
|
||||||
|
|||||||
@@ -0,0 +1,418 @@
|
|||||||
|
package headless
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrConfirmationRequired is returned when --yes is not set and the user
|
||||||
|
// must explicitly confirm the operation.
|
||||||
|
var ErrConfirmationRequired = fmt.Errorf("confirmation required: pass --yes to proceed")
|
||||||
|
|
||||||
|
// validConfigNames maps lowercase CLI input to the deployer key used in
|
||||||
|
// replaceConfigs. Keep in sync with the config types checked by
|
||||||
|
// shouldReplaceConfig in deployer.go.
|
||||||
|
var validConfigNames = map[string]string{
|
||||||
|
"niri": "Niri",
|
||||||
|
"hyprland": "Hyprland",
|
||||||
|
"ghostty": "Ghostty",
|
||||||
|
"kitty": "Kitty",
|
||||||
|
"alacritty": "Alacritty",
|
||||||
|
}
|
||||||
|
|
||||||
|
// orderedConfigNames defines the canonical order for config names in output.
|
||||||
|
// Must be kept in sync with validConfigNames.
|
||||||
|
var orderedConfigNames = []string{"niri", "hyprland", "ghostty", "kitty", "alacritty"}
|
||||||
|
|
||||||
|
// Config holds all CLI parameters for unattended installation.
|
||||||
|
type Config struct {
|
||||||
|
Compositor string // "niri" or "hyprland"
|
||||||
|
Terminal string // "ghostty", "kitty", or "alacritty"
|
||||||
|
IncludeDeps []string
|
||||||
|
ExcludeDeps []string
|
||||||
|
ReplaceConfigs []string // specific configs to deploy (e.g. "niri", "ghostty")
|
||||||
|
ReplaceConfigsAll bool // deploy/replace all configurations
|
||||||
|
Yes bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runner orchestrates unattended (headless) installation.
|
||||||
|
type Runner struct {
|
||||||
|
cfg Config
|
||||||
|
logChan chan string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRunner creates a new headless runner.
|
||||||
|
func NewRunner(cfg Config) *Runner {
|
||||||
|
return &Runner{
|
||||||
|
cfg: cfg,
|
||||||
|
logChan: make(chan string, 1000),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLogChan returns the log channel for file logging.
|
||||||
|
func (r *Runner) GetLogChan() <-chan string {
|
||||||
|
return r.logChan
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run executes the full unattended installation flow.
|
||||||
|
func (r *Runner) Run() error {
|
||||||
|
r.log("Starting headless installation")
|
||||||
|
|
||||||
|
// 1. Parse compositor and terminal selections
|
||||||
|
wm, err := r.parseWindowManager()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
terminal, err := r.parseTerminal()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Build replace-configs map
|
||||||
|
replaceConfigs, err := r.buildReplaceConfigs()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Detect OS
|
||||||
|
r.log("Detecting operating system...")
|
||||||
|
osInfo, err := distros.GetOSInfo()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("OS detection failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if distros.IsUnsupportedDistro(osInfo.Distribution.ID, osInfo.VersionID) {
|
||||||
|
return fmt.Errorf("unsupported distribution: %s %s", osInfo.PrettyName, osInfo.VersionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stdout, "Detected: %s (%s)\n", osInfo.PrettyName, osInfo.Architecture)
|
||||||
|
|
||||||
|
// 4. Create distribution instance
|
||||||
|
distro, err := distros.NewDistribution(osInfo.Distribution.ID, r.logChan)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to initialize distribution: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Detect dependencies
|
||||||
|
r.log("Detecting dependencies...")
|
||||||
|
fmt.Fprintln(os.Stdout, "Detecting dependencies...")
|
||||||
|
dependencies, err := distro.DetectDependenciesWithTerminal(context.Background(), wm, terminal)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("dependency detection failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Apply include/exclude filters and build the disabled-items map.
|
||||||
|
// Headless mode does not currently collect any explicit reinstall selections,
|
||||||
|
// so keep reinstallItems nil instead of constructing an always-empty map.
|
||||||
|
disabledItems, err := r.buildDisabledItems(dependencies)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var reinstallItems map[string]bool
|
||||||
|
|
||||||
|
// Print dependency summary
|
||||||
|
fmt.Fprintln(os.Stdout, "\nDependencies:")
|
||||||
|
for _, dep := range dependencies {
|
||||||
|
marker := " "
|
||||||
|
status := ""
|
||||||
|
if disabledItems[dep.Name] {
|
||||||
|
marker = " SKIP "
|
||||||
|
status = "(disabled)"
|
||||||
|
} else {
|
||||||
|
switch dep.Status {
|
||||||
|
case deps.StatusInstalled:
|
||||||
|
marker = " OK "
|
||||||
|
status = "(installed)"
|
||||||
|
case deps.StatusMissing:
|
||||||
|
marker = " NEW "
|
||||||
|
status = "(will install)"
|
||||||
|
case deps.StatusNeedsUpdate:
|
||||||
|
marker = " UPD "
|
||||||
|
status = "(will update)"
|
||||||
|
case deps.StatusNeedsReinstall:
|
||||||
|
marker = " RE "
|
||||||
|
status = "(will reinstall)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stdout, "%s%-30s %s\n", marker, dep.Name, status)
|
||||||
|
}
|
||||||
|
fmt.Fprintln(os.Stdout)
|
||||||
|
|
||||||
|
// 6b. Require explicit confirmation unless --yes is set
|
||||||
|
if !r.cfg.Yes {
|
||||||
|
if replaceConfigs == nil {
|
||||||
|
// --replace-configs-all
|
||||||
|
fmt.Fprintln(os.Stdout, "Packages will be installed and all configurations will be replaced.")
|
||||||
|
fmt.Fprintln(os.Stdout, "Existing config files will be backed up before replacement.")
|
||||||
|
} else if r.anyConfigEnabled(replaceConfigs) {
|
||||||
|
var names []string
|
||||||
|
for _, cliName := range orderedConfigNames {
|
||||||
|
deployerKey := validConfigNames[cliName]
|
||||||
|
if replaceConfigs[deployerKey] {
|
||||||
|
names = append(names, deployerKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stdout, "Packages will be installed. The following configurations will be replaced (with backups): %s\n", strings.Join(names, ", "))
|
||||||
|
} else {
|
||||||
|
fmt.Fprintln(os.Stdout, "Packages will be installed. No configurations will be deployed.")
|
||||||
|
}
|
||||||
|
fmt.Fprintln(os.Stdout, "Re-run with --yes (-y) to proceed.")
|
||||||
|
r.log("Aborted: --yes not set")
|
||||||
|
return ErrConfirmationRequired
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Authenticate sudo
|
||||||
|
sudoPassword, err := r.resolveSudoPassword()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Install packages
|
||||||
|
fmt.Fprintln(os.Stdout, "Installing packages...")
|
||||||
|
r.log("Starting package installation")
|
||||||
|
|
||||||
|
progressChan := make(chan distros.InstallProgressMsg, 100)
|
||||||
|
|
||||||
|
installErr := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
defer close(progressChan)
|
||||||
|
installErr <- distro.InstallPackages(
|
||||||
|
context.Background(),
|
||||||
|
dependencies,
|
||||||
|
wm,
|
||||||
|
sudoPassword,
|
||||||
|
reinstallItems,
|
||||||
|
disabledItems,
|
||||||
|
false, // skipGlobalUseFlags
|
||||||
|
progressChan,
|
||||||
|
)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Consume progress messages and print them
|
||||||
|
for msg := range progressChan {
|
||||||
|
if msg.Error != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", msg.Error)
|
||||||
|
} else if msg.Step != "" {
|
||||||
|
fmt.Fprintf(os.Stdout, " [%3.0f%%] %s\n", msg.Progress*100, msg.Step)
|
||||||
|
}
|
||||||
|
if msg.LogOutput != "" {
|
||||||
|
r.log(msg.LogOutput)
|
||||||
|
fmt.Fprintf(os.Stdout, " %s\n", msg.LogOutput)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := <-installErr; err != nil {
|
||||||
|
return fmt.Errorf("package installation failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. Greeter setup (if dms-greeter was included)
|
||||||
|
if !disabledItems["dms-greeter"] && r.depExists(dependencies, "dms-greeter") {
|
||||||
|
compositorName := "niri"
|
||||||
|
if wm == deps.WindowManagerHyprland {
|
||||||
|
compositorName = "Hyprland"
|
||||||
|
}
|
||||||
|
fmt.Fprintln(os.Stdout, "Configuring DMS greeter...")
|
||||||
|
logFunc := func(line string) {
|
||||||
|
r.log(line)
|
||||||
|
fmt.Fprintf(os.Stdout, " greeter: %s\n", line)
|
||||||
|
}
|
||||||
|
if err := greeter.AutoSetupGreeter(compositorName, sudoPassword, logFunc); err != nil {
|
||||||
|
// Non-fatal, matching TUI behavior
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: greeter setup issue (non-fatal): %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. Deploy configurations
|
||||||
|
fmt.Fprintln(os.Stdout, "Deploying configurations...")
|
||||||
|
r.log("Starting configuration deployment")
|
||||||
|
|
||||||
|
deployer := config.NewConfigDeployer(r.logChan)
|
||||||
|
results, err := deployer.DeployConfigurationsSelectiveWithReinstalls(
|
||||||
|
context.Background(),
|
||||||
|
wm,
|
||||||
|
terminal,
|
||||||
|
dependencies,
|
||||||
|
replaceConfigs,
|
||||||
|
reinstallItems,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("configuration deployment failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, result := range results {
|
||||||
|
if result.Deployed {
|
||||||
|
msg := fmt.Sprintf(" Deployed: %s", result.ConfigType)
|
||||||
|
if result.BackupPath != "" {
|
||||||
|
msg += fmt.Sprintf(" (backup: %s)", result.BackupPath)
|
||||||
|
}
|
||||||
|
fmt.Fprintln(os.Stdout, msg)
|
||||||
|
}
|
||||||
|
if result.Error != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, " Error deploying %s: %v\n", result.ConfigType, result.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintln(os.Stdout, "\nInstallation complete!")
|
||||||
|
r.log("Headless installation completed successfully")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildDisabledItems computes the set of dependencies that should be skipped
|
||||||
|
// during installation, applying the --include-deps and --exclude-deps filters.
|
||||||
|
// dms-greeter is disabled by default (opt-in), matching TUI behavior.
|
||||||
|
func (r *Runner) buildDisabledItems(dependencies []deps.Dependency) (map[string]bool, error) {
|
||||||
|
disabledItems := make(map[string]bool)
|
||||||
|
|
||||||
|
// dms-greeter is opt-in (disabled by default), matching TUI behavior
|
||||||
|
for i := range dependencies {
|
||||||
|
if dependencies[i].Name == "dms-greeter" {
|
||||||
|
disabledItems["dms-greeter"] = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process --include-deps (enable items that are disabled by default)
|
||||||
|
for _, name := range r.cfg.IncludeDeps {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !r.depExists(dependencies, name) {
|
||||||
|
return nil, fmt.Errorf("--include-deps: unknown dependency %q", name)
|
||||||
|
}
|
||||||
|
delete(disabledItems, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process --exclude-deps (disable items)
|
||||||
|
for _, name := range r.cfg.ExcludeDeps {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !r.depExists(dependencies, name) {
|
||||||
|
return nil, fmt.Errorf("--exclude-deps: unknown dependency %q", name)
|
||||||
|
}
|
||||||
|
// Don't allow excluding DMS itself
|
||||||
|
if name == "dms (DankMaterialShell)" {
|
||||||
|
return nil, fmt.Errorf("--exclude-deps: cannot exclude required package %q", name)
|
||||||
|
}
|
||||||
|
disabledItems[name] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return disabledItems, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildReplaceConfigs converts the --replace-configs / --replace-configs-all
|
||||||
|
// flags into the map[string]bool consumed by the config deployer.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - nil when --replace-configs-all is set (deployer treats nil as "replace all")
|
||||||
|
// - a map with all known configs set to false when neither flag is set (deploy only if config file is missing on disk)
|
||||||
|
// - a map with requested configs true, all others false for --replace-configs
|
||||||
|
// - an error when both flags are set or an invalid config name is given
|
||||||
|
func (r *Runner) buildReplaceConfigs() (map[string]bool, error) {
|
||||||
|
hasSpecific := len(r.cfg.ReplaceConfigs) > 0
|
||||||
|
if hasSpecific && r.cfg.ReplaceConfigsAll {
|
||||||
|
return nil, fmt.Errorf("--replace-configs and --replace-configs-all are mutually exclusive")
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.cfg.ReplaceConfigsAll {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a map with all known configs explicitly set to false
|
||||||
|
result := make(map[string]bool, len(validConfigNames))
|
||||||
|
for _, cliName := range orderedConfigNames {
|
||||||
|
result[validConfigNames[cliName]] = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable only the requested configs
|
||||||
|
for _, name := range r.cfg.ReplaceConfigs {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
deployerKey, ok := validConfigNames[strings.ToLower(name)]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("--replace-configs: unknown config %q; valid values: niri, hyprland, ghostty, kitty, alacritty", name)
|
||||||
|
}
|
||||||
|
result[deployerKey] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) log(message string) {
|
||||||
|
select {
|
||||||
|
case r.logChan <- message:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) parseWindowManager() (deps.WindowManager, error) {
|
||||||
|
switch strings.ToLower(r.cfg.Compositor) {
|
||||||
|
case "niri":
|
||||||
|
return deps.WindowManagerNiri, nil
|
||||||
|
case "hyprland":
|
||||||
|
return deps.WindowManagerHyprland, nil
|
||||||
|
default:
|
||||||
|
return 0, fmt.Errorf("invalid --compositor value %q: must be 'niri' or 'hyprland'", r.cfg.Compositor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) parseTerminal() (deps.Terminal, error) {
|
||||||
|
switch strings.ToLower(r.cfg.Terminal) {
|
||||||
|
case "ghostty":
|
||||||
|
return deps.TerminalGhostty, nil
|
||||||
|
case "kitty":
|
||||||
|
return deps.TerminalKitty, nil
|
||||||
|
case "alacritty":
|
||||||
|
return deps.TerminalAlacritty, nil
|
||||||
|
default:
|
||||||
|
return 0, fmt.Errorf("invalid --term value %q: must be 'ghostty', 'kitty', or 'alacritty'", r.cfg.Terminal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) resolveSudoPassword() (string, error) {
|
||||||
|
// Check if sudo credentials are cached (via sudo -v or NOPASSWD)
|
||||||
|
cmd := exec.Command("sudo", "-n", "true")
|
||||||
|
if err := cmd.Run(); err == nil {
|
||||||
|
r.log("sudo cache is valid, no password needed")
|
||||||
|
fmt.Fprintln(os.Stdout, "sudo: using cached credentials")
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf(
|
||||||
|
"sudo authentication required but no cached credentials found\n" +
|
||||||
|
"Options:\n" +
|
||||||
|
" 1. Run 'sudo -v' before dankinstall to cache credentials\n" +
|
||||||
|
" 2. Configure passwordless sudo for your user",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) anyConfigEnabled(m map[string]bool) bool {
|
||||||
|
for _, v := range m {
|
||||||
|
if v {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) depExists(dependencies []deps.Dependency, name string) bool {
|
||||||
|
for _, dep := range dependencies {
|
||||||
|
if dep.Name == name {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,459 @@
|
|||||||
|
package headless
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseWindowManager(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want deps.WindowManager
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"niri lowercase", "niri", deps.WindowManagerNiri, false},
|
||||||
|
{"niri mixed case", "Niri", deps.WindowManagerNiri, false},
|
||||||
|
{"hyprland lowercase", "hyprland", deps.WindowManagerHyprland, false},
|
||||||
|
{"hyprland mixed case", "Hyprland", deps.WindowManagerHyprland, false},
|
||||||
|
{"invalid", "sway", 0, true},
|
||||||
|
{"empty", "", 0, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
r := NewRunner(Config{Compositor: tt.input})
|
||||||
|
got, err := r.parseWindowManager()
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("parseWindowManager() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !tt.wantErr && got != tt.want {
|
||||||
|
t.Errorf("parseWindowManager() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTerminal(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want deps.Terminal
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"ghostty lowercase", "ghostty", deps.TerminalGhostty, false},
|
||||||
|
{"ghostty mixed case", "Ghostty", deps.TerminalGhostty, false},
|
||||||
|
{"kitty lowercase", "kitty", deps.TerminalKitty, false},
|
||||||
|
{"alacritty lowercase", "alacritty", deps.TerminalAlacritty, false},
|
||||||
|
{"alacritty uppercase", "ALACRITTY", deps.TerminalAlacritty, false},
|
||||||
|
{"invalid", "wezterm", 0, true},
|
||||||
|
{"empty", "", 0, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
r := NewRunner(Config{Terminal: tt.input})
|
||||||
|
got, err := r.parseTerminal()
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("parseTerminal() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !tt.wantErr && got != tt.want {
|
||||||
|
t.Errorf("parseTerminal() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDepExists(t *testing.T) {
|
||||||
|
dependencies := []deps.Dependency{
|
||||||
|
{Name: "niri", Status: deps.StatusInstalled},
|
||||||
|
{Name: "ghostty", Status: deps.StatusMissing},
|
||||||
|
{Name: "dms (DankMaterialShell)", Status: deps.StatusInstalled},
|
||||||
|
{Name: "dms-greeter", Status: deps.StatusMissing},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
dep string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"existing dep", "niri", true},
|
||||||
|
{"existing dep with special chars", "dms (DankMaterialShell)", true},
|
||||||
|
{"existing optional dep", "dms-greeter", true},
|
||||||
|
{"non-existing dep", "firefox", false},
|
||||||
|
{"empty name", "", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
r := NewRunner(Config{})
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := r.depExists(dependencies, tt.dep); got != tt.want {
|
||||||
|
t.Errorf("depExists(%q) = %v, want %v", tt.dep, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewRunner(t *testing.T) {
|
||||||
|
cfg := Config{
|
||||||
|
Compositor: "niri",
|
||||||
|
Terminal: "ghostty",
|
||||||
|
IncludeDeps: []string{"dms-greeter"},
|
||||||
|
ExcludeDeps: []string{"some-pkg"},
|
||||||
|
Yes: true,
|
||||||
|
}
|
||||||
|
r := NewRunner(cfg)
|
||||||
|
|
||||||
|
if r == nil {
|
||||||
|
t.Fatal("NewRunner returned nil")
|
||||||
|
}
|
||||||
|
if r.cfg.Compositor != "niri" {
|
||||||
|
t.Errorf("cfg.Compositor = %q, want %q", r.cfg.Compositor, "niri")
|
||||||
|
}
|
||||||
|
if r.cfg.Terminal != "ghostty" {
|
||||||
|
t.Errorf("cfg.Terminal = %q, want %q", r.cfg.Terminal, "ghostty")
|
||||||
|
}
|
||||||
|
if !r.cfg.Yes {
|
||||||
|
t.Error("cfg.Yes = false, want true")
|
||||||
|
}
|
||||||
|
if r.logChan == nil {
|
||||||
|
t.Error("logChan is nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetLogChan(t *testing.T) {
|
||||||
|
r := NewRunner(Config{})
|
||||||
|
ch := r.GetLogChan()
|
||||||
|
if ch == nil {
|
||||||
|
t.Fatal("GetLogChan returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the channel is readable by sending a message
|
||||||
|
go func() {
|
||||||
|
r.logChan <- "test message"
|
||||||
|
}()
|
||||||
|
msg := <-ch
|
||||||
|
if msg != "test message" {
|
||||||
|
t.Errorf("received %q, want %q", msg, "test message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLog(t *testing.T) {
|
||||||
|
r := NewRunner(Config{})
|
||||||
|
|
||||||
|
// log should not block even if channel is full
|
||||||
|
for i := 0; i < 1100; i++ {
|
||||||
|
r.log("message")
|
||||||
|
}
|
||||||
|
// If we reach here without hanging, the non-blocking send works
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunRequiresYes(t *testing.T) {
|
||||||
|
// Verify that ErrConfirmationRequired is a distinct sentinel error
|
||||||
|
if ErrConfirmationRequired == nil {
|
||||||
|
t.Fatal("ErrConfirmationRequired should not be nil")
|
||||||
|
}
|
||||||
|
expected := "confirmation required: pass --yes to proceed"
|
||||||
|
if ErrConfirmationRequired.Error() != expected {
|
||||||
|
t.Errorf("ErrConfirmationRequired = %q, want %q", ErrConfirmationRequired.Error(), expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigYesStoredCorrectly(t *testing.T) {
|
||||||
|
// Yes=false (default) should be stored
|
||||||
|
rNo := NewRunner(Config{Compositor: "niri", Terminal: "ghostty", Yes: false})
|
||||||
|
if rNo.cfg.Yes {
|
||||||
|
t.Error("cfg.Yes = true, want false")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yes=true should be stored
|
||||||
|
rYes := NewRunner(Config{Compositor: "niri", Terminal: "ghostty", Yes: true})
|
||||||
|
if !rYes.cfg.Yes {
|
||||||
|
t.Error("cfg.Yes = false, want true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidConfigNamesCompleteness(t *testing.T) {
|
||||||
|
// orderedConfigNames and validConfigNames must stay in sync.
|
||||||
|
if len(orderedConfigNames) != len(validConfigNames) {
|
||||||
|
t.Fatalf("orderedConfigNames has %d entries but validConfigNames has %d",
|
||||||
|
len(orderedConfigNames), len(validConfigNames))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every entry in orderedConfigNames must exist in validConfigNames.
|
||||||
|
for _, name := range orderedConfigNames {
|
||||||
|
if _, ok := validConfigNames[name]; !ok {
|
||||||
|
t.Errorf("orderedConfigNames contains %q which is missing from validConfigNames", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validConfigNames must have no extra keys not in orderedConfigNames.
|
||||||
|
ordered := make(map[string]bool, len(orderedConfigNames))
|
||||||
|
for _, name := range orderedConfigNames {
|
||||||
|
ordered[name] = true
|
||||||
|
}
|
||||||
|
for key := range validConfigNames {
|
||||||
|
if !ordered[key] {
|
||||||
|
t.Errorf("validConfigNames contains %q which is missing from orderedConfigNames", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildReplaceConfigs(t *testing.T) {
|
||||||
|
allDeployerKeys := []string{"Niri", "Hyprland", "Ghostty", "Kitty", "Alacritty"}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
replaceConfigs []string
|
||||||
|
replaceAll bool
|
||||||
|
wantNil bool // expect nil (replace all)
|
||||||
|
wantEnabled []string // deployer keys that should be true
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "neither flag set",
|
||||||
|
wantNil: false,
|
||||||
|
wantEnabled: nil, // all should be false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "replace-configs-all",
|
||||||
|
replaceAll: true,
|
||||||
|
wantNil: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "specific configs",
|
||||||
|
replaceConfigs: []string{"niri", "ghostty"},
|
||||||
|
wantNil: false,
|
||||||
|
wantEnabled: []string{"Niri", "Ghostty"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "both flags set",
|
||||||
|
replaceConfigs: []string{"niri"},
|
||||||
|
replaceAll: true,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid config name",
|
||||||
|
replaceConfigs: []string{"foo"},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "case insensitive",
|
||||||
|
replaceConfigs: []string{"NIRI", "Ghostty"},
|
||||||
|
wantNil: false,
|
||||||
|
wantEnabled: []string{"Niri", "Ghostty"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single config",
|
||||||
|
replaceConfigs: []string{"kitty"},
|
||||||
|
wantNil: false,
|
||||||
|
wantEnabled: []string{"Kitty"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "whitespace entry",
|
||||||
|
replaceConfigs: []string{" ", "niri"},
|
||||||
|
wantNil: false,
|
||||||
|
wantEnabled: []string{"Niri"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "duplicate entry",
|
||||||
|
replaceConfigs: []string{"niri", "niri"},
|
||||||
|
wantNil: false,
|
||||||
|
wantEnabled: []string{"Niri"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
r := NewRunner(Config{
|
||||||
|
ReplaceConfigs: tt.replaceConfigs,
|
||||||
|
ReplaceConfigsAll: tt.replaceAll,
|
||||||
|
})
|
||||||
|
got, err := r.buildReplaceConfigs()
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Fatalf("buildReplaceConfigs() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
if tt.wantErr {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tt.wantNil {
|
||||||
|
if got != nil {
|
||||||
|
t.Fatalf("buildReplaceConfigs() = %v, want nil", got)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("buildReplaceConfigs() = nil, want non-nil map")
|
||||||
|
}
|
||||||
|
|
||||||
|
// All known deployer keys must be present
|
||||||
|
for _, key := range allDeployerKeys {
|
||||||
|
if _, exists := got[key]; !exists {
|
||||||
|
t.Errorf("missing deployer key %q in result map", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build enabled set for easy lookup
|
||||||
|
enabledSet := make(map[string]bool)
|
||||||
|
for _, k := range tt.wantEnabled {
|
||||||
|
enabledSet[k] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range allDeployerKeys {
|
||||||
|
want := enabledSet[key]
|
||||||
|
if got[key] != want {
|
||||||
|
t.Errorf("replaceConfigs[%q] = %v, want %v", key, got[key], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigReplaceConfigsStoredCorrectly(t *testing.T) {
|
||||||
|
r := NewRunner(Config{
|
||||||
|
Compositor: "niri",
|
||||||
|
Terminal: "ghostty",
|
||||||
|
ReplaceConfigs: []string{"niri", "ghostty"},
|
||||||
|
ReplaceConfigsAll: false,
|
||||||
|
})
|
||||||
|
if len(r.cfg.ReplaceConfigs) != 2 {
|
||||||
|
t.Errorf("len(ReplaceConfigs) = %d, want 2", len(r.cfg.ReplaceConfigs))
|
||||||
|
}
|
||||||
|
if r.cfg.ReplaceConfigsAll {
|
||||||
|
t.Error("ReplaceConfigsAll = true, want false")
|
||||||
|
}
|
||||||
|
|
||||||
|
r2 := NewRunner(Config{
|
||||||
|
Compositor: "niri",
|
||||||
|
Terminal: "ghostty",
|
||||||
|
ReplaceConfigsAll: true,
|
||||||
|
})
|
||||||
|
if !r2.cfg.ReplaceConfigsAll {
|
||||||
|
t.Error("ReplaceConfigsAll = false, want true")
|
||||||
|
}
|
||||||
|
if len(r2.cfg.ReplaceConfigs) != 0 {
|
||||||
|
t.Errorf("len(ReplaceConfigs) = %d, want 0", len(r2.cfg.ReplaceConfigs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildDisabledItems(t *testing.T) {
|
||||||
|
dependencies := []deps.Dependency{
|
||||||
|
{Name: "niri", Status: deps.StatusInstalled},
|
||||||
|
{Name: "ghostty", Status: deps.StatusMissing},
|
||||||
|
{Name: "dms (DankMaterialShell)", Status: deps.StatusInstalled},
|
||||||
|
{Name: "dms-greeter", Status: deps.StatusMissing},
|
||||||
|
{Name: "waybar", Status: deps.StatusMissing},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
includeDeps []string
|
||||||
|
excludeDeps []string
|
||||||
|
deps []deps.Dependency // nil means use the shared fixture
|
||||||
|
wantErr bool
|
||||||
|
errContains string // substring expected in error message
|
||||||
|
wantDisabled []string // dep names that should be in disabledItems
|
||||||
|
wantEnabled []string // dep names that should NOT be in disabledItems (extra check)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no flags set, dms-greeter disabled by default",
|
||||||
|
wantDisabled: []string{"dms-greeter"},
|
||||||
|
wantEnabled: []string{"niri", "ghostty", "waybar"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "include dms-greeter enables it",
|
||||||
|
includeDeps: []string{"dms-greeter"},
|
||||||
|
wantEnabled: []string{"dms-greeter"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exclude a regular dep",
|
||||||
|
excludeDeps: []string{"waybar"},
|
||||||
|
wantDisabled: []string{"dms-greeter", "waybar"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "include unknown dep returns error",
|
||||||
|
includeDeps: []string{"nonexistent"},
|
||||||
|
wantErr: true,
|
||||||
|
errContains: "--include-deps",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exclude unknown dep returns error",
|
||||||
|
excludeDeps: []string{"nonexistent"},
|
||||||
|
wantErr: true,
|
||||||
|
errContains: "--exclude-deps",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exclude DMS itself is forbidden",
|
||||||
|
excludeDeps: []string{"dms (DankMaterialShell)"},
|
||||||
|
wantErr: true,
|
||||||
|
errContains: "cannot exclude required package",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "include and exclude same dep",
|
||||||
|
includeDeps: []string{"dms-greeter"},
|
||||||
|
excludeDeps: []string{"dms-greeter"},
|
||||||
|
wantDisabled: []string{"dms-greeter"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "whitespace entries are skipped",
|
||||||
|
includeDeps: []string{" ", "dms-greeter"},
|
||||||
|
wantEnabled: []string{"dms-greeter"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no dms-greeter in deps, nothing disabled by default",
|
||||||
|
deps: []deps.Dependency{
|
||||||
|
{Name: "niri", Status: deps.StatusInstalled},
|
||||||
|
},
|
||||||
|
wantEnabled: []string{"niri"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
r := NewRunner(Config{
|
||||||
|
IncludeDeps: tt.includeDeps,
|
||||||
|
ExcludeDeps: tt.excludeDeps,
|
||||||
|
})
|
||||||
|
d := tt.deps
|
||||||
|
if d == nil {
|
||||||
|
d = dependencies
|
||||||
|
}
|
||||||
|
got, err := r.buildDisabledItems(d)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Fatalf("buildDisabledItems() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
if tt.wantErr {
|
||||||
|
if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
|
||||||
|
t.Errorf("error %q does not contain %q", err.Error(), tt.errContains)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("buildDisabledItems() returned nil map, want non-nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check expected disabled items
|
||||||
|
for _, name := range tt.wantDisabled {
|
||||||
|
if !got[name] {
|
||||||
|
t.Errorf("expected %q to be disabled, but it is not", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check expected enabled items (should not be in the map or be false)
|
||||||
|
for _, name := range tt.wantEnabled {
|
||||||
|
if got[name] {
|
||||||
|
t.Errorf("expected %q to NOT be disabled, but it is", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If wantDisabled is empty, the map should have length 0
|
||||||
|
if len(tt.wantDisabled) == 0 && len(got) != 0 {
|
||||||
|
t.Errorf("expected empty disabledItems map, got %v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -21,7 +22,16 @@ type FileLogger struct {
|
|||||||
|
|
||||||
func NewFileLogger() (*FileLogger, error) {
|
func NewFileLogger() (*FileLogger, error) {
|
||||||
timestamp := time.Now().Unix()
|
timestamp := time.Now().Unix()
|
||||||
logPath := fmt.Sprintf("/tmp/dankinstall-%d.log", timestamp)
|
|
||||||
|
// Use DANKINSTALL_LOG_DIR if set, otherwise fall back to /tmp.
|
||||||
|
logDir := os.Getenv("DANKINSTALL_LOG_DIR")
|
||||||
|
if logDir == "" {
|
||||||
|
logDir = "/tmp"
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(logDir, 0o755); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create log directory: %w", err)
|
||||||
|
}
|
||||||
|
logPath := filepath.Join(logDir, fmt.Sprintf("dankinstall-%d.log", timestamp))
|
||||||
|
|
||||||
file, err := os.Create(logPath)
|
file, err := os.Create(logPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -444,20 +444,21 @@ func GetFocusedMonitor() string {
|
|||||||
|
|
||||||
type outputInfo struct {
|
type outputInfo struct {
|
||||||
x, y int32
|
x, y int32
|
||||||
|
scale float64
|
||||||
transform int32
|
transform int32
|
||||||
}
|
}
|
||||||
|
|
||||||
func getOutputInfo(outputName string) (*outputInfo, bool) {
|
func getAllOutputInfos() map[string]*outputInfo {
|
||||||
display, err := client.Connect("")
|
display, err := client.Connect("")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false
|
return nil
|
||||||
}
|
}
|
||||||
ctx := display.Context()
|
ctx := display.Context()
|
||||||
defer ctx.Close()
|
defer ctx.Close()
|
||||||
|
|
||||||
registry, err := display.GetRegistry()
|
registry, err := display.GetRegistry()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var outputManager *wlr_output_management.ZwlrOutputManagerV1
|
var outputManager *wlr_output_management.ZwlrOutputManagerV1
|
||||||
@@ -476,16 +477,17 @@ func getOutputInfo(outputName string) (*outputInfo, bool) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if err := wlhelpers.Roundtrip(display, ctx); err != nil {
|
if err := wlhelpers.Roundtrip(display, ctx); err != nil {
|
||||||
return nil, false
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if outputManager == nil {
|
if outputManager == nil {
|
||||||
return nil, false
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type headState struct {
|
type headState struct {
|
||||||
name string
|
name string
|
||||||
x, y int32
|
x, y int32
|
||||||
|
scale float64
|
||||||
transform int32
|
transform int32
|
||||||
}
|
}
|
||||||
heads := make(map[*wlr_output_management.ZwlrOutputHeadV1]*headState)
|
heads := make(map[*wlr_output_management.ZwlrOutputHeadV1]*headState)
|
||||||
@@ -501,6 +503,9 @@ func getOutputInfo(outputName string) (*outputInfo, bool) {
|
|||||||
state.x = pe.X
|
state.x = pe.X
|
||||||
state.y = pe.Y
|
state.y = pe.Y
|
||||||
})
|
})
|
||||||
|
e.Head.SetScaleHandler(func(se wlr_output_management.ZwlrOutputHeadV1ScaleEvent) {
|
||||||
|
state.scale = se.Scale
|
||||||
|
})
|
||||||
e.Head.SetTransformHandler(func(te wlr_output_management.ZwlrOutputHeadV1TransformEvent) {
|
e.Head.SetTransformHandler(func(te wlr_output_management.ZwlrOutputHeadV1TransformEvent) {
|
||||||
state.transform = te.Transform
|
state.transform = te.Transform
|
||||||
})
|
})
|
||||||
@@ -511,21 +516,32 @@ func getOutputInfo(outputName string) (*outputInfo, bool) {
|
|||||||
|
|
||||||
for !done {
|
for !done {
|
||||||
if err := ctx.Dispatch(); err != nil {
|
if err := ctx.Dispatch(); err != nil {
|
||||||
return nil, false
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
result := make(map[string]*outputInfo, len(heads))
|
||||||
for _, state := range heads {
|
for _, state := range heads {
|
||||||
if state.name == outputName {
|
if state.name == "" {
|
||||||
return &outputInfo{
|
continue
|
||||||
x: state.x,
|
}
|
||||||
y: state.y,
|
result[state.name] = &outputInfo{
|
||||||
transform: state.transform,
|
x: state.x,
|
||||||
}, true
|
y: state.y,
|
||||||
|
scale: state.scale,
|
||||||
|
transform: state.transform,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
return nil, false
|
func getOutputInfo(outputName string) (*outputInfo, bool) {
|
||||||
|
infos := getAllOutputInfos()
|
||||||
|
if infos == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
info, ok := infos[outputName]
|
||||||
|
return info, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
func getDWLActiveWindow() (*WindowGeometry, error) {
|
func getDWLActiveWindow() (*WindowGeometry, error) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package screenshot
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
@@ -304,22 +305,20 @@ func (s *Screenshoter) captureAllScreens() (*CaptureResult, error) {
|
|||||||
if len(outputs) == 0 {
|
if len(outputs) == 0 {
|
||||||
return nil, fmt.Errorf("no outputs available")
|
return nil, fmt.Errorf("no outputs available")
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(outputs) == 1 {
|
if len(outputs) == 1 {
|
||||||
return s.captureWholeOutput(outputs[0])
|
return s.captureWholeOutput(outputs[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture all outputs first to get actual buffer sizes
|
wlrInfos := getAllOutputInfos()
|
||||||
type capturedOutput struct {
|
|
||||||
output *WaylandOutput
|
|
||||||
result *CaptureResult
|
|
||||||
physX int
|
|
||||||
physY int
|
|
||||||
}
|
|
||||||
captured := make([]capturedOutput, 0, len(outputs))
|
|
||||||
|
|
||||||
var minX, minY, maxX, maxY int
|
type pendingOutput struct {
|
||||||
first := true
|
result *CaptureResult
|
||||||
|
logX float64
|
||||||
|
logY float64
|
||||||
|
scale float64
|
||||||
|
}
|
||||||
|
var pending []pendingOutput
|
||||||
|
maxScale := 1.0
|
||||||
|
|
||||||
for _, output := range outputs {
|
for _, output := range outputs {
|
||||||
result, err := s.captureWholeOutput(output)
|
result, err := s.captureWholeOutput(output)
|
||||||
@@ -328,50 +327,74 @@ func (s *Screenshoter) captureAllScreens() (*CaptureResult, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
outX, outY := output.x, output.y
|
logX, logY := float64(output.x), float64(output.y)
|
||||||
scale := float64(output.scale)
|
scale := float64(output.scale)
|
||||||
|
|
||||||
switch DetectCompositor() {
|
switch DetectCompositor() {
|
||||||
case CompositorHyprland:
|
case CompositorHyprland:
|
||||||
if hx, hy, _, _, ok := GetHyprlandMonitorGeometry(output.name); ok {
|
if hx, hy, _, _, ok := GetHyprlandMonitorGeometry(output.name); ok {
|
||||||
outX, outY = hx, hy
|
logX, logY = float64(hx), float64(hy)
|
||||||
}
|
}
|
||||||
if s := GetHyprlandMonitorScale(output.name); s > 0 {
|
if hs := GetHyprlandMonitorScale(output.name); hs > 0 {
|
||||||
scale = s
|
scale = hs
|
||||||
}
|
}
|
||||||
case CompositorDWL:
|
default:
|
||||||
if info, ok := getOutputInfo(output.name); ok {
|
if wlrInfos != nil {
|
||||||
outX, outY = info.x, info.y
|
if info, ok := wlrInfos[output.name]; ok {
|
||||||
|
logX, logY = float64(info.x), float64(info.y)
|
||||||
|
if info.scale > 0 {
|
||||||
|
scale = info.scale
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if scale <= 0 {
|
if scale <= 0 {
|
||||||
scale = 1.0
|
scale = 1.0
|
||||||
}
|
}
|
||||||
|
|
||||||
physX := int(float64(outX) * scale)
|
pending = append(pending, pendingOutput{result: result, logX: logX, logY: logY, scale: scale})
|
||||||
physY := int(float64(outY) * scale)
|
if scale > maxScale {
|
||||||
|
maxScale = scale
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
captured = append(captured, capturedOutput{
|
if len(pending) == 0 {
|
||||||
output: output,
|
return nil, fmt.Errorf("failed to capture any outputs")
|
||||||
result: result,
|
}
|
||||||
physX: physX,
|
if len(pending) == 1 {
|
||||||
physY: physY,
|
return pending[0].result, nil
|
||||||
})
|
}
|
||||||
|
|
||||||
right := physX + result.Buffer.Width
|
type layoutEntry struct {
|
||||||
bottom := physY + result.Buffer.Height
|
result *CaptureResult
|
||||||
|
canvasX int
|
||||||
|
canvasY int
|
||||||
|
canvasW int
|
||||||
|
canvasH int
|
||||||
|
}
|
||||||
|
entries := make([]layoutEntry, len(pending))
|
||||||
|
var minX, minY, maxX, maxY int
|
||||||
|
|
||||||
if first {
|
for i, p := range pending {
|
||||||
minX, minY = physX, physY
|
cx := int(math.Round(p.logX * maxScale))
|
||||||
maxX, maxY = right, bottom
|
cy := int(math.Round(p.logY * maxScale))
|
||||||
first = false
|
cw := int(math.Round(float64(p.result.Buffer.Width) * maxScale / p.scale))
|
||||||
|
ch := int(math.Round(float64(p.result.Buffer.Height) * maxScale / p.scale))
|
||||||
|
|
||||||
|
entries[i] = layoutEntry{result: p.result, canvasX: cx, canvasY: cy, canvasW: cw, canvasH: ch}
|
||||||
|
|
||||||
|
right := cx + cw
|
||||||
|
bottom := cy + ch
|
||||||
|
if i == 0 {
|
||||||
|
minX, minY, maxX, maxY = cx, cy, right, bottom
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if cx < minX {
|
||||||
if physX < minX {
|
minX = cx
|
||||||
minX = physX
|
|
||||||
}
|
}
|
||||||
if physY < minY {
|
if cy < minY {
|
||||||
minY = physY
|
minY = cy
|
||||||
}
|
}
|
||||||
if right > maxX {
|
if right > maxX {
|
||||||
maxX = right
|
maxX = right
|
||||||
@@ -381,35 +404,26 @@ func (s *Screenshoter) captureAllScreens() (*CaptureResult, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(captured) == 0 {
|
|
||||||
return nil, fmt.Errorf("failed to capture any outputs")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(captured) == 1 {
|
|
||||||
return captured[0].result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
totalW := maxX - minX
|
totalW := maxX - minX
|
||||||
totalH := maxY - minY
|
totalH := maxY - minY
|
||||||
|
composite, err := CreateShmBuffer(totalW, totalH, totalW*4)
|
||||||
compositeStride := totalW * 4
|
|
||||||
composite, err := CreateShmBuffer(totalW, totalH, compositeStride)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
for _, c := range captured {
|
for _, e := range entries {
|
||||||
c.result.Buffer.Close()
|
e.result.Buffer.Close()
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("create composite buffer: %w", err)
|
return nil, fmt.Errorf("create composite buffer: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
composite.Clear()
|
composite.Clear()
|
||||||
|
|
||||||
var format uint32
|
var format uint32
|
||||||
for _, c := range captured {
|
for _, e := range entries {
|
||||||
if format == 0 {
|
if format == 0 {
|
||||||
format = c.result.Format
|
format = e.result.Format
|
||||||
}
|
}
|
||||||
s.blitBuffer(composite, c.result.Buffer, c.physX-minX, c.physY-minY, c.result.YInverted)
|
s.blitBufferScaled(composite, e.result.Buffer,
|
||||||
c.result.Buffer.Close()
|
e.canvasX-minX, e.canvasY-minY, e.canvasW, e.canvasH,
|
||||||
|
e.result.YInverted)
|
||||||
|
e.result.Buffer.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
return &CaptureResult{
|
return &CaptureResult{
|
||||||
@@ -419,32 +433,44 @@ func (s *Screenshoter) captureAllScreens() (*CaptureResult, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Screenshoter) blitBuffer(dst, src *ShmBuffer, dstX, dstY int, yInverted bool) {
|
func (s *Screenshoter) blitBufferScaled(dst, src *ShmBuffer, dstX, dstY, dstW, dstH int, yInverted bool) {
|
||||||
|
if dstW <= 0 || dstH <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
srcData := src.Data()
|
srcData := src.Data()
|
||||||
dstData := dst.Data()
|
dstData := dst.Data()
|
||||||
|
|
||||||
for srcY := 0; srcY < src.Height; srcY++ {
|
for dy := 0; dy < dstH; dy++ {
|
||||||
actualSrcY := srcY
|
canvasY := dstY + dy
|
||||||
if yInverted {
|
if canvasY < 0 || canvasY >= dst.Height {
|
||||||
actualSrcY = src.Height - 1 - srcY
|
|
||||||
}
|
|
||||||
|
|
||||||
dy := dstY + srcY
|
|
||||||
if dy < 0 || dy >= dst.Height {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
srcRowOff := actualSrcY * src.Stride
|
srcY := dy * src.Height / dstH
|
||||||
dstRowOff := dy * dst.Stride
|
if yInverted {
|
||||||
|
srcY = src.Height - 1 - srcY
|
||||||
|
}
|
||||||
|
if srcY < 0 || srcY >= src.Height {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
for srcX := 0; srcX < src.Width; srcX++ {
|
srcRowOff := srcY * src.Stride
|
||||||
dx := dstX + srcX
|
dstRowOff := canvasY * dst.Stride
|
||||||
if dx < 0 || dx >= dst.Width {
|
|
||||||
|
for dx := 0; dx < dstW; dx++ {
|
||||||
|
canvasX := dstX + dx
|
||||||
|
if canvasX < 0 || canvasX >= dst.Width {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
srcX := dx * src.Width / dstW
|
||||||
|
if srcX >= src.Width {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
si := srcRowOff + srcX*4
|
si := srcRowOff + srcX*4
|
||||||
di := dstRowOff + dx*4
|
di := dstRowOff + canvasX*4
|
||||||
|
|
||||||
if si+3 >= len(srcData) || di+3 >= len(dstData) {
|
if si+3 >= len(srcData) || di+3 >= len(dstData) {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -158,18 +158,26 @@ func (b *NetworkManagerBackend) GetWiFiNetworkDetails(ssid string) (*NetworkInfo
|
|||||||
|
|
||||||
channel := frequencyToChannel(freq)
|
channel := frequencyToChannel(freq)
|
||||||
|
|
||||||
|
isConnected := ssid == currentSSID && bssid == currentBSSID
|
||||||
|
rate := maxBitrate / 1000
|
||||||
|
if isConnected {
|
||||||
|
if devBitrate, err := w.GetPropertyBitrate(); err == nil && devBitrate > 0 {
|
||||||
|
rate = devBitrate / 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
network := WiFiNetwork{
|
network := WiFiNetwork{
|
||||||
SSID: ssid,
|
SSID: ssid,
|
||||||
BSSID: bssid,
|
BSSID: bssid,
|
||||||
Signal: strength,
|
Signal: strength,
|
||||||
Secured: secured,
|
Secured: secured,
|
||||||
Enterprise: enterprise,
|
Enterprise: enterprise,
|
||||||
Connected: ssid == currentSSID && bssid == currentBSSID,
|
Connected: isConnected,
|
||||||
Saved: savedSSIDs[ssid],
|
Saved: savedSSIDs[ssid],
|
||||||
Autoconnect: autoconnectMap[ssid],
|
Autoconnect: autoconnectMap[ssid],
|
||||||
Frequency: freq,
|
Frequency: freq,
|
||||||
Mode: modeStr,
|
Mode: modeStr,
|
||||||
Rate: maxBitrate / 1000,
|
Rate: rate,
|
||||||
Channel: channel,
|
Channel: channel,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -514,19 +522,27 @@ func (b *NetworkManagerBackend) updateWiFiNetworks() ([]WiFiNetwork, error) {
|
|||||||
|
|
||||||
channel := frequencyToChannel(freq)
|
channel := frequencyToChannel(freq)
|
||||||
|
|
||||||
|
isConnected := ssid == currentSSID
|
||||||
|
rate := maxBitrate / 1000
|
||||||
|
if isConnected {
|
||||||
|
if devBitrate, err := w.GetPropertyBitrate(); err == nil && devBitrate > 0 {
|
||||||
|
rate = devBitrate / 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
network := WiFiNetwork{
|
network := WiFiNetwork{
|
||||||
SSID: ssid,
|
SSID: ssid,
|
||||||
BSSID: bssid,
|
BSSID: bssid,
|
||||||
Signal: strength,
|
Signal: strength,
|
||||||
Secured: secured,
|
Secured: secured,
|
||||||
Enterprise: enterprise,
|
Enterprise: enterprise,
|
||||||
Connected: ssid == currentSSID,
|
Connected: isConnected,
|
||||||
Saved: savedSSIDs[ssid],
|
Saved: savedSSIDs[ssid],
|
||||||
Autoconnect: autoconnectMap[ssid],
|
Autoconnect: autoconnectMap[ssid],
|
||||||
Hidden: hiddenSSIDs[ssid],
|
Hidden: hiddenSSIDs[ssid],
|
||||||
Frequency: freq,
|
Frequency: freq,
|
||||||
Mode: modeStr,
|
Mode: modeStr,
|
||||||
Rate: maxBitrate / 1000,
|
Rate: rate,
|
||||||
Channel: channel,
|
Channel: channel,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1062,19 +1078,27 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() {
|
|||||||
|
|
||||||
channel := frequencyToChannel(freq)
|
channel := frequencyToChannel(freq)
|
||||||
|
|
||||||
|
isConnected := connected && apSSID == ssid
|
||||||
|
rate := maxBitrate / 1000
|
||||||
|
if isConnected {
|
||||||
|
if devBitrate, err := devInfo.wireless.GetPropertyBitrate(); err == nil && devBitrate > 0 {
|
||||||
|
rate = devBitrate / 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
network := WiFiNetwork{
|
network := WiFiNetwork{
|
||||||
SSID: apSSID,
|
SSID: apSSID,
|
||||||
BSSID: apBSSID,
|
BSSID: apBSSID,
|
||||||
Signal: strength,
|
Signal: strength,
|
||||||
Secured: secured,
|
Secured: secured,
|
||||||
Enterprise: enterprise,
|
Enterprise: enterprise,
|
||||||
Connected: connected && apSSID == ssid,
|
Connected: isConnected,
|
||||||
Saved: savedSSIDs[apSSID],
|
Saved: savedSSIDs[apSSID],
|
||||||
Autoconnect: autoconnectMap[apSSID],
|
Autoconnect: autoconnectMap[apSSID],
|
||||||
Hidden: hiddenSSIDs[apSSID],
|
Hidden: hiddenSSIDs[apSSID],
|
||||||
Frequency: freq,
|
Frequency: freq,
|
||||||
Mode: modeStr,
|
Mode: modeStr,
|
||||||
Rate: maxBitrate / 1000,
|
Rate: rate,
|
||||||
Channel: channel,
|
Channel: channel,
|
||||||
Device: name,
|
Device: name,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import (
|
|||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/trayrecovery"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput"
|
||||||
@@ -72,6 +73,7 @@ var clipboardManager *clipboard.Manager
|
|||||||
var dbusManager *serverDbus.Manager
|
var dbusManager *serverDbus.Manager
|
||||||
var wlContext *wlcontext.SharedContext
|
var wlContext *wlcontext.SharedContext
|
||||||
var themeModeManager *thememode.Manager
|
var themeModeManager *thememode.Manager
|
||||||
|
var trayRecoveryManager *trayrecovery.Manager
|
||||||
var locationManager *location.Manager
|
var locationManager *location.Manager
|
||||||
var geoClientInstance geolocation.Client
|
var geoClientInstance geolocation.Client
|
||||||
|
|
||||||
@@ -394,6 +396,18 @@ func InitializeThemeModeManager() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func InitializeTrayRecoveryManager() error {
|
||||||
|
manager, err := trayrecovery.NewManager()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
trayRecoveryManager = manager
|
||||||
|
|
||||||
|
log.Info("TrayRecovery manager initialized")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func InitializeLocationManager(geoClient geolocation.Client) error {
|
func InitializeLocationManager(geoClient geolocation.Client) error {
|
||||||
manager, err := location.NewManager(geoClient)
|
manager, err := location.NewManager(geoClient)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1325,6 +1339,9 @@ func cleanupManagers() {
|
|||||||
if themeModeManager != nil {
|
if themeModeManager != nil {
|
||||||
themeModeManager.Close()
|
themeModeManager.Close()
|
||||||
}
|
}
|
||||||
|
if trayRecoveryManager != nil {
|
||||||
|
trayRecoveryManager.Close()
|
||||||
|
}
|
||||||
if wlContext != nil {
|
if wlContext != nil {
|
||||||
wlContext.Close()
|
wlContext.Close()
|
||||||
}
|
}
|
||||||
@@ -1610,6 +1627,18 @@ func Start(printDocs bool) error {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
<-loginctlReady
|
||||||
|
if loginctlManager == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := InitializeTrayRecoveryManager(); err != nil {
|
||||||
|
log.Warnf("TrayRecovery manager unavailable: %v", err)
|
||||||
|
} else {
|
||||||
|
trayRecoveryManager.WatchLoginctl(loginctlManager)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
geoClient := geolocation.NewClient()
|
geoClient := geolocation.NewClient()
|
||||||
geoClientInstance = geoClient
|
geoClientInstance = geoClient
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package trayrecovery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl"
|
||||||
|
"github.com/godbus/dbus/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
const resumeDelay = 3 * time.Second
|
||||||
|
|
||||||
|
type Manager struct {
|
||||||
|
conn *dbus.Conn
|
||||||
|
stopChan chan struct{}
|
||||||
|
wg sync.WaitGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewManager() (*Manager, error) {
|
||||||
|
conn, err := dbus.ConnectSessionBus()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to connect to session bus: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m := &Manager{
|
||||||
|
conn: conn,
|
||||||
|
stopChan: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run a startup scan after a delay — covers the case where the process
|
||||||
|
// was killed during suspend and restarted by systemd (Type=dbus).
|
||||||
|
// The fresh process never sees the PrepareForSleep true→false transition,
|
||||||
|
// so the loginctl watcher alone is not enough.
|
||||||
|
go m.scheduleRecovery()
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WatchLoginctl subscribes to loginctl session state changes and triggers
|
||||||
|
// tray recovery after resume from suspend (PrepareForSleep false transition).
|
||||||
|
// This handles the case where the process survives suspend.
|
||||||
|
func (m *Manager) WatchLoginctl(lm *loginctl.Manager) {
|
||||||
|
ch := lm.Subscribe("tray-recovery")
|
||||||
|
m.wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer m.wg.Done()
|
||||||
|
defer lm.Unsubscribe("tray-recovery")
|
||||||
|
|
||||||
|
wasSleeping := false
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-m.stopChan:
|
||||||
|
return
|
||||||
|
case state, ok := <-ch:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if state.PreparingForSleep {
|
||||||
|
wasSleeping = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if wasSleeping {
|
||||||
|
wasSleeping = false
|
||||||
|
go m.scheduleRecovery()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) scheduleRecovery() {
|
||||||
|
select {
|
||||||
|
case <-time.After(resumeDelay):
|
||||||
|
m.recoverTrayItems()
|
||||||
|
case <-m.stopChan:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Close() {
|
||||||
|
select {
|
||||||
|
case <-m.stopChan:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
close(m.stopChan)
|
||||||
|
}
|
||||||
|
m.wg.Wait()
|
||||||
|
if m.conn != nil {
|
||||||
|
m.conn.Close()
|
||||||
|
}
|
||||||
|
log.Info("TrayRecovery manager closed")
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
package trayrecovery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
|
"github.com/godbus/dbus/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
sniWatcherDest = "org.kde.StatusNotifierWatcher"
|
||||||
|
sniWatcherPath = "/StatusNotifierWatcher"
|
||||||
|
sniWatcherIface = "org.kde.StatusNotifierWatcher"
|
||||||
|
sniItemIface = "org.kde.StatusNotifierItem"
|
||||||
|
dbusIface = "org.freedesktop.DBus"
|
||||||
|
propsIface = "org.freedesktop.DBus.Properties"
|
||||||
|
probeTimeout = 300 * time.Millisecond
|
||||||
|
connProbeTimeout = 150 * time.Millisecond
|
||||||
|
batchSize = 30
|
||||||
|
)
|
||||||
|
|
||||||
|
var excludedPrefixes = []string{
|
||||||
|
"org.freedesktop.",
|
||||||
|
"org.gnome.",
|
||||||
|
"org.kde.StatusNotifier",
|
||||||
|
"com.canonical.AppMenu",
|
||||||
|
"org.mpris.",
|
||||||
|
"org.pipewire.",
|
||||||
|
"org.pulseaudio",
|
||||||
|
"fi.epitaph",
|
||||||
|
"quickshell",
|
||||||
|
"org.kde.quickshell",
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) recoverTrayItems() {
|
||||||
|
registeredItems := m.getRegisteredItems()
|
||||||
|
allNames := m.getDBusNames()
|
||||||
|
if allNames == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
registeredConnIDs := m.buildRegisteredConnIDs(registeredItems)
|
||||||
|
|
||||||
|
count := len(registeredItems)
|
||||||
|
log.Infof("TrayRecoveryService: scanning DBus for unregistered SNI items (%d already registered)...", count)
|
||||||
|
|
||||||
|
m.scanWellKnownNames(allNames, registeredItems, registeredConnIDs)
|
||||||
|
m.scanConnectionIDs(allNames, registeredItems, registeredConnIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) getRegisteredItems() []string {
|
||||||
|
obj := m.conn.Object(sniWatcherDest, sniWatcherPath)
|
||||||
|
variant, err := obj.GetProperty(sniWatcherIface + ".RegisteredStatusNotifierItems")
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("TrayRecoveryService: failed to get registered items: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch v := variant.Value().(type) {
|
||||||
|
case []string:
|
||||||
|
return v
|
||||||
|
case []any:
|
||||||
|
items := make([]string, 0, len(v))
|
||||||
|
for _, elem := range v {
|
||||||
|
if s, ok := elem.(string); ok {
|
||||||
|
items = append(items, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) getDBusNames() []string {
|
||||||
|
var names []string
|
||||||
|
err := m.conn.BusObject().Call(dbusIface+".ListNames", 0).Store(&names)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("TrayRecoveryService: failed to list bus names: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) getNameOwner(name string) string {
|
||||||
|
var owner string
|
||||||
|
err := m.conn.BusObject().Call(dbusIface+".GetNameOwner", 0, name).Store(&owner)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return owner
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRegisteredConnIDs resolves every registered SNI item (well-known name
|
||||||
|
// or :1.xxx connection ID) to a canonical connection ID. This prevents
|
||||||
|
// duplicates in both directions.
|
||||||
|
func (m *Manager) buildRegisteredConnIDs(registeredItems []string) map[string]bool {
|
||||||
|
connIDs := make(map[string]bool, len(registeredItems))
|
||||||
|
for _, item := range registeredItems {
|
||||||
|
name := extractName(item)
|
||||||
|
if strings.HasPrefix(name, ":1.") {
|
||||||
|
connIDs[name] = true
|
||||||
|
} else {
|
||||||
|
owner := m.getNameOwner(name)
|
||||||
|
if owner != "" {
|
||||||
|
connIDs[owner] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return connIDs
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanWellKnownNames probes well-known names (e.g. DinoX, nm-applet) for
|
||||||
|
// unregistered SNI items and re-registers them.
|
||||||
|
func (m *Manager) scanWellKnownNames(allNames []string, registeredItems []string, registeredConnIDs map[string]bool) {
|
||||||
|
registeredRaw := strings.Join(registeredItems, "\n")
|
||||||
|
|
||||||
|
for _, name := range allNames {
|
||||||
|
if strings.HasPrefix(name, ":") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(registeredRaw, name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if this name's connection ID is already in the registered set
|
||||||
|
// (handles the case where the app registered via connection ID instead)
|
||||||
|
connForName := m.getNameOwner(name)
|
||||||
|
if connForName != "" && registeredConnIDs[connForName] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if isExcludedName(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
short := shortName(name)
|
||||||
|
objectPaths := []string{
|
||||||
|
"/StatusNotifierItem",
|
||||||
|
"/org/ayatana/NotificationItem/" + short,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, objPath := range objectPaths {
|
||||||
|
if m.probeSNI(name, objPath, probeTimeout) {
|
||||||
|
m.registerSNI(name)
|
||||||
|
// Update set so the connection-ID section won't double-register this app
|
||||||
|
if connForName != "" {
|
||||||
|
registeredConnIDs[connForName] = true
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanConnectionIDs probes all :1.xxx connections in parallel for unregistered
|
||||||
|
// SNI items (e.g. Vesktop, Electron apps). Most non-SNI connections return an
|
||||||
|
// error instantly, so this is fast.
|
||||||
|
func (m *Manager) scanConnectionIDs(allNames []string, registeredItems []string, registeredConnIDs map[string]bool) {
|
||||||
|
registeredRaw := strings.Join(registeredItems, "\n")
|
||||||
|
registeredLower := strings.ToLower(registeredRaw)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
sem := make(chan struct{}, batchSize)
|
||||||
|
|
||||||
|
for _, name := range allNames {
|
||||||
|
if !strings.HasPrefix(name, ":1.") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if registeredConnIDs[name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
sem <- struct{}{}
|
||||||
|
wg.Add(1)
|
||||||
|
go func(conn string) {
|
||||||
|
defer wg.Done()
|
||||||
|
defer func() { <-sem }()
|
||||||
|
|
||||||
|
sniID := m.getSNIId(conn, connProbeTimeout)
|
||||||
|
if sniID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if an item with the same Id is already registered (case-insensitive)
|
||||||
|
if strings.Contains(registeredLower, strings.ToLower(sniID)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m.registerSNI(conn)
|
||||||
|
log.Infof("TrayRecovery: re-registered %s (Id: %s)", conn, sniID)
|
||||||
|
}(name)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) probeSNI(dest, path string, timeout time.Duration) bool {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
obj := m.conn.Object(dest, dbus.ObjectPath(path))
|
||||||
|
var props map[string]dbus.Variant
|
||||||
|
err := obj.CallWithContext(ctx, propsIface+".GetAll", 0, sniItemIface).Store(&props)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
_, hasID := props["Id"]
|
||||||
|
return hasID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) getSNIId(dest string, timeout time.Duration) string {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
obj := m.conn.Object(dest, "/StatusNotifierItem")
|
||||||
|
var variant dbus.Variant
|
||||||
|
err := obj.CallWithContext(ctx, propsIface+".Get", 0, sniItemIface, "Id").Store(&variant)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
id, _ := variant.Value().(string)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) registerSNI(name string) {
|
||||||
|
obj := m.conn.Object(sniWatcherDest, sniWatcherPath)
|
||||||
|
call := obj.Call(sniWatcherIface+".RegisterStatusNotifierItem", 0, name)
|
||||||
|
if call.Err != nil {
|
||||||
|
log.Warnf("TrayRecovery: failed to register %s: %v", name, call.Err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Infof("TrayRecovery: re-registered %s", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractName(item string) string {
|
||||||
|
if idx := strings.IndexByte(item, '/'); idx != -1 {
|
||||||
|
return item[:idx]
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
func shortName(name string) string {
|
||||||
|
parts := strings.Split(name, ".")
|
||||||
|
if len(parts) > 0 {
|
||||||
|
return parts[len(parts)-1]
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
func isExcludedName(name string) bool {
|
||||||
|
for _, prefix := range excludedPrefixes {
|
||||||
|
if strings.HasPrefix(name, prefix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -3,8 +3,11 @@ package wayland
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"slices"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -73,7 +76,10 @@ func NewManager(display wlclient.WaylandDisplay, config Config) (*Manager, error
|
|||||||
m.post(func() {
|
m.post(func() {
|
||||||
log.Info("Gamma control enabled at startup")
|
log.Info("Gamma control enabled at startup")
|
||||||
gammaMgr := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1)
|
gammaMgr := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1)
|
||||||
if err := m.setupOutputControls(m.availableOutputs, gammaMgr); err != nil {
|
m.availOutputsMu.RLock()
|
||||||
|
outs := slices.Clone(m.availableOutputs)
|
||||||
|
m.availOutputsMu.RUnlock()
|
||||||
|
if err := m.setupOutputControls(outs, gammaMgr); err != nil {
|
||||||
log.Errorf("Failed to initialize gamma controls: %v", err)
|
log.Errorf("Failed to initialize gamma controls: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -170,6 +176,7 @@ func (m *Manager) setupRegistry() error {
|
|||||||
})
|
})
|
||||||
if gammaMgr != nil {
|
if gammaMgr != nil {
|
||||||
outputs = append(outputs, output)
|
outputs = append(outputs, output)
|
||||||
|
m.addAvailableOutput(output)
|
||||||
}
|
}
|
||||||
m.outputRegNames.Store(outputID, e.Name)
|
m.outputRegNames.Store(outputID, e.Name)
|
||||||
|
|
||||||
@@ -204,6 +211,11 @@ func (m *Manager) setupRegistry() error {
|
|||||||
}
|
}
|
||||||
if foundOut.gammaControl != nil {
|
if foundOut.gammaControl != nil {
|
||||||
foundOut.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1).Destroy()
|
foundOut.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1).Destroy()
|
||||||
|
foundOut.gammaControl = nil
|
||||||
|
}
|
||||||
|
m.removeAvailableOutput(foundOut.output)
|
||||||
|
if foundOut.output != nil && !foundOut.output.IsZombie() {
|
||||||
|
_ = foundOut.output.Release()
|
||||||
}
|
}
|
||||||
m.outputs.Delete(foundID)
|
m.outputs.Delete(foundID)
|
||||||
|
|
||||||
@@ -288,14 +300,28 @@ func (m *Manager) setupControlHandlers(state *outputState, control *wlr_gamma_co
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if ctrl, ok := out.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1); ok && ctrl != nil && !ctrl.IsZombie() {
|
||||||
|
ctrl.Destroy()
|
||||||
|
}
|
||||||
|
out.gammaControl = nil
|
||||||
out.failed = true
|
out.failed = true
|
||||||
out.rampSize = 0
|
out.rampSize = 0
|
||||||
out.retryCount++
|
out.retryCount++
|
||||||
out.lastFailTime = time.Now()
|
out.lastFailTime = time.Now()
|
||||||
|
|
||||||
|
if !m.outputStillValid(out) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
backoff := time.Duration(300<<uint(min(out.retryCount-1, 4))) * time.Millisecond
|
backoff := time.Duration(300<<uint(min(out.retryCount-1, 4))) * time.Millisecond
|
||||||
time.AfterFunc(backoff, func() {
|
time.AfterFunc(backoff, func() {
|
||||||
m.post(func() {
|
m.post(func() {
|
||||||
|
if !m.outputStillValid(out) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, stillTracked := m.outputs.Load(outputID); !stillTracked {
|
||||||
|
return
|
||||||
|
}
|
||||||
m.recreateOutputControl(out)
|
m.recreateOutputControl(out)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -303,12 +329,75 @@ func (m *Manager) setupControlHandlers(state *outputState, control *wlr_gamma_co
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) addAvailableOutput(o *wlclient.Output) {
|
||||||
|
if o == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.availOutputsMu.Lock()
|
||||||
|
defer m.availOutputsMu.Unlock()
|
||||||
|
if slices.Contains(m.availableOutputs, o) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.availableOutputs = append(m.availableOutputs, o)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) removeAvailableOutput(o *wlclient.Output) {
|
||||||
|
if o == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.availOutputsMu.Lock()
|
||||||
|
defer m.availOutputsMu.Unlock()
|
||||||
|
m.availableOutputs = slices.DeleteFunc(m.availableOutputs, func(existing *wlclient.Output) bool {
|
||||||
|
return existing == o
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) outputStillValid(out *outputState) bool {
|
||||||
|
switch {
|
||||||
|
case out == nil:
|
||||||
|
return false
|
||||||
|
case out.output == nil:
|
||||||
|
return false
|
||||||
|
case out.output.IsZombie():
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
m.availOutputsMu.RLock()
|
||||||
|
defer m.availOutputsMu.RUnlock()
|
||||||
|
return slices.Contains(m.availableOutputs, out.output)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isConnectionDeadErr(err error) bool {
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
return false
|
||||||
|
case errors.Is(err, syscall.EPIPE):
|
||||||
|
return true
|
||||||
|
case errors.Is(err, syscall.ECONNRESET):
|
||||||
|
return true
|
||||||
|
case errors.Is(err, syscall.EBADF):
|
||||||
|
return true
|
||||||
|
case errors.Is(err, io.EOF):
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) addOutputControl(output *wlclient.Output) error {
|
func (m *Manager) addOutputControl(output *wlclient.Output) error {
|
||||||
|
switch {
|
||||||
|
case m.connectionDead.Load():
|
||||||
|
return nil
|
||||||
|
case output == nil || output.IsZombie():
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
outputID := output.ID()
|
outputID := output.ID()
|
||||||
gammaMgr := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1)
|
gammaMgr := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1)
|
||||||
|
|
||||||
control, err := gammaMgr.GetGammaControl(output)
|
control, err := gammaMgr.GetGammaControl(output)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if isConnectionDeadErr(err) {
|
||||||
|
m.markConnectionDead(err)
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,26 +418,37 @@ func (m *Manager) recreateOutputControl(out *outputState) error {
|
|||||||
enabled := m.config.Enabled
|
enabled := m.config.Enabled
|
||||||
m.configMutex.RUnlock()
|
m.configMutex.RUnlock()
|
||||||
|
|
||||||
if !enabled || !m.controlsInitialized {
|
switch {
|
||||||
|
case m.connectionDead.Load():
|
||||||
|
return nil
|
||||||
|
case !enabled || !m.controlsInitialized:
|
||||||
|
return nil
|
||||||
|
case out.isVirtual:
|
||||||
|
return nil
|
||||||
|
case out.retryCount >= 10:
|
||||||
|
return nil
|
||||||
|
case !m.outputStillValid(out):
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if _, ok := m.outputs.Load(out.id); !ok {
|
if _, ok := m.outputs.Load(out.id); !ok {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if out.isVirtual {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if out.retryCount >= 10 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
gammaMgr, ok := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1)
|
gammaMgr, ok := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("no gamma manager")
|
return fmt.Errorf("no gamma manager")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if existing, ok := out.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1); ok && existing != nil && !existing.IsZombie() {
|
||||||
|
existing.Destroy()
|
||||||
|
out.gammaControl = nil
|
||||||
|
}
|
||||||
|
|
||||||
control, err := gammaMgr.GetGammaControl(out.output)
|
control, err := gammaMgr.GetGammaControl(out.output)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if isConnectionDeadErr(err) {
|
||||||
|
m.markConnectionDead(err)
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,6 +458,13 @@ func (m *Manager) recreateOutputControl(out *outputState) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) markConnectionDead(err error) {
|
||||||
|
if m.connectionDead.Swap(true) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Errorf("gamma: wayland connection appears dead (%v); pausing gamma operations", err)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) recalcSchedule(now time.Time) {
|
func (m *Manager) recalcSchedule(now time.Time) {
|
||||||
m.configMutex.RLock()
|
m.configMutex.RLock()
|
||||||
config := m.config
|
config := m.config
|
||||||
@@ -690,11 +797,12 @@ func (m *Manager) applyGamma(temp int) {
|
|||||||
gamma := m.config.Gamma
|
gamma := m.config.Gamma
|
||||||
m.configMutex.RUnlock()
|
m.configMutex.RUnlock()
|
||||||
|
|
||||||
if !m.controlsInitialized {
|
switch {
|
||||||
|
case m.connectionDead.Load():
|
||||||
return
|
return
|
||||||
}
|
case !m.controlsInitialized:
|
||||||
|
return
|
||||||
if m.lastAppliedTemp == temp && m.lastAppliedGamma == gamma {
|
case m.lastAppliedTemp == temp && m.lastAppliedGamma == gamma:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -714,7 +822,14 @@ func (m *Manager) applyGamma(temp int) {
|
|||||||
var jobs []job
|
var jobs []job
|
||||||
|
|
||||||
for _, out := range outs {
|
for _, out := range outs {
|
||||||
if out.failed || out.rampSize == 0 {
|
switch {
|
||||||
|
case out.failed:
|
||||||
|
continue
|
||||||
|
case out.rampSize == 0:
|
||||||
|
continue
|
||||||
|
case out.gammaControl == nil:
|
||||||
|
continue
|
||||||
|
case !m.outputStillValid(out):
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
ramp := GenerateGammaRamp(out.rampSize, temp, gamma)
|
ramp := GenerateGammaRamp(out.rampSize, temp, gamma)
|
||||||
@@ -732,18 +847,16 @@ func (m *Manager) applyGamma(temp int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, j := range jobs {
|
for _, j := range jobs {
|
||||||
if err := m.setGammaBytes(j.out, j.data); err != nil {
|
err := m.setGammaBytes(j.out, j.data)
|
||||||
log.Warnf("gamma: failed to set output %d: %v", j.out.id, err)
|
if err == nil {
|
||||||
j.out.failed = true
|
continue
|
||||||
j.out.rampSize = 0
|
}
|
||||||
outID := j.out.id
|
log.Warnf("gamma: failed to set output %d: %v", j.out.id, err)
|
||||||
time.AfterFunc(300*time.Millisecond, func() {
|
j.out.failed = true
|
||||||
m.post(func() {
|
j.out.rampSize = 0
|
||||||
if out, ok := m.outputs.Load(outID); ok && out.failed {
|
if isConnectionDeadErr(err) {
|
||||||
m.recreateOutputControl(out)
|
m.markConnectionDead(err)
|
||||||
}
|
return
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -752,6 +865,14 @@ func (m *Manager) applyGamma(temp int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) setGammaBytes(out *outputState, data []byte) error {
|
func (m *Manager) setGammaBytes(out *outputState, data []byte) error {
|
||||||
|
if out.gammaControl == nil {
|
||||||
|
return fmt.Errorf("no gamma control")
|
||||||
|
}
|
||||||
|
ctrl, ok := out.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1)
|
||||||
|
if !ok || ctrl == nil || ctrl.IsZombie() {
|
||||||
|
return fmt.Errorf("gamma control invalid")
|
||||||
|
}
|
||||||
|
|
||||||
fd, err := MemfdCreate("gamma-ramp", 0)
|
fd, err := MemfdCreate("gamma-ramp", 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -774,7 +895,6 @@ func (m *Manager) setGammaBytes(out *outputState, data []byte) error {
|
|||||||
}
|
}
|
||||||
syscall.Seek(fd, 0, 0)
|
syscall.Seek(fd, 0, 0)
|
||||||
|
|
||||||
ctrl := out.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1)
|
|
||||||
return ctrl.SetGamma(fd)
|
return ctrl.SetGamma(fd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -882,10 +1002,10 @@ func (m *Manager) dbusMonitor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) handleDBusSignal(sig *dbus.Signal) {
|
func (m *Manager) handleDBusSignal(sig *dbus.Signal) {
|
||||||
if sig.Name != "org.freedesktop.login1.Manager.PrepareForSleep" {
|
switch {
|
||||||
|
case sig.Name != "org.freedesktop.login1.Manager.PrepareForSleep":
|
||||||
return
|
return
|
||||||
}
|
case len(sig.Body) == 0:
|
||||||
if len(sig.Body) == 0 {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
preparing, ok := sig.Body[0].(bool)
|
preparing, ok := sig.Body[0].(bool)
|
||||||
@@ -899,27 +1019,34 @@ func (m *Manager) handleDBusSignal(sig *dbus.Signal) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
time.AfterFunc(500*time.Millisecond, func() {
|
time.AfterFunc(500*time.Millisecond, func() {
|
||||||
m.post(func() {
|
m.post(m.handleResume)
|
||||||
m.configMutex.RLock()
|
|
||||||
stillEnabled := m.config.Enabled
|
|
||||||
m.configMutex.RUnlock()
|
|
||||||
if !stillEnabled || !m.controlsInitialized {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
m.outputs.Range(func(_ uint32, out *outputState) bool {
|
|
||||||
if out.gammaControl != nil {
|
|
||||||
out.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1).Destroy()
|
|
||||||
out.gammaControl = nil
|
|
||||||
}
|
|
||||||
out.retryCount = 0
|
|
||||||
out.failed = false
|
|
||||||
m.recreateOutputControl(out)
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) handleResume() {
|
||||||
|
m.configMutex.RLock()
|
||||||
|
stillEnabled := m.config.Enabled
|
||||||
|
m.configMutex.RUnlock()
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case !stillEnabled:
|
||||||
|
return
|
||||||
|
case !m.controlsInitialized:
|
||||||
|
return
|
||||||
|
case m.connectionDead.Load():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compositors (Niri, Hyprland, wlroots-based) re-apply the cached gamma
|
||||||
|
// ramp to DRM on resume; gamma_control objects stay valid. We just need
|
||||||
|
// to force a resend so the schedule catches up with the current time of
|
||||||
|
// day — the original #1235 regression was caused by lastAppliedTemp
|
||||||
|
// matching and the send being skipped.
|
||||||
|
m.recalcSchedule(time.Now())
|
||||||
|
m.lastAppliedTemp = 0
|
||||||
|
m.applyCurrentTemp("resume")
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) triggerUpdate() {
|
func (m *Manager) triggerUpdate() {
|
||||||
select {
|
select {
|
||||||
case m.updateTrigger <- struct{}{}:
|
case m.updateTrigger <- struct{}{}:
|
||||||
@@ -1058,7 +1185,10 @@ func (m *Manager) SetEnabled(enabled bool) {
|
|||||||
case enabled && !m.controlsInitialized:
|
case enabled && !m.controlsInitialized:
|
||||||
m.post(func() {
|
m.post(func() {
|
||||||
gammaMgr := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1)
|
gammaMgr := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1)
|
||||||
if err := m.setupOutputControls(m.availableOutputs, gammaMgr); err != nil {
|
m.availOutputsMu.RLock()
|
||||||
|
outs := slices.Clone(m.availableOutputs)
|
||||||
|
m.availOutputsMu.RUnlock()
|
||||||
|
if err := m.setupOutputControls(outs, gammaMgr); err != nil {
|
||||||
log.Errorf("gamma: failed to create controls: %v", err)
|
log.Errorf("gamma: failed to create controls: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package wayland
|
|||||||
import (
|
import (
|
||||||
"math"
|
"math"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
|
||||||
@@ -71,9 +72,11 @@ type Manager struct {
|
|||||||
registry *wlclient.Registry
|
registry *wlclient.Registry
|
||||||
gammaControl any
|
gammaControl any
|
||||||
availableOutputs []*wlclient.Output
|
availableOutputs []*wlclient.Output
|
||||||
|
availOutputsMu sync.RWMutex
|
||||||
outputRegNames syncmap.Map[uint32, uint32]
|
outputRegNames syncmap.Map[uint32, uint32]
|
||||||
outputs syncmap.Map[uint32, *outputState]
|
outputs syncmap.Map[uint32, *outputState]
|
||||||
controlsInitialized bool
|
controlsInitialized bool
|
||||||
|
connectionDead atomic.Bool
|
||||||
|
|
||||||
cmdq chan cmd
|
cmdq chan cmd
|
||||||
alive bool
|
alive bool
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ func dmsPackageName(distroID string, dependencies []deps.Dependency) string {
|
|||||||
if isGit {
|
if isGit {
|
||||||
return "dms-shell-git"
|
return "dms-shell-git"
|
||||||
}
|
}
|
||||||
return "dms-shell-bin"
|
return "dms-shell"
|
||||||
case distros.FamilyFedora, distros.FamilyUbuntu, distros.FamilyDebian, distros.FamilySUSE:
|
case distros.FamilyFedora, distros.FamilyUbuntu, distros.FamilyDebian, distros.FamilySUSE:
|
||||||
if isGit {
|
if isGit {
|
||||||
return "dms-git"
|
return "dms-git"
|
||||||
|
|||||||
@@ -124,6 +124,8 @@ Singleton {
|
|||||||
|
|
||||||
property string vpnLastConnected: ""
|
property string vpnLastConnected: ""
|
||||||
|
|
||||||
|
property string lastPlayerIdentity: ""
|
||||||
|
|
||||||
property var deviceMaxVolumes: ({})
|
property var deviceMaxVolumes: ({})
|
||||||
property var hiddenOutputDeviceNames: []
|
property var hiddenOutputDeviceNames: []
|
||||||
property var hiddenInputDeviceNames: []
|
property var hiddenInputDeviceNames: []
|
||||||
|
|||||||
@@ -301,6 +301,7 @@ Singleton {
|
|||||||
property var workspaceNameIcons: ({})
|
property var workspaceNameIcons: ({})
|
||||||
property bool waveProgressEnabled: true
|
property bool waveProgressEnabled: true
|
||||||
property bool scrollTitleEnabled: true
|
property bool scrollTitleEnabled: true
|
||||||
|
property bool mediaAdaptiveWidthEnabled: true
|
||||||
property bool audioVisualizerEnabled: true
|
property bool audioVisualizerEnabled: true
|
||||||
property string audioScrollMode: "volume"
|
property string audioScrollMode: "volume"
|
||||||
property int audioWheelScrollAmount: 5
|
property int audioWheelScrollAmount: 5
|
||||||
@@ -434,6 +435,7 @@ Singleton {
|
|||||||
property bool soundNewNotification: true
|
property bool soundNewNotification: true
|
||||||
property bool soundVolumeChanged: true
|
property bool soundVolumeChanged: true
|
||||||
property bool soundPluggedIn: true
|
property bool soundPluggedIn: true
|
||||||
|
property bool soundLogin: false
|
||||||
|
|
||||||
property int acMonitorTimeout: 0
|
property int acMonitorTimeout: 0
|
||||||
property int acLockTimeout: 0
|
property int acLockTimeout: 0
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ var SPEC = {
|
|||||||
|
|
||||||
vpnLastConnected: { def: "" },
|
vpnLastConnected: { def: "" },
|
||||||
|
|
||||||
|
lastPlayerIdentity: { def: "" },
|
||||||
|
|
||||||
deviceMaxVolumes: { def: {} },
|
deviceMaxVolumes: { def: {} },
|
||||||
hiddenOutputDeviceNames: { def: [] },
|
hiddenOutputDeviceNames: { def: [] },
|
||||||
hiddenInputDeviceNames: { def: [] },
|
hiddenInputDeviceNames: { def: [] },
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ var SPEC = {
|
|||||||
workspaceNameIcons: { def: {} },
|
workspaceNameIcons: { def: {} },
|
||||||
waveProgressEnabled: { def: true },
|
waveProgressEnabled: { def: true },
|
||||||
scrollTitleEnabled: { def: true },
|
scrollTitleEnabled: { def: true },
|
||||||
|
mediaAdaptiveWidthEnabled: { def: true },
|
||||||
audioVisualizerEnabled: { def: true },
|
audioVisualizerEnabled: { def: true },
|
||||||
audioScrollMode: { def: "volume" },
|
audioScrollMode: { def: "volume" },
|
||||||
audioWheelScrollAmount: { def: 5 },
|
audioWheelScrollAmount: { def: 5 },
|
||||||
@@ -242,6 +243,7 @@ var SPEC = {
|
|||||||
|
|
||||||
soundsEnabled: { def: true },
|
soundsEnabled: { def: true },
|
||||||
useSystemSoundTheme: { def: false },
|
useSystemSoundTheme: { def: false },
|
||||||
|
soundLogin: { def: false },
|
||||||
soundNewNotification: { def: true },
|
soundNewNotification: { def: true },
|
||||||
soundVolumeChanged: { def: true },
|
soundVolumeChanged: { def: true },
|
||||||
soundPluggedIn: { def: true },
|
soundPluggedIn: { def: true },
|
||||||
|
|||||||
@@ -221,10 +221,22 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: loginSoundTimer
|
||||||
|
// Half a second delay before playing login sound, otherwise the sound may be cut off
|
||||||
|
// 50 is the minimum that seems to work, but 500 is safer
|
||||||
|
interval: 500
|
||||||
|
repeat: false
|
||||||
|
onTriggered: {
|
||||||
|
AudioService.playLoginSoundIfApplicable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
dockRecreateDebounce.start();
|
dockRecreateDebounce.start();
|
||||||
// Force PolkitService singleton to initialize
|
// Force PolkitService singleton to initialize
|
||||||
PolkitService.polkitAvailable;
|
PolkitService.polkitAvailable;
|
||||||
|
loginSoundTimer.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
Loader {
|
Loader {
|
||||||
|
|||||||
@@ -369,9 +369,7 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function previous(): void {
|
function previous(): void {
|
||||||
if (MprisController.activePlayer && MprisController.activePlayer.canGoPrevious) {
|
MprisController.previousOrRewind();
|
||||||
MprisController.activePlayer.previous();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function next(): void {
|
function next(): void {
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
StyledText {
|
StyledText {
|
||||||
text: I18n.tr("No recent clipboard entries found")
|
text: clipboardContent.modal.clipboardAvailable ? I18n.tr("No recent clipboard entries found") : I18n.tr("Connecting to clipboard service…")
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
font.pixelSize: Theme.fontSizeMedium
|
font.pixelSize: Theme.fontSizeMedium
|
||||||
color: Theme.surfaceVariantText
|
color: Theme.surfaceVariantText
|
||||||
@@ -181,7 +181,7 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
StyledText {
|
StyledText {
|
||||||
text: I18n.tr("No saved clipboard entries")
|
text: clipboardContent.modal.clipboardAvailable ? I18n.tr("No saved clipboard entries") : I18n.tr("Connecting to clipboard service…")
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
font.pixelSize: Theme.fontSizeMedium
|
font.pixelSize: Theme.fontSizeMedium
|
||||||
color: Theme.surfaceVariantText
|
color: Theme.surfaceVariantText
|
||||||
|
|||||||
@@ -60,15 +60,12 @@ DankModal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function show() {
|
function show() {
|
||||||
if (!clipboardAvailable) {
|
|
||||||
ToastService.showError(I18n.tr("Clipboard service not available"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
open();
|
open();
|
||||||
activeImageLoads = 0;
|
activeImageLoads = 0;
|
||||||
shouldHaveFocus = true;
|
shouldHaveFocus = true;
|
||||||
ClipboardService.reset();
|
ClipboardService.reset();
|
||||||
ClipboardService.refresh();
|
if (clipboardAvailable)
|
||||||
|
ClipboardService.refresh();
|
||||||
keyboardController.reset();
|
keyboardController.reset();
|
||||||
|
|
||||||
Qt.callLater(function () {
|
Qt.callLater(function () {
|
||||||
|
|||||||
@@ -50,14 +50,11 @@ DankPopout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function show() {
|
function show() {
|
||||||
if (!clipboardAvailable) {
|
|
||||||
ToastService.showError(I18n.tr("Clipboard service not available"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
open();
|
open();
|
||||||
activeImageLoads = 0;
|
activeImageLoads = 0;
|
||||||
ClipboardService.reset();
|
ClipboardService.reset();
|
||||||
ClipboardService.refresh();
|
if (clipboardAvailable)
|
||||||
|
ClipboardService.refresh();
|
||||||
keyboardController.reset();
|
keyboardController.reset();
|
||||||
|
|
||||||
Qt.callLater(function () {
|
Qt.callLater(function () {
|
||||||
@@ -122,10 +119,10 @@ DankPopout {
|
|||||||
onBackgroundClicked: hide()
|
onBackgroundClicked: hide()
|
||||||
|
|
||||||
onShouldBeVisibleChanged: {
|
onShouldBeVisibleChanged: {
|
||||||
if (!shouldBeVisible) {
|
if (!shouldBeVisible)
|
||||||
return;
|
return;
|
||||||
}
|
if (clipboardAvailable)
|
||||||
ClipboardService.refresh();
|
ClipboardService.refresh();
|
||||||
keyboardController.reset();
|
keyboardController.reset();
|
||||||
Qt.callLater(function () {
|
Qt.callLater(function () {
|
||||||
if (contentLoader.item?.searchField) {
|
if (contentLoader.item?.searchField) {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ Item {
|
|||||||
property real animationOffset: Theme.spacingL
|
property real animationOffset: Theme.spacingL
|
||||||
property list<real> animationEnterCurve: Theme.expressiveCurves.expressiveDefaultSpatial
|
property list<real> animationEnterCurve: Theme.expressiveCurves.expressiveDefaultSpatial
|
||||||
property list<real> animationExitCurve: Theme.expressiveCurves.emphasized
|
property list<real> animationExitCurve: Theme.expressiveCurves.emphasized
|
||||||
property color backgroundColor: Theme.surfaceContainer
|
property color backgroundColor: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
|
||||||
property color borderColor: Theme.outlineMedium
|
property color borderColor: Theme.outlineMedium
|
||||||
property real borderWidth: 0
|
property real borderWidth: 0
|
||||||
property real cornerRadius: Theme.cornerRadius
|
property real cornerRadius: Theme.cornerRadius
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ DankModal {
|
|||||||
|
|
||||||
modalWidth: 680
|
modalWidth: 680
|
||||||
modalHeight: contentLoader.item ? contentLoader.item.implicitHeight + Theme.spacingM * 2 : 680
|
modalHeight: contentLoader.item ? contentLoader.item.implicitHeight + Theme.spacingM * 2 : 680
|
||||||
backgroundColor: Theme.surfaceContainer
|
backgroundColor: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
|
||||||
cornerRadius: Theme.cornerRadius
|
cornerRadius: Theme.cornerRadius
|
||||||
borderColor: Theme.outlineMedium
|
borderColor: Theme.outlineMedium
|
||||||
borderWidth: 1
|
borderWidth: 1
|
||||||
|
|||||||
@@ -311,7 +311,7 @@ FocusScope {
|
|||||||
|
|
||||||
Item {
|
Item {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
visible: !editMode
|
visible: !editMode && !(root.parentModal?.isClosing ?? false)
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: footerBar
|
id: footerBar
|
||||||
@@ -737,8 +737,6 @@ FocusScope {
|
|||||||
Item {
|
Item {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
height: parent.height - searchField.height - categoryRow.height - fileFilterRow.height - actionPanel.height - Theme.spacingXS * ((categoryRow.visible ? 1 : 0) + (fileFilterRow.visible ? 1 : 0) + 2)
|
height: parent.height - searchField.height - categoryRow.height - fileFilterRow.height - actionPanel.height - Theme.spacingXS * ((categoryRow.visible ? 1 : 0) + (fileFilterRow.visible ? 1 : 0) + 2)
|
||||||
opacity: root.parentModal?.isClosing ? 0 : 1
|
|
||||||
|
|
||||||
ResultsList {
|
ResultsList {
|
||||||
id: resultsList
|
id: resultsList
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
|
|||||||
@@ -324,6 +324,8 @@ Item {
|
|||||||
height: 24
|
height: 24
|
||||||
z: 100
|
z: 100
|
||||||
visible: {
|
visible: {
|
||||||
|
if (BlurService.enabled)
|
||||||
|
return false;
|
||||||
if (mainListView.contentHeight <= mainListView.height)
|
if (mainListView.contentHeight <= mainListView.height)
|
||||||
return false;
|
return false;
|
||||||
var atBottom = mainListView.contentY >= mainListView.contentHeight - mainListView.height + mainListView.originY - 5;
|
var atBottom = mainListView.contentY >= mainListView.contentHeight - mainListView.height + mainListView.originY - 5;
|
||||||
@@ -449,7 +451,7 @@ Item {
|
|||||||
case "apps":
|
case "apps":
|
||||||
return "apps";
|
return "apps";
|
||||||
default:
|
default:
|
||||||
return root.controller?.searchQuery?.length > 0 ? "search_off" : "search";
|
return "search_off";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -485,9 +487,9 @@ Item {
|
|||||||
case "plugins":
|
case "plugins":
|
||||||
return hasQuery ? I18n.tr("No plugin results") : I18n.tr("Browse or search plugins");
|
return hasQuery ? I18n.tr("No plugin results") : I18n.tr("Browse or search plugins");
|
||||||
case "apps":
|
case "apps":
|
||||||
return hasQuery ? I18n.tr("No apps found") : I18n.tr("Type to search apps");
|
return I18n.tr("No apps found");
|
||||||
default:
|
default:
|
||||||
return hasQuery ? I18n.tr("No results found") : I18n.tr("Type to search");
|
return I18n.tr("No results found");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ DankPopout {
|
|||||||
QtObject {
|
QtObject {
|
||||||
id: modalAdapter
|
id: modalAdapter
|
||||||
property bool spotlightOpen: appDrawerPopout.shouldBeVisible
|
property bool spotlightOpen: appDrawerPopout.shouldBeVisible
|
||||||
property bool isClosing: false
|
readonly property bool isClosing: !appDrawerPopout.shouldBeVisible
|
||||||
|
|
||||||
function hide() {
|
function hide() {
|
||||||
appDrawerPopout.close();
|
appDrawerPopout.close();
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ PluginComponent {
|
|||||||
id: detailRoot
|
id: detailRoot
|
||||||
implicitHeight: detailColumn.implicitHeight + Theme.spacingM * 2
|
implicitHeight: detailColumn.implicitHeight + Theme.spacingM * 2
|
||||||
radius: Theme.cornerRadius
|
radius: Theme.cornerRadius
|
||||||
color: Theme.surfaceContainerHigh
|
color: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
|
||||||
|
|
||||||
DankActionButton {
|
DankActionButton {
|
||||||
anchors.top: parent.top
|
anchors.top: parent.top
|
||||||
@@ -252,7 +252,7 @@ PluginComponent {
|
|||||||
width: parent ? parent.width : 300
|
width: parent ? parent.width : 300
|
||||||
height: 50
|
height: 50
|
||||||
radius: Theme.cornerRadius
|
radius: Theme.cornerRadius
|
||||||
color: Theme.surfaceContainerHighest
|
color: Theme.surfaceLight
|
||||||
border.width: 1
|
border.width: 1
|
||||||
border.color: Theme.outlineLight
|
border.color: Theme.outlineLight
|
||||||
opacity: 1.0
|
opacity: 1.0
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ Row {
|
|||||||
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||||
|
|
||||||
background: Rectangle {
|
background: Rectangle {
|
||||||
color: Theme.surfaceContainer
|
color: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
|
||||||
border.color: Theme.primarySelected
|
border.color: Theme.primarySelected
|
||||||
border.width: 0
|
border.width: 0
|
||||||
radius: Theme.cornerRadius
|
radius: Theme.cornerRadius
|
||||||
|
|||||||
@@ -207,9 +207,9 @@ Rectangle {
|
|||||||
width: parent.width
|
width: parent.width
|
||||||
height: 50
|
height: 50
|
||||||
radius: Theme.cornerRadius
|
radius: Theme.cornerRadius
|
||||||
color: deviceMouseArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : Theme.withAlpha(Theme.surfaceContainerHighest, Theme.popupTransparency)
|
color: deviceMouseArea.containsMouse ? Theme.primaryHoverLight : Theme.surfaceLight
|
||||||
border.color: modelData === AudioService.source ? Theme.primary : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
|
border.color: modelData === AudioService.source ? Theme.primary : Theme.outlineLight
|
||||||
border.width: 0
|
border.width: modelData === AudioService.source ? 2 : 1
|
||||||
|
|
||||||
Row {
|
Row {
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
|
|||||||
@@ -218,9 +218,9 @@ Rectangle {
|
|||||||
width: parent.width
|
width: parent.width
|
||||||
height: 50
|
height: 50
|
||||||
radius: Theme.cornerRadius
|
radius: Theme.cornerRadius
|
||||||
color: deviceMouseArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : Theme.withAlpha(Theme.surfaceContainerHighest, Theme.popupTransparency)
|
color: deviceMouseArea.containsMouse ? Theme.primaryHoverLight : Theme.surfaceLight
|
||||||
border.color: modelData === AudioService.sink ? Theme.primary : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
|
border.color: modelData === AudioService.sink ? Theme.primary : Theme.outlineLight
|
||||||
border.width: 0
|
border.width: modelData === AudioService.sink ? 2 : 1
|
||||||
|
|
||||||
DankRipple {
|
DankRipple {
|
||||||
id: deviceRipple
|
id: deviceRipple
|
||||||
@@ -397,9 +397,9 @@ Rectangle {
|
|||||||
width: parent.width
|
width: parent.width
|
||||||
height: 50
|
height: 50
|
||||||
radius: Theme.cornerRadius
|
radius: Theme.cornerRadius
|
||||||
color: Theme.withAlpha(Theme.surfaceContainerHighest, Theme.popupTransparency)
|
color: Theme.surfaceLight
|
||||||
border.color: modelData === AudioService.sink ? Theme.primary : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
|
border.color: modelData === AudioService.sink ? Theme.primary : Theme.outlineLight
|
||||||
border.width: 0
|
border.width: modelData === AudioService.sink ? 2 : 1
|
||||||
|
|
||||||
Row {
|
Row {
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
|
|||||||
@@ -129,8 +129,9 @@ Rectangle {
|
|||||||
width: (parent.width - Theme.spacingM) / 2
|
width: (parent.width - Theme.spacingM) / 2
|
||||||
height: 64
|
height: 64
|
||||||
radius: Theme.cornerRadius
|
radius: Theme.cornerRadius
|
||||||
color: Theme.withAlpha(Theme.surfaceContainerHighest, Theme.popupTransparency)
|
color: Theme.surfaceLight
|
||||||
border.width: 0
|
border.color: Theme.outlineLight
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
@@ -164,8 +165,9 @@ Rectangle {
|
|||||||
width: (parent.width - Theme.spacingM) / 2
|
width: (parent.width - Theme.spacingM) / 2
|
||||||
height: 64
|
height: 64
|
||||||
radius: Theme.cornerRadius
|
radius: Theme.cornerRadius
|
||||||
color: Theme.withAlpha(Theme.surfaceContainerHighest, Theme.popupTransparency)
|
color: Theme.surfaceLight
|
||||||
border.width: 0
|
border.color: Theme.outlineLight
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ Item {
|
|||||||
width: 320
|
width: 320
|
||||||
height: contentColumn.implicitHeight + Theme.spacingL * 2
|
height: contentColumn.implicitHeight + Theme.spacingL * 2
|
||||||
radius: Theme.cornerRadius
|
radius: Theme.cornerRadius
|
||||||
color: Theme.surfaceContainer
|
color: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
|
||||||
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
|
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
|
||||||
border.width: 0
|
border.width: 0
|
||||||
opacity: modalVisible ? 1 : 0
|
opacity: modalVisible ? 1 : 0
|
||||||
|
|||||||
@@ -229,7 +229,6 @@ Rectangle {
|
|||||||
width: parent.width
|
width: parent.width
|
||||||
height: 50
|
height: 50
|
||||||
radius: Theme.cornerRadius
|
radius: Theme.cornerRadius
|
||||||
border.width: 0
|
|
||||||
|
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
if (!isConnected)
|
if (!isConnected)
|
||||||
@@ -243,8 +242,8 @@ Rectangle {
|
|||||||
if (isConnecting)
|
if (isConnecting)
|
||||||
return Qt.rgba(Theme.warning.r, Theme.warning.g, Theme.warning.b, 0.12);
|
return Qt.rgba(Theme.warning.r, Theme.warning.g, Theme.warning.b, 0.12);
|
||||||
if (deviceMouseArea.containsMouse)
|
if (deviceMouseArea.containsMouse)
|
||||||
return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08);
|
return Theme.primaryHoverLight;
|
||||||
return Theme.withAlpha(Theme.surfaceContainerHighest, Theme.popupTransparency);
|
return Theme.surfaceLight;
|
||||||
}
|
}
|
||||||
|
|
||||||
border.color: {
|
border.color: {
|
||||||
@@ -252,8 +251,9 @@ Rectangle {
|
|||||||
return Theme.warning;
|
return Theme.warning;
|
||||||
if (isConnected)
|
if (isConnected)
|
||||||
return Theme.primary;
|
return Theme.primary;
|
||||||
return Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12);
|
return Theme.outlineLight;
|
||||||
}
|
}
|
||||||
|
border.width: (isConnecting || isConnected) ? 2 : 1
|
||||||
|
|
||||||
Row {
|
Row {
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
@@ -490,9 +490,9 @@ Rectangle {
|
|||||||
width: parent.width
|
width: parent.width
|
||||||
height: 50
|
height: 50
|
||||||
radius: Theme.cornerRadius
|
radius: Theme.cornerRadius
|
||||||
color: availableMouseArea.containsMouse && isInteractive ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : Theme.withAlpha(Theme.surfaceContainerHighest, Theme.popupTransparency)
|
color: availableMouseArea.containsMouse && isInteractive ? Theme.primaryHoverLight : Theme.surfaceLight
|
||||||
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
|
border.color: Theme.outlineLight
|
||||||
border.width: 0
|
border.width: 1
|
||||||
opacity: isInteractive ? 1 : 0.6
|
opacity: isInteractive ? 1 : 0.6
|
||||||
|
|
||||||
Row {
|
Row {
|
||||||
|
|||||||
@@ -79,9 +79,9 @@ Rectangle {
|
|||||||
width: parent.width
|
width: parent.width
|
||||||
height: 80
|
height: 80
|
||||||
radius: Theme.cornerRadius
|
radius: Theme.cornerRadius
|
||||||
color: Theme.withAlpha(Theme.surfaceContainerHighest, Theme.popupTransparency)
|
color: Theme.surfaceLight
|
||||||
border.color: modelData.mount === currentMountPath ? Theme.primary : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
|
border.color: modelData.mount === currentMountPath ? Theme.primary : Theme.outlineLight
|
||||||
border.width: modelData.mount === currentMountPath ? 2 : 0
|
border.width: modelData.mount === currentMountPath ? 2 : 1
|
||||||
|
|
||||||
Row {
|
Row {
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
|
|||||||
@@ -308,9 +308,9 @@ Rectangle {
|
|||||||
width: parent.width
|
width: parent.width
|
||||||
height: wiredContentRow.implicitHeight + Theme.spacingM * 2
|
height: wiredContentRow.implicitHeight + Theme.spacingM * 2
|
||||||
radius: Theme.cornerRadius
|
radius: Theme.cornerRadius
|
||||||
color: wiredNetworkMouseArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : Theme.withAlpha(Theme.surfaceContainerHighest, Theme.popupTransparency)
|
color: wiredNetworkMouseArea.containsMouse ? Theme.primaryHoverLight : Theme.surfaceLight
|
||||||
border.color: Theme.primary
|
border.color: isActive ? Theme.primary : Theme.outlineLight
|
||||||
border.width: 0
|
border.width: isActive ? 2 : 1
|
||||||
|
|
||||||
Row {
|
Row {
|
||||||
id: wiredContentRow
|
id: wiredContentRow
|
||||||
@@ -565,9 +565,9 @@ Rectangle {
|
|||||||
width: wifiContent.width
|
width: wifiContent.width
|
||||||
height: wifiContentRow.implicitHeight + Theme.spacingM * 2
|
height: wifiContentRow.implicitHeight + Theme.spacingM * 2
|
||||||
radius: Theme.cornerRadius
|
radius: Theme.cornerRadius
|
||||||
color: networkMouseArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : Theme.withAlpha(Theme.surfaceContainerHighest, Theme.popupTransparency)
|
color: networkMouseArea.containsMouse ? Theme.primaryHoverLight : Theme.surfaceLight
|
||||||
border.color: wifiDelegate.isConnected ? Theme.primary : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
|
border.color: wifiDelegate.isConnected ? Theme.primary : Theme.outlineLight
|
||||||
border.width: 0
|
border.width: wifiDelegate.isConnected ? 2 : 1
|
||||||
|
|
||||||
Row {
|
Row {
|
||||||
id: wifiContentRow
|
id: wifiContentRow
|
||||||
|
|||||||
@@ -969,6 +969,7 @@ Item {
|
|||||||
axis: barWindow.axis
|
axis: barWindow.axis
|
||||||
barSpacing: barConfig?.spacing ?? 4
|
barSpacing: barConfig?.spacing ?? 4
|
||||||
barConfig: topBarContent.barConfig
|
barConfig: topBarContent.barConfig
|
||||||
|
widgetData: parent.widgetData
|
||||||
isAutoHideBar: topBarContent.barConfig?.autoHide ?? false
|
isAutoHideBar: topBarContent.barConfig?.autoHide ?? false
|
||||||
isAtBottom: barWindow.axis?.edge === "bottom"
|
isAtBottom: barWindow.axis?.edge === "bottom"
|
||||||
visible: SettingsData.getFilteredScreens("systemTray").includes(barWindow.screen) && SystemTray.items.values.length > 0
|
visible: SettingsData.getFilteredScreens("systemTray").includes(barWindow.screen) && SystemTray.items.values.length > 0
|
||||||
@@ -1437,12 +1438,21 @@ Item {
|
|||||||
parentScreen: barWindow.screen
|
parentScreen: barWindow.screen
|
||||||
onClicked: {
|
onClicked: {
|
||||||
systemUpdateLoader.active = true;
|
systemUpdateLoader.active = true;
|
||||||
|
if (!systemUpdateLoader.item)
|
||||||
|
return;
|
||||||
|
const popout = systemUpdateLoader.item;
|
||||||
const effectiveBarConfig = topBarContent.barConfig;
|
const effectiveBarConfig = topBarContent.barConfig;
|
||||||
const barPosition = barWindow.axis?.edge === "left" ? 2 : (barWindow.axis?.edge === "right" ? 3 : (barWindow.axis?.edge === "top" ? 0 : 1));
|
const barPosition = barWindow.axis?.edge === "left" ? 2 : (barWindow.axis?.edge === "right" ? 3 : (barWindow.axis?.edge === "top" ? 0 : 1));
|
||||||
if (systemUpdateLoader.item && systemUpdateLoader.item.setBarContext) {
|
if (popout.setBarContext) {
|
||||||
systemUpdateLoader.item.setBarContext(barPosition, effectiveBarConfig?.bottomGap ?? 0);
|
popout.setBarContext(barPosition, effectiveBarConfig?.bottomGap ?? 0);
|
||||||
}
|
}
|
||||||
systemUpdateLoader.item?.toggle();
|
if (popout.setTriggerPosition) {
|
||||||
|
const globalPos = visualContent.mapToItem(null, 0, 0);
|
||||||
|
const currentScreen = parentScreen || Screen;
|
||||||
|
const pos = SettingsData.getPopupTriggerPosition(globalPos, currentScreen, barWindow.effectiveBarThickness, visualWidth, effectiveBarConfig?.spacing ?? 4, barPosition, effectiveBarConfig);
|
||||||
|
popout.setTriggerPosition(pos.x, pos.y, pos.width, section, currentScreen, barPosition, barWindow.effectiveBarThickness, effectiveBarConfig?.spacing ?? 4, effectiveBarConfig);
|
||||||
|
}
|
||||||
|
PopoutManager.requestPopout(popout, undefined, "systemUpdate");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
|
import Quickshell.Services.UPower
|
||||||
import qs.Common
|
import qs.Common
|
||||||
import qs.Modules.Plugins
|
import qs.Modules.Plugins
|
||||||
import qs.Services
|
import qs.Services
|
||||||
@@ -10,6 +11,8 @@ BasePill {
|
|||||||
property bool batteryPopupVisible: false
|
property bool batteryPopupVisible: false
|
||||||
property var popoutTarget: null
|
property var popoutTarget: null
|
||||||
|
|
||||||
|
property real touchpadAccumulator: 0
|
||||||
|
|
||||||
readonly property int barPosition: {
|
readonly property int barPosition: {
|
||||||
switch (axis?.edge) {
|
switch (axis?.edge) {
|
||||||
case "top":
|
case "top":
|
||||||
@@ -119,5 +122,44 @@ BasePill {
|
|||||||
battery.triggerRipple(this, mouse.x, mouse.y);
|
battery.triggerRipple(this, mouse.x, mouse.y);
|
||||||
toggleBatteryPopup();
|
toggleBatteryPopup();
|
||||||
}
|
}
|
||||||
|
onWheel: wheel => {
|
||||||
|
var delta = wheel.angleDelta.y;
|
||||||
|
if (delta === 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Check if this is a touchpad
|
||||||
|
if (delta !== 120 && delta !== -120) {
|
||||||
|
touchpadAccumulator += delta;
|
||||||
|
console.info("Acc: "+touchpadAccumulator);
|
||||||
|
if (Math.abs(touchpadAccumulator) < 500)
|
||||||
|
return;
|
||||||
|
delta = touchpadAccumulator;
|
||||||
|
touchpadAccumulator = 0;
|
||||||
|
}
|
||||||
|
console.info("Trigger! Delta: "+delta)
|
||||||
|
|
||||||
|
// This is after the other delta checks so it only shows on valid Y scroll
|
||||||
|
if (typeof PowerProfiles === "undefined") {
|
||||||
|
ToastService.showError("power-profiles-daemon not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get list of profiles, and current index
|
||||||
|
const profiles = [PowerProfile.PowerSaver, PowerProfile.Balanced].concat(PowerProfiles.hasPerformanceProfile ? [PowerProfile.Performance] : []);
|
||||||
|
var index = profiles.findIndex(profile => PowerProfiles.profile === profile);
|
||||||
|
|
||||||
|
// Step once based on mouse wheel direction
|
||||||
|
if (delta > 0) index += 1;
|
||||||
|
else index -= 1;
|
||||||
|
|
||||||
|
// Already at end of list, can't go further
|
||||||
|
if (index < 0 || index >= profiles.length) return;
|
||||||
|
|
||||||
|
// Set new profile
|
||||||
|
PowerProfiles.profile = profiles[index];
|
||||||
|
if (PowerProfiles.profile !== profiles[index]) {
|
||||||
|
ToastService.showError("Failed to set power profile");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ BasePill {
|
|||||||
StyledTextMetrics {
|
StyledTextMetrics {
|
||||||
id: cpuBaseline
|
id: cpuBaseline
|
||||||
font.pixelSize: Theme.barTextSize(root.barThickness, root.barConfig?.fontScale, root.barConfig?.maximizeWidgetText)
|
font.pixelSize: Theme.barTextSize(root.barThickness, root.barConfig?.fontScale, root.barConfig?.maximizeWidgetText)
|
||||||
text: "88%"
|
text: "100%"
|
||||||
}
|
}
|
||||||
|
|
||||||
StyledTextMetrics {
|
StyledTextMetrics {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ BasePill {
|
|||||||
property int availableWidth: 400
|
property int availableWidth: 400
|
||||||
readonly property int maxNormalWidth: 456
|
readonly property int maxNormalWidth: 456
|
||||||
readonly property int maxCompactWidth: 288
|
readonly property int maxCompactWidth: 288
|
||||||
readonly property Toplevel activeWindow: ToplevelManager.activeToplevel
|
property Toplevel activeWindow: null
|
||||||
property var activeDesktopEntry: null
|
property var activeDesktopEntry: null
|
||||||
property bool isHovered: mouseArea.containsMouse
|
property bool isHovered: mouseArea.containsMouse
|
||||||
property bool isAutoHideBar: false
|
property bool isAutoHideBar: false
|
||||||
@@ -38,10 +38,44 @@ BasePill {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateActiveWindow() {
|
||||||
|
const active = ToplevelManager.activeToplevel;
|
||||||
|
|
||||||
|
if (!active) {
|
||||||
|
// Only clear if our tracked window is no longer alive
|
||||||
|
if (activeWindow) {
|
||||||
|
const alive = ToplevelManager.toplevels?.values;
|
||||||
|
if (alive && !Array.from(alive).some(t => t === activeWindow))
|
||||||
|
activeWindow = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parentScreen || CompositorService.filterCurrentDisplay([active], parentScreen?.name)?.length > 0) {
|
||||||
|
activeWindow = active;
|
||||||
|
}
|
||||||
|
// else: active window is on a different screen so keep the previous value
|
||||||
|
}
|
||||||
|
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
|
updateActiveWindow();
|
||||||
updateDesktopEntry();
|
updateDesktopEntry();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: ToplevelManager
|
||||||
|
function onActiveToplevelChanged() {
|
||||||
|
root.updateActiveWindow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: CompositorService
|
||||||
|
function onToplevelsChanged() {
|
||||||
|
root.updateActiveWindow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Connections {
|
Connections {
|
||||||
target: DesktopEntries
|
target: DesktopEntries
|
||||||
function onApplicationsChanged() {
|
function onApplicationsChanged() {
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ BasePill {
|
|||||||
readonly property bool usePlayerVolume: activePlayer && activePlayer.volumeSupported && !__isChromeBrowser
|
readonly property bool usePlayerVolume: activePlayer && activePlayer.volumeSupported && !__isChromeBrowser
|
||||||
property bool compactMode: false
|
property bool compactMode: false
|
||||||
property var widgetData: null
|
property var widgetData: null
|
||||||
readonly property int textWidth: {
|
readonly property bool adaptiveWidthEnabled: SettingsData.mediaAdaptiveWidthEnabled
|
||||||
|
readonly property int maxTextWidth: {
|
||||||
const size = widgetData?.mediaSize !== undefined ? widgetData.mediaSize : SettingsData.mediaSize;
|
const size = widgetData?.mediaSize !== undefined ? widgetData.mediaSize : SettingsData.mediaSize;
|
||||||
switch (size) {
|
switch (size) {
|
||||||
case 0:
|
case 0:
|
||||||
@@ -36,10 +37,7 @@ BasePill {
|
|||||||
if (isVerticalOrientation) {
|
if (isVerticalOrientation) {
|
||||||
return widgetThickness - horizontalPadding * 2;
|
return widgetThickness - horizontalPadding * 2;
|
||||||
}
|
}
|
||||||
const controlsWidth = 20 + Theme.spacingXS + 24 + Theme.spacingXS + 20;
|
return 0;
|
||||||
const audioVizWidth = 20;
|
|
||||||
const contentWidth = audioVizWidth + Theme.spacingXS + controlsWidth;
|
|
||||||
return contentWidth + (textWidth > 0 ? textWidth + Theme.spacingXS : 0);
|
|
||||||
}
|
}
|
||||||
readonly property int currentContentHeight: {
|
readonly property int currentContentHeight: {
|
||||||
if (!isVerticalOrientation) {
|
if (!isVerticalOrientation) {
|
||||||
@@ -99,7 +97,7 @@ BasePill {
|
|||||||
|
|
||||||
if (isMouseWheelY) {
|
if (isMouseWheelY) {
|
||||||
if (deltaY > 0) {
|
if (deltaY > 0) {
|
||||||
activePlayer.previous();
|
MprisController.previousOrRewind();
|
||||||
} else {
|
} else {
|
||||||
activePlayer.next();
|
activePlayer.next();
|
||||||
}
|
}
|
||||||
@@ -107,7 +105,7 @@ BasePill {
|
|||||||
scrollAccumulatorY += deltaY;
|
scrollAccumulatorY += deltaY;
|
||||||
if (Math.abs(scrollAccumulatorY) >= touchpadThreshold) {
|
if (Math.abs(scrollAccumulatorY) >= touchpadThreshold) {
|
||||||
if (scrollAccumulatorY > 0) {
|
if (scrollAccumulatorY > 0) {
|
||||||
activePlayer.previous();
|
MprisController.previousOrRewind();
|
||||||
} else {
|
} else {
|
||||||
activePlayer.next();
|
activePlayer.next();
|
||||||
}
|
}
|
||||||
@@ -119,7 +117,28 @@ BasePill {
|
|||||||
|
|
||||||
content: Component {
|
content: Component {
|
||||||
Item {
|
Item {
|
||||||
implicitWidth: root.playerAvailable ? root.currentContentWidth : 0
|
id: contentRoot
|
||||||
|
readonly property real measuredTextWidth: {
|
||||||
|
if (!root.playerAvailable || root.maxTextWidth <= 0 || !textContainer.visible)
|
||||||
|
return 0;
|
||||||
|
// Preserve the fixed-width text slot even if metadata is briefly empty.
|
||||||
|
if (!root.adaptiveWidthEnabled)
|
||||||
|
return root.maxTextWidth;
|
||||||
|
if (textContainer.displayText.length === 0)
|
||||||
|
return 0;
|
||||||
|
const rawWidth = mediaText.contentWidth;
|
||||||
|
if (!isFinite(rawWidth) || rawWidth <= 0)
|
||||||
|
return 0;
|
||||||
|
return Math.min(root.maxTextWidth, Math.ceil(rawWidth));
|
||||||
|
}
|
||||||
|
readonly property int horizontalContentWidth: {
|
||||||
|
const controlsWidth = 20 + Theme.spacingXS + 24 + Theme.spacingXS + 20;
|
||||||
|
const audioVizWidth = 20;
|
||||||
|
const baseWidth = audioVizWidth + Theme.spacingXS + controlsWidth;
|
||||||
|
return baseWidth + (measuredTextWidth > 0 ? measuredTextWidth + Theme.spacingXS : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
implicitWidth: root.playerAvailable ? (root.isVerticalOrientation ? root.currentContentWidth : horizontalContentWidth) : 0
|
||||||
implicitHeight: root.playerAvailable ? root.currentContentHeight : 0
|
implicitHeight: root.playerAvailable ? root.currentContentHeight : 0
|
||||||
opacity: root.playerAvailable ? 1 : 0
|
opacity: root.playerAvailable ? 1 : 0
|
||||||
|
|
||||||
@@ -132,8 +151,9 @@ BasePill {
|
|||||||
|
|
||||||
Behavior on implicitWidth {
|
Behavior on implicitWidth {
|
||||||
NumberAnimation {
|
NumberAnimation {
|
||||||
duration: Theme.shortDuration
|
duration: Theme.mediumDuration
|
||||||
easing.type: Theme.standardEasing
|
easing.type: Easing.BezierSpline
|
||||||
|
easing.bezierCurve: Theme.expressiveCurves.emphasizedDecel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,7 +234,7 @@ BasePill {
|
|||||||
if (mouse.button === Qt.LeftButton) {
|
if (mouse.button === Qt.LeftButton) {
|
||||||
activePlayer.togglePlaying();
|
activePlayer.togglePlaying();
|
||||||
} else if (mouse.button === Qt.MiddleButton) {
|
} else if (mouse.button === Qt.MiddleButton) {
|
||||||
activePlayer.previous();
|
MprisController.previousOrRewind();
|
||||||
} else if (mouse.button === Qt.RightButton) {
|
} else if (mouse.button === Qt.RightButton) {
|
||||||
activePlayer.next();
|
activePlayer.next();
|
||||||
}
|
}
|
||||||
@@ -269,7 +289,7 @@ BasePill {
|
|||||||
}
|
}
|
||||||
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
width: textWidth
|
width: contentRoot.measuredTextWidth
|
||||||
height: root.widgetThickness
|
height: root.widgetThickness
|
||||||
visible: {
|
visible: {
|
||||||
const size = widgetData?.mediaSize !== undefined ? widgetData.mediaSize : SettingsData.mediaSize;
|
const size = widgetData?.mediaSize !== undefined ? widgetData.mediaSize : SettingsData.mediaSize;
|
||||||
@@ -278,50 +298,95 @@ BasePill {
|
|||||||
clip: true
|
clip: true
|
||||||
color: "transparent"
|
color: "transparent"
|
||||||
|
|
||||||
StyledText {
|
Behavior on width {
|
||||||
id: mediaText
|
NumberAnimation {
|
||||||
property bool needsScrolling: implicitWidth > textContainer.width && SettingsData.scrollTitleEnabled
|
duration: Theme.mediumDuration
|
||||||
property real scrollOffset: 0
|
easing.type: Easing.BezierSpline
|
||||||
|
easing.bezierCurve: Theme.expressiveCurves.emphasizedDecel
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
text: textContainer.displayText
|
|
||||||
font.pixelSize: Theme.barTextSize(root.barThickness, root.barConfig?.fontScale, root.barConfig?.maximizeWidgetText)
|
|
||||||
color: Theme.widgetTextColor
|
|
||||||
wrapMode: Text.NoWrap
|
|
||||||
x: needsScrolling ? -scrollOffset : 0
|
|
||||||
onTextChanged: {
|
|
||||||
scrollOffset = 0;
|
|
||||||
scrollAnimation.restart();
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SequentialAnimation {
|
Item {
|
||||||
id: scrollAnimation
|
id: textClip
|
||||||
running: mediaText.needsScrolling && textContainer.visible
|
anchors.fill: parent
|
||||||
loops: Animation.Infinite
|
clip: true
|
||||||
|
|
||||||
PauseAnimation {
|
StyledText {
|
||||||
duration: 2000
|
id: mediaText
|
||||||
|
property bool needsScrolling: implicitWidth > textContainer.width && SettingsData.scrollTitleEnabled
|
||||||
|
property real scrollOffset: 0
|
||||||
|
property real textShift: 0
|
||||||
|
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: textContainer.displayText
|
||||||
|
font.pixelSize: Theme.barTextSize(root.barThickness, root.barConfig?.fontScale, root.barConfig?.maximizeWidgetText)
|
||||||
|
color: Theme.widgetTextColor
|
||||||
|
wrapMode: Text.NoWrap
|
||||||
|
x: (needsScrolling ? -scrollOffset : 0) + textShift
|
||||||
|
opacity: 1
|
||||||
|
|
||||||
|
onTextChanged: {
|
||||||
|
scrollOffset = 0;
|
||||||
|
textShift = 0;
|
||||||
|
scrollAnimation.restart();
|
||||||
|
textChangeAnimation.restart();
|
||||||
}
|
}
|
||||||
|
|
||||||
NumberAnimation {
|
SequentialAnimation {
|
||||||
target: mediaText
|
id: scrollAnimation
|
||||||
property: "scrollOffset"
|
running: mediaText.needsScrolling && textContainer.visible
|
||||||
from: 0
|
loops: Animation.Infinite
|
||||||
to: mediaText.implicitWidth - textContainer.width + 5
|
|
||||||
duration: Math.max(1000, (mediaText.implicitWidth - textContainer.width + 5) * 60)
|
PauseAnimation {
|
||||||
easing.type: Easing.Linear
|
duration: 2000
|
||||||
|
}
|
||||||
|
|
||||||
|
NumberAnimation {
|
||||||
|
target: mediaText
|
||||||
|
property: "scrollOffset"
|
||||||
|
from: 0
|
||||||
|
to: mediaText.implicitWidth - textContainer.width + 5
|
||||||
|
duration: Math.max(1000, (mediaText.implicitWidth - textContainer.width + 5) * 60)
|
||||||
|
easing.type: Easing.Linear
|
||||||
|
}
|
||||||
|
|
||||||
|
PauseAnimation {
|
||||||
|
duration: 2000
|
||||||
|
}
|
||||||
|
|
||||||
|
NumberAnimation {
|
||||||
|
target: mediaText
|
||||||
|
property: "scrollOffset"
|
||||||
|
to: 0
|
||||||
|
duration: Math.max(1000, (mediaText.implicitWidth - textContainer.width + 5) * 60)
|
||||||
|
easing.type: Easing.Linear
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
PauseAnimation {
|
SequentialAnimation {
|
||||||
duration: 2000
|
id: textChangeAnimation
|
||||||
}
|
|
||||||
|
|
||||||
NumberAnimation {
|
ParallelAnimation {
|
||||||
target: mediaText
|
NumberAnimation {
|
||||||
property: "scrollOffset"
|
target: mediaText
|
||||||
to: 0
|
property: "opacity"
|
||||||
duration: Math.max(1000, (mediaText.implicitWidth - textContainer.width + 5) * 60)
|
from: 0.7
|
||||||
easing.type: Easing.Linear
|
to: 1
|
||||||
|
duration: Theme.shortDuration
|
||||||
|
easing.type: Easing.BezierSpline
|
||||||
|
easing.bezierCurve: Theme.expressiveCurves.emphasizedDecel
|
||||||
|
}
|
||||||
|
|
||||||
|
NumberAnimation {
|
||||||
|
target: mediaText
|
||||||
|
property: "textShift"
|
||||||
|
from: 4
|
||||||
|
to: 0
|
||||||
|
duration: Theme.shortDuration
|
||||||
|
easing.type: Easing.BezierSpline
|
||||||
|
easing.bezierCurve: Theme.expressiveCurves.emphasizedDecel
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -370,11 +435,7 @@ BasePill {
|
|||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
enabled: root.playerAvailable
|
enabled: root.playerAvailable
|
||||||
cursorShape: Qt.PointingHandCursor
|
cursorShape: Qt.PointingHandCursor
|
||||||
onClicked: {
|
onClicked: MprisController.previousOrRewind()
|
||||||
if (activePlayer) {
|
|
||||||
activePlayer.previous();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -271,7 +271,7 @@ BasePill {
|
|||||||
radius: Theme.cornerRadius
|
radius: Theme.cornerRadius
|
||||||
color: {
|
color: {
|
||||||
if (isFocused) {
|
if (isFocused) {
|
||||||
return mouseArea.containsMouse ? Theme.primarySelected : Theme.withAlpha(Theme.primary, 0.2);
|
return mouseArea.containsMouse ? Theme.primarySelected : Theme.withAlpha(Theme.primary, 0.45);
|
||||||
}
|
}
|
||||||
return mouseArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent";
|
return mouseArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent";
|
||||||
}
|
}
|
||||||
@@ -526,7 +526,7 @@ BasePill {
|
|||||||
radius: Theme.cornerRadius
|
radius: Theme.cornerRadius
|
||||||
color: {
|
color: {
|
||||||
if (isFocused) {
|
if (isFocused) {
|
||||||
return mouseArea.containsMouse ? Theme.primarySelected : Theme.withAlpha(Theme.primary, 0.2);
|
return mouseArea.containsMouse ? Theme.primarySelected : Theme.withAlpha(Theme.primary, 0.45);
|
||||||
}
|
}
|
||||||
return mouseArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent";
|
return mouseArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,11 @@ BasePill {
|
|||||||
enableCursor: false
|
enableCursor: false
|
||||||
|
|
||||||
property var parentWindow: null
|
property var parentWindow: null
|
||||||
|
property var widgetData: null
|
||||||
|
property string section: "right"
|
||||||
property bool isAtBottom: false
|
property bool isAtBottom: false
|
||||||
property bool isAutoHideBar: false
|
property bool isAutoHideBar: false
|
||||||
|
property bool useOverflowPopup: !widgetData?.trayUseInlineExpansion
|
||||||
readonly property var hiddenTrayIds: {
|
readonly property var hiddenTrayIds: {
|
||||||
const envValue = Quickshell.env("DMS_HIDE_TRAYIDS") || "";
|
const envValue = Quickshell.env("DMS_HIDE_TRAYIDS") || "";
|
||||||
return envValue ? envValue.split(",").map(id => id.trim().toLowerCase()) : [];
|
return envValue ? envValue.split(",").map(id => id.trim().toLowerCase()) : [];
|
||||||
@@ -40,6 +43,76 @@ BasePill {
|
|||||||
return `${id}::${tooltipTitle}`;
|
return `${id}::${tooltipTitle}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function trayIconSourceFor(trayItem) {
|
||||||
|
let icon = trayItem && trayItem.icon;
|
||||||
|
if (typeof icon === 'string' || icon instanceof String) {
|
||||||
|
if (icon === "")
|
||||||
|
return "";
|
||||||
|
if (icon.includes("?path=")) {
|
||||||
|
const split = icon.split("?path=");
|
||||||
|
if (split.length !== 2)
|
||||||
|
return icon;
|
||||||
|
const name = split[0];
|
||||||
|
const path = split[1];
|
||||||
|
let fileName = name.substring(name.lastIndexOf("/") + 1);
|
||||||
|
if (fileName.startsWith("dropboxstatus")) {
|
||||||
|
fileName = `hicolor/16x16/status/${fileName}`;
|
||||||
|
}
|
||||||
|
return `file://${path}/${fileName}`;
|
||||||
|
}
|
||||||
|
if (icon.startsWith("/") && !icon.startsWith("file://"))
|
||||||
|
return `file://${icon}`;
|
||||||
|
return icon;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateInlineTrayItem(trayItem, anchorItem) {
|
||||||
|
if (!trayItem)
|
||||||
|
return;
|
||||||
|
if (!trayItem.onlyMenu) {
|
||||||
|
trayItem.activate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!trayItem.hasMenu)
|
||||||
|
return;
|
||||||
|
root.showForTrayItem(trayItem, anchorItem, parentScreen, root.isAtBottom, root.isVerticalOrientation, root.axis);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openInlineTrayContextMenu(trayItem, areaItem, mouse, anchorItem) {
|
||||||
|
if (!trayItem) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!trayItem.hasMenu) {
|
||||||
|
const gp = areaItem.mapToGlobal(mouse.x, mouse.y);
|
||||||
|
root.callContextMenuFallback(trayItem.id, Math.round(gp.x), Math.round(gp.y));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
root.showForTrayItem(trayItem, anchorItem, parentScreen, root.isAtBottom, root.isVerticalOrientation, root.axis);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleIconName() {
|
||||||
|
const edge = root.axis?.edge;
|
||||||
|
if (root.useOverflowPopup) {
|
||||||
|
switch (edge) {
|
||||||
|
case "left":
|
||||||
|
return root.menuOpen ? "keyboard_arrow_left" : "keyboard_arrow_right";
|
||||||
|
case "right":
|
||||||
|
return root.menuOpen ? "keyboard_arrow_right" : "keyboard_arrow_left";
|
||||||
|
case "bottom":
|
||||||
|
return root.menuOpen ? "keyboard_arrow_down" : "keyboard_arrow_up";
|
||||||
|
case "top":
|
||||||
|
return root.menuOpen ? "keyboard_arrow_up" : "keyboard_arrow_down";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (edge === "left" || edge === "right") {
|
||||||
|
return root.menuOpen == (root.section !== "right") ? "keyboard_arrow_up" : "keyboard_arrow_down";
|
||||||
|
}
|
||||||
|
|
||||||
|
return root.menuOpen != (root.section === "right") ? "keyboard_arrow_left" : "keyboard_arrow_right";
|
||||||
|
}
|
||||||
|
|
||||||
// ! TODO - replace with either native dbus client (like plugins use) or just a DMS cli or something
|
// ! TODO - replace with either native dbus client (like plugins use) or just a DMS cli or something
|
||||||
function callContextMenuFallback(trayItemId, globalX, globalY) {
|
function callContextMenuFallback(trayItemId, globalX, globalY) {
|
||||||
const script = ['ITEMS=$(dbus-send --session --print-reply --dest=org.kde.StatusNotifierWatcher /StatusNotifierWatcher org.freedesktop.DBus.Properties.Get string:org.kde.StatusNotifierWatcher string:RegisteredStatusNotifierItems 2>/dev/null)', 'while IFS= read -r line; do', ' line="${line#*\\\"}"', ' line="${line%\\\"*}"', ' [ -z "$line" ] && continue', ' BUS="${line%%/*}"', ' OBJ="/${line#*/}"', ' ID=$(dbus-send --session --print-reply --dest="$BUS" "$OBJ" org.freedesktop.DBus.Properties.Get string:org.kde.StatusNotifierItem string:Id 2>/dev/null | grep -oP "(?<=\\\")(.*?)(?=\\\")" | tail -1)', ' if [ "$ID" = "$1" ]; then', ' dbus-send --session --type=method_call --dest="$BUS" "$OBJ" org.kde.StatusNotifierItem.ContextMenu int32:"$2" int32:"$3"', ' exit 0', ' fi', 'done <<< "$ITEMS"',].join("\n");
|
const script = ['ITEMS=$(dbus-send --session --print-reply --dest=org.kde.StatusNotifierWatcher /StatusNotifierWatcher org.freedesktop.DBus.Properties.Get string:org.kde.StatusNotifierWatcher string:RegisteredStatusNotifierItems 2>/dev/null)', 'while IFS= read -r line; do', ' line="${line#*\\\"}"', ' line="${line%\\\"*}"', ' [ -z "$line" ] && continue', ' BUS="${line%%/*}"', ' OBJ="/${line#*/}"', ' ID=$(dbus-send --session --print-reply --dest="$BUS" "$OBJ" org.freedesktop.DBus.Properties.Get string:org.kde.StatusNotifierItem string:Id 2>/dev/null | grep -oP "(?<=\\\")(.*?)(?=\\\")" | tail -1)', ' if [ "$ID" = "$1" ]; then', ' dbus-send --session --type=method_call --dest="$BUS" "$OBJ" org.kde.StatusNotifierItem.ContextMenu int32:"$2" int32:"$3"', ' exit 0', ' fi', 'done <<< "$ITEMS"',].join("\n");
|
||||||
@@ -78,6 +151,13 @@ BasePill {
|
|||||||
item: item
|
item: item
|
||||||
}))
|
}))
|
||||||
readonly property var hiddenBarItems: allSortedTrayItems.filter(item => SessionData.isHiddenTrayId(root.getTrayItemKey(item)))
|
readonly property var hiddenBarItems: allSortedTrayItems.filter(item => SessionData.isHiddenTrayId(root.getTrayItemKey(item)))
|
||||||
|
readonly property bool reverseInlineHorizontal: !useOverflowPopup && !isVerticalOrientation && section === "right"
|
||||||
|
readonly property bool reverseInlineVertical: !useOverflowPopup && isVerticalOrientation && section === "right"
|
||||||
|
readonly property var displayedMainBarItems: reverseInlineHorizontal ? [...mainBarItems].reverse() : mainBarItems
|
||||||
|
readonly property var displayedInlineExpandedItems: (reverseInlineHorizontal ? [...hiddenBarItems].reverse() : hiddenBarItems).map(item => ({
|
||||||
|
key: getTrayItemKey(item),
|
||||||
|
item: item
|
||||||
|
}))
|
||||||
|
|
||||||
function moveTrayItemInFullOrder(visibleFromIndex, visibleToIndex) {
|
function moveTrayItemInFullOrder(visibleFromIndex, visibleToIndex) {
|
||||||
if (visibleFromIndex === visibleToIndex || visibleFromIndex < 0 || visibleToIndex < 0)
|
if (visibleFromIndex === visibleToIndex || visibleFromIndex < 0 || visibleToIndex < 0)
|
||||||
@@ -103,6 +183,7 @@ BasePill {
|
|||||||
property int dropTargetIndex: -1
|
property int dropTargetIndex: -1
|
||||||
property bool suppressShiftAnimation: false
|
property bool suppressShiftAnimation: false
|
||||||
readonly property bool hasHiddenItems: allTrayItems.length > mainBarItems.length
|
readonly property bool hasHiddenItems: allTrayItems.length > mainBarItems.length
|
||||||
|
readonly property bool inlineExpanded: hasHiddenItems && !useOverflowPopup && menuOpen
|
||||||
visible: allTrayItems.length > 0
|
visible: allTrayItems.length > 0
|
||||||
opacity: allTrayItems.length > 0 ? 1 : 0
|
opacity: allTrayItems.length > 0 ? 1 : 0
|
||||||
|
|
||||||
@@ -198,10 +279,11 @@ BasePill {
|
|||||||
id: rowComp
|
id: rowComp
|
||||||
Row {
|
Row {
|
||||||
spacing: 0
|
spacing: 0
|
||||||
|
layoutDirection: root.reverseInlineHorizontal ? Qt.RightToLeft : Qt.LeftToRight
|
||||||
|
|
||||||
Repeater {
|
Repeater {
|
||||||
model: ScriptModel {
|
model: ScriptModel {
|
||||||
values: root.mainBarItems
|
values: root.displayedMainBarItems
|
||||||
objectProp: "key"
|
objectProp: "key"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,29 +291,7 @@ BasePill {
|
|||||||
id: delegateRoot
|
id: delegateRoot
|
||||||
property var trayItem: modelData.item
|
property var trayItem: modelData.item
|
||||||
property string itemKey: modelData.key
|
property string itemKey: modelData.key
|
||||||
property string iconSource: {
|
property string iconSource: root.trayIconSourceFor(trayItem)
|
||||||
let icon = trayItem && trayItem.icon;
|
|
||||||
if (typeof icon === 'string' || icon instanceof String) {
|
|
||||||
if (icon === "")
|
|
||||||
return "";
|
|
||||||
if (icon.includes("?path=")) {
|
|
||||||
const split = icon.split("?path=");
|
|
||||||
if (split.length !== 2)
|
|
||||||
return icon;
|
|
||||||
const name = split[0];
|
|
||||||
const path = split[1];
|
|
||||||
let fileName = name.substring(name.lastIndexOf("/") + 1);
|
|
||||||
if (fileName.startsWith("dropboxstatus")) {
|
|
||||||
fileName = `hicolor/16x16/status/${fileName}`;
|
|
||||||
}
|
|
||||||
return `file://${path}/${fileName}`;
|
|
||||||
}
|
|
||||||
if (icon.startsWith("/") && !icon.startsWith("file://"))
|
|
||||||
return `file://${icon}`;
|
|
||||||
return icon;
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
width: root.trayItemSize
|
width: root.trayItemSize
|
||||||
height: root.barThickness
|
height: root.barThickness
|
||||||
@@ -371,7 +431,8 @@ BasePill {
|
|||||||
}
|
}
|
||||||
if (!delegateRoot.trayItem.hasMenu)
|
if (!delegateRoot.trayItem.hasMenu)
|
||||||
return;
|
return;
|
||||||
root.menuOpen = false;
|
if (root.useOverflowPopup)
|
||||||
|
root.menuOpen = false;
|
||||||
root.showForTrayItem(delegateRoot.trayItem, visualContent, parentScreen, root.isAtBottom, root.isVerticalOrientation, root.axis);
|
root.showForTrayItem(delegateRoot.trayItem, visualContent, parentScreen, root.isAtBottom, root.isVerticalOrientation, root.axis);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,8 +441,8 @@ BasePill {
|
|||||||
const distance = Math.abs(mouse.x - dragHandler.dragStartPos.x);
|
const distance = Math.abs(mouse.x - dragHandler.dragStartPos.x);
|
||||||
if (distance > 5) {
|
if (distance > 5) {
|
||||||
dragHandler.dragging = true;
|
dragHandler.dragging = true;
|
||||||
root.draggedIndex = index;
|
root.draggedIndex = root.reverseInlineHorizontal ? (root.mainBarItems.length - 1 - index) : index;
|
||||||
root.dropTargetIndex = index;
|
root.dropTargetIndex = root.draggedIndex;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!dragHandler.dragging)
|
if (!dragHandler.dragging)
|
||||||
@@ -391,7 +452,8 @@ BasePill {
|
|||||||
dragHandler.dragAxisOffset = axisOffset;
|
dragHandler.dragAxisOffset = axisOffset;
|
||||||
const itemSize = root.trayItemSize;
|
const itemSize = root.trayItemSize;
|
||||||
const slotOffset = Math.round(axisOffset / itemSize);
|
const slotOffset = Math.round(axisOffset / itemSize);
|
||||||
const newTargetIndex = Math.max(0, Math.min(root.mainBarItems.length - 1, index + slotOffset));
|
const visualTargetIndex = Math.max(0, Math.min(root.mainBarItems.length - 1, index + slotOffset));
|
||||||
|
const newTargetIndex = root.reverseInlineHorizontal ? (root.mainBarItems.length - 1 - visualTargetIndex) : visualTargetIndex;
|
||||||
if (newTargetIndex !== root.dropTargetIndex) {
|
if (newTargetIndex !== root.dropTargetIndex) {
|
||||||
root.dropTargetIndex = newTargetIndex;
|
root.dropTargetIndex = newTargetIndex;
|
||||||
}
|
}
|
||||||
@@ -407,7 +469,8 @@ BasePill {
|
|||||||
root.callContextMenuFallback(delegateRoot.trayItem.id, Math.round(gp.x), Math.round(gp.y));
|
root.callContextMenuFallback(delegateRoot.trayItem.id, Math.round(gp.x), Math.round(gp.y));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
root.menuOpen = false;
|
if (root.useOverflowPopup)
|
||||||
|
root.menuOpen = false;
|
||||||
root.showForTrayItem(delegateRoot.trayItem, visualContent, parentScreen, root.isAtBottom, root.isVerticalOrientation, root.axis);
|
root.showForTrayItem(delegateRoot.trayItem, visualContent, parentScreen, root.isAtBottom, root.isVerticalOrientation, root.axis);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -429,7 +492,7 @@ BasePill {
|
|||||||
|
|
||||||
DankIcon {
|
DankIcon {
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
name: root.menuOpen ? "expand_less" : "expand_more"
|
name: root.toggleIconName()
|
||||||
size: Theme.barIconSize(root.barThickness, undefined, root.barConfig?.maximizeWidgetIcons, root.barConfig?.iconScale)
|
size: Theme.barIconSize(root.barThickness, undefined, root.barConfig?.maximizeWidgetIcons, root.barConfig?.iconScale)
|
||||||
color: Theme.widgetTextColor
|
color: Theme.widgetTextColor
|
||||||
}
|
}
|
||||||
@@ -451,6 +514,301 @@ BasePill {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: ScriptModel {
|
||||||
|
values: root.displayedInlineExpandedItems
|
||||||
|
objectProp: "key"
|
||||||
|
}
|
||||||
|
|
||||||
|
delegate: inlineExpandedTrayItemDelegate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component {
|
||||||
|
id: inlineExpandedTrayItemDelegate
|
||||||
|
|
||||||
|
Item {
|
||||||
|
property var trayItem: modelData.item
|
||||||
|
property string itemKey: modelData.key
|
||||||
|
property string iconSource: root.trayIconSourceFor(trayItem)
|
||||||
|
|
||||||
|
width: root.isVerticalOrientation ? root.barThickness : (root.inlineExpanded ? root.trayItemSize : 0)
|
||||||
|
height: root.isVerticalOrientation ? (root.inlineExpanded ? root.trayItemSize : 0) : root.barThickness
|
||||||
|
visible: width > 0 || height > 0
|
||||||
|
|
||||||
|
Behavior on width {
|
||||||
|
enabled: !root.isVerticalOrientation
|
||||||
|
NumberAnimation {
|
||||||
|
duration: Theme.shortDuration
|
||||||
|
easing.type: Theme.standardEasing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Behavior on height {
|
||||||
|
enabled: root.isVerticalOrientation
|
||||||
|
NumberAnimation {
|
||||||
|
duration: Theme.shortDuration
|
||||||
|
easing.type: Theme.standardEasing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: inlineVisualContent
|
||||||
|
width: root.trayItemSize
|
||||||
|
height: root.trayItemSize
|
||||||
|
x: root.isVerticalOrientation ? Math.round((parent.width - width) / 2) : (root.reverseInlineHorizontal ? parent.width - width : 0)
|
||||||
|
y: root.isVerticalOrientation ? (root.reverseInlineVertical ? parent.height - height : 0) : Math.round((parent.height - height) / 2)
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: inlineTrayItemArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent"
|
||||||
|
opacity: root.inlineExpanded ? 1 : 0
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
NumberAnimation {
|
||||||
|
duration: Theme.shortDuration
|
||||||
|
easing.type: Theme.standardEasing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconImage {
|
||||||
|
id: inlineIconImg
|
||||||
|
anchors.centerIn: parent
|
||||||
|
width: Theme.barIconSize(root.barThickness, undefined, root.barConfig?.maximizeWidgetIcons, root.barConfig?.iconScale)
|
||||||
|
height: Theme.barIconSize(root.barThickness, undefined, root.barConfig?.maximizeWidgetIcons, root.barConfig?.iconScale)
|
||||||
|
source: iconSource
|
||||||
|
asynchronous: true
|
||||||
|
smooth: true
|
||||||
|
mipmap: true
|
||||||
|
visible: status === Image.Ready
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
visible: !inlineIconImg.visible
|
||||||
|
text: {
|
||||||
|
const itemId = trayItem?.id || "";
|
||||||
|
if (!itemId)
|
||||||
|
return "?";
|
||||||
|
return itemId.charAt(0).toUpperCase();
|
||||||
|
}
|
||||||
|
font.pixelSize: 10
|
||||||
|
color: Theme.widgetTextColor
|
||||||
|
}
|
||||||
|
|
||||||
|
DankRipple {
|
||||||
|
id: inlineItemRipple
|
||||||
|
cornerRadius: Theme.cornerRadius
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: inlineTrayItemArea
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
enabled: root.inlineExpanded
|
||||||
|
|
||||||
|
onPressed: mouse => {
|
||||||
|
const pos = mapToItem(inlineVisualContent, mouse.x, mouse.y);
|
||||||
|
inlineItemRipple.trigger(pos.x, pos.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
onClicked: mouse => {
|
||||||
|
if (mouse.button === Qt.LeftButton) {
|
||||||
|
root.activateInlineTrayItem(trayItem, inlineVisualContent);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mouse.button !== Qt.RightButton)
|
||||||
|
return;
|
||||||
|
root.openInlineTrayContextMenu(trayItem, inlineTrayItemArea, mouse, inlineVisualContent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component {
|
||||||
|
id: verticalMainTrayItemDelegate
|
||||||
|
|
||||||
|
Item {
|
||||||
|
property var trayItem: modelData.item
|
||||||
|
property string itemKey: modelData.key
|
||||||
|
property string iconSource: root.trayIconSourceFor(trayItem)
|
||||||
|
|
||||||
|
width: root.barThickness
|
||||||
|
height: root.trayItemSize
|
||||||
|
z: dragHandler.dragging ? 100 : 0
|
||||||
|
|
||||||
|
property real shiftOffset: {
|
||||||
|
if (root.draggedIndex < 0)
|
||||||
|
return 0;
|
||||||
|
if (index === root.draggedIndex)
|
||||||
|
return 0;
|
||||||
|
const dragIdx = root.draggedIndex;
|
||||||
|
const dropIdx = root.dropTargetIndex;
|
||||||
|
const shiftAmount = root.trayItemSize;
|
||||||
|
if (dropIdx < 0)
|
||||||
|
return 0;
|
||||||
|
if (dragIdx < dropIdx && index > dragIdx && index <= dropIdx)
|
||||||
|
return -shiftAmount;
|
||||||
|
if (dragIdx > dropIdx && index >= dropIdx && index < dragIdx)
|
||||||
|
return shiftAmount;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
transform: Translate {
|
||||||
|
y: shiftOffset
|
||||||
|
Behavior on y {
|
||||||
|
enabled: !root.suppressShiftAnimation
|
||||||
|
NumberAnimation {
|
||||||
|
duration: 150
|
||||||
|
easing.type: Easing.OutCubic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: dragHandler
|
||||||
|
anchors.fill: parent
|
||||||
|
property bool dragging: false
|
||||||
|
property point dragStartPos: Qt.point(0, 0)
|
||||||
|
property real dragAxisOffset: 0
|
||||||
|
property bool longPressing: false
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: longPressTimer
|
||||||
|
interval: 400
|
||||||
|
repeat: false
|
||||||
|
onTriggered: dragHandler.longPressing = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: visualContent
|
||||||
|
width: root.trayItemSize
|
||||||
|
height: root.trayItemSize
|
||||||
|
anchors.centerIn: parent
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: trayItemArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent"
|
||||||
|
border.width: dragHandler.dragging ? 2 : 0
|
||||||
|
border.color: Theme.primary
|
||||||
|
opacity: dragHandler.dragging ? 0.8 : 1.0
|
||||||
|
|
||||||
|
transform: Translate {
|
||||||
|
y: dragHandler.dragging ? dragHandler.dragAxisOffset : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
IconImage {
|
||||||
|
id: iconImg
|
||||||
|
anchors.centerIn: parent
|
||||||
|
width: Theme.barIconSize(root.barThickness, undefined, root.barConfig?.maximizeWidgetIcons, root.barConfig?.iconScale)
|
||||||
|
height: Theme.barIconSize(root.barThickness, undefined, root.barConfig?.maximizeWidgetIcons, root.barConfig?.iconScale)
|
||||||
|
source: iconSource
|
||||||
|
asynchronous: true
|
||||||
|
smooth: true
|
||||||
|
mipmap: true
|
||||||
|
visible: status === Image.Ready
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
visible: !iconImg.visible
|
||||||
|
text: {
|
||||||
|
const itemId = trayItem?.id || "";
|
||||||
|
if (!itemId)
|
||||||
|
return "?";
|
||||||
|
return itemId.charAt(0).toUpperCase();
|
||||||
|
}
|
||||||
|
font.pixelSize: 10
|
||||||
|
color: Theme.widgetTextColor
|
||||||
|
}
|
||||||
|
|
||||||
|
DankRipple {
|
||||||
|
id: itemRipple
|
||||||
|
cornerRadius: Theme.cornerRadius
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: trayItemArea
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||||
|
cursorShape: dragHandler.longPressing ? Qt.DragMoveCursor : Qt.PointingHandCursor
|
||||||
|
|
||||||
|
onPressed: mouse => {
|
||||||
|
const pos = mapToItem(visualContent, mouse.x, mouse.y);
|
||||||
|
itemRipple.trigger(pos.x, pos.y);
|
||||||
|
if (mouse.button === Qt.LeftButton) {
|
||||||
|
dragHandler.dragStartPos = Qt.point(mouse.x, mouse.y);
|
||||||
|
longPressTimer.start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onReleased: mouse => {
|
||||||
|
longPressTimer.stop();
|
||||||
|
const wasDragging = dragHandler.dragging;
|
||||||
|
const didReorder = wasDragging && root.dropTargetIndex >= 0 && root.dropTargetIndex !== root.draggedIndex;
|
||||||
|
|
||||||
|
if (didReorder) {
|
||||||
|
root.suppressShiftAnimation = true;
|
||||||
|
root.moveTrayItemInFullOrder(root.draggedIndex, root.dropTargetIndex);
|
||||||
|
Qt.callLater(() => root.suppressShiftAnimation = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
dragHandler.longPressing = false;
|
||||||
|
dragHandler.dragging = false;
|
||||||
|
dragHandler.dragAxisOffset = 0;
|
||||||
|
root.draggedIndex = -1;
|
||||||
|
root.dropTargetIndex = -1;
|
||||||
|
|
||||||
|
if (wasDragging || mouse.button !== Qt.LeftButton)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!trayItem)
|
||||||
|
return;
|
||||||
|
if (!trayItem.onlyMenu) {
|
||||||
|
trayItem.activate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!trayItem.hasMenu)
|
||||||
|
return;
|
||||||
|
if (root.useOverflowPopup)
|
||||||
|
root.menuOpen = false;
|
||||||
|
root.showForTrayItem(trayItem, visualContent, parentScreen, root.isAtBottom, root.isVerticalOrientation, root.axis);
|
||||||
|
}
|
||||||
|
|
||||||
|
onPositionChanged: mouse => {
|
||||||
|
if (dragHandler.longPressing && !dragHandler.dragging) {
|
||||||
|
const distance = Math.abs(mouse.y - dragHandler.dragStartPos.y);
|
||||||
|
if (distance > 5) {
|
||||||
|
dragHandler.dragging = true;
|
||||||
|
root.draggedIndex = index;
|
||||||
|
root.dropTargetIndex = root.draggedIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!dragHandler.dragging)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const axisOffset = mouse.y - dragHandler.dragStartPos.y;
|
||||||
|
dragHandler.dragAxisOffset = axisOffset;
|
||||||
|
const itemSize = root.trayItemSize;
|
||||||
|
const slotOffset = Math.round(axisOffset / itemSize);
|
||||||
|
const newTargetIndex = Math.max(0, Math.min(root.mainBarItems.length - 1, index + slotOffset));
|
||||||
|
if (newTargetIndex !== root.dropTargetIndex) {
|
||||||
|
root.dropTargetIndex = newTargetIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onClicked: mouse => {
|
||||||
|
if (dragHandler.dragging)
|
||||||
|
return;
|
||||||
|
if (mouse.button !== Qt.RightButton)
|
||||||
|
return;
|
||||||
|
root.openInlineTrayContextMenu(trayItem, trayItemArea, mouse, visualContent);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,219 +817,23 @@ BasePill {
|
|||||||
Column {
|
Column {
|
||||||
spacing: 0
|
spacing: 0
|
||||||
|
|
||||||
|
// Column lacks layoutDirection, so we use four repeaters with mutually exclusive models to control whether main items or expanded items appear above/ below the toggle button.
|
||||||
|
// When reverseInlineVertical is true the first and third repeaters are empty and the second and fourth are active, and vice-versa.
|
||||||
|
// Because items are swapped between repeaters rather than reversed within a single list, vertical drag-and-drop indices don't need remapping (unlike the horizontal RightToLeft case).
|
||||||
Repeater {
|
Repeater {
|
||||||
model: ScriptModel {
|
model: ScriptModel {
|
||||||
values: root.mainBarItems
|
values: root.reverseInlineVertical ? [] : root.displayedMainBarItems
|
||||||
objectProp: "key"
|
objectProp: "key"
|
||||||
}
|
}
|
||||||
|
delegate: verticalMainTrayItemDelegate
|
||||||
|
}
|
||||||
|
|
||||||
delegate: Item {
|
Repeater {
|
||||||
id: delegateRoot
|
model: ScriptModel {
|
||||||
property var trayItem: modelData.item
|
values: root.reverseInlineVertical ? root.displayedInlineExpandedItems : []
|
||||||
property string itemKey: modelData.key
|
objectProp: "key"
|
||||||
property string iconSource: {
|
|
||||||
let icon = trayItem && trayItem.icon;
|
|
||||||
if (typeof icon === 'string' || icon instanceof String) {
|
|
||||||
if (icon === "")
|
|
||||||
return "";
|
|
||||||
if (icon.includes("?path=")) {
|
|
||||||
const split = icon.split("?path=");
|
|
||||||
if (split.length !== 2)
|
|
||||||
return icon;
|
|
||||||
const name = split[0];
|
|
||||||
const path = split[1];
|
|
||||||
let fileName = name.substring(name.lastIndexOf("/") + 1);
|
|
||||||
if (fileName.startsWith("dropboxstatus")) {
|
|
||||||
fileName = `hicolor/16x16/status/${fileName}`;
|
|
||||||
}
|
|
||||||
return `file://${path}/${fileName}`;
|
|
||||||
}
|
|
||||||
if (icon.startsWith("/") && !icon.startsWith("file://"))
|
|
||||||
return `file://${icon}`;
|
|
||||||
return icon;
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
width: root.barThickness
|
|
||||||
height: root.trayItemSize
|
|
||||||
z: dragHandler.dragging ? 100 : 0
|
|
||||||
|
|
||||||
property real shiftOffset: {
|
|
||||||
if (root.draggedIndex < 0)
|
|
||||||
return 0;
|
|
||||||
if (index === root.draggedIndex)
|
|
||||||
return 0;
|
|
||||||
const dragIdx = root.draggedIndex;
|
|
||||||
const dropIdx = root.dropTargetIndex;
|
|
||||||
const shiftAmount = root.trayItemSize;
|
|
||||||
if (dropIdx < 0)
|
|
||||||
return 0;
|
|
||||||
if (dragIdx < dropIdx && index > dragIdx && index <= dropIdx)
|
|
||||||
return -shiftAmount;
|
|
||||||
if (dragIdx > dropIdx && index >= dropIdx && index < dragIdx)
|
|
||||||
return shiftAmount;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
transform: Translate {
|
|
||||||
y: delegateRoot.shiftOffset
|
|
||||||
Behavior on y {
|
|
||||||
enabled: !root.suppressShiftAnimation
|
|
||||||
NumberAnimation {
|
|
||||||
duration: 150
|
|
||||||
easing.type: Easing.OutCubic
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Item {
|
|
||||||
id: dragHandler
|
|
||||||
anchors.fill: parent
|
|
||||||
property bool dragging: false
|
|
||||||
property point dragStartPos: Qt.point(0, 0)
|
|
||||||
property real dragAxisOffset: 0
|
|
||||||
property bool longPressing: false
|
|
||||||
|
|
||||||
Timer {
|
|
||||||
id: longPressTimer
|
|
||||||
interval: 400
|
|
||||||
repeat: false
|
|
||||||
onTriggered: dragHandler.longPressing = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
id: visualContent
|
|
||||||
width: root.trayItemSize
|
|
||||||
height: root.trayItemSize
|
|
||||||
anchors.centerIn: parent
|
|
||||||
radius: Theme.cornerRadius
|
|
||||||
color: trayItemArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent"
|
|
||||||
border.width: dragHandler.dragging ? 2 : 0
|
|
||||||
border.color: Theme.primary
|
|
||||||
opacity: dragHandler.dragging ? 0.8 : 1.0
|
|
||||||
|
|
||||||
transform: Translate {
|
|
||||||
y: dragHandler.dragging ? dragHandler.dragAxisOffset : 0
|
|
||||||
}
|
|
||||||
|
|
||||||
IconImage {
|
|
||||||
id: iconImg
|
|
||||||
anchors.centerIn: parent
|
|
||||||
width: Theme.barIconSize(root.barThickness, undefined, root.barConfig?.maximizeWidgetIcons, root.barConfig?.iconScale)
|
|
||||||
height: Theme.barIconSize(root.barThickness, undefined, root.barConfig?.maximizeWidgetIcons, root.barConfig?.iconScale)
|
|
||||||
source: delegateRoot.iconSource
|
|
||||||
asynchronous: true
|
|
||||||
smooth: true
|
|
||||||
mipmap: true
|
|
||||||
visible: status === Image.Ready
|
|
||||||
}
|
|
||||||
|
|
||||||
Text {
|
|
||||||
anchors.centerIn: parent
|
|
||||||
visible: !iconImg.visible
|
|
||||||
text: {
|
|
||||||
const itemId = trayItem?.id || "";
|
|
||||||
if (!itemId)
|
|
||||||
return "?";
|
|
||||||
return itemId.charAt(0).toUpperCase();
|
|
||||||
}
|
|
||||||
font.pixelSize: 10
|
|
||||||
color: Theme.widgetTextColor
|
|
||||||
}
|
|
||||||
|
|
||||||
DankRipple {
|
|
||||||
id: itemRipple
|
|
||||||
cornerRadius: Theme.cornerRadius
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
MouseArea {
|
|
||||||
id: trayItemArea
|
|
||||||
anchors.fill: parent
|
|
||||||
hoverEnabled: true
|
|
||||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
|
||||||
cursorShape: dragHandler.longPressing ? Qt.DragMoveCursor : Qt.PointingHandCursor
|
|
||||||
|
|
||||||
onPressed: mouse => {
|
|
||||||
const pos = mapToItem(visualContent, mouse.x, mouse.y);
|
|
||||||
itemRipple.trigger(pos.x, pos.y);
|
|
||||||
if (mouse.button === Qt.LeftButton) {
|
|
||||||
dragHandler.dragStartPos = Qt.point(mouse.x, mouse.y);
|
|
||||||
longPressTimer.start();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onReleased: mouse => {
|
|
||||||
longPressTimer.stop();
|
|
||||||
const wasDragging = dragHandler.dragging;
|
|
||||||
const didReorder = wasDragging && root.dropTargetIndex >= 0 && root.dropTargetIndex !== root.draggedIndex;
|
|
||||||
|
|
||||||
if (didReorder) {
|
|
||||||
root.suppressShiftAnimation = true;
|
|
||||||
root.moveTrayItemInFullOrder(root.draggedIndex, root.dropTargetIndex);
|
|
||||||
Qt.callLater(() => root.suppressShiftAnimation = false);
|
|
||||||
}
|
|
||||||
|
|
||||||
dragHandler.longPressing = false;
|
|
||||||
dragHandler.dragging = false;
|
|
||||||
dragHandler.dragAxisOffset = 0;
|
|
||||||
root.draggedIndex = -1;
|
|
||||||
root.dropTargetIndex = -1;
|
|
||||||
|
|
||||||
if (wasDragging || mouse.button !== Qt.LeftButton)
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (!delegateRoot.trayItem)
|
|
||||||
return;
|
|
||||||
if (!delegateRoot.trayItem.onlyMenu) {
|
|
||||||
delegateRoot.trayItem.activate();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!delegateRoot.trayItem.hasMenu)
|
|
||||||
return;
|
|
||||||
root.menuOpen = false;
|
|
||||||
root.showForTrayItem(delegateRoot.trayItem, visualContent, parentScreen, root.isAtBottom, root.isVerticalOrientation, root.axis);
|
|
||||||
}
|
|
||||||
|
|
||||||
onPositionChanged: mouse => {
|
|
||||||
if (dragHandler.longPressing && !dragHandler.dragging) {
|
|
||||||
const distance = Math.abs(mouse.y - dragHandler.dragStartPos.y);
|
|
||||||
if (distance > 5) {
|
|
||||||
dragHandler.dragging = true;
|
|
||||||
root.draggedIndex = index;
|
|
||||||
root.dropTargetIndex = index;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!dragHandler.dragging)
|
|
||||||
return;
|
|
||||||
|
|
||||||
const axisOffset = mouse.y - dragHandler.dragStartPos.y;
|
|
||||||
dragHandler.dragAxisOffset = axisOffset;
|
|
||||||
const itemSize = root.trayItemSize;
|
|
||||||
const slotOffset = Math.round(axisOffset / itemSize);
|
|
||||||
const newTargetIndex = Math.max(0, Math.min(root.mainBarItems.length - 1, index + slotOffset));
|
|
||||||
if (newTargetIndex !== root.dropTargetIndex) {
|
|
||||||
root.dropTargetIndex = newTargetIndex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onClicked: mouse => {
|
|
||||||
if (dragHandler.dragging)
|
|
||||||
return;
|
|
||||||
if (mouse.button !== Qt.RightButton)
|
|
||||||
return;
|
|
||||||
if (!delegateRoot.trayItem?.hasMenu) {
|
|
||||||
const gp = trayItemArea.mapToGlobal(mouse.x, mouse.y);
|
|
||||||
root.callContextMenuFallback(delegateRoot.trayItem.id, Math.round(gp.x), Math.round(gp.y));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
root.menuOpen = false;
|
|
||||||
root.showForTrayItem(delegateRoot.trayItem, visualContent, parentScreen, root.isAtBottom, root.isVerticalOrientation, root.axis);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
delegate: inlineExpandedTrayItemDelegate
|
||||||
}
|
}
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
@@ -689,14 +851,7 @@ BasePill {
|
|||||||
|
|
||||||
DankIcon {
|
DankIcon {
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
name: {
|
name: root.toggleIconName()
|
||||||
const edge = root.axis?.edge;
|
|
||||||
if (edge === "left") {
|
|
||||||
return root.menuOpen ? "chevron_left" : "chevron_right";
|
|
||||||
} else {
|
|
||||||
return root.menuOpen ? "chevron_right" : "chevron_left";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
size: Theme.barIconSize(root.barThickness, undefined, root.barConfig?.maximizeWidgetIcons, root.barConfig?.iconScale)
|
size: Theme.barIconSize(root.barThickness, undefined, root.barConfig?.maximizeWidgetIcons, root.barConfig?.iconScale)
|
||||||
color: Theme.widgetTextColor
|
color: Theme.widgetTextColor
|
||||||
}
|
}
|
||||||
@@ -718,6 +873,22 @@ BasePill {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: ScriptModel {
|
||||||
|
values: root.reverseInlineVertical ? [] : root.displayedInlineExpandedItems
|
||||||
|
objectProp: "key"
|
||||||
|
}
|
||||||
|
delegate: inlineExpandedTrayItemDelegate
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: ScriptModel {
|
||||||
|
values: root.reverseInlineVertical ? root.displayedMainBarItems : []
|
||||||
|
objectProp: "key"
|
||||||
|
}
|
||||||
|
delegate: verticalMainTrayItemDelegate
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -733,7 +904,7 @@ BasePill {
|
|||||||
blurRadius: Theme.cornerRadius
|
blurRadius: Theme.cornerRadius
|
||||||
}
|
}
|
||||||
|
|
||||||
visible: root.menuOpen
|
visible: root.useOverflowPopup && root.menuOpen
|
||||||
screen: root.parentScreen
|
screen: root.parentScreen
|
||||||
WlrLayershell.layer: WlrLayershell.Top
|
WlrLayershell.layer: WlrLayershell.Top
|
||||||
WlrLayershell.exclusiveZone: -1
|
WlrLayershell.exclusiveZone: -1
|
||||||
@@ -749,13 +920,14 @@ BasePill {
|
|||||||
|
|
||||||
HyprlandFocusGrab {
|
HyprlandFocusGrab {
|
||||||
windows: [overflowMenu]
|
windows: [overflowMenu]
|
||||||
active: CompositorService.useHyprlandFocusGrab && root.menuOpen
|
active: CompositorService.useHyprlandFocusGrab && root.useOverflowPopup && root.menuOpen
|
||||||
}
|
}
|
||||||
|
|
||||||
Connections {
|
Connections {
|
||||||
target: PopoutManager
|
target: PopoutManager
|
||||||
function onPopoutOpening() {
|
function onPopoutOpening() {
|
||||||
root.menuOpen = false;
|
if (root.useOverflowPopup)
|
||||||
|
root.menuOpen = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1021,30 +1193,7 @@ BasePill {
|
|||||||
|
|
||||||
delegate: Rectangle {
|
delegate: Rectangle {
|
||||||
property var trayItem: modelData
|
property var trayItem: modelData
|
||||||
property string iconSource: {
|
property string iconSource: root.trayIconSourceFor(trayItem)
|
||||||
let icon = trayItem?.icon;
|
|
||||||
if (typeof icon === 'string' || icon instanceof String) {
|
|
||||||
if (icon === "")
|
|
||||||
return "";
|
|
||||||
if (icon.includes("?path=")) {
|
|
||||||
const split = icon.split("?path=");
|
|
||||||
if (split.length !== 2)
|
|
||||||
return icon;
|
|
||||||
const name = split[0];
|
|
||||||
const path = split[1];
|
|
||||||
let fileName = name.substring(name.lastIndexOf("/") + 1);
|
|
||||||
if (fileName.startsWith("dropboxstatus")) {
|
|
||||||
fileName = `hicolor/16x16/status/${fileName}`;
|
|
||||||
}
|
|
||||||
return `file://${path}/${fileName}`;
|
|
||||||
}
|
|
||||||
if (icon.startsWith("/") && !icon.startsWith("file://")) {
|
|
||||||
return `file://${icon}`;
|
|
||||||
}
|
|
||||||
return icon;
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
width: root.trayItemSize + 4
|
width: root.trayItemSize + 4
|
||||||
height: root.trayItemSize + 4
|
height: root.trayItemSize + 4
|
||||||
@@ -1313,7 +1462,8 @@ BasePill {
|
|||||||
onVisibleChanged: {
|
onVisibleChanged: {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
updatePosition();
|
updatePosition();
|
||||||
root.menuOpen = false;
|
if (root.useOverflowPopup)
|
||||||
|
root.menuOpen = false;
|
||||||
PopoutManager.closeAllPopouts();
|
PopoutManager.closeAllPopouts();
|
||||||
ModalManager.closeAllModalsExcept(null);
|
ModalManager.closeAllModalsExcept(null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,46 @@ Item {
|
|||||||
property var blurBarWindow: null
|
property var blurBarWindow: null
|
||||||
property var hyprlandOverviewLoader: null
|
property var hyprlandOverviewLoader: null
|
||||||
property var parentScreen: null
|
property var parentScreen: null
|
||||||
|
|
||||||
|
readonly property real _leftMargin: {
|
||||||
|
if (isVertical)
|
||||||
|
return 0;
|
||||||
|
root.x;
|
||||||
|
if (!root.parent)
|
||||||
|
return 0;
|
||||||
|
const gap = root.mapToItem(null, 0, 0).x;
|
||||||
|
return (gap > 0 && gap < 30) ? gap + 5 : 0;
|
||||||
|
}
|
||||||
|
readonly property real _rightMargin: {
|
||||||
|
if (isVertical)
|
||||||
|
return 0;
|
||||||
|
root.x;
|
||||||
|
root.width;
|
||||||
|
if (!root.parent || !blurBarWindow)
|
||||||
|
return 0;
|
||||||
|
const gap = blurBarWindow.width - root.mapToItem(null, root.width, 0).x;
|
||||||
|
return (gap > 0 && gap < 30) ? gap + 5 : 0;
|
||||||
|
}
|
||||||
|
readonly property real _topMargin: {
|
||||||
|
if (!isVertical)
|
||||||
|
return 0;
|
||||||
|
root.y;
|
||||||
|
if (!root.parent)
|
||||||
|
return 0;
|
||||||
|
const gap = root.mapToItem(null, 0, 0).y;
|
||||||
|
return (gap > 0 && gap < 30) ? gap + 5 : 0;
|
||||||
|
}
|
||||||
|
readonly property real _bottomMargin: {
|
||||||
|
if (!isVertical)
|
||||||
|
return 0;
|
||||||
|
root.y;
|
||||||
|
root.height;
|
||||||
|
if (!root.parent || !blurBarWindow)
|
||||||
|
return 0;
|
||||||
|
const gap = blurBarWindow.height - root.mapToItem(null, 0, root.height).y;
|
||||||
|
return (gap > 0 && gap < 30) ? gap + 5 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
property int _desktopEntriesUpdateTrigger: 0
|
property int _desktopEntriesUpdateTrigger: 0
|
||||||
readonly property var sortedToplevels: {
|
readonly property var sortedToplevels: {
|
||||||
return CompositorService.filterCurrentWorkspace(CompositorService.sortedToplevels, screenName);
|
return CompositorService.filterCurrentWorkspace(CompositorService.sortedToplevels, screenName);
|
||||||
@@ -539,6 +579,60 @@ Item {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function switchToWorkspaceByModelData(data) {
|
||||||
|
if (!data)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (root.useExtWorkspace && (data.id || data.name)) {
|
||||||
|
ExtWorkspaceService.activateWorkspace(data.id || data.name, data.groupID || "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (CompositorService.compositor) {
|
||||||
|
case "niri":
|
||||||
|
if (data.idx !== undefined)
|
||||||
|
NiriService.switchToWorkspace(data.idx);
|
||||||
|
break;
|
||||||
|
case "hyprland":
|
||||||
|
if (data.id)
|
||||||
|
Hyprland.dispatch(`workspace ${data.id}`);
|
||||||
|
break;
|
||||||
|
case "dwl":
|
||||||
|
if (data.tag !== undefined)
|
||||||
|
DwlService.switchToTag(root.screenName, data.tag);
|
||||||
|
break;
|
||||||
|
case "sway":
|
||||||
|
case "scroll":
|
||||||
|
case "miracle":
|
||||||
|
if (data.num)
|
||||||
|
try {
|
||||||
|
I3.dispatch(`workspace number ${data.num}`);
|
||||||
|
} catch (_) {}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findClosestWorkspaceIndex(localX, localY) {
|
||||||
|
if (workspaceRepeater.count === 0)
|
||||||
|
return -1;
|
||||||
|
|
||||||
|
let closestIdx = -1;
|
||||||
|
let closestDist = Infinity;
|
||||||
|
|
||||||
|
for (let i = 0; i < workspaceRepeater.count; i++) {
|
||||||
|
const item = workspaceRepeater.itemAt(i);
|
||||||
|
if (!item)
|
||||||
|
continue;
|
||||||
|
const center = item.mapToItem(root, item.width / 2, item.height / 2);
|
||||||
|
const dist = isVertical ? Math.abs(localY - center.y) : Math.abs(localX - center.x);
|
||||||
|
if (dist < closestDist) {
|
||||||
|
closestDist = dist;
|
||||||
|
closestIdx = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return closestIdx;
|
||||||
|
}
|
||||||
|
|
||||||
function switchWorkspace(direction) {
|
function switchWorkspace(direction) {
|
||||||
if (useExtWorkspace) {
|
if (useExtWorkspace) {
|
||||||
const realWorkspaces = getRealWorkspaces();
|
const realWorkspaces = getRealWorkspaces();
|
||||||
@@ -752,8 +846,15 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
MouseArea {
|
MouseArea {
|
||||||
anchors.fill: parent
|
id: edgeMouseArea
|
||||||
acceptedButtons: Qt.RightButton
|
z: -1
|
||||||
|
x: -root._leftMargin
|
||||||
|
y: -root._topMargin
|
||||||
|
width: root.width + root._leftMargin + root._rightMargin
|
||||||
|
height: root.height + root._topMargin + root._bottomMargin
|
||||||
|
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
|
||||||
property real touchpadAccumulator: 0
|
property real touchpadAccumulator: 0
|
||||||
property real mouseAccumulator: 0
|
property real mouseAccumulator: 0
|
||||||
@@ -766,12 +867,20 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onClicked: mouse => {
|
onClicked: mouse => {
|
||||||
if (mouse.button === Qt.RightButton) {
|
const rootPos = edgeMouseArea.mapToItem(root, mouse.x, mouse.y);
|
||||||
|
switch (mouse.button) {
|
||||||
|
case Qt.RightButton:
|
||||||
if (CompositorService.isNiri) {
|
if (CompositorService.isNiri) {
|
||||||
NiriService.toggleOverview();
|
NiriService.toggleOverview();
|
||||||
} else if (CompositorService.isHyprland && root.hyprlandOverviewLoader?.item) {
|
} else if (CompositorService.isHyprland && root.hyprlandOverviewLoader?.item) {
|
||||||
root.hyprlandOverviewLoader.item.overviewOpen = !root.hyprlandOverviewLoader.item.overviewOpen;
|
root.hyprlandOverviewLoader.item.overviewOpen = !root.hyprlandOverviewLoader.item.overviewOpen;
|
||||||
}
|
}
|
||||||
|
break;
|
||||||
|
case Qt.LeftButton:
|
||||||
|
const idx = root.findClosestWorkspaceIndex(rootPos.x, rootPos.y);
|
||||||
|
if (idx >= 0)
|
||||||
|
root.switchToWorkspaceByModelData(root.workspaceList[idx]);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ DankPopout {
|
|||||||
if (currentPlayer && currentPlayer !== player && currentPlayer.canPause) {
|
if (currentPlayer && currentPlayer !== player && currentPlayer.canPause) {
|
||||||
currentPlayer.pause();
|
currentPlayer.pause();
|
||||||
}
|
}
|
||||||
MprisController.activePlayer = player;
|
MprisController.setActivePlayer(player);
|
||||||
root.__hideDropdowns();
|
root.__hideDropdowns();
|
||||||
}
|
}
|
||||||
onDeviceSelected: device => {
|
onDeviceSelected: device => {
|
||||||
|
|||||||
@@ -487,17 +487,7 @@ Item {
|
|||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
cursorShape: Qt.PointingHandCursor
|
cursorShape: Qt.PointingHandCursor
|
||||||
onClicked: {
|
onClicked: MprisController.previousOrRewind()
|
||||||
if (!activePlayer) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activePlayer.position > 8 && activePlayer.canSeek) {
|
|
||||||
activePlayer.position = 0;
|
|
||||||
} else {
|
|
||||||
activePlayer.previous();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,14 +145,7 @@ Card {
|
|||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
cursorShape: Qt.PointingHandCursor
|
cursorShape: Qt.PointingHandCursor
|
||||||
onClicked: {
|
onClicked: MprisController.previousOrRewind()
|
||||||
if (!activePlayer) return
|
|
||||||
if (activePlayer.position > 8 && activePlayer.canSeek) {
|
|
||||||
activePlayer.position = 0
|
|
||||||
} else {
|
|
||||||
activePlayer.previous()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1338,7 +1338,7 @@ Item {
|
|||||||
enabled: MprisController.activePlayer?.canGoPrevious ?? false
|
enabled: MprisController.activePlayer?.canGoPrevious ?? false
|
||||||
hoverEnabled: enabled
|
hoverEnabled: enabled
|
||||||
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||||
onClicked: MprisController.activePlayer?.previous()
|
onClicked: MprisController.previousOrRewind()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,13 @@ Item {
|
|||||||
onToggled: checked => SettingsData.set("audioVisualizerEnabled", checked)
|
onToggled: checked => SettingsData.set("audioVisualizerEnabled", checked)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SettingsToggleRow {
|
||||||
|
text: I18n.tr("Adaptive Media Width")
|
||||||
|
description: I18n.tr("Shrink the media widget to fit shorter song titles while still respecting the configured maximum size")
|
||||||
|
checked: SettingsData.mediaAdaptiveWidthEnabled
|
||||||
|
onToggled: checked => SettingsData.set("mediaAdaptiveWidthEnabled", checked)
|
||||||
|
}
|
||||||
|
|
||||||
SettingsDropdownRow {
|
SettingsDropdownRow {
|
||||||
property var scrollOptsInternal: ["volume", "song", "nothing"]
|
property var scrollOptsInternal: ["volume", "song", "nothing"]
|
||||||
property var scrollOptsDisplay: [I18n.tr("Change Volume", "media scroll wheel option"), I18n.tr("Change Song", "media scroll wheel option"), I18n.tr("Nothing", "media scroll wheel option")]
|
property var scrollOptsDisplay: [I18n.tr("Change Volume", "media scroll wheel option"), I18n.tr("Change Song", "media scroll wheel option"), I18n.tr("Nothing", "media scroll wheel option")]
|
||||||
|
|||||||
@@ -91,6 +91,16 @@ Item {
|
|||||||
visible: AudioService.gsettingsAvailable
|
visible: AudioService.gsettingsAvailable
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SettingsToggleRow {
|
||||||
|
tab: "sounds"
|
||||||
|
tags: ["sound", "login", "startup", "boot"]
|
||||||
|
settingKey: "soundLogin"
|
||||||
|
text: I18n.tr("Login")
|
||||||
|
description: I18n.tr("Play sound after logging in")
|
||||||
|
checked: SettingsData.soundLogin
|
||||||
|
onToggled: checked => SettingsData.set("soundLogin", checked)
|
||||||
|
}
|
||||||
|
|
||||||
SettingsToggleRow {
|
SettingsToggleRow {
|
||||||
tab: "sounds"
|
tab: "sounds"
|
||||||
tags: ["sound", "notification", "new"]
|
tags: ["sound", "notification", "new"]
|
||||||
|
|||||||
@@ -430,7 +430,7 @@ Item {
|
|||||||
"id": widget.id,
|
"id": widget.id,
|
||||||
"enabled": widget.enabled
|
"enabled": widget.enabled
|
||||||
};
|
};
|
||||||
var keys = ["size", "selectedGpuIndex", "pciId", "mountPath", "diskUsageMode", "minimumWidth", "showSwap", "showInGb", "mediaSize", "clockCompactMode", "focusedWindowCompactMode", "runningAppsCompactMode", "keyboardLayoutNameCompactMode", "runningAppsGroupByApp", "runningAppsCurrentWorkspace", "runningAppsCurrentMonitor", "showNetworkIcon", "showBluetoothIcon", "showAudioIcon", "showAudioPercent", "showVpnIcon", "showBrightnessIcon", "showBrightnessPercent", "showMicIcon", "showMicPercent", "showBatteryIcon", "showPrinterIcon", "showScreenSharingIcon", "controlCenterGroupOrder", "barMaxVisibleApps", "barMaxVisibleRunningApps", "barShowOverflowBadge"];
|
var keys = ["size", "selectedGpuIndex", "pciId", "mountPath", "diskUsageMode", "minimumWidth", "showSwap", "showInGb", "mediaSize", "clockCompactMode", "focusedWindowCompactMode", "runningAppsCompactMode", "keyboardLayoutNameCompactMode", "runningAppsGroupByApp", "runningAppsCurrentWorkspace", "runningAppsCurrentMonitor", "showNetworkIcon", "showBluetoothIcon", "showAudioIcon", "showAudioPercent", "showVpnIcon", "showBrightnessIcon", "showBrightnessPercent", "showMicIcon", "showMicPercent", "showBatteryIcon", "showPrinterIcon", "showScreenSharingIcon", "controlCenterGroupOrder", "barMaxVisibleApps", "barMaxVisibleRunningApps", "barShowOverflowBadge", "trayUseInlineExpansion"];
|
||||||
for (var i = 0; i < keys.length; i++) {
|
for (var i = 0; i < keys.length; i++) {
|
||||||
if (widget[keys[i]] !== undefined)
|
if (widget[keys[i]] !== undefined)
|
||||||
result[keys[i]] = widget[keys[i]];
|
result[keys[i]] = widget[keys[i]];
|
||||||
@@ -712,6 +712,8 @@ Item {
|
|||||||
item.barMaxVisibleRunningApps = widget.barMaxVisibleRunningApps;
|
item.barMaxVisibleRunningApps = widget.barMaxVisibleRunningApps;
|
||||||
if (widget.barShowOverflowBadge !== undefined)
|
if (widget.barShowOverflowBadge !== undefined)
|
||||||
item.barShowOverflowBadge = widget.barShowOverflowBadge;
|
item.barShowOverflowBadge = widget.barShowOverflowBadge;
|
||||||
|
if (widget.trayUseInlineExpansion !== undefined)
|
||||||
|
item.trayUseInlineExpansion = widget.trayUseInlineExpansion;
|
||||||
}
|
}
|
||||||
widgets.push(item);
|
widgets.push(item);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import QtQuick.Controls
|
import QtQuick.Controls
|
||||||
import QtQuick.Layouts
|
|
||||||
import qs.Common
|
import qs.Common
|
||||||
import qs.Widgets
|
import qs.Widgets
|
||||||
import qs.Services
|
import qs.Services
|
||||||
@@ -40,7 +39,7 @@ Column {
|
|||||||
"id": widget.id,
|
"id": widget.id,
|
||||||
"enabled": widget.enabled
|
"enabled": widget.enabled
|
||||||
};
|
};
|
||||||
var keys = ["size", "selectedGpuIndex", "pciId", "mountPath", "diskUsageMode", "minimumWidth", "showSwap", "showInGb", "mediaSize", "clockCompactMode", "focusedWindowCompactMode", "runningAppsCompactMode", "keyboardLayoutNameCompactMode", "runningAppsGroupByApp", "runningAppsCurrentWorkspace", "runningAppsCurrentMonitor", "showNetworkIcon", "showBluetoothIcon", "showAudioIcon", "showAudioPercent", "showVpnIcon", "showBrightnessIcon", "showBrightnessPercent", "showMicIcon", "showMicPercent", "showBatteryIcon", "showPrinterIcon", "showScreenSharingIcon", "controlCenterGroupOrder", "barMaxVisibleApps", "barMaxVisibleRunningApps", "barShowOverflowBadge"];
|
var keys = ["size", "selectedGpuIndex", "pciId", "mountPath", "diskUsageMode", "minimumWidth", "showSwap", "showInGb", "mediaSize", "clockCompactMode", "focusedWindowCompactMode", "runningAppsCompactMode", "keyboardLayoutNameCompactMode", "runningAppsGroupByApp", "runningAppsCurrentWorkspace", "runningAppsCurrentMonitor", "showNetworkIcon", "showBluetoothIcon", "showAudioIcon", "showAudioPercent", "showVpnIcon", "showBrightnessIcon", "showBrightnessPercent", "showMicIcon", "showMicPercent", "showBatteryIcon", "showPrinterIcon", "showScreenSharingIcon", "controlCenterGroupOrder", "barMaxVisibleApps", "barMaxVisibleRunningApps", "barShowOverflowBadge", "trayUseInlineExpansion"];
|
||||||
for (var i = 0; i < keys.length; i++) {
|
for (var i = 0; i < keys.length; i++) {
|
||||||
if (widget[keys[i]] !== undefined)
|
if (widget[keys[i]] !== undefined)
|
||||||
result[keys[i]] = widget[keys[i]];
|
result[keys[i]] = widget[keys[i]];
|
||||||
@@ -52,15 +51,14 @@ Column {
|
|||||||
height: implicitHeight
|
height: implicitHeight
|
||||||
spacing: Theme.spacingM
|
spacing: Theme.spacingM
|
||||||
|
|
||||||
RowLayout {
|
Row {
|
||||||
width: parent.width
|
|
||||||
spacing: Theme.spacingM
|
spacing: Theme.spacingM
|
||||||
|
|
||||||
DankIcon {
|
DankIcon {
|
||||||
name: root.titleIcon
|
name: root.titleIcon
|
||||||
size: Theme.iconSize
|
size: Theme.iconSize
|
||||||
color: Theme.primary
|
color: Theme.primary
|
||||||
Layout.alignment: Qt.AlignVCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
}
|
}
|
||||||
|
|
||||||
StyledText {
|
StyledText {
|
||||||
@@ -68,7 +66,7 @@ Column {
|
|||||||
font.pixelSize: Theme.fontSizeLarge
|
font.pixelSize: Theme.fontSizeLarge
|
||||||
font.weight: Font.Medium
|
font.weight: Font.Medium
|
||||||
color: Theme.surfaceText
|
color: Theme.surfaceText
|
||||||
Layout.alignment: Qt.AlignVCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -439,7 +437,7 @@ Column {
|
|||||||
|
|
||||||
Row {
|
Row {
|
||||||
spacing: Theme.spacingXS
|
spacing: Theme.spacingXS
|
||||||
visible: modelData.id === "clock" || modelData.id === "focusedWindow" || modelData.id === "keyboard_layout_name" || modelData.id === "appsDock"
|
visible: modelData.id === "clock" || modelData.id === "focusedWindow" || modelData.id === "keyboard_layout_name" || modelData.id === "appsDock" || modelData.id === "systemTray"
|
||||||
|
|
||||||
DankActionButton {
|
DankActionButton {
|
||||||
id: compactModeButton
|
id: compactModeButton
|
||||||
@@ -545,6 +543,39 @@ Column {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DankActionButton {
|
||||||
|
id: trayMenuButton
|
||||||
|
buttonSize: 32
|
||||||
|
visible: modelData.id === "systemTray"
|
||||||
|
iconName: "more_vert"
|
||||||
|
iconSize: 18
|
||||||
|
iconColor: Theme.outline
|
||||||
|
onClicked: {
|
||||||
|
trayContextMenu.widgetData = modelData;
|
||||||
|
trayContextMenu.sectionId = root.sectionId;
|
||||||
|
trayContextMenu.widgetIndex = index;
|
||||||
|
|
||||||
|
var buttonPos = trayMenuButton.mapToItem(root, 0, 0);
|
||||||
|
var popupWidth = trayContextMenu.width;
|
||||||
|
var popupHeight = trayContextMenu.height;
|
||||||
|
|
||||||
|
var xPos = buttonPos.x - popupWidth - Theme.spacingS;
|
||||||
|
if (xPos < 0)
|
||||||
|
xPos = buttonPos.x + trayMenuButton.width + Theme.spacingS;
|
||||||
|
|
||||||
|
var yPos = buttonPos.y - popupHeight / 2 + trayMenuButton.height / 2;
|
||||||
|
if (yPos < 0) {
|
||||||
|
yPos = Theme.spacingS;
|
||||||
|
} else if (yPos + popupHeight > root.height) {
|
||||||
|
yPos = root.height - popupHeight - Theme.spacingS;
|
||||||
|
}
|
||||||
|
|
||||||
|
trayContextMenu.x = xPos;
|
||||||
|
trayContextMenu.y = yPos;
|
||||||
|
trayContextMenu.open();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
id: compactModeTooltip
|
id: compactModeTooltip
|
||||||
width: tooltipText.contentWidth + Theme.spacingM * 2
|
width: tooltipText.contentWidth + Theme.spacingM * 2
|
||||||
@@ -933,6 +964,88 @@ Column {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Popup {
|
||||||
|
id: trayContextMenu
|
||||||
|
|
||||||
|
property var widgetData: null
|
||||||
|
property string sectionId: ""
|
||||||
|
property int widgetIndex: -1
|
||||||
|
readonly property var currentWidgetData: (widgetIndex >= 0 && widgetIndex < root.items.length) ? root.items[widgetIndex] : widgetData
|
||||||
|
|
||||||
|
width: 220
|
||||||
|
height: contentColumn.implicitHeight + Theme.spacingS * 2
|
||||||
|
padding: 0
|
||||||
|
modal: true
|
||||||
|
focus: true
|
||||||
|
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
color: Theme.surfaceContainer
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
|
||||||
|
border.width: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
contentItem: Item {
|
||||||
|
Column {
|
||||||
|
id: contentColumn
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Theme.spacingS
|
||||||
|
spacing: 2
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width
|
||||||
|
height: 32
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: trayOverflowArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: Theme.spacingS
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: Theme.spacingS
|
||||||
|
|
||||||
|
DankIcon {
|
||||||
|
name: "arrow_selector_tool"
|
||||||
|
size: 16
|
||||||
|
color: Theme.surfaceText
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
text: I18n.tr("Use Inline Expansion")
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: Theme.surfaceText
|
||||||
|
font.weight: Font.Normal
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DankToggle {
|
||||||
|
id: trayOverflowToggle
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: Theme.spacingS
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: 40
|
||||||
|
height: 20
|
||||||
|
checked: trayContextMenu.currentWidgetData?.trayUseInlineExpansion ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: trayOverflowArea
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: {
|
||||||
|
const newValue = !(trayContextMenu.currentWidgetData?.trayUseInlineExpansion ?? false);
|
||||||
|
root.overflowSettingChanged(trayContextMenu.sectionId, trayContextMenu.widgetIndex, "trayUseInlineExpansion", newValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Popup {
|
Popup {
|
||||||
id: diskUsageContextMenu
|
id: diskUsageContextMenu
|
||||||
|
|
||||||
@@ -981,10 +1094,26 @@ Column {
|
|||||||
|
|
||||||
Repeater {
|
Repeater {
|
||||||
model: [
|
model: [
|
||||||
{ label: I18n.tr("Percentage"), mode: 0, icon: "percent" },
|
{
|
||||||
{ label: I18n.tr("Total"), mode: 1, icon: "storage" },
|
label: I18n.tr("Percentage"),
|
||||||
{ label: I18n.tr("Remaining"), mode: 2, icon: "hourglass_empty" },
|
mode: 0,
|
||||||
{ label: I18n.tr("Remaining / Total"), mode: 3, icon: "pie_chart" }
|
icon: "percent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: I18n.tr("Total"),
|
||||||
|
mode: 1,
|
||||||
|
icon: "storage"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: I18n.tr("Remaining"),
|
||||||
|
mode: 2,
|
||||||
|
icon: "hourglass_empty"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: I18n.tr("Remaining / Total"),
|
||||||
|
mode: 3,
|
||||||
|
icon: "pie_chart"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
delegate: Rectangle {
|
delegate: Rectangle {
|
||||||
@@ -1316,20 +1445,7 @@ Column {
|
|||||||
id: longestControlCenterLabelMetrics
|
id: longestControlCenterLabelMetrics
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
text: {
|
text: {
|
||||||
const labels = [
|
const labels = [I18n.tr("Network"), I18n.tr("VPN"), I18n.tr("Bluetooth"), I18n.tr("Audio"), I18n.tr("Volume"), I18n.tr("Microphone"), I18n.tr("Microphone Volume"), I18n.tr("Brightness"), I18n.tr("Brightness Value"), I18n.tr("Battery"), I18n.tr("Printer"), I18n.tr("Screen Sharing")];
|
||||||
I18n.tr("Network"),
|
|
||||||
I18n.tr("VPN"),
|
|
||||||
I18n.tr("Bluetooth"),
|
|
||||||
I18n.tr("Audio"),
|
|
||||||
I18n.tr("Volume"),
|
|
||||||
I18n.tr("Microphone"),
|
|
||||||
I18n.tr("Microphone Volume"),
|
|
||||||
I18n.tr("Brightness"),
|
|
||||||
I18n.tr("Brightness Value"),
|
|
||||||
I18n.tr("Battery"),
|
|
||||||
I18n.tr("Printer"),
|
|
||||||
I18n.tr("Screen Sharing")
|
|
||||||
];
|
|
||||||
let longest = "";
|
let longest = "";
|
||||||
for (let i = 0; i < labels.length; i++) {
|
for (let i = 0; i < labels.length; i++) {
|
||||||
if (labels[i].length > longest.length)
|
if (labels[i].length > longest.length)
|
||||||
@@ -1340,6 +1456,7 @@ Column {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Repeater {
|
Repeater {
|
||||||
|
id: groupRepeater
|
||||||
model: controlCenterContextMenu.controlCenterGroups
|
model: controlCenterContextMenu.controlCenterGroups
|
||||||
|
|
||||||
delegate: Item {
|
delegate: Item {
|
||||||
@@ -1569,8 +1686,6 @@ Column {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
id: groupRepeater
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ Singleton {
|
|||||||
property var powerUnplugSound: null
|
property var powerUnplugSound: null
|
||||||
property var normalNotificationSound: null
|
property var normalNotificationSound: null
|
||||||
property var criticalNotificationSound: null
|
property var criticalNotificationSound: null
|
||||||
|
property var loginSound: null
|
||||||
property real notificationsVolume: 1.0
|
property real notificationsVolume: 1.0
|
||||||
property bool notificationsAudioMuted: false
|
property bool notificationsAudioMuted: false
|
||||||
|
|
||||||
@@ -67,6 +68,16 @@ Singleton {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Used in playLoginSoundIfApplicable()
|
||||||
|
Process {
|
||||||
|
id: loginSoundChecker
|
||||||
|
onExited: (exitCode) => {
|
||||||
|
if (exitCode === 0) {
|
||||||
|
playLoginSound();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getAvailableSinks() {
|
function getAvailableSinks() {
|
||||||
const hidden = SessionData.hiddenOutputDeviceNames ?? [];
|
const hidden = SessionData.hiddenOutputDeviceNames ?? [];
|
||||||
return Pipewire.nodes.values.filter(node => node.audio && node.isSink && !node.isStream && !hidden.includes(node.name));
|
return Pipewire.nodes.values.filter(node => node.audio && node.isSink && !node.isStream && !hidden.includes(node.name));
|
||||||
@@ -395,7 +406,7 @@ EOFCONFIG
|
|||||||
const themesToSearch = themeName !== "freedesktop" ? `${themeName} freedesktop` : themeName;
|
const themesToSearch = themeName !== "freedesktop" ? `${themeName} freedesktop` : themeName;
|
||||||
|
|
||||||
const script = `
|
const script = `
|
||||||
for event_key in audio-volume-change power-plug power-unplug message message-new-instant; do
|
for event_key in audio-volume-change power-plug power-unplug message message-new-instant desktop-login; do
|
||||||
found=0
|
found=0
|
||||||
|
|
||||||
case "$event_key" in
|
case "$event_key" in
|
||||||
@@ -457,7 +468,8 @@ EOFCONFIG
|
|||||||
"power-plug": "../assets/sounds/plasma/power-plug.wav",
|
"power-plug": "../assets/sounds/plasma/power-plug.wav",
|
||||||
"power-unplug": "../assets/sounds/plasma/power-unplug.wav",
|
"power-unplug": "../assets/sounds/plasma/power-unplug.wav",
|
||||||
"message": "../assets/sounds/freedesktop/message.wav",
|
"message": "../assets/sounds/freedesktop/message.wav",
|
||||||
"message-new-instant": "../assets/sounds/freedesktop/message-new-instant.wav"
|
"message-new-instant": "../assets/sounds/freedesktop/message-new-instant.wav",
|
||||||
|
"desktop-login": "../assets/sounds/freedesktop/desktop-login.wav"
|
||||||
};
|
};
|
||||||
|
|
||||||
const specialConditions = {
|
const specialConditions = {
|
||||||
@@ -551,6 +563,10 @@ EOFCONFIG
|
|||||||
criticalNotificationSound.destroy();
|
criticalNotificationSound.destroy();
|
||||||
criticalNotificationSound = null;
|
criticalNotificationSound = null;
|
||||||
}
|
}
|
||||||
|
if (loginSound) {
|
||||||
|
loginSound.destroy();
|
||||||
|
loginSound = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSoundPlayers() {
|
function createSoundPlayers() {
|
||||||
@@ -622,6 +638,19 @@ EOFCONFIG
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
`, root, "AudioService.CriticalNotificationSound");
|
`, root, "AudioService.CriticalNotificationSound");
|
||||||
|
|
||||||
|
const loginPath = getSoundPath("desktop-login");
|
||||||
|
loginSound = Qt.createQmlObject(`
|
||||||
|
import QtQuick
|
||||||
|
import QtMultimedia
|
||||||
|
MediaPlayer {
|
||||||
|
source: "${loginPath}"
|
||||||
|
audioOutput: AudioOutput {
|
||||||
|
${deviceProperty}volume: notificationsVolume
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`, root, "AudioService.LoginSound");
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("AudioService: Error creating sound players:", e);
|
console.warn("AudioService: Error creating sound players:", e);
|
||||||
}
|
}
|
||||||
@@ -661,6 +690,31 @@ EOFCONFIG
|
|||||||
criticalNotificationSound.play();
|
criticalNotificationSound.play();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function playLoginSound() {
|
||||||
|
if (!soundsAvailable || !loginSound || notificationsAudioMuted || isMediaPlaying()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loginSound.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
function playLoginSoundIfApplicable() {
|
||||||
|
if (SettingsData.soundsEnabled && SettingsData.soundLogin && !notificationsAudioMuted) {
|
||||||
|
// plays login sound on session start, but only if a specific file doesn't exist,
|
||||||
|
// to prevent it from playing on every DMS restart during the session
|
||||||
|
const runtimeDir = Quickshell.env("XDG_RUNTIME_DIR");
|
||||||
|
const sessionId = Quickshell.env("XDG_SESSION_ID") || "0";
|
||||||
|
|
||||||
|
if (!runtimeDir) return;
|
||||||
|
|
||||||
|
const loginFile = `${runtimeDir}/danklinux.login-${sessionId}`;
|
||||||
|
|
||||||
|
// if file doesn't exist, touch it (0)
|
||||||
|
// If it exists, do nothing (1)
|
||||||
|
loginSoundChecker.command = ["sh", "-c", `[ ! -f ${loginFile} ] && touch ${loginFile}`];
|
||||||
|
loginSoundChecker.running = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function playVolumeChangeSoundIfEnabled() {
|
function playVolumeChangeSoundIfEnabled() {
|
||||||
if (SettingsData.soundsEnabled && SettingsData.soundVolumeChanged && !notificationsAudioMuted) {
|
if (SettingsData.soundsEnabled && SettingsData.soundVolumeChanged && !notificationsAudioMuted) {
|
||||||
playVolumeChangeSound();
|
playVolumeChangeSound();
|
||||||
|
|||||||
@@ -3,13 +3,16 @@ pragma ComponentBehavior: Bound
|
|||||||
|
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import Quickshell
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
import Quickshell.Wayland // ! Import is needed despite what qmlls says
|
import Quickshell.Wayland // ! Import is needed despite what qmlls says
|
||||||
import qs.Common
|
import qs.Common
|
||||||
|
|
||||||
Singleton {
|
Singleton {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
property bool available: false
|
property bool quickshellSupported: false
|
||||||
|
property bool compositorSupported: false
|
||||||
|
property bool available: quickshellSupported && compositorSupported
|
||||||
readonly property bool enabled: available && (SettingsData.blurEnabled ?? false)
|
readonly property bool enabled: available && (SettingsData.blurEnabled ?? false)
|
||||||
|
|
||||||
readonly property color borderColor: {
|
readonly property color borderColor: {
|
||||||
@@ -72,6 +75,27 @@ Singleton {
|
|||||||
region.destroy();
|
region.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: blurProbe
|
||||||
|
running: false
|
||||||
|
command: ["dms", "blur", "check"]
|
||||||
|
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: {
|
||||||
|
root.compositorSupported = text.trim() === "supported";
|
||||||
|
if (root.compositorSupported)
|
||||||
|
console.info("BlurService: Compositor supports ext-background-effect-v1");
|
||||||
|
else
|
||||||
|
console.info("BlurService: Compositor does not support ext-background-effect-v1");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onExited: exitCode => {
|
||||||
|
if (exitCode !== 0)
|
||||||
|
console.warn("BlurService: blur probe failed with code:", exitCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
try {
|
try {
|
||||||
const test = Qt.createQmlObject(`
|
const test = Qt.createQmlObject(`
|
||||||
@@ -79,8 +103,9 @@ Singleton {
|
|||||||
Region { radius: 0 }
|
Region { radius: 0 }
|
||||||
`, root, "BlurAvailabilityTest");
|
`, root, "BlurAvailabilityTest");
|
||||||
test.destroy();
|
test.destroy();
|
||||||
available = true;
|
quickshellSupported = true;
|
||||||
console.info("BlurService: Initialized with blur support");
|
console.info("BlurService: Quickshell blur support available");
|
||||||
|
blurProbe.running = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.info("BlurService: BackgroundEffect not available - blur disabled. Requires a newer version of Quickshell.");
|
console.info("BlurService: BackgroundEffect not available - blur disabled. Requires a newer version of Quickshell.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -255,6 +255,12 @@ Singleton {
|
|||||||
return pinnedEntries.some(pinnedEntry => pinnedEntry.hash === entryHash);
|
return pinnedEntries.some(pinnedEntry => pinnedEntry.hash === entryHash);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onClipboardAvailableChanged: {
|
||||||
|
if (!clipboardAvailable || refCount <= 0)
|
||||||
|
return;
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
Connections {
|
Connections {
|
||||||
target: DMSService
|
target: DMSService
|
||||||
enabled: root.refCount > 0
|
enabled: root.refCount > 0
|
||||||
|
|||||||
@@ -819,6 +819,7 @@ Singleton {
|
|||||||
if (event.event === "unlock" || event.event === "resume") {
|
if (event.event === "unlock" || event.event === "resume") {
|
||||||
suppressOsd = true;
|
suppressOsd = true;
|
||||||
osdSuppressTimer.restart();
|
osdSuppressTimer.restart();
|
||||||
|
evaluateNightMode();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1032,7 +1033,6 @@ Singleton {
|
|||||||
target: "brightness"
|
target: "brightness"
|
||||||
}
|
}
|
||||||
|
|
||||||
// IPC Handler for night mode control
|
|
||||||
IpcHandler {
|
IpcHandler {
|
||||||
function toggle(): string {
|
function toggle(): string {
|
||||||
root.toggleNightMode();
|
root.toggleNightMode();
|
||||||
@@ -1050,43 +1050,119 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function status(): string {
|
function status(): string {
|
||||||
return root.nightModeEnabled ? "Night mode is enabled" : "Night mode is disabled";
|
if (!root.gammaControlAvailable)
|
||||||
|
return "Night mode: unavailable (no gamma control)";
|
||||||
|
|
||||||
|
const parts = ["Night mode: " + (root.nightModeEnabled ? "enabled" : "disabled")];
|
||||||
|
|
||||||
|
if (root.gammaCurrentTemp > 0)
|
||||||
|
parts.push("Current temperature: " + root.gammaCurrentTemp + "K");
|
||||||
|
|
||||||
|
parts.push("Target night temperature: " + SessionData.nightModeTemperature + "K");
|
||||||
|
|
||||||
|
if (SessionData.nightModeAutoEnabled) {
|
||||||
|
parts.push("Target day temperature: " + SessionData.nightModeHighTemperature + "K");
|
||||||
|
parts.push("Automation: " + SessionData.nightModeAutoMode);
|
||||||
|
parts.push("Period: " + (root.gammaIsDay ? "day" : "night"));
|
||||||
|
|
||||||
|
if (root.gammaNextTransition)
|
||||||
|
parts.push("Next transition: " + root.gammaNextTransition);
|
||||||
|
if (root.gammaSunriseTime)
|
||||||
|
parts.push("Sunrise: " + root.gammaSunriseTime);
|
||||||
|
if (root.gammaSunsetTime)
|
||||||
|
parts.push("Sunset: " + root.gammaSunsetTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function temperature(value: string): string {
|
function getCurrentTemp(): string {
|
||||||
if (!value) {
|
if (!root.gammaControlAvailable)
|
||||||
return "Current temperature: " + SessionData.nightModeTemperature + "K";
|
return "Gamma control not available";
|
||||||
}
|
if (root.gammaCurrentTemp <= 0)
|
||||||
|
return "No current temperature reported";
|
||||||
|
return root.gammaCurrentTemp.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTargetTemp(): string {
|
||||||
|
return SessionData.nightModeTemperature.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDayTemp(): string {
|
||||||
|
return SessionData.nightModeHighTemperature.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTargetTemp(value: string): string {
|
||||||
|
if (!value)
|
||||||
|
return "Usage: night setTargetTemp <2500-6000>";
|
||||||
|
|
||||||
const temp = parseInt(value);
|
const temp = parseInt(value);
|
||||||
if (isNaN(temp)) {
|
if (isNaN(temp))
|
||||||
return "Invalid temperature. Use a value between 2500 and 6000 (in steps of 500)";
|
return "Invalid temperature: " + value;
|
||||||
}
|
if (temp < 2500 || temp > 6000)
|
||||||
|
|
||||||
// Validate temperature is in valid range and steps
|
|
||||||
if (temp < 2500 || temp > 6000) {
|
|
||||||
return "Temperature must be between 2500K and 6000K";
|
return "Temperature must be between 2500K and 6000K";
|
||||||
}
|
|
||||||
|
|
||||||
// Round to nearest 500
|
|
||||||
const rounded = Math.round(temp / 500) * 500;
|
const rounded = Math.round(temp / 500) * 500;
|
||||||
|
|
||||||
SessionData.setNightModeTemperature(rounded);
|
SessionData.setNightModeTemperature(rounded);
|
||||||
|
|
||||||
// Restart night mode with new temperature if active
|
|
||||||
if (root.nightModeEnabled) {
|
if (root.nightModeEnabled) {
|
||||||
if (SessionData.nightModeAutoEnabled) {
|
switch (true) {
|
||||||
|
case SessionData.nightModeAutoEnabled:
|
||||||
root.startAutomation();
|
root.startAutomation();
|
||||||
} else {
|
break;
|
||||||
|
default:
|
||||||
root.applyNightModeDirectly();
|
root.applyNightModeDirectly();
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rounded !== temp) {
|
if (rounded !== temp)
|
||||||
return "Night mode temperature set to " + rounded + "K (rounded from " + temp + "K)";
|
return "Night temperature set to " + rounded + "K (rounded from " + temp + "K)";
|
||||||
} else {
|
return "Night temperature set to " + rounded + "K";
|
||||||
return "Night mode temperature set to " + rounded + "K";
|
}
|
||||||
}
|
|
||||||
|
function setDayTemp(value: string): string {
|
||||||
|
if (!value)
|
||||||
|
return "Usage: night setDayTemp <2500-6500>";
|
||||||
|
|
||||||
|
const temp = parseInt(value);
|
||||||
|
if (isNaN(temp))
|
||||||
|
return "Invalid temperature: " + value;
|
||||||
|
if (temp < 2500 || temp > 6500)
|
||||||
|
return "Temperature must be between 2500K and 6500K";
|
||||||
|
|
||||||
|
const rounded = Math.round(temp / 500) * 500;
|
||||||
|
SessionData.setNightModeHighTemperature(rounded);
|
||||||
|
|
||||||
|
if (root.nightModeEnabled && SessionData.nightModeAutoEnabled)
|
||||||
|
root.startAutomation();
|
||||||
|
|
||||||
|
if (rounded !== temp)
|
||||||
|
return "Day temperature set to " + rounded + "K (rounded from " + temp + "K)";
|
||||||
|
return "Day temperature set to " + rounded + "K";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSchedule(): string {
|
||||||
|
if (!SessionData.nightModeAutoEnabled)
|
||||||
|
return "Automation disabled";
|
||||||
|
|
||||||
|
const parts = ["Mode: " + SessionData.nightModeAutoMode];
|
||||||
|
parts.push("Period: " + (root.gammaIsDay ? "day" : "night"));
|
||||||
|
|
||||||
|
if (root.gammaDawnTime)
|
||||||
|
parts.push("Dawn: " + root.gammaDawnTime);
|
||||||
|
if (root.gammaSunriseTime)
|
||||||
|
parts.push("Sunrise: " + root.gammaSunriseTime);
|
||||||
|
if (root.gammaSunsetTime)
|
||||||
|
parts.push("Sunset: " + root.gammaSunsetTime);
|
||||||
|
if (root.gammaNightTime)
|
||||||
|
parts.push("Night: " + root.gammaNightTime);
|
||||||
|
if (root.gammaNextTransition)
|
||||||
|
parts.push("Next transition: " + root.gammaNextTransition);
|
||||||
|
if (root.gammaSunPosition > 0)
|
||||||
|
parts.push("Sun position: " + root.gammaSunPosition.toFixed(2) + "°");
|
||||||
|
|
||||||
|
return parts.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
target: "night"
|
target: "night"
|
||||||
|
|||||||
@@ -4,10 +4,75 @@ pragma ComponentBehavior: Bound
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import Quickshell
|
import Quickshell
|
||||||
import Quickshell.Services.Mpris
|
import Quickshell.Services.Mpris
|
||||||
|
import qs.Common
|
||||||
|
|
||||||
Singleton {
|
Singleton {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
readonly property list<MprisPlayer> availablePlayers: Mpris.players.values
|
readonly property list<MprisPlayer> availablePlayers: Mpris.players.values
|
||||||
property MprisPlayer activePlayer: availablePlayers.find(p => p.isPlaying) ?? availablePlayers.find(p => p.canControl && p.canPlay) ?? null
|
property MprisPlayer activePlayer: null
|
||||||
|
|
||||||
|
onAvailablePlayersChanged: _resolveActivePlayer()
|
||||||
|
Component.onCompleted: _resolveActivePlayer()
|
||||||
|
|
||||||
|
Instantiator {
|
||||||
|
model: root.availablePlayers
|
||||||
|
delegate: Connections {
|
||||||
|
required property MprisPlayer modelData
|
||||||
|
target: modelData
|
||||||
|
function onIsPlayingChanged() {
|
||||||
|
if (modelData.isPlaying)
|
||||||
|
root._resolveActivePlayer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _resolveActivePlayer(): void {
|
||||||
|
const playing = availablePlayers.find(p => p.isPlaying);
|
||||||
|
if (playing) {
|
||||||
|
activePlayer = playing;
|
||||||
|
_persistIdentity(playing.identity);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (activePlayer && availablePlayers.indexOf(activePlayer) >= 0)
|
||||||
|
return;
|
||||||
|
const savedId = SessionData.lastPlayerIdentity;
|
||||||
|
if (savedId) {
|
||||||
|
const match = availablePlayers.find(p => p.identity === savedId);
|
||||||
|
if (match) {
|
||||||
|
activePlayer = match;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activePlayer = availablePlayers.find(p => p.canControl && p.canPlay) ?? null;
|
||||||
|
if (activePlayer)
|
||||||
|
_persistIdentity(activePlayer.identity);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActivePlayer(player: MprisPlayer): void {
|
||||||
|
activePlayer = player;
|
||||||
|
if (player)
|
||||||
|
_persistIdentity(player.identity);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _persistIdentity(identity: string): void {
|
||||||
|
if (identity && SessionData.lastPlayerIdentity !== identity)
|
||||||
|
SessionData.set("lastPlayerIdentity", identity);
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
interval: 1000
|
||||||
|
running: root.activePlayer?.playbackState === MprisPlaybackState.Playing
|
||||||
|
repeat: true
|
||||||
|
onTriggered: root.activePlayer?.positionChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
function previousOrRewind(): void {
|
||||||
|
if (!activePlayer)
|
||||||
|
return;
|
||||||
|
if (activePlayer.position > 8 && activePlayer.canSeek)
|
||||||
|
activePlayer.position = 0;
|
||||||
|
else if (activePlayer.canGoPrevious)
|
||||||
|
activePlayer.previous();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import QtQuick
|
|||||||
import Quickshell
|
import Quickshell
|
||||||
import Quickshell.Io
|
import Quickshell.Io
|
||||||
import Quickshell.Services.Pipewire
|
import Quickshell.Services.Pipewire
|
||||||
|
import qs.Services
|
||||||
|
|
||||||
Singleton {
|
Singleton {
|
||||||
id: root
|
id: root
|
||||||
@@ -58,6 +59,10 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
readonly property bool screensharingActive: {
|
readonly property bool screensharingActive: {
|
||||||
|
if (CompositorService.isNiri && NiriService.hasActiveCast) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
if (!Pipewire.ready || !Pipewire.nodes?.values) {
|
if (!Pipewire.ready || !Pipewire.nodes?.values) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -74,6 +79,12 @@ Singleton {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (node.properties && node.properties["media.class"] === "Stream/Output/Video") {
|
||||||
|
if (looksLikeScreencast(node)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (node.properties && node.properties["media.class"] === "Stream/Input/Audio") {
|
if (node.properties && node.properties["media.class"] === "Stream/Input/Audio") {
|
||||||
const mediaName = (node.properties["media.name"] || "").toLowerCase()
|
const mediaName = (node.properties["media.name"] || "").toLowerCase()
|
||||||
const appName = (node.properties["application.name"] || "").toLowerCase()
|
const appName = (node.properties["application.name"] || "").toLowerCase()
|
||||||
@@ -110,8 +121,9 @@ Singleton {
|
|||||||
}
|
}
|
||||||
const appName = (node.properties && node.properties["application.name"] || "").toLowerCase()
|
const appName = (node.properties && node.properties["application.name"] || "").toLowerCase()
|
||||||
const nodeName = (node.name || "").toLowerCase()
|
const nodeName = (node.name || "").toLowerCase()
|
||||||
const combined = appName + " " + nodeName
|
const mediaName = (node.properties && node.properties["media.name"] || "").toLowerCase()
|
||||||
return /xdg-desktop-portal|xdpw|screencast|screen|gnome shell|kwin|obs/.test(combined)
|
const combined = appName + " " + nodeName + " " + mediaName
|
||||||
|
return /xdg-desktop-portal|xdpw|screencast|screen-cast|screen|gnome shell|kwin|obs|niri/.test(combined)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMicrophoneStatus() {
|
function getMicrophoneStatus() {
|
||||||
|
|||||||
@@ -231,7 +231,10 @@ Singleton {
|
|||||||
return;
|
return;
|
||||||
isChecking = true;
|
isChecking = true;
|
||||||
hasError = false;
|
hasError = false;
|
||||||
if (updChecker.length > 0) {
|
if (pkgManager === "paru" || pkgManager === "yay") {
|
||||||
|
const repoCmd = updChecker.length > 0 ? updChecker : `${pkgManager} -Qu`;
|
||||||
|
updateChecker.command = ["sh", "-c", `(${repoCmd} 2>/dev/null; ${pkgManager} -Qua 2>/dev/null) || true`];
|
||||||
|
} else if (updChecker.length > 0) {
|
||||||
updateChecker.command = [updChecker].concat(updateCheckerParams[updChecker].listUpdatesSettings.params);
|
updateChecker.command = [updChecker].concat(updateCheckerParams[updChecker].listUpdatesSettings.params);
|
||||||
} else {
|
} else {
|
||||||
updateChecker.command = [pkgManager].concat(packageManagerParams[pkgManager].listUpdatesSettings.params);
|
updateChecker.command = [pkgManager].concat(packageManagerParams[pkgManager].listUpdatesSettings.params);
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ Row {
|
|||||||
width: Math.max(contentItem.implicitWidth + root.buttonPadding * 2, root.minButtonWidth) + (selected ? 4 : 0)
|
width: Math.max(contentItem.implicitWidth + root.buttonPadding * 2, root.minButtonWidth) + (selected ? 4 : 0)
|
||||||
height: root.buttonHeight
|
height: root.buttonHeight
|
||||||
|
|
||||||
color: selected ? Theme.buttonBg : Theme.surfaceVariant
|
color: selected ? Theme.buttonBg : Theme.withAlpha(Theme.surfaceVariant, Theme.popupTransparency)
|
||||||
border.color: "transparent"
|
border.color: "transparent"
|
||||||
border.width: 0
|
border.width: 0
|
||||||
|
|
||||||
|
|||||||
@@ -266,7 +266,7 @@ PanelWindow {
|
|||||||
scale: shouldBeVisible ? 1 : 0.9
|
scale: shouldBeVisible ? 1 : 0.9
|
||||||
|
|
||||||
property bool childHovered: false
|
property bool childHovered: false
|
||||||
readonly property real popupSurfaceAlpha: SettingsData.popupTransparency
|
readonly property real popupSurfaceAlpha: Theme.popupTransparency
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
id: background
|
id: background
|
||||||
@@ -286,7 +286,7 @@ PanelWindow {
|
|||||||
level: Theme.elevationLevel3
|
level: Theme.elevationLevel3
|
||||||
fallbackOffset: 6
|
fallbackOffset: 6
|
||||||
targetRadius: Theme.cornerRadius
|
targetRadius: Theme.cornerRadius
|
||||||
targetColor: Theme.surfaceContainer
|
targetColor: Theme.withAlpha(Theme.surfaceContainer, osdContainer.popupSurfaceAlpha)
|
||||||
borderColor: Theme.outlineMedium
|
borderColor: Theme.outlineMedium
|
||||||
borderWidth: 1
|
borderWidth: 1
|
||||||
shadowEnabled: Theme.elevationEnabled && SettingsData.popoutElevationEnabled && Quickshell.env("DMS_DISABLE_LAYER") !== "true" && Quickshell.env("DMS_DISABLE_LAYER") !== "1"
|
shadowEnabled: Theme.elevationEnabled && SettingsData.popoutElevationEnabled && Quickshell.env("DMS_DISABLE_LAYER") !== "true" && Quickshell.env("DMS_DISABLE_LAYER") !== "1"
|
||||||
|
|||||||
@@ -576,14 +576,6 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
anchors.fill: parent
|
|
||||||
radius: Theme.cornerRadius
|
|
||||||
color: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
|
|
||||||
border.color: BlurService.enabled ? BlurService.borderColor : Theme.outlineMedium
|
|
||||||
border.width: BlurService.borderWidth
|
|
||||||
}
|
|
||||||
|
|
||||||
Loader {
|
Loader {
|
||||||
id: contentLoader
|
id: contentLoader
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
@@ -591,6 +583,21 @@ Item {
|
|||||||
asynchronous: false
|
asynchronous: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width
|
||||||
|
height: parent.height
|
||||||
|
x: contentWrapper.x
|
||||||
|
y: contentWrapper.y
|
||||||
|
opacity: contentWrapper.opacity
|
||||||
|
scale: contentWrapper.scale
|
||||||
|
visible: contentWrapper.visible
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: "transparent"
|
||||||
|
border.color: BlurService.enabled ? BlurService.borderColor : Theme.outlineMedium
|
||||||
|
border.width: BlurService.borderWidth
|
||||||
|
z: 100
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
|
|||||||
@@ -8,13 +8,122 @@ Item {
|
|||||||
id: root
|
id: root
|
||||||
|
|
||||||
property MprisPlayer activePlayer
|
property MprisPlayer activePlayer
|
||||||
property real value: {
|
property real seekPreviewRatio: -1
|
||||||
if (!activePlayer || activePlayer.length <= 0) return 0
|
readonly property real playerValue: {
|
||||||
const pos = (activePlayer.position || 0) % Math.max(1, activePlayer.length)
|
if (!activePlayer || activePlayer.length <= 0)
|
||||||
const calculatedRatio = pos / activePlayer.length
|
return 0;
|
||||||
return Math.max(0, Math.min(1, calculatedRatio))
|
const pos = (activePlayer.position || 0) % Math.max(1, activePlayer.length);
|
||||||
|
const calculatedRatio = pos / activePlayer.length;
|
||||||
|
return Math.max(0, Math.min(1, calculatedRatio));
|
||||||
}
|
}
|
||||||
|
property real value: seekPreviewRatio >= 0 ? seekPreviewRatio : playerValue
|
||||||
property bool isSeeking: false
|
property bool isSeeking: false
|
||||||
|
property bool isDraggingSeek: false
|
||||||
|
property real committedSeekRatio: -1
|
||||||
|
property int previewSettleChecksRemaining: 0
|
||||||
|
property real dragThreshold: 4
|
||||||
|
property int holdIndicatorDelay: 180
|
||||||
|
|
||||||
|
function clampRatio(ratio) {
|
||||||
|
return Math.max(0, Math.min(1, ratio));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ratioForPosition(position) {
|
||||||
|
if (!activePlayer || activePlayer.length <= 0)
|
||||||
|
return 0;
|
||||||
|
return clampRatio(position / activePlayer.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionForRatio(ratio) {
|
||||||
|
if (!activePlayer || activePlayer.length <= 0)
|
||||||
|
return 0;
|
||||||
|
const rawPosition = clampRatio(ratio) * activePlayer.length;
|
||||||
|
return Math.min(rawPosition, activePlayer.length * 0.99);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePreviewFromMouse(mouseX, width) {
|
||||||
|
if (!activePlayer || activePlayer.length <= 0 || width <= 0)
|
||||||
|
return;
|
||||||
|
seekPreviewRatio = clampRatio(mouseX / width);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCommittedSeekPreview() {
|
||||||
|
previewSettleTimer.stop();
|
||||||
|
committedSeekRatio = -1;
|
||||||
|
previewSettleChecksRemaining = 0;
|
||||||
|
if (!isSeeking)
|
||||||
|
seekPreviewRatio = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginCommittedSeekPreview(position) {
|
||||||
|
seekPreviewRatio = ratioForPosition(position);
|
||||||
|
committedSeekRatio = seekPreviewRatio;
|
||||||
|
previewSettleChecksRemaining = 15;
|
||||||
|
previewSettleTimer.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSeekPressed(mouse, width, mouseArea, holdTimer) {
|
||||||
|
isSeeking = true;
|
||||||
|
isDraggingSeek = false;
|
||||||
|
mouseArea.pressX = mouse.x;
|
||||||
|
clearCommittedSeekPreview();
|
||||||
|
holdTimer.restart();
|
||||||
|
if (activePlayer && activePlayer.length > 0 && activePlayer.canSeek) {
|
||||||
|
updatePreviewFromMouse(mouse.x, width);
|
||||||
|
mouseArea.pendingSeekPosition = positionForRatio(seekPreviewRatio);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSeekReleased(mouseArea, holdTimer) {
|
||||||
|
holdTimer.stop();
|
||||||
|
isSeeking = false;
|
||||||
|
isDraggingSeek = false;
|
||||||
|
if (mouseArea.pendingSeekPosition >= 0 && activePlayer && activePlayer.canSeek && activePlayer.length > 0) {
|
||||||
|
const clamped = Math.min(mouseArea.pendingSeekPosition, activePlayer.length * 0.99);
|
||||||
|
activePlayer.position = clamped;
|
||||||
|
mouseArea.pendingSeekPosition = -1;
|
||||||
|
beginCommittedSeekPreview(clamped);
|
||||||
|
} else {
|
||||||
|
seekPreviewRatio = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSeekPositionChanged(mouse, width, mouseArea) {
|
||||||
|
if (mouseArea.pressed && isSeeking && activePlayer && activePlayer.length > 0 && activePlayer.canSeek) {
|
||||||
|
if (!isDraggingSeek && Math.abs(mouse.x - mouseArea.pressX) >= dragThreshold)
|
||||||
|
isDraggingSeek = true;
|
||||||
|
updatePreviewFromMouse(mouse.x, width);
|
||||||
|
mouseArea.pendingSeekPosition = positionForRatio(seekPreviewRatio);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSeekCanceled(mouseArea, holdTimer) {
|
||||||
|
holdTimer.stop();
|
||||||
|
isSeeking = false;
|
||||||
|
isDraggingSeek = false;
|
||||||
|
mouseArea.pendingSeekPosition = -1;
|
||||||
|
clearCommittedSeekPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: previewSettleTimer
|
||||||
|
interval: 80
|
||||||
|
repeat: true
|
||||||
|
onTriggered: {
|
||||||
|
if (root.isSeeking || root.committedSeekRatio < 0) {
|
||||||
|
stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewSettled = Math.abs(root.playerValue - root.committedSeekRatio) <= 0.0015;
|
||||||
|
if (previewSettled || root.previewSettleChecksRemaining <= 0) {
|
||||||
|
root.clearCommittedSeekPreview();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.previewSettleChecksRemaining -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
implicitHeight: 20
|
implicitHeight: 20
|
||||||
|
|
||||||
@@ -29,58 +138,35 @@ Item {
|
|||||||
|
|
||||||
M3WaveProgress {
|
M3WaveProgress {
|
||||||
value: root.value
|
value: root.value
|
||||||
|
actualValue: root.playerValue
|
||||||
|
showActualPlaybackState: root.isSeeking
|
||||||
|
actualProgressColor: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.45)
|
||||||
isPlaying: activePlayer && activePlayer.playbackState === MprisPlaybackState.Playing
|
isPlaying: activePlayer && activePlayer.playbackState === MprisPlaybackState.Playing
|
||||||
|
|
||||||
MouseArea {
|
MouseArea {
|
||||||
|
id: waveMouseArea
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
cursorShape: Qt.PointingHandCursor
|
cursorShape: Qt.PointingHandCursor
|
||||||
enabled: activePlayer && activePlayer.canSeek && activePlayer.length > 0
|
enabled: activePlayer && activePlayer.canSeek && activePlayer.length > 0
|
||||||
|
|
||||||
property real pendingSeekPosition: -1
|
property real pendingSeekPosition: -1
|
||||||
|
property real pressX: 0
|
||||||
|
|
||||||
Timer {
|
Timer {
|
||||||
id: waveSeekDebounceTimer
|
id: waveHoldIndicatorTimer
|
||||||
interval: 150
|
interval: root.holdIndicatorDelay
|
||||||
|
repeat: false
|
||||||
onTriggered: {
|
onTriggered: {
|
||||||
if (parent.pendingSeekPosition >= 0 && activePlayer && activePlayer.canSeek && activePlayer.length > 0) {
|
if (parent.pressed && root.isSeeking)
|
||||||
const clamped = Math.min(parent.pendingSeekPosition, activePlayer.length * 0.99)
|
root.isDraggingSeek = true;
|
||||||
activePlayer.position = clamped
|
|
||||||
parent.pendingSeekPosition = -1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onPressed: (mouse) => {
|
onPressed: mouse => root.handleSeekPressed(mouse, parent.width, waveMouseArea, waveHoldIndicatorTimer)
|
||||||
root.isSeeking = true
|
onReleased: root.handleSeekReleased(waveMouseArea, waveHoldIndicatorTimer)
|
||||||
if (activePlayer && activePlayer.length > 0 && activePlayer.canSeek) {
|
onPositionChanged: mouse => root.handleSeekPositionChanged(mouse, parent.width, waveMouseArea)
|
||||||
const r = Math.max(0, Math.min(1, mouse.x / parent.width))
|
onCanceled: root.handleSeekCanceled(waveMouseArea, waveHoldIndicatorTimer)
|
||||||
pendingSeekPosition = r * activePlayer.length
|
|
||||||
waveSeekDebounceTimer.restart()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onReleased: {
|
|
||||||
root.isSeeking = false
|
|
||||||
waveSeekDebounceTimer.stop()
|
|
||||||
if (pendingSeekPosition >= 0 && activePlayer && activePlayer.canSeek && activePlayer.length > 0) {
|
|
||||||
const clamped = Math.min(pendingSeekPosition, activePlayer.length * 0.99)
|
|
||||||
activePlayer.position = clamped
|
|
||||||
pendingSeekPosition = -1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onPositionChanged: (mouse) => {
|
|
||||||
if (pressed && root.isSeeking && activePlayer && activePlayer.length > 0 && activePlayer.canSeek) {
|
|
||||||
const r = Math.max(0, Math.min(1, mouse.x / parent.width))
|
|
||||||
pendingSeekPosition = r * activePlayer.length
|
|
||||||
waveSeekDebounceTimer.restart()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onClicked: (mouse) => {
|
|
||||||
if (activePlayer && activePlayer.length > 0 && activePlayer.canSeek) {
|
|
||||||
const r = Math.max(0, Math.min(1, mouse.x / parent.width))
|
|
||||||
activePlayer.position = r * activePlayer.length
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,6 +179,7 @@ Item {
|
|||||||
property color trackColor: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.40)
|
property color trackColor: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.40)
|
||||||
property color fillColor: Theme.primary
|
property color fillColor: Theme.primary
|
||||||
property color playheadColor: Theme.primary
|
property color playheadColor: Theme.primary
|
||||||
|
property color actualProgressColor: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.45)
|
||||||
readonly property real midY: height / 2
|
readonly property real midY: height / 2
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
@@ -110,7 +197,22 @@ Item {
|
|||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
color: parent.fillColor
|
color: parent.fillColor
|
||||||
radius: height / 2
|
radius: height / 2
|
||||||
Behavior on width { NumberAnimation { duration: 80 } }
|
Behavior on width {
|
||||||
|
NumberAnimation {
|
||||||
|
duration: 80
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
visible: root.isDraggingSeek
|
||||||
|
width: 2
|
||||||
|
height: Math.max(parent.lineWidth + 4, 10)
|
||||||
|
radius: width / 2
|
||||||
|
color: parent.actualProgressColor
|
||||||
|
x: Math.max(0, Math.min(parent.width, parent.width * root.playerValue)) - width / 2
|
||||||
|
y: parent.midY - height / 2
|
||||||
|
z: 2
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
@@ -122,59 +224,37 @@ Item {
|
|||||||
x: Math.max(0, Math.min(parent.width, parent.width * root.value)) - width / 2
|
x: Math.max(0, Math.min(parent.width, parent.width * root.value)) - width / 2
|
||||||
y: parent.midY - height / 2
|
y: parent.midY - height / 2
|
||||||
z: 3
|
z: 3
|
||||||
Behavior on x { NumberAnimation { duration: 80 } }
|
Behavior on x {
|
||||||
|
NumberAnimation {
|
||||||
|
duration: 80
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
MouseArea {
|
MouseArea {
|
||||||
|
id: flatMouseArea
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
cursorShape: Qt.PointingHandCursor
|
cursorShape: Qt.PointingHandCursor
|
||||||
enabled: activePlayer && activePlayer.canSeek && activePlayer.length > 0
|
enabled: activePlayer && activePlayer.canSeek && activePlayer.length > 0
|
||||||
|
|
||||||
property real pendingSeekPosition: -1
|
property real pendingSeekPosition: -1
|
||||||
|
property real pressX: 0
|
||||||
|
|
||||||
Timer {
|
Timer {
|
||||||
id: flatSeekDebounceTimer
|
id: flatHoldIndicatorTimer
|
||||||
interval: 150
|
interval: root.holdIndicatorDelay
|
||||||
|
repeat: false
|
||||||
onTriggered: {
|
onTriggered: {
|
||||||
if (parent.pendingSeekPosition >= 0 && activePlayer && activePlayer.canSeek && activePlayer.length > 0) {
|
if (parent.pressed && root.isSeeking)
|
||||||
const clamped = Math.min(parent.pendingSeekPosition, activePlayer.length * 0.99)
|
root.isDraggingSeek = true;
|
||||||
activePlayer.position = clamped
|
|
||||||
parent.pendingSeekPosition = -1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onPressed: (mouse) => {
|
onPressed: mouse => root.handleSeekPressed(mouse, parent.width, flatMouseArea, flatHoldIndicatorTimer)
|
||||||
root.isSeeking = true
|
onReleased: root.handleSeekReleased(flatMouseArea, flatHoldIndicatorTimer)
|
||||||
if (activePlayer && activePlayer.length > 0 && activePlayer.canSeek) {
|
onPositionChanged: mouse => root.handleSeekPositionChanged(mouse, parent.width, flatMouseArea)
|
||||||
const r = Math.max(0, Math.min(1, mouse.x / parent.width))
|
onCanceled: root.handleSeekCanceled(flatMouseArea, flatHoldIndicatorTimer)
|
||||||
pendingSeekPosition = r * activePlayer.length
|
|
||||||
flatSeekDebounceTimer.restart()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onReleased: {
|
|
||||||
root.isSeeking = false
|
|
||||||
flatSeekDebounceTimer.stop()
|
|
||||||
if (pendingSeekPosition >= 0 && activePlayer && activePlayer.canSeek && activePlayer.length > 0) {
|
|
||||||
const clamped = Math.min(pendingSeekPosition, activePlayer.length * 0.99)
|
|
||||||
activePlayer.position = clamped
|
|
||||||
pendingSeekPosition = -1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onPositionChanged: (mouse) => {
|
|
||||||
if (pressed && root.isSeeking && activePlayer && activePlayer.length > 0 && activePlayer.canSeek) {
|
|
||||||
const r = Math.max(0, Math.min(1, mouse.x / parent.width))
|
|
||||||
pendingSeekPosition = r * activePlayer.length
|
|
||||||
flatSeekDebounceTimer.restart()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onClicked: (mouse) => {
|
|
||||||
if (activePlayer && activePlayer.length > 0 && activePlayer.canSeek) {
|
|
||||||
const r = Math.max(0, Math.min(1, mouse.x / parent.width))
|
|
||||||
activePlayer.position = r * activePlayer.length
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ Item {
|
|||||||
id: root
|
id: root
|
||||||
|
|
||||||
property real value: 0
|
property real value: 0
|
||||||
|
property real actualValue: value
|
||||||
|
property bool showActualPlaybackState: false
|
||||||
property real lineWidth: 2
|
property real lineWidth: 2
|
||||||
property real wavelength: 20
|
property real wavelength: 20
|
||||||
property real amp: 1.6
|
property real amp: 1.6
|
||||||
@@ -15,6 +17,7 @@ Item {
|
|||||||
property color trackColor: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.40)
|
property color trackColor: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.40)
|
||||||
property color fillColor: Theme.primary
|
property color fillColor: Theme.primary
|
||||||
property color playheadColor: Theme.primary
|
property color playheadColor: Theme.primary
|
||||||
|
property color actualProgressColor: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.45)
|
||||||
|
|
||||||
property real dpr: (root.window ? root.window.devicePixelRatio : 1)
|
property real dpr: (root.window ? root.window.devicePixelRatio : 1)
|
||||||
function snap(v) {
|
function snap(v) {
|
||||||
@@ -22,7 +25,12 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
readonly property real playX: snap(root.width * root.value)
|
readonly property real playX: snap(root.width * root.value)
|
||||||
|
readonly property real actualX: snap(root.width * root.actualValue)
|
||||||
readonly property real midY: snap(height / 2)
|
readonly property real midY: snap(height / 2)
|
||||||
|
readonly property bool previewAhead: root.showActualPlaybackState && root.value > root.actualValue
|
||||||
|
readonly property bool previewBehind: root.showActualPlaybackState && root.value < root.actualValue
|
||||||
|
readonly property real previewGapStartX: Math.min(root.playX, root.actualX)
|
||||||
|
readonly property real previewGapEndX: Math.max(root.playX, root.actualX)
|
||||||
|
|
||||||
Behavior on currentAmp {
|
Behavior on currentAmp {
|
||||||
NumberAnimation {
|
NumberAnimation {
|
||||||
@@ -65,7 +73,9 @@ Item {
|
|||||||
|
|
||||||
readonly property real startX: snap(root.lineWidth / 2)
|
readonly property real startX: snap(root.lineWidth / 2)
|
||||||
readonly property real aaBias: (0.25 / root.dpr)
|
readonly property real aaBias: (0.25 / root.dpr)
|
||||||
readonly property real endX: Math.max(startX, Math.min(root.playX - startX - aaBias, width))
|
readonly property real endX: root.previewAhead ? Math.max(startX, Math.min(root.actualX - aaBias, width)) : Math.max(startX, Math.min(root.playX - startX - aaBias, width))
|
||||||
|
readonly property real gapStartX: root.previewAhead ? Math.max(startX, Math.min(root.actualX + aaBias, width)) : Math.max(startX, Math.min(root.playX + playhead.width / 2, width))
|
||||||
|
readonly property real gapEndX: root.previewAhead ? Math.max(gapStartX, Math.min(root.playX - playhead.width / 2 - aaBias, width)) : Math.max(gapStartX, Math.min(root.actualX - aaBias, width))
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
id: mask
|
id: mask
|
||||||
@@ -100,6 +110,37 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: actualMask
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
x: waveClip.gapStartX
|
||||||
|
width: Math.max(0, waveClip.gapEndX - waveClip.gapStartX)
|
||||||
|
color: "transparent"
|
||||||
|
clip: true
|
||||||
|
visible: (root.previewBehind || root.previewAhead) && width > 0
|
||||||
|
|
||||||
|
Shape {
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
width: root.width + 4 * root.wavelength
|
||||||
|
antialiasing: true
|
||||||
|
preferredRendererType: Shape.CurveRenderer
|
||||||
|
x: waveOffsetX
|
||||||
|
|
||||||
|
ShapePath {
|
||||||
|
strokeColor: root.actualProgressColor
|
||||||
|
strokeWidth: snap(root.lineWidth)
|
||||||
|
capStyle: ShapePath.RoundCap
|
||||||
|
joinStyle: ShapePath.RoundJoin
|
||||||
|
fillColor: "transparent"
|
||||||
|
PathSvg {
|
||||||
|
path: waveSvg.path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
id: startCap
|
id: startCap
|
||||||
width: snap(root.lineWidth)
|
width: snap(root.lineWidth)
|
||||||
@@ -107,7 +148,7 @@ Item {
|
|||||||
radius: width / 2
|
radius: width / 2
|
||||||
color: root.fillColor
|
color: root.fillColor
|
||||||
x: waveClip.startX - width / 2
|
x: waveClip.startX - width / 2
|
||||||
y: root.midY - height / 2 + root.currentAmp * Math.sin((waveClip.startX / root.wavelength) * 2 * Math.PI + root.phase)
|
y: waveY(waveClip.startX) - height / 2
|
||||||
visible: waveClip.endX > waveClip.startX
|
visible: waveClip.endX > waveClip.startX
|
||||||
z: 2
|
z: 2
|
||||||
}
|
}
|
||||||
@@ -119,10 +160,34 @@ Item {
|
|||||||
radius: width / 2
|
radius: width / 2
|
||||||
color: root.fillColor
|
color: root.fillColor
|
||||||
x: waveClip.endX - width / 2
|
x: waveClip.endX - width / 2
|
||||||
y: root.midY - height / 2 + root.currentAmp * Math.sin((waveClip.endX / root.wavelength) * 2 * Math.PI + root.phase)
|
y: waveY(waveClip.endX) - height / 2
|
||||||
visible: waveClip.endX > waveClip.startX
|
visible: waveClip.endX > waveClip.startX
|
||||||
z: 2
|
z: 2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: actualEndCap
|
||||||
|
width: snap(root.lineWidth)
|
||||||
|
height: snap(root.lineWidth)
|
||||||
|
radius: width / 2
|
||||||
|
color: root.actualProgressColor
|
||||||
|
x: waveClip.gapEndX - width / 2
|
||||||
|
y: waveY(waveClip.gapEndX) - height / 2
|
||||||
|
visible: (root.previewBehind || root.previewAhead) && actualMask.width > 0
|
||||||
|
z: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: actualMarker
|
||||||
|
width: 2
|
||||||
|
height: Math.max(root.lineWidth + 4, 10)
|
||||||
|
radius: width / 2
|
||||||
|
color: root.actualProgressColor
|
||||||
|
x: root.actualX - width / 2
|
||||||
|
y: root.midY - height / 2
|
||||||
|
visible: root.showActualPlaybackState
|
||||||
|
z: 2
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
@@ -141,6 +206,10 @@ Item {
|
|||||||
let r = a % m;
|
let r = a % m;
|
||||||
return r < 0 ? r + m : r;
|
return r < 0 ? r + m : r;
|
||||||
}
|
}
|
||||||
|
function waveY(x, amplitude = root.currentAmp, phaseOffset = root.phase) {
|
||||||
|
return root.midY + amplitude * Math.sin((x / root.wavelength) * 2 * Math.PI + phaseOffset);
|
||||||
|
}
|
||||||
|
|
||||||
readonly property real waveOffsetX: -wrapMod(phase / k, wavelength)
|
readonly property real waveOffsetX: -wrapMod(phase / k, wavelength)
|
||||||
|
|
||||||
FrameAnimation {
|
FrameAnimation {
|
||||||
@@ -148,8 +217,9 @@ Item {
|
|||||||
onTriggered: {
|
onTriggered: {
|
||||||
if (root.isPlaying)
|
if (root.isPlaying)
|
||||||
root.phase += 0.03 * frameTime * 60;
|
root.phase += 0.03 * frameTime * 60;
|
||||||
startCap.y = root.midY - startCap.height / 2 + root.currentAmp * Math.sin((waveClip.startX / root.wavelength) * 2 * Math.PI + root.phase);
|
startCap.y = waveY(waveClip.startX) - startCap.height / 2;
|
||||||
endCap.y = root.midY - endCap.height / 2 + root.currentAmp * Math.sin((waveClip.endX / root.wavelength) * 2 * Math.PI + root.phase);
|
endCap.y = waveY(waveClip.endX) - endCap.height / 2;
|
||||||
|
actualEndCap.y = waveY(waveClip.gapEndX) - actualEndCap.height / 2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ Rectangle {
|
|||||||
width: fieldContent.width + Theme.spacingM * 2
|
width: fieldContent.width + Theme.spacingM * 2
|
||||||
height: 32
|
height: 32
|
||||||
radius: Theme.cornerRadius - 2
|
radius: Theme.cornerRadius - 2
|
||||||
color: Theme.surfaceContainerHigh
|
color: Theme.surfaceLight
|
||||||
border.width: 1
|
border.width: 1
|
||||||
border.color: Theme.outlineLight
|
border.color: Theme.outlineLight
|
||||||
|
|
||||||
@@ -272,7 +272,9 @@ Rectangle {
|
|||||||
checked: configData ? (configData.autoconnect || false) : false
|
checked: configData ? (configData.autoconnect || false) : false
|
||||||
visible: !VPNService.configLoading && configData !== null
|
visible: !VPNService.configLoading && configData !== null
|
||||||
onToggled: checked => {
|
onToggled: checked => {
|
||||||
VPNService.updateConfig(profile.uuid, {autoconnect: checked});
|
VPNService.updateConfig(profile.uuid, {
|
||||||
|
autoconnect: checked
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
+1506
-1374
File diff suppressed because it is too large
Load Diff
@@ -86,13 +86,13 @@ def create_poeditor_json(translations):
|
|||||||
references.append(ref)
|
references.append(ref)
|
||||||
|
|
||||||
contexts = sorted(data['contexts']) if data['contexts'] else []
|
contexts = sorted(data['contexts']) if data['contexts'] else []
|
||||||
context_str = " | ".join(contexts) if contexts else term
|
comment = " | ".join(contexts) if contexts else ""
|
||||||
|
|
||||||
entry = {
|
entry = {
|
||||||
"term": term,
|
"term": term,
|
||||||
"context": context_str,
|
"context": term,
|
||||||
"reference": ", ".join(references),
|
"reference": ", ".join(references),
|
||||||
"comment": ""
|
"comment": comment
|
||||||
}
|
}
|
||||||
poeditor_data.append(entry)
|
poeditor_data.append(entry)
|
||||||
|
|
||||||
|
|||||||
+1191
-1383
File diff suppressed because it is too large
Load Diff
+1188
-1380
File diff suppressed because it is too large
Load Diff
+1188
-1380
File diff suppressed because it is too large
Load Diff
+1233
-1425
File diff suppressed because it is too large
Load Diff
+1231
-1423
File diff suppressed because it is too large
Load Diff
+1232
-1424
File diff suppressed because it is too large
Load Diff
+1214
-1406
File diff suppressed because it is too large
Load Diff
+1188
-1380
File diff suppressed because it is too large
Load Diff
+1191
-1383
File diff suppressed because it is too large
Load Diff
+1188
-1380
File diff suppressed because it is too large
Load Diff
+1210
-1402
File diff suppressed because it is too large
Load Diff
+1188
-1380
File diff suppressed because it is too large
Load Diff
+1188
-1380
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user