1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-08-02 03:28:28 -04:00

Compare commits

...

19 Commits

Author SHA1 Message Date
LuckShiba 969b780ad1 niri: update embedded config to add optional=true 2026-07-07 22:20:32 -03:00
LuckShiba f5dc5061fd nix: fix tests 2026-07-07 22:19:39 -03:00
LuckShiba c44de46022 nix/niri: use optional=true in includes 2026-07-07 21:52:22 -03:00
LuckShiba 03ebf6693d nix: update flake.lock 2026-07-07 21:50:28 -03:00
purian23 1724aedd49 fix(AudioService): update the default audio sink device handling
- Fixes #2764
2026-07-07 20:17:40 -04:00
bbedward d799175c07 media: tweak seek bar accent color 2026-07-07 18:36:38 -04:00
bbedward ee6f7b4798 qs/media: replace FBOs with ClippingRectangle and rewrite seekbar as a
shader
2026-07-07 18:16:26 -04:00
bbedward 0511cd19df qs: numerous performance optimizations
- ClippingRectangle usages
- Asynchronous loader usages
- Replace cava visualizers with shaders
2026-07-07 16:11:11 -04:00
bbedward 71b1901ab0 notepad: add inline tab renaming and bump tab size 2026-07-07 11:05:14 -04:00
purian23 32e2d96e55 fix(popout): reintroduce texture sizing during animation to prevent content shift 2026-07-07 10:01:05 -04:00
bbedward 647766b9fa dankinstall: xray false for niri in default config 2026-07-07 09:38:45 -04:00
bbedward 0cd8974110 i18n: add Korean 2026-07-07 09:32:15 -04:00
bbedward 0c4c5fc146 changelog: enable for 1.5 2026-07-07 09:16:54 -04:00
bbedward b447e16374 launcher: dont cache clipboard results and fix image previews 2026-07-06 23:57:27 -04:00
purian23 0bb8353a33 refactor(blurSync): reimplement blur sync in popouts & modals 2026-07-06 23:52:57 -04:00
purian23 19a7dcf17d feat: (void-linux): add dankinstall support for auto installs 2026-07-06 23:01:16 -04:00
bbedward 8a1acb63c9 launcher: improve clipboard preview performance
related #2769
2026-07-06 18:15:49 -04:00
bbedward 6f298d3f52 workspace: fix hiding index numbers on sway
fixes #2768
2026-07-06 16:20:31 -04:00
bbedward 3e481a566b wallpaper: more resilience to updatesEnabled missing expose/update
events
2026-07-06 15:57:19 -04:00
82 changed files with 2658 additions and 1593 deletions
+5 -2
View File
@@ -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.
**dankinstall**
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.
Distribution-aware installer for deploying DMS and compositor configurations on Arch, Fedora, Debian, Ubuntu, openSUSE, Gentoo, and Void. Supports both an interactive TUI and a headless (unattended) mode via CLI flags.
## System Integration
@@ -193,7 +193,7 @@ Set the `DANKINSTALL_LOG_DIR` environment variable to override the log directory
## Supported Distributions
Arch, Fedora, Debian, Ubuntu, openSUSE, Gentoo (and derivatives)
Arch, Fedora, Debian, Ubuntu, openSUSE, Gentoo, Void (and derivatives)
**Arch Linux**
Uses `pacman` for system packages, builds AUR packages via `makepkg`, no AUR helper dependency.
@@ -214,4 +214,7 @@ Most packages available in standard repos. Minimal building required.
**Gentoo**
Uses Portage with GURU overlay. Automatically configures USE flags. Variable success depending on system configuration.
**Void Linux**
Uses XBPS with the DMS and DankLinux self-hosted repositories.
See installer output for distribution-specific details during installation.
+4 -1
View File
@@ -1534,6 +1534,8 @@ func packageInstallHint() string {
return "Install with 'sudo dnf install dms-greeter' (requires COPR: sudo dnf copr enable avengemedia/danklinux)"
case distros.FamilyArch:
return "Install from AUR with 'paru -S greetd-dms-greeter-git' or 'yay -S greetd-dms-greeter-git'"
case distros.FamilyVoid:
return "Install with 'sudo xbps-install -S dms-greeter' (requires DMS XBPS repo: echo 'repository=https://avengemedia.github.io/DankMaterialShell/current' | sudo tee /etc/xbps.d/dms.conf)"
default:
return "Run 'dms greeter install' to install greeter"
}
@@ -1572,7 +1574,8 @@ func isPackageOnlyGreeterDistro() bool {
config.Family == distros.FamilySUSE ||
config.Family == distros.FamilyUbuntu ||
config.Family == distros.FamilyFedora ||
config.Family == distros.FamilyArch
config.Family == distros.FamilyArch ||
config.Family == distros.FamilyVoid
}
func promptCompositorChoice(compositors []string) (string, error) {
+13
View File
@@ -10,6 +10,7 @@ import (
"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"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
@@ -298,6 +299,9 @@ func runSetup() error {
if wmSelected {
if wm == deps.WindowManagerMango {
useSystemd = false
} else if isVoidSetup() {
useSystemd = false
fmt.Println("\nVoid Linux detected; deploying non-systemd session config.")
} else {
useSystemd = promptSystemd()
}
@@ -372,6 +376,15 @@ func runSetup() error {
return nil
}
func isVoidSetup() bool {
osInfo, err := distros.GetOSInfo()
if err != nil {
return false
}
config, exists := distros.Registry[osInfo.Distribution.ID]
return exists && config.Family == distros.FamilyVoid
}
// Add user to the input group for the evdev manager for inut state tracking.
// Caps Lock OSD and the Caps Lock bar indicator.
func ensureInputGroup() {
+4
View File
@@ -61,6 +61,10 @@ func (cd *ConfigDeployer) DeployConfigurationsSelectiveWithReinstalls(ctx contex
return cd.deployConfigurationsInternal(ctx, wm, terminal, installedDeps, replaceConfigs, reinstallItems, true)
}
func (cd *ConfigDeployer) DeployConfigurationsSelectiveWithReinstallsAndSystemd(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal, installedDeps []deps.Dependency, replaceConfigs map[string]bool, reinstallItems map[string]bool, useSystemd bool) ([]DeploymentResult, error) {
return cd.deployConfigurationsInternal(ctx, wm, terminal, installedDeps, replaceConfigs, reinstallItems, useSystemd)
}
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
+2 -2
View File
@@ -20,8 +20,8 @@ mouse-hide-while-typing = true
copy-on-select = false
confirm-close-surface = false
# Disable annoying copied to clipboard
app-notifications = no-clipboard-copy,no-config-reload
# Disable in-app Ghostty toast notifications
app-notifications = false
# Key bindings for common actions
#keybind = ctrl+c=copy_to_clipboard
+1 -1
View File
@@ -9,7 +9,7 @@ hl.bind("SUPER + M", hl.dsp.exec_cmd("dms ipc call processlist focusOrToggle"))
hl.bind("SUPER + comma", hl.dsp.exec_cmd("dms ipc call settings focusOrToggle"))
hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notifications toggle"))
hl.bind("SUPER + SHIFT + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"))
hl.bind("SUPER + Y", hl.dsp.exec_cmd("dms ipc call dankdash wallpaper"))
hl.bind("SUPER + Y", hl.dsp.exec_cmd("dms ipc call dash toggle wallpaper"))
hl.bind("SUPER + TAB", hl.dsp.exec_cmd("dms ipc call hypr toggleOverview"))
hl.bind("SUPER + O", hl.dsp.exec_cmd("dms ipc call hypr toggleOverview"))
hl.bind("SUPER + X", hl.dsp.exec_cmd("dms ipc call powermenu toggle"))
@@ -22,7 +22,7 @@ bind=SUPER,n,spawn,dms ipc call notifications toggle
# Notepad
bind=SUPER+SHIFT,n,spawn,dms ipc call notepad toggle
# Browse Wallpapers
bind=SUPER,y,spawn,dms ipc call dankdash wallpaper
bind=SUPER,y,spawn,dms ipc call dash toggle wallpaper
# Power Menu
bind=SUPER,x,spawn,dms ipc call powermenu toggle
# Cycle Display Profile
+1 -1
View File
@@ -24,7 +24,7 @@ binds {
spawn "dms" "ipc" "call" "settings" "focusOrToggle";
}
Mod+Y hotkey-overlay-title="Browse Wallpapers" {
spawn "dms" "ipc" "call" "dankdash" "wallpaper";
spawn "dms" "ipc" "call" "dash" "toggle" "wallpaper";
}
Mod+N hotkey-overlay-title="Notification Center" { spawn "dms" "ipc" "call" "notifications" "toggle"; }
Mod+Shift+N hotkey-overlay-title="Notepad" { spawn "dms" "ipc" "call" "notepad" "toggle"; }
@@ -20,3 +20,10 @@ window-rule {
tiled-state true
draw-border-with-background false
}
layer-rule {
exclude namespace="^dms:bar$"
background-effect {
xray false
}
}
+6 -6
View File
@@ -265,9 +265,9 @@ recent-windows {
}
// Include dms files
include "dms/colors.kdl"
include "dms/layout.kdl"
include "dms/alttab.kdl"
include "dms/binds.kdl"
include "dms/outputs.kdl"
include "dms/cursor.kdl"
include optional=true "dms/colors.kdl"
include optional=true "dms/layout.kdl"
include optional=true "dms/alttab.kdl"
include optional=true "dms/binds.kdl"
include optional=true "dms/outputs.kdl"
include optional=true "dms/cursor.kdl"
+3
View File
@@ -17,6 +17,7 @@ const (
FamilyDebian DistroFamily = "debian"
FamilyNix DistroFamily = "nix"
FamilyGentoo DistroFamily = "gentoo"
FamilyVoid DistroFamily = "void"
)
// PackageManagerType defines the package manager a distro uses
@@ -29,6 +30,7 @@ const (
PackageManagerZypper PackageManagerType = "zypper"
PackageManagerNix PackageManagerType = "nix"
PackageManagerPortage PackageManagerType = "portage"
PackageManagerXBPS PackageManagerType = "xbps"
)
// RepositoryType defines the type of repository for a package
@@ -42,6 +44,7 @@ const (
RepoTypeOBS RepositoryType = "obs" // OpenBuild Service (Debian/OpenSUSE)
RepoTypeFlake RepositoryType = "flake" // Nix flake
RepoTypeGURU RepositoryType = "guru" // Gentoo GURU
RepoTypeXBPS RepositoryType = "xbps" // Custom XBPS repository
RepoTypeManual RepositoryType = "manual" // Manual build from source
)
+530
View File
@@ -0,0 +1,530 @@
package distros
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
)
const (
VoidDMSRepo = "https://avengemedia.github.io/DankMaterialShell/current"
VoidDankLinuxRepo = "https://avengemedia.github.io/DankLinux/current"
VoidHyprlandRepo = "https://mirror.black-hole.dev/x86_64"
voidRunitSvDir = "/etc/sv"
voidRunitServiceDir = "/var/service"
)
func init() {
Register("void", "#478061", FamilyVoid, func(config DistroConfig, logChan chan<- string) Distribution {
return NewVoidDistribution(config, logChan)
})
}
type VoidDistribution struct {
*BaseDistribution
config DistroConfig
}
func NewVoidDistribution(config DistroConfig, logChan chan<- string) *VoidDistribution {
return &VoidDistribution{
BaseDistribution: NewBaseDistribution(logChan),
config: config,
}
}
func (v *VoidDistribution) GetID() string {
return v.config.ID
}
func (v *VoidDistribution) GetColorHex() string {
return v.config.ColorHex
}
func (v *VoidDistribution) GetFamily() DistroFamily {
return v.config.Family
}
func (v *VoidDistribution) GetPackageManager() PackageManagerType {
return PackageManagerXBPS
}
func (v *VoidDistribution) DetectDependencies(ctx context.Context, wm deps.WindowManager) ([]deps.Dependency, error) {
return v.DetectDependenciesWithTerminal(ctx, wm, deps.TerminalGhostty)
}
func (v *VoidDistribution) DetectDependenciesWithTerminal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal) ([]deps.Dependency, error) {
var dependencies []deps.Dependency
dependencies = append(dependencies, v.detectDMS())
dependencies = append(dependencies, v.detectSpecificTerminal(terminal))
dependencies = append(dependencies, v.detectGit())
dependencies = append(dependencies, v.detectWindowManager(wm))
dependencies = append(dependencies, v.detectQuickshell())
dependencies = append(dependencies, v.detectDMSGreeter())
dependencies = append(dependencies, v.detectXDGPortal())
dependencies = append(dependencies, v.detectAccountsService())
dependencies = append(dependencies, v.detectDBus())
dependencies = append(dependencies, v.detectElogind())
if wm == deps.WindowManagerHyprland {
dependencies = append(dependencies, v.detectHyprlandTools()...)
}
if wm == deps.WindowManagerNiri || wm == deps.WindowManagerMango {
dependencies = append(dependencies, v.detectXwaylandSatellite())
}
dependencies = append(dependencies, v.detectMatugen())
dependencies = append(dependencies, v.detectDgop())
return dependencies, nil
}
func (v *VoidDistribution) detectDMS() deps.Dependency {
status := deps.StatusMissing
version := ""
variant := deps.VariantStable
if v.packageInstalled("dms-git") {
status = deps.StatusInstalled
version = v.packageVersion("dms-git")
variant = deps.VariantGit
} else if v.packageInstalled("dms") {
status = deps.StatusInstalled
version = v.packageVersion("dms")
} else if v.commandExists("dms") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "dms (DankMaterialShell)",
Status: status,
Version: version,
Description: "Desktop Management System package",
Required: true,
Variant: variant,
CanToggle: true,
}
}
func (v *VoidDistribution) detectQuickshell() deps.Dependency {
dep := v.BaseDistribution.detectQuickshell()
dep.CanToggle = false
return dep
}
func (v *VoidDistribution) detectXDGPortal() deps.Dependency {
return v.detectPackage("xdg-desktop-portal-gtk", "Desktop integration portal for GTK", v.packageInstalled("xdg-desktop-portal-gtk"))
}
func (v *VoidDistribution) detectDMSGreeter() deps.Dependency {
return v.detectOptionalPackage("dms-greeter", "DankMaterialShell greetd greeter", v.packageInstalled("dms-greeter"))
}
func (v *VoidDistribution) detectAccountsService() deps.Dependency {
return v.detectPackage("accountsservice", "D-Bus interface for user account query and manipulation", v.packageInstalled("accountsservice"))
}
func (v *VoidDistribution) detectDBus() deps.Dependency {
return v.detectPackage("dbus", "D-Bus system and session message bus", v.packageInstalled("dbus"))
}
func (v *VoidDistribution) detectElogind() deps.Dependency {
return v.detectPackage("elogind", "loginctl/logind provider for power management and session tracking", v.packageInstalled("elogind") || v.commandExists("loginctl"))
}
func (v *VoidDistribution) detectXwaylandSatellite() deps.Dependency {
return v.detectPackage("xwayland-satellite", "Xwayland support", v.packageInstalled("xwayland-satellite"))
}
func (v *VoidDistribution) packageInstalled(pkg string) bool {
return exec.Command("xbps-query", pkg).Run() == nil
}
func (v *VoidDistribution) packageVersion(pkg string) string {
output, err := exec.Command("xbps-query", "-p", "pkgver", pkg).Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(output))
}
func (v *VoidDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
return v.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant))
}
func (v *VoidDistribution) GetPackageMappingWithVariants(wm deps.WindowManager, variants map[string]deps.PackageVariant) map[string]PackageMapping {
packages := map[string]PackageMapping{
"git": {Name: "git", Repository: RepoTypeSystem},
"ghostty": {Name: "ghostty", Repository: RepoTypeSystem},
"kitty": {Name: "kitty", Repository: RepoTypeSystem},
"alacritty": {Name: "alacritty", Repository: RepoTypeSystem},
"xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem},
"accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem},
"dbus": {Name: "dbus", Repository: RepoTypeSystem},
"elogind": {Name: "elogind", Repository: RepoTypeSystem},
"quickshell": {Name: "quickshell", Repository: RepoTypeSystem},
"matugen": {Name: "matugen", Repository: RepoTypeSystem},
"dms (DankMaterialShell)": v.getDmsMapping(variants["dms (DankMaterialShell)"]),
"dms-greeter": {Name: "dms-greeter", Repository: RepoTypeXBPS, RepoURL: VoidDMSRepo},
"dgop": {Name: "dgop", Repository: RepoTypeXBPS, RepoURL: VoidDankLinuxRepo},
}
switch wm {
case deps.WindowManagerHyprland:
packages["hyprland"] = PackageMapping{Name: "hyprland", Repository: RepoTypeXBPS, RepoURL: VoidHyprlandRepo}
packages["hyprctl"] = PackageMapping{Name: "hyprland", Repository: RepoTypeXBPS, RepoURL: VoidHyprlandRepo}
packages["jq"] = PackageMapping{Name: "jq", Repository: RepoTypeSystem}
case deps.WindowManagerNiri:
packages["niri"] = PackageMapping{Name: "niri", Repository: RepoTypeSystem}
packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem}
case deps.WindowManagerMango:
packages["mango"] = PackageMapping{Name: "mangowc", Repository: RepoTypeSystem}
packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem}
}
return packages
}
func (v *VoidDistribution) getDmsMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantStable {
return PackageMapping{Name: "dms", Repository: RepoTypeXBPS, RepoURL: VoidDMSRepo}
}
return PackageMapping{Name: "dms-git", Repository: RepoTypeXBPS, RepoURL: VoidDMSRepo}
}
func (v *VoidDistribution) InstallPrerequisites(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.06,
Step: "Checking XBPS...",
IsComplete: false,
LogOutput: "Checking for xbps-install",
}
if _, err := exec.LookPath("xbps-install"); err != nil {
return fmt.Errorf("xbps-install not found; Void Linux package tools are required: %w", err)
}
return nil
}
func (v *VoidDistribution) InstallPackages(ctx context.Context, dependencies []deps.Dependency, wm deps.WindowManager, sudoPassword string, reinstallFlags map[string]bool, disabledFlags map[string]bool, skipGlobalUseFlags bool, progressChan chan<- InstallProgressMsg) error {
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.05,
Step: "Checking system prerequisites...",
IsComplete: false,
LogOutput: "Starting prerequisite check...",
}
if wm == deps.WindowManagerHyprland {
arch, err := v.xbpsArch(ctx)
if err != nil {
return fmt.Errorf("failed to detect XBPS architecture for Hyprland repository selection: %w", err)
}
if arch != "x86_64" {
return fmt.Errorf("hyprland on Void Linux is installed from %s, which currently provides x86_64 packages only (detected %s)", VoidHyprlandRepo, arch)
}
}
if err := v.InstallPrerequisites(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install prerequisites: %w", err)
}
systemPkgs, xbpsPkgs := v.categorizePackages(dependencies, wm, reinstallFlags, disabledFlags)
if len(xbpsPkgs) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.15,
Step: "Enabling DMS XBPS repositories...",
IsComplete: false,
NeedsSudo: true,
LogOutput: "Setting up custom XBPS repositories for DMS packages",
}
if err := v.enableXBPSRepos(ctx, xbpsPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to enable XBPS repositories: %w", err)
}
}
allPkgs := v.uniquePackageNames(systemPkgs, v.extractPackageNames(xbpsPkgs))
if len(allPkgs) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.35,
Step: fmt.Sprintf("Installing %d XBPS packages...", len(allPkgs)),
IsComplete: false,
NeedsSudo: true,
LogOutput: fmt.Sprintf("Installing XBPS packages: %s", strings.Join(allPkgs, ", ")),
}
if err := v.installXBPSPackages(ctx, allPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install XBPS packages: %w", err)
}
}
progressChan <- InstallProgressMsg{
Phase: PhaseConfiguration,
Progress: 0.90,
Step: "Configuring system...",
IsComplete: false,
LogOutput: "Starting post-installation configuration...",
}
v.log("Void Linux detected; DMS environment and autostart will be configured in the compositor config instead of systemd")
if err := v.ensureSessionServices(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to enable Void session services: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseComplete,
Progress: 1.0,
Step: "Installation complete!",
IsComplete: true,
LogOutput: "All packages installed and configured successfully",
}
return nil
}
func (v *VoidDistribution) ensureSessionServices(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if !v.isRunitSystem() {
v.log("Void runit service directory not detected; skipping dbus/elogind service enablement")
return nil
}
for _, service := range []string{"dbus", "elogind"} {
if !v.runitServiceInstalled(service) {
v.log(fmt.Sprintf("Warning: %s runit service not found in %s; power/session actions may not work until %s is installed", service, voidRunitSvDir, service))
continue
}
if v.runitServiceEnabled(service) {
v.log(fmt.Sprintf("Void runit service %s already enabled", service))
continue
}
progressChan <- InstallProgressMsg{
Phase: PhaseConfiguration,
Progress: 0.92,
Step: fmt.Sprintf("Enabling %s runit service...", service),
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo ln -sf %s %s", filepath.Join(voidRunitSvDir, service), filepath.Join(voidRunitServiceDir, service)),
LogOutput: fmt.Sprintf("Enabling Void runit service: %s", service),
}
cmd := privesc.ExecCommand(ctx, sudoPassword, fmt.Sprintf("ln -sf %s %s", filepath.Join(voidRunitSvDir, service), filepath.Join(voidRunitServiceDir, service)))
if err := v.runWithProgress(cmd, progressChan, PhaseConfiguration, 0.92, 0.95); err != nil {
return fmt.Errorf("failed to enable %s runit service: %w", service, err)
}
v.log(fmt.Sprintf("✓ Enabled %s runit service", service))
}
return nil
}
func (v *VoidDistribution) isRunitSystem() bool {
if fi, err := os.Stat("/run/runit"); err == nil && fi.IsDir() {
return true
}
if _, err := os.Stat("/run/systemd/system"); err == nil {
return false
}
if fi, err := os.Stat(voidRunitServiceDir); err == nil && fi.IsDir() {
return true
}
return false
}
func (v *VoidDistribution) runitServiceInstalled(name string) bool {
fi, err := os.Stat(filepath.Join(voidRunitSvDir, name))
return err == nil && fi.IsDir()
}
func (v *VoidDistribution) runitServiceEnabled(name string) bool {
_, err := os.Lstat(filepath.Join(voidRunitServiceDir, name))
return err == nil
}
func (v *VoidDistribution) categorizePackages(dependencies []deps.Dependency, wm deps.WindowManager, reinstallFlags map[string]bool, disabledFlags map[string]bool) ([]string, []PackageMapping) {
systemPkgs := []string{}
xbpsPkgs := []PackageMapping{}
variantMap := make(map[string]deps.PackageVariant)
for _, dep := range dependencies {
variantMap[dep.Name] = dep.Variant
}
packageMap := v.GetPackageMappingWithVariants(wm, variantMap)
for _, dep := range dependencies {
if disabledFlags[dep.Name] {
continue
}
if dep.Status == deps.StatusInstalled && !reinstallFlags[dep.Name] {
continue
}
pkgInfo, exists := packageMap[dep.Name]
if !exists {
v.log(fmt.Sprintf("Warning: No package mapping for %s", dep.Name))
continue
}
switch pkgInfo.Repository {
case RepoTypeXBPS:
xbpsPkgs = append(xbpsPkgs, pkgInfo)
case RepoTypeSystem:
systemPkgs = append(systemPkgs, pkgInfo.Name)
}
}
return systemPkgs, xbpsPkgs
}
func (v *VoidDistribution) enableXBPSRepos(ctx context.Context, xbpsPkgs []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
enabledRepos := make(map[string]bool)
enabledRepoURLs := []string{}
for _, pkg := range xbpsPkgs {
if pkg.RepoURL == "" || enabledRepos[pkg.RepoURL] {
continue
}
repoName := v.xbpsRepoName(pkg.RepoURL)
confPath := filepath.Join("/etc/xbps.d", repoName+".conf")
repoLine := fmt.Sprintf("repository=%s", pkg.RepoURL)
repoFileContent := repoLine + "\n"
if content, err := os.ReadFile(confPath); err == nil && string(content) == repoFileContent {
v.log(fmt.Sprintf("XBPS repo %s already configured, skipping", pkg.RepoURL))
enabledRepos[pkg.RepoURL] = true
enabledRepoURLs = append(enabledRepoURLs, pkg.RepoURL)
continue
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.18,
Step: fmt.Sprintf("Adding XBPS repo %s...", repoName),
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("echo 'repository=%s' | sudo tee %s", pkg.RepoURL, confPath),
LogOutput: fmt.Sprintf("Adding XBPS repository: %s", pkg.RepoURL),
}
mkdirCmd := privesc.ExecCommand(ctx, sudoPassword, "mkdir -p /etc/xbps.d")
if err := v.runWithProgress(mkdirCmd, progressChan, PhaseSystemPackages, 0.18, 0.19); err != nil {
return fmt.Errorf("failed to create /etc/xbps.d: %w", err)
}
writeCmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf("bash -c 'printf \"%%s\\n\" %q > %s'", repoLine, confPath))
if err := v.runWithProgress(writeCmd, progressChan, PhaseSystemPackages, 0.19, 0.22); err != nil {
return fmt.Errorf("failed to add XBPS repo %s: %w", pkg.RepoURL, err)
}
enabledRepos[pkg.RepoURL] = true
enabledRepoURLs = append(enabledRepoURLs, pkg.RepoURL)
}
if len(enabledRepos) > 0 {
syncArgs := []string{"xbps-install", "-Sy", "-i"}
for _, repoURL := range enabledRepoURLs {
syncArgs = append(syncArgs, "--repository", repoURL)
}
syncCommand := strings.Join(syncArgs, " ")
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.25,
Step: "Synchronizing XBPS repositories...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo sh -c 'yes y | " + syncCommand + "'",
LogOutput: "Synchronizing XBPS repository indexes",
}
syncCmd := privesc.ExecCommand(ctx, sudoPassword, "sh -c 'yes y | "+syncCommand+"'")
if err := v.runWithProgress(syncCmd, progressChan, PhaseSystemPackages, 0.25, 0.30); err != nil {
return fmt.Errorf("failed to synchronize XBPS repositories: %w", err)
}
}
return nil
}
func (v *VoidDistribution) xbpsRepoName(repoURL string) string {
switch repoURL {
case VoidDMSRepo:
return "dms"
case VoidDankLinuxRepo:
return "danklinux"
case VoidHyprlandRepo:
return "hyprland"
default:
name := strings.TrimPrefix(repoURL, "https://")
name = strings.TrimPrefix(name, "http://")
name = strings.NewReplacer("/", "-", ".", "-").Replace(name)
return name
}
}
func (v *VoidDistribution) xbpsArch(ctx context.Context) (string, error) {
output, err := exec.CommandContext(ctx, "xbps-uhelper", "arch").Output()
if err != nil {
return "", err
}
return strings.TrimSpace(string(output)), nil
}
func (v *VoidDistribution) installXBPSPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
}
args := append([]string{"xbps-install", "-Sy"}, packages...)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.40,
Step: "Installing XBPS packages...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
return v.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.40, 0.85)
}
func (v *VoidDistribution) extractPackageNames(packages []PackageMapping) []string {
names := make([]string, len(packages))
for i, pkg := range packages {
names[i] = pkg.Name
}
return names
}
func (v *VoidDistribution) uniquePackageNames(groups ...[]string) []string {
seen := make(map[string]bool)
var unique []string
for _, group := range groups {
for _, pkg := range group {
if pkg == "" || seen[pkg] {
continue
}
seen[pkg] = true
unique = append(unique, pkg)
}
}
return unique
}
+150
View File
@@ -30,6 +30,11 @@ const appArmorProfileDest = "/etc/apparmor.d/usr.bin.dms-greeter"
const GreeterCacheDir = "/var/cache/dms-greeter"
const (
runitSvDir = "/etc/sv"
runitServiceDir = "/var/service"
)
func DetectDMSPath() (string, error) {
return config.LocateDMSConfig()
}
@@ -41,6 +46,96 @@ func IsNixOS() bool {
return err == nil
}
func IsVoidLinux() bool {
osInfo, err := distros.GetOSInfo()
if err != nil {
return false
}
config, exists := distros.Registry[osInfo.Distribution.ID]
return exists && config.Family == distros.FamilyVoid
}
func isRunit() bool {
if fi, err := os.Stat("/run/runit"); err == nil && fi.IsDir() {
return true
}
if _, err := os.Stat("/run/systemd/system"); err == nil {
return false
}
if fi, err := os.Stat(runitServiceDir); err == nil && fi.IsDir() {
return true
}
return false
}
func runitServiceInstalled(name string) bool {
fi, err := os.Stat(filepath.Join(runitSvDir, name))
return err == nil && fi.IsDir()
}
func runitServiceEnabled(name string) bool {
_, err := os.Lstat(filepath.Join(runitServiceDir, name))
return err == nil
}
func enableRunitService(name, sudoPassword string) error {
if !runitServiceInstalled(name) {
return fmt.Errorf("runit service %q not found in %s", name, runitSvDir)
}
if runitServiceEnabled(name) {
return nil
}
return privesc.Run(context.Background(), sudoPassword, "ln", "-sf",
filepath.Join(runitSvDir, name), filepath.Join(runitServiceDir, name))
}
func disableRunitService(name, sudoPassword string) error {
if !runitServiceEnabled(name) {
return nil
}
return privesc.Run(context.Background(), sudoPassword, "rm", "-f",
filepath.Join(runitServiceDir, name))
}
func ensureRunitSeat(greeterUser, sudoPassword string, logFunc func(string)) {
if runitServiceInstalled("seatd") {
if err := enableRunitService("seatd", sudoPassword); err != nil {
logFunc(fmt.Sprintf("⚠ could not enable seatd: %v", err))
} else {
logFunc("✓ seatd enabled")
}
} else {
logFunc("⚠ seatd not installed — the greeter compositor needs it for GPU/seat access")
}
if err := privesc.Run(context.Background(), sudoPassword, "usermod", "-aG", "_seatd,video,input", greeterUser); err != nil {
logFunc(fmt.Sprintf("⚠ could not add %s to seat groups: %v", greeterUser, err))
} else {
logFunc(fmt.Sprintf("✓ %s added to seat groups (_seatd, video, input)", greeterUser))
}
}
func ensureGreetdPamRundir(sudoPassword string, logFunc func(string)) {
const pamPath = "/etc/pam.d/greetd"
data, err := os.ReadFile(pamPath)
if err != nil {
logFunc(fmt.Sprintf("⚠ could not read %s: %v", pamPath, err))
return
}
if strings.Contains(string(data), "pam_rundir") {
logFunc("✓ pam_rundir already present in greetd PAM")
return
}
line := "session optional pam_rundir.so"
if err := privesc.Run(context.Background(), sudoPassword, "sh", "-c",
fmt.Sprintf("printf '%%s\\n' %q >> %s", line, pamPath)); err != nil {
logFunc(fmt.Sprintf("⚠ could not add pam_rundir to %s: %v", pamPath, err))
return
}
logFunc("✓ pam_rundir added to greetd PAM (provides XDG_RUNTIME_DIR for the session)")
}
func DetectGreeterGroup() string {
data, err := os.ReadFile("/etc/group")
if err != nil {
@@ -766,6 +861,8 @@ func EnsureGreetdInstalled(logFunc func(string), sudoPassword string) error {
installCmd = privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y greetd")
case distros.FamilyGentoo:
installCmd = privesc.ExecCommand(ctx, sudoPassword, "emerge --ask n sys-apps/greetd")
case distros.FamilyVoid:
installCmd = privesc.ExecCommand(ctx, sudoPassword, "xbps-install -Sy greetd")
case distros.FamilyNix:
return fmt.Errorf("on NixOS, please add greetd to your configuration.nix")
default:
@@ -892,6 +989,14 @@ func TryInstallGreeterPackage(logFunc func(string), sudoPassword string) bool {
}
failHint = fmt.Sprintf("⚠ dms-greeter install failed. Install from AUR: %s -S greetd-dms-greeter-git", aurHelper)
installCmd = exec.CommandContext(ctx, aurHelper, "-S", "--noconfirm", "greetd-dms-greeter-git")
case distros.FamilyVoid:
failHint = "⚠ dms-greeter install failed. Add the DMS XBPS repo manually:\necho 'repository=https://avengemedia.github.io/DankMaterialShell/current' | sudo tee /etc/xbps.d/dms.conf\nsudo xbps-install -Sy dms-greeter"
logFunc("Adding DMS XBPS repository...")
if err := ensureVoidXBPSRepo(ctx, sudoPassword, "dms", distros.VoidDMSRepo); err != nil {
logFunc(fmt.Sprintf("⚠ Failed to add DMS XBPS repository: %v", err))
}
privesc.ExecCommand(ctx, sudoPassword, "sh -c 'yes y | xbps-install -Sy -i --repository "+distros.VoidDMSRepo+"'").Run()
installCmd = privesc.ExecCommand(ctx, sudoPassword, "xbps-install -Sy dms-greeter")
default:
return false
}
@@ -909,6 +1014,20 @@ func TryInstallGreeterPackage(logFunc func(string), sudoPassword string) bool {
return true
}
func ensureVoidXBPSRepo(ctx context.Context, sudoPassword, name, repoURL string) error {
confPath := filepath.Join("/etc/xbps.d", name+".conf")
repoLine := fmt.Sprintf("repository=%s", repoURL)
repoFileContent := repoLine + "\n"
if content, err := os.ReadFile(confPath); err == nil && string(content) == repoFileContent {
return nil
}
if err := privesc.Run(ctx, sudoPassword, "mkdir", "-p", "/etc/xbps.d"); err != nil {
return err
}
return privesc.Run(ctx, sudoPassword, "sh", "-c",
fmt.Sprintf("printf '%%s\\n' %q > %s", repoLine, confPath))
}
// CopyGreeterFiles installs the dms-greeter wrapper and sets up cache directory
func CopyGreeterFiles(dmsPath, compositor string, logFunc func(string), sudoPassword string) error {
if IsGreeterPackaged() {
@@ -2275,6 +2394,19 @@ func checkSystemdEnabled(service string) (string, error) {
func DisableConflictingDisplayManagers(sudoPassword string, logFunc func(string)) error {
conflictingDMs := []string{"gdm", "gdm3", "lightdm", "sddm", "lxdm", "xdm", "cosmic-greeter"}
for _, dm := range conflictingDMs {
if isRunit() {
if !runitServiceEnabled(dm) {
continue
}
logFunc(fmt.Sprintf("Disabling conflicting display manager: %s", dm))
if err := disableRunitService(dm, sudoPassword); err != nil {
logFunc(fmt.Sprintf("⚠ Warning: Failed to disable %s: %v", dm, err))
} else {
logFunc(fmt.Sprintf("✓ Disabled %s", dm))
}
continue
}
state, err := checkSystemdEnabled(dm)
if err != nil || state == "" || state == "not-found" {
continue
@@ -2294,6 +2426,19 @@ func DisableConflictingDisplayManagers(sudoPassword string, logFunc func(string)
// EnableGreetd unmasks and enables greetd, forcing it over any other DM.
func EnableGreetd(sudoPassword string, logFunc func(string)) error {
if isRunit() {
if !runitServiceInstalled("greetd") {
return fmt.Errorf("greetd service not found in %s; ensure greetd is installed", runitSvDir)
}
ensureRunitSeat(DetectGreeterUser(), sudoPassword, logFunc)
ensureGreetdPamRundir(sudoPassword, logFunc)
if err := enableRunitService("greetd", sudoPassword); err != nil {
return fmt.Errorf("failed to enable greetd: %w", err)
}
logFunc(fmt.Sprintf("✓ greetd enabled (%s)", runitServiceDir))
return nil
}
state, err := checkSystemdEnabled("greetd")
if err != nil {
return fmt.Errorf("failed to check greetd state: %w", err)
@@ -2317,6 +2462,11 @@ func EnableGreetd(sudoPassword string, logFunc func(string)) error {
}
func EnsureGraphicalTarget(sudoPassword string, logFunc func(string)) error {
if isRunit() {
logFunc("✓ runit detected; no graphical target is needed")
return nil
}
cmd := exec.Command("systemctl", "get-default")
output, err := cmd.Output()
if err != nil {
+6 -1
View File
@@ -236,13 +236,18 @@ func (r *Runner) Run() error {
r.log("Starting configuration deployment")
deployer := config.NewConfigDeployer(r.logChan)
results, err := deployer.DeployConfigurationsSelectiveWithReinstalls(
useSystemd := true
if distroConfig, exists := distros.Registry[osInfo.Distribution.ID]; exists && distroConfig.Family == distros.FamilyVoid {
useSystemd = false
}
results, err := deployer.DeployConfigurationsSelectiveWithReinstallsAndSystemd(
context.Background(),
wm,
terminal,
dependencies,
replaceConfigs,
reinstallItems,
useSystemd,
)
if err != nil {
return fmt.Errorf("configuration deployment failed: %w", err)
+5 -3
View File
@@ -68,6 +68,8 @@ func GetQtLoggingRules() string {
level = "info"
}
// scene carries QML engine warnings (e.g. QQuickImage "Cannot open" cache
// probes); suppressed except at debug level
var rules []string
switch strings.ToLower(level) {
case "fatal":
@@ -75,13 +77,13 @@ func GetQtLoggingRules() string {
case "error":
rules = []string{"*.debug=false", "*.info=false", "*.warning=false"}
case "warn", "warning":
rules = []string{"*.debug=false", "*.info=false"}
rules = []string{"*.debug=false", "*.info=false", "scene.warning=false"}
case "info":
rules = []string{"*.debug=false"}
rules = []string{"*.debug=false", "scene.warning=false"}
case "debug":
return ""
default:
rules = []string{"*.debug=false"}
rules = []string{"*.debug=false", "scene.warning=false"}
}
return strings.Join(rules, ";")
+51 -1
View File
@@ -9,6 +9,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
tea "github.com/charmbracelet/bubbletea"
)
@@ -129,7 +130,7 @@ func (m Model) deployConfigurations() tea.Cmd {
deployer := config.NewConfigDeployer(m.logChan)
results, err := deployer.DeployConfigurationsSelectiveWithReinstalls(context.Background(), wm, terminal, m.dependencies, m.replaceConfigs, m.reinstallItems)
results, err := deployer.DeployConfigurationsSelectiveWithReinstallsAndSystemd(context.Background(), wm, terminal, m.dependencies, m.replaceConfigs, m.reinstallItems, m.useSystemdConfig())
return configDeploymentResult{
results: results,
@@ -138,6 +139,17 @@ func (m Model) deployConfigurations() tea.Cmd {
}
}
func (m Model) useSystemdConfig() bool {
if m.osInfo == nil {
return true
}
distroConfig, exists := distros.Registry[m.osInfo.Distribution.ID]
if !exists {
return true
}
return distroConfig.Family != distros.FamilyVoid
}
func (m Model) viewConfigConfirmation() string {
var b strings.Builder
@@ -195,12 +207,50 @@ func (m Model) viewConfigConfirmation() string {
b.WriteString(backup)
b.WriteString("\n\n")
if note := m.configReplacementNote(); note != "" {
b.WriteString(m.styles.Subtle.Render(note))
b.WriteString("\n\n")
}
help := m.styles.Subtle.Render("↑/↓: Navigate, Space: Toggle replace/keep, Enter: Continue")
b.WriteString(help)
return b.String()
}
func (m Model) configReplacementNote() string {
if m.selectedConfig < 0 || m.selectedConfig >= len(m.existingConfigs) {
return ""
}
configInfo := m.existingConfigs[m.selectedConfig]
if !configInfo.Exists {
return ""
}
switch configInfo.ConfigType {
case "Niri":
if m.useSystemdConfig() {
return "Replacing Niri writes the DMS Niri template and uses the user systemd dms service for shell autostart."
}
return `Replacing Niri writes the DMS Niri template and starts DMS from Niri with spawn-at-startup "dms" "run".`
case "Hyprland":
if m.useSystemdConfig() {
return "Replacing Hyprland writes the DMS Lua template and uses the user systemd dms service for shell autostart."
}
return `Replacing Hyprland writes the DMS Lua template and starts DMS from Hyprland with hl.exec_cmd("dms run").`
case "Mango":
return "Replacing Mango writes the DMS Mango template and starts DMS from Mango with exec-once=dms run."
case "Ghostty":
return "Replacing Ghostty writes the DMS terminal defaults and theme include."
case "Kitty":
return "Replacing Kitty writes the DMS terminal defaults, theme include, and tab styling."
case "Alacritty":
return "Replacing Alacritty writes the DMS terminal defaults and theme import."
default:
return ""
}
}
func (m Model) updateConfigConfirmationState(msg tea.Msg) (tea.Model, tea.Cmd) {
if result, ok := msg.(configCheckResult); ok {
if result.error != nil {
+33 -10
View File
@@ -140,7 +140,7 @@ func dmsPackageName(distroID string, dependencies []deps.Dependency) string {
return "dms-shell-git"
}
return "dms-shell"
case distros.FamilyFedora, distros.FamilyUbuntu, distros.FamilyDebian, distros.FamilySUSE:
case distros.FamilyFedora, distros.FamilyUbuntu, distros.FamilyDebian, distros.FamilySUSE, distros.FamilyVoid:
if isGit {
return "dms-git"
}
@@ -168,6 +168,8 @@ func uninstallCommand(distroID string, dependencies []deps.Dependency) string {
return "sudo apt remove " + pkg
case distros.FamilySUSE:
return "sudo zypper remove " + pkg
case distros.FamilyVoid:
return "sudo xbps-remove -R " + pkg
default:
return ""
}
@@ -201,15 +203,26 @@ func (m Model) viewInstallComplete() string {
wm := m.selectedWindowManager()
// mango launches DMS via `exec-once=dms run` (not a systemd session target)
loginHint := "If you do not have a greeter, login with \"niri-session\" or \"Hyprland\""
switch wm {
case deps.WindowManagerNiri:
loginHint = "If you do not have a greeter, login with \"niri-session\""
case deps.WindowManagerHyprland:
loginHint = "If you do not have a greeter, login with \"Hyprland\""
case deps.WindowManagerMango:
loginHint = "If you do not have a greeter, login with \"mango\""
if !m.useSystemdConfig() {
switch wm {
case deps.WindowManagerNiri:
loginHint = "If you do not have a greeter, from a TTY run: dbus-run-session niri"
case deps.WindowManagerHyprland:
loginHint = "If you do not have a greeter, from a TTY run: dbus-run-session Hyprland"
case deps.WindowManagerMango:
loginHint = "If you do not have a greeter, from a TTY run: dbus-run-session mango"
}
} else {
// mango launches DMS via `exec-once=dms run` (not a systemd session target)
switch wm {
case deps.WindowManagerNiri:
loginHint = "If you do not have a greeter, login with \"niri-session\""
case deps.WindowManagerHyprland:
loginHint = "If you do not have a greeter, login with \"Hyprland\""
case deps.WindowManagerMango:
loginHint = "If you do not have a greeter, login with \"mango\""
}
}
b.WriteString("\n")
@@ -222,7 +235,17 @@ func (m Model) viewInstallComplete() string {
labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(theme.Subtle))
b.WriteString(labelStyle.Render("Troubleshooting:") + "\n")
if wm == deps.WindowManagerMango {
if !m.useSystemdConfig() {
switch wm {
case deps.WindowManagerNiri:
b.WriteString(labelStyle.Render(" Disable autostart: ") + cmdStyle.Render(`remove spawn-at-startup "dms" "run" from ~/.config/niri/config.kdl`) + "\n")
case deps.WindowManagerHyprland:
b.WriteString(labelStyle.Render(" Disable autostart: ") + cmdStyle.Render(`remove hl.exec_cmd("dms run") from ~/.config/hypr/hyprland.lua`) + "\n")
case deps.WindowManagerMango:
b.WriteString(labelStyle.Render(" Disable autostart: ") + cmdStyle.Render("remove 'exec-once=dms run' from ~/.config/mango/config.conf") + "\n")
}
b.WriteString(labelStyle.Render(" View logs: ") + cmdStyle.Render("quickshell --path ~/.config/quickshell/dms log") + "\n")
} else if wm == deps.WindowManagerMango {
b.WriteString(labelStyle.Render(" Disable autostart: ") + cmdStyle.Render("remove 'exec-once=dms run' from ~/.config/mango/config.conf") + "\n")
b.WriteString(labelStyle.Render(" View logs: ") + cmdStyle.Render("qs -p ~/.config/quickshell/dms log") + "\n")
} else {
+1 -1
View File
@@ -93,7 +93,7 @@ in {
text = lib.pipe cfg'.filesToInclude [
(map (filename: "dms/${filename}"))
withOriginalConfig
(map (filename: "include \"${filename}.kdl\""))
(map (filename: "include optional=true \"${filename}.kdl\""))
(files: files ++ fixes)
(builtins.concatStringsSep "\n")
];
+2 -2
View File
@@ -6,8 +6,8 @@
let
homeManagerNixosModule =
(fetchTarball {
url = "https://github.com/nix-community/home-manager/archive/e82d4a4ecd18363aa2054cbaa3e32e4134c3dbf4.tar.gz";
sha256 = "sha256-ZTYDofOM3/PJhRF1EuBh6uibm+DmkhU7Wor6mMN7YTc=";
url = "https://github.com/nix-community/home-manager/archive/53ebbdc405acc04acd9bb73ccca462b51ddb8c6d.tar.gz";
sha256 = "1cqmfgwb3jac2zzv82bwvgypxff1z30xkz9j6qcinkmqf58j3k3b";
})
+ "/nixos";
in
+4 -4
View File
@@ -6,8 +6,8 @@
let
homeManagerNixosModule =
(fetchTarball {
url = "https://github.com/nix-community/home-manager/archive/e82d4a4ecd18363aa2054cbaa3e32e4134c3dbf4.tar.gz";
sha256 = "sha256-ZTYDofOM3/PJhRF1EuBh6uibm+DmkhU7Wor6mMN7YTc=";
url = "https://github.com/nix-community/home-manager/archive/53ebbdc405acc04acd9bb73ccca462b51ddb8c6d.tar.gz";
sha256 = "1cqmfgwb3jac2zzv82bwvgypxff1z30xkz9j6qcinkmqf58j3k3b";
})
+ "/nixos";
@@ -76,8 +76,8 @@ pkgs.testers.runNixOSTest {
machine.wait_for_unit("multi-user.target")
machine.succeed("su -- danklinux -c 'test -f ~/.config/niri/config.kdl'")
machine.succeed("su -- danklinux -c 'grep -F \"include \\\"dms/binds.kdl\\\"\" ~/.config/niri/config.kdl'")
machine.succeed("su -- danklinux -c 'grep -F \"include \\\"hm.kdl\\\"\" ~/.config/niri/config.kdl'")
machine.succeed("su -- danklinux -c 'grep -F \"include optional=true \\\"dms/binds.kdl\\\"\" ~/.config/niri/config.kdl'")
machine.succeed("su -- danklinux -c 'grep -F \"include optional=true \\\"hm.kdl\\\"\" ~/.config/niri/config.kdl'")
machine.succeed("su -- danklinux -c 'grep -F \"spawn-at-startup\" ~/.config/niri/hm.kdl'")
machine.succeed("su -- danklinux -c 'grep -F \"\\\"dms\\\" \\\"run\\\"\" ~/.config/niri/hm.kdl'")
'';
+23 -2
View File
@@ -52,8 +52,8 @@ checkout at `srcpkgs/<pkg>/template` to build or submit it.
## Dependencies
Installing `dms` automatically pulls in `quickshell`, `accountsservice`, `dgop`,
and `matugen` (which drives the Material You theming). The rest are optional —
install whichever features you want:
`matugen` (which drives the Material You theming), `dbus`, and `elogind`.
The rest are optional, install whichever features you want:
| Package | Enables |
| --- | --- |
@@ -98,6 +98,27 @@ spawn-at-startup "dms" "run"
or Hyprland: `exec-once = dms run`.
From a TTY on Void without a greeter, start your compositor through a D-Bus
session:
```sh
dbus-run-session niri
dbus-run-session Hyprland
dbus-run-session mango
```
The `mangowc` package provides the `mango` command.
For power menu actions to work on runit systems, make sure the system D-Bus and
elogind services are enabled:
```sh
sudo ln -sf /etc/sv/dbus /var/service/dbus
sudo ln -sf /etc/sv/elogind /var/service/elogind
```
The `dankinstall` Void path does this automatically after installing packages.
## Greeter (optional)
Install `dms-greeter`, then let the CLI do the setup:
+1 -2
View File
@@ -32,13 +32,12 @@ conflicts="dms"
provides="dms-${version}_${revision}"
# Optional feature deps are listed in distro/void/README.md.
depends="quickshell accountsservice dgop matugen dbus"
depends="quickshell accountsservice dgop matugen dbus elogind"
post_install() {
# QML shell tree (build_style=go already installed the dms binary)
vmkdir usr/share/quickshell/dms
vcopy "${wrksrc}/quickshell/*" usr/share/quickshell/dms
echo "${version}" > "${DESTDIR}/usr/share/quickshell/dms/VERSION"
# Desktop entry + icon
vinstall "${wrksrc}/assets/dms-open.desktop" 644 usr/share/applications
+1 -2
View File
@@ -22,13 +22,12 @@ distfiles="https://github.com/AvengeMedia/DankMaterialShell/archive/refs/tags/v$
checksum=f54601e522c883fa9cce02bec070e4321e47389a1cf453e7ad0bb7379ad91b61
# Optional feature deps are listed in distro/void/README.md.
depends="quickshell accountsservice dgop matugen dbus"
depends="quickshell accountsservice dgop matugen dbus elogind"
post_install() {
# QML shell tree (build_style=go already installed the dms binary)
vmkdir usr/share/quickshell/dms
vcopy "${wrksrc}/quickshell/*" usr/share/quickshell/dms
echo "${version}" > "${DESTDIR}/usr/share/quickshell/dms/VERSION"
# Desktop entry + icon
vinstall "${wrksrc}/assets/dms-open.desktop" 644 usr/share/applications
Generated
+3 -3
View File
@@ -18,11 +18,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1778443072,
"narHash": "sha256-zi7/fsqM/kFdNuED//4WOCUtezGtKKqRNORjMvfwjnA=",
"lastModified": 1783224372,
"narHash": "sha256-8i/87eeoqiGE4yOTjwSA3Eh/ziJRQEmd/unYU+K27sk=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "da5ad661ba4e5ef59ba743f0d112cbc30e474f32",
"rev": "d407951447dcd00442e97087bf374aad70c04cea",
"type": "github"
},
"original": {
+17 -2
View File
@@ -120,12 +120,18 @@ Singleton {
function setMonitorScrollPosition(screenName, scrollX, scrollY) {
var newPositions = Object.assign({}, monitorScrollPositions);
newPositions[screenName] = { scrollX: scrollX, scrollY: scrollY };
newPositions[screenName] = {
scrollX: scrollX,
scrollY: scrollY
};
monitorScrollPositions = newPositions;
}
function getMonitorScrollPosition(screenName) {
return monitorScrollPositions[screenName] || { scrollX: 50, scrollY: 50 };
return monitorScrollPositions[screenName] || {
scrollX: 50,
scrollY: 50
};
}
function clearMonitorScrollPosition(screenName) {
@@ -209,6 +215,8 @@ Singleton {
property string locale: ""
property string timeLocale: ""
property string notepadLastMode: ""
property string launcherLastMode: "all"
property string launcherLastFileSearchType: "all"
property string launcherLastQuery: ""
@@ -1216,6 +1224,13 @@ Singleton {
I18n.useLocale(locale, locale.startsWith("en") ? "" : I18n.folder + "/" + locale + ".json");
}
function setNotepadLastMode(mode) {
if (notepadLastMode === mode)
return;
notepadLastMode = mode;
saveSettings();
}
function setLauncherLastMode(mode) {
launcherLastMode = mode;
saveSettings();
@@ -88,6 +88,8 @@ var SPEC = {
locale: { def: "", onChange: "updateLocale" },
timeLocale: { def: "" },
notepadLastMode: { def: "" },
launcherLastMode: { def: "all" },
launcherLastFileSearchType: { def: "all" },
launcherLastQuery: { def: "" },
+6 -5
View File
@@ -373,7 +373,7 @@ Item {
}
function open(): string {
if (SettingsData.notepadDefaultMode === "popout") {
if (PopoutService.notepadResolvedMode === "popout") {
PopoutService.openNotepadPopout();
return "NOTEPAD_OPEN_SUCCESS";
}
@@ -388,7 +388,7 @@ Item {
function openFile(path: string): string {
if (!path)
return open();
if (SettingsData.notepadDefaultMode === "popout") {
if (PopoutService.notepadResolvedMode === "popout") {
PopoutService.openNotepadPopoutWithFile(path);
return "NOTEPAD_OPEN_FILE_SUCCESS";
}
@@ -402,7 +402,7 @@ Item {
}
function close(): string {
if (SettingsData.notepadDefaultMode === "popout") {
if (PopoutService.notepadResolvedMode === "popout") {
PopoutService.notepadPopout?.hide();
return "NOTEPAD_CLOSE_SUCCESS";
}
@@ -415,7 +415,7 @@ Item {
}
function toggle(): string {
if (SettingsData.notepadDefaultMode === "popout") {
if (PopoutService.notepadResolvedMode === "popout") {
PopoutService.toggleNotepadPopout();
return "NOTEPAD_TOGGLE_SUCCESS";
}
@@ -712,12 +712,13 @@ Item {
target: "hypr"
}
// ! TODO - remove for v1.6
IpcHandler {
function wallpaper(): string {
const bar = root.getPreferredBar("clockButtonRef") || root.getPreferredBar();
if (bar) {
bar.triggerWallpaperBrowser();
return "SUCCESS: Toggled wallpaper browser";
return "WARN; deprecated, use dms ipc call dash toggle wallpaper instead";
}
return "ERROR: Failed to toggle wallpaper browser";
}
@@ -65,7 +65,7 @@ Column {
StyledText {
id: codenameText
anchors.centerIn: parent
text: "Saffron Bloom"
text: "The Wolverine"
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
color: Theme.primary
@@ -74,7 +74,7 @@ Column {
}
StyledText {
text: "New launcher, enhanced plugin system, KDE Connect, & more"
text: "Frame Mode, DankCalendar, Spotlight, & more"
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceVariantText
}
@@ -108,77 +108,99 @@ Column {
ChangelogFeatureCard {
width: (parent.width - Theme.spacingS) / 2
iconName: "space_dashboard"
title: "Dank Launcher V2"
description: "New capabilities & plugins"
onClicked: PopoutService.openDankLauncherV2()
iconName: "border_outer"
title: "Frame Mode"
description: "Connected shell surfaces"
onClicked: PopoutService.openSettingsWithTab("frame")
}
ChangelogFeatureCard {
width: (parent.width - Theme.spacingS) / 2
iconName: "smartphone"
title: "Phone Connect"
description: "KDE Connect & Valent"
onClicked: Qt.openUrlExternally("https://github.com/AvengeMedia/dms-plugins/tree/master/DankKDEConnect")
iconName: "calendar_month"
title: "DankCalendar"
description: "Native calendar & events"
onClicked: Qt.openUrlExternally("https://github.com/AvengeMedia/dankcalendar")
}
ChangelogFeatureCard {
width: (parent.width - Theme.spacingS) / 2
iconName: "monitor_heart"
title: "System Monitor"
description: "Redesigned process list"
onClicked: PopoutService.showProcessListModal()
iconName: "search"
title: "Spotlight"
description: "Lightweight launcher"
onClicked: PopoutService.openSpotlightBar()
}
ChangelogFeatureCard {
width: (parent.width - Theme.spacingS) / 2
iconName: "window"
title: "Window Rules"
description: "niri window rule manager"
visible: CompositorService.isNiri
description: "Rules for all compositors"
onClicked: PopoutService.openSettingsWithTab("window_rules")
}
ChangelogFeatureCard {
width: (parent.width - Theme.spacingS) / 2
iconName: "notifications_active"
title: "Enhanced Notifications"
description: "Configurable rules & styling"
visible: !CompositorService.isNiri
onClicked: PopoutService.openSettingsWithTab("notifications")
iconName: "display_settings"
title: "Display Profiles"
description: "Auto-switch monitor layouts"
onClicked: PopoutService.openSettingsWithTab("display_config")
}
ChangelogFeatureCard {
width: (parent.width - Theme.spacingS) / 2
iconName: "dock_to_bottom"
title: "Dock Enhancements"
description: "Bar dock widget & more"
onClicked: PopoutService.openSettingsWithTab("dock")
iconName: "dvr"
title: "Multiplexer Launcher"
description: "Attach to tmux sessions"
onClicked: PopoutService.openSettingsWithTab("multiplexers")
}
ChangelogFeatureCard {
width: (parent.width - Theme.spacingS) / 2
iconName: "volume_up"
title: "Audio Aliases"
description: "Custom device names"
onClicked: PopoutService.openSettingsWithTab("audio")
iconName: "edit_note"
title: "Notepad Rewrite"
description: "Popout & tiling support"
onClicked: PopoutService.openNotepad()
}
ChangelogFeatureCard {
width: (parent.width - Theme.spacingS) / 2
iconName: "extension"
title: "Enhanced Plugin System"
description: "Enables new types of plugins"
onClicked: PopoutService.openSettingsWithTab("plugins")
}
ChangelogFeatureCard {
width: (parent.width - Theme.spacingS) / 2
iconName: "light_mode"
title: "Auto Light/Dark"
description: "Automatic mode switching"
iconName: "gradient"
title: "M3 Shadows"
description: "Reworked elevation system"
onClicked: PopoutService.openSettingsWithTab("theme")
}
ChangelogFeatureCard {
width: (parent.width - Theme.spacingS) / 2
iconName: "login"
title: "Greeter Enhancements"
description: "Settings GUI & multi-user"
onClicked: PopoutService.openSettingsWithTab("greeter")
}
ChangelogFeatureCard {
width: (parent.width - Theme.spacingS) / 2
iconName: "content_paste"
title: "Clipboard Filtering"
description: "Text, image & pinned filters"
onClicked: PopoutService.openSettingsWithTab("clipboard")
}
ChangelogFeatureCard {
width: (parent.width - Theme.spacingS) / 2
iconName: "restart_alt"
title: "XDG Autostart"
description: "Manage apps at login"
onClicked: PopoutService.openSettingsWithTab("autostart")
}
ChangelogFeatureCard {
width: (parent.width - Theme.spacingS) / 2
iconName: "apps"
title: "Default Apps"
description: "Set preferred applications"
onClicked: PopoutService.openSettingsWithTab("default_apps")
}
}
}
@@ -230,12 +252,7 @@ Column {
ChangelogUpgradeNote {
width: parent.width
text: "Spotlight replaced by Dank Launcher V2 — check settings for new options"
}
ChangelogUpgradeNote {
width: parent.width
text: "Plugin API updated — third-party plugins may need updates"
text: "App ID changed to com.danklinux.dms — update any compositor window rules targeting the old ID"
}
}
}
@@ -131,7 +131,7 @@ FloatingWindow {
iconName: "open_in_new"
backgroundColor: Theme.surfaceContainerHighest
textColor: Theme.surfaceText
onClicked: Qt.openUrlExternally("https://danklinux.com/blog/v1-4-release")
onClicked: Qt.openUrlExternally("https://danklinux.com/blog/v1-5-release")
}
DankButton {
@@ -1,10 +1,10 @@
import QtQuick
import QtQuick.Effects
import Quickshell.Widgets
import qs.Common
import qs.Services
import qs.Widgets
Item {
ClippingRectangle {
id: thumbnail
readonly property var log: Log.scoped("ClipboardThumbnail")
@@ -15,6 +15,10 @@ Item {
required property int itemIndex
property bool disposed: false
radius: Theme.cornerRadius / 2
color: "transparent"
antialiasing: true
Image {
id: thumbnailImage
@@ -34,7 +38,7 @@ Item {
fillMode: Image.PreserveAspectCrop
smooth: true
cache: false
visible: false
visible: entryType === "image" && status === Image.Ready && source != ""
asynchronous: true
sourceSize.width: 128
sourceSize.height: 128
@@ -236,33 +240,6 @@ Item {
}
}
MultiEffect {
anchors.fill: parent
anchors.margins: 2
source: thumbnailImage
maskEnabled: true
maskSource: clipboardRoundedRectangularMask
visible: entryType === "image" && thumbnailImage.status === Image.Ready && thumbnailImage.source != ""
maskThresholdMin: 0.5
maskSpreadAtMin: 1
}
Item {
id: clipboardRoundedRectangularMask
width: ClipboardConstants.thumbnailSize
height: ClipboardConstants.itemHeight - 4
layer.enabled: true
layer.smooth: true
visible: false
Rectangle {
anchors.fill: parent
radius: Theme.cornerRadius / 2 // Thumbnail corner radius is divided by 2 so it doesnt look weird on large corner radius (eg: 32px)
color: "black"
antialiasing: true
}
}
DankIcon {
visible: !(entryType === "image" && thumbnailImage.status === Image.Ready && thumbnailImage.source != "")
name: {
@@ -62,6 +62,11 @@ Item {
property bool animationsEnabled: true
function _kickBlurCommit() {
if (typeof contentWindow.update === "function")
contentWindow.update();
}
function open() {
closeTimer.stop();
isClosing = false;
@@ -201,6 +206,12 @@ Item {
}
})(), dpr)
onAlignedXChanged: _kickBlurCommit()
onAlignedYChanged: _kickBlurCommit()
onAlignedWidthChanged: _kickBlurCommit()
onAlignedHeightChanged: _kickBlurCommit()
onShouldBeVisibleChanged: _kickBlurCommit()
PanelWindow {
id: clickCatcher
visible: false
@@ -244,11 +255,13 @@ Item {
WindowBlur {
targetWindow: contentWindow
readonly property real s: Math.min(1, modalContainer.scaleValue)
readonly property real op: Math.max(0, Math.min(1, (morph.openProgress - 0.06) * 2))
readonly property real visibleScale: s * op
// Blur tracks the surface's scaled rect, matching the connected backend.
blurX: modalContainer.x + modalContainer.width * (1 - s) * 0.5 + Theme.snap(modalContainer.animX, root.dpr)
blurY: modalContainer.y + modalContainer.height * (1 - s) * 0.5 + Theme.snap(modalContainer.animY, root.dpr)
blurWidth: root.shouldBeVisible ? modalContainer.width * s : 0
blurHeight: root.shouldBeVisible ? modalContainer.height * s : 0
blurX: modalContainer.x + modalContainer.width * (1 - visibleScale) * 0.5 + Theme.snap(modalContainer.animX, root.dpr)
blurY: modalContainer.y + modalContainer.height * (1 - visibleScale) * 0.5 + Theme.snap(modalContainer.animY, root.dpr)
blurWidth: root.shouldBeVisible ? modalContainer.width * visibleScale : 0
blurHeight: root.shouldBeVisible ? modalContainer.height * visibleScale : 0
blurRadius: root.cornerRadius
}
@@ -338,6 +351,7 @@ Item {
QtObject {
id: morph
property real openProgress: root.shouldBeVisible ? 1 : 0
onOpenProgressChanged: root._kickBlurCommit()
Behavior on openProgress {
enabled: root.animationsEnabled
DankAnim {
@@ -13,7 +13,7 @@ Rectangle {
property string cachedMimeType: ""
property var _requestedEntryId: null
readonly property bool canLoadImage: !!entry?.isImage && (entry?.mimeType ?? "").startsWith("image/")
readonly property bool canLoadImage: typeof entry?.id === "number" && !!entry?.isImage && String(entry?.mimeType ?? "").startsWith("image/")
readonly property string sourceUrl: resolvedSourceUrl(cachedImageData, cachedMimeType || (entry?.mimeType ?? ""))
radius: Math.max(6, Theme.cornerRadius - 2)
@@ -41,13 +41,18 @@ Rectangle {
}
function reloadPreview() {
cachedImageData = "";
cachedMimeType = "";
if (!canLoadImage || !entry?.id) {
if (!canLoadImage || typeof entry?.id !== "number") {
_requestedEntryId = null;
cachedImageData = "";
cachedMimeType = "";
return;
}
// Entry objects are rebuilt per search; same id means same content
if (entry.id === _requestedEntryId)
return;
cachedImageData = "";
cachedMimeType = "";
const entryId = entry.id;
_requestedEntryId = entryId;
DMSService.sendRequest("clipboard.getEntry", {
@@ -55,17 +60,22 @@ Rectangle {
}, function (response) {
if (_requestedEntryId !== entryId)
return;
if (response.error)
if (response.error) {
_requestedEntryId = null;
return;
}
if (!response.result) {
_requestedEntryId = null;
ClipboardService.refresh();
return;
}
const result = response.result;
const mimeType = (result.mimeType ?? entry?.mimeType ?? "").toString();
const data = (result.data ?? "").toString();
if (data.length === 0 || !resolvedSourceUrl(data, mimeType))
if (data.length === 0 || !resolvedSourceUrl(data, mimeType)) {
_requestedEntryId = null;
return;
}
cachedMimeType = mimeType;
cachedImageData = data;
});
@@ -78,6 +88,8 @@ Rectangle {
asynchronous: true
cache: false
smooth: true
sourceSize.width: 128
sourceSize.height: 128
fillMode: Image.PreserveAspectCrop
visible: status === Image.Ready
}
+57 -14
View File
@@ -50,15 +50,15 @@ Item {
}
onActiveChanged: {
if (!active) {
SessionData.addLauncherHistory(searchQuery);
ClipboardService.invalidateLauncherSearchCache();
if (active)
return;
sections = [];
flatModel = [];
selectedItem = null;
_clearModeCache();
ClipboardService.invalidateLauncherSearchCache();
}
SessionData.addLauncherHistory(searchQuery);
sections = [];
flatModel = [];
selectedItem = null;
_clearModeCache();
}
onSearchModeChanged: {
@@ -110,7 +110,7 @@ Item {
if (query !== effectiveQuery)
return;
searchDebounce.restart();
root.requestSearch();
}
}
@@ -276,9 +276,23 @@ Item {
property string appCategory: ""
property var appCategories: []
function builtInSectionViewPref(sectionId) {
switch (sectionId) {
case "clipboard":
return getPluginViewPref("dms_clipboard_search");
case "settings":
return getPluginViewPref("dms_settings_search");
default:
return null;
}
}
function getSectionViewMode(sectionId) {
if (sectionId === "browse_plugins")
return "list";
var builtInPref = builtInSectionViewPref(sectionId);
if (builtInPref?.enforced)
return builtInPref.mode;
if (pluginViewPreferences[sectionId]?.enforced)
return pluginViewPreferences[sectionId].mode;
if (sectionViewModes[sectionId])
@@ -302,6 +316,8 @@ Item {
function setSectionViewMode(sectionId, mode) {
if (sectionId === "browse_plugins")
return;
if (builtInSectionViewPref(sectionId)?.enforced)
return;
if (pluginViewPreferences[sectionId]?.enforced)
return;
sectionViewModes = Object.assign({}, sectionViewModes, {
@@ -325,6 +341,8 @@ Item {
function canChangeSectionViewMode(sectionId) {
if (sectionId === "browse_plugins")
return false;
if (builtInSectionViewPref(sectionId)?.enforced)
return false;
return !pluginViewPreferences[sectionId]?.enforced;
}
@@ -380,10 +398,29 @@ Item {
property bool _pluginPhaseForceFirst: false
property var _phase1Items: []
property bool _searchPending: false
// Leading-edge debounce: search immediately when idle, coalesce bursts
function requestSearch() {
if (searchDebounce.running) {
_searchPending = true;
searchDebounce.restart();
return;
}
_searchPending = false;
performSearch();
searchDebounce.restart();
}
Timer {
id: searchDebounce
interval: 60
onTriggered: root.performSearch()
onTriggered: {
if (!root._searchPending)
return;
root._searchPending = false;
root.performSearch();
}
}
Timer {
@@ -409,7 +446,7 @@ Item {
_phase1Items = [];
pluginPhaseTimer.stop();
searchQuery = query;
searchDebounce.restart();
requestSearch();
if (searchMode !== "plugins" && query.startsWith("/")) {
var prefix = Utils.parseFileSearchPrefix(query);
@@ -694,6 +731,7 @@ Item {
if (triggerMatch.isBuiltIn) {
var builtInItems = AppSearchService.getBuiltInLauncherItems(triggerMatch.pluginId, triggerMatch.query);
for (var j = 0; j < builtInItems.length; j++) {
builtInItems[j]._preScored = 1000 - j;
allItems.push(transformBuiltInSearchItem(builtInItems[j], triggerMatch.pluginId));
}
}
@@ -1198,8 +1236,12 @@ Item {
}
function transformBuiltInSearchItem(item, pluginId) {
if (pluginId === "dms_clipboard_search" || item.type === "clipboard")
return transformClipboardEntry(item.data || item);
if (pluginId === "dms_clipboard_search" || item.type === "clipboard") {
var transformed = transformClipboardEntry(item.data || item);
if (item._preScored !== undefined)
transformed._preScored = item._preScored;
return transformed;
}
return transformBuiltInLauncherItem(item, pluginId);
}
@@ -1871,7 +1913,8 @@ Item {
}
function executeSelected() {
if (searchDebounce.running) {
if (_searchPending) {
_searchPending = false;
searchDebounce.stop();
performSearch();
}
@@ -120,6 +120,18 @@ Item {
readonly property int borderWidth: SettingsData.dankLauncherV2BorderEnabled ? SettingsData.dankLauncherV2BorderThickness : 0
readonly property bool useSingleWindow: CompositorService.isHyprland || useBackgroundDarken
// Blur region isn't auto-committed on geometry changes; kick twice to catch resize settling.
function _kickBlurCommit() {
launcherBlur.kick();
Qt.callLater(launcherBlur.kick);
}
onAlignedXChanged: _kickBlurCommit()
onAlignedYChanged: _kickBlurCommit()
onAlignedWidthChanged: _kickBlurCommit()
on_ContentImplicitHChanged: _kickBlurCommit()
onContentVisibleChanged: _kickBlurCommit()
signal dialogClosed
function _ensureContentLoadedAndInitialize(query, mode) {
@@ -328,6 +340,7 @@ Item {
exclusionMode: ExclusionMode.Ignore
WindowBlur {
id: launcherBlur
targetWindow: launcherWindow
readonly property real op: Math.max(0, Math.min(1, (modalContainer.opacity - 0.06) * 2))
blurX: modalContainer.x
@@ -337,6 +350,9 @@ Item {
blurRadius: root.cornerRadius
}
onWidthChanged: root._kickBlurCommit()
onHeightChanged: root._kickBlurCommit()
WlrLayershell.namespace: "dms:spotlight"
WlrLayershell.layer: root.effectiveLauncherLayer
WlrLayershell.exclusiveZone: -1
@@ -421,6 +437,9 @@ Item {
opacity: contentVisible ? 1 : 0
onOpacityChanged: root._kickBlurCommit()
onSlideOffsetChanged: root._kickBlurCommit()
Behavior on opacity {
NumberAnimation {
duration: contentVisible ? root._openDuration : root._closeDuration
@@ -82,6 +82,12 @@ Item {
readonly property real windowWidth: alignedWidth + contentX + shadowPad
readonly property real windowHeight: alignedHeight + contentY + shadowPad
onAlignedXChanged: _kickBlurCommit()
onAlignedYChanged: _kickBlurCommit()
onAlignedWidthChanged: _kickBlurCommit()
onAlignedHeightChanged: _kickBlurCommit()
onContentVisibleChanged: _kickBlurCommit()
readonly property color backgroundColor: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
readonly property bool useBackgroundDarken: !FrameTransitionState.effectiveFrameEnabled && SettingsData.modalDarkenBackground
readonly property bool useSingleWindow: CompositorService.isHyprland || useBackgroundDarken
@@ -113,6 +119,11 @@ Item {
signal dialogClosed
function _kickBlurCommit() {
if (typeof launcherWindow.update === "function")
launcherWindow.update();
}
function _ensureContentLoadedAndInitialize(query, mode) {
_pendingQuery = query || "";
_pendingMode = mode || "";
@@ -366,11 +377,13 @@ Item {
WindowBlur {
targetWindow: launcherWindow
readonly property real s: Math.min(1, modalContainer.publishedScale)
readonly property real op: Math.max(0, Math.min(1, (modalContainer.opacity - 0.06) * 2))
readonly property real visibleScale: s * op
// Blur tracks the surface's scaled rect
blurX: modalContainer.x + modalContainer.width * (1 - s) * 0.5
blurY: modalContainer.y + modalContainer.height * (1 - s) * 0.5
blurWidth: contentVisible ? modalContainer.width * s : 0
blurHeight: contentVisible ? modalContainer.height * s : 0
blurX: modalContainer.x + modalContainer.width * (1 - visibleScale) * 0.5
blurY: modalContainer.y + modalContainer.height * (1 - visibleScale) * 0.5
blurWidth: contentVisible ? modalContainer.width * visibleScale : 0
blurHeight: contentVisible ? modalContainer.height * visibleScale : 0
blurRadius: root.cornerRadius
}
@@ -460,6 +473,8 @@ Item {
opacity: contentVisible ? 1 : 0
scale: contentVisible ? 1 : 0.96
transformOrigin: Item.Center
onOpacityChanged: root._kickBlurCommit()
onPublishedScaleChanged: root._kickBlurCommit()
Behavior on opacity {
NumberAnimation {
@@ -33,7 +33,7 @@ Rectangle {
return item.icon || "";
}
}
readonly property bool hasClipboardPreview: item?.type === "clipboard" && item?.data?.isImage === true && (item?.data?.mimeType ?? "").startsWith("image/")
readonly property bool hasClipboardPreview: item?.type === "clipboard" && !!item?.data?.isImage && String(item?.data?.mimeType ?? "").startsWith("image/")
width: parent?.width ?? 200
height: 52
@@ -48,9 +48,8 @@ Rectangle {
return "file://" + raw;
return raw;
}
readonly property bool hasClipboardPreview: item?.type === "clipboard" && item?.data?.isImage === true && (item?.data?.mimeType ?? "").startsWith("image/")
readonly property bool hasClipboardPreview: item?.type === "clipboard" && !!item?.data?.isImage && String(item?.data?.mimeType ?? "").startsWith("image/")
readonly property bool hasMediaPreview: previewSource.length > 0 || hasClipboardPreview
readonly property bool previewAnimated: previewSource.toLowerCase().indexOf(".gif") >= 0
readonly property string typeLabel: {
if (!item)
@@ -284,16 +283,10 @@ Rectangle {
anchors.fill: parent
source: root.previewSource
asynchronous: true
sourceSize.width: 128
sourceSize.height: 128
fillMode: Image.PreserveAspectCrop
visible: !root.hasClipboardPreview && !root.previewAnimated
}
AnimatedImage {
anchors.fill: parent
source: root.previewSource
fillMode: Image.PreserveAspectCrop
playing: visible
visible: !root.hasClipboardPreview && root.previewAnimated
visible: !root.hasClipboardPreview
}
ClipboardLauncherPreview {
@@ -165,6 +165,9 @@ Rectangle {
anchors.margins: root.hasScreencopy ? 4 : 0
fillMode: Image.PreserveAspectFit
source: root.item?.data?.attribution || ""
asynchronous: true
sourceSize.width: 80
sourceSize.height: 80
mipmap: true
}
}
@@ -1,5 +1,5 @@
import QtQuick
import QtQuick.Effects
import Quickshell.Widgets
import qs.Common
import qs.Widgets
@@ -98,25 +98,25 @@ StyledRect {
}
property string _videoThumb: ""
property bool _thumbGenAttempted: false
// Probe the thumbnail optimistically; Image.Error triggers generation
onVideoThumbnailPathChanged: {
_videoThumb = "";
if (!videoThumbnailPath)
_thumbGenAttempted = false;
_videoThumb = videoThumbnailPath;
}
function generateVideoThumbnail() {
if (_thumbGenAttempted)
return;
_thumbGenAttempted = true;
_videoThumb = "";
const thumbPath = videoThumbnailPath;
const thumbDir = _xdgCacheHome + "/thumbnails/" + _thumbnailSize;
const size = _thumbnailPx;
const fp = delegateRoot.filePath;
Paths.mkdir(thumbDir);
Proc.runCommand(null, ["test", "-f", thumbPath], function (output, exitCode) {
if (exitCode === 0) {
const script = "mkdir -p \"$1\" && ffmpegthumbnailer -i \"$2\" -o \"$3\" -s " + _thumbnailPx + " -f";
Proc.runCommand(null, ["sh", "-c", script, "thumb", thumbDir, delegateRoot.filePath, thumbPath], function (output, exitCode) {
if (exitCode === 0)
_videoThumb = thumbPath;
} else {
Proc.runCommand(null, ["ffmpegthumbnailer", "-i", fp, "-o", thumbPath, "-s", String(size), "-f"], function (output, exitCode) {
if (exitCode === 0)
_videoThumb = thumbPath;
});
}
});
}
@@ -160,75 +160,52 @@ StyledRect {
height: weMode ? 165 : (iconSizes[iconSizeIndex] - 8)
anchors.horizontalCenter: parent.horizontalCenter
Image {
id: gridPreviewImage
ClippingRectangle {
anchors.fill: parent
anchors.margins: 2
property var weExtensions: [".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tga", ".jxl", ".avif", ".heif", ".exr"]
property int weExtIndex: 0
property string imagePath: {
if (weMode && delegateRoot.fileIsDir)
return delegateRoot.filePath + "/preview" + weExtensions[weExtIndex];
if (_videoThumb)
return _videoThumb;
return "";
}
source: imagePath ? "file://" + imagePath.split('/').map(s => encodeURIComponent(s)).join('/') : ""
onStatusChanged: {
if (weMode && delegateRoot.fileIsDir && status === Image.Error) {
if (weExtIndex < weExtensions.length - 1) {
weExtIndex++;
} else {
imagePath = "";
}
}
}
fillMode: Image.PreserveAspectCrop
sourceSize.width: weMode ? 225 : iconSizes[iconSizeIndex]
sourceSize.height: weMode ? 225 : iconSizes[iconSizeIndex]
asynchronous: true
visible: false
}
radius: Theme.cornerRadius
color: "transparent"
CachingImage {
anchors.fill: parent
anchors.margins: 2
imagePath: !delegateRoot.fileIsDir && isImage ? delegateRoot.filePath : ""
maxCacheSize: 256
visible: !delegateRoot.fileIsDir && isImage
layer.enabled: true
layer.effect: MultiEffect {
maskEnabled: true
maskSource: gridImageMask
maskThresholdMin: 0.5
maskSpreadAtMin: 1
}
}
MultiEffect {
anchors.fill: parent
anchors.margins: 2
source: gridPreviewImage
maskEnabled: true
maskSource: gridImageMask
visible: gridPreviewImage.status === Image.Ready && ((!delegateRoot.fileIsDir && isVideo) || (weMode && delegateRoot.fileIsDir))
maskThresholdMin: 0.5
maskSpreadAtMin: 1
}
Item {
id: gridImageMask
anchors.fill: parent
anchors.margins: 2
layer.enabled: true
layer.smooth: true
visible: false
Rectangle {
Image {
id: gridPreviewImage
anchors.fill: parent
radius: Theme.cornerRadius
color: "black"
antialiasing: true
property var weExtensions: [".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tga", ".jxl", ".avif", ".heif", ".exr"]
property int weExtIndex: 0
property string imagePath: {
if (weMode && delegateRoot.fileIsDir)
return delegateRoot.filePath + "/preview" + weExtensions[weExtIndex];
if (_videoThumb)
return _videoThumb;
return "";
}
source: imagePath ? "file://" + imagePath.split('/').map(s => encodeURIComponent(s)).join('/') : ""
onStatusChanged: {
if (status !== Image.Error)
return;
if (weMode && delegateRoot.fileIsDir) {
if (weExtIndex < weExtensions.length - 1) {
weExtIndex++;
} else {
imagePath = "";
}
return;
}
if (_videoThumb)
generateVideoThumbnail();
}
fillMode: Image.PreserveAspectCrop
sourceSize.width: weMode ? 225 : iconSizes[iconSizeIndex]
sourceSize.height: weMode ? 225 : iconSizes[iconSizeIndex]
asynchronous: true
visible: status === Image.Ready && ((!delegateRoot.fileIsDir && isVideo) || (weMode && delegateRoot.fileIsDir))
}
CachingImage {
anchors.fill: parent
imagePath: !delegateRoot.fileIsDir && isImage ? delegateRoot.filePath : ""
maxCacheSize: 256
animate: false
visible: !delegateRoot.fileIsDir && isImage
}
}
@@ -1,5 +1,5 @@
import QtQuick
import QtQuick.Effects
import Quickshell.Widgets
import qs.Common
import qs.Widgets
@@ -95,23 +95,25 @@ StyledRect {
}
property string _videoThumb: ""
property bool _thumbGenAttempted: false
// Probe the thumbnail optimistically; Image.Error triggers generation
onVideoThumbnailPathChanged: {
_videoThumb = "";
if (!videoThumbnailPath)
_thumbGenAttempted = false;
_videoThumb = videoThumbnailPath;
}
function generateVideoThumbnail() {
if (_thumbGenAttempted)
return;
_thumbGenAttempted = true;
_videoThumb = "";
const thumbPath = videoThumbnailPath;
const fp = listDelegateRoot.filePath;
Paths.mkdir(_xdgCacheHome + "/thumbnails/normal");
Proc.runCommand(null, ["test", "-f", thumbPath], function (output, exitCode) {
if (exitCode === 0) {
const thumbDir = _xdgCacheHome + "/thumbnails/normal";
const script = "mkdir -p \"$1\" && ffmpegthumbnailer -i \"$2\" -o \"$3\" -s 128 -f";
Proc.runCommand(null, ["sh", "-c", script, "thumb", thumbDir, listDelegateRoot.filePath, thumbPath], function (output, exitCode) {
if (exitCode === 0)
_videoThumb = thumbPath;
} else {
Proc.runCommand(null, ["ffmpegthumbnailer", "-i", fp, "-o", thumbPath, "-s", "128", "-f"], function (output, exitCode) {
if (exitCode === 0)
_videoThumb = thumbPath;
});
}
});
}
@@ -165,54 +167,33 @@ StyledRect {
height: 28
anchors.verticalCenter: parent.verticalCenter
Image {
id: listPreviewImage
ClippingRectangle {
anchors.fill: parent
property string imagePath: _videoThumb
source: imagePath ? "file://" + imagePath.split('/').map(s => encodeURIComponent(s)).join('/') : ""
fillMode: Image.PreserveAspectCrop
sourceSize.width: 32
sourceSize.height: 32
asynchronous: true
visible: false
}
radius: Theme.cornerRadius
color: "transparent"
CachingImage {
anchors.fill: parent
imagePath: !listDelegateRoot.fileIsDir && isImage ? listDelegateRoot.filePath : ""
maxCacheSize: 256
visible: !listDelegateRoot.fileIsDir && isImage
layer.enabled: true
layer.effect: MultiEffect {
maskEnabled: true
maskSource: listImageMask
maskThresholdMin: 0.5
maskSpreadAtMin: 1
}
}
MultiEffect {
anchors.fill: parent
source: listPreviewImage
maskEnabled: true
maskSource: listImageMask
visible: listPreviewImage.status === Image.Ready && !listDelegateRoot.fileIsDir && isVideo
maskThresholdMin: 0.5
maskSpreadAtMin: 1
}
Item {
id: listImageMask
anchors.fill: parent
layer.enabled: true
layer.smooth: true
visible: false
Rectangle {
Image {
id: listPreviewImage
anchors.fill: parent
radius: Theme.cornerRadius
color: "black"
antialiasing: true
property string imagePath: _videoThumb
source: imagePath ? "file://" + imagePath.split('/').map(s => encodeURIComponent(s)).join('/') : ""
fillMode: Image.PreserveAspectCrop
sourceSize.width: 32
sourceSize.height: 32
asynchronous: true
visible: status === Image.Ready && !listDelegateRoot.fileIsDir && isVideo
onStatusChanged: {
if (status === Image.Error && _videoThumb)
generateVideoThumbnail();
}
}
CachingImage {
anchors.fill: parent
imagePath: !listDelegateRoot.fileIsDir && isImage ? listDelegateRoot.filePath : ""
maxCacheSize: 256
animate: false
visible: !listDelegateRoot.fileIsDir && isImage
}
}
+5 -31
View File
@@ -1,6 +1,6 @@
import QtQuick
import QtQuick.Effects
import Quickshell
import Quickshell.Widgets
import qs.Common
import qs.Modals.Common
import qs.Services
@@ -591,24 +591,11 @@ DankModal {
border.color: isSelected ? Theme.primary : Theme.withAlpha(Theme.primary, 0)
border.width: isSelected ? 2 : 0
Rectangle {
id: gridProgressMask
ClippingRectangle {
anchors.fill: parent
radius: parent.radius
visible: false
layer.enabled: true
}
Item {
anchors.fill: parent
color: "transparent"
visible: gridButtonRect.isHolding
layer.enabled: gridButtonRect.isHolding
layer.effect: MultiEffect {
maskEnabled: true
maskSource: gridProgressMask
maskSpreadAtMin: 1
maskThresholdMin: 0.5
}
Rectangle {
anchors.left: parent.left
@@ -729,24 +716,11 @@ DankModal {
border.color: isSelected ? Theme.primary : Theme.withAlpha(Theme.primary, 0)
border.width: isSelected ? 2 : 0
Rectangle {
id: listProgressMask
ClippingRectangle {
anchors.fill: parent
radius: parent.radius
visible: false
layer.enabled: true
}
Item {
anchors.fill: parent
color: "transparent"
visible: listButtonRect.isHolding
layer.enabled: listButtonRect.isHolding
layer.effect: MultiEffect {
maskEnabled: true
maskSource: listProgressMask
maskSpreadAtMin: 1
maskThresholdMin: 0.5
}
Rectangle {
anchors.left: parent.left
@@ -108,16 +108,48 @@ Variants {
function invalidate() {
_settleFrames = 3;
backingWindow?.update();
if (!_wedgeBounced)
wedgeWatchdog.restart();
}
onRenderActiveChanged: invalidate()
onBackingWindowChanged: invalidate()
// Same wedge recovery as WallpaperBackground
property bool _wedgeBounced: false
Timer {
id: wedgeWatchdog
interval: 3000
repeat: false
onTriggered: {
if (!root.backingWindow || !blurWallpaperWindow.visible || IdleService.isShellLocked)
return;
log.warn("no frame swapped on", modelData.name, "since last invalidate, re-attaching surface");
root._wedgeBounced = true;
surfaceReattach.restart();
}
}
Timer {
id: surfaceReattach
interval: 0
repeat: false
onTriggered: {
blurWallpaperWindow.visible = false;
Qt.callLater(() => {
blurWallpaperWindow.visible = true;
});
}
}
Connections {
target: root.backingWindow
function onFrameSwapped() {
if (root._settleFrames > 0)
root._settleFrames--;
root._wedgeBounced = false;
wedgeWatchdog.stop();
}
function onVisibleChanged() {
root.invalidate();
@@ -142,10 +174,29 @@ Variants {
function onWallpaperFillModeChanged() {
root.invalidate();
}
function onWallpaperBackgroundColorModeChanged() {
function onEffectiveWallpaperBackgroundColorChanged() {
root.invalidate();
}
function onWallpaperBackgroundCustomColorChanged() {
}
Connections {
target: SessionData
function onMonitorWallpaperFillModesChanged() {
root.invalidate();
}
function onPerMonitorWallpaperChanged() {
root.invalidate();
}
}
// Theme changes repaint DankBackdrop but nothing else wakes the render loop
Connections {
target: Theme
enabled: root.isColorSource || currentWallpaper.status === Image.Error
function onPrimaryChanged() {
root.invalidate();
}
function onBackgroundChanged() {
root.invalidate();
}
}
@@ -170,6 +221,7 @@ Variants {
}
onSourceChanged: {
invalidate();
if (!source || source.startsWith("#")) {
setWallpaperImmediate("");
return;
@@ -190,6 +242,7 @@ Variants {
}
function setWallpaperImmediate(newSource) {
transitionDelayTimer.stop();
transitionAnimation.stop();
root.transitionProgress = 0.0;
root.effectActive = false;
@@ -7,15 +7,24 @@ Item {
id: root
readonly property MprisPlayer activePlayer: MprisController.activePlayer
readonly property bool hasActiveMedia: activePlayer !== null
readonly property bool isPlaying: hasActiveMedia && activePlayer && activePlayer.playbackState === MprisPlaybackState.Playing
readonly property bool isPlaying: activePlayer !== null && activePlayer.playbackState === MprisPlaybackState.Playing
readonly property bool live: visible && isPlaying
width: 20
height: Theme.iconSize
Loader {
active: isPlaying
readonly property real maxBarHeight: Theme.iconSize - 2
readonly property real minBarHeight: 3
onLiveChanged: {
if (!live) {
bars.bandsA = Qt.vector4d(0, 0, 0, 0);
bars.bandsB = Qt.vector2d(0, 0);
}
}
Loader {
active: root.live
sourceComponent: Component {
Ref {
service: CavaService
@@ -23,15 +32,8 @@ Item {
}
}
readonly property real maxBarHeight: Theme.iconSize - 2
readonly property real minBarHeight: 3
readonly property real heightRange: maxBarHeight - minBarHeight
property var barHeights: [minBarHeight, minBarHeight, minBarHeight, minBarHeight, minBarHeight, minBarHeight]
Timer {
id: fallbackTimer
running: !CavaService.cavaAvailable && isPlaying
running: !CavaService.cavaAvailable && root.live
interval: 500
repeat: true
onTriggered: {
@@ -41,54 +43,32 @@ Item {
Connections {
target: CavaService
enabled: root.live
function onValuesChanged() {
if (!root.isPlaying) {
root.barHeights = [root.minBarHeight, root.minBarHeight, root.minBarHeight, root.minBarHeight, root.minBarHeight, root.minBarHeight];
const v = CavaService.values;
if (v.length < 6)
return;
}
const newHeights = [];
for (let i = 0; i < 6; i++) {
if (CavaService.values.length <= i) {
newHeights.push(root.minBarHeight);
continue;
}
const rawLevel = CavaService.values[i];
if (rawLevel <= 0) {
newHeights.push(root.minBarHeight);
} else if (rawLevel >= 100) {
newHeights.push(root.maxBarHeight);
} else {
newHeights.push(root.minBarHeight + Math.sqrt(rawLevel * 0.01) * root.heightRange);
}
}
root.barHeights = newHeights;
const n = i => {
const x = v[i];
return x <= 0 ? 0 : x >= 100 ? 1 : Math.sqrt(x * 0.01);
};
bars.bandsA = Qt.vector4d(n(0), n(1), n(2), n(3));
bars.bandsB = Qt.vector2d(n(4), n(5));
}
}
Row {
anchors.centerIn: parent
spacing: 1.5
ShaderEffect {
id: bars
anchors.fill: parent
Repeater {
model: 6
property real widthPx: width
property real heightPx: height
property real minH: root.minBarHeight
property real maxH: root.maxBarHeight
property vector4d bandsA: Qt.vector4d(0, 0, 0, 0)
property vector2d bandsB: Qt.vector2d(0, 0)
property vector4d fillColor: Qt.vector4d(Theme.primary.r, Theme.primary.g, Theme.primary.b, Theme.primary.a)
Rectangle {
width: 2
height: root.barHeights[index]
radius: 1.5
color: Theme.primary
anchors.verticalCenter: parent.verticalCenter
Behavior on height {
enabled: root.isPlaying && !CavaService.cavaAvailable
NumberAnimation {
duration: 100
easing.type: Easing.Linear
}
}
}
}
fragmentShader: Qt.resolvedUrl("../../../Shaders/qsb/viz_bars.frag.qsb")
}
}
@@ -1,6 +1,5 @@
import QtQuick
import QtQuick.Effects
import QtQuick.Layouts
import Quickshell
import Quickshell.Wayland
import Quickshell.Widgets
@@ -234,16 +233,19 @@ BasePill {
color: Theme.widgetTextColor
}
RowLayout {
Row {
id: contentRow
anchors.centerIn: parent
spacing: Theme.spacingS
visible: !root.isVerticalOrientation
readonly property real iconSize: Theme.barIconSize(root.barThickness, undefined, root.barConfig?.maximizeWidgetIcons, root.barConfig?.iconScale)
IconImage {
id: horizontalAppIcon
Layout.preferredWidth: Theme.barIconSize(root.barThickness, undefined, root.barConfig?.maximizeWidgetIcons, root.barConfig?.iconScale)
Layout.preferredHeight: Layout.preferredWidth
width: contentRow.iconSize
height: contentRow.iconSize
anchors.verticalCenter: parent.verticalCenter
visible: root.showIcon && activeWindow && status === Image.Ready
source: {
if (!activeWindow || !activeWindow.appId)
@@ -264,8 +266,10 @@ BasePill {
}
DankIcon {
Layout.preferredWidth: Theme.barIconSize(root.barThickness, undefined, root.barConfig?.maximizeWidgetIcons, root.barConfig?.iconScale)
size: Layout.preferredWidth
id: horizontalSteamIcon
width: contentRow.iconSize
size: contentRow.iconSize
anchors.verticalCenter: parent.verticalCenter
name: "sports_esports"
color: Theme.widgetTextColor
visible: root.showIcon && activeWindow && activeWindow.appId && horizontalAppIcon.status !== Image.Ready && Paths.isSteamApp(activeWindow.appId)
@@ -280,9 +284,11 @@ BasePill {
}
font.pixelSize: Theme.barTextSize(root.barThickness, root.barConfig?.fontScale, root.barConfig?.maximizeWidgetText)
color: Theme.widgetTextColor
anchors.verticalCenter: parent.verticalCenter
wrapMode: Text.NoWrap
elide: Text.ElideRight
maximumLineCount: 1
Layout.maximumWidth: compactMode ? 80 : 180
width: Math.min(implicitWidth, compactMode ? 80 : 180)
visible: text.length > 0
}
@@ -291,6 +297,7 @@ BasePill {
text: compactMode ? "" : "•"
font.pixelSize: Theme.barTextSize(root.barThickness, root.barConfig?.fontScale, root.barConfig?.maximizeWidgetText)
color: Theme.outlineButton
anchors.verticalCenter: parent.verticalCenter
visible: !compactMode && appText.text && titleText.text
}
@@ -318,9 +325,24 @@ BasePill {
}
font.pixelSize: Theme.barTextSize(root.barThickness, root.barConfig?.fontScale, root.barConfig?.maximizeWidgetText)
color: Theme.widgetTextColor
anchors.verticalCenter: parent.verticalCenter
wrapMode: Text.NoWrap
elide: Text.ElideRight
maximumLineCount: 1
Layout.maximumWidth: maxWidth - appText.implicitWidth - appSeparator.implicitWidth
width: {
const sp = contentRow.spacing;
let used = 0;
if (horizontalAppIcon.visible)
used += horizontalAppIcon.width + sp;
else if (horizontalSteamIcon.visible)
used += horizontalSteamIcon.width + sp;
if (appText.visible)
used += appText.width + sp;
if (appSeparator.visible)
used += appSeparator.width + sp;
const budget = root.maxWidth - root.horizontalPadding * 2 - used;
return Math.min(implicitWidth, Math.max(0, budget));
}
visible: text.length > 0
}
}
@@ -182,7 +182,7 @@ Item {
function mapWorkspace(ws) {
return {
"num": ws.number,
"name": ws.name,
"name": stripSwayWorkspaceNumber(ws.number, ws.name),
"focused": ws.focused,
"active": ws.active,
"urgent": ws.urgent,
@@ -202,6 +202,18 @@ Item {
];
}
// sway/scroll fold `<num>:<name>` into the name field (num 1 name "1:test"); drop the redundant prefix so the index option controls it
function stripSwayWorkspaceNumber(num, name) {
if (num === undefined || num === -1)
return name;
if (typeof name !== "string")
return name;
const prefix = num + ":";
if (!name.startsWith(prefix))
return name;
return name.slice(prefix.length);
}
// Numbered workspaces first in ascending order; purely-named workspaces (sway reports num -1) after, by name
function swayWorkspaceOrder(a, b) {
const keyA = a.num === -1 ? Number.MAX_SAFE_INTEGER : a.num;
@@ -240,8 +240,10 @@ DankPopout {
Connections {
target: root
function onShouldBeVisibleChanged() {
if (root.shouldBeVisible)
mainContainer.forceActiveFocus();
if (!root.shouldBeVisible)
return;
mainContainer.forceActiveFocus();
tabBar.snapIndicator();
}
}
@@ -411,6 +413,7 @@ DankPopout {
anchors.fill: parent
active: root.currentTabId === "media"
visible: active
asynchronous: true
sourceComponent: Component {
MediaPlayerTab {
targetScreen: root.screen
@@ -462,7 +465,7 @@ DankPopout {
DankSpinner {
anchors.centerIn: parent
size: 40
visible: wallpaperLoader.active && wallpaperLoader.status === Loader.Loading
visible: (wallpaperLoader.active && wallpaperLoader.status === Loader.Loading) || (mediaLoader.active && mediaLoader.status === Loader.Loading) || (weatherLoader.active && weatherLoader.status === Loader.Loading)
}
Loader {
@@ -470,6 +473,7 @@ DankPopout {
anchors.fill: parent
active: root.currentTabId === "weather"
visible: active
asynchronous: true
sourceComponent: Component {
WeatherTab {}
}
@@ -44,6 +44,8 @@ Item {
property int __panelHoverCount: 0
readonly property bool overlayBlurActive: dropdownBlur.active
onDropdownTypeChanged: {
if (dropdownType === 0) {
__panelHoverCount = 0;
@@ -75,8 +77,10 @@ Item {
}
WindowBlur {
id: dropdownBlur
targetWindow: root.targetWindow
readonly property bool active: root.__activePanel !== null && root.__activePanel.visible && root.__activePanel.opacity > 0
blurEnabled: active && Theme.connectedSurfaceBlurEnabled
readonly property real s: root.__activePanel ? Math.min(1, root.__activePanel.scale) : 1
blurX: root.__activePanel ? root.__activePanel.x + root.__activePanel.width * (1 - s) * 0.5 : 0
blurY: root.__activePanel ? root.__activePanel.y + root.__activePanel.height * (1 - s) * 0.5 : 0
+100 -54
View File
@@ -2,6 +2,7 @@ import QtQuick
import QtQuick.Effects
import QtQuick.Layouts
import Quickshell.Services.Mpris
import Quickshell.Widgets
import qs.Common
import qs.Services
import qs.Widgets
@@ -24,6 +25,11 @@ Item {
property string section: ""
property int barPosition: SettingsData.Position.Top
readonly property color accent: MediaAccentService.accent
readonly property color onAccent: MediaAccentService.onAccent
readonly property color accentHover: MediaAccentService.accentHover
readonly property color accentPressed: MediaAccentService.accentPressed
signal showVolumeDropdown(point pos, var screen, bool rightEdge, var player, var players)
signal showAudioDevicesDropdown(point pos, var screen, bool rightEdge)
signal showPlayersDropdown(point pos, var screen, bool rightEdge, var player, var players)
@@ -70,7 +76,7 @@ Item {
property bool _switchHold: false
Timer {
id: _switchHoldTimer
interval: 650
interval: 1500
repeat: false
onTriggered: _switchHold = false
}
@@ -78,7 +84,8 @@ Item {
onActivePlayerChanged: {
if (!activePlayer) {
isSwitching = false;
_switchHold = false;
_switchHold = true;
_switchHoldTimer.restart();
return;
}
isSwitching = true;
@@ -117,14 +124,10 @@ Item {
Connections {
target: MprisController
function onAvailablePlayersChanged() {
const count = (MprisController.availablePlayers?.length || 0);
if (count === 0) {
if ((MprisController.availablePlayers?.length || 0) === 0)
isSwitching = false;
_switchHold = false;
} else {
_switchHold = true;
_switchHoldTimer.restart();
}
_switchHold = true;
_switchHoldTimer.restart();
}
}
@@ -290,34 +293,72 @@ Item {
Item {
id: bgContainer
anchors.fill: parent
visible: TrackArtService.resolvedArtUrl !== ""
Image {
id: bgImage
anchors.centerIn: parent
width: Math.max(parent.width, parent.height) * 1.1
height: width
source: TrackArtService.resolvedArtUrl
fillMode: Image.PreserveAspectCrop
asynchronous: true
cache: true
visible: false
onStatusChanged: {
if (status === Image.Ready)
maybeFinishSwitch();
readonly property string curArt: TrackArtService.resolvedArtUrl
// Two layers crossfade: new art loads into the hidden one and fades in once decoded.
property bool _showA: true
visible: layerA.ready || layerB.ready
onCurArtChanged: syncArt()
Component.onCompleted: syncArt()
function syncArt() {
if (curArt === "")
return;
const frontArt = _showA ? layerA.art : layerB.art;
const backArt = _showA ? layerB.art : layerA.art;
if (frontArt == curArt)
return;
if (backArt == curArt) {
_showA = !_showA;
return;
}
if (_showA)
layerB.art = curArt;
else
layerA.art = curArt;
}
Item {
id: blurredBg
component BgBlurLayer: ClippingRectangle {
id: layer
property alias art: layerImg.source
readonly property bool ready: layerImg.status === Image.Ready && layerImg.source != ""
property bool front: false
signal loaded
anchors.fill: parent
visible: false
radius: Theme.cornerRadius
color: "transparent"
antialiasing: true
opacity: front ? 0.7 : 0
Behavior on opacity {
NumberAnimation {
duration: 350
easing.type: Easing.InOutQuad
}
}
Image {
id: layerImg
anchors.centerIn: parent
width: Math.max(parent.width, parent.height) * 1.1
height: width
fillMode: Image.PreserveAspectCrop
asynchronous: true
cache: true
visible: false
onStatusChanged: {
if (status === Image.Ready && source != "")
layer.loaded();
}
}
MultiEffect {
anchors.centerIn: parent
width: bgImage.width
height: bgImage.height
source: bgImage
width: layerImg.width
height: layerImg.height
source: layerImg
blurEnabled: true
blurMax: 64
blur: 0.8
@@ -326,22 +367,26 @@ Item {
}
}
Rectangle {
id: bgMask
anchors.fill: parent
radius: Theme.cornerRadius
visible: false
layer.enabled: true
BgBlurLayer {
id: layerA
front: bgContainer._showA
onLoaded: {
if (!bgContainer._showA) {
bgContainer._showA = true;
root.maybeFinishSwitch();
}
}
}
MultiEffect {
anchors.fill: parent
source: blurredBg
maskEnabled: true
maskSource: bgMask
maskThresholdMin: 0.5
maskSpreadAtMin: 1.0
opacity: 0.7
BgBlurLayer {
id: layerB
front: !bgContainer._showA
onLoaded: {
if (bgContainer._showA) {
bgContainer._showA = false;
root.maybeFinishSwitch();
}
}
}
Rectangle {
@@ -442,7 +487,8 @@ Item {
elide: Text.ElideRight
wrapMode: Text.WordWrap
maximumLineCount: 1
visible: text.length > 0
// Reserve the line so late album metadata doesn't shift the seekbar.
height: Math.max(implicitHeight, Theme.fontSizeSmall * 1.4)
}
}
@@ -531,13 +577,13 @@ Item {
height: 40
radius: 20
anchors.centerIn: parent
color: shuffleArea.containsMouse ? Theme.primaryHover : Theme.withAlpha(Theme.primaryHover, 0)
color: shuffleArea.containsMouse ? root.accentHover : Theme.withAlpha(root.accent, 0)
DankIcon {
anchors.centerIn: parent
name: "shuffle"
size: 20
color: activePlayer && activePlayer.shuffle ? Theme.primary : Theme.surfaceText
color: activePlayer && activePlayer.shuffle ? root.accent : Theme.surfaceText
}
MouseArea {
@@ -593,13 +639,13 @@ Item {
height: 50
radius: 25
anchors.centerIn: parent
color: Theme.primary
color: root.accent
DankIcon {
anchors.centerIn: parent
name: activePlayer && activePlayer.playbackState === MprisPlaybackState.Playing ? "pause" : "play_arrow"
size: 28
color: Theme.background
color: root.onAccent
weight: 500
}
@@ -663,7 +709,7 @@ Item {
height: 40
radius: 20
anchors.centerIn: parent
color: repeatArea.containsMouse ? Theme.primaryHover : Theme.withAlpha(Theme.primaryHover, 0)
color: repeatArea.containsMouse ? root.accentHover : Theme.withAlpha(root.accent, 0)
DankIcon {
anchors.centerIn: parent
@@ -680,7 +726,7 @@ Item {
}
}
size: 20
color: activePlayer && activePlayer.loopState !== MprisLoopState.None ? Theme.primary : Theme.surfaceText
color: activePlayer && activePlayer.loopState !== MprisLoopState.None ? root.accent : Theme.surfaceText
}
MouseArea {
@@ -719,7 +765,7 @@ Item {
radius: 20
x: isRightEdge ? Theme.spacingM : parent.width - 40 - Theme.spacingM
y: 185
color: playerSelectorArea.containsMouse || playersExpanded ? Theme.primaryPressed : Theme.withAlpha(Theme.primaryPressed, 0)
color: playerSelectorArea.containsMouse || playersExpanded ? root.accentPressed : Theme.withAlpha(root.accentPressed, 0)
border.color: Theme.outlineStrong
border.width: 1
z: 100
@@ -786,7 +832,7 @@ Item {
radius: 20
x: isRightEdge ? Theme.spacingM : parent.width - 40 - Theme.spacingM
y: 130
color: volumeButtonArea.containsMouse && volumeAvailable || volumeExpanded ? Theme.primaryPressed : Theme.withAlpha(Theme.primaryPressed, 0)
color: volumeButtonArea.containsMouse && volumeAvailable || volumeExpanded ? root.accentPressed : Theme.withAlpha(root.accentPressed, 0)
border.color: volumeAvailable ? Theme.outlineStrong : Theme.outlineMedium
border.width: 1
z: 101
@@ -798,7 +844,7 @@ Item {
anchors.centerIn: parent
name: getVolumeIcon()
size: 18
color: volumeAvailable && currentVolume > 0 ? Theme.primary : Theme.withAlpha(Theme.surfaceText, volumeAvailable ? 1.0 : 0.5)
color: volumeAvailable && currentVolume > 0 ? root.accent : Theme.withAlpha(Theme.surfaceText, volumeAvailable ? 1.0 : 0.5)
}
MouseArea {
@@ -849,7 +895,7 @@ Item {
radius: 20
x: isRightEdge ? Theme.spacingM : parent.width - 40 - Theme.spacingM
y: 240
color: audioDevicesArea.containsMouse || devicesExpanded ? Theme.primaryPressed : Theme.withAlpha(Theme.primaryPressed, 0)
color: audioDevicesArea.containsMouse || devicesExpanded ? root.accentPressed : Theme.withAlpha(root.accentPressed, 0)
border.color: Theme.outlineStrong
border.width: 1
z: 100
@@ -154,13 +154,13 @@ Card {
width: 32
height: 32
radius: 16
color: Theme.primary
color: MediaAccentService.accent
DankIcon {
anchors.centerIn: parent
name: activePlayer?.playbackState === MprisPlaybackState.Playing ? "pause" : "play_arrow"
size: 16
color: Theme.background
color: MediaAccentService.onAccent
}
MouseArea {
+17 -6
View File
@@ -1,6 +1,7 @@
import QtQuick
import qs.Common
import qs.Modules.DankDash.Overview
import qs.Widgets
Item {
id: root
@@ -17,7 +18,7 @@ Item {
signal navFocusRequested
function handleKeyEvent(event) {
return calendarCard.handleKeyEvent(event);
return calendarLoader.item ? calendarLoader.item.handleKeyEvent(event) : false;
}
Item {
@@ -57,16 +58,26 @@ Item {
height: 220
}
// Calendar - bottom middle (wider and taller)
CalendarOverviewCard {
id: calendarCard
// Calendar - bottom middle; deferred so the grid stays off the emerge frame.
Loader {
id: calendarLoader
x: parent.width * 0.2 - Theme.spacingM
y: 100 + Theme.spacingM
width: parent.width * 0.6
height: 300
asynchronous: true
sourceComponent: Component {
CalendarOverviewCard {
onCloseDash: root.closeDash()
onNavFocusRequested: root.navFocusRequested()
}
}
onCloseDash: root.closeDash()
onNavFocusRequested: root.navFocusRequested()
DankSpinner {
anchors.centerIn: parent
size: 32
visible: calendarLoader.status === Loader.Loading
}
}
// Media - bottom right (narrow and taller)
+2 -32
View File
@@ -1,5 +1,4 @@
import QtQuick
import QtQuick.Effects
import QtQuick.Shapes
import qs.Common
import qs.Services
@@ -245,17 +244,6 @@ Item {
size: Theme.iconSize * 2
color: Theme.primary
anchors.verticalCenter: parent.verticalCenter
layer.enabled: Theme.elevationEnabled
layer.effect: MultiEffect {
shadowEnabled: Theme.elevationEnabled
shadowHorizontalOffset: Theme.elevationOffsetX(Theme.elevationLevel1)
shadowVerticalOffset: Theme.elevationOffsetY(Theme.elevationLevel1, 1)
shadowBlur: Theme.elevationEnabled ? Math.max(0, Math.min(1, (Theme.elevationLevel1 && Theme.elevationLevel1.blurPx !== undefined ? Theme.elevationLevel1.blurPx : 4) / Theme.elevationBlurMax)) : 0
blurMax: Theme.elevationBlurMax
shadowColor: Theme.elevationShadowColor(Theme.elevationLevel1)
shadowOpacity: Theme.elevationLevel1 && Theme.elevationLevel1.alpha !== undefined ? Theme.elevationLevel1.alpha : 0.2
}
}
Column {
@@ -820,16 +808,6 @@ Item {
property var pos: WeatherService.getSkyArcPosition(skyBox.currentDate, false)
x: (pos?.h ?? 0) * skyBox.effectiveWidth - (moonPhase.width / 2) + skyBox.hMargin
y: (pos?.v ?? 0) * -(skyBox.effectiveHeight / 2) + skyBox.effectiveHeight / 2 - (moonPhase.height / 2) + skyBox.vMargin
layer.enabled: Theme.elevationEnabled
layer.effect: MultiEffect {
shadowEnabled: Theme.elevationEnabled
shadowHorizontalOffset: Theme.elevationOffsetX(Theme.elevationLevel2)
shadowVerticalOffset: Theme.elevationOffsetY(Theme.elevationLevel2, 4)
shadowBlur: Theme.elevationEnabled ? Math.max(0, Math.min(1, (Theme.elevationLevel2 && Theme.elevationLevel2.blurPx !== undefined ? Theme.elevationLevel2.blurPx : 8) / Theme.elevationBlurMax)) : 0
blurMax: Theme.elevationBlurMax
shadowColor: Theme.elevationShadowColor(Theme.elevationLevel2)
}
}
DankIcon {
@@ -842,16 +820,6 @@ Item {
property var pos: WeatherService.getSkyArcPosition(skyBox.currentDate, true)
x: (pos?.h ?? 0) * skyBox.effectiveWidth - (sun.width / 2) + skyBox.hMargin
y: (pos?.v ?? 0) * -(skyBox.effectiveHeight / 2) + skyBox.effectiveHeight / 2 - (sun.height / 2) + skyBox.vMargin
layer.enabled: Theme.elevationEnabled
layer.effect: MultiEffect {
shadowEnabled: Theme.elevationEnabled
shadowHorizontalOffset: Theme.elevationOffsetX(Theme.elevationLevel2)
shadowVerticalOffset: Theme.elevationOffsetY(Theme.elevationLevel2, 4)
shadowBlur: Theme.elevationEnabled ? Math.max(0, Math.min(1, (Theme.elevationLevel2 && Theme.elevationLevel2.blurPx !== undefined ? Theme.elevationLevel2.blurPx : 8) / Theme.elevationBlurMax)) : 0
blurMax: Theme.elevationBlurMax
shadowColor: Theme.elevationShadowColor(Theme.elevationLevel2)
}
}
}
}
@@ -993,6 +961,7 @@ Item {
ListView {
id: hourlyList
anchors.fill: parent
reuseItems: true
orientation: ListView.Horizontal
spacing: Theme.spacingS
clip: true
@@ -1079,6 +1048,7 @@ Item {
ListView {
id: dailyList
anchors.fill: parent
reuseItems: true
orientation: ListView.Horizontal
spacing: Theme.spacingS
clip: true
+5 -31
View File
@@ -1,7 +1,7 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Effects
import Quickshell.Widgets
import qs.Common
import qs.Services
import qs.Widgets
@@ -567,24 +567,11 @@ Rectangle {
border.color: isSelected ? Theme.primary : Theme.withAlpha(Theme.primary, 0)
border.width: isSelected ? 2 : 0
Rectangle {
id: gridProgressMask
ClippingRectangle {
anchors.fill: parent
radius: parent.radius
visible: false
layer.enabled: true
}
Item {
anchors.fill: parent
color: "transparent"
visible: gridButtonRect.isHolding
layer.enabled: gridButtonRect.isHolding
layer.effect: MultiEffect {
maskEnabled: true
maskSource: gridProgressMask
maskSpreadAtMin: 1
maskThresholdMin: 0.5
}
Rectangle {
anchors.left: parent.left
@@ -700,24 +687,11 @@ Rectangle {
border.color: isSelected ? Theme.primary : Theme.withAlpha(Theme.primary, 0)
border.width: isSelected ? 2 : 0
Rectangle {
id: listProgressMask
ClippingRectangle {
anchors.fill: parent
radius: parent.radius
visible: false
layer.enabled: true
}
Item {
anchors.fill: parent
color: "transparent"
visible: listButtonRect.isHolding
layer.enabled: listButtonRect.isHolding
layer.effect: MultiEffect {
maskEnabled: true
maskSource: listProgressMask
maskSpreadAtMin: 1
maskThresholdMin: 0.5
}
Rectangle {
anchors.left: parent.left
+78 -3
View File
@@ -13,12 +13,19 @@ Column {
property int draggedIndex: -1
property int dropTargetIndex: -1
property bool suppressShiftAnimation: false
readonly property real tabItemSize: 128 + Theme.spacingXS
property int editingIndex: -1
readonly property real tabItemSize: tabRow.dynamicTabWidth + Theme.spacingXS
signal tabSwitched(int tabIndex)
signal tabClosed(int tabIndex)
signal newTabRequested
function commitRename(index, newTitle) {
if (index >= 0)
NotepadStorageService.renameTab(index, newTitle);
editingIndex = -1;
}
function hasUnsavedChangesForTab(tab) {
if (!tab)
return false;
@@ -32,11 +39,19 @@ Column {
spacing: Theme.spacingXS
Row {
id: tabRow
width: parent.width
height: 36
spacing: Theme.spacingXS
readonly property real dynamicTabWidth: {
var count = Math.max(1, NotepadStorageService.tabs.length);
var raw = (tabScroll.width - (count - 1) * Theme.spacingXS) / count;
return Math.max(128, Math.min(300, raw));
}
ScrollView {
id: tabScroll
width: parent.width - newTabButton.width - Theme.spacingXS
height: parent.height
clip: true
@@ -57,7 +72,8 @@ Column {
readonly property bool isActive: NotepadStorageService.currentTabIndex === index
readonly property bool isHovered: tabMouseArea.containsMouse && !closeMouseArea.containsMouse
readonly property real tabWidth: 128
readonly property bool editing: root.editingIndex === index
readonly property real tabWidth: tabRow.dynamicTabWidth
property bool longPressing: false
property bool dragging: false
property point dragStartPos: Qt.point(0, 0)
@@ -138,6 +154,7 @@ Column {
StyledText {
id: tabText
visible: !delegateItem.editing
width: parent.width - (tabCloseButton.visible ? tabCloseButton.width + Theme.spacingXS : 0)
text: {
var prefix = "";
@@ -155,13 +172,54 @@ Column {
anchors.verticalCenter: parent.verticalCenter
}
TextInput {
id: renameField
visible: delegateItem.editing
enabled: delegateItem.editing
width: parent.width
anchors.verticalCenter: parent.verticalCenter
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
color: Theme.primary
selectionColor: Theme.primary
selectedTextColor: Theme.background
selectByMouse: true
clip: true
onEditingFinished: root.commitRename(index, text)
Keys.onEscapePressed: event => {
text = modelData.title || "Untitled";
root.editingIndex = -1;
event.accepted = true;
}
// A tab switch re-focuses the editor via Qt.callLater; the
// timer fires afterwards so the field keeps focus + selection.
Timer {
id: renameFocusTimer
interval: 20
repeat: false
onTriggered: {
renameField.forceActiveFocus();
renameField.selectAll();
}
}
onVisibleChanged: {
if (!visible)
return;
text = modelData.title || "Untitled";
renameFocusTimer.restart();
}
}
Rectangle {
id: tabCloseButton
width: 20
height: 20
radius: Theme.cornerRadius
color: closeMouseArea.containsMouse ? Theme.surfaceTextHover : Theme.withAlpha(Theme.surfaceTextHover, 0)
visible: NotepadStorageService.tabs.length > 1
visible: NotepadStorageService.tabs.length > 1 && !delegateItem.editing
anchors.verticalCenter: parent.verticalCenter
DankIcon {
@@ -195,11 +253,24 @@ Column {
MouseArea {
id: tabMouseArea
anchors.fill: parent
enabled: !delegateItem.editing
hoverEnabled: true
preventStealing: dragging || longPressing
cursorShape: dragging || longPressing ? Qt.ClosedHandCursor : Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton
onDoubleClicked: {
root.tabSwitched(index);
root.editingIndex = index;
}
onExited: tabTooltip.hide()
onContainsMouseChanged: {
if (containsMouse && tabText.truncated)
tabTooltip.show(modelData.title || "Untitled", delegateItem, 0, 0, "bottom");
}
onPressed: mouse => {
if (mouse.button === Qt.LeftButton && NotepadStorageService.tabs.length > 1) {
delegateItem.dragStartPos = Qt.point(mouse.x, mouse.y);
@@ -279,4 +350,8 @@ Column {
onClicked: root.newTabRequested()
}
}
DankTooltipV2 {
id: tabTooltip
}
}
+5 -21
View File
@@ -4,6 +4,7 @@ import qs.Common
import qs.Services
import qs.Widgets
import Quickshell.Services.Mpris
import Quickshell.Widgets
DankOSD {
id: root
@@ -185,10 +186,11 @@ DankOSD {
visible: false
}
Item {
id: blurredBg
ClippingRectangle {
anchors.fill: parent
visible: false
radius: Theme.cornerRadius
color: "transparent"
opacity: 0.7
MultiEffect {
anchors.centerIn: parent
@@ -203,24 +205,6 @@ DankOSD {
}
}
Rectangle {
id: bgMask
anchors.fill: parent
radius: Theme.cornerRadius
visible: false
layer.enabled: true
}
MultiEffect {
anchors.fill: parent
source: blurredBg
maskEnabled: true
maskSource: bgMask
maskThresholdMin: 0.5
maskSpreadAtMin: 1.0
opacity: 0.7
}
Rectangle {
anchors.fill: parent
radius: Theme.cornerRadius
+20 -31
View File
@@ -1,7 +1,7 @@
import QtCore
import QtQuick
import QtQuick.Effects
import Quickshell
import Quickshell.Widgets
import qs.Common
import qs.Modals.FileBrowser
import qs.Services
@@ -520,28 +520,27 @@ Item {
radius: Theme.cornerRadius
color: Theme.surfaceVariant
Image {
ClippingRectangle {
anchors.fill: parent
anchors.margins: 1
source: {
var wp = Theme.wallpaperPath;
if (!wp || wp === "" || wp.startsWith("#"))
return "";
if (wp.startsWith("file://"))
wp = wp.substring(7);
return "file://" + wp.split('/').map(s => encodeURIComponent(s)).join('/');
}
fillMode: Image.PreserveAspectCrop
visible: Theme.wallpaperPath && !Theme.wallpaperPath.startsWith("#")
sourceSize.width: 120
sourceSize.height: 120
asynchronous: true
layer.enabled: true
layer.effect: MultiEffect {
maskEnabled: true
maskSource: autoWallpaperMask
maskThresholdMin: 0.5
maskSpreadAtMin: 1
radius: Theme.cornerRadius - 1
color: "transparent"
Image {
anchors.fill: parent
source: {
var wp = Theme.wallpaperPath;
if (!wp || wp === "" || wp.startsWith("#"))
return "";
if (wp.startsWith("file://"))
wp = wp.substring(7);
return "file://" + wp.split('/').map(s => encodeURIComponent(s)).join('/');
}
fillMode: Image.PreserveAspectCrop
visible: Theme.wallpaperPath && !Theme.wallpaperPath.startsWith("#")
sourceSize.width: 120
sourceSize.height: 120
asynchronous: true
}
}
@@ -553,16 +552,6 @@ Item {
visible: Theme.wallpaperPath && Theme.wallpaperPath.startsWith("#")
}
Rectangle {
id: autoWallpaperMask
anchors.fill: parent
anchors.margins: 1
radius: Theme.cornerRadius - 1
color: "black"
visible: false
layer.enabled: true
}
DankIcon {
anchors.centerIn: parent
name: (ToastService.wallpaperErrorStatus === "error" || ToastService.wallpaperErrorStatus === "matugen_missing") ? "error" : "palette"
+64 -97
View File
@@ -1,6 +1,6 @@
import QtQuick
import QtQuick.Effects
import Quickshell
import Quickshell.Widgets
import qs.Common
import qs.Modals.FileBrowser
import qs.Services
@@ -75,28 +75,27 @@ Item {
radius: Theme.cornerRadius
color: Theme.surfaceVariant
Image {
ClippingRectangle {
anchors.fill: parent
anchors.margins: 1
source: {
var wp = root.currentWallpaper;
if (wp === "" || wp.startsWith("#"))
return "";
if (wp.startsWith("file://"))
wp = wp.substring(7);
return "file://" + wp.split('/').map(s => encodeURIComponent(s)).join('/');
}
fillMode: Image.PreserveAspectCrop
visible: root.currentWallpaper !== "" && !root.currentWallpaper.startsWith("#")
sourceSize.width: 160
sourceSize.height: 160
asynchronous: true
layer.enabled: true
layer.effect: MultiEffect {
maskEnabled: true
maskSource: wallpaperMask
maskThresholdMin: 0.5
maskSpreadAtMin: 1
radius: Theme.cornerRadius - 1
color: "transparent"
Image {
anchors.fill: parent
source: {
var wp = root.currentWallpaper;
if (wp === "" || wp.startsWith("#"))
return "";
if (wp.startsWith("file://"))
wp = wp.substring(7);
return "file://" + wp.split('/').map(s => encodeURIComponent(s)).join('/');
}
fillMode: Image.PreserveAspectCrop
visible: root.currentWallpaper !== "" && !root.currentWallpaper.startsWith("#")
sourceSize.width: 160
sourceSize.height: 160
asynchronous: true
}
}
@@ -108,16 +107,6 @@ Item {
visible: root.currentWallpaper !== "" && root.currentWallpaper.startsWith("#")
}
Rectangle {
id: wallpaperMask
anchors.fill: parent
anchors.margins: 1
radius: Theme.cornerRadius - 1
color: "black"
visible: false
layer.enabled: true
}
DankIcon {
anchors.centerIn: parent
name: "image"
@@ -421,31 +410,30 @@ Item {
radius: Theme.cornerRadius
color: Theme.surfaceVariant
Image {
ClippingRectangle {
anchors.fill: parent
anchors.margins: 1
source: {
var wp = SessionData.wallpaperPathLight;
if (wp === "" || wp.startsWith("#"))
return "";
if (wp.startsWith("file://"))
wp = wp.substring(7);
return "file://" + wp.split('/').map(s => encodeURIComponent(s)).join('/');
}
fillMode: Image.PreserveAspectCrop
visible: {
var lightWallpaper = SessionData.wallpaperPathLight;
return lightWallpaper !== "" && !lightWallpaper.startsWith("#");
}
sourceSize.width: 160
sourceSize.height: 160
asynchronous: true
layer.enabled: true
layer.effect: MultiEffect {
maskEnabled: true
maskSource: lightMask
maskThresholdMin: 0.5
maskSpreadAtMin: 1
radius: Theme.cornerRadius - 1
color: "transparent"
Image {
anchors.fill: parent
source: {
var wp = SessionData.wallpaperPathLight;
if (wp === "" || wp.startsWith("#"))
return "";
if (wp.startsWith("file://"))
wp = wp.substring(7);
return "file://" + wp.split('/').map(s => encodeURIComponent(s)).join('/');
}
fillMode: Image.PreserveAspectCrop
visible: {
var lightWallpaper = SessionData.wallpaperPathLight;
return lightWallpaper !== "" && !lightWallpaper.startsWith("#");
}
sourceSize.width: 160
sourceSize.height: 160
asynchronous: true
}
}
@@ -463,16 +451,6 @@ Item {
}
}
Rectangle {
id: lightMask
anchors.fill: parent
anchors.margins: 1
radius: Theme.cornerRadius - 1
color: "black"
visible: false
layer.enabled: true
}
DankIcon {
anchors.centerIn: parent
name: "light_mode"
@@ -611,31 +589,30 @@ Item {
radius: Theme.cornerRadius
color: Theme.surfaceVariant
Image {
ClippingRectangle {
anchors.fill: parent
anchors.margins: 1
source: {
var wp = SessionData.wallpaperPathDark;
if (wp === "" || wp.startsWith("#"))
return "";
if (wp.startsWith("file://"))
wp = wp.substring(7);
return "file://" + wp.split('/').map(s => encodeURIComponent(s)).join('/');
}
fillMode: Image.PreserveAspectCrop
visible: {
var darkWallpaper = SessionData.wallpaperPathDark;
return darkWallpaper !== "" && !darkWallpaper.startsWith("#");
}
sourceSize.width: 160
sourceSize.height: 160
asynchronous: true
layer.enabled: true
layer.effect: MultiEffect {
maskEnabled: true
maskSource: darkMask
maskThresholdMin: 0.5
maskSpreadAtMin: 1
radius: Theme.cornerRadius - 1
color: "transparent"
Image {
anchors.fill: parent
source: {
var wp = SessionData.wallpaperPathDark;
if (wp === "" || wp.startsWith("#"))
return "";
if (wp.startsWith("file://"))
wp = wp.substring(7);
return "file://" + wp.split('/').map(s => encodeURIComponent(s)).join('/');
}
fillMode: Image.PreserveAspectCrop
visible: {
var darkWallpaper = SessionData.wallpaperPathDark;
return darkWallpaper !== "" && !darkWallpaper.startsWith("#");
}
sourceSize.width: 160
sourceSize.height: 160
asynchronous: true
}
}
@@ -653,16 +630,6 @@ Item {
}
}
Rectangle {
id: darkMask
anchors.fill: parent
anchors.margins: 1
radius: Theme.cornerRadius - 1
color: "black"
visible: false
layer.enabled: true
}
DankIcon {
anchors.centerIn: parent
name: "dark_mode"
+53 -5
View File
@@ -141,16 +141,37 @@ Variants {
function invalidate() {
_settleFrames = 3;
backingWindow?.update();
if (!_wedgeBounced)
wedgeWatchdog.restart();
}
onRenderActiveChanged: invalidate()
onBackingWindowChanged: invalidate()
// No swap after a requested frame: dropped frame callback left the window
// unexposed (qtwayland mFrameCallbackTimedOut); only surface re-attach recovers.
property bool _wedgeBounced: false
Timer {
id: wedgeWatchdog
interval: 3000
repeat: false
onTriggered: {
if (!root.backingWindow || !wallpaperWindow.visible || IdleService.isShellLocked)
return;
log.warn("no frame swapped on", modelData.name, "since last invalidate, re-attaching surface");
root._wedgeBounced = true;
surfaceReattach.restart();
}
}
Connections {
target: root.backingWindow
function onFrameSwapped() {
if (root._settleFrames > 0)
root._settleFrames--;
root._wedgeBounced = false;
wedgeWatchdog.stop();
}
function onVisibleChanged() {
root.invalidate();
@@ -176,10 +197,29 @@ Variants {
function onWallpaperFillModeChanged() {
root.invalidate();
}
function onWallpaperBackgroundColorModeChanged() {
function onEffectiveWallpaperBackgroundColorChanged() {
root.invalidate();
}
function onWallpaperBackgroundCustomColorChanged() {
}
Connections {
target: SessionData
function onMonitorWallpaperFillModesChanged() {
root.invalidate();
}
function onPerMonitorWallpaperChanged() {
root.invalidate();
}
}
// Theme changes repaint DankBackdrop but nothing else wakes the render loop
Connections {
target: Theme
enabled: root.isColorSource || currentWallpaper.status === Image.Error
function onPrimaryChanged() {
root.invalidate();
}
function onBackgroundChanged() {
root.invalidate();
}
}
@@ -502,13 +542,13 @@ Variants {
}
onSourceChanged: {
invalidate();
if (!source || source.startsWith("#")) {
setWallpaperImmediate("");
return;
}
root.changePending = true;
invalidate();
const formattedSource = source.startsWith("file://") ? source : encodeFileUrl(source);
@@ -528,10 +568,14 @@ Variants {
}
function setWallpaperImmediate(newSource) {
transitionDelayTimer.stop();
transitionAnimation.stop();
root.transitionProgress = 0.0;
root.effectActive = false;
root.screenScale = CompositorService.getScreenScale(modelData);
// No status change coming to clear the flag
if (!newSource || currentWallpaper.source.toString() === newSource)
root.changePending = false;
currentWallpaper.source = newSource;
nextWallpaper.source = "";
@@ -567,10 +611,14 @@ Variants {
}
function changeWallpaper(newPath, force) {
if (!force && newPath === currentWallpaper.source)
if (!force && newPath === currentWallpaper.source.toString()) {
root.changePending = false;
return;
if (!newPath || newPath.startsWith("#"))
}
if (!newPath || newPath.startsWith("#")) {
root.changePending = false;
return;
}
root.screenScale = CompositorService.getScreenScale(modelData);
if (root.transitioning || root.effectActive) {
root.pendingWallpaper = newPath;
@@ -1,8 +1,8 @@
import QtQuick
import QtQuick.Effects
import QtQuick.Layouts
import Quickshell
import Quickshell.Wayland
import Quickshell.Widgets
import qs.Common
Item {
@@ -58,23 +58,6 @@ Item {
visible: intersectsViewport
opacity: (monitorObj?.id ?? -1) == widgetMonitorId ? 1 : 0.4
Rectangle {
id: maskRect
width: root.width
height: root.height
radius: Theme.cornerRadius
visible: false
layer.enabled: true
}
layer.enabled: true
layer.effect: MultiEffect {
maskEnabled: true
maskSource: maskRect
maskSpreadAtMin: 1
maskThresholdMin: 0.5
}
Behavior on x {
NumberAnimation {
duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewOpen)
@@ -104,51 +87,55 @@ Item {
}
}
ScreencopyView {
id: windowPreview
ClippingRectangle {
anchors.fill: parent
captureSource: root.overviewOpen ? root.toplevel?.wayland : null
live: true
radius: Theme.cornerRadius
color: "transparent"
Rectangle {
ScreencopyView {
id: windowPreview
anchors.fill: parent
radius: Theme.cornerRadius
color: pressed ? Theme.withAlpha(Theme.surfaceContainerHigh, 0.5) :
hovered ? Theme.withAlpha(Theme.surfaceVariant, 0.3) :
Theme.withAlpha(Theme.surfaceContainer, 0.1)
border.color: Theme.withAlpha(Theme.outline, 0.3)
border.width: 1
}
captureSource: root.overviewOpen ? root.toplevel?.wayland : null
live: true
ColumnLayout {
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.right: parent.right
spacing: Theme.fontSizeSmall * 0.5
Rectangle {
anchors.fill: parent
radius: Theme.cornerRadius
color: pressed ? Theme.withAlpha(Theme.surfaceContainerHigh, 0.5) : hovered ? Theme.withAlpha(Theme.surfaceVariant, 0.3) : Theme.withAlpha(Theme.surfaceContainer, 0.1)
border.color: Theme.withAlpha(Theme.outline, 0.3)
border.width: 1
}
Image {
id: windowIcon
property var iconSize: {
return Math.min(targetWindowWidth, targetWindowHeight) * (root.compactMode ? root.iconToWindowRatioCompact : root.iconToWindowRatio) / (root.monitorData?.scale ?? 1)
}
Layout.alignment: Qt.AlignHCenter
source: root.iconPath
width: iconSize
height: iconSize
sourceSize: Qt.size(iconSize, iconSize)
ColumnLayout {
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.right: parent.right
spacing: Theme.fontSizeSmall * 0.5
Behavior on width {
NumberAnimation {
duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewOpen)
easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.variantModalEnterCurve
Image {
id: windowIcon
property var iconSize: {
return Math.min(targetWindowWidth, targetWindowHeight) * (root.compactMode ? root.iconToWindowRatioCompact : root.iconToWindowRatio) / (root.monitorData?.scale ?? 1);
}
}
Behavior on height {
NumberAnimation {
duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewOpen)
easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.variantModalEnterCurve
Layout.alignment: Qt.AlignHCenter
source: root.iconPath
width: iconSize
height: iconSize
sourceSize: Qt.size(iconSize, iconSize)
Behavior on width {
NumberAnimation {
duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewOpen)
easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.variantModalEnterCurve
}
}
Behavior on height {
NumberAnimation {
duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewOpen)
easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.variantModalEnterCurve
}
}
}
}
+5 -1
View File
@@ -298,7 +298,11 @@ Singleton {
function getBuiltInLauncherItems(pluginId, query) {
if (pluginId === "dms_clipboard_search") {
const trimmed = (query || "").toString().trim();
const entries = ClipboardService.internalEntries.length > 0 ? ClipboardService.getLauncherEntries(trimmed, 20, 0) : ClipboardService.getCachedLauncherSearchEntries(trimmed, 20);
const entries = ClipboardService.getCachedLauncherSearchEntries(trimmed, 20).slice().sort((a, b) => {
if (a.pinned !== b.pinned)
return b.pinned ? 1 : -1;
return (b.id || 0) - (a.id || 0);
});
return entries.map(entry => ({
type: "clipboard",
data: entry
+22 -19
View File
@@ -841,13 +841,32 @@ EOFCONFIG
function setVolume(percentage) {
if (!root.sink?.audio)
return "No audio sink available";
if (isNaN(percentage))
return "Invalid percentage";
const maxVol = root.sinkMaxVolume;
const clampedVolume = Math.max(0, Math.min(maxVol, percentage));
root.sink.audio.volume = clampedVolume / 100;
Quickshell.execDetached(["wpctl", "set-volume", "-l", String(maxVol / 100), "@DEFAULT_AUDIO_SINK@", String(clampedVolume / 100)]);
return `Volume set to ${clampedVolume}%`;
}
function outputVolumeStep(step) {
const parsed = parseInt(step || "5");
return isNaN(parsed) ? 5 : Math.max(0, parsed);
}
function adjustDefaultSinkVolume(step, direction) {
const maxVol = root.sinkMaxVolume;
const stepValue = outputVolumeStep(step);
const currentVolume = Math.round(root.sink.audio.volume * 100);
const newVolume = Math.max(0, Math.min(maxVol, currentVolume + direction * stepValue));
const suffix = direction > 0 ? "+" : "-";
Quickshell.execDetached(["wpctl", "set-mute", "@DEFAULT_AUDIO_SINK@", "0"]);
Quickshell.execDetached(["wpctl", "set-volume", "-l", String(maxVol / 100), "@DEFAULT_AUDIO_SINK@", `${stepValue}%${suffix}`]);
return newVolume;
}
function toggleMute() {
if (!root.sink?.audio) {
return "No audio sink available";
@@ -939,15 +958,7 @@ EOFCONFIG
if (!root.sink?.audio)
return "No audio sink available";
if (root.sink.audio.muted)
root.sink.audio.muted = false;
const maxVol = root.sinkMaxVolume;
const currentVolume = Math.round(root.sink.audio.volume * 100);
const stepValue = parseInt(step || "5");
const newVolume = Math.max(0, Math.min(maxVol, currentVolume + stepValue));
root.sink.audio.volume = newVolume / 100;
const newVolume = root.adjustDefaultSinkVolume(step, 1);
return `Volume increased to ${newVolume}%`;
}
@@ -955,15 +966,7 @@ EOFCONFIG
if (!root.sink?.audio)
return "No audio sink available";
if (root.sink.audio.muted)
root.sink.audio.muted = false;
const maxVol = root.sinkMaxVolume;
const currentVolume = Math.round(root.sink.audio.volume * 100);
const stepValue = parseInt(step || "5");
const newVolume = Math.max(0, Math.min(maxVol, currentVolume - stepValue));
root.sink.audio.volume = newVolume / 100;
const newVolume = root.adjustDefaultSinkVolume(step, -1);
return `Volume decreased to ${newVolume}%`;
}
+2 -2
View File
@@ -12,8 +12,8 @@ Singleton {
id: root
readonly property var log: Log.scoped("ChangelogService")
readonly property string currentVersion: "1.4"
readonly property bool changelogEnabled: false
readonly property string currentVersion: "1.5"
readonly property bool changelogEnabled: true
readonly property string configDir: Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation)) + "/DankMaterialShell"
readonly property string changelogMarkerPath: configDir + "/.changelog-" + currentVersion
+19 -84
View File
@@ -30,7 +30,6 @@ Singleton {
property int selectedIndex: 0
property bool keyboardNavigationActive: false
property int refCount: 0
property real _launcherLastRefresh: 0
property bool _launcherCacheValid: false
property string _launcherCachedQuery: ""
property var _launcherCachedEntries: []
@@ -85,31 +84,30 @@ Singleton {
}
function updateFilteredModel() {
let filtered = internalEntries;
const query = searchText.trim().toLowerCase();
const filterAll = activeFilter === "all";
const unpinned = [];
const pinned = [];
if (activeFilter !== "all") {
filtered = filtered.filter(entry => getEntryType(entry) === activeFilter);
for (let i = 0; i < internalEntries.length; i++) {
const entry = internalEntries[i];
if (!filterAll && getEntryType(entry) !== activeFilter)
continue;
if (query.length > 0 && !entry.preview.toLowerCase().includes(query))
continue;
(entry.pinned ? pinned : unpinned).push(entry);
}
const query = searchText.trim();
const byIdDesc = (a, b) => b.id - a.id;
pinned.sort(byIdDesc);
unpinned.sort(byIdDesc);
if (query.length > 0) {
const lowerQuery = query.toLowerCase();
filtered = filtered.filter(entry => entry.preview.toLowerCase().includes(lowerQuery));
}
filtered.sort((a, b) => {
if (a.pinned !== b.pinned)
return b.pinned ? 1 : -1;
return b.id - a.id;
});
clipboardEntries = filtered;
unpinnedEntries = filtered.filter(e => !e.pinned);
pinnedEntries = filtered.filter(e => e.pinned);
pinnedEntries = pinned;
unpinnedEntries = unpinned;
clipboardEntries = pinned.concat(unpinned);
totalCount = clipboardEntries.length;
const activeCount = Math.max(unpinnedEntries.length, pinnedEntries.length);
const activeCount = Math.max(unpinned.length, pinned.length);
if (activeCount === 0) {
keyboardNavigationActive = false;
@@ -117,9 +115,8 @@ Singleton {
return;
}
if (selectedIndex >= activeCount) {
if (selectedIndex >= activeCount)
selectedIndex = activeCount - 1;
}
}
function refresh() {
@@ -138,18 +135,6 @@ Singleton {
});
}
function ensureLauncherHistory() {
if (!clipboardAvailable) {
return;
}
const now = Date.now();
if (internalEntries.length === 0 || now - _launcherLastRefresh > 5000) {
_launcherLastRefresh = now;
refresh();
}
}
function requestLauncherSearch(query, limit) {
if (!clipboardAvailable) {
return;
@@ -207,56 +192,6 @@ Singleton {
_launcherSearchSeq++;
}
function getLauncherEntries(query, limit, minLength) {
if (!clipboardAvailable) {
return [];
}
const trimmed = (query || "").toString().trim();
const requiredLength = minLength !== undefined ? minLength : 2;
if (trimmed.length < requiredLength) {
return [];
}
const lowerQuery = trimmed.toLowerCase();
const maxItems = limit > 0 ? limit : 8;
const matches = [];
for (var i = 0; i < internalEntries.length; i++) {
const entry = internalEntries[i];
const preview = getEntryPreview(entry).toString();
const typeText = entry.isImage ? "image picture screenshot clipboard" : "text clipboard";
const haystack = (preview + " " + typeText).toLowerCase();
if (haystack.indexOf(lowerQuery) === -1) {
continue;
}
matches.push(entry);
}
matches.sort((a, b) => {
if (a.pinned !== b.pinned)
return b.pinned ? 1 : -1;
return (b.id || 0) - (a.id || 0);
});
return matches.slice(0, maxItems);
}
function getRecentLauncherEntries(limit) {
if (!clipboardAvailable) {
return [];
}
const maxItems = limit > 0 ? limit : 20;
const entries = internalEntries.slice();
entries.sort((a, b) => {
if (a.pinned !== b.pinned)
return b.pinned ? 1 : -1;
return (b.id || 0) - (a.id || 0);
});
return entries.slice(0, maxItems);
}
function reset() {
searchText = "";
selectedIndex = 0;
@@ -0,0 +1,76 @@
pragma Singleton
pragma ComponentBehavior: Bound
import Quickshell
import QtQuick
import qs.Common
import qs.Services
// Accent color extracted from the current track's album art via ColorQuantizer,
// falling back to Theme.primary when no usable accent is available.
Singleton {
id: root
readonly property bool hasAccent: _accent !== null
readonly property color accent: _accent !== null ? _accent : Theme.primary
readonly property color onAccent: {
const c = accent;
const lum = 0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b;
return lum > 0.6 ? Qt.rgba(0, 0, 0, 1) : Qt.rgba(1, 1, 1, 1);
}
readonly property color accentHover: Theme.withAlpha(accent, 0.12)
readonly property color accentPressed: Theme.withAlpha(accent, Theme.transparentBlurLayers ? 0.24 : 0.16)
readonly property color accentTrack: Theme.withAlpha(accent, 0.28)
readonly property color accentSubtle: Theme.withAlpha(accent, 0.55)
// Plain-named alias: underscore-prefixed props with onChanged handlers crash config load.
readonly property string artUrl: TrackArtService.resolvedArtUrl
onArtUrlChanged: {
if (artUrl === "")
_accent = null;
}
property var _accent: null
ColorQuantizer {
id: quantizer
source: root.artUrl
depth: 4
rescaleSize: 64
onColorsChanged: root._accent = root._pickAccent(colors)
}
function _pickAccent(colors) {
if (!colors || colors.length === 0)
return null;
let best = null;
let bestScore = -1;
for (let i = 0; i < colors.length; i++) {
const c = colors[i];
const s = c.hsvSaturation;
const v = c.hsvValue;
if (v < 0.22 || v > 0.96 || s < 0.22)
continue;
const score = s * (1 - Math.abs(v - 0.68));
if (score > bestScore) {
bestScore = score;
best = c;
}
}
if (!best)
return null;
return _normalize(best);
}
function _normalize(c) {
const hue = c.hsvHue < 0 ? 0 : c.hsvHue;
const s = Math.min(1, c.hsvSaturation * 1.05);
const v = Math.max(c.hsvValue, 0.62);
return Qt.hsva(hue, s, v, 1);
}
}
+227 -276
View File
@@ -29,45 +29,48 @@ Singleton {
function setSessionBuffer(tabId, content, baseline) {
if (tabId === undefined || tabId === null || tabId < 0)
return
var next = Object.assign({}, sessionBuffers)
return;
var next = Object.assign({}, sessionBuffers);
if (content !== baseline) {
next[tabId] = { content: content, baseline: baseline }
next[tabId] = {
content: content,
baseline: baseline
};
} else {
delete next[tabId]
delete next[tabId];
}
sessionBuffers = next
sessionBufferRevision++
sessionBuffers = next;
sessionBufferRevision++;
}
function getSessionBuffer(tabId) {
return sessionBuffers[tabId]
return sessionBuffers[tabId];
}
function clearSessionBuffer(tabId) {
if (sessionBuffers[tabId] === undefined)
return
var next = Object.assign({}, sessionBuffers)
delete next[tabId]
sessionBuffers = next
sessionBufferRevision++
return;
var next = Object.assign({}, sessionBuffers);
delete next[tabId];
sessionBuffers = next;
sessionBufferRevision++;
}
property var conflictTabId: -1
property string conflictDiskContent: ""
function flagConflict(tabId, diskContent) {
conflictDiskContent = diskContent
conflictTabId = tabId
conflictDiskContent = diskContent;
conflictTabId = tabId;
}
function clearConflict() {
conflictTabId = -1
conflictDiskContent = ""
conflictTabId = -1;
conflictDiskContent = "";
}
Component.onCompleted: {
ensureDirectories()
ensureDirectories();
}
FileView {
@@ -78,64 +81,66 @@ Singleton {
onLoaded: {
try {
var data = JSON.parse(text())
root.tabs = data.tabs || []
root.currentTabIndex = data.currentTabIndex || 0
root.metadataLoaded = true
root.validateTabs()
} catch(e) {
log.warn("Failed to parse notepad metadata:", e)
root.createDefaultTab()
var data = JSON.parse(text());
root.tabs = data.tabs || [];
root.currentTabIndex = data.currentTabIndex || 0;
root.metadataLoaded = true;
root.validateTabs();
} catch (e) {
log.warn("Failed to parse notepad metadata:", e);
root.createDefaultTab();
}
}
onLoadFailed: {
root.createDefaultTab()
root.createDefaultTab();
}
}
onRefCountChanged: {
if (refCount === 1 && !metadataLoaded) {
metadataFile.path = ""
metadataFile.path = root.metadataPath
metadataFile.path = "";
metadataFile.path = root.metadataPath;
}
}
function ensureDirectories() {
mkdirProcess.running = true
Proc.runCommand("", ["mkdir", "-p", root.baseDir, root.filesDir], null);
}
function loadMetadata() {
metadataFile.path = ""
metadataFile.path = root.metadataPath
metadataFile.path = "";
metadataFile.path = root.metadataPath;
}
function createDefaultTab() {
var id = Date.now()
var filePath = "notepad-files/untitled-" + id + ".txt"
var fullPath = baseDir + "/" + filePath
var id = Date.now();
var filePath = "notepad-files/untitled-" + id + ".txt";
var fullPath = baseDir + "/" + filePath;
var newTabsBeingCreated = Object.assign({}, tabsBeingCreated)
newTabsBeingCreated[id] = true
tabsBeingCreated = newTabsBeingCreated
var newTabsBeingCreated = Object.assign({}, tabsBeingCreated);
newTabsBeingCreated[id] = true;
tabsBeingCreated = newTabsBeingCreated;
root.createEmptyFile(fullPath, function() {
root.tabs = [{
id: id,
title: I18n.tr("Untitled"),
filePath: filePath,
isTemporary: true,
lastModified: new Date().toISOString(),
cursorPosition: 0,
scrollPosition: 0
}]
root.currentTabIndex = 0
root.createEmptyFile(fullPath, function () {
root.tabs = [
{
id: id,
title: I18n.tr("Untitled"),
filePath: filePath,
isTemporary: true,
lastModified: new Date().toISOString(),
cursorPosition: 0,
scrollPosition: 0
}
];
root.currentTabIndex = 0;
var updatedTabsBeingCreated = Object.assign({}, tabsBeingCreated)
delete updatedTabsBeingCreated[id]
tabsBeingCreated = updatedTabsBeingCreated
root.saveMetadata()
})
var updatedTabsBeingCreated = Object.assign({}, tabsBeingCreated);
delete updatedTabsBeingCreated[id];
tabsBeingCreated = updatedTabsBeingCreated;
root.saveMetadata();
});
}
function saveMetadata() {
@@ -143,82 +148,73 @@ Singleton {
version: 1,
currentTabIndex: currentTabIndex,
tabs: tabs
}
metadataFile.setText(JSON.stringify(metadata, null, 2))
};
metadataFile.setText(JSON.stringify(metadata, null, 2));
}
function getTabById(tabId) {
for (var i = 0; i < tabs.length; i++) {
if (tabs[i].id === tabId)
return tabs[i]
return tabs[i];
}
return null
return null;
}
function loadTabContent(tabIndex, callback) {
if (tabIndex < 0 || tabIndex >= tabs.length) {
callback("")
return
callback("");
return;
}
var tab = tabs[tabIndex]
var requestTabId = tab.id
var fullPath = tab.isTemporary
? baseDir + "/" + tab.filePath
: tab.filePath
var tab = tabs[tabIndex];
var requestTabId = tab.id;
var fullPath = tab.isTemporary ? baseDir + "/" + tab.filePath : tab.filePath;
if (tabsBeingCreated[tab.id]) {
Qt.callLater(() => {
loadTabContent(tabIndex, callback)
})
return
loadTabContent(tabIndex, callback);
});
return;
}
var fileChecker = fileExistsComponent.createObject(root, {
path: fullPath,
callback: (exists) => {
var currentTab = root.getTabById(requestTabId)
var currentPath = currentTab
? (currentTab.isTemporary ? baseDir + "/" + currentTab.filePath : currentTab.filePath)
: ""
Proc.runCommand("", ["test", "-f", fullPath], (output, exitCode) => {
var currentTab = root.getTabById(requestTabId);
var currentPath = currentTab ? (currentTab.isTemporary ? baseDir + "/" + currentTab.filePath : currentTab.filePath) : "";
if (!currentTab || currentPath !== fullPath) {
callback("")
return
}
if (exists) {
var loader = tabFileLoaderComponent.createObject(root, {
path: fullPath,
callback: callback
})
} else {
log.warn("Tab file does not exist:", fullPath)
callback("")
}
if (!currentTab || currentPath !== fullPath) {
callback("");
return;
}
})
if (exitCode === 0) {
tabFileLoaderComponent.createObject(root, {
path: fullPath,
callback: callback
});
} else {
log.warn("Tab file does not exist:", fullPath);
callback("");
}
});
}
function saveTabContent(tabIndex, content) {
if (tabIndex < 0 || tabIndex >= tabs.length) return
var tab = tabs[tabIndex]
var fullPath = tab.isTemporary
? baseDir + "/" + tab.filePath
: tab.filePath
if (tabIndex < 0 || tabIndex >= tabs.length)
return;
var tab = tabs[tabIndex];
var fullPath = tab.isTemporary ? baseDir + "/" + tab.filePath : tab.filePath;
var saver = tabFileSaverComponent.createObject(root, {
path: fullPath,
content: content,
tabIndex: tabIndex
})
});
}
function createNewTab() {
var id = Date.now()
var filePath = "notepad-files/untitled-" + id + ".txt"
var fullPath = baseDir + "/" + filePath
var id = Date.now();
var filePath = "notepad-files/untitled-" + id + ".txt";
var fullPath = baseDir + "/" + filePath;
var newTab = {
id: id,
@@ -228,29 +224,29 @@ Singleton {
lastModified: new Date().toISOString(),
cursorPosition: 0,
scrollPosition: 0
}
};
var newTabsBeingCreated = Object.assign({}, tabsBeingCreated)
newTabsBeingCreated[id] = true
tabsBeingCreated = newTabsBeingCreated
createEmptyFile(fullPath, function() {
var newTabs = tabs.slice()
newTabs.push(newTab)
tabs = newTabs
currentTabIndex = tabs.length - 1
var newTabsBeingCreated = Object.assign({}, tabsBeingCreated);
newTabsBeingCreated[id] = true;
tabsBeingCreated = newTabsBeingCreated;
createEmptyFile(fullPath, function () {
var newTabs = tabs.slice();
newTabs.push(newTab);
tabs = newTabs;
currentTabIndex = tabs.length - 1;
var updatedTabsBeingCreated = Object.assign({}, tabsBeingCreated)
delete updatedTabsBeingCreated[id]
tabsBeingCreated = updatedTabsBeingCreated
saveMetadata()
})
var updatedTabsBeingCreated = Object.assign({}, tabsBeingCreated);
delete updatedTabsBeingCreated[id];
tabsBeingCreated = updatedTabsBeingCreated;
saveMetadata();
});
return newTab
return newTab;
}
function createTabForFile(path) {
var id = Date.now()
var fileName = path.split('/').pop()
var id = Date.now();
var fileName = path.split('/').pop();
var newTab = {
id: id,
@@ -260,34 +256,34 @@ Singleton {
lastModified: new Date().toISOString(),
cursorPosition: 0,
scrollPosition: 0
}
};
var newTabs = tabs.slice()
newTabs.push(newTab)
tabs = newTabs
currentTabIndex = tabs.length - 1
saveMetadata()
var newTabs = tabs.slice();
newTabs.push(newTab);
tabs = newTabs;
currentTabIndex = tabs.length - 1;
saveMetadata();
return newTab
return newTab;
}
function closeTab(tabIndex) {
if (tabIndex < 0 || tabIndex >= tabs.length) return
var newTabs = tabs.slice()
var closedTabId = newTabs[tabIndex] ? newTabs[tabIndex].id : -1
clearSessionBuffer(closedTabId)
if (tabIndex < 0 || tabIndex >= tabs.length)
return;
var newTabs = tabs.slice();
var closedTabId = newTabs[tabIndex] ? newTabs[tabIndex].id : -1;
clearSessionBuffer(closedTabId);
if (conflictTabId === closedTabId)
clearConflict()
clearConflict();
if (newTabs.length <= 1) {
var id = Date.now()
var filePath = "notepad-files/untitled-" + id + ".txt"
var id = Date.now();
var filePath = "notepad-files/untitled-" + id + ".txt";
var newTabsBeingCreated = Object.assign({}, tabsBeingCreated)
newTabsBeingCreated[id] = true
tabsBeingCreated = newTabsBeingCreated
createEmptyFile(baseDir + "/" + filePath, function() {
var newTabsBeingCreated = Object.assign({}, tabsBeingCreated);
newTabsBeingCreated[id] = true;
tabsBeingCreated = newTabsBeingCreated;
createEmptyFile(baseDir + "/" + filePath, function () {
newTabs[0] = {
id: id,
title: I18n.tr("Untitled"),
@@ -296,110 +292,129 @@ Singleton {
lastModified: new Date().toISOString(),
cursorPosition: 0,
scrollPosition: 0
}
currentTabIndex = 0
tabs = newTabs
};
currentTabIndex = 0;
tabs = newTabs;
var updatedTabsBeingCreated = Object.assign({}, tabsBeingCreated)
delete updatedTabsBeingCreated[id]
tabsBeingCreated = updatedTabsBeingCreated
saveMetadata()
})
return
var updatedTabsBeingCreated = Object.assign({}, tabsBeingCreated);
delete updatedTabsBeingCreated[id];
tabsBeingCreated = updatedTabsBeingCreated;
saveMetadata();
});
return;
} else {
var tabToDelete = newTabs[tabIndex]
var tabToDelete = newTabs[tabIndex];
if (tabToDelete && tabToDelete.isTemporary) {
deleteFile(baseDir + "/" + tabToDelete.filePath)
deleteFile(baseDir + "/" + tabToDelete.filePath);
}
newTabs.splice(tabIndex, 1)
newTabs.splice(tabIndex, 1);
if (currentTabIndex >= newTabs.length) {
currentTabIndex = newTabs.length - 1
currentTabIndex = newTabs.length - 1;
} else if (currentTabIndex > tabIndex) {
currentTabIndex -= 1
currentTabIndex -= 1;
}
}
tabs = newTabs
saveMetadata()
tabs = newTabs;
saveMetadata();
}
function switchToTab(tabIndex) {
if (tabIndex < 0 || tabIndex >= tabs.length) return
currentTabIndex = tabIndex
saveMetadata()
if (tabIndex < 0 || tabIndex >= tabs.length)
return;
currentTabIndex = tabIndex;
saveMetadata();
}
function reorderTab(fromIndex, toIndex) {
if (fromIndex < 0 || fromIndex >= tabs.length || toIndex < 0 || toIndex >= tabs.length)
return
return;
if (fromIndex === toIndex)
return
var newTabs = tabs.slice()
var moved = newTabs.splice(fromIndex, 1)[0]
newTabs.splice(toIndex, 0, moved)
tabs = newTabs
return;
var newTabs = tabs.slice();
var moved = newTabs.splice(fromIndex, 1)[0];
newTabs.splice(toIndex, 0, moved);
tabs = newTabs;
if (currentTabIndex === fromIndex) {
currentTabIndex = toIndex
currentTabIndex = toIndex;
} else if (fromIndex < currentTabIndex && toIndex >= currentTabIndex) {
currentTabIndex--
currentTabIndex--;
} else if (fromIndex > currentTabIndex && toIndex <= currentTabIndex) {
currentTabIndex++
currentTabIndex++;
}
saveMetadata()
saveMetadata();
}
function saveTabAs(tabIndex, userPath) {
if (tabIndex < 0 || tabIndex >= tabs.length) return
var tab = tabs[tabIndex]
var fileName = userPath.split('/').pop()
if (tabIndex < 0 || tabIndex >= tabs.length)
return;
var tab = tabs[tabIndex];
var fileName = userPath.split('/').pop();
if (tab.isTemporary) {
var tempPath = baseDir + "/" + tab.filePath
copyFile(tempPath, userPath)
deleteFile(tempPath)
var tempPath = baseDir + "/" + tab.filePath;
copyFile(tempPath, userPath);
deleteFile(tempPath);
}
var newTabs = tabs.slice()
var newTabs = tabs.slice();
newTabs[tabIndex] = Object.assign({}, tab, {
title: fileName,
filePath: userPath,
isTemporary: false,
lastModified: new Date().toISOString()
})
tabs = newTabs
saveMetadata()
});
tabs = newTabs;
saveMetadata();
}
function renameTab(tabIndex, newTitle) {
if (tabIndex < 0 || tabIndex >= tabs.length)
return;
var trimmed = (newTitle || "").trim();
var tab = tabs[tabIndex];
if (trimmed.length === 0 || trimmed === tab.title)
return;
if (tab.isTemporary) {
updateTabMetadata(tabIndex, {
title: trimmed
});
return;
}
var dir = tab.filePath.substring(0, tab.filePath.lastIndexOf('/') + 1);
var newPath = dir + trimmed;
moveFile(tab.filePath, newPath);
updateTabMetadata(tabIndex, {
title: trimmed,
filePath: newPath
});
}
function updateTabMetadata(tabIndex, properties) {
if (tabIndex < 0 || tabIndex >= tabs.length) return
var newTabs = tabs.slice()
var updatedTab = Object.assign({}, newTabs[tabIndex], properties)
updatedTab.lastModified = new Date().toISOString()
newTabs[tabIndex] = updatedTab
tabs = newTabs
saveMetadata()
if (tabIndex < 0 || tabIndex >= tabs.length)
return;
var newTabs = tabs.slice();
var updatedTab = Object.assign({}, newTabs[tabIndex], properties);
updatedTab.lastModified = new Date().toISOString();
newTabs[tabIndex] = updatedTab;
tabs = newTabs;
saveMetadata();
}
function validateTabs() {
var validTabs = []
var validTabs = [];
for (var i = 0; i < tabs.length; i++) {
var tab = tabs[i]
validTabs.push(tab)
var tab = tabs[i];
validTabs.push(tab);
}
tabs = validTabs
tabs = validTabs;
if (tabs.length === 0) {
root.createDefaultTab()
root.createDefaultTab();
}
}
@@ -411,29 +426,13 @@ Singleton {
preload: true
onLoaded: {
callback(text())
destroy()
callback(text());
destroy();
}
onLoadFailed: {
callback("")
destroy()
}
}
}
Component {
id: fileExistsComponent
Process {
property string path
property var callback
command: ["test", "-f", path]
Component.onCompleted: running = true
onExited: (exitCode) => {
callback(exitCode === 0)
destroy()
callback("");
destroy();
}
}
}
@@ -452,94 +451,46 @@ Singleton {
onSaved: {
if (tabIndex >= 0) {
root.updateTabMetadata(tabIndex, {})
root.updateTabMetadata(tabIndex, {});
}
if (creationCallback) {
creationCallback()
creationCallback();
}
destroy()
destroy();
}
onSaveFailed: {
log.error("Failed to save tab content")
log.error("Failed to save tab content");
if (creationCallback) {
creationCallback()
creationCallback();
}
destroy()
destroy();
}
}
}
function createEmptyFile(path, callback) {
var cleanPath = decodeURI(path.toString())
var cleanPath = decodeURI(path.toString());
if (!cleanPath.startsWith("/")) {
cleanPath = baseDir + "/" + cleanPath
cleanPath = baseDir + "/" + cleanPath;
}
var creator = fileCreatorComponent.createObject(root, {
filePath: cleanPath,
creationCallback: callback
})
Proc.runCommand("", ["touch", cleanPath], (output, exitCode) => {
if (callback)
callback();
});
}
function copyFile(source, destination) {
copyProcess.source = source
copyProcess.destination = destination
copyProcess.running = true
Proc.runCommand("", ["cp", source, destination], null);
}
function deleteFile(path) {
deleteProcess.filePath = path
deleteProcess.running = true
Proc.runCommand("", ["rm", "-f", path], null);
}
Component {
id: fileCreatorComponent
QtObject {
property string filePath
property var creationCallback
Component.onCompleted: {
var touchProcess = touchProcessComponent.createObject(this, {
filePath: filePath,
callback: creationCallback
})
}
}
}
Component {
id: touchProcessComponent
Process {
property string filePath
property var callback
command: ["touch", filePath]
Component.onCompleted: running = true
onExited: (exitCode) => {
if (callback) callback()
destroy()
}
}
}
Process {
id: copyProcess
property string source
property string destination
command: ["cp", source, destination]
}
Process {
id: deleteProcess
property string filePath
command: ["rm", "-f", filePath]
}
Process {
id: mkdirProcess
command: ["mkdir", "-p", root.baseDir, root.filesDir]
function moveFile(source, destination) {
Proc.runCommand("", ["mv", source, destination], null);
}
}
+27 -6
View File
@@ -4,6 +4,7 @@ import QtQuick
import Quickshell
import Quickshell.Wayland
import qs.Common
import qs.Services
Singleton {
id: root
@@ -850,10 +851,28 @@ Singleton {
}
}
function notepadSlideoutForFocusedScreen() {
if (!notepadSlideouts || notepadSlideouts.length === 0)
return null;
const focused = BarWidgetService.getFocusedScreenName();
if (focused) {
for (var i = 0; i < notepadSlideouts.length; i++) {
if (notepadSlideouts[i]?.modelData?.name === focused)
return notepadSlideouts[i];
}
}
return notepadSlideouts[0];
}
// Remembered presentation wins over the configured default until the user
// changes the default in settings (handled below).
readonly property string notepadResolvedMode: SessionData.notepadLastMode || SettingsData.notepadDefaultMode
function openNotepadSlideout() {
SessionData.setNotepadLastMode("slideout");
notepadPopout?.hide();
if (notepadSlideouts.length > 0) {
notepadSlideouts[0]?.show();
notepadSlideoutForFocusedScreen()?.show();
}
}
@@ -861,6 +880,7 @@ Singleton {
Connections {
target: SettingsData
function onNotepadDefaultModeChanged() {
SessionData.setNotepadLastMode(SettingsData.notepadDefaultMode);
if (SettingsData.notepadDefaultMode === "popout") {
var hadSlideout = false;
for (var i = 0; i < root.notepadSlideouts.length; i++) {
@@ -879,7 +899,7 @@ Singleton {
}
function openNotepad() {
if (SettingsData.notepadDefaultMode === "popout") {
if (notepadResolvedMode === "popout") {
openNotepadPopout();
return;
}
@@ -887,22 +907,22 @@ Singleton {
}
function closeNotepad() {
if (SettingsData.notepadDefaultMode === "popout") {
if (notepadResolvedMode === "popout") {
notepadPopout?.hide();
return;
}
if (notepadSlideouts.length > 0) {
notepadSlideouts[0]?.hide();
notepadSlideoutForFocusedScreen()?.hide();
}
}
function toggleNotepad() {
if (SettingsData.notepadDefaultMode === "popout") {
if (notepadResolvedMode === "popout") {
toggleNotepadPopout();
return;
}
if (notepadSlideouts.length > 0) {
notepadSlideouts[0]?.toggle();
notepadSlideoutForFocusedScreen()?.toggle();
}
}
@@ -912,6 +932,7 @@ Singleton {
property string _notepadPendingOpenFilePath: ""
function openNotepadPopout() {
SessionData.setNotepadLastMode("popout");
closeNotepadSlideouts();
if (notepadPopout) {
notepadPopout.show();
+34 -5
View File
@@ -14,6 +14,8 @@ Singleton {
property bool hasUwsm: false
property bool isElogind: false
property bool loginctlCommandAvailable: false
property bool systemctlCommandAvailable: false
property bool hibernateSupported: false
property bool inhibitorAvailable: true
property bool idleInhibited: false
@@ -52,6 +54,8 @@ Singleton {
repeat: false
onTriggered: {
detectElogindProcess.running = true;
detectLoginctlProcess.running = true;
detectSystemctlProcess.running = true;
detectHibernateProcess.running = true;
detectPrimeRunProcess.running = true;
if (!SettingsData.loginctlLockIntegration) {
@@ -87,6 +91,26 @@ Singleton {
}
}
Process {
id: detectLoginctlProcess
running: false
command: ["sh", "-c", "command -v loginctl"]
onExited: function (exitCode) {
loginctlCommandAvailable = (exitCode === 0);
}
}
Process {
id: detectSystemctlProcess
running: false
command: ["sh", "-c", "command -v systemctl"]
onExited: function (exitCode) {
systemctlCommandAvailable = (exitCode === 0);
}
}
Process {
id: detectHibernateProcess
running: false
@@ -325,9 +349,14 @@ Singleton {
}
}
function powerManagerCommand(action) {
const useLoginctl = isElogind || (loginctlCommandAvailable && !systemctlCommandAvailable);
return [useLoginctl ? "loginctl" : "systemctl", action];
}
function suspend() {
if (SettingsData.customPowerActionSuspend.length === 0) {
Quickshell.execDetached([isElogind ? "loginctl" : "systemctl", "suspend"]);
Quickshell.execDetached(powerManagerCommand("suspend"));
} else {
Quickshell.execDetached(["sh", "-c", SettingsData.customPowerActionSuspend]);
}
@@ -338,13 +367,13 @@ Singleton {
if (SettingsData.customPowerActionHibernate.length > 0) {
hibernateProcess.command = ["sh", "-c", SettingsData.customPowerActionHibernate];
} else {
hibernateProcess.command = [isElogind ? "loginctl" : "systemctl", "hibernate"];
hibernateProcess.command = powerManagerCommand("hibernate");
}
hibernateProcess.running = true;
}
function suspendThenHibernate() {
Quickshell.execDetached([isElogind ? "loginctl" : "systemctl", "suspend-then-hibernate"]);
Quickshell.execDetached(powerManagerCommand("suspend-then-hibernate"));
}
function suspendWithBehavior(behavior) {
@@ -359,7 +388,7 @@ Singleton {
function reboot() {
if (SettingsData.customPowerActionReboot.length === 0) {
Quickshell.execDetached([isElogind ? "loginctl" : "systemctl", "reboot"]);
Quickshell.execDetached(powerManagerCommand("reboot"));
} else {
Quickshell.execDetached(["sh", "-c", SettingsData.customPowerActionReboot]);
}
@@ -367,7 +396,7 @@ Singleton {
function poweroff() {
if (SettingsData.customPowerActionPowerOff.length === 0) {
Quickshell.execDetached([isElogind ? "loginctl" : "systemctl", "poweroff"]);
Quickshell.execDetached(powerManagerCommand("poweroff"));
} else {
Quickshell.execDetached(["sh", "-c", SettingsData.customPowerActionPowerOff]);
}
+30 -5
View File
@@ -56,18 +56,19 @@ Singleton {
function loadArtwork(url) {
if (!url || url === "") {
resolvedArtUrl = "";
// Keep stale art; only blank once the empty url debounce settles.
_lastArtUrl = "";
loading = false;
_clearDebounce.restart();
return;
}
_clearDebounce.stop();
if (url === _lastArtUrl)
return;
_lastArtUrl = url;
if (url.startsWith("http://") || url.startsWith("https://")) {
loading = true;
resolvedArtUrl = ""; // Clear stale artwork immediately while loading
const targetUrl = url;
const hash = djb2Hash(url);
const cacheDir = Paths.strip(Paths.imagecache);
@@ -134,19 +135,30 @@ Singleton {
}
loading = true;
resolvedArtUrl = ""; // Clear stale artwork immediately while verifying local file
const localUrl = url;
const filePath = url.startsWith("file://") ? url.substring(7) : url;
Proc.runCommand(null, ["test", "-f", filePath], (output, exitCode) => {
// Cover file often lands after the metadata update, so poll briefly.
Proc.runCommand(null, ["sh", "-c", "for i in $(seq 20); do [ -f \"$1\" ] && exit 0; sleep 0.15; done; exit 1", "sh", filePath], (output, exitCode) => {
if (_lastArtUrl !== localUrl)
return;
resolvedArtUrl = exitCode === 0 ? localUrl : "";
loading = false;
}, 200);
}, 50, 5000);
}
Timer {
id: _clearDebounce
interval: 800
onTriggered: {
if (root._lastArtUrl === "")
root.resolvedArtUrl = "";
}
}
property MprisPlayer activePlayer: MprisController.activePlayer
property string _resolvedTrackKey: ""
onActivePlayerChanged: _updateArtUrl()
Connections {
@@ -157,8 +169,21 @@ Singleton {
function onMetadataChanged() { root._updateArtUrl(); }
}
function _trackKey() {
const p = activePlayer;
if (!p)
return "";
return (p.trackTitle || "") + "" + (p.trackArtist || "") + "" + (p.trackAlbum || "");
}
function _updateArtUrl() {
const url = getArtworkUrl(activePlayer);
const key = _trackKey();
// Ignore metadata jitter once resolved, but only when the url still matches
// (trackArtUrl can update before the title, and skipping then wedges old art).
if (key !== "" && key === _resolvedTrackKey && url === _lastArtUrl && resolvedArtUrl !== "")
return;
_resolvedTrackKey = key;
loadArtwork(url);
}
}
+87
View File
@@ -0,0 +1,87 @@
#version 450
layout(location = 0) in vec2 qt_TexCoord0;
layout(location = 0) out vec4 fragColor;
layout(std140, binding = 0) uniform buf {
mat4 qt_Matrix;
float qt_Opacity;
float phase;
float spin;
float sizePx;
float baseRadiusPx;
float amplitudePx;
float activation;
float energy;
vec2 bandsB;
vec4 bandsA;
vec4 fillColor;
} ubuf;
const float TAU = 6.28318530718;
const float NOISE_FREQ = 1.25;
const float ORBIT_RADIUS = 1.0;
const float WARP_STRENGTH = 0.75;
const float RING_BIAS = 0.25;
const float MID_FREQ = 2.4;
const float MID_GAIN = 0.34;
const float HIGH_FREQ = 5.0;
const float HIGH_GAIN = 0.16;
const float LOBE_MAX = 1.3;
float hash(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float vnoise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
vec2 u = f * f * (3.0 - 2.0 * f);
float a = hash(i);
float b = hash(i + vec2(1.0, 0.0));
float c = hash(i + vec2(0.0, 1.0));
float d = hash(i + vec2(1.0, 1.0));
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
float fbm(vec2 p) {
return 0.55 * vnoise(p) + 0.3 * vnoise(p * 2.0 + vec2(17.1, 9.2)) + 0.15 * vnoise(p * 3.9 + vec2(4.3, 21.7));
}
void main() {
vec2 p = (qt_TexCoord0 - 0.5) * ubuf.sizePx;
float r = length(p);
float aa = max(fwidth(r), 0.75);
float T = ubuf.phase * TAU;
vec2 dir = r > 0.001 ? p / r : vec2(1.0, 0.0);
float cs = cos(ubuf.spin);
float sn = sin(ubuf.spin);
dir = vec2(dir.x * cs - dir.y * sn, dir.x * sn + dir.y * cs);
vec2 q = dir * NOISE_FREQ;
vec2 orbitA = vec2(cos(T), sin(T)) * ORBIT_RADIUS;
vec2 orbitB = vec2(cos(2.0 * T + 2.1), sin(2.0 * T + 2.1)) * (ORBIT_RADIUS * 0.6);
float w1 = fbm(q + orbitA);
float w2 = fbm(q * 1.3 + orbitB + vec2(3.7, 7.3));
float n = fbm(q + orbitA + WARP_STRENGTH * (vec2(w1, w2) - 0.5));
float mid = max(ubuf.bandsA.z, ubuf.bandsA.w);
float high = max(ubuf.bandsB.x, ubuf.bandsB.y);
vec2 orbitM = vec2(cos(3.0 * T + 0.7), sin(3.0 * T + 0.7)) * 0.9;
vec2 orbitH = vec2(cos(5.0 * T + 4.2), sin(5.0 * T + 4.2)) * 1.2;
float midTerm = (vnoise(dir * MID_FREQ + orbitM) - 0.5) * MID_GAIN * mid;
float highTerm = (vnoise(dir * HIGH_FREQ + orbitH) - 0.5) * HIGH_GAIN * high;
float shaped = smoothstep(0.34, 0.7, n);
float body = ubuf.energy * (RING_BIAS + (1.0 - RING_BIAS) * shaped);
float lobe = clamp(body + midTerm + highTerm, 0.0, LOBE_MAX);
float blobR = ubuf.baseRadiusPx + ubuf.activation * ubuf.amplitudePx * lobe;
float mask = 1.0 - smoothstep(-aa, aa, r - blobR);
float a = ubuf.fillColor.a * mask * ubuf.qt_Opacity;
fragColor = vec4(ubuf.fillColor.rgb * a, a);
}
+50
View File
@@ -0,0 +1,50 @@
#version 450
layout(location = 0) in vec2 qt_TexCoord0;
layout(location = 0) out vec4 fragColor;
layout(std140, binding = 0) uniform buf {
mat4 qt_Matrix;
float qt_Opacity;
float widthPx;
float heightPx;
float minH;
float maxH;
vec2 bandsB;
vec4 bandsA;
vec4 fillColor;
} ubuf;
const float BAR_W = 2.0;
const float GAP = 1.5;
const float RADIUS = 1.0;
float sdRoundBar(vec2 p, vec2 halfSize, float r) {
vec2 q = abs(p) - halfSize + vec2(r);
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;
}
void main() {
vec2 px = vec2(qt_TexCoord0.x * ubuf.widthPx, qt_TexCoord0.y * ubuf.heightPx);
float total = 6.0 * BAR_W + 5.0 * GAP;
float x0 = (ubuf.widthPx - total) * 0.5;
float slot = BAR_W + GAP;
float fi = floor((px.x - x0) / slot);
if (fi < 0.0 || fi > 5.0) {
fragColor = vec4(0.0);
return;
}
int i = int(fi);
float lvl = i == 0 ? ubuf.bandsA.x : i == 1 ? ubuf.bandsA.y : i == 2 ? ubuf.bandsA.z : i == 3 ? ubuf.bandsA.w : i == 4 ? ubuf.bandsB.x : ubuf.bandsB.y;
float h = ubuf.minH + clamp(lvl, 0.0, 1.0) * (ubuf.maxH - ubuf.minH);
float lx = px.x - x0 - fi * slot;
vec2 p = vec2(lx - BAR_W * 0.5, px.y - ubuf.heightPx * 0.5);
float d = sdRoundBar(p, vec2(BAR_W * 0.5, h * 0.5), RADIUS);
float mask = 1.0 - smoothstep(-0.6, 0.6, d);
float a = ubuf.fillColor.a * mask * ubuf.qt_Opacity;
fragColor = vec4(ubuf.fillColor.rgb * a, a);
}
+115
View File
@@ -0,0 +1,115 @@
#version 450
layout(location = 0) in vec2 qt_TexCoord0;
layout(location = 0) out vec4 fragColor;
layout(std140, binding = 0) uniform buf {
mat4 qt_Matrix;
float qt_Opacity;
float widthPx;
float heightPx;
float value;
float actualValue;
float phase;
float ampPx;
float wavelengthPx;
float lineWidthPx;
float showActual;
vec4 fillColor;
vec4 trackColor;
vec4 playheadColor;
vec4 actualColor;
} ubuf;
const float TAU = 6.28318530718;
const float AA = 0.75; // pixel-space antialias band
// Signed distance to a rounded box centered at the origin.
float sdRoundBar(vec2 p, vec2 halfSize, float r) {
vec2 q = abs(p) - halfSize + vec2(r);
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;
}
// Composite a straight-alpha color over a premultiplied accumulator.
vec4 blendOver(vec4 dst, vec3 rgb, float a) {
return vec4(rgb * a + dst.rgb * (1.0 - a), a + dst.a * (1.0 - a));
}
void main() {
float w = ubuf.widthPx;
float h = ubuf.heightPx;
vec2 px = vec2(qt_TexCoord0.x * w, qt_TexCoord0.y * h);
float mid = h * 0.5;
float halfW = ubuf.lineWidthPx * 0.5;
float k = TAU / max(ubuf.wavelengthPx, 1e-3);
float playX = clamp(ubuf.value, 0.0, 1.0) * w;
float actualX = clamp(ubuf.actualValue, 0.0, 1.0) * w;
bool seeking = ubuf.showActual > 0.5;
float loX = min(playX, actualX);
float hiX = max(playX, actualX);
float fillEnd = seeking ? loX : playX; // filled progress ends here
float actStart = seeking ? loX : playX; // seek-preview segment
float actEnd = seeking ? hiX : playX;
float trackStart = seeking ? hiX : playX; // unplayed remainder
// Perpendicular distance to the animated sine stroke.
float ang = k * px.x + ubuf.phase;
float wy = mid + ubuf.ampPx * sin(ang);
float slope = ubuf.ampPx * k * cos(ang);
float dWave = abs(px.y - wy) / sqrt(1.0 + slope * slope);
float aaW = AA;
float waveStroke = 1.0 - smoothstep(halfW - aaW, halfW + aaW, dWave);
// Straight remainder line.
float dLine = abs(px.y - mid);
float aaL = AA;
float lineStroke = 1.0 - smoothstep(halfW - aaL, halfW + aaL, dLine);
vec4 col = vec4(0.0);
// 1. Track (unplayed remainder), to the right of the progress head.
{
float m = lineStroke * step(trackStart, px.x);
col = blendOver(col, ubuf.trackColor.rgb, ubuf.trackColor.a * m);
}
// 2. Seek-preview segment (only while seeking).
if (seeking) {
float m = waveStroke * step(actStart, px.x) * step(px.x, actEnd);
col = blendOver(col, ubuf.actualColor.rgb, ubuf.actualColor.a * m);
}
// 3. Filled progress wave.
{
float m = waveStroke * step(halfW, px.x) * step(px.x, fillEnd);
// Rounded start cap.
float capS = length(px - vec2(halfW, mid + ubuf.ampPx * sin(k * halfW + ubuf.phase))) - halfW;
float capM = 1.0 - smoothstep(-aaW, aaW, capS);
m = max(m, capM * step(halfW - 1.0, px.x));
col = blendOver(col, ubuf.fillColor.rgb, ubuf.fillColor.a * m);
}
// 4. Actual-position marker (only while seeking).
if (seeking) {
float amH = max(ubuf.lineWidthPx + 4.0, 10.0);
float d = sdRoundBar(px - vec2(actualX, mid), vec2(1.0, amH * 0.5), 1.0);
float aa = AA;
float m = 1.0 - smoothstep(-aa, aa, d);
col = blendOver(col, ubuf.actualColor.rgb, ubuf.actualColor.a * m);
}
// 5. Playhead pill (on top).
{
float phW = 3.5;
float phH = max(ubuf.lineWidthPx + 12.0, 16.0);
float d = sdRoundBar(px - vec2(playX, mid), vec2(phW * 0.5, phH * 0.5), phW * 0.5);
float aa = AA;
float m = 1.0 - smoothstep(-aa, aa, d);
col = blendOver(col, ubuf.playheadColor.rgb, ubuf.playheadColor.a * m);
}
fragColor = col * ubuf.qt_Opacity;
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -85,6 +85,7 @@ Item {
anchors.fill: parent
imagePath: root.imagePath
maxCacheSize: root.iconSize * 2
animate: false
visible: root.isImage && status === Image.Ready
}
+124 -108
View File
@@ -1,5 +1,4 @@
import QtQuick
import QtQuick.Shapes
import Quickshell.Services.Mpris
import qs.Common
import qs.Services
@@ -15,6 +14,36 @@ Item {
property bool showAnimation: true
property real animationScale: 1.0
readonly property real blobBaseRadiusFactor: 0.43
readonly property real blobAmplitudeFactor: 0.115
readonly property real blobOvershoot: 1.15
readonly property real blobEnergySensitivity: 1.15
readonly property real cavaFullScale: 45
readonly property real blobAttack: 0.75
readonly property real blobRelease: 0.2
readonly property real blobBeatBoost: 2.5
readonly property real blobBeatKick: 4
readonly property real blobOnsetThreshold: 1.4
readonly property real blobSpringStiffness: 220
readonly property real blobSpringDamping: 19
readonly property real blobMorphSpeed: 0.05
readonly property real blobMorphBoost: 1.7
readonly property real blobSpinSpeed: 0.03
readonly property bool blobActive: CavaService.cavaAvailable && activePlayer?.playbackState === MprisPlaybackState.Playing && showAnimation && albumArtStatus === Image.Ready
property var smoothedBands: [0, 0, 0, 0, 0, 0]
property var slowBands: [0, 0, 0, 0, 0, 0]
property var bandTargets: [0, 0, 0, 0, 0, 0]
property var bandDisplay: [0, 0, 0, 0, 0, 0]
property var prevLevels: [0, 0, 0, 0, 0, 0]
readonly property var fluxWeights: [1.0, 1.0, 0.6, 0.6, 0.35, 0.35]
property real fluxAvg: 0.02
property real loudCtx: 0.1
property int beatCooldown: 0
property real energyTarget: 0
property real energyPos: 0
property real energyVel: 0
onActivePlayerChanged: {
lastValidArtUrl = "";
}
@@ -25,6 +54,62 @@ Item {
}
}
function updateBands() {
const vals = CavaService.values;
if (!vals || vals.length < 6)
return;
const s = smoothedBands;
const slow = slowBands;
const out = bandTargets;
const prev = prevLevels;
const w = fluxWeights;
let flux = 0;
for (let i = 0; i < 6; i++) {
const level = Math.min(Math.max(vals[i], 0), cavaFullScale) / cavaFullScale;
flux += Math.max(0, level - prev[i]) * w[i];
prev[i] = level;
const alpha = level > s[i] ? blobAttack : blobRelease;
s[i] += alpha * (level - s[i]);
slow[i] += 0.05 * (level - slow[i]);
const punch = Math.max(0, s[i] - slow[i]) * blobBeatBoost;
out[i] = Math.min(1, (0.55 * s[i] + punch) * blobEnergySensitivity);
}
const ratio = flux / Math.max(fluxAvg, 0.004);
fluxAvg += 0.06 * (flux - fluxAvg);
if (beatCooldown > 0) {
beatCooldown--;
} else if (ratio > blobOnsetThreshold && flux > 0.008) {
energyVel += blobBeatKick * Math.min(2.5, ratio - 1);
beatCooldown = 3;
}
const loud = 0.7 * Math.max(prev[0], prev[1]) + 0.3 * Math.max(prev[2], prev[3]);
loudCtx += 0.03 * (loud - loudCtx);
const surge = Math.max(0, loud / Math.max(loudCtx, 0.05) - 1);
energyTarget = Math.min(1, 0.5 * loud + 0.6 * Math.min(1, surge));
}
function stepBlob(dt) {
energyVel += (blobSpringStiffness * (energyTarget - energyPos) - blobSpringDamping * energyVel) * dt;
energyPos = Math.max(0, Math.min(blobOvershoot, energyPos + energyVel * dt));
blobEffect.energy = energyPos;
const d = bandDisplay;
const t = bandTargets;
const f = Math.min(1, dt * 14);
for (let i = 0; i < 6; i++) {
d[i] += f * (t[i] - d[i]);
}
blobEffect.bandsA = Qt.vector4d(d[0], d[1], d[2], d[3]);
blobEffect.bandsB = Qt.vector2d(d[4], d[5]);
const speed = 1 + energyPos * blobMorphBoost;
blobEffect.phase = (blobEffect.phase + dt * blobMorphSpeed * speed) % 1;
blobEffect.spin = (blobEffect.spin + dt * blobSpinSpeed) % 6.28318530718;
}
Loader {
active: activePlayer?.playbackState === MprisPlaybackState.Playing && showAnimation
sourceComponent: Component {
@@ -34,119 +119,50 @@ Item {
}
}
Shape {
id: morphingBlob
width: parent.width * 1.1
height: parent.height * 1.1
Connections {
target: CavaService
enabled: blobEffect.visible
function onValuesChanged() {
root.updateBands();
}
}
FrameAnimation {
running: blobEffect.visible
onTriggered: root.stepBlob(Math.min(frameTime, 0.05))
}
ShaderEffect {
id: blobEffect
readonly property real span: Math.min(root.width, root.height)
width: span * (root.blobBaseRadiusFactor + root.blobAmplitudeFactor * root.blobOvershoot) * 2 * root.animationScale + 4
height: width
anchors.centerIn: parent
visible: CavaService.cavaAvailable && activePlayer?.playbackState === MprisPlaybackState.Playing && showAnimation
asynchronous: false
antialiasing: true
preferredRendererType: Shape.CurveRenderer
z: 0
layer.enabled: false
visible: root.blobActive || activation > 0.004
readonly property real centerX: width / 2
readonly property real centerY: height / 2
readonly property real baseRadius: Math.min(width, height) * 0.41 * root.animationScale
readonly property int segments: 28
property real phase: 0
property real spin: 0
property real sizePx: width
property real baseRadiusPx: span * root.blobBaseRadiusFactor * root.animationScale
property real amplitudePx: span * root.blobAmplitudeFactor * root.animationScale
property real activation: root.blobActive ? 1 : 0
property real energy: 0
property vector4d bandsA: Qt.vector4d(0, 0, 0, 0)
property vector2d bandsB: Qt.vector2d(0, 0)
readonly property color accentColor: MediaAccentService.accent
property vector4d fillColor: Qt.vector4d(accentColor.r, accentColor.g, accentColor.b, accentColor.a)
property var audioLevels: {
if (!CavaService.cavaAvailable || CavaService.values.length === 0) {
return [0.5, 0.3, 0.7, 0.4, 0.6, 0.5, 0.8, 0.2, 0.9, 0.6];
}
return CavaService.values;
}
property var smoothedLevels: [0.5, 0.3, 0.7, 0.4, 0.6, 0.5, 0.8, 0.2, 0.9, 0.6]
property var cubics: []
Connections {
target: CavaService
function onValuesChanged() {
if (morphingBlob.visible) {
morphingBlob.updatePath();
}
Behavior on activation {
NumberAnimation {
duration: 550
easing.type: Easing.InOutQuad
}
}
Component {
id: cubicSegment
PathCubic {}
}
Component {
id: pathMoveComp
PathMove {}
}
Component.onCompleted: {
shapePath.pathElements.push(pathMoveComp.createObject(shapePath));
for (let i = 0; i < segments; i++) {
const seg = cubicSegment.createObject(shapePath);
shapePath.pathElements.push(seg);
cubics.push(seg);
}
updatePath();
}
function updatePath() {
if (cubics.length === 0)
return;
const alpha = 0.35;
const minLen = Math.min(smoothedLevels.length, audioLevels.length);
for (let i = 0; i < minLen; i++) {
smoothedLevels[i] += alpha * (audioLevels[i] - smoothedLevels[i]);
}
const angleStep = 2 * Math.PI / segments;
const tension3 = 0.16666667;
const startMove = shapePath.pathElements[0];
const points = new Array(segments);
for (let i = 0; i < segments; i++) {
const angle = i * angleStep;
const audioIndex = i % 10;
const rawLevel = smoothedLevels[audioIndex] || 0;
const clampedLevel = rawLevel < 0 ? 0 : (rawLevel > 100 ? 100 : rawLevel);
const audioLevel = Math.max(0.15, Math.sqrt(clampedLevel * 0.01)) * 0.5;
const radius = baseRadius * (1.0 + audioLevel);
points[i] = {
x: centerX + Math.cos(angle) * radius,
y: centerY + Math.sin(angle) * radius
};
}
startMove.x = points[0].x;
startMove.y = points[0].y;
for (let i = 0; i < segments; i++) {
const p0 = points[(i + segments - 1) % segments];
const p1 = points[i];
const p2 = points[(i + 1) % segments];
const p3 = points[(i + 2) % segments];
const seg = cubics[i];
seg.control1X = p1.x + (p2.x - p0.x) * tension3;
seg.control1Y = p1.y + (p2.y - p0.y) * tension3;
seg.control2X = p2.x - (p3.x - p1.x) * tension3;
seg.control2Y = p2.y - (p3.y - p1.y) * tension3;
seg.x = p2.x;
seg.y = p2.y;
}
}
ShapePath {
id: shapePath
fillColor: Theme.primary
strokeColor: "transparent"
strokeWidth: 0
joinStyle: ShapePath.RoundJoin
fillRule: ShapePath.WindingFill
}
fragmentShader: Qt.resolvedUrl("../Shaders/qsb/blob.frag.qsb")
}
DankCircularImage {
@@ -158,7 +174,7 @@ Item {
imageSource: artUrl || lastValidArtUrl || ""
fallbackIcon: "album"
border.color: Theme.primary
border.color: MediaAccentService.accent
border.width: 2
onImageSourceChanged: {
+45 -86
View File
@@ -1,6 +1,6 @@
import QtQuick
import QtQuick.Window
import QtQuick.Effects
import Quickshell.Widgets
import qs.Common
import qs.Widgets
@@ -13,9 +13,17 @@ Rectangle {
property bool cacheImages: true
property bool hasImage: imageSource !== ""
readonly property bool shouldProbe: imageSource !== "" && !imageSource.startsWith("image://")
// Probe with AnimatedImage first; once loaded, check frameCount to decide.
readonly property bool isAnimated: shouldProbe && probe.status === Image.Ready && probe.frameCount > 1
readonly property var activeImage: isAnimated ? probe : staticImage
readonly property bool probeSettled: probe.status === Image.Ready || probe.status === Image.Error
readonly property var activeImage: {
if (isAnimated)
return probe;
if (staticImage.status === Image.Ready)
return staticImage;
if (probe.status === Image.Ready && probe.source !== "")
return probe;
return staticImage;
}
property int imageStatus: activeImage.status
signal imageSaved(string filePath)
@@ -57,93 +65,44 @@ Rectangle {
border.color: "transparent"
border.width: 0
// Probe: loads as AnimatedImage to detect frame count.
AnimatedImage {
id: probe
ClippingRectangle {
anchors.fill: parent
anchors.margins: 2
asynchronous: true
fillMode: Image.PreserveAspectCrop
smooth: true
mipmap: true
cache: root.cacheImages
visible: false
source: root.shouldProbe ? root.imageSource : ""
}
radius: Math.min(width, height) / 2
color: "transparent"
// Static fallback: used once probe confirms the image is not animated.
Image {
id: staticImage
anchors.fill: parent
anchors.margins: 2
asynchronous: true
fillMode: Image.PreserveAspectCrop
smooth: true
mipmap: true
cache: root.cacheImages
visible: false
sourceSize.width: Math.max(width * 2, 128)
sourceSize.height: Math.max(height * 2, 128)
source: !root.shouldProbe ? root.imageSource : ""
}
// Once the probe loads, if not animated, hand off to Image and unload probe.
Connections {
target: probe
function onStatusChanged() {
if (!root.shouldProbe)
return;
switch (probe.status) {
case Image.Ready:
if (probe.frameCount <= 1) {
staticImage.source = root.imageSource;
probe.source = "";
}
break;
case Image.Error:
staticImage.source = root.imageSource;
probe.source = "";
break;
}
}
}
// If imageSource changes, reset: re-probe with AnimatedImage.
onImageSourceChanged: {
if (root.shouldProbe) {
staticImage.source = "";
probe.source = root.imageSource;
} else {
probe.source = "";
staticImage.source = root.imageSource;
}
}
MultiEffect {
anchors.fill: parent
anchors.margins: 2
source: root.activeImage
maskEnabled: true
maskSource: circularMask
visible: root.activeImage.status === Image.Ready && root.imageSource !== ""
maskThresholdMin: 0.5
maskSpreadAtMin: 1
}
Item {
id: circularMask
anchors.centerIn: parent
width: parent.width - 4
height: parent.height - 4
layer.enabled: true
layer.smooth: true
visible: false
Rectangle {
// Probes as AnimatedImage to read frameCount; retires once staticImage is ready.
AnimatedImage {
id: probe
anchors.fill: parent
radius: width / 2
color: "black"
antialiasing: true
asynchronous: true
fillMode: Image.PreserveAspectCrop
smooth: true
mipmap: true
cache: root.cacheImages
visible: root.activeImage === probe && probe.status === Image.Ready && root.imageSource !== ""
source: root.shouldProbe && (root.isAnimated || staticImage.status !== Image.Ready) ? root.imageSource : ""
}
// Takes over once the probe settles on a non-animated image, then latches.
Image {
id: staticImage
anchors.fill: parent
asynchronous: true
fillMode: Image.PreserveAspectCrop
smooth: true
mipmap: true
cache: root.cacheImages
visible: root.activeImage === staticImage && staticImage.status === Image.Ready && root.imageSource !== ""
sourceSize.width: Math.max(width * 2, 128)
sourceSize.height: Math.max(height * 2, 128)
source: {
if (!root.shouldProbe)
return root.imageSource;
if ((root.probeSettled && !root.isAnimated) || staticImage.status !== Image.Null)
return root.imageSource;
return "";
}
}
}
+3 -12
View File
@@ -1249,14 +1249,14 @@ Item {
// Fast fade duration for superseded close.
readonly property bool _supersededFade: root._supersededClose && !root.shouldBeVisible
readonly property real _targetOpacity: root._supersededClose ? (root.shouldBeVisible ? 1 : 0) : (Theme.isDirectionalEffect ? 1 : (root.shouldBeVisible ? 1 : 0))
property real publishedOpacity: _targetOpacity
readonly property real publishedOpacity: opacity
opacity: _targetOpacity
visible: _renderActive
scale: contentContainer.scaleValue
x: Theme.snap(contentContainer.animX + (rollOutAdjuster.baseWidth - width) * (1 - scale) * 0.5, root.dpr)
y: Theme.snap(contentContainer.animY + (rollOutAdjuster.baseHeight - height) * (1 - scale) * 0.5, root.dpr)
x: Theme.snap(contentContainer.animX, root.dpr)
y: Theme.snap(contentContainer.animY, root.dpr)
layer.enabled: _animating || (_fadeWithOpacity && publishedOpacity < 1)
layer.smooth: false
@@ -1276,15 +1276,6 @@ Item {
}
}
Behavior on publishedOpacity {
enabled: contentWrapper._fadeWithOpacity
NumberAnimation {
duration: contentWrapper._supersededFade ? Theme.shorterDuration : Math.round(Theme.variantDuration(animationDuration, shouldBeVisible) * Theme.variantOpacityDurationScale)
easing.type: Easing.BezierSpline
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
}
}
Connections {
target: root
function onShouldBeVisibleChanged() {
+34 -8
View File
@@ -220,6 +220,31 @@ Item {
onTriggered: root._bgCommitWindow = false
}
// An idle layer surface won't commit the cleared blur region on auto-close, so the
// blur sticks; pulse updatesEnabled false->true to force a re-commit.
property bool _blurCommitSuppress: false
Timer {
id: blurCommitPulseTimer
interval: 16
onTriggered: root._blurCommitSuppress = false
}
function _pulseBlurCommit() {
if (!backgroundWindow.visible)
return;
_blurCommitSuppress = true;
blurCommitPulseTimer.restart();
}
Connections {
target: overlayLoader.item
ignoreUnknownSignals: true
function onOverlayBlurActiveChanged() {
root._pulseBlurCommit();
}
}
function _setSurfaceGeometry(bodyX, bodyY, bodyW, bodyH) {
const newX = Theme.snap(bodyX, dpr);
const newY = Theme.snap(bodyY, dpr);
@@ -540,7 +565,7 @@ Item {
// Skip buffer updates when there's nothing to render. Briefly flipped
// true via _bgCommitWindow when _surfaceBodyW/H changes so the
// contentHoleRect mask carve-out actually commits to the compositor.
updatesEnabled: root.overlayContent !== null || root._bgCommitWindow
updatesEnabled: !root._blurCommitSuppress && (root.overlayContent !== null || root._bgCommitWindow)
WlrLayershell.namespace: root.layerNamespace + ":background"
WlrLayershell.layer: root.effectivePopoutLayer
@@ -620,13 +645,15 @@ Item {
id: popoutBlur
targetWindow: contentWindow
readonly property real s: Math.min(1, contentContainer.scaleValue)
readonly property real op: Math.max(0, Math.min(1, (morph.openProgress - 0.08) * 1.6))
readonly property real visibleScale: s * op
readonly property bool revealClipActive: root.fluidStandaloneActive
// Blur tracks the surface's scaled rect, matching the connected backend
blurX: revealClipActive ? contentContainer.x : contentContainer.x + contentContainer.width * (1 - s) * 0.5 + Theme.snap(contentContainer.animX, root.dpr)
blurY: revealClipActive ? contentContainer.y : contentContainer.y + contentContainer.height * (1 - s) * 0.5 + Theme.snap(contentContainer.animY, root.dpr)
blurWidth: root.shouldBeVisible ? (revealClipActive ? contentContainer.width : contentContainer.width * s) : 0
blurHeight: root.shouldBeVisible ? (revealClipActive ? contentContainer.height : contentContainer.height * s) : 0
blurX: revealClipActive ? contentContainer.x : contentContainer.x + contentContainer.width * (1 - visibleScale) * 0.5 + Theme.snap(contentContainer.animX, root.dpr)
blurY: revealClipActive ? contentContainer.y : contentContainer.y + contentContainer.height * (1 - visibleScale) * 0.5 + Theme.snap(contentContainer.animY, root.dpr)
blurWidth: root.shouldBeVisible ? (revealClipActive ? contentContainer.width : contentContainer.width * visibleScale) : 0
blurHeight: root.shouldBeVisible ? (revealClipActive ? contentContainer.height : contentContainer.height * visibleScale) : 0
blurRadius: Theme.cornerRadius
clipEnabled: revealClipActive
clipX: contentContainer.x + contentContainer.revealX
@@ -742,8 +769,7 @@ Item {
QtObject {
id: morph
property real openProgress: 0
onOpenProgressChanged: if (root.fluidStandaloneActive)
root._kickBlurCommit()
onOpenProgressChanged: root._kickBlurCommit()
Behavior on openProgress {
enabled: root.animationsEnabled
NumberAnimation {
@@ -846,7 +872,7 @@ Item {
x: Theme.snap(contentContainer.animX + (rollOutAdjuster.baseWidth - width) * (1 - contentContainer.scaleValue) * 0.5, root.dpr)
y: Theme.snap(contentContainer.animY + (rollOutAdjuster.baseHeight - height) * (1 - contentContainer.scaleValue) * 0.5, root.dpr)
layer.enabled: !Theme.isDirectionalEffect && publishedOpacity < 1
layer.enabled: !Theme.isDirectionalEffect && _renderActive
layer.smooth: false
layer.textureSize: Qt.size(0, 0)
+6 -3
View File
@@ -142,7 +142,10 @@ Item {
value: root.value
actualValue: root.playerValue
showActualPlaybackState: root.isSeeking
actualProgressColor: Theme.onSurface_38
fillColor: MediaAccentService.accent
playheadColor: MediaAccentService.accent
trackColor: MediaAccentService.accentTrack
actualProgressColor: MediaAccentService.accentSubtle
isPlaying: activePlayer && activePlayer.playbackState === MprisPlaybackState.Playing
MouseArea {
@@ -179,8 +182,8 @@ Item {
Item {
property real lineWidth: 3
property color trackColor: Theme.withAlpha(Theme.surfaceVariant, 0.40)
property color fillColor: Theme.primary
property color playheadColor: Theme.primary
property color fillColor: MediaAccentService.accent
property color playheadColor: MediaAccentService.accent
property color actualProgressColor: Theme.onSurface_38
readonly property real midY: height / 2
+5
View File
@@ -256,6 +256,11 @@ FocusScope {
}
}
function snapIndicator() {
indicator.initialSetupComplete = false;
updateIndicator();
}
onCurrentIndexChanged: {
Qt.callLater(updateIndicator);
}
+20 -234
View File
@@ -1,7 +1,8 @@
import QtQuick
import QtQuick.Shapes
import qs.Common
// Wave progress indicator: track, animated fill, seek preview and playhead are
// all drawn in a single fragment shader (Shaders/frag/wave_progress.frag).
Item {
id: root
@@ -19,19 +20,6 @@ Item {
property color playheadColor: Theme.primary
property color actualProgressColor: Theme.onSurface_38
property real dpr: (root.window ? root.window.devicePixelRatio : 1)
function snap(v) {
return Math.round(v * dpr) / dpr;
}
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 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 {
NumberAnimation {
duration: 300
@@ -39,236 +27,34 @@ Item {
}
}
onIsPlayingChanged: currentAmp = isPlaying ? amp : 0
Component.onCompleted: currentAmp = isPlaying ? amp : 0
Shape {
id: flatTrack
ShaderEffect {
anchors.fill: parent
antialiasing: true
preferredRendererType: Shape.CurveRenderer
layer.enabled: true
blending: true
ShapePath {
strokeColor: root.trackColor
strokeWidth: snap(root.lineWidth)
capStyle: ShapePath.RoundCap
joinStyle: ShapePath.RoundJoin
fillColor: "transparent"
PathMove {
id: flatStart
x: Math.min(root.width, snap(root.playX + playhead.width / 2))
y: root.midY
}
PathLine {
id: flatEnd
x: root.width
y: root.midY
}
}
readonly property real widthPx: width
readonly property real heightPx: height
readonly property real value: root.value
readonly property real actualValue: root.actualValue
readonly property real phase: root.phase
readonly property real ampPx: root.currentAmp
readonly property real wavelengthPx: root.wavelength
readonly property real lineWidthPx: root.lineWidth
readonly property real showActual: root.showActualPlaybackState ? 1.0 : 0.0
readonly property color fillColor: root.fillColor
readonly property color trackColor: root.trackColor
readonly property color playheadColor: root.playheadColor
readonly property color actualColor: root.actualProgressColor
fragmentShader: Qt.resolvedUrl("../Shaders/qsb/wave_progress.frag.qsb")
}
Item {
id: waveClip
anchors.fill: parent
clip: true
readonly property real startX: snap(root.lineWidth / 2)
readonly property real aaBias: (0.25 / root.dpr)
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 {
id: mask
anchors.top: parent.top
anchors.bottom: parent.bottom
x: 0
width: waveClip.endX
color: "transparent"
clip: true
Shape {
id: waveShape
anchors.top: parent.top
anchors.bottom: parent.bottom
width: parent.width + 4 * root.wavelength
antialiasing: true
preferredRendererType: Shape.CurveRenderer
x: waveOffsetX
ShapePath {
id: wavePath
strokeColor: root.fillColor
strokeWidth: snap(root.lineWidth)
capStyle: ShapePath.RoundCap
joinStyle: ShapePath.RoundJoin
fillColor: "transparent"
PathSvg {
id: waveSvg
path: ""
}
}
}
}
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 {
id: startCap
width: snap(root.lineWidth)
height: snap(root.lineWidth)
radius: width / 2
color: root.fillColor
x: waveClip.startX - width / 2
y: waveY(waveClip.startX) - height / 2
visible: waveClip.endX > waveClip.startX
z: 2
}
Rectangle {
id: endCap
width: snap(root.lineWidth)
height: snap(root.lineWidth)
radius: width / 2
color: root.fillColor
x: waveClip.endX - width / 2
y: waveY(waveClip.endX) - height / 2
visible: waveClip.endX > waveClip.startX
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 {
id: playhead
width: 3.5
height: Math.max(root.lineWidth + 12, 16)
radius: width / 2
color: root.playheadColor
x: root.playX - width / 2
y: root.midY - height / 2
z: 3
}
property real k: (2 * Math.PI) / Math.max(1e-6, wavelength)
function wrapMod(a, m) {
let r = a % m;
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)
FrameAnimation {
running: root.visible && (root.isPlaying || root.currentAmp > 0)
onTriggered: {
if (root.isPlaying)
root.phase += 0.03 * frameTime * 60;
startCap.y = waveY(waveClip.startX) - startCap.height / 2;
endCap.y = waveY(waveClip.endX) - endCap.height / 2;
actualEndCap.y = waveY(waveClip.gapEndX) - actualEndCap.height / 2;
}
}
function buildStaticWave() {
const start = waveClip.startX - 2 * root.wavelength;
const end = width + 2 * root.wavelength;
if (end <= start) {
waveSvg.path = "";
return;
}
const kLocal = k;
const halfPeriod = root.wavelength / 2;
function y0(x) {
return root.midY + root.currentAmp * Math.sin(kLocal * x);
}
function dy0(x) {
return root.currentAmp * Math.cos(kLocal * x) * kLocal;
}
let x0 = start;
let d = `M ${x0} ${y0(x0)}`;
while (x0 < end) {
const x1 = Math.min(x0 + halfPeriod, end);
const dx = x1 - x0;
const yA = y0(x0), yB = y0(x1);
const dyA = dy0(x0), dyB = dy0(x1);
const c1x = x0 + dx / 3;
const c1y = yA + (dyA * dx) / 3;
const c2x = x1 - dx / 3;
const c2y = yB - (dyB * dx) / 3;
d += ` C ${c1x} ${c1y} ${c2x} ${c2y} ${x1} ${yB}`;
x0 = x1;
}
waveSvg.path = d;
}
Component.onCompleted: {
currentAmp = isPlaying ? amp : 0;
buildStaticWave();
}
onWidthChanged: {
flatEnd.x = width;
buildStaticWave();
}
onHeightChanged: buildStaticWave()
onCurrentAmpChanged: buildStaticWave()
onWavelengthChanged: {
k = (2 * Math.PI) / Math.max(1e-6, wavelength);
buildStaticWave();
}
}
+2 -1
View File
@@ -31,7 +31,8 @@ LANGUAGES = {
"de": "de.json",
"sv": "sv.json",
"vi": "vi.json",
"eo": "eo.json"
"eo": "eo.json",
"ko": "ko.json"
}
def error(msg):