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

Compare commits

..

31 Commits

Author SHA1 Message Date
purian23 a575363f89 fix(notifWidget): restore hand cursor on hover
Port 1.5
2026-08-06 23:44:41 -04:00
David Mireles 3f6cd0b579 feat(registry): support multiple plugin and theme registries via env vars (#2972)
feat(registry): support multiple plugin/theme registries

closes #2763

---------

Co-authored-by: bbedward <bbedward@gmail.com>
2026-08-06 22:43:44 -04:00
dms-ci[bot] 630e9bd3cd nix: sync flake.lock to dank-qml-common submodule 2026-08-07 00:52:37 +00:00
purian23 9013464312 feat(theme/settings): dedicated floating window styling with shared chrome
- Refactor Widget Styling into Surface Styling & Floating
  Windows
- New sync per-host opacity & foreground layer control
- Shared DankFloatingWindow chrome
- Settings fix to destroy Settings window on-hide so niri floating
  rules match subsequent runs
Related: #2709
2026-08-06 20:52:14 -04:00
Huỳnh Thiện Lộc dade416ca0 feat(settings): add keyboard configuration tab for Niri (#2937)
* feat(settings): add dedicated keyboard configuration tab

* chore(settings): configure default keyboard settings and clean up embedded niri config

* settings/keyboard: emit niri keyboard config only when set, dedup setup banner, index tab

---------

Co-authored-by: bbedward <bbedward@gmail.com>
2026-08-06 18:40:17 -04:00
FormalSnake 401872881e media: add opt-in Apple Music animated album covers (#2918)
* media: add opt-in Apple Music animated album covers

* media: validate itunes match, touch files for cache

---------

Co-authored-by: bbedward <bbedward@gmail.com>
2026-08-06 14:47:51 -04:00
Huỳnh Thiện Lộc 7cb97f4462 fix(launcher): honor source badge setting (#3002) 2026-08-06 00:33:39 -04:00
Huỳnh Thiện Lộc 82b1b36b1a fix(settings): hide removed widget tooltip (#3003) 2026-08-06 00:33:17 -04:00
FormalSnake e203e76ccc powermenu: support user-defined custom power buttons (#2916) 2026-08-05 20:46:42 -04:00
FormalSnake adcafbcc8e location: add missing subscribe fan-out for location events (#2914)
handleSubscribe forwards every service except location, so neither
subscribe-all connections nor explicit location subscriptions ever
receive location events, and the shell's only source of coordinates
is a single getState pull that races the geoclue TimeThreshold and
loses. Wire location into the fan-out like loginctl/sysupdate:
initial state on subscribe, then manager pushes.
2026-08-05 19:28:33 -04:00
Huỳnh Thiện Lộc 9981fcf529 fix(slideout): yield keyboard focus when modal is open on same screen (#2902)
DankSlideout always held WlrKeyboardFocus.OnDemand regardless of modals,
causing Notepad to steal focus back when Launcher was opened.

ModalManager: clone currentModalsByScreen on mutation so QML bindings
re-evaluate when modals open/close.
DankSlideout: set keyboardFocus to None while a modal is active on the
same screen, letting the modal (launcher) claim focus exclusively.
2026-08-05 19:19:47 -04:00
lankong0522 2baf048293 feat(tray): add IPC action to open tray menus (#2701)
* feat(tray): add IPC action to open tray menus

* tray: open IPC-requested menus on the focused screen only

---------

Co-authored-by: bbedward <bbedward@gmail.com>
2026-08-05 17:31:25 -04:00
bbedward cffc33e14a display config/niri: fix nested kdl blocks as being attributed to output
state

fixes #2966
port 1.5
2026-08-05 17:02:38 -04:00
bbedward 0c811e3417 gamma: validate config before write and publish state every scheduler
wakeup

fixes #2967
port 1.5
2026-08-05 16:55:34 -04:00
bbedward 6ad46cf2c2 bar: fix shadow from tinting goth corners
fixes #2975
port 1.5
2026-08-04 22:15:34 -04:00
Thomas Kroll 34626070af fix(tray): don't dismiss click-opened tray menus from hover controller (#2979)
closeHoverSurfaces() called TrayMenuManager.closeAllMenus(), tearing down
every registered tray menu including ones opened by right-click. With
hoverPopouts enabled, moving the pointer after a right-click re-ran the
hit test and closed the menu before it could be reached.

Tag each menu with how it was opened and add closeHoverMenus(), which
only closes hover-opened menus. The hover controller now uses that.

Click-driven callers pass no argument, so byHover stays undefined and
`byHover === true` evaluates to false.

Fixes #2978


Claude-Session: https://claude.ai/code/session_01DYF9vdsaToU4Usu7kxPCNB

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 21:25:22 -04:00
bbedward 80b27b9a6a settings: remove dead keys
fixes #2977
2026-08-04 21:25:06 -04:00
jack-mil 49f968d26b fix(mpris): remove required non-null artist/title from idle condition (#2993)
The current isIdle detection only works if the player has a title _and_ artist metadata,
which is not appropriate for all cases, e.g. MPV playing a video does not set artist.

In the current implementation, after a video stops playing in MPV, and the window closes
(but MPV and the MPRIS plugin server is still running as a background process), the playback controls remain
shown on the bar, and have non-functional controls.

It is sufficient to only check for the "Stopped" playing state. The MPRIS specification
states that Playback_Status==Stopped should be used to indicate that *NO* track is currently
playing. A compliant player should set that, and should also set the CanPlay/CanPause boolean
to false is not track is being played.

See: https://specifications.freedesktop.org/mpris/latest/Player_Interface.html#Enum:Playback_Status
2026-08-04 21:22:22 -04:00
Huỳnh Thiện Lộc 99b0dc596d feat(wallpaper): support random cycling order (#2936) 2026-08-04 21:21:52 -04:00
rredlip 158c0c12d8 Changed margin of the rectangle so the pixel of the half circle are completely covered. (#2989) 2026-08-04 21:20:53 -04:00
bbedward e089948225 powermenu: add soft reboot option for systemd systems
fixes #2986
port 1.5
2026-08-04 18:04:19 -04:00
bbedward 7f2ba56e06 dank16: correct DPS lab lightness scale
fixes #2987
port 1.5
2026-08-04 17:55:53 -04:00
nozsh 6de5593216 feat(keybinds): add keybind for PAM U2F on lock screen (#2930)
* feat(keybinds): add keybind for PAM U2F on lock screen

* add: tooltip, settings json & error handling

---------
2026-08-03 22:09:53 -04:00
bbedward 365474b0d9 launcher: dispatch plugin-category items as plugin, not app
fixes #2988
port 1.5
2026-08-03 20:05:23 -04:00
bbedward dc8a47644a evdev: resolve capslock state from any available keyboard
fixes #2991
port 1.5
2026-08-03 19:59:12 -04:00
bbedward 27483e68dc dankinstall/gentoo: perform autounmask on install
fixes #2992
port 1.5
2026-08-03 19:40:00 -04:00
purian23 400a18a8ed fix(battery): reliably apply configured power profiles at shell startup
Port 1.5
2026-08-02 19:01:39 -04:00
purian23 32ddf614c3 refactor(media OSD): add previous/next UI controls
Port 1.5
2026-08-02 18:10:12 -04:00
purian23 19d919ed5c refactor(mpris): consolidate equivalent-player resolution & update track matching
Port 1.5
2026-08-02 18:09:18 -04:00
Rubén García 594a2cde19 refactor(mpris): enrich metadata across equivalent players (#2928)
* fix(mpris): enrich metadata across equivalent players

* fix(mpris): address review feedback on source stealing and title stripping

- _bestPlayingPlayer: when the active player is playing but not
  controllable, only hand ownership to a controllable peer that passes
  isSameTrack; unrelated players can no longer steal the active source.
- displayTrackTitle: keep the full trackTitle for display. Suffix
  stripping now only applies to the isSameTrack matching key, and only
  for a known app-name allowlist (YouTube, SoundCloud, browsers, etc.)
  so legitimate titles containing ' | ' are never mangled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Port 1.5
2026-08-02 17:34:19 -04:00
Connor Welsh 11287459c3 popout: re-apply surface geometry after shouldBeVisible (#2983)
Popouts sized from their content sometimes open too short and clip what
they render, then come back correct on the next open.

open() snapshots the surface geometry before setting shouldBeVisible,
and the onAligned{X,Y,Width,Height}Changed handlers are all gated on
that flag. Mapping contentWindow happens in between, and that's what
finally runs the layout Qt deferred while the window was hidden.
Content-sized popouts pick up their real height in that gap, so the
correction is dropped and the surface stays short. Re-snapshot once the
flag is set.
2026-08-02 01:30:21 -04:00
112 changed files with 4541 additions and 971 deletions
+1
View File
@@ -704,6 +704,7 @@ func getCommonCommands() []*cobra.Command {
ipcCmd, ipcCmd,
debugSrvCmd, debugSrvCmd,
pluginsCmd, pluginsCmd,
registryCmd,
dank16Cmd, dank16Cmd,
brightnessCmd, brightnessCmd,
dpmsCmd, dpmsCmd,
+67
View File
@@ -0,0 +1,67 @@
package main
import (
"fmt"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
"github.com/spf13/afero"
"github.com/spf13/cobra"
)
var registryCmd = &cobra.Command{
Use: "registry",
Short: "Manage plugin and theme registries",
Long: "Manage the registries DMS fetches plugins and themes from. The official registry is always active; additional registries can be added by name and git URL.",
}
var registryListCmd = &cobra.Command{
Use: "list",
Short: "List configured registries",
Run: func(cmd *cobra.Command, args []string) {
for _, s := range registries.Load(afero.NewOsFs()) {
suffix := ""
if s.Official() {
suffix = " (official)"
}
fmt.Printf("%s%s\n %s\n", s.Name, suffix, s.URL)
}
},
}
var registryAddCmd = &cobra.Command{
Use: "add <name> <url>",
Short: "Add a registry",
Long: "Add a registry by name and git URL. The repository must contain a plugins/ or themes/ directory in the registry format.",
Args: cobra.ExactArgs(2),
Run: func(cmd *cobra.Command, args []string) {
if err := registries.Add(afero.NewOsFs(), args[0], args[1]); err != nil {
log.Fatalf("Error adding registry: %v", err)
}
fmt.Printf("Registry added: %s\n", args[0])
},
}
var registryRemoveCmd = &cobra.Command{
Use: "remove <name>",
Short: "Remove a registry",
Args: cobra.ExactArgs(1),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) != 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
var names []string
for _, s := range registries.Load(afero.NewOsFs()) {
if !s.Official() {
names = append(names, s.Name)
}
}
return names, cobra.ShellCompDirectiveNoFileComp
},
Run: func(cmd *cobra.Command, args []string) {
if err := registries.Remove(afero.NewOsFs(), args[0]); err != nil {
log.Fatalf("Error removing registry: %v", err)
}
fmt.Printf("Registry removed: %s\n", args[0])
},
}
+1
View File
@@ -16,6 +16,7 @@ func init() {
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd) setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
updateCmd.AddCommand(updateCheckCmd) updateCmd.AddCommand(updateCheckCmd)
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd) pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
registryCmd.AddCommand(registryListCmd, registryAddCmd, registryRemoveCmd)
rootCmd.AddCommand(getCommonCommands()...) rootCmd.AddCommand(getCommonCommands()...)
rootCmd.AddCommand(authCmd) rootCmd.AddCommand(authCmd)
+22 -7
View File
@@ -313,6 +313,7 @@ func EnsureContrastDPSLstar(hexColor, hexBg string, minLc float64, isLightMode b
fg := HexToRGB(hexColor) fg := HexToRGB(hexColor)
cf := colorful.Color{R: fg.R, G: fg.G, B: fg.B} cf := colorful.Color{R: fg.R, G: fg.G, B: fg.B}
Lf, af, bf := cf.Lab() Lf, af, bf := cf.Lab()
Lf *= 100.0
dir := 1.0 dir := 1.0
if isLightMode { if isLightMode {
@@ -341,6 +342,7 @@ func EnsureContrastDPSBidirectional(hexColor, hexBg string, minLc float64, isLig
fg := HexToRGB(hexColor) fg := HexToRGB(hexColor)
cf := colorful.Color{R: fg.R, G: fg.G, B: fg.B} cf := colorful.Color{R: fg.R, G: fg.G, B: fg.B}
origL, af, bf := cf.Lab() origL, af, bf := cf.Lab()
origL *= 100.0
var darkerResult, lighterResult string var darkerResult, lighterResult string
darkerL, lighterL := origL, origL darkerL, lighterL := origL, origL
@@ -420,6 +422,24 @@ func blendHue(base, target, factor float64) float64 {
return result return result
} }
// color8 sits a fixed L* offset from the background so it keeps its dim role
// regardless of primary brightness (conventional palettes put ANSI bright
// black ~2-2.5:1 from the background, e.g. catppuccin-mocha #585b70 on #1e1e2e)
func DeriveDim(bgHex string, hue, sat float64, isLight bool) string {
offset := 22.0
if isLight {
offset = -offset
}
bgL := getLstar(bgHex)
targetL := math.Max(0, math.Min(100, bgL+offset))
tint := HSVToRGB(HSV{H: hue, S: sat, V: 0.5})
c := colorful.Color{R: tint.R, G: tint.G, B: tint.B}
_, af, bf := c.Lab()
return labToHex(targetL, af, bf)
}
func DeriveContainer(primary string, isLight bool) string { func DeriveContainer(primary string, isLight bool) string {
rgb := HexToRGB(primary) rgb := HexToRGB(primary)
hsv := RGBToHSV(rgb) hsv := RGBToHSV(rgb)
@@ -500,10 +520,7 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
gray7V := baseVal * 0.28 gray7V := baseVal * 0.28
palette.Color7 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray7S, V: gray7V})), bgColor, normalTextTarget, opts)) palette.Color7 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray7S, V: gray7V})), bgColor, normalTextTarget, opts))
gray8S := baseSat * 0.05 palette.Color8 = NewColorInfo(DeriveDim(bgColor, hsv.H, baseSat*0.05, true))
gray8V := baseVal * 0.85
dimTarget := secondaryTarget * 0.5
palette.Color8 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray8S, V: gray8V})), bgColor, dimTarget, opts))
brightRedS := math.Min(baseSat*1.0, 1.0) brightRedS := math.Min(baseSat*1.0, 1.0)
brightRedV := math.Min(baseVal*1.2, 1.0) brightRedV := math.Min(baseVal*1.2, 1.0)
@@ -559,9 +576,7 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
gray7V := math.Min(baseVal*1.05, 1.0) gray7V := math.Min(baseVal*1.05, 1.0)
palette.Color7 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray7S, V: gray7V})), bgColor, normalTextTarget, opts)) palette.Color7 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray7S, V: gray7V})), bgColor, normalTextTarget, opts))
gray8S := baseSat * 0.15 palette.Color8 = NewColorInfo(DeriveDim(bgColor, hsv.H, baseSat*0.15, false))
gray8V := baseVal * 0.65
palette.Color8 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray8S, V: gray8V})), bgColor, secondaryTarget, opts))
brightRedS := math.Min(baseSat*0.75, 1.0) brightRedS := math.Min(baseSat*0.75, 1.0)
brightRedV := math.Min(baseVal*1.35, 1.0) brightRedV := math.Min(baseVal*1.35, 1.0)
+70
View File
@@ -679,3 +679,73 @@ func TestContrastAlgorithmComparison(t *testing.T) {
t.Logf("WCAG and DPS palettes differ in %d/16 colors", differentCount) t.Logf("WCAG and DPS palettes differ in %d/16 colors", differentCount)
} }
func TestEnsureContrastDPSLightModeStaysLight(t *testing.T) {
tests := []struct {
name string
result string
bg string
minLstar float64
}{
{
name: "bidirectional adjustment",
result: EnsureContrastDPSBidirectional("#d0ccc6", "#f8f8f8", 17.5, true),
bg: "#f8f8f8",
minLstar: 20.0,
},
{
name: "lstar adjustment",
result: EnsureContrastDPSLstar("#c0c0c0", "#f8f8f8", 30.0, true),
bg: "#f8f8f8",
minLstar: 20.0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
lstar := getLstar(tt.result)
if lstar < tt.minLstar {
t.Errorf("result %s has L* %.2f on light bg %s, expected >= %.2f (collapsed to near-black)",
tt.result, lstar, tt.bg, tt.minLstar)
}
})
}
}
func TestGeneratePaletteColor8Dim(t *testing.T) {
hues := []string{"#e91e63", "#f59e0b", "#22c55e", "#06b6d4", "#8b5cf6", "#ef4444"}
for _, base := range hues {
t.Run(base, func(t *testing.T) {
palette := GeneratePalette(base, PaletteOptions{IsLight: false, UseDPS: true})
bgRatio := ContrastRatio(palette.Color8.Hex, palette.Color0.Hex)
if bgRatio < 1.5 || bgRatio > 3.0 {
t.Errorf("Color8 %s vs bg %s ratio %.2f, expected 1.5-3.0 (bright black stays near bg)",
palette.Color8.Hex, palette.Color0.Hex, bgRatio)
}
sepRatio := ContrastRatio(palette.Color4.Hex, palette.Color8.Hex)
if sepRatio < 2.0 {
t.Errorf("Color4 %s vs Color8 %s ratio %.2f, expected >= 2.0 (blue must not collide with bright black)",
palette.Color4.Hex, palette.Color8.Hex, sepRatio)
}
})
}
}
func TestGeneratePaletteLightColor8StaysLight(t *testing.T) {
palette := GeneratePalette("#f59e0b", PaletteOptions{IsLight: true, UseDPS: true})
lstar := getLstar(palette.Color8.Hex)
if lstar < 60.0 {
t.Errorf("light mode Color8 %s has L* %.2f, expected >= 60 (dim grey, not near-black)",
palette.Color8.Hex, lstar)
}
bgRatio := ContrastRatio(palette.Color8.Hex, palette.Color0.Hex)
if bgRatio > 3.0 {
t.Errorf("light mode Color8 %s vs bg %s ratio %.2f, expected <= 3.0",
palette.Color8.Hex, palette.Color0.Hex, bgRatio)
}
}
+9 -7
View File
@@ -53,6 +53,11 @@ func NewGentooDistribution(config DistroConfig, logChan chan<- string) *GentooDi
} }
} }
func emergeInstallArgs(packages []string) []string {
args := []string{"emerge", "--ask=n", "--quiet", "--autounmask-continue=y", "--autounmask-keep-keywords=y", "--autounmask-keep-masks=y"}
return append(args, packages...)
}
func (g *GentooDistribution) getArchKeyword() string { func (g *GentooDistribution) getArchKeyword() string {
arch := runtime.GOARCH arch := runtime.GOARCH
switch arch { switch arch {
@@ -319,6 +324,7 @@ func (g *GentooDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
} }
g.log("Portage tree synced successfully") g.log("Portage tree synced successfully")
args := emergeInstallArgs(missingPkgs)
g.log(fmt.Sprintf("Installing prerequisites: %s", strings.Join(missingPkgs, ", "))) g.log(fmt.Sprintf("Installing prerequisites: %s", strings.Join(missingPkgs, ", ")))
progressChan <- InstallProgressMsg{ progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites, Phase: PhasePrerequisites,
@@ -326,12 +332,10 @@ func (g *GentooDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
Step: fmt.Sprintf("Installing %d prerequisites...", len(missingPkgs)), Step: fmt.Sprintf("Installing %d prerequisites...", len(missingPkgs)),
IsComplete: false, IsComplete: false,
NeedsSudo: true, NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo emerge --ask=n %s", strings.Join(missingPkgs, " ")), CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
LogOutput: fmt.Sprintf("Installing prerequisites: %s", strings.Join(missingPkgs, ", ")), LogOutput: fmt.Sprintf("Installing prerequisites: %s", strings.Join(missingPkgs, ", ")),
} }
args := []string{"emerge", "--ask=n", "--quiet"}
args = append(args, missingPkgs...)
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " ")) cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
@@ -521,8 +525,7 @@ func (g *GentooDistribution) installPortagePackages(ctx context.Context, package
} }
} }
args := []string{"emerge", "--ask=n", "--quiet"} args := emergeInstallArgs(packageNames)
args = append(args, packageNames...)
progressChan <- InstallProgressMsg{ progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages, Phase: PhaseSystemPackages,
@@ -713,8 +716,7 @@ func (g *GentooDistribution) installGURUPackages(ctx context.Context, packages [
guruPackages[i] = pkg + "::guru" guruPackages[i] = pkg + "::guru"
} }
args := []string{"emerge", "--ask=n", "--quiet"} args := emergeInstallArgs(guruPackages)
args = append(args, guruPackages...)
progressChan <- InstallProgressMsg{ progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages, Phase: PhaseAURPackages,
+93 -39
View File
@@ -2,17 +2,17 @@ package plugins
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
"github.com/go-git/go-git/v6" "github.com/go-git/go-git/v6"
"github.com/spf13/afero" "github.com/spf13/afero"
) )
const registryRepo = "https://github.com/AvengeMedia/dms-plugin-registry.git"
type Plugin struct { type Plugin struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -33,6 +33,7 @@ type Plugin struct {
type GitClient interface { type GitClient interface {
PlainClone(path string, url string) error PlainClone(path string, url string) error
Pull(path string) error Pull(path string) error
OriginURL(path string) (string, error)
HasUpdates(path string) (hasUpdates bool, localHash string, remoteHash string, err error) HasUpdates(path string) (hasUpdates bool, localHash string, remoteHash string, err error)
} }
@@ -65,6 +66,22 @@ func (g *realGitClient) Pull(path string) error {
return nil return nil
} }
func (g *realGitClient) OriginURL(path string) (string, error) {
repo, err := git.PlainOpen(path)
if err != nil {
return "", err
}
remote, err := repo.Remote("origin")
if err != nil {
return "", err
}
urls := remote.Config().URLs
if len(urls) == 0 {
return "", errors.New("origin remote has no URL")
}
return urls[0], nil
}
func (g *realGitClient) HasUpdates(path string) (bool, string, string, error) { func (g *realGitClient) HasUpdates(path string) (bool, string, string, error) {
repo, err := git.PlainOpen(path) repo, err := git.PlainOpen(path)
if err != nil { if err != nil {
@@ -121,6 +138,7 @@ func (g *realGitClient) HasUpdates(path string) (bool, string, string, error) {
type Registry struct { type Registry struct {
fs afero.Fs fs afero.Fs
cacheDir string cacheDir string
registries []registries.Source
plugins []Plugin plugins []Plugin
git GitClient git GitClient
} }
@@ -130,63 +148,63 @@ func NewRegistry() (*Registry, error) {
} }
func NewRegistryWithFs(fs afero.Fs) (*Registry, error) { func NewRegistryWithFs(fs afero.Fs) (*Registry, error) {
cacheDir := getCacheDir()
return &Registry{ return &Registry{
fs: fs, fs: fs,
cacheDir: cacheDir, cacheDir: getCacheDir(),
registries: registries.Load(fs),
git: &realGitClient{}, git: &realGitClient{},
}, nil }, nil
} }
func (r *Registry) cacheDirFor(src registries.Source) string {
return filepath.Join(r.cacheDir, src.Name)
}
func getCacheDir() string { func getCacheDir() string {
return filepath.Join(os.TempDir(), "dankdots-plugin-registry") return filepath.Join(os.TempDir(), "dankdots-plugin-registry")
} }
func (r *Registry) Update() error { // A cached clone is reused only when its origin still matches the configured
exists, err := afero.DirExists(r.fs, r.cacheDir) // URL; renamed or re-pointed registries re-clone instead of pulling from the
// stale remote.
func (r *Registry) updateOne(src registries.Source) error {
dir := r.cacheDirFor(src)
exists, err := afero.DirExists(r.fs, dir)
if err != nil { if err != nil {
return fmt.Errorf("failed to check cache directory: %w", err) return fmt.Errorf("failed to check cache directory: %w", err)
} }
if !exists { if exists {
if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0o755); err != nil { origin, originErr := r.git.OriginURL(dir)
if originErr == nil && origin == src.URL && r.git.Pull(dir) == nil {
return nil
}
if err := r.fs.RemoveAll(dir); err != nil {
return fmt.Errorf("failed to remove stale registry cache: %w", err)
}
}
if err := r.fs.MkdirAll(filepath.Dir(dir), 0o755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err) return fmt.Errorf("failed to create cache directory: %w", err)
} }
if err := r.git.PlainClone(dir, src.URL); err != nil {
if err := r.git.PlainClone(r.cacheDir, registryRepo); err != nil { return fmt.Errorf("failed to clone: %w", err)
return fmt.Errorf("failed to clone registry: %w", err)
} }
} else { return nil
// Try to pull, if it fails (e.g., shallow clone corruption), delete and re-clone
if err := r.git.Pull(r.cacheDir); err != nil {
// Repository is likely corrupted or has issues, delete and re-clone
if err := r.fs.RemoveAll(r.cacheDir); err != nil {
return fmt.Errorf("failed to remove corrupted registry: %w", err)
} }
if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0o755); err != nil { // A registry without a plugins/ directory is a valid themes-only registry.
return fmt.Errorf("failed to create cache directory: %w", err) func (r *Registry) loadPluginsFrom(dir string) ([]Plugin, error) {
} pluginsDir := filepath.Join(dir, "plugins")
if err := r.git.PlainClone(r.cacheDir, registryRepo); err != nil {
return fmt.Errorf("failed to re-clone registry: %w", err)
}
}
}
return r.loadPlugins()
}
func (r *Registry) loadPlugins() error {
pluginsDir := filepath.Join(r.cacheDir, "plugins")
entries, err := afero.ReadDir(r.fs, pluginsDir) entries, err := afero.ReadDir(r.fs, pluginsDir)
if err != nil { if err != nil {
return fmt.Errorf("failed to read plugins directory: %w", err) if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("failed to read plugins directory: %w", err)
} }
r.plugins = []Plugin{} var plugins []Plugin
for _, entry := range entries { for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
continue continue
@@ -206,15 +224,51 @@ func (r *Registry) loadPlugins() error {
plugin.ID = strings.TrimSuffix(entry.Name(), ".json") plugin.ID = strings.TrimSuffix(entry.Name(), ".json")
} }
r.plugins = append(r.plugins, plugin) plugins = append(plugins, plugin)
}
return plugins, nil
} }
return nil // Pre-multi-registry caches were a single clone at the base dir; the per-name
// layout nests under it, so a leftover clone is deleted wholesale first.
func (r *Registry) resetLegacyCache() {
if exists, _ := afero.DirExists(r.fs, filepath.Join(r.cacheDir, ".git")); exists {
_ = r.fs.RemoveAll(r.cacheDir)
}
}
// Update refreshes every configured registry, aggregating plugins in
// declaration order (first occurrence of an ID wins). A failing registry is
// reported in the joined error but does not block the others.
func (r *Registry) Update() error {
r.resetLegacyCache()
r.plugins = []Plugin{}
seen := make(map[string]struct{})
var errs []error
for _, src := range r.registries {
if err := r.updateOne(src); err != nil {
errs = append(errs, fmt.Errorf("registry %s: %w", src.Name, err))
continue
}
plugins, err := r.loadPluginsFrom(r.cacheDirFor(src))
if err != nil {
errs = append(errs, fmt.Errorf("registry %s: %w", src.Name, err))
continue
}
for _, p := range plugins {
if _, dup := seen[p.ID]; dup {
continue
}
seen[p.ID] = struct{}{}
r.plugins = append(r.plugins, p)
}
}
return errors.Join(errs...)
} }
func (r *Registry) List() ([]Plugin, error) { func (r *Registry) List() ([]Plugin, error) {
if len(r.plugins) == 0 { if len(r.plugins) == 0 {
if err := r.Update(); err != nil { if err := r.Update(); err != nil && len(r.plugins) == 0 {
return nil, err return nil, err
} }
} }
+228 -53
View File
@@ -2,17 +2,22 @@ package plugins
import ( import (
"encoding/json" "encoding/json"
"errors"
"path/filepath" "path/filepath"
"testing" "testing"
"github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
const testRegistryURL = "https://example.com/test-registry.git"
type mockGitClient struct { type mockGitClient struct {
cloneFunc func(path string, url string) error cloneFunc func(path string, url string) error
pullFunc func(path string) error pullFunc func(path string) error
originFunc func(path string) (string, error)
hasUpdatesFunc func(path string) (bool, string, string, error) hasUpdatesFunc func(path string) (bool, string, string, error)
} }
@@ -30,6 +35,13 @@ func (m *mockGitClient) Pull(path string) error {
return nil return nil
} }
func (m *mockGitClient) OriginURL(path string) (string, error) {
if m.originFunc != nil {
return m.originFunc(path)
}
return "", errors.New("not a repository")
}
func (m *mockGitClient) HasUpdates(path string) (bool, string, string, error) { func (m *mockGitClient) HasUpdates(path string) (bool, string, string, error) {
if m.hasUpdatesFunc != nil { if m.hasUpdatesFunc != nil {
return m.hasUpdatesFunc(path) return m.hasUpdatesFunc(path)
@@ -42,6 +54,8 @@ func TestNewRegistry(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.NotNil(t, registry) assert.NotNil(t, registry)
assert.NotEmpty(t, registry.cacheDir) assert.NotEmpty(t, registry.cacheDir)
require.NotEmpty(t, registry.registries)
assert.Equal(t, registries.OfficialName, registry.registries[0].Name)
} }
func TestGetCacheDir(t *testing.T) { func TestGetCacheDir(t *testing.T) {
@@ -55,6 +69,7 @@ func setupTestRegistry(t *testing.T) (*Registry, afero.Fs, string) {
registry := &Registry{ registry := &Registry{
fs: fs, fs: fs,
cacheDir: tmpDir, cacheDir: tmpDir,
registries: []registries.Source{{Name: "test", URL: testRegistryURL}},
plugins: []Plugin{}, plugins: []Plugin{},
git: &mockGitClient{}, git: &mockGitClient{},
} }
@@ -104,14 +119,14 @@ func TestLoadPlugins(t *testing.T) {
createTestPlugin(t, fs, tmpDir, "plugin1.json", plugin1) createTestPlugin(t, fs, tmpDir, "plugin1.json", plugin1)
createTestPlugin(t, fs, tmpDir, "plugin2.json", plugin2) createTestPlugin(t, fs, tmpDir, "plugin2.json", plugin2)
err := registry.loadPlugins() plugins, err := registry.loadPluginsFrom(tmpDir)
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, registry.plugins, 2) assert.Len(t, plugins, 2)
assert.Equal(t, "TestPlugin1", registry.plugins[0].Name) assert.Equal(t, "TestPlugin1", plugins[0].Name)
assert.Equal(t, "TestPlugin2", registry.plugins[1].Name) assert.Equal(t, "TestPlugin2", plugins[1].Name)
assert.Equal(t, []string{"dankbar-widget"}, registry.plugins[0].Capabilities) assert.Equal(t, []string{"dankbar-widget"}, plugins[0].Capabilities)
assert.Equal(t, []string{"dep1", "dep2"}, registry.plugins[1].Dependencies) assert.Equal(t, []string{"dep1", "dep2"}, plugins[1].Dependencies)
}) })
t.Run("skips non-json files", func(t *testing.T) { t.Run("skips non-json files", func(t *testing.T) {
@@ -136,34 +151,10 @@ func TestLoadPlugins(t *testing.T) {
} }
createTestPlugin(t, fs, tmpDir, "valid.json", plugin) createTestPlugin(t, fs, tmpDir, "valid.json", plugin)
err = registry.loadPlugins() plugins, err := registry.loadPluginsFrom(tmpDir)
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, registry.plugins, 1) assert.Len(t, plugins, 1)
assert.Equal(t, "ValidPlugin", registry.plugins[0].Name) assert.Equal(t, "ValidPlugin", plugins[0].Name)
})
t.Run("skips directories", func(t *testing.T) {
registry, fs, tmpDir := setupTestRegistry(t)
pluginsDir := filepath.Join(tmpDir, "plugins")
err := fs.MkdirAll(filepath.Join(pluginsDir, "subdir"), 0o755)
require.NoError(t, err)
plugin := Plugin{
Name: "ValidPlugin",
Capabilities: []string{"test"},
Category: "test",
Repo: "https://github.com/test/test",
Author: "Test",
Description: "Test",
Compositors: []string{"niri"},
Distro: []string{"any"},
}
createTestPlugin(t, fs, tmpDir, "valid.json", plugin)
err = registry.loadPlugins()
assert.NoError(t, err)
assert.Len(t, registry.plugins, 1)
}) })
t.Run("skips invalid json files", func(t *testing.T) { t.Run("skips invalid json files", func(t *testing.T) {
@@ -188,18 +179,18 @@ func TestLoadPlugins(t *testing.T) {
} }
createTestPlugin(t, fs, tmpDir, "valid.json", plugin) createTestPlugin(t, fs, tmpDir, "valid.json", plugin)
err = registry.loadPlugins() plugins, err := registry.loadPluginsFrom(tmpDir)
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, registry.plugins, 1) assert.Len(t, plugins, 1)
assert.Equal(t, "ValidPlugin", registry.plugins[0].Name) assert.Equal(t, "ValidPlugin", plugins[0].Name)
}) })
t.Run("returns error when plugins directory missing", func(t *testing.T) { t.Run("missing plugins directory is a themes-only registry", func(t *testing.T) {
registry, _, _ := setupTestRegistry(t) registry, _, _ := setupTestRegistry(t)
err := registry.loadPlugins() plugins, err := registry.loadPluginsFrom(registry.cacheDir)
assert.Error(t, err) assert.NoError(t, err)
assert.Contains(t, err.Error(), "failed to read plugins directory") assert.Empty(t, plugins)
}) })
} }
@@ -240,19 +231,40 @@ func TestList(t *testing.T) {
Distro: []string{"any"}, Distro: []string{"any"},
} }
mockGit := &mockGitClient{ registry.git = &mockGitClient{
cloneFunc: func(path string, url string) error { cloneFunc: func(path string, url string) error {
createTestPlugin(t, fs, path, "plugin.json", plugin) createTestPlugin(t, fs, path, "plugin.json", plugin)
return nil return nil
}, },
} }
registry.git = mockGit
plugins, err := registry.List() plugins, err := registry.List()
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, plugins, 1) assert.Len(t, plugins, 1)
assert.Equal(t, "NewPlugin", plugins[0].Name) assert.Equal(t, "NewPlugin", plugins[0].Name)
}) })
t.Run("partial registry failure still returns loaded plugins", func(t *testing.T) {
registry, fs, _ := setupTestRegistry(t)
registry.registries = []registries.Source{
{Name: "test", URL: testRegistryURL},
{Name: "broken", URL: "https://example.com/broken.git"},
}
registry.git = &mockGitClient{
cloneFunc: func(path string, url string) error {
if url != testRegistryURL {
return errors.New("clone failed")
}
createTestPlugin(t, fs, path, "x.json", Plugin{ID: "x", Name: "X"})
return nil
},
}
plugins, err := registry.List()
assert.NoError(t, err)
assert.Len(t, plugins, 1)
})
} }
func TestUpdate(t *testing.T) { func TestUpdate(t *testing.T) {
@@ -271,16 +283,15 @@ func TestUpdate(t *testing.T) {
} }
cloneCalled := false cloneCalled := false
mockGit := &mockGitClient{ registry.git = &mockGitClient{
cloneFunc: func(path string, url string) error { cloneFunc: func(path string, url string) error {
cloneCalled = true cloneCalled = true
assert.Equal(t, registryRepo, url) assert.Equal(t, testRegistryURL, url)
assert.Equal(t, tmpDir, path) assert.Equal(t, filepath.Join(tmpDir, "test"), path)
createTestPlugin(t, fs, path, "plugin.json", plugin) createTestPlugin(t, fs, path, "plugin.json", plugin)
return nil return nil
}, },
} }
registry.git = mockGit
err := registry.Update() err := registry.Update()
assert.NoError(t, err) assert.NoError(t, err)
@@ -289,7 +300,7 @@ func TestUpdate(t *testing.T) {
assert.Equal(t, "RepoPlugin", registry.plugins[0].Name) assert.Equal(t, "RepoPlugin", registry.plugins[0].Name)
}) })
t.Run("pulls updates when cache exists", func(t *testing.T) { t.Run("pulls when cache exists with matching origin", func(t *testing.T) {
registry, fs, tmpDir := setupTestRegistry(t) registry, fs, tmpDir := setupTestRegistry(t)
plugin := Plugin{ plugin := Plugin{
@@ -303,24 +314,188 @@ func TestUpdate(t *testing.T) {
Distro: []string{"any"}, Distro: []string{"any"},
} }
err := fs.MkdirAll(tmpDir, 0o755) subdir := filepath.Join(tmpDir, "test")
require.NoError(t, err) require.NoError(t, fs.MkdirAll(subdir, 0o755))
pullCalled := false pullCalled := false
mockGit := &mockGitClient{ registry.git = &mockGitClient{
originFunc: func(path string) (string, error) {
return testRegistryURL, nil
},
pullFunc: func(path string) error { pullFunc: func(path string) error {
pullCalled = true pullCalled = true
assert.Equal(t, tmpDir, path) assert.Equal(t, subdir, path)
createTestPlugin(t, fs, path, "plugin.json", plugin) createTestPlugin(t, fs, path, "plugin.json", plugin)
return nil return nil
}, },
} }
registry.git = mockGit
err = registry.Update() err := registry.Update()
assert.NoError(t, err) assert.NoError(t, err)
assert.True(t, pullCalled) assert.True(t, pullCalled)
assert.Len(t, registry.plugins, 1) assert.Len(t, registry.plugins, 1)
assert.Equal(t, "UpdatedPlugin", registry.plugins[0].Name) assert.Equal(t, "UpdatedPlugin", registry.plugins[0].Name)
}) })
t.Run("re-clones when cached origin does not match configured URL", func(t *testing.T) {
registry, fs, tmpDir := setupTestRegistry(t)
subdir := filepath.Join(tmpDir, "test")
require.NoError(t, fs.MkdirAll(subdir, 0o755))
require.NoError(t, afero.WriteFile(fs, filepath.Join(subdir, "stale"), []byte("x"), 0o644))
pullCalled := false
cloneCalled := false
registry.git = &mockGitClient{
originFunc: func(path string) (string, error) {
return "https://example.com/old-origin.git", nil
},
pullFunc: func(path string) error {
pullCalled = true
return nil
},
cloneFunc: func(path string, url string) error {
cloneCalled = true
assert.Equal(t, testRegistryURL, url)
createTestPlugin(t, fs, path, "x.json", Plugin{ID: "x", Name: "X"})
return nil
},
}
err := registry.Update()
assert.NoError(t, err)
assert.False(t, pullCalled, "stale origin must not be pulled")
assert.True(t, cloneCalled)
exists, _ := afero.Exists(fs, filepath.Join(subdir, "stale"))
assert.False(t, exists, "stale cache contents removed before re-clone")
})
t.Run("re-clones when pull fails", func(t *testing.T) {
registry, fs, tmpDir := setupTestRegistry(t)
subdir := filepath.Join(tmpDir, "test")
require.NoError(t, fs.MkdirAll(subdir, 0o755))
cloneCalled := false
registry.git = &mockGitClient{
originFunc: func(path string) (string, error) {
return testRegistryURL, nil
},
pullFunc: func(path string) error {
return errors.New("shallow clone corruption")
},
cloneFunc: func(path string, url string) error {
cloneCalled = true
createTestPlugin(t, fs, path, "x.json", Plugin{ID: "x", Name: "X"})
return nil
},
}
err := registry.Update()
assert.NoError(t, err)
assert.True(t, cloneCalled)
})
t.Run("aggregates from multiple registries", func(t *testing.T) {
registry, fs, _ := setupTestRegistry(t)
pluginA := Plugin{ID: "a", Name: "PluginA", Compositors: []string{"niri"}, Distro: []string{"any"}}
pluginB := Plugin{ID: "b", Name: "PluginB", Compositors: []string{"niri"}, Distro: []string{"any"}}
registry.registries = []registries.Source{
{Name: "official", URL: testRegistryURL},
{Name: "louzt", URL: "https://example.com/louzt.git"},
}
registry.git = &mockGitClient{
cloneFunc: func(path string, url string) error {
switch filepath.Base(path) {
case "official":
createTestPlugin(t, fs, path, "x.json", pluginA)
case "louzt":
createTestPlugin(t, fs, path, "x.json", pluginB)
}
return nil
},
}
err := registry.Update()
assert.NoError(t, err)
assert.Len(t, registry.plugins, 2)
assert.Equal(t, "a", registry.plugins[0].ID)
assert.Equal(t, "b", registry.plugins[1].ID)
})
t.Run("dedupes by ID with declaration order priority", func(t *testing.T) {
registry, fs, _ := setupTestRegistry(t)
pluginOfficial := Plugin{ID: "weather", Name: "OfficialWeather", Compositors: []string{"niri"}, Distro: []string{"any"}}
pluginLouzt := Plugin{ID: "weather", Name: "LouztWeather", Compositors: []string{"niri"}, Distro: []string{"any"}}
registry.registries = []registries.Source{
{Name: "official", URL: testRegistryURL},
{Name: "louzt", URL: "https://example.com/louzt.git"},
}
registry.git = &mockGitClient{
cloneFunc: func(path string, url string) error {
switch filepath.Base(path) {
case "official":
createTestPlugin(t, fs, path, "x.json", pluginOfficial)
case "louzt":
createTestPlugin(t, fs, path, "x.json", pluginLouzt)
}
return nil
},
}
err := registry.Update()
assert.NoError(t, err)
assert.Len(t, registry.plugins, 1)
assert.Equal(t, "OfficialWeather", registry.plugins[0].Name)
})
t.Run("continues past failing registry and reports it", func(t *testing.T) {
registry, fs, _ := setupTestRegistry(t)
registry.registries = []registries.Source{
{Name: "broken", URL: "https://example.com/broken.git"},
{Name: "test", URL: testRegistryURL},
}
registry.git = &mockGitClient{
cloneFunc: func(path string, url string) error {
if url != testRegistryURL {
return errors.New("network unreachable")
}
createTestPlugin(t, fs, path, "x.json", Plugin{ID: "x", Name: "X"})
return nil
},
}
err := registry.Update()
assert.Error(t, err)
assert.Contains(t, err.Error(), "registry broken")
assert.Len(t, registry.plugins, 1, "healthy registry still loads")
})
t.Run("removes legacy single-clone cache at base", func(t *testing.T) {
registry, fs, tmpDir := setupTestRegistry(t)
require.NoError(t, fs.MkdirAll(filepath.Join(tmpDir, ".git"), 0o755))
createTestPlugin(t, fs, tmpDir, "legacy.json", Plugin{ID: "legacy", Name: "Legacy"})
registry.git = &mockGitClient{
cloneFunc: func(path string, url string) error {
createTestPlugin(t, fs, path, "x.json", Plugin{ID: "x", Name: "X"})
return nil
},
}
err := registry.Update()
assert.NoError(t, err)
exists, _ := afero.DirExists(fs, filepath.Join(tmpDir, ".git"))
assert.False(t, exists, "legacy clone removed")
assert.Len(t, registry.plugins, 1)
assert.Equal(t, "x", registry.plugins[0].ID)
})
} }
+130
View File
@@ -0,0 +1,130 @@
package registries
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/spf13/afero"
)
const (
OfficialName = "official"
officialURL = "https://github.com/AvengeMedia/dms-plugin-registry.git"
)
// Source identifies a registry repository. Name doubles as the per-registry
// cache subdirectory, so it is restricted to a filesystem-safe slug.
type Source struct {
Name string `json:"name"`
URL string `json:"url"`
}
func (s Source) Official() bool {
return s.Name == OfficialName
}
var nameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`)
func configPath() (string, error) {
configDir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("failed to get user config dir: %w", err)
}
return filepath.Join(configDir, "DankMaterialShell", "registries.json"), nil
}
// Load returns the official registry followed by any user-configured extras.
// A missing or unreadable config yields just the official registry.
func Load(fs afero.Fs) []Source {
sources := []Source{{Name: OfficialName, URL: officialURL}}
path, err := configPath()
if err != nil {
return sources
}
data, err := afero.ReadFile(fs, path)
if err != nil {
return sources
}
var extras []Source
if err := json.Unmarshal(data, &extras); err != nil {
return sources
}
for _, s := range extras {
if !nameRe.MatchString(s.Name) || s.Name == OfficialName || s.URL == "" {
continue
}
sources = append(sources, s)
}
return sources
}
func saveExtras(fs afero.Fs, extras []Source) error {
path, err := configPath()
if err != nil {
return err
}
if err := fs.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("failed to create config dir: %w", err)
}
data, err := json.MarshalIndent(extras, "", " ")
if err != nil {
return err
}
return afero.WriteFile(fs, path, append(data, '\n'), 0o644)
}
func loadExtras(fs afero.Fs) []Source {
sources := Load(fs)
return sources[1:]
}
func Add(fs afero.Fs, name, url string) error {
name = strings.TrimSpace(name)
url = strings.TrimSpace(url)
if !nameRe.MatchString(name) {
return fmt.Errorf("invalid registry name %q: use 1-32 lowercase letters, digits or hyphens", name)
}
if name == OfficialName {
return fmt.Errorf("registry name %q is reserved", OfficialName)
}
if url == "" {
return fmt.Errorf("registry URL is required")
}
extras := loadExtras(fs)
for _, s := range extras {
if s.Name == name {
return fmt.Errorf("registry %q already exists", name)
}
if s.URL == url {
return fmt.Errorf("registry %q already uses this URL", s.Name)
}
}
return saveExtras(fs, append(extras, Source{Name: name, URL: url}))
}
func Remove(fs afero.Fs, name string) error {
if name == OfficialName {
return fmt.Errorf("the official registry cannot be removed")
}
extras := loadExtras(fs)
kept := make([]Source, 0, len(extras))
for _, s := range extras {
if s.Name != name {
kept = append(kept, s)
}
}
if len(kept) == len(extras) {
return fmt.Errorf("registry %q not found", name)
}
return saveExtras(fs, kept)
}
@@ -0,0 +1,79 @@
package registries
import (
"testing"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setupFs(t *testing.T) afero.Fs {
t.Setenv("XDG_CONFIG_HOME", "/xdg")
return afero.NewMemMapFs()
}
func TestLoadDefaults(t *testing.T) {
fs := setupFs(t)
sources := Load(fs)
require.Len(t, sources, 1)
assert.Equal(t, OfficialName, sources[0].Name)
assert.Equal(t, officialURL, sources[0].URL)
assert.True(t, sources[0].Official())
}
func TestAddAndLoad(t *testing.T) {
fs := setupFs(t)
require.NoError(t, Add(fs, "extra", "https://example.com/extra.git"))
require.NoError(t, Add(fs, "another", "https://example.com/another.git"))
sources := Load(fs)
require.Len(t, sources, 3)
assert.Equal(t, OfficialName, sources[0].Name)
assert.Equal(t, "extra", sources[1].Name)
assert.Equal(t, "another", sources[2].Name)
assert.False(t, sources[1].Official())
}
func TestAddValidation(t *testing.T) {
fs := setupFs(t)
assert.Error(t, Add(fs, "", "https://example.com/x.git"))
assert.Error(t, Add(fs, "Has Spaces", "https://example.com/x.git"))
assert.Error(t, Add(fs, "UPPER", "https://example.com/x.git"))
assert.Error(t, Add(fs, "../escape", "https://example.com/x.git"))
assert.Error(t, Add(fs, OfficialName, "https://example.com/x.git"))
assert.Error(t, Add(fs, "noname", ""))
require.NoError(t, Add(fs, "extra", "https://example.com/x.git"))
assert.Error(t, Add(fs, "extra", "https://example.com/other.git"), "duplicate name rejected")
assert.Error(t, Add(fs, "extra2", "https://example.com/x.git"), "duplicate URL rejected")
}
func TestRemove(t *testing.T) {
fs := setupFs(t)
require.NoError(t, Add(fs, "extra", "https://example.com/x.git"))
require.NoError(t, Remove(fs, "extra"))
assert.Len(t, Load(fs), 1)
assert.Error(t, Remove(fs, "extra"), "already removed")
assert.Error(t, Remove(fs, OfficialName), "official is not removable")
}
func TestLoadIgnoresInvalidConfig(t *testing.T) {
fs := setupFs(t)
require.NoError(t, fs.MkdirAll("/xdg/DankMaterialShell", 0o755))
require.NoError(t, afero.WriteFile(fs, "/xdg/DankMaterialShell/registries.json", []byte("{not json"), 0o644))
assert.Len(t, Load(fs), 1)
entries := `[{"name":"ok","url":"https://example.com/ok.git"},{"name":"Bad Name","url":"https://example.com/bad.git"},{"name":"official","url":"https://example.com/spoof.git"},{"name":"nourl","url":""}]`
require.NoError(t, afero.WriteFile(fs, "/xdg/DankMaterialShell/registries.json", []byte(entries), 0o644))
sources := Load(fs)
require.Len(t, sources, 2, "invalid entries dropped")
assert.Equal(t, "ok", sources[1].Name)
assert.Equal(t, officialURL, sources[0].URL, "official cannot be spoofed from config")
}
+25 -21
View File
@@ -48,7 +48,7 @@ func NewManager() (*Manager, error) {
return nil, fmt.Errorf("failed to find keyboards: %w", err) return nil, fmt.Errorf("failed to find keyboards: %w", err)
} }
initialCapsLock := readInitialCapsLockState(devices[0]) initialCapsLock, _ := capsLockFromDevices(devices)
watcher, err := fsnotify.NewWatcher() watcher, err := fsnotify.NewWatcher()
if err != nil { if err != nil {
@@ -85,14 +85,21 @@ func NewManager() (*Manager, error) {
return m, nil return m, nil
} }
func readInitialCapsLockState(device EvdevDevice) bool { func capsLockFromDevices(devices []EvdevDevice) (bool, bool) {
ledStates, err := device.State(evLedType) for _, device := range devices {
if err != nil { if device == nil {
log.Debugf("Could not read LED state: %v", err) continue
return false
} }
return ledStates[ledCapslockKey] ledStates, err := device.State(evLedType)
if err != nil || len(ledStates) == 0 {
continue
}
return ledStates[ledCapslockKey], true
}
return false, false
} }
func findKeyboards() ([]EvdevDevice, error) { func findKeyboards() ([]EvdevDevice, error) {
@@ -297,25 +304,22 @@ func (m *Manager) readAndUpdateCapsLockState(deviceIndex int) {
m.devicesMutex.RUnlock() m.devicesMutex.RUnlock()
return return
} }
device := m.devices[deviceIndex] ordered := make([]EvdevDevice, 0, len(m.devices))
ordered = append(ordered, m.devices[deviceIndex])
for i, device := range m.devices {
if i == deviceIndex {
continue
}
ordered = append(ordered, device)
}
m.devicesMutex.RUnlock() m.devicesMutex.RUnlock()
ledStates, err := device.State(evLedType) capsLockState, ok := capsLockFromDevices(ordered)
if err != nil { if !ok {
log.Warnf("Failed to read LED state: %v", err) log.Debug("No LED-capable device available for caps lock state")
return return
} }
if len(ledStates) == 0 {
log.Debug("No LED state available (empty map)")
// This means the device either:
// - doesn't support LED reporting at all, or
// - the kernel returned an empty state
return
}
capsLockState := ledStates[ledCapslockKey]
m.updateCapsLockStateDirect(capsLockState) m.updateCapsLockStateDirect(capsLockState)
} }
+22 -4
View File
@@ -306,7 +306,7 @@ func TestNotifySubscribers(t *testing.T) {
m.Close() m.Close()
} }
func TestReadInitialCapsLockState(t *testing.T) { func TestCapsLockFromDevices(t *testing.T) {
t.Run("caps lock is on", func(t *testing.T) { t.Run("caps lock is on", func(t *testing.T) {
mockDevice := mocks.NewMockEvdevDevice(t) mockDevice := mocks.NewMockEvdevDevice(t)
ledState := evdev.StateMap{ ledState := evdev.StateMap{
@@ -314,7 +314,8 @@ func TestReadInitialCapsLockState(t *testing.T) {
} }
mockDevice.EXPECT().State(evdev.EvType(evLedType)).Return(ledState, nil).Once() mockDevice.EXPECT().State(evdev.EvType(evLedType)).Return(ledState, nil).Once()
result := readInitialCapsLockState(mockDevice) result, ok := capsLockFromDevices([]EvdevDevice{mockDevice})
assert.True(t, ok)
assert.True(t, result) assert.True(t, result)
}) })
@@ -325,7 +326,8 @@ func TestReadInitialCapsLockState(t *testing.T) {
} }
mockDevice.EXPECT().State(evdev.EvType(evLedType)).Return(ledState, nil).Once() mockDevice.EXPECT().State(evdev.EvType(evLedType)).Return(ledState, nil).Once()
result := readInitialCapsLockState(mockDevice) result, ok := capsLockFromDevices([]EvdevDevice{mockDevice})
assert.True(t, ok)
assert.False(t, result) assert.False(t, result)
}) })
@@ -333,9 +335,25 @@ func TestReadInitialCapsLockState(t *testing.T) {
mockDevice := mocks.NewMockEvdevDevice(t) mockDevice := mocks.NewMockEvdevDevice(t)
mockDevice.EXPECT().State(evdev.EvType(evLedType)).Return(nil, errors.New("read error")).Once() mockDevice.EXPECT().State(evdev.EvType(evLedType)).Return(nil, errors.New("read error")).Once()
result := readInitialCapsLockState(mockDevice) result, ok := capsLockFromDevices([]EvdevDevice{mockDevice})
assert.False(t, ok)
assert.False(t, result) assert.False(t, result)
}) })
t.Run("falls back past device without LED state", func(t *testing.T) {
noLedDevice := mocks.NewMockEvdevDevice(t)
noLedDevice.EXPECT().State(evdev.EvType(evLedType)).Return(evdev.StateMap{}, nil).Once()
ledDevice := mocks.NewMockEvdevDevice(t)
ledState := evdev.StateMap{
ledCapslockKey: true,
}
ledDevice.EXPECT().State(evdev.EvType(evLedType)).Return(ledState, nil).Once()
result, ok := capsLockFromDevices([]EvdevDevice{noLedDevice, nil, ledDevice})
assert.True(t, ok)
assert.True(t, result)
})
} }
func TestHasInputGroupAccess(t *testing.T) { func TestHasInputGroupAccess(t *testing.T) {
@@ -0,0 +1,83 @@
package registries
import (
"fmt"
"github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/spf13/afero"
)
type RegistryInfo struct {
Name string `json:"name"`
URL string `json:"url"`
Official bool `json:"official"`
}
type SuccessResult struct {
Success bool `json:"success"`
Message string `json:"message"`
}
func HandleRequest(conn *models.Conn, req models.Request) {
switch req.Method {
case "registries.list":
HandleList(conn, req)
case "registries.add":
HandleAdd(conn, req)
case "registries.remove":
HandleRemove(conn, req)
default:
models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
}
}
func HandleList(conn *models.Conn, req models.Request) {
sources := registries.Load(afero.NewOsFs())
result := make([]RegistryInfo, len(sources))
for i, s := range sources {
result[i] = RegistryInfo{Name: s.Name, URL: s.URL, Official: s.Official()}
}
models.Respond(conn, req.ID, result)
}
func HandleAdd(conn *models.Conn, req models.Request) {
name, ok := models.Get[string](req, "name")
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'name' parameter")
return
}
url, ok := models.Get[string](req, "url")
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'url' parameter")
return
}
if err := registries.Add(afero.NewOsFs(), name, url); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, SuccessResult{
Success: true,
Message: fmt.Sprintf("registry added: %s", name),
})
}
func HandleRemove(conn *models.Conn, req models.Request) {
name, ok := models.Get[string](req, "name")
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'name' parameter")
return
}
if err := registries.Remove(afero.NewOsFs(), name); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, SuccessResult{
Success: true,
Message: fmt.Sprintf("registry removed: %s", name),
})
}
+6
View File
@@ -18,6 +18,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
serverPlugins "github.com/AvengeMedia/DankMaterialShell/core/internal/server/plugins" serverPlugins "github.com/AvengeMedia/DankMaterialShell/core/internal/server/plugins"
serverRegistries "github.com/AvengeMedia/DankMaterialShell/core/internal/server/registries"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/sysupdate" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/sysupdate"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/tailscale" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/tailscale"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
@@ -47,6 +48,11 @@ func RouteRequest(conn *models.Conn, req models.Request) {
return return
} }
if strings.HasPrefix(req.Method, "registries.") {
serverRegistries.HandleRequest(conn, req)
return
}
if strings.HasPrefix(req.Method, "theme.auto.") { if strings.HasPrefix(req.Method, "theme.auto.") {
if themeModeManager == nil { if themeModeManager == nil {
models.RespondError(conn, req.ID, "theme mode manager not initialized") models.RespondError(conn, req.ID, "theme mode manager not initialized")
+33 -1
View File
@@ -36,7 +36,7 @@ import (
"github.com/AvengeMedia/dankgo/syncmap" "github.com/AvengeMedia/dankgo/syncmap"
) )
const APIVersion = 28 const APIVersion = 29
var CLIVersion = "dev" var CLIVersion = "dev"
@@ -1183,6 +1183,38 @@ func handleSubscribe(conn *models.Conn, req models.Request) {
}() }()
} }
if shouldSubscribe("location") && locationManager != nil {
wg.Add(1)
locationChan := locationManager.Subscribe(clientID + "-location")
go func() {
defer wg.Done()
defer locationManager.Unsubscribe(clientID + "-location")
initialState := locationManager.GetState()
select {
case eventChan <- ServiceEvent{Service: "location", Data: initialState}:
case <-stopChan:
return
}
for {
select {
case state, ok := <-locationChan:
if !ok {
return
}
select {
case eventChan <- ServiceEvent{Service: "location", Data: state}:
case <-stopChan:
return
}
case <-stopChan:
return
}
}
}()
}
if shouldSubscribe("sysupdate") && sysUpdateManager != nil { if shouldSubscribe("sysupdate") && sysUpdateManager != nil {
wg.Add(1) wg.Add(1)
sysupdateChan := sysUpdateManager.Subscribe(clientID + "-sysupdate") sysupdateChan := sysUpdateManager.Subscribe(clientID + "-sysupdate")
+27 -16
View File
@@ -759,6 +759,9 @@ func (m *Manager) schedulerLoop() {
now := time.Now() now := time.Now()
m.recalcSchedule(now) m.recalcSchedule(now)
// publish independent of output readiness so night status never
// presents a stale schedule while applies are blocked (#2967)
m.updateStateFromSchedule()
waitDur := 24 * time.Hour waitDur := 24 * time.Hour
if enabled { if enabled {
@@ -1104,13 +1107,15 @@ func (m *Manager) SetTemperature(low, high int) error {
m.configMutex.Unlock() m.configMutex.Unlock()
return nil return nil
} }
m.config.LowTemp = low updated := m.config
m.config.HighTemp = high updated.LowTemp = low
err := m.config.Validate() updated.HighTemp = high
if err := updated.Validate(); err != nil {
m.configMutex.Unlock() m.configMutex.Unlock()
if err != nil {
return err return err
} }
m.config = updated
m.configMutex.Unlock()
m.triggerUpdate() m.triggerUpdate()
return nil return nil
} }
@@ -1122,14 +1127,16 @@ func (m *Manager) SetLocation(lat, lon float64) error {
m.configMutex.Unlock() m.configMutex.Unlock()
return nil return nil
} }
m.config.Latitude = &lat updated := m.config
m.config.Longitude = &lon updated.Latitude = &lat
m.config.UseIPLocation = false updated.Longitude = &lon
err := m.config.Validate() updated.UseIPLocation = false
if err := updated.Validate(); err != nil {
m.configMutex.Unlock() m.configMutex.Unlock()
if err != nil {
return err return err
} }
m.config = updated
m.configMutex.Unlock()
m.triggerUpdate() m.triggerUpdate()
return nil return nil
} }
@@ -1164,13 +1171,15 @@ func (m *Manager) SetManualTimes(sunrise, sunset time.Time) error {
m.configMutex.Unlock() m.configMutex.Unlock()
return nil return nil
} }
m.config.ManualSunrise = &sunrise updated := m.config
m.config.ManualSunset = &sunset updated.ManualSunrise = &sunrise
err := m.config.Validate() updated.ManualSunset = &sunset
if err := updated.Validate(); err != nil {
m.configMutex.Unlock() m.configMutex.Unlock()
if err != nil {
return err return err
} }
m.config = updated
m.configMutex.Unlock()
m.triggerUpdate() m.triggerUpdate()
return nil return nil
} }
@@ -1193,12 +1202,14 @@ func (m *Manager) SetGamma(gamma float64) error {
m.configMutex.Unlock() m.configMutex.Unlock()
return nil return nil
} }
m.config.Gamma = gamma updated := m.config
err := m.config.Validate() updated.Gamma = gamma
if err := updated.Validate(); err != nil {
m.configMutex.Unlock() m.configMutex.Unlock()
if err != nil {
return err return err
} }
m.config = updated
m.configMutex.Unlock()
m.triggerUpdate() m.triggerUpdate()
return nil return nil
} }
@@ -9,6 +9,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
mocks_wlclient "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/wlclient" mocks_wlclient "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/wlclient"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_gamma_control"
) )
func TestManager_ActorSerializesOutputStateAccess(t *testing.T) { func TestManager_ActorSerializesOutputStateAccess(t *testing.T) {
@@ -412,3 +413,75 @@ func TestNewManager_InvalidConfig(t *testing.T) {
_, err := NewManager(mockDisplay, config) _, err := NewManager(mockDisplay, config)
assert.Error(t, err) assert.Error(t, err)
} }
func TestSetters_RejectedValuesLeaveConfigUntouched(t *testing.T) {
newManager := func() *Manager {
return &Manager{
config: DefaultConfig(),
updateTrigger: make(chan struct{}, 1),
}
}
t.Run("SetTemperature", func(t *testing.T) {
m := newManager()
before := m.config
err := m.SetTemperature(3200, 2500)
assert.Error(t, err)
assert.Equal(t, before, m.config)
assert.Empty(t, m.updateTrigger)
})
t.Run("SetLocation", func(t *testing.T) {
m := newManager()
before := m.config
err := m.SetLocation(120.0, 10.0)
assert.Error(t, err)
assert.Equal(t, before, m.config)
assert.Empty(t, m.updateTrigger)
})
t.Run("SetGamma", func(t *testing.T) {
m := newManager()
before := m.config
err := m.SetGamma(-1.0)
assert.Error(t, err)
assert.Equal(t, before, m.config)
assert.Empty(t, m.updateTrigger)
})
}
func TestSetters_ValidValuesCommitAndTrigger(t *testing.T) {
m := &Manager{
config: DefaultConfig(),
updateTrigger: make(chan struct{}, 1),
}
err := m.SetTemperature(3000, 6000)
assert.NoError(t, err)
assert.Equal(t, 3000, m.config.LowTemp)
assert.Equal(t, 6000, m.config.HighTemp)
assert.Len(t, m.updateTrigger, 1)
}
func TestApplyGamma_SkipsUnchangedTempAndGamma(t *testing.T) {
m := &Manager{config: DefaultConfig()}
m.controlsInitialized = true
out := &outputState{
id: 1,
rampSize: 256,
gammaControl: &wlr_gamma_control.ZwlrGammaControlV1{},
lastTemp: 5000,
lastGamma: m.config.Gamma,
}
m.outputs.Store(out.id, out)
m.applyGamma(5000)
assert.False(t, out.failed, "unchanged temp must not reach the compositor write path")
assert.Equal(t, 5000, out.lastTemp)
assert.Equal(t, uint32(256), out.rampSize)
}
+108 -39
View File
@@ -7,12 +7,11 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
"github.com/go-git/go-git/v6" "github.com/go-git/go-git/v6"
"github.com/spf13/afero" "github.com/spf13/afero"
) )
const registryRepo = "https://github.com/AvengeMedia/dms-plugin-registry.git"
type ColorScheme struct { type ColorScheme struct {
Primary string `json:"primary,omitempty"` Primary string `json:"primary,omitempty"`
PrimaryText string `json:"primaryText,omitempty"` PrimaryText string `json:"primaryText,omitempty"`
@@ -151,6 +150,7 @@ type Theme struct {
type GitClient interface { type GitClient interface {
PlainClone(path string, url string) error PlainClone(path string, url string) error
Pull(path string) error Pull(path string) error
OriginURL(path string) (string, error)
} }
type realGitClient struct{} type realGitClient struct{}
@@ -182,9 +182,26 @@ func (g *realGitClient) Pull(path string) error {
return nil return nil
} }
func (g *realGitClient) OriginURL(path string) (string, error) {
repo, err := git.PlainOpen(path)
if err != nil {
return "", err
}
remote, err := repo.Remote("origin")
if err != nil {
return "", err
}
urls := remote.Config().URLs
if len(urls) == 0 {
return "", errors.New("origin remote has no URL")
}
return urls[0], nil
}
type Registry struct { type Registry struct {
fs afero.Fs fs afero.Fs
cacheDir string cacheDir string
registries []registries.Source
themes []Theme themes []Theme
git GitClient git GitClient
} }
@@ -194,61 +211,63 @@ func NewRegistry() (*Registry, error) {
} }
func NewRegistryWithFs(fs afero.Fs) (*Registry, error) { func NewRegistryWithFs(fs afero.Fs) (*Registry, error) {
cacheDir := getCacheDir()
return &Registry{ return &Registry{
fs: fs, fs: fs,
cacheDir: cacheDir, cacheDir: getCacheDir(),
registries: registries.Load(fs),
git: &realGitClient{}, git: &realGitClient{},
}, nil }, nil
} }
func (r *Registry) cacheDirFor(src registries.Source) string {
return filepath.Join(r.cacheDir, src.Name)
}
func getCacheDir() string { func getCacheDir() string {
return filepath.Join(os.TempDir(), "dankdots-plugin-registry") return filepath.Join(os.TempDir(), "dankdots-plugin-registry")
} }
func (r *Registry) Update() error { // A cached clone is reused only when its origin still matches the configured
exists, err := afero.DirExists(r.fs, r.cacheDir) // URL; renamed or re-pointed registries re-clone instead of pulling from the
// stale remote.
func (r *Registry) updateOne(src registries.Source) error {
dir := r.cacheDirFor(src)
exists, err := afero.DirExists(r.fs, dir)
if err != nil { if err != nil {
return fmt.Errorf("failed to check cache directory: %w", err) return fmt.Errorf("failed to check cache directory: %w", err)
} }
if !exists { if exists {
if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0o755); err != nil { origin, originErr := r.git.OriginURL(dir)
if originErr == nil && origin == src.URL && r.git.Pull(dir) == nil {
return nil
}
if err := r.fs.RemoveAll(dir); err != nil {
return fmt.Errorf("failed to remove stale registry cache: %w", err)
}
}
if err := r.fs.MkdirAll(filepath.Dir(dir), 0o755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err) return fmt.Errorf("failed to create cache directory: %w", err)
} }
if err := r.git.PlainClone(dir, src.URL); err != nil {
if err := r.git.PlainClone(r.cacheDir, registryRepo); err != nil { return fmt.Errorf("failed to clone: %w", err)
return fmt.Errorf("failed to clone registry: %w", err)
} }
} else { return nil
if err := r.git.Pull(r.cacheDir); err != nil {
if err := r.fs.RemoveAll(r.cacheDir); err != nil {
return fmt.Errorf("failed to remove corrupted registry: %w", err)
} }
if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0o755); err != nil { // A registry without a themes/ directory is a valid plugins-only registry.
return fmt.Errorf("failed to create cache directory: %w", err) func (r *Registry) loadThemesFrom(dir string) ([]Theme, error) {
} themesDir := filepath.Join(dir, "themes")
if err := r.git.PlainClone(r.cacheDir, registryRepo); err != nil {
return fmt.Errorf("failed to re-clone registry: %w", err)
}
}
}
return r.loadThemes()
}
func (r *Registry) loadThemes() error {
themesDir := filepath.Join(r.cacheDir, "themes")
entries, err := afero.ReadDir(r.fs, themesDir) entries, err := afero.ReadDir(r.fs, themesDir)
if err != nil { if err != nil {
return fmt.Errorf("failed to read themes directory: %w", err) if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("failed to read themes directory: %w", err)
} }
r.themes = []Theme{} var themes []Theme
for _, entry := range entries { for _, entry := range entries {
if !entry.IsDir() { if !entry.IsDir() {
continue continue
@@ -278,10 +297,46 @@ func (r *Registry) loadThemes() error {
theme.PreviewPath = previewPath theme.PreviewPath = previewPath
} }
r.themes = append(r.themes, theme) themes = append(themes, theme)
}
return themes, nil
} }
return nil // Pre-multi-registry caches were a single clone at the base dir; the per-name
// layout nests under it, so a leftover clone is deleted wholesale first.
func (r *Registry) resetLegacyCache() {
if exists, _ := afero.DirExists(r.fs, filepath.Join(r.cacheDir, ".git")); exists {
_ = r.fs.RemoveAll(r.cacheDir)
}
}
// Update refreshes every configured registry, aggregating themes in
// declaration order (first occurrence of an ID wins). A failing registry is
// reported in the joined error but does not block the others.
func (r *Registry) Update() error {
r.resetLegacyCache()
r.themes = []Theme{}
seen := make(map[string]struct{})
var errs []error
for _, src := range r.registries {
if err := r.updateOne(src); err != nil {
errs = append(errs, fmt.Errorf("registry %s: %w", src.Name, err))
continue
}
themes, err := r.loadThemesFrom(r.cacheDirFor(src))
if err != nil {
errs = append(errs, fmt.Errorf("registry %s: %w", src.Name, err))
continue
}
for _, t := range themes {
if _, dup := seen[t.ID]; dup {
continue
}
seen[t.ID] = struct{}{}
r.themes = append(r.themes, t)
}
}
return errors.Join(errs...)
} }
func loadThemeWCAG(fs afero.Fs, themeDir string) *ThemeWCAG { func loadThemeWCAG(fs afero.Fs, themeDir string) *ThemeWCAG {
@@ -300,7 +355,7 @@ func loadThemeWCAG(fs afero.Fs, themeDir string) *ThemeWCAG {
func (r *Registry) List() ([]Theme, error) { func (r *Registry) List() ([]Theme, error) {
if len(r.themes) == 0 { if len(r.themes) == 0 {
if err := r.Update(); err != nil { if err := r.Update(); err != nil && len(r.themes) == 0 {
return nil, err return nil, err
} }
} }
@@ -343,11 +398,25 @@ func (r *Registry) Get(idOrName string) (*Theme, error) {
} }
func (r *Registry) GetThemeSourcePath(themeID string) string { func (r *Registry) GetThemeSourcePath(themeID string) string {
return filepath.Join(r.cacheDir, "themes", themeID, "theme.json") // Themes may live under any registry's subdir. Search them all; first hit wins.
for _, cfg := range r.registries {
candidate := filepath.Join(r.cacheDirFor(cfg), "themes", themeID, "theme.json")
if exists, _ := afero.Exists(r.fs, candidate); exists {
return candidate
}
}
// Fallback to first registry (legacy path semantics).
return filepath.Join(r.cacheDirFor(r.registries[0]), "themes", themeID, "theme.json")
} }
func (r *Registry) GetThemeDir(themeID string) string { func (r *Registry) GetThemeDir(themeID string) string {
return filepath.Join(r.cacheDir, "themes", themeID) for _, cfg := range r.registries {
candidate := filepath.Join(r.cacheDirFor(cfg), "themes", themeID)
if exists, _ := afero.DirExists(r.fs, candidate); exists {
return candidate
}
}
return filepath.Join(r.cacheDirFor(r.registries[0]), "themes", themeID)
} }
func SortByFirstParty(themes []Theme) []Theme { func SortByFirstParty(themes []Theme) []Theme {
+72
View File
@@ -1,8 +1,10 @@
package themes package themes
import ( import (
"os"
"testing" "testing"
"github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
"github.com/spf13/afero" "github.com/spf13/afero"
) )
@@ -64,3 +66,73 @@ func TestLoadThemeWCAGInvalidJSON(t *testing.T) {
t.Fatalf("expected nil for invalid wcag.json, got %+v", wcag) t.Fatalf("expected nil for invalid wcag.json, got %+v", wcag)
} }
} }
type stubGitClient struct {
cloneFunc func(path string, url string) error
}
func (s *stubGitClient) PlainClone(path string, url string) error {
if s.cloneFunc != nil {
return s.cloneFunc(path, url)
}
return nil
}
func (s *stubGitClient) Pull(path string) error { return nil }
func (s *stubGitClient) OriginURL(path string) (string, error) { return "", os.ErrNotExist }
func writeTestTheme(t *testing.T, fs afero.Fs, registryDir, themeID, name string) {
dir := registryDir + "/themes/" + themeID
if err := fs.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
themeJSON := `{"id":"` + themeID + `","name":"` + name + `","version":"1.0","author":"a","description":"d"}`
if err := afero.WriteFile(fs, dir+"/theme.json", []byte(themeJSON), 0o644); err != nil {
t.Fatal(err)
}
}
func TestUpdateMultiRegistry(t *testing.T) {
fs := afero.NewMemMapFs()
base := "/test-cache"
r := &Registry{
fs: fs,
cacheDir: base,
registries: []registries.Source{
{Name: "official", URL: "https://example.com/official.git"},
{Name: "extra", URL: "https://example.com/extra.git"},
},
themes: []Theme{},
}
r.git = &stubGitClient{
cloneFunc: func(path string, url string) error {
switch path {
case base + "/official":
writeTestTheme(t, fs, path, "shared", "OfficialShared")
writeTestTheme(t, fs, path, "one", "One")
case base + "/extra":
writeTestTheme(t, fs, path, "shared", "ExtraShared")
writeTestTheme(t, fs, path, "two", "Two")
}
return nil
},
}
if err := r.Update(); err != nil {
t.Fatalf("Update: %v", err)
}
if len(r.themes) != 3 {
t.Fatalf("expected 3 themes after dedupe, got %d", len(r.themes))
}
for _, theme := range r.themes {
if theme.ID == "shared" && theme.Name != "OfficialShared" {
t.Fatalf("first registry should win for duplicate ID, got %q", theme.Name)
}
}
if dir := r.GetThemeDir("two"); dir != base+"/extra/themes/two" {
t.Fatalf("expected theme dir under extra registry, got %q", dir)
}
if path := r.GetThemeSourcePath("one"); path != base+"/official/themes/one/theme.json" {
t.Fatalf("expected theme source under official registry, got %q", path)
}
}
Generated
+3 -3
View File
@@ -3,11 +3,11 @@
"dank-qml-common": { "dank-qml-common": {
"flake": false, "flake": false,
"locked": { "locked": {
"lastModified": 1785445867, "lastModified": 1786062187,
"narHash": "sha256-l9IRsLIVZ7bV+KMuTdCga89tGt4lpdMXhQR7f/2qoe4=", "narHash": "sha256-i9tB3+PoM2d5NHfb7bvNc5IHvymp088OW3ThMAL+39I=",
"owner": "AvengeMedia", "owner": "AvengeMedia",
"repo": "dank-qml-common", "repo": "dank-qml-common",
"rev": "3b06bd9372e18bc8086cc3958d4f677d57fbfdd8", "rev": "3513b348ba425eab27d35b27ce924a0e324dc359",
"type": "github" "type": "github"
}, },
"original": { "original": {
+58
View File
@@ -271,3 +271,61 @@ function getConflictingBinds(keyCombo, currentAction, allBinds, modKey) {
} }
return conflicts; return conflicts;
} }
function qtKeyFromName(name) {
var n = (name || "").toUpperCase();
if (n.length === 1 && n >= "A" && n <= "Z")
return Qt.Key_A + (n.charCodeAt(0) - 65);
if (n.length === 1 && n >= "0" && n <= "9")
return Qt.Key_0 + (n.charCodeAt(0) - 48);
if (n.length >= 2 && n[0] === "F") {
var f = parseInt(n.slice(1), 10);
if (f >= 1 && f <= 12)
return Qt.Key_F1 + (f - 1);
}
var named = {
"SPACE": Qt.Key_Space,
"TAB": Qt.Key_Tab,
"RETURN": Qt.Key_Return,
"ENTER": Qt.Key_Enter,
"BACKSPACE": Qt.Key_Backspace,
"DELETE": Qt.Key_Delete,
"HOME": Qt.Key_Home,
"END": Qt.Key_End,
"UP": Qt.Key_Up,
"DOWN": Qt.Key_Down,
"LEFT": Qt.Key_Left,
"RIGHT": Qt.Key_Right
};
return named[n] || 0;
}
function isModifierKey(qk) {
return qk === Qt.Key_Control || qk === Qt.Key_Shift || qk === Qt.Key_Alt || qk === Qt.Key_Meta
|| qk === Qt.Key_NumLock || qk === Qt.Key_CapsLock || qk === Qt.Key_ScrollLock;
}
function eventMatchesCombo(event, combo) {
if (!combo)
return false;
var parts = combo.split("+");
var keyName = parts[parts.length - 1].trim().toUpperCase();
var wantsShift = false;
var hasCtrl = false;
for (var i = 0; i < parts.length - 1; i++) {
var mod = parts[i].trim().toLowerCase();
if (mod === "shift")
wantsShift = true;
else if (mod === "ctrl" || mod === "control")
hasCtrl = true;
else
return false;
}
if (hasCtrl && !(event.modifiers & Qt.ControlModifier))
return false;
if (event.modifiers & (Qt.AltModifier | Qt.MetaModifier))
return false;
if (((event.modifiers & Qt.ShiftModifier) !== 0) !== wantsShift)
return false;
return event.key === qtKeyFromName(keyName);
}
+11 -2
View File
@@ -15,7 +15,11 @@ Singleton {
function openModal(modal) { function openModal(modal) {
PopoutManager.screenshotActive = false; PopoutManager.screenshotActive = false;
const screenName = modal.effectiveScreen?.name ?? "unknown"; const screenName = modal.effectiveScreen?.name ?? "unknown";
currentModalsByScreen[screenName] = modal; var next = {};
for (var k in currentModalsByScreen)
next[k] = currentModalsByScreen[k];
next[screenName] = modal;
currentModalsByScreen = next;
modalChanged(); modalChanged();
Qt.callLater(() => { Qt.callLater(() => {
if (!modal.allowStacking) if (!modal.allowStacking)
@@ -34,7 +38,12 @@ Singleton {
function closeModal(modal) { function closeModal(modal) {
const screenName = modal.effectiveScreen?.name ?? "unknown"; const screenName = modal.effectiveScreen?.name ?? "unknown";
if (currentModalsByScreen[screenName] === modal) { if (currentModalsByScreen[screenName] === modal) {
delete currentModalsByScreen[screenName]; var next = {};
for (var k in currentModalsByScreen) {
if (k !== screenName)
next[k] = currentModalsByScreen[k];
}
currentModalsByScreen = next;
modalChanged(); modalChanged();
} }
} }
+38
View File
@@ -148,6 +148,7 @@ Singleton {
property var includedTransitions: availableWallpaperTransitions.filter(t => t !== "none") property var includedTransitions: availableWallpaperTransitions.filter(t => t !== "none")
property bool wallpaperCyclingEnabled: false property bool wallpaperCyclingEnabled: false
property bool wallpaperCyclingRandom: false
property string wallpaperCyclingMode: "interval" property string wallpaperCyclingMode: "interval"
property int wallpaperCyclingInterval: 300 property int wallpaperCyclingInterval: 300
property string wallpaperCyclingTime: "06:00" property string wallpaperCyclingTime: "06:00"
@@ -646,6 +647,11 @@ Singleton {
saveSettings(); saveSettings();
} }
function setWallpaperCyclingRandom(random) {
wallpaperCyclingRandom = random;
saveSettings();
}
function setWallpaperCyclingMode(mode) { function setWallpaperCyclingMode(mode) {
wallpaperCyclingMode = mode; wallpaperCyclingMode = mode;
saveSettings(); saveSettings();
@@ -692,6 +698,37 @@ Singleton {
saveSettings(); saveSettings();
} }
function setMonitorCyclingRandom(screenName, random) {
var screen = null;
var screens = Quickshell.screens;
for (var i = 0; i < screens.length; i++) {
if (screens[i].name === screenName) {
screen = screens[i];
break;
}
}
if (!screen) {
log.warn("Screen not found");
return;
}
var identifier = typeof SettingsData !== "undefined" ? SettingsData.getScreenDisplayName(screen) : screen.name;
var newSettings = {};
for (var key in monitorCyclingSettings) {
var isThisScreen = key === screen.name || (screen.model && key === screen.model);
if (!isThisScreen) {
newSettings[key] = monitorCyclingSettings[key];
}
}
newSettings[identifier] = getMonitorCyclingSettings(screenName);
newSettings[identifier].random = random;
monitorCyclingSettings = newSettings;
saveSettings();
}
function setMonitorCyclingMode(screenName, mode) { function setMonitorCyclingMode(screenName, mode) {
var screen = null; var screen = null;
var screens = Quickshell.screens; var screens = Quickshell.screens;
@@ -1322,6 +1359,7 @@ Singleton {
function getMonitorCyclingSettings(screenName) { function getMonitorCyclingSettings(screenName) {
var defaults = { var defaults = {
"enabled": false, "enabled": false,
"random": false,
"mode": "interval", "mode": "interval",
"interval": 300, "interval": 300,
"time": "06:00" "time": "06:00"
+20 -6
View File
@@ -164,6 +164,11 @@ Singleton {
property string matugenTargetMonitor: "" property string matugenTargetMonitor: ""
property real popupTransparency: 1.0 property real popupTransparency: 1.0
property real dockTransparency: 1 property real dockTransparency: 1
property bool floatingWindowSyncGlobal: true
property real floatingWindowTransparency: 1.0
property bool floatingWindowForegroundLayers: true
property real floatingWindowForegroundTransparency: 1.0
property bool dmsWindowsFloating: true
property string widgetBackgroundColor: "sch" property string widgetBackgroundColor: "sch"
property string widgetBackgroundCustomColor: "#6750A4" property string widgetBackgroundCustomColor: "#6750A4"
property real widgetBackgroundCustomStrength: 0.50 property real widgetBackgroundCustomStrength: 0.50
@@ -203,6 +208,16 @@ Singleton {
property bool touchpadTapAndDrag: true property bool touchpadTapAndDrag: true
property bool touchpadTapToClick: true property bool touchpadTapToClick: true
property string keyboardLayouts: ""
property string keyboardVariants: ""
property string keyboardModel: ""
property string keyboardOptions: ""
property string keyboardKeymapFile: ""
property string keyboardTrackLayout: ""
property int keyboardRepeatDelay: 0
property int keyboardRepeatRate: 0
property bool keyboardNumlock: false
property int firstDayOfWeek: -1 property int firstDayOfWeek: -1
property bool showWeekNumber: false property bool showWeekNumber: false
property string calendarBackend: "auto" property string calendarBackend: "auto"
@@ -254,6 +269,7 @@ Singleton {
onBlurEnabledChanged: saveSettings() onBlurEnabledChanged: saveSettings()
property bool blurForegroundLayers: true property bool blurForegroundLayers: true
onBlurForegroundLayersChanged: saveSettings() onBlurForegroundLayersChanged: saveSettings()
property real foregroundLayerTransparency: 1.0
property real blurLayerOutlineOpacity: 0.12 property real blurLayerOutlineOpacity: 0.12
onBlurLayerOutlineOpacityChanged: saveSettings() onBlurLayerOutlineOpacityChanged: saveSettings()
property bool blurBorderEnabled: true property bool blurBorderEnabled: true
@@ -479,6 +495,7 @@ Singleton {
property bool mediaAdaptiveWidthEnabled: true property bool mediaAdaptiveWidthEnabled: true
property bool audioVisualizerEnabled: true property bool audioVisualizerEnabled: true
property bool mediaUseAlbumArtAccent: false property bool mediaUseAlbumArtAccent: false
property bool appleMusicAnimatedArtEnabled: false
property string audioScrollMode: "volume" property string audioScrollMode: "volume"
property int audioWheelScrollAmount: 5 property int audioWheelScrollAmount: 5
property bool audioDeviceScrollVolumeEnabled: false property bool audioDeviceScrollVolumeEnabled: false
@@ -659,9 +676,6 @@ Singleton {
readonly property string iconTheme: resolveIconTheme() readonly property string iconTheme: resolveIconTheme()
property var availableIconThemes: ["System Default"] property var availableIconThemes: ["System Default"]
property string systemDefaultIconTheme: "" property string systemDefaultIconTheme: ""
property bool qt5ctAvailable: false
property bool qt6ctAvailable: false
property bool gtkAvailable: false
property var cursorSettings: ({ property var cursorSettings: ({
"theme": "System Default", "theme": "System Default",
@@ -798,8 +812,6 @@ Singleton {
property int fadeToDpmsGracePeriod: 5 property int fadeToDpmsGracePeriod: 5
property string launchPrefix: "" property string launchPrefix: ""
property bool gtkThemingEnabled: false
property bool qtThemingEnabled: false
property bool syncModeWithPortal: true property bool syncModeWithPortal: true
property bool terminalsAlwaysDark: false property bool terminalsAlwaysDark: false
@@ -922,6 +934,8 @@ Singleton {
property bool lockPamInlineU2f: false property bool lockPamInlineU2f: false
property bool lockPamExternallyManaged: false property bool lockPamExternallyManaged: false
property string lockU2fPamPath: "" property string lockU2fPamPath: ""
property string lockScreenSecurityKeyShortcut: "Ctrl+Q"
property bool lockScreenSecurityKeyShortcutEnabled: false
property bool greeterPamExternallyManaged: false property bool greeterPamExternallyManaged: false
property string lockScreenInactiveColor: "#000000" property string lockScreenInactiveColor: "#000000"
property int lockScreenNotificationMode: 0 property int lockScreenNotificationMode: 0
@@ -976,6 +990,7 @@ Singleton {
property string customPowerActionHibernate: "" property string customPowerActionHibernate: ""
property string customPowerActionReboot: "" property string customPowerActionReboot: ""
property string customPowerActionPowerOff: "" property string customPowerActionPowerOff: ""
property var customPowerButtons: []
property bool updaterHideWidget: false property bool updaterHideWidget: false
property bool updaterCheckOnStart: false property bool updaterCheckOnStart: false
@@ -1683,7 +1698,6 @@ Singleton {
_hasLoaded = true; _hasLoaded = true;
applyStoredTheme(); applyStoredTheme();
updateCompositorCursor(); updateCompositorCursor();
Processes.detectQtTools();
Qt.callLater(checkIconThemeDrift); Qt.callLater(checkIconThemeDrift);
_checkSettingsWritable(); _checkSettingsWritable();
+33 -4
View File
@@ -96,8 +96,6 @@ Singleton {
} }
property bool matugenAvailable: false property bool matugenAvailable: false
property bool gtkThemingEnabled: typeof SettingsData !== "undefined" ? SettingsData.gtkAvailable : false
property bool qtThemingEnabled: typeof SettingsData !== "undefined" ? (SettingsData.qt5ctAvailable || SettingsData.qt6ctAvailable) : false
property var workerRunning: false property var workerRunning: false
property var pendingThemeRequest: null property var pendingThemeRequest: null
@@ -347,11 +345,28 @@ Singleton {
readonly property bool foregroundLayers: typeof SettingsData === "undefined" || (SettingsData.blurForegroundLayers ?? true) readonly property bool foregroundLayers: typeof SettingsData === "undefined" || (SettingsData.blurForegroundLayers ?? true)
readonly property bool blurForegroundLayers: blurLayersActive && foregroundLayers readonly property bool blurForegroundLayers: blurLayersActive && foregroundLayers
readonly property bool transparentBlurLayers: blurLayersActive && !foregroundLayers readonly property bool transparentBlurLayers: blurLayersActive && !foregroundLayers
readonly property real foregroundLayerTransparency: typeof SettingsData === "undefined" ? 1.0 : (SettingsData.foregroundLayerTransparency ?? 1.0)
readonly property bool notificationForegroundLayers: typeof SettingsData === "undefined" || (SettingsData.notificationForegroundLayers ?? true) readonly property bool notificationForegroundLayers: typeof SettingsData === "undefined" || (SettingsData.notificationForegroundLayers ?? true)
readonly property color readableSurface: withAlpha(surfaceContainer, popupTransparency) readonly property color readableSurface: withAlpha(surfaceContainer, popupTransparency)
readonly property color readableSurfaceHigh: withAlpha(surfaceContainerHigh, popupTransparency) readonly property color readableSurfaceHigh: withAlpha(surfaceContainerHigh, popupTransparency)
readonly property color floatingSurface: foregroundLayers ? readableSurface : withAlpha(readableSurface, 0) readonly property color floatingSurface: foregroundLayers ? readableSurface : withAlpha(readableSurface, 0)
readonly property color floatingSurfaceHigh: foregroundLayers ? readableSurfaceHigh : withAlpha(readableSurfaceHigh, 0) readonly property color floatingSurfaceHigh: foregroundLayers ? withAlpha(surfaceContainerHigh, foregroundLayerTransparency) : withAlpha(surfaceContainerHigh, 0)
readonly property bool floatingWindowSynced: typeof SettingsData === "undefined" || (SettingsData.floatingWindowSyncGlobal ?? true)
readonly property real floatingWindowTransparency: {
if (typeof SettingsData === "undefined" || floatingWindowSynced)
return popupTransparency;
return SettingsData.floatingWindowTransparency ?? 1.0;
}
readonly property bool floatingWindowForegroundLayers: floatingWindowSynced ? foregroundLayers : (SettingsData.floatingWindowForegroundLayers ?? true)
readonly property real floatingWindowForegroundTransparency: {
if (typeof SettingsData === "undefined" || floatingWindowSynced)
return foregroundLayerTransparency;
return SettingsData.floatingWindowForegroundTransparency ?? 1.0;
}
readonly property color floatingWindowSurface: withAlpha(surfaceContainer, floatingWindowTransparency)
readonly property color floatingWindowSurfaceHigh: floatingWindowForegroundLayers ? withAlpha(surfaceContainerHigh, floatingWindowForegroundTransparency) : withAlpha(surfaceContainerHigh, 0)
readonly property color floatingWindowNestedSurface: floatingWindowSurfaceHigh
readonly property color notepadWindowSurface: withAlpha(surfaceContainer, notepadTransparency)
readonly property color nestedSurface: floatingSurfaceHigh readonly property color nestedSurface: floatingSurfaceHigh
readonly property color notificationFloatingSurface: notificationForegroundLayers ? readableSurface : withAlpha(readableSurface, 0) readonly property color notificationFloatingSurface: notificationForegroundLayers ? readableSurface : withAlpha(readableSurface, 0)
readonly property color notificationFloatingSurfaceHigh: notificationForegroundLayers ? readableSurfaceHigh : withAlpha(readableSurfaceHigh, 0) readonly property color notificationFloatingSurfaceHigh: notificationForegroundLayers ? readableSurfaceHigh : withAlpha(readableSurfaceHigh, 0)
@@ -359,6 +374,20 @@ Singleton {
readonly property real blurLayerOutlineOpacity: Math.max(0, Math.min(1, typeof SettingsData === "undefined" ? 0.12 : (SettingsData.blurLayerOutlineOpacity ?? 0.12))) readonly property real blurLayerOutlineOpacity: Math.max(0, Math.min(1, typeof SettingsData === "undefined" ? 0.12 : (SettingsData.blurLayerOutlineOpacity ?? 0.12)))
readonly property real layerOutlineOpacity: blurLayerOutlineOpacity readonly property real layerOutlineOpacity: blurLayerOutlineOpacity
readonly property int layerOutlineWidth: layerOutlineOpacity > 0 ? 1 : 0 readonly property int layerOutlineWidth: layerOutlineOpacity > 0 ? 1 : 0
readonly property real floatingWindowFieldAlpha: floatingWindowTransparency
readonly property color floatingWindowFieldColor: withAlpha(surfaceContainerHigh, floatingWindowFieldAlpha)
readonly property real popupFieldAlpha: {
if (transparentBlurLayers)
return 0.28;
if (blurForegroundLayers)
return Math.max(foregroundLayerTransparency, 0.62);
return popupTransparency;
}
readonly property color popupFieldColor: withAlpha(surfaceContainerHigh, popupFieldAlpha)
readonly property color popupFieldBorderColor: withAlpha(outline, blurLayersActive ? 0.16 : layerOutlineOpacity)
readonly property color popupFieldFocusedBorderColor: withAlpha(primary, blurLayersActive ? 0.72 : 1.0)
readonly property color floatingWindowFieldBorderColor: popupFieldBorderColor
readonly property color floatingWindowFieldFocusedBorderColor: popupFieldFocusedBorderColor
property color surfaceTextHover: withAlpha(surfaceText, 0.08) property color surfaceTextHover: withAlpha(surfaceText, 0.08)
property color surfaceTextAlpha: withAlpha(surfaceText, 0.3) property color surfaceTextAlpha: withAlpha(surfaceText, 0.3)
@@ -1196,7 +1225,7 @@ Singleton {
readonly property var _availableThemeNames: StockThemes.getAllThemeNames() readonly property var _availableThemeNames: StockThemes.getAllThemeNames()
property string currentThemeName: currentTheme property string currentThemeName: currentTheme
property real notepadTransparency: SettingsData.notepadTransparencyOverride >= 0 ? SettingsData.notepadTransparencyOverride : popupTransparency property real notepadTransparency: SettingsData.notepadTransparencyOverride >= 0 ? SettingsData.notepadTransparencyOverride : floatingWindowTransparency
property bool widgetBackgroundHasAlpha: { property bool widgetBackgroundHasAlpha: {
const colorMode = typeof SettingsData !== "undefined" ? SettingsData.widgetBackgroundColor : "sch"; const colorMode = typeof SettingsData !== "undefined" ? SettingsData.widgetBackgroundColor : "sch";
+61 -10
View File
@@ -1,31 +1,69 @@
pragma Singleton pragma Singleton
import Quickshell import Quickshell
import Quickshell.Services.SystemTray
import QtQuick import QtQuick
Singleton { Singleton {
id: root id: root
property var activeTrayMenus: ({}) property var activeTrayMenus: ({})
property var _pendingMenuRequest: null
signal openTrayMenuRequested
function requestOpenMenu(itemId, screenName) {
_pendingMenuRequest = {
"itemId": itemId,
"screenName": screenName
};
openTrayMenuRequested();
}
// Every SystemTrayBar instance receives the signal; the claim ensures
// exactly one opens the menu, preferring the requested screen
function claimMenuRequest(instanceScreenName) {
if (!_pendingMenuRequest)
return null;
if (_pendingMenuRequest.screenName && _pendingMenuRequest.screenName !== instanceScreenName)
return null;
const request = _pendingMenuRequest;
_pendingMenuRequest = null;
return request;
}
function findTrayItem(itemId: string): var {
if (!itemId)
return null;
return SystemTray.items.values.find(item => {
const id = item?.id || "";
const title = item?.tooltipTitle || "";
const fullKey = title ? `${id}::${title}` : id;
return fullKey === itemId || id === itemId;
});
}
function registerMenu(screenName, menu) { function registerMenu(screenName, menu) {
if (!screenName || !menu) return if (!screenName || !menu)
const newMenus = Object.assign({}, activeTrayMenus) return;
newMenus[screenName] = menu const newMenus = Object.assign({}, activeTrayMenus);
activeTrayMenus = newMenus newMenus[screenName] = menu;
activeTrayMenus = newMenus;
} }
function unregisterMenu(screenName) { function unregisterMenu(screenName) {
if (!screenName) return if (!screenName)
const newMenus = Object.assign({}, activeTrayMenus) return;
delete newMenus[screenName] const newMenus = Object.assign({}, activeTrayMenus);
activeTrayMenus = newMenus delete newMenus[screenName];
activeTrayMenus = newMenus;
} }
function closeAllMenus() { function closeHoverMenus() {
for (const screenName in activeTrayMenus) { for (const screenName in activeTrayMenus) {
const menu = activeTrayMenus[screenName] const menu = activeTrayMenus[screenName]
if (!menu) continue if (!menu || menu.openedByHover !== true) continue
if (typeof menu.close === "function") { if (typeof menu.close === "function") {
menu.close() menu.close()
} else if (menu.showMenu !== undefined) { } else if (menu.showMenu !== undefined) {
@@ -33,4 +71,17 @@ Singleton {
} }
} }
} }
function closeAllMenus() {
for (const screenName in activeTrayMenus) {
const menu = activeTrayMenus[screenName];
if (!menu)
continue;
if (typeof menu.close === "function") {
menu.close();
} else if (menu.showMenu !== undefined) {
menu.showMenu = false;
}
}
}
} }
-31
View File
@@ -487,41 +487,10 @@ Singleton {
return pamFprintDetected ? "probe_failed" : "missing_pam_support"; return pamFprintDetected ? "probe_failed" : "missing_pam_support";
} }
// --- Qt tools detection ---
function detectQtTools() {
qtToolsDetectionProcess.running = true;
}
function checkPluginSettings() { function checkPluginSettings() {
pluginSettingsCheckProcess.running = true; pluginSettingsCheckProcess.running = true;
} }
property var qtToolsDetectionProcess: Process {
command: ["sh", "-c", "echo -n 'qt5ct:'; command -v qt5ct >/dev/null && echo 'true' || echo 'false'; echo -n 'qt6ct:'; command -v qt6ct >/dev/null && echo 'true' || echo 'false'; echo -n 'gtk:'; (command -v gsettings >/dev/null || command -v dconf >/dev/null) && echo 'true' || echo 'false'"]
running: false
stdout: StdioCollector {
onStreamFinished: {
if (!settingsRoot)
return;
if (text && text.trim()) {
const lines = text.trim().split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith("qt5ct:")) {
settingsRoot.qt5ctAvailable = line.split(":")[1] === "true";
} else if (line.startsWith("qt6ct:")) {
settingsRoot.qt6ctAvailable = line.split(":")[1] === "true";
} else if (line.startsWith("gtk:")) {
settingsRoot.gtkAvailable = line.split(":")[1] === "true";
}
}
}
}
}
}
Timer { Timer {
id: authApplyDebounce id: authApplyDebounce
interval: 300 interval: 300
@@ -19,6 +19,7 @@ var SPEC = {
includedTransitions: { def: ["fade", "wipe", "disc", "stripes", "iris bloom", "pixelate", "portal"] }, includedTransitions: { def: ["fade", "wipe", "disc", "stripes", "iris bloom", "pixelate", "portal"] },
wallpaperCyclingEnabled: { def: false }, wallpaperCyclingEnabled: { def: false },
wallpaperCyclingRandom: { def: false },
wallpaperCyclingMode: { def: "interval" }, wallpaperCyclingMode: { def: "interval" },
wallpaperCyclingInterval: { def: 300 }, wallpaperCyclingInterval: { def: 300 },
wallpaperCyclingTime: { def: "06:00" }, wallpaperCyclingTime: { def: "06:00" },
+20 -5
View File
@@ -17,6 +17,11 @@ var SPEC = {
popupTransparency: { def: 1.0, coerce: percentToUnit }, popupTransparency: { def: 1.0, coerce: percentToUnit },
dockTransparency: { def: 1.0, coerce: percentToUnit }, dockTransparency: { def: 1.0, coerce: percentToUnit },
floatingWindowSyncGlobal: { def: true },
floatingWindowTransparency: { def: 1.0, coerce: percentToUnit },
floatingWindowForegroundLayers: { def: true },
floatingWindowForegroundTransparency: { def: 1.0, coerce: percentToUnit },
dmsWindowsFloating: { def: true },
widgetBackgroundColor: { def: "sch" }, widgetBackgroundColor: { def: "sch" },
widgetBackgroundCustomColor: { def: "#6750A4" }, widgetBackgroundCustomColor: { def: "#6750A4" },
@@ -57,6 +62,16 @@ var SPEC = {
touchpadTapAndDrag: { def: true, onChange: "updateCompositorInput" }, touchpadTapAndDrag: { def: true, onChange: "updateCompositorInput" },
touchpadTapToClick: { def: true, onChange: "updateCompositorInput" }, touchpadTapToClick: { def: true, onChange: "updateCompositorInput" },
keyboardLayouts: { def: "", onChange: "updateCompositorInput" },
keyboardVariants: { def: "", onChange: "updateCompositorInput" },
keyboardModel: { def: "", onChange: "updateCompositorInput" },
keyboardOptions: { def: "", onChange: "updateCompositorInput" },
keyboardKeymapFile: { def: "", onChange: "updateCompositorInput" },
keyboardTrackLayout: { def: "", onChange: "updateCompositorInput" },
keyboardRepeatDelay: { def: 0, onChange: "updateCompositorInput" },
keyboardRepeatRate: { def: 0, onChange: "updateCompositorInput" },
keyboardNumlock: { def: false, onChange: "updateCompositorInput" },
firstDayOfWeek: { def: -1 }, firstDayOfWeek: { def: -1 },
showWeekNumber: { def: false }, showWeekNumber: { def: false },
calendarBackend: { def: "auto" }, calendarBackend: { def: "auto" },
@@ -87,6 +102,7 @@ var SPEC = {
barElevationEnabled: { def: true }, barElevationEnabled: { def: true },
blurEnabled: { def: false }, blurEnabled: { def: false },
blurForegroundLayers: { def: true }, blurForegroundLayers: { def: true },
foregroundLayerTransparency: { def: 1.0, coerce: percentToUnit },
blurLayerOutlineOpacity: { def: 0.12, coerce: percentToUnit }, blurLayerOutlineOpacity: { def: 0.12, coerce: percentToUnit },
blurBorderEnabled: { def: true }, blurBorderEnabled: { def: true },
blurBorderColor: { def: "outline" }, blurBorderColor: { def: "outline" },
@@ -205,6 +221,7 @@ var SPEC = {
mediaAdaptiveWidthEnabled: { def: true }, mediaAdaptiveWidthEnabled: { def: true },
audioVisualizerEnabled: { def: true }, audioVisualizerEnabled: { def: true },
mediaUseAlbumArtAccent: { def: false }, mediaUseAlbumArtAccent: { def: false },
appleMusicAnimatedArtEnabled: { def: false },
audioScrollMode: { def: "volume" }, audioScrollMode: { def: "volume" },
audioWheelScrollAmount: { def: 5 }, audioWheelScrollAmount: { def: 5 },
audioDeviceScrollVolumeEnabled: { def: false }, audioDeviceScrollVolumeEnabled: { def: false },
@@ -298,9 +315,6 @@ var SPEC = {
lastAppliedIconTheme: { def: "" }, lastAppliedIconTheme: { def: "" },
availableIconThemes: { def: ["System Default"], persist: false }, availableIconThemes: { def: ["System Default"], persist: false },
systemDefaultIconTheme: { def: "", persist: false }, systemDefaultIconTheme: { def: "", persist: false },
qt5ctAvailable: { def: false, persist: false },
qt6ctAvailable: { def: false, persist: false },
gtkAvailable: { def: false, persist: false },
cursorSettings: { def: { theme: "System Default", size: 24, niri: { hideWhenTyping: false, hideAfterInactiveMs: 0 }, hyprland: { hideOnKeyPress: false, hideOnTouch: false, inactiveTimeout: 0 }, dwl: { cursorHideTimeout: 0 }, mango: { cursorHideTimeout: 0 } }, onChange: "updateCompositorCursor" }, cursorSettings: { def: { theme: "System Default", size: 24, niri: { hideWhenTyping: false, hideAfterInactiveMs: 0 }, hyprland: { hideOnKeyPress: false, hideOnTouch: false, inactiveTimeout: 0 }, dwl: { cursorHideTimeout: 0 }, mango: { cursorHideTimeout: 0 } }, onChange: "updateCompositorCursor" },
availableCursorThemes: { def: ["System Default"], persist: false }, availableCursorThemes: { def: ["System Default"], persist: false },
@@ -374,8 +388,6 @@ var SPEC = {
fadeToDpmsGracePeriod: { def: 5 }, fadeToDpmsGracePeriod: { def: 5 },
launchPrefix: { def: "" }, launchPrefix: { def: "" },
gtkThemingEnabled: { def: false, onChange: "regenSystemThemes" },
qtThemingEnabled: { def: false, onChange: "regenSystemThemes" },
syncModeWithPortal: { def: true }, syncModeWithPortal: { def: true },
terminalsAlwaysDark: { def: false, onChange: "regenSystemThemes" }, terminalsAlwaysDark: { def: false, onChange: "regenSystemThemes" },
@@ -473,6 +485,8 @@ var SPEC = {
enableU2f: { def: false, onChange: "scheduleAuthApply" }, enableU2f: { def: false, onChange: "scheduleAuthApply" },
u2fMode: { def: "or" }, u2fMode: { def: "or" },
lockPamPath: { def: "" }, lockPamPath: { def: "" },
lockScreenSecurityKeyShortcut: { def: "Ctrl+Q" },
lockScreenSecurityKeyShortcutEnabled: { def: false },
lockPamInlineFprint: { def: false }, lockPamInlineFprint: { def: false },
lockPamInlineU2f: { def: false }, lockPamInlineU2f: { def: false },
lockPamExternallyManaged: { def: false }, lockPamExternallyManaged: { def: false },
@@ -529,6 +543,7 @@ var SPEC = {
customPowerActionHibernate: { def: "" }, customPowerActionHibernate: { def: "" },
customPowerActionReboot: { def: "" }, customPowerActionReboot: { def: "" },
customPowerActionPowerOff: { def: "" }, customPowerActionPowerOff: { def: "" },
customPowerButtons: { def: [] },
updaterHideWidget: { def: false }, updaterHideWidget: { def: false },
updaterCheckOnStart: { def: false }, updaterCheckOnStart: { def: false },
+5 -1
View File
@@ -635,7 +635,7 @@ Item {
if (visible) { if (visible) {
wasShown = true; wasShown = true;
} else if (wasShown) { } else if (wasShown) {
PopoutService.unloadSettings(); Qt.callLater(() => PopoutService.unloadSettingsNow());
} }
} }
} }
@@ -940,6 +940,7 @@ Item {
expandedWidthValue: 960 expandedWidthValue: 960
edgeGap: SettingsData.notepadEffectiveEdgeGap edgeGap: SettingsData.notepadEffectiveEdgeGap
slideEdge: SettingsData.notepadSlideoutSide slideEdge: SettingsData.notepadSlideoutSide
customTransparency: Theme.notepadTransparency
onIsVisibleChanged: { onIsVisibleChanged: {
if (isVisible) if (isVisible)
@@ -1015,6 +1016,9 @@ Item {
case "reboot": case "reboot":
SessionService.reboot(); SessionService.reboot();
break; break;
case "softreboot":
SessionService.softReboot();
break;
case "poweroff": case "poweroff":
SessionService.poweroff(); SessionService.poweroff();
break; break;
+14 -14
View File
@@ -2023,18 +2023,6 @@ Item {
} }
IpcHandler { IpcHandler {
function findTrayItem(itemId: string): var {
if (!itemId)
return null;
return SystemTray.items.values.find(item => {
const id = item?.id || "";
const title = item?.tooltipTitle || "";
const fullKey = title ? `${id}::${title}` : id;
return fullKey === itemId || id === itemId;
});
}
function list(): string { function list(): string {
const items = SystemTray.items.values; const items = SystemTray.items.values;
if (items.length === 0) if (items.length === 0)
@@ -2050,7 +2038,7 @@ Item {
} }
function activate(itemId: string): string { function activate(itemId: string): string {
const item = findTrayItem(itemId); const item = TrayMenuManager.findTrayItem(itemId);
if (!item) if (!item)
return `ERROR: Tray item not found: ${itemId}`; return `ERROR: Tray item not found: ${itemId}`;
@@ -2058,8 +2046,20 @@ Item {
return `SUCCESS: Activated ${itemId}`; return `SUCCESS: Activated ${itemId}`;
} }
function menu(itemId: string): string {
const item = TrayMenuManager.findTrayItem(itemId);
if (!item)
return `ERROR: Tray item not found: ${itemId}`;
if (!item.hasMenu)
return `ERROR: Tray item has no menu: ${itemId}`;
TrayMenuManager.requestOpenMenu(itemId, BarWidgetService.getFocusedScreenName());
return `SUCCESS: Requested menu ${itemId}`;
}
function status(itemId: string): string { function status(itemId: string): string {
const item = findTrayItem(itemId); const item = TrayMenuManager.findTrayItem(itemId);
if (!item) if (!item)
return `ERROR: Tray item not found: ${itemId}`; return `ERROR: Tray item not found: ${itemId}`;
-4
View File
@@ -318,10 +318,6 @@ DankModal {
width: parent.width - Theme.spacingS * 2 width: parent.width - Theme.spacingS * 2
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
height: 52 height: 52
cornerRadius: Theme.cornerRadius
backgroundColor: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
normalBorderColor: Theme.outlineMedium
focusedBorderColor: Theme.primary
leftIconName: "search" leftIconName: "search"
leftIconSize: Theme.iconSize leftIconSize: Theme.iconSize
leftIconColor: Theme.surfaceVariantText leftIconColor: Theme.surfaceVariantText
@@ -15,7 +15,7 @@ Rectangle {
height: Math.round(Theme.fontSizeMedium * 4.2) height: Math.round(Theme.fontSizeMedium * 4.2)
radius: Theme.cornerRadius radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh color: Theme.floatingWindowNestedSurface
Rectangle { Rectangle {
anchors.fill: parent anchors.fill: parent
@@ -4,10 +4,9 @@ import qs.Common
import qs.Services import qs.Services
import qs.Widgets import qs.Widgets
FloatingWindow { DankFloatingWindow {
id: root id: root
property bool disablePopupTransparency: true
readonly property int modalWidth: 680 readonly property int modalWidth: 680
readonly property int modalHeight: screen ? Math.min(720, screen.height - 80) : 720 readonly property int modalHeight: screen ? Math.min(720, screen.height - 80) : 720
@@ -21,7 +20,6 @@ FloatingWindow {
title: i18n("What's New") title: i18n("What's New")
minimumSize: Qt.size(modalWidth, modalHeight) minimumSize: Qt.size(modalWidth, modalHeight)
maximumSize: Qt.size(modalWidth, modalHeight) maximumSize: Qt.size(modalWidth, modalHeight)
color: Theme.surfaceContainer
visible: false visible: false
onClosed: visible = false onClosed: visible = false
@@ -112,7 +110,7 @@ FloatingWindow {
anchors.right: parent.right anchors.right: parent.right
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
height: Math.round(Theme.fontSizeMedium * 4.5) height: Math.round(Theme.fontSizeMedium * 4.5)
color: Theme.surfaceContainerHigh color: Theme.floatingWindowNestedSurface
Rectangle { Rectangle {
anchors.top: parent.top anchors.top: parent.top
@@ -819,6 +819,13 @@ Item {
} }
if (isCategoryFiltered) { if (isCategoryFiltered) {
var categoryPluginId = AppSearchService.getPluginIdForCategory(appCategory);
if (categoryPluginId) {
var pluginCategoryItems = getPluginItems(categoryPluginId, "");
for (var i = 0; i < pluginCategoryItems.length; i++) {
allItems.push(pluginCategoryItems[i]);
}
} else {
var rawApps = AppSearchService.getAppsInCategory(appCategory); var rawApps = AppSearchService.getAppsInCategory(appCategory);
for (var i = 0; i < rawApps.length; i++) { for (var i = 0; i < rawApps.length; i++) {
allItems.push(getOrTransformApp(rawApps[i])); allItems.push(getOrTransformApp(rawApps[i]));
@@ -830,6 +837,7 @@ Item {
if (coreAppCats.indexOf(appCategory) !== -1) if (coreAppCats.indexOf(appCategory) !== -1)
allItems.push(transformCoreApp(allCoreApps[i])); allItems.push(transformCoreApp(allCoreApps[i]));
} }
}
} else { } else {
var apps = searchApps(searchQuery); var apps = searchApps(searchQuery);
for (var i = 0; i < apps.length; i++) { for (var i = 0; i < apps.length; i++) {
@@ -839,7 +847,7 @@ Item {
var scoredItems = Scorer.scoreItems(allItems, searchQuery, getFrecencyForItem); var scoredItems = Scorer.scoreItems(allItems, searchQuery, getFrecencyForItem);
var sortAlpha = !searchQuery && SettingsData.sortAppsAlphabetically; var sortAlpha = !searchQuery && SettingsData.sortAppsAlphabetically;
var newSections = Scorer.groupBySection(scoredItems, sectionDefinitions, sortAlpha, searchQuery ? 50 : 500); var newSections = Scorer.groupBySection(scoredItems, buildDynamicSectionDefs(allItems), sortAlpha, searchQuery ? 50 : 500);
for (var sid in collapsedSections) { for (var sid in collapsedSections) {
for (var i = 0; i < newSections.length; i++) { for (var i = 0; i < newSections.length; i++) {
@@ -23,17 +23,6 @@ FocusScope {
property bool editMode: false property bool editMode: false
property var editingApp: null property var editingApp: null
property string editAppId: "" property string editAppId: ""
readonly property bool _blurActive: Theme.blurForegroundLayers || Theme.transparentBlurLayers
readonly property real _launcherFieldAlpha: {
if (Theme.transparentBlurLayers)
return 0.28;
if (Theme.blurForegroundLayers)
return Math.max(Theme.popupTransparency, 0.62);
return Theme.popupTransparency;
}
readonly property color _launcherSearchFieldColor: Theme.withAlpha(Theme.surfaceContainerHigh, _launcherFieldAlpha)
readonly property color _launcherSearchBorderColor: Theme.withAlpha(Theme.outline, _blurActive ? 0.16 : Theme.layerOutlineOpacity)
readonly property color _launcherSearchFocusedBorderColor: Theme.withAlpha(Theme.primary, _blurActive ? 0.72 : 1.0)
function resetScroll() { function resetScroll() {
resultsList.resetScroll(); resultsList.resetScroll();
@@ -314,7 +303,7 @@ FocusScope {
anchors.fill: parent anchors.fill: parent
anchors.topMargin: -Theme.cornerRadius anchors.topMargin: -Theme.cornerRadius
// In connected mode the launcher provides the surface so update the toolbar for arcs // In connected mode the launcher provides the surface so update the toolbar for arcs
visible: !(root.parentModal?.frameOwnsConnectedChrome ?? false) && !root._blurActive visible: !(root.parentModal?.frameOwnsConnectedChrome ?? false) && !Theme.blurLayersActive
color: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency) color: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
radius: Theme.cornerRadius radius: Theme.cornerRadius
} }
@@ -472,12 +461,6 @@ FocusScope {
DankTextField { DankTextField {
id: searchField id: searchField
width: parent.width - (pluginBadge.visible ? pluginBadge.width + Theme.spacingS : 0) width: parent.width - (pluginBadge.visible ? pluginBadge.width + Theme.spacingS : 0)
cornerRadius: Theme.cornerRadius
backgroundColor: root._launcherSearchFieldColor
normalBorderColor: root._launcherSearchBorderColor
focusedBorderColor: root._launcherSearchFocusedBorderColor
borderWidth: 1
focusedBorderWidth: 2
leftIconName: controller.activePluginId ? "extension" : controller.searchQuery.startsWith("/") ? "folder" : "search" leftIconName: controller.activePluginId ? "extension" : controller.searchQuery.startsWith("/") ? "folder" : "search"
leftIconSize: Theme.iconSize leftIconSize: Theme.iconSize
leftIconColor: Theme.surfaceVariantText leftIconColor: Theme.surfaceVariantText
@@ -7,6 +7,7 @@ Item {
property string source: "" property string source: ""
property int glyphSize: 14 property int glyphSize: 14
property bool badgeVisible: true
readonly property var sourceAsset: ({ readonly property var sourceAsset: ({
"flatpak": "../../assets/package-sources/flatpak.svg", "flatpak": "../../assets/package-sources/flatpak.svg",
@@ -17,7 +18,7 @@ Item {
readonly property string assetPath: sourceAsset[source] || "" readonly property string assetPath: sourceAsset[source] || ""
visible: SettingsData.dankLauncherV2ShowSourceBadges && assetPath.length > 0 visible: badgeVisible && SettingsData.dankLauncherV2ShowSourceBadges && assetPath.length > 0
implicitWidth: glyphSize implicitWidth: glyphSize
implicitHeight: glyphSize implicitHeight: glyphSize
@@ -178,7 +178,7 @@ Rectangle {
anchors.margins: Theme.spacingXS anchors.margins: Theme.spacingXS
source: root.item?.type === "app" ? (root.item.source || "") : "" source: root.item?.type === "app" ? (root.item.source || "") : ""
glyphSize: 16 glyphSize: 16
visible: !root.isSelected && !!source badgeVisible: !root.isSelected
} }
} }
} }
@@ -146,7 +146,7 @@ Item {
width: parent.width width: parent.width
height: keybindsGrid.height + Theme.spacingM * 2 height: keybindsGrid.height + Theme.spacingM * 2
radius: Theme.cornerRadius radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh color: Theme.floatingWindowNestedSurface
readonly property bool useTwoColumns: width > 500 readonly property bool useTwoColumns: width > 500
readonly property int columnCount: useTwoColumns ? 2 : 1 readonly property int columnCount: useTwoColumns ? 2 : 1
@@ -240,7 +240,7 @@ Item {
width: parent.width width: parent.width
height: noKeybindsColumn.height + Theme.spacingM * 2 height: noKeybindsColumn.height + Theme.spacingM * 2
radius: Theme.cornerRadius radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh color: Theme.floatingWindowNestedSurface
visible: !root.hasKeybinds visible: !root.hasKeybinds
Column { Column {
@@ -291,7 +291,7 @@ Item {
anchors.leftMargin: Theme.spacingXL anchors.leftMargin: Theme.spacingXL
anchors.rightMargin: Theme.spacingXL anchors.rightMargin: Theme.spacingXL
radius: Theme.cornerRadius radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh color: Theme.floatingWindowNestedSurface
clip: true clip: true
Column { Column {
@@ -15,7 +15,7 @@ Rectangle {
height: Math.round(Theme.fontSizeMedium * 6.4) height: Math.round(Theme.fontSizeMedium * 6.4)
radius: Theme.cornerRadius radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh color: Theme.floatingWindowNestedSurface
Rectangle { Rectangle {
anchors.fill: parent anchors.fill: parent
+3 -5
View File
@@ -5,11 +5,10 @@ import qs.Common
import qs.Services import qs.Services
import qs.Widgets import qs.Widgets
FloatingWindow { DankFloatingWindow {
id: root id: root
readonly property var log: Log.scoped("GreeterModal") readonly property var log: Log.scoped("GreeterModal")
property bool disablePopupTransparency: true
property int currentPage: 0 property int currentPage: 0
readonly property int totalPages: 3 readonly property int totalPages: 3
readonly property var pageComponents: [welcomePage, doctorPage, completePage] readonly property var pageComponents: [welcomePage, doctorPage, completePage]
@@ -90,7 +89,6 @@ FloatingWindow {
title: I18n.tr("Welcome", "greeter modal window title") title: I18n.tr("Welcome", "greeter modal window title")
minimumSize: Qt.size(modalWidth, modalHeight) minimumSize: Qt.size(modalWidth, modalHeight)
maximumSize: Qt.size(modalWidth, modalHeight) maximumSize: Qt.size(modalWidth, modalHeight)
color: Theme.surfaceContainer
visible: false visible: false
onClosed: visible = false onClosed: visible = false
@@ -173,7 +171,7 @@ FloatingWindow {
width: pageIndicatorRow.width + Theme.spacingM * 2 width: pageIndicatorRow.width + Theme.spacingM * 2
height: indicatorHeight height: indicatorHeight
radius: indicatorHeight / 2 radius: indicatorHeight / 2
color: Theme.surfaceContainerHigh color: Theme.floatingWindowNestedSurface
Row { Row {
id: pageIndicatorRow id: pageIndicatorRow
@@ -259,7 +257,7 @@ FloatingWindow {
anchors.right: parent.right anchors.right: parent.right
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
height: Math.round(Theme.fontSizeMedium * 4.5) height: Math.round(Theme.fontSizeMedium * 4.5)
color: Theme.surfaceContainerHigh color: Theme.floatingWindowNestedSurface
Rectangle { Rectangle {
anchors.top: parent.top anchors.top: parent.top
@@ -13,7 +13,7 @@ Rectangle {
height: Math.round(Theme.fontSizeMedium * 3.1) height: Math.round(Theme.fontSizeMedium * 3.1)
radius: Theme.cornerRadius radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh color: Theme.floatingWindowNestedSurface
Rectangle { Rectangle {
anchors.fill: parent anchors.fill: parent
@@ -15,7 +15,7 @@ Rectangle {
height: Math.round(Theme.fontSizeMedium * 4.5) height: Math.round(Theme.fontSizeMedium * 4.5)
radius: Theme.cornerRadius radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh color: Theme.floatingWindowNestedSurface
Rectangle { Rectangle {
anchors.fill: parent anchors.fill: parent
@@ -9,7 +9,7 @@ Rectangle {
property string label: "" property string label: ""
property string iconName: "" property string iconName: ""
property color iconColor: Theme.surfaceText property color iconColor: Theme.surfaceText
property color bgColor: Theme.surfaceContainerHigh property color bgColor: Theme.floatingWindowNestedSurface
property bool selected: false property bool selected: false
signal clicked signal clicked
+1 -9
View File
@@ -5,10 +5,9 @@ import qs.Modals
import qs.Services import qs.Services
import qs.Widgets import qs.Widgets
FloatingWindow { DankFloatingWindow {
id: win id: win
property bool disablePopupTransparency: true
property alias shouldBeVisible: win.visible property alias shouldBeVisible: win.visible
signal floatingToggleRequested signal floatingToggleRequested
@@ -30,7 +29,6 @@ FloatingWindow {
minimumSize: Qt.size(Math.min(560, Screen.width), Math.min(400, Screen.height)) minimumSize: Qt.size(Math.min(560, Screen.width), Math.min(400, Screen.height))
implicitWidth: 1000 implicitWidth: 1000
implicitHeight: screen ? Math.min(820, screen.height - 100) : 820 implicitHeight: screen ? Math.min(820, screen.height - 100) : 820
color: Theme.surfaceContainer
visible: false visible: false
onVisibleChanged: { onVisibleChanged: {
@@ -61,12 +59,6 @@ FloatingWindow {
onDoubleClicked: windowControls.tryToggleMaximize() onDoubleClicked: windowControls.tryToggleMaximize()
} }
Rectangle {
anchors.fill: parent
color: Theme.surfaceContainer
opacity: 0.5
}
Row { Row {
anchors.left: parent.left anchors.left: parent.left
anchors.leftMargin: Theme.spacingL anchors.leftMargin: Theme.spacingL
-4
View File
@@ -313,10 +313,6 @@ DankModal {
width: parent.width width: parent.width
height: 48 height: 48
cornerRadius: Theme.cornerRadius
backgroundColor: Theme.surfaceContainerHigh
normalBorderColor: Theme.outlineMedium
focusedBorderColor: Theme.primary
leftIconName: "search" leftIconName: "search"
leftIconSize: Theme.iconSize leftIconSize: Theme.iconSize
leftIconColor: Theme.surfaceVariantText leftIconColor: Theme.surfaceVariantText
+2 -7
View File
@@ -202,7 +202,7 @@ FocusScope {
StyledText { StyledText {
text: root.currentFlow?.message ?? "" text: root.currentFlow?.message ?? ""
font.pixelSize: Theme.fontSizeMedium font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceTextMedium color: Theme.surfaceText
width: parent.width width: parent.width
wrapMode: Text.Wrap wrapMode: Text.Wrap
maximumLineCount: 2 maximumLineCount: 2
@@ -272,11 +272,6 @@ FocusScope {
width: parent.width width: parent.width
height: root.inputFieldHeight height: root.inputFieldHeight
backgroundColor: Theme.surfaceHover
normalBorderColor: Theme.outlineStrong
focusedBorderColor: Theme.primary
borderWidth: 1
focusedBorderWidth: 2
leftIconName: root.polkitPamHasFprint ? "fingerprint" : "" leftIconName: root.polkitPamHasFprint ? "fingerprint" : ""
leftIconSize: 20 leftIconSize: 20
leftIconColor: Theme.primary leftIconColor: Theme.primary
@@ -352,7 +347,7 @@ FocusScope {
anchors.centerIn: parent anchors.centerIn: parent
text: I18n.tr("Authenticate") text: I18n.tr("Authenticate")
font.pixelSize: Theme.fontSizeMedium font.pixelSize: Theme.fontSizeMedium
color: Theme.background color: Theme.primaryText
font.weight: Font.Medium font.weight: Font.Medium
} }
+1 -4
View File
@@ -4,11 +4,9 @@ import qs.Common
import qs.Services import qs.Services
import qs.Widgets import qs.Widgets
FloatingWindow { DankFloatingWindow {
id: root id: root
property bool disablePopupTransparency: true
function show() { function show() {
if (contentLoader.item) if (contentLoader.item)
contentLoader.item.reset(); contentLoader.item.reset();
@@ -29,7 +27,6 @@ FloatingWindow {
title: I18n.tr("Authentication") title: I18n.tr("Authentication")
minimumSize: Qt.size(460, 220) minimumSize: Qt.size(460, 220)
maximumSize: Qt.size(460, 220) maximumSize: Qt.size(460, 220)
color: Theme.surfaceContainer
visible: false visible: false
onClosed: hide() onClosed: hide()
+45 -7
View File
@@ -129,6 +129,13 @@ DankModal {
switchUserRequested(); switchUserRequested();
return; return;
} }
if (action.startsWith("custom:")) {
const button = (SettingsData.customPowerButtons || [])[parseInt(action.slice(7), 10)];
close();
if (button?.command)
Quickshell.execDetached(["sh", "-c", button.command]);
return;
}
close(); close();
root.powerActionRequested(action, "", ""); root.powerActionRequested(action, "", "");
} }
@@ -172,11 +179,14 @@ DankModal {
function updateVisibleActions() { function updateVisibleActions() {
const allActions = SettingsData.powerMenuActions || ["reboot", "logout", "poweroff", "lock", "suspend", "restart"]; const allActions = SettingsData.powerMenuActions || ["reboot", "logout", "poweroff", "lock", "suspend", "restart"];
const customButtons = SettingsData.customPowerButtons || [];
visibleActions = allActions.filter(action => { visibleActions = allActions.filter(action => {
if (action === "hibernate" && !SessionService.hibernateSupported) if (action === "hibernate" && !SessionService.hibernateSupported)
return false; return false;
if (action === "softreboot" && !SessionService.softRebootSupported)
return false;
return true; return true;
}); }).concat(customButtons.map((button, i) => "custom:" + i));
if (!SettingsData.powerMenuGridLayout) if (!SettingsData.powerMenuGridLayout)
return; return;
@@ -216,6 +226,14 @@ DankModal {
} }
function getActionData(action) { function getActionData(action) {
if (action.startsWith("custom:")) {
const button = (SettingsData.customPowerButtons || [])[parseInt(action.slice(7), 10)];
return {
"icon": button?.icon || "terminal",
"label": button?.label || button?.command || "",
"key": ""
};
}
switch (action) { switch (action) {
case "reboot": case "reboot":
return { return {
@@ -223,6 +241,12 @@ DankModal {
"label": I18n.tr("Reboot"), "label": I18n.tr("Reboot"),
"key": "R" "key": "R"
}; };
case "softreboot":
return {
"icon": "autorenew",
"label": I18n.tr("Soft Reboot"),
"key": "B"
};
case "logout": case "logout":
return { return {
"icon": "logout", "icon": "logout",
@@ -370,7 +394,7 @@ DankModal {
function handleListNavigation(event, isPressed) { function handleListNavigation(event, isPressed) {
if (!isPressed) { if (!isPressed) {
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_R || event.key === Qt.Key_X || event.key === Qt.Key_L || event.key === Qt.Key_S || event.key === Qt.Key_H || event.key === Qt.Key_D || (event.key === Qt.Key_P && !(event.modifiers & Qt.ControlModifier))) { if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_R || event.key === Qt.Key_B || event.key === Qt.Key_X || event.key === Qt.Key_L || event.key === Qt.Key_S || event.key === Qt.Key_H || event.key === Qt.Key_D || (event.key === Qt.Key_P && !(event.modifiers & Qt.ControlModifier))) {
cancelHold(); cancelHold();
event.accepted = true; event.accepted = true;
} }
@@ -429,6 +453,12 @@ DankModal {
event.accepted = true; event.accepted = true;
} }
break; break;
case Qt.Key_B:
if (visibleActions.includes("softreboot")) {
startHold("softreboot", visibleActions.indexOf("softreboot"));
event.accepted = true;
}
break;
case Qt.Key_X: case Qt.Key_X:
if (visibleActions.includes("logout")) { if (visibleActions.includes("logout")) {
startHold("logout", visibleActions.indexOf("logout")); startHold("logout", visibleActions.indexOf("logout"));
@@ -464,7 +494,7 @@ DankModal {
function handleGridNavigation(event, isPressed) { function handleGridNavigation(event, isPressed) {
if (!isPressed) { if (!isPressed) {
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_R || event.key === Qt.Key_X || event.key === Qt.Key_L || event.key === Qt.Key_S || event.key === Qt.Key_H || event.key === Qt.Key_D || (event.key === Qt.Key_P && !(event.modifiers & Qt.ControlModifier))) { if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_R || event.key === Qt.Key_B || event.key === Qt.Key_X || event.key === Qt.Key_L || event.key === Qt.Key_S || event.key === Qt.Key_H || event.key === Qt.Key_D || (event.key === Qt.Key_P && !(event.modifiers & Qt.ControlModifier))) {
cancelHold(); cancelHold();
event.accepted = true; event.accepted = true;
} }
@@ -539,6 +569,12 @@ DankModal {
event.accepted = true; event.accepted = true;
} }
break; break;
case Qt.Key_B:
if (visibleActions.includes("softreboot")) {
startHold("softreboot", visibleActions.indexOf("softreboot"));
event.accepted = true;
}
break;
case Qt.Key_X: case Qt.Key_X:
if (visibleActions.includes("logout")) { if (visibleActions.includes("logout")) {
startHold("logout", visibleActions.indexOf("logout")); startHold("logout", visibleActions.indexOf("logout"));
@@ -597,7 +633,7 @@ DankModal {
readonly property var actionData: root.getActionData(modelData) readonly property var actionData: root.getActionData(modelData)
readonly property bool isSelected: root.selectedIndex === index readonly property bool isSelected: root.selectedIndex === index
readonly property bool showWarning: modelData === "reboot" || modelData === "poweroff" readonly property bool showWarning: modelData === "reboot" || modelData === "softreboot" || modelData === "poweroff"
readonly property bool isHolding: root.holdActionIndex === index && root.holdProgress > 0 readonly property bool isHolding: root.holdActionIndex === index && root.holdProgress > 0
width: (root.modalWidth - Theme.spacingL * 2 - Theme.spacingS * (root.gridColumns - 1)) / root.gridColumns width: (root.modalWidth - Theme.spacingL * 2 - Theme.spacingS * (root.gridColumns - 1)) / root.gridColumns
@@ -627,7 +663,7 @@ DankModal {
color: { color: {
if (gridButtonRect.modelData === "poweroff") if (gridButtonRect.modelData === "poweroff")
return Theme.errorSelected; return Theme.errorSelected;
if (gridButtonRect.modelData === "reboot") if (gridButtonRect.modelData === "reboot" || gridButtonRect.modelData === "softreboot")
return Theme.withAlpha(Theme.warning, 0.3); return Theme.withAlpha(Theme.warning, 0.3);
return Theme.primarySelected; return Theme.primarySelected;
} }
@@ -668,6 +704,7 @@ DankModal {
height: 16 height: 16
radius: 4 radius: 4
color: Theme.onSurface_12 color: Theme.onSurface_12
visible: gridButtonRect.actionData.key !== ""
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
StyledText { StyledText {
@@ -722,7 +759,7 @@ DankModal {
readonly property var actionData: root.getActionData(modelData) readonly property var actionData: root.getActionData(modelData)
readonly property bool isSelected: root.selectedIndex === index readonly property bool isSelected: root.selectedIndex === index
readonly property bool showWarning: modelData === "reboot" || modelData === "poweroff" readonly property bool showWarning: modelData === "reboot" || modelData === "softreboot" || modelData === "poweroff"
readonly property bool isHolding: root.holdActionIndex === index && root.holdProgress > 0 readonly property bool isHolding: root.holdActionIndex === index && root.holdProgress > 0
width: parent.width width: parent.width
@@ -752,7 +789,7 @@ DankModal {
color: { color: {
if (listButtonRect.modelData === "poweroff") if (listButtonRect.modelData === "poweroff")
return Theme.errorSelected; return Theme.errorSelected;
if (listButtonRect.modelData === "reboot") if (listButtonRect.modelData === "reboot" || listButtonRect.modelData === "softreboot")
return Theme.withAlpha(Theme.warning, 0.3); return Theme.withAlpha(Theme.warning, 0.3);
return Theme.primarySelected; return Theme.primarySelected;
} }
@@ -800,6 +837,7 @@ DankModal {
height: 20 height: 20
radius: 4 radius: 4
color: Theme.onSurface_12 color: Theme.onSurface_12
visible: listButtonRect.actionData.key !== ""
anchors { anchors {
right: parent.right right: parent.right
rightMargin: Theme.spacingM rightMargin: Theme.spacingM
+2 -4
View File
@@ -8,11 +8,10 @@ import qs.Services
import qs.Widgets import qs.Widgets
import "../Common/Format.js" as Format import "../Common/Format.js" as Format
FloatingWindow { DankFloatingWindow {
id: processListModal id: processListModal
readonly property var log: Log.scoped("ProcessListModal") readonly property var log: Log.scoped("ProcessListModal")
property bool disablePopupTransparency: true
property int currentTab: 0 property int currentTab: 0
property string searchText: "" property string searchText: ""
property string expandedPid: "" property string expandedPid: ""
@@ -81,7 +80,6 @@ FloatingWindow {
minimumSize: Qt.size(Math.min(Math.round(Theme.fontSizeMedium * 48), Screen.width), Math.min(Math.round(Theme.fontSizeMedium * 34), Screen.height)) minimumSize: Qt.size(Math.min(Math.round(Theme.fontSizeMedium * 48), Screen.width), Math.min(Math.round(Theme.fontSizeMedium * 34), Screen.height))
implicitWidth: Math.round(Theme.fontSizeMedium * 71) implicitWidth: Math.round(Theme.fontSizeMedium * 71)
implicitHeight: Math.round(Theme.fontSizeMedium * 51) implicitHeight: Math.round(Theme.fontSizeMedium * 51)
color: Theme.surfaceContainer
visible: false visible: false
onClosed: hide() onClosed: hide()
@@ -420,7 +418,7 @@ FloatingWindow {
Layout.margins: Theme.spacingL Layout.margins: Theme.spacingL
Layout.topMargin: Theme.spacingM Layout.topMargin: Theme.spacingM
radius: Theme.cornerRadius radius: Theme.cornerRadius
color: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency) color: Theme.floatingWindowNestedSurface
border.color: Theme.outlineLight border.color: Theme.outlineLight
border.width: 1 border.width: 1
clip: true clip: true
@@ -733,5 +733,20 @@ FocusScope {
Qt.callLater(() => item.forceActiveFocus()); Qt.callLater(() => item.forceActiveFocus());
} }
} }
Loader {
id: keyboardLoader
anchors.fill: parent
active: root.currentIndex === 45
visible: active
focus: active
sourceComponent: KeyboardTab {}
onActiveChanged: {
if (active && item)
Qt.callLater(() => item.forceActiveFocus());
}
}
} }
} }
+2 -11
View File
@@ -5,7 +5,7 @@ import qs.Modals.FileBrowser
import qs.Services import qs.Services
import qs.Widgets import qs.Widgets
FloatingWindow { DankFloatingWindow {
id: settingsModal id: settingsModal
property var profileBrowser: profileBrowserLoader.item property var profileBrowser: profileBrowserLoader.item
@@ -95,7 +95,6 @@ FloatingWindow {
minimumSize: Qt.size(500, 400) minimumSize: Qt.size(500, 400)
implicitWidth: 900 implicitWidth: 900
implicitHeight: screen ? Math.min(940, screen.height - 100) : 940 implicitHeight: screen ? Math.min(940, screen.height - 100) : 940
color: Theme.surfaceContainer
visible: false visible: false
onClosed: hide() onClosed: hide()
@@ -180,8 +179,6 @@ FloatingWindow {
FocusScope { FocusScope {
id: contentFocusScope id: contentFocusScope
property bool disablePopupTransparency: true
LayoutMirroring.enabled: I18n.isRtl LayoutMirroring.enabled: I18n.isRtl
LayoutMirroring.childrenInherit: true LayoutMirroring.childrenInherit: true
@@ -203,12 +200,6 @@ FloatingWindow {
onDoubleClicked: windowControls.tryToggleMaximize() onDoubleClicked: windowControls.tryToggleMaximize()
} }
Rectangle {
anchors.fill: parent
color: Theme.surfaceContainer
opacity: 0.5
}
Row { Row {
anchors.left: parent.left anchors.left: parent.left
anchors.leftMargin: Theme.spacingL anchors.leftMargin: Theme.spacingL
@@ -276,7 +267,7 @@ FloatingWindow {
width: parent.width width: parent.width
height: showBanner ? bannerContent.implicitHeight + Theme.spacingM * 2 : 0 height: showBanner ? bannerContent.implicitHeight + Theme.spacingM * 2 : 0
color: Theme.surfaceContainerHigh color: Theme.floatingWindowNestedSurface
visible: showBanner visible: showBanner
clip: true clip: true
@@ -326,6 +326,13 @@ Rectangle {
"tabIndex": 44, "tabIndex": 44,
"niriOnly": true "niriOnly": true
}, },
{
"id": "keyboard",
"text": I18n.tr("Keyboard"),
"icon": "keyboard",
"tabIndex": 45,
"niriOnly": true
},
{ {
"id": "locale", "id": "locale",
"text": I18n.tr("Locale"), "text": I18n.tr("Locale"),
@@ -641,7 +648,7 @@ Rectangle {
implicitWidth: __calculatedWidth implicitWidth: __calculatedWidth
width: __calculatedWidth width: __calculatedWidth
height: parent.height height: parent.height
color: Theme.surfaceContainer color: "transparent"
radius: Theme.cornerRadius radius: Theme.cornerRadius
Component.onCompleted: { Component.onCompleted: {
@@ -742,12 +749,9 @@ Rectangle {
id: searchField id: searchField
width: parent.width - parent.leftPadding - parent.rightPadding width: parent.width - parent.leftPadding - parent.rightPadding
placeholderText: I18n.tr("Search...") placeholderText: I18n.tr("Search...")
normalBorderColor: Theme.outlineMedium
focusedBorderColor: Theme.primary
leftIconName: "search" leftIconName: "search"
leftIconSize: Theme.iconSize - 4 leftIconSize: Theme.iconSize - 4
showClearButton: text.length > 0 showClearButton: text.length > 0
usePopupTransparency: false
onTextChanged: { onTextChanged: {
SettingsSearchService.search(text); SettingsSearchService.search(text);
root.searchSelectedIndex = 0; root.searchSelectedIndex = 0;
+1 -3
View File
@@ -4,10 +4,9 @@ import qs.Common
import qs.Services import qs.Services
import qs.Widgets import qs.Widgets
FloatingWindow { DankFloatingWindow {
id: root id: root
property bool disablePopupTransparency: true
property var editingRule: null property var editingRule: null
property bool isEditMode: editingRule !== null property bool isEditMode: editingRule !== null
property bool isNiri: CompositorService.isNiri property bool isNiri: CompositorService.isNiri
@@ -29,7 +28,6 @@ FloatingWindow {
title: isEditMode ? I18n.tr("Edit Window Rule") : I18n.tr("Create Window Rule") title: isEditMode ? I18n.tr("Edit Window Rule") : I18n.tr("Create Window Rule")
minimumSize: Qt.size(500, 600) minimumSize: Qt.size(500, 600)
maximumSize: Qt.size(500, 600) maximumSize: Qt.size(500, 600)
color: Theme.surfaceContainer
visible: false visible: false
onClosed: hide() onClosed: hide()
+1 -3
View File
@@ -4,18 +4,16 @@ import qs.Common
import qs.Services import qs.Services
import qs.Widgets import qs.Widgets
FloatingWindow { DankFloatingWindow {
id: root id: root
readonly property var log: Log.scoped("WorkspaceRenameModal") readonly property var log: Log.scoped("WorkspaceRenameModal")
property bool disablePopupTransparency: true
readonly property int inputFieldHeight: Theme.fontSizeMedium + Theme.spacingL * 2 readonly property int inputFieldHeight: Theme.fontSizeMedium + Theme.spacingL * 2
objectName: "workspaceRenameModal" objectName: "workspaceRenameModal"
title: I18n.tr("Rename Workspace") title: I18n.tr("Rename Workspace")
minimumSize: Qt.size(400, 160) minimumSize: Qt.size(400, 160)
maximumSize: Qt.size(400, 160) maximumSize: Qt.size(400, 160)
color: Theme.surfaceContainer
visible: false visible: false
onClosed: hide() onClosed: hide()
+10 -1
View File
@@ -201,7 +201,16 @@ Item {
direction: root.effectiveShadowDirection direction: root.effectiveShadowDirection
fallbackOffset: 4 fallbackOffset: 4
targetRadius: root.rt targetRadius: root.rt
targetColor: barWindow._bgColor // wing-side body corners are square where the gothic fillets attach;
// rounding the shadow there leaves a shadow-filled notch under the
// translucent bar fill (#2975)
topLeftRadius: root.gothEnabled && (root.isBottom || root.isRight) ? 0 : root.rt
topRightRadius: root.gothEnabled && (root.isBottom || root.isLeft) ? 0 : root.rt
bottomLeftRadius: root.gothEnabled && (root.isTop || root.isRight) ? 0 : root.rt
bottomRightRadius: root.gothEnabled && (root.isTop || root.isLeft) ? 0 : root.rt
// barShape below is the sole painter of the bar; a fill here doubles
// the alpha of translucent bars while the wings stay single-painted
targetColor: "transparent"
shadowBlurPx: root.shadowBlurPx shadowBlurPx: root.shadowBlurPx
shadowOffsetX: root.shadowOffsetX shadowOffsetX: root.shadowOffsetX
@@ -609,7 +609,7 @@ Item {
_closeHoverNotepad(); _closeHoverNotepad();
activeHoverTrigger = ""; activeHoverTrigger = "";
PopoutManager.dismissHoverPopoutForScreen(barWindow?.screen); PopoutManager.dismissHoverPopoutForScreen(barWindow?.screen);
TrayMenuManager.closeAllMenus(); TrayMenuManager.closeHoverMenus();
} }
function _beginSupersededCloseForActive() { function _beginSupersededCloseForActive() {
@@ -67,6 +67,7 @@ BasePill {
MouseArea { MouseArea {
anchors.fill: parent anchors.fill: parent
acceptedButtons: Qt.MiddleButton acceptedButtons: Qt.MiddleButton
cursorShape: Qt.PointingHandCursor
onPressed: mouse => { onPressed: mouse => {
root.triggerRipple(this, mouse.x, mouse.y); root.triggerRipple(this, mouse.x, mouse.y);
SessionData.setDoNotDisturb(!SessionData.doNotDisturb); SessionData.setDoNotDisturb(!SessionData.doNotDisturb);
@@ -83,6 +83,22 @@ BasePill {
root.showForTrayItem(trayItem, anchorItem, parentScreen, root.isAtBottom, root.isVerticalOrientation, root.axis); root.showForTrayItem(trayItem, anchorItem, parentScreen, root.isAtBottom, root.isVerticalOrientation, root.axis);
} }
Connections {
target: TrayMenuManager
function onOpenTrayMenuRequested() {
const request = TrayMenuManager.claimMenuRequest(root.parentScreen?.name);
if (!request)
return;
const item = TrayMenuManager.findTrayItem(request.itemId);
if (!item || !item.hasMenu)
return;
root.showForTrayItem(item, root, parentScreen, root.isAtBottom, root.isVerticalOrientation, root.axis);
}
}
function openInlineTrayContextMenu(trayItem, areaItem, mouse, anchorItem) { function openInlineTrayContextMenu(trayItem, areaItem, mouse, anchorItem) {
if (!trayItem) { if (!trayItem) {
return; return;
@@ -1484,6 +1500,7 @@ BasePill {
property bool isVertical: false property bool isVertical: false
property var axis: null property var axis: null
property bool showMenu: false property bool showMenu: false
property bool openedByHover: false
property var menuHandle: null property var menuHandle: null
ListModel { ListModel {
@@ -1493,7 +1510,8 @@ BasePill {
return entryStack.count ? entryStack.get(entryStack.count - 1).handle : null; return entryStack.count ? entryStack.get(entryStack.count - 1).handle : null;
} }
function showForTrayItem(item, anchor, screen, atBottom, vertical, axisObj) { function showForTrayItem(item, anchor, screen, atBottom, vertical, axisObj, byHover) {
openedByHover = byHover === true;
trayItem = item; trayItem = item;
anchorItem = anchor; anchorItem = anchor;
parentScreen = screen; parentScreen = screen;
@@ -2084,7 +2102,7 @@ BasePill {
} }
} }
function showForTrayItem(item, anchor, screen, atBottom, vertical, axisObj) { function showForTrayItem(item, anchor, screen, atBottom, vertical, axisObj, byHover) {
if (!screen) if (!screen)
return; return;
if (currentTrayMenu) { if (currentTrayMenu) {
@@ -2099,7 +2117,7 @@ BasePill {
currentTrayMenu = trayMenuComponent.createObject(null); currentTrayMenu = trayMenuComponent.createObject(null);
if (!currentTrayMenu) if (!currentTrayMenu)
return; return;
currentTrayMenu.showForTrayItem(item, anchor, screen, atBottom, vertical ?? false, axisObj); currentTrayMenu.showForTrayItem(item, anchor, screen, atBottom, vertical ?? false, axisObj, byHover === true);
} }
function _trayLayoutRoot() { function _trayLayoutRoot() {
@@ -2147,7 +2165,7 @@ BasePill {
if (!hit?.trayItem?.hasMenu) if (!hit?.trayItem?.hasMenu)
return false; return false;
const anchor = hit.children?.length > 0 ? hit.children[0] : hit; const anchor = hit.children?.length > 0 ? hit.children[0] : hit;
showForTrayItem(hit.trayItem, anchor, parentScreen, isAtBottom, isVerticalOrientation, axis); showForTrayItem(hit.trayItem, anchor, parentScreen, isAtBottom, isVerticalOrientation, axis, true);
return true; return true;
} }
} }
@@ -493,7 +493,7 @@ Item {
} }
StyledText { StyledText {
text: activePlayer?.trackAlbum || "" text: MprisController.stableAlbum
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceTextSecondary color: Theme.surfaceTextSecondary
width: parent.width width: parent.width
+22 -4
View File
@@ -14,6 +14,7 @@ import qs.Services
import qs.Widgets import qs.Widgets
import qs.DankCommon.Session import qs.DankCommon.Session
import "../../DankCommon/Common/LayoutCodes.js" as LayoutCodes import "../../DankCommon/Common/LayoutCodes.js" as LayoutCodes
import "../../Common/KeyUtils.js" as KeyUtils
Item { Item {
id: root id: root
@@ -98,6 +99,18 @@ Item {
return !demoMode && pam && pam.u2f && pam.u2f.available && SettingsData.enableU2f && SettingsData.u2fMode === "or" && !pam.passwd.active && !pam.u2f.active && !pam.u2fPending && !root.unlocking; return !demoMode && pam && pam.u2f && pam.u2f.available && SettingsData.enableU2f && SettingsData.u2fMode === "or" && !pam.passwd.active && !pam.u2f.active && !pam.u2fPending && !root.unlocking;
} }
function triggerSecurityKeyUnlock() {
if (!canStartSecurityKeyUnlock())
return;
passwordField.clear();
pam.u2f.startForAlternativeAuth();
}
function securityKeyShortcutMatches(event) {
return SettingsData.lockScreenSecurityKeyShortcutEnabled
&& KeyUtils.eventMatchesCombo(event, SettingsData.lockScreenSecurityKeyShortcut);
}
Component.onCompleted: { Component.onCompleted: {
WeatherService.addRef(); WeatherService.addRef();
UserInfoService.getUserInfo(); UserInfoService.getUserInfo();
@@ -962,6 +975,12 @@ Item {
} }
if ((event.modifiers & Qt.ControlModifier) && !(event.modifiers & (Qt.AltModifier | Qt.MetaModifier))) { if ((event.modifiers & Qt.ControlModifier) && !(event.modifiers & (Qt.AltModifier | Qt.MetaModifier))) {
if (securityKeyShortcutMatches(event) && canStartSecurityKeyUnlock()) {
triggerSecurityKeyUnlock();
event.accepted = true;
return;
}
switch (event.key) { switch (event.key) {
case Qt.Key_A: case Qt.Key_A:
cursorPosition = 0; cursorPosition = 0;
@@ -1238,10 +1257,8 @@ Item {
buttonSize: 32 buttonSize: 32
visible: root.canStartSecurityKeyUnlock() visible: root.canStartSecurityKeyUnlock()
enabled: visible enabled: visible
onClicked: { tooltipText: SettingsData.lockScreenSecurityKeyShortcutEnabled ? I18n.tr("Security key (%1)", "lock screen security key button tooltip with shortcut").arg(SettingsData.lockScreenSecurityKeyShortcut) : I18n.tr("Security key", "lock screen security key button tooltip")
passwordField.clear(); onClicked: root.triggerSecurityKeyUnlock()
pam.u2f.startForAlternativeAuth();
}
} }
DankActionButton { DankActionButton {
id: virtualKeyboardButton id: virtualKeyboardButton
@@ -1329,6 +1346,7 @@ Item {
width: parent.width width: parent.width
height: parent.height / 2 height: parent.height / 2
anchors.top: parent.top anchors.top: parent.top
anchors.topMargin: -1
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
color: Theme.withAlpha(Theme.surfaceContainer, 0.9) color: Theme.withAlpha(Theme.surfaceContainer, 0.9)
} }
@@ -5,7 +5,7 @@ import qs.Services
import qs.Widgets import qs.Widgets
import qs.Modules.Notepad import qs.Modules.Notepad
FloatingWindow { DankFloatingWindow {
id: win id: win
property alias shouldBeVisible: win.visible property alias shouldBeVisible: win.visible
@@ -27,7 +27,7 @@ FloatingWindow {
minimumSize: Qt.size(360, 320) minimumSize: Qt.size(360, 320)
implicitWidth: 640 implicitWidth: 640
implicitHeight: 760 implicitHeight: 760
color: Theme.surfaceContainer surfaceColor: Theme.notepadWindowSurface
visible: false visible: false
onVisibleChanged: { onVisibleChanged: {
@@ -38,7 +38,6 @@ FloatingWindow {
} }
} }
// A compositor close (e.g. niri close-window)
onClosed: win.visible = false onClosed: win.visible = false
Item { Item {
@@ -58,12 +57,6 @@ FloatingWindow {
onDoubleClicked: windowControls.tryToggleMaximize() onDoubleClicked: windowControls.tryToggleMaximize()
} }
Rectangle {
anchors.fill: parent
color: Theme.surfaceContainerHigh
opacity: 0.5
}
Row { Row {
anchors.left: parent.left anchors.left: parent.left
anchors.leftMargin: Theme.spacingM anchors.leftMargin: Theme.spacingM
@@ -353,7 +353,7 @@ Item {
anchors.leftMargin: -Theme.spacingM anchors.leftMargin: -Theme.spacingM
width: parent.width + Theme.spacingM width: parent.width + Theme.spacingM
text: I18n.tr("Surface Opacity") text: I18n.tr("Surface Opacity")
description: I18n.tr("Override global transparency for Notepad") description: I18n.tr("Override floating window transparency for Notepad")
checked: SettingsData.notepadTransparencyOverride >= 0 checked: SettingsData.notepadTransparencyOverride >= 0
onToggled: checked => { onToggled: checked => {
if (checked) { if (checked) {
@@ -370,7 +370,7 @@ Item {
width: parent.width + Theme.spacingM width: parent.width + Theme.spacingM
height: 24 height: 24
visible: SettingsData.notepadTransparencyOverride >= 0 visible: SettingsData.notepadTransparencyOverride >= 0
value: Math.round((SettingsData.notepadTransparencyOverride >= 0 ? SettingsData.notepadTransparencyOverride : SettingsData.popupTransparency) * 100) value: Math.round(Theme.notepadTransparency * 100)
minimum: 0 minimum: 0
maximum: 100 maximum: 100
unit: "" unit: ""
@@ -364,7 +364,7 @@ Column {
height: 48 height: 48
visible: searchVisible visible: searchVisible
opacity: searchVisible ? 1 : 0 opacity: searchVisible ? 1 : 0
color: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency) color: Theme.floatingWindowNestedSurface
border.color: searchField.activeFocus ? Theme.primary : Theme.outlineMedium border.color: searchField.activeFocus ? Theme.primary : Theme.outlineMedium
border.width: searchField.activeFocus ? 2 : 1 border.width: searchField.activeFocus ? 2 : 1
radius: Theme.cornerRadius radius: Theme.cornerRadius
@@ -941,7 +941,7 @@ Column {
width: Math.min(root.width, 360) width: Math.min(root.width, 360)
height: pathInfoRow.implicitHeight + Theme.spacingS * 2 height: pathInfoRow.implicitHeight + Theme.spacingS * 2
radius: Theme.cornerRadius radius: Theme.cornerRadius
color: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency) color: Theme.floatingWindowNestedSurface
border.color: Theme.outlineMedium border.color: Theme.outlineMedium
border.width: 1 border.width: 1
z: 10 z: 10
+73 -7
View File
@@ -156,9 +156,10 @@ DankOSD {
if (MprisController.isFirefoxYoutubeHoverPreview(player)) if (MprisController.isFirefoxYoutubeHoverPreview(player))
return; return;
const newTitle = player.trackTitle || ""; const metaPlayer = MprisController.bestMetadataPlayer(player);
const newArtist = player.trackArtist || ""; const newTitle = MprisController.displayTrackTitle(metaPlayer);
const newAlbum = player.trackAlbum || ""; const newArtist = metaPlayer.trackArtist || "";
const newAlbum = metaPlayer.trackAlbum || "";
const trackChanged = newTitle !== root._displayTitle || newArtist !== root._displayArtist || newAlbum !== root._displayAlbum; const trackChanged = newTitle !== root._displayTitle || newArtist !== root._displayArtist || newAlbum !== root._displayAlbum;
root._displayTitle = newTitle; root._displayTitle = newTitle;
@@ -263,13 +264,48 @@ DankOSD {
} }
} }
Row {
id: transportControls
x: parent.gap
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingXXS
Rectangle {
width: Theme.iconSize - 4
height: Theme.iconSize - 4
radius: (Theme.iconSize - 4) / 2
anchors.verticalCenter: parent.verticalCenter
color: prevButton.containsMouse ? Theme.surfaceTextHover : "transparent"
opacity: (root.player?.canGoPrevious ?? false) ? 1 : 0.3
DankIcon {
anchors.centerIn: parent
name: "skip_previous"
size: Theme.iconSize - 10
color: prevButton.containsMouse ? Theme.primary : Theme.surfaceText
}
MouseArea {
id: prevButton
anchors.fill: parent
hoverEnabled: true
enabled: root.player?.canGoPrevious ?? false
cursorShape: Qt.PointingHandCursor
onClicked: {
MprisController.previousOrRewind();
root.hide();
}
}
}
Rectangle { Rectangle {
width: Theme.iconSize width: Theme.iconSize
height: Theme.iconSize height: Theme.iconSize
radius: Theme.iconSize / 2 radius: Theme.iconSize / 2
color: "transparent"
x: parent.gap
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
color: "transparent"
DankIcon { DankIcon {
anchors.centerIn: parent anchors.centerIn: parent
@@ -291,9 +327,39 @@ DankOSD {
} }
} }
Rectangle {
width: Theme.iconSize - 4
height: Theme.iconSize - 4
radius: (Theme.iconSize - 4) / 2
anchors.verticalCenter: parent.verticalCenter
color: nextButton.containsMouse ? Theme.surfaceTextHover : "transparent"
opacity: (root.player?.canGoNext ?? false) ? 1 : 0.3
DankIcon {
anchors.centerIn: parent
name: "skip_next"
size: Theme.iconSize - 10
color: nextButton.containsMouse ? Theme.primary : Theme.surfaceText
}
MouseArea {
id: nextButton
anchors.fill: parent
hoverEnabled: true
enabled: root.player?.canGoNext ?? false
cursorShape: Qt.PointingHandCursor
onClicked: {
MprisController.next();
root.hide();
}
}
}
}
Column { Column {
x: parent.gap * 2 + Theme.iconSize x: parent.gap * 2 + transportControls.width
width: parent.width - Theme.iconSize - parent.gap * 3 width: parent.width - transportControls.width - parent.gap * 3
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingXXS spacing: Theme.spacingXXS
@@ -118,7 +118,9 @@ Column {
width: parent.width width: parent.width
height: 56 height: 56
radius: Theme.cornerRadius radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh color: Theme.floatingWindowFieldColor
border.color: Theme.floatingWindowFieldBorderColor
border.width: 1
Row { Row {
anchors.fill: parent anchors.fill: parent
@@ -6,10 +6,9 @@ import qs.Common
import qs.Services import qs.Services
import qs.Widgets import qs.Widgets
FloatingWindow { DankFloatingWindow {
id: root id: root
property bool disablePopupTransparency: true
property string searchQuery: "" property string searchQuery: ""
property var filteredWidgets: [] property var filteredWidgets: []
property int selectedIndex: -1 property int selectedIndex: -1
@@ -107,7 +106,6 @@ FloatingWindow {
minimumSize: Qt.size(400, 350) minimumSize: Qt.size(400, 350)
implicitWidth: 500 implicitWidth: 500
implicitHeight: 550 implicitHeight: 550
color: Theme.surfaceContainer
visible: false visible: false
onClosed: hide() onClosed: hide()
@@ -276,10 +274,6 @@ FloatingWindow {
id: searchField id: searchField
width: parent.width width: parent.width
height: 48 height: 48
cornerRadius: Theme.cornerRadius
backgroundColor: Theme.surfaceContainerHigh
normalBorderColor: Theme.outlineMedium
focusedBorderColor: Theme.primary
leftIconName: "search" leftIconName: "search"
leftIconSize: Theme.iconSize leftIconSize: Theme.iconSize
leftIconColor: Theme.surfaceVariantText leftIconColor: Theme.surfaceVariantText
@@ -1111,15 +1111,50 @@ Singleton {
} }
} }
function extractNiriOutputBlocks(content) {
const blocks = [];
const headerRegex = /output\s+"([^"]+)"\s*\{/g;
let match;
while ((match = headerRegex.exec(content)) !== null) {
const start = headerRegex.lastIndex;
let depth = 1;
let i = start;
while (i < content.length && depth > 0) {
const ch = content[i];
if (ch === '{')
depth++;
else if (ch === '}')
depth--;
i++;
}
blocks.push({
"name": match[1],
"body": content.slice(start, i - 1)
});
headerRegex.lastIndex = i;
}
return blocks;
}
function stripNestedBlocks(body) {
let stripped = body;
let prev;
do {
prev = stripped;
stripped = stripped.replace(/[\w-]+\s*\{[^{}]*\}/g, "");
} while (stripped !== prev)
return stripped;
}
function parseNiriOutputs(content) { function parseNiriOutputs(content) {
const result = {}; const result = {};
const outputRegex = /output\s+"([^"]+)"\s*\{([^}]*)\}/g; for (const block of extractNiriOutputBlocks(content)) {
let match; const name = block.name;
while ((match = outputRegex.exec(content)) !== null) { const body = block.body;
const name = match[1];
const body = match[2];
const disabled = /^\s*off\s*$/m.test(body); // off marks the output disabled only at the top level of its block;
// nested sections like hot-corners { off } must not count (#2966)
const disabled = /^\s*off\s*$/m.test(stripNestedBlocks(body));
const modeMatch = body.match(/mode\s+"(\d+)x(\d+)@([\d.]+)"/); const modeMatch = body.match(/mode\s+"(\d+)x(\d+)@([\d.]+)"/);
const posMatch = body.match(/position\s+x=(-?\d+)\s+y=(-?\d+)/); const posMatch = body.match(/position\s+x=(-?\d+)\s+y=(-?\d+)/);
const scaleMatch = body.match(/scale\s+([\d.]+)/); const scaleMatch = body.match(/scale\s+([\d.]+)/);
+267
View File
@@ -0,0 +1,267 @@
import QtQuick
import qs.Common
import qs.Services
import qs.Widgets
import qs.Modules.Settings.Widgets
Item {
id: root
LayoutMirroring.enabled: I18n.isRtl
LayoutMirroring.childrenInherit: true
DankFlickable {
anchors.fill: parent
clip: true
contentHeight: settingsColumn.height + Theme.spacingXL
contentWidth: width
Column {
id: settingsColumn
topPadding: 4
width: Math.min(550, parent.width - Theme.spacingL * 2)
anchors.horizontalCenter: parent.horizontalCenter
spacing: Theme.spacingXL
NiriInputSetupBanner {
width: parent.width
}
SettingsCard {
width: parent.width
tags: ["keyboard", "layout", "language", "input", "xkb"]
title: I18n.tr("Keyboard Layouts")
settingKey: "keyboardLayoutsSettings"
iconName: "keyboard"
Column {
width: parent?.width ?? 0
spacing: Theme.spacingS
StyledText {
width: parent.width
text: I18n.tr("Keyboard Layouts")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
}
StyledText {
width: parent.width
text: I18n.tr("Comma-separated list of layout names. Leave empty to use the system keyboard settings.")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
}
DankTextField {
width: parent.width
text: SettingsData.keyboardLayouts
placeholderText: "us,de"
onTextEdited: SettingsData.set("keyboardLayouts", text)
}
}
SettingsDropdownRow {
tags: ["keyboard", "layout", "switch", "shortcut", "xkb"]
settingKey: "keyboardOptions"
text: I18n.tr("Switch Layout Shortcut")
description: I18n.tr("Choose a shortcut key to cycle between keyboard layouts")
options: [I18n.tr("Alt + Shift"), I18n.tr("Ctrl + Shift"), I18n.tr("Caps Lock"), I18n.tr("Super + Space"), I18n.tr("Custom / None")]
currentValue: {
const opt = SettingsData.keyboardOptions;
if (opt.includes("grp:alt_shift_toggle"))
return options[0];
if (opt.includes("grp:ctrl_shift_toggle"))
return options[1];
if (opt.includes("grp:caps_toggle"))
return options[2];
if (opt.includes("grp:win_space_toggle"))
return options[3];
return options[4];
}
onValueChanged: value => {
const idx = options.indexOf(value);
let opt = SettingsData.keyboardOptions.split(",").filter(o => !o.startsWith("grp:")).join(",");
let newGrp = "";
switch (idx) {
case 0:
newGrp = "grp:alt_shift_toggle";
break;
case 1:
newGrp = "grp:ctrl_shift_toggle";
break;
case 2:
newGrp = "grp:caps_toggle";
break;
case 3:
newGrp = "grp:win_space_toggle";
break;
}
if (newGrp)
opt = opt ? opt + "," + newGrp : newGrp;
SettingsData.set("keyboardOptions", opt);
}
}
Column {
width: parent?.width ?? 0
spacing: Theme.spacingS
StyledText {
width: parent.width
text: I18n.tr("XKB Options")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
}
StyledText {
width: parent.width
text: I18n.tr("Advanced comma-separated libxkbcommon options.")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
}
DankTextField {
width: parent.width
text: SettingsData.keyboardOptions
placeholderText: "compose:ralt,ctrl:nocaps"
onTextEdited: SettingsData.set("keyboardOptions", text)
}
}
Column {
width: parent?.width ?? 0
spacing: Theme.spacingS
StyledText {
width: parent.width
text: I18n.tr("Keyboard Variant")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
}
DankTextField {
width: parent.width
text: SettingsData.keyboardVariants
placeholderText: "colemak"
onTextEdited: SettingsData.set("keyboardVariants", text)
}
}
Column {
width: parent?.width ?? 0
spacing: Theme.spacingS
StyledText {
width: parent.width
text: I18n.tr("Keyboard Model")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
}
DankTextField {
width: parent.width
text: SettingsData.keyboardModel
placeholderText: "pc104"
onTextEdited: SettingsData.set("keyboardModel", text)
}
}
Column {
width: parent?.width ?? 0
spacing: Theme.spacingS
StyledText {
width: parent.width
text: I18n.tr("Keymap File Path")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
}
StyledText {
width: parent.width
text: I18n.tr("Direct path to a .xkb keymap file. Overrides layouts/variants above.")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
}
DankTextField {
width: parent.width
text: SettingsData.keyboardKeymapFile
placeholderText: "~/.config/keymap.xkb"
onTextEdited: SettingsData.set("keyboardKeymapFile", text)
}
}
}
SettingsCard {
width: parent.width
tags: ["keyboard", "repeat", "delay", "rate", "numlock", "behavior"]
title: I18n.tr("Keyboard Behavior")
settingKey: "keyboardBehaviorSettings"
iconName: "settings"
SettingsButtonGroupRow {
tags: ["keyboard", "track", "layout"]
settingKey: "keyboardTrackLayout"
text: I18n.tr("Remember Layout")
description: I18n.tr("How layout changes are remembered across apps")
model: [I18n.tr("Globally"), I18n.tr("Per Window")]
currentIndex: SettingsData.keyboardTrackLayout === "window" ? 1 : 0
onSelectionChanged: (index, selected) => {
if (!selected)
return;
SettingsData.set("keyboardTrackLayout", index === 1 ? "window" : "global");
}
}
SettingsToggleRow {
tags: ["keyboard", "numlock", "startup"]
settingKey: "keyboardNumlock"
text: I18n.tr("Enable Num Lock")
description: I18n.tr("Automatically turn on Num Lock at startup")
checked: SettingsData.keyboardNumlock
onToggled: checked => SettingsData.set("keyboardNumlock", checked)
}
SettingsSliderRow {
tags: ["keyboard", "repeat", "delay", "speed"]
settingKey: "keyboardRepeatDelay"
text: I18n.tr("Repeat Delay")
description: I18n.tr("Delay before characters start repeating")
value: SettingsData.keyboardRepeatDelay || 600
minimum: 100
maximum: 2000
step: 50
defaultValue: 600
unit: "ms"
onSliderValueChanged: newValue => SettingsData.set("keyboardRepeatDelay", newValue)
}
SettingsSliderRow {
tags: ["keyboard", "repeat", "rate", "speed"]
settingKey: "keyboardRepeatRate"
text: I18n.tr("Repeat Rate")
description: I18n.tr("Characters per second while holding down key")
value: SettingsData.keyboardRepeatRate || 25
minimum: 1
maximum: 100
step: 1
defaultValue: 25
unit: ""
onSliderValueChanged: newValue => SettingsData.set("keyboardRepeatRate", newValue)
}
}
}
}
}
@@ -5,6 +5,7 @@ import qs.Modals.FileBrowser
import qs.Services import qs.Services
import qs.Widgets import qs.Widgets
import qs.Modules.Settings.Widgets import qs.Modules.Settings.Widgets
import "../../Common/KeyUtils.js" as KeyUtils
Item { Item {
id: root id: root
@@ -518,6 +519,117 @@ Item {
} }
} }
SettingsToggleRow {
settingKey: "lockScreenSecurityKeyShortcutEnabled"
tags: ["lock", "screen", "u2f", "yubikey", "security", "key", "shortcut", "keybind", "authentication"]
text: I18n.tr("Security key shortcut", "lock screen security key shortcut toggle")
description: I18n.tr("Keyboard shortcut to start security key unlock", "lock screen security key shortcut setting")
checked: SettingsData.lockScreenSecurityKeyShortcutEnabled
visible: SettingsData.enableU2f && SettingsData.u2fMode === "or" && !root.lockU2fControlledByPrimary
onToggled: checked => SettingsData.set("lockScreenSecurityKeyShortcutEnabled", checked)
}
Row {
width: parent.width - Theme.spacingM * 2
x: Theme.spacingM
spacing: Theme.spacingM
visible: SettingsData.lockScreenSecurityKeyShortcutEnabled && SettingsData.enableU2f && SettingsData.u2fMode === "or" && !root.lockU2fControlledByPrimary
Column {
width: parent.width - securityKeyCapture.width - parent.spacing
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingXS
StyledText {
text: I18n.tr("Key combination", "lock screen security key shortcut key combination setting")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
width: parent.width
horizontalAlignment: Text.AlignLeft
}
StyledText {
text: securityKeyCapture.captureError !== "" ? securityKeyCapture.captureError : I18n.tr("Press Ctrl+key to set. Esc cancels.", "lock screen security key shortcut key combination capture hint")
font.pixelSize: Theme.fontSizeSmall
color: securityKeyCapture.captureError !== "" ? Theme.warning : Theme.surfaceVariantText
wrapMode: Text.WordWrap
width: parent.width
horizontalAlignment: Text.AlignLeft
}
}
DankButton {
id: securityKeyCapture
width: 200
anchors.verticalCenter: parent.verticalCenter
focus: capturing
text: capturing ? I18n.tr("Press key...", "lock screen security key shortcut key combination capture prompt") : SettingsData.lockScreenSecurityKeyShortcut
backgroundColor: capturing ? Theme.primaryContainer : Theme.surfaceContainer
textColor: Theme.surfaceText
property bool capturing: false
property string captureError: ""
readonly property var reservedKeys: ["A", "E", "B", "F", "U", "K", "W", "H", "D"]
function startCapture() {
captureError = "";
capturing = true;
securityKeyCapture.forceActiveFocus();
}
function stopCapture() {
capturing = false;
}
onClicked: {
if (capturing)
stopCapture();
else
startCapture();
}
Keys.onPressed: event => {
if (!securityKeyCapture.capturing)
return;
if (KeyUtils.isModifierKey(event.key))
return;
if (event.key === Qt.Key_Escape) {
securityKeyCapture.stopCapture();
event.accepted = true;
return;
}
const mods = KeyUtils.modsFromEvent(event.modifiers);
const hasCtrl = mods.includes("Ctrl");
const hasAlt = mods.includes("Alt") || mods.includes("Super");
if (!hasCtrl || hasAlt)
return;
const hasShift = mods.includes("Shift");
const key = KeyUtils.xkbKeyFromQtKey(event.key, !!(event.modifiers & Qt.KeypadModifier), hasShift);
if (!key)
return;
if (!KeyUtils.qtKeyFromName(key))
return;
if (!hasShift && securityKeyCapture.reservedKeys.indexOf(key.toUpperCase()) !== -1) {
securityKeyCapture.captureError = I18n.tr("Ctrl+%1 is used for password editing", "lock screen security key shortcut reserved key warning").arg(key.toUpperCase());
event.accepted = true;
return;
}
SettingsData.set("lockScreenSecurityKeyShortcut", KeyUtils.formatToken(mods, key));
securityKeyCapture.captureError = "";
securityKeyCapture.stopCapture();
event.accepted = true;
}
}
}
SettingsDropdownRow { SettingsDropdownRow {
settingKey: "lockU2fPamPath" settingKey: "lockU2fPamPath"
tags: ["lock", "screen", "pam", "u2f", "security", "key", "source", "service"] tags: ["lock", "screen", "pam", "u2f", "security", "key", "source", "service"]
@@ -82,6 +82,15 @@ Item {
onToggled: checked => SettingsData.set("mediaUseAlbumArtAccent", checked) onToggled: checked => SettingsData.set("mediaUseAlbumArtAccent", checked)
} }
SettingsToggleRow {
settingKey: "appleMusicAnimatedArtEnabled"
tags: ["apple", "animated", "video", "artwork", "cover"]
text: I18n.tr("Apple Music animated covers")
description: I18n.tr("Show looping video album artwork from Apple Music when available. Sends the playing artist and album name to Apple.")
checked: SettingsData.appleMusicAnimatedArtEnabled
onToggled: checked => SettingsData.set("appleMusicAnimatedArtEnabled", checked)
}
SettingsDropdownRow { SettingsDropdownRow {
property var scrollOptsInternal: ["volume", "song", "nothing"] property var scrollOptsInternal: ["volume", "song", "nothing"]
property var scrollOptsDisplay: [I18n.tr("Change Volume", "media scroll wheel option"), I18n.tr("Change Song", "media scroll wheel option"), I18n.tr("Nothing", "media scroll wheel option")] property var scrollOptsDisplay: [I18n.tr("Change Volume", "media scroll wheel option"), I18n.tr("Change Song", "media scroll wheel option"), I18n.tr("Nothing", "media scroll wheel option")]
+29 -163
View File
@@ -1,10 +1,8 @@
import QtCore
import QtQuick import QtQuick
import qs.Common import qs.Common
import qs.Services import qs.Services
import qs.Widgets import qs.Widgets
import qs.Modules.Settings.Widgets import qs.Modules.Settings.Widgets
import "../../Common/ConfigIncludeResolve.js" as ConfigIncludeResolve
Item { Item {
id: root id: root
@@ -12,93 +10,6 @@ Item {
LayoutMirroring.enabled: I18n.isRtl LayoutMirroring.enabled: I18n.isRtl
LayoutMirroring.childrenInherit: true LayoutMirroring.childrenInherit: true
property var inputIncludeStatus: ({
"exists": false,
"included": false,
"configFormat": "",
"readOnly": false
})
property bool checkingInclude: false
property bool fixingInclude: false
function getInputConfigPaths() {
const configDir = Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation));
if (CompositorService.compositor !== "niri")
return null;
return {
"configFile": configDir + "/niri/config.kdl",
"layoutFile": configDir + "/niri/dms/input.kdl",
"grepPattern": 'include.*"dms/input.kdl"',
"includeLine": 'include "dms/input.kdl"'
};
}
function checkInputIncludeStatus() {
if (CompositorService.compositor !== "niri") {
inputIncludeStatus = {
"exists": false,
"included": false,
"configFormat": "",
"readOnly": false
};
return;
}
checkingInclude = true;
Proc.runCommand("check-input-include", [Proc.dmsBin, "config", "resolve-include", "niri", "input.kdl"], (output, exitCode) => {
checkingInclude = false;
if (exitCode !== 0) {
inputIncludeStatus = {
"exists": false,
"included": false,
"configFormat": "",
"readOnly": false
};
return;
}
try {
inputIncludeStatus = JSON.parse(output.trim());
} catch (e) {
inputIncludeStatus = {
"exists": false,
"included": false,
"configFormat": "",
"readOnly": false
};
}
});
}
function fixInputInclude() {
const paths = getInputConfigPaths();
if (!paths)
return;
fixingInclude = true;
const unixTime = Math.floor(Date.now() / 1000);
const backupFile = paths.configFile + ".backup" + unixTime;
const script = ConfigIncludeResolve.buildRepairScript({
configFile: paths.configFile,
backupFile: backupFile,
fragmentFile: paths.layoutFile,
grepPattern: paths.grepPattern,
includeLine: paths.includeLine
});
Proc.runCommand("fix-input-include", ["sh", "-c", script], (output, exitCode) => {
fixingInclude = false;
if (exitCode !== 0)
return;
checkInputIncludeStatus();
SettingsData.updateCompositorInput();
});
}
Component.onCompleted: {
if (CompositorService.isNiri) {
checkInputIncludeStatus();
}
}
DankFlickable { DankFlickable {
anchors.fill: parent anchors.fill: parent
clip: true clip: true
@@ -113,67 +24,8 @@ Item {
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
spacing: Theme.spacingXL spacing: Theme.spacingXL
StyledRect { NiriInputSetupBanner {
id: warningBox
width: parent.width width: parent.width
height: warningContent.implicitHeight + Theme.spacingL * 2
radius: Theme.cornerRadius
readonly property bool showSetup: !root.inputIncludeStatus.included
color: showSetup ? Theme.withAlpha(Theme.primary, 0.15) : Theme.withAlpha(Theme.primary, 0)
border.color: showSetup ? Theme.withAlpha(Theme.primary, 0.3) : Theme.withAlpha(Theme.primary, 0)
border.width: 1
visible: showSetup && !root.checkingInclude && CompositorService.isNiri
Row {
id: warningContent
anchors.fill: parent
anchors.margins: Theme.spacingL
spacing: Theme.spacingM
DankIcon {
name: "warning"
size: Theme.iconSize
color: Theme.primary
anchors.verticalCenter: parent.verticalCenter
}
Column {
width: parent.width - Theme.iconSize - (fixButton.visible ? fixButton.width + Theme.spacingM : 0) - Theme.spacingM
spacing: Theme.spacingXS
anchors.verticalCenter: parent.verticalCenter
StyledText {
text: I18n.tr("First Time Setup")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.primary
width: parent.width
horizontalAlignment: Text.AlignLeft
}
StyledText {
text: I18n.tr("Click 'Setup' to create %1 and add include to your compositor config.").arg("dms/input")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
width: parent.width
horizontalAlignment: Text.AlignLeft
}
}
DankButton {
id: fixButton
visible: warningBox.showSetup
text: root.fixingInclude ? I18n.tr("Setting up...") : I18n.tr("Setup")
backgroundColor: Theme.primary
textColor: Theme.primaryText
enabled: !root.fixingInclude
anchors.verticalCenter: parent.verticalCenter
onClicked: root.fixInputInclude()
}
}
} }
SettingsCard { SettingsCard {
@@ -212,12 +64,15 @@ Item {
description: I18n.tr("Flat uses constant speed; Adaptive scales with movement speed") description: I18n.tr("Flat uses constant speed; Adaptive scales with movement speed")
model: [I18n.tr("Default"), I18n.tr("Flat"), I18n.tr("Adaptive")] model: [I18n.tr("Default"), I18n.tr("Flat"), I18n.tr("Adaptive")]
currentIndex: { currentIndex: {
if (SettingsData.mouseAccelProfile === "flat") return 1; if (SettingsData.mouseAccelProfile === "flat")
if (SettingsData.mouseAccelProfile === "adaptive") return 2; return 1;
if (SettingsData.mouseAccelProfile === "adaptive")
return 2;
return 0; return 0;
} }
onSelectionChanged: (index, selected) => { onSelectionChanged: (index, selected) => {
if (!selected) return; if (!selected)
return;
const profiles = ["default", "flat", "adaptive"]; const profiles = ["default", "flat", "adaptive"];
SettingsData.set("mouseAccelProfile", profiles[index]); SettingsData.set("mouseAccelProfile", profiles[index]);
} }
@@ -252,12 +107,15 @@ Item {
description: I18n.tr("Choose when to generate scrolling events") description: I18n.tr("Choose when to generate scrolling events")
model: [I18n.tr("Default"), I18n.tr("No Scroll"), I18n.tr("On Button Down")] model: [I18n.tr("Default"), I18n.tr("No Scroll"), I18n.tr("On Button Down")]
currentIndex: { currentIndex: {
if (SettingsData.mouseScrollMethod === "no-scroll") return 1; if (SettingsData.mouseScrollMethod === "no-scroll")
if (SettingsData.mouseScrollMethod === "on-button-down") return 2; return 1;
if (SettingsData.mouseScrollMethod === "on-button-down")
return 2;
return 0; return 0;
} }
onSelectionChanged: (index, selected) => { onSelectionChanged: (index, selected) => {
if (!selected) return; if (!selected)
return;
const methods = ["default", "no-scroll", "on-button-down"]; const methods = ["default", "no-scroll", "on-button-down"];
SettingsData.set("mouseScrollMethod", methods[index]); SettingsData.set("mouseScrollMethod", methods[index]);
} }
@@ -327,12 +185,15 @@ Item {
description: I18n.tr("Flat uses constant speed; Adaptive scales with movement speed") description: I18n.tr("Flat uses constant speed; Adaptive scales with movement speed")
model: [I18n.tr("Default"), I18n.tr("Flat"), I18n.tr("Adaptive")] model: [I18n.tr("Default"), I18n.tr("Flat"), I18n.tr("Adaptive")]
currentIndex: { currentIndex: {
if (SettingsData.touchpadAccelProfile === "flat") return 1; if (SettingsData.touchpadAccelProfile === "flat")
if (SettingsData.touchpadAccelProfile === "adaptive") return 2; return 1;
if (SettingsData.touchpadAccelProfile === "adaptive")
return 2;
return 0; return 0;
} }
onSelectionChanged: (index, selected) => { onSelectionChanged: (index, selected) => {
if (!selected) return; if (!selected)
return;
const profiles = ["default", "flat", "adaptive"]; const profiles = ["default", "flat", "adaptive"];
SettingsData.set("touchpadAccelProfile", profiles[index]); SettingsData.set("touchpadAccelProfile", profiles[index]);
} }
@@ -367,14 +228,19 @@ Item {
description: I18n.tr("Choose when to generate scrolling events") description: I18n.tr("Choose when to generate scrolling events")
model: [I18n.tr("Default"), I18n.tr("Two Finger"), I18n.tr("Edge"), I18n.tr("No Scroll"), I18n.tr("On Button Down")] model: [I18n.tr("Default"), I18n.tr("Two Finger"), I18n.tr("Edge"), I18n.tr("No Scroll"), I18n.tr("On Button Down")]
currentIndex: { currentIndex: {
if (SettingsData.touchpadScrollMethod === "two-finger") return 1; if (SettingsData.touchpadScrollMethod === "two-finger")
if (SettingsData.touchpadScrollMethod === "edge") return 2; return 1;
if (SettingsData.touchpadScrollMethod === "no-scroll") return 3; if (SettingsData.touchpadScrollMethod === "edge")
if (SettingsData.touchpadScrollMethod === "on-button-down") return 4; return 2;
if (SettingsData.touchpadScrollMethod === "no-scroll")
return 3;
if (SettingsData.touchpadScrollMethod === "on-button-down")
return 4;
return 0; return 0;
} }
onSelectionChanged: (index, selected) => { onSelectionChanged: (index, selected) => {
if (!selected) return; if (!selected)
return;
const methods = ["default", "two-finger", "edge", "no-scroll", "on-button-down"]; const methods = ["default", "two-finger", "edge", "no-scroll", "on-button-down"];
SettingsData.set("touchpadScrollMethod", methods[index]); SettingsData.set("touchpadScrollMethod", methods[index]);
} }
@@ -465,7 +465,6 @@ Item {
iconName: "restart_alt" iconName: "restart_alt"
iconSize: 20 iconSize: 20
visible: JSON.stringify(SettingsData.notificationRules) !== JSON.stringify(SettingsData.getDefaultNotificationRules()) visible: JSON.stringify(SettingsData.notificationRules) !== JSON.stringify(SettingsData.getDefaultNotificationRules())
backgroundColor: Theme.surfaceContainer
iconColor: Theme.surfaceVariantText iconColor: Theme.surfaceVariantText
onClicked: SettingsData.resetNotificationRules() onClicked: SettingsData.resetNotificationRules()
}, },
@@ -473,7 +472,6 @@ Item {
buttonSize: 36 buttonSize: 36
iconName: "add" iconName: "add"
iconSize: 20 iconSize: 20
backgroundColor: Theme.surfaceContainer
iconColor: Theme.primary iconColor: Theme.primary
onClicked: { onClicked: {
SettingsData.addNotificationRule(); SettingsData.addNotificationRule();
@@ -723,7 +721,7 @@ Item {
} }
Item { Item {
width: Math.max(0, parent.width - parent.spacing - mutedAppLabel.width - unmuteBtn.width - deleteBtn.width - Theme.spacingS * 5) width: Math.max(0, parent.width - parent.spacing - mutedAppLabel.width - unmuteBtn.width - mutedDeleteBtn.width - Theme.spacingS * 5)
height: 1 height: 1
} }
@@ -736,7 +734,7 @@ Item {
} }
Item { Item {
id: deleteBtn id: mutedDeleteBtn
width: 28 width: 28
height: 28 height: 28
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
@@ -744,18 +742,18 @@ Item {
Rectangle { Rectangle {
anchors.fill: parent anchors.fill: parent
radius: Theme.cornerRadius radius: Theme.cornerRadius
color: deleteArea.containsMouse ? Theme.withAlpha(Theme.error, 0.2) : Theme.withAlpha(Theme.error, 0) color: mutedDeleteArea.containsMouse ? Theme.withAlpha(Theme.error, 0.2) : Theme.withAlpha(Theme.error, 0)
} }
DankIcon { DankIcon {
anchors.centerIn: parent anchors.centerIn: parent
name: "delete" name: "delete"
size: 18 size: 18
color: deleteArea.containsMouse ? Theme.error : Theme.surfaceVariantText color: mutedDeleteArea.containsMouse ? Theme.error : Theme.surfaceVariantText
} }
MouseArea { MouseArea {
id: deleteArea id: mutedDeleteArea
anchors.fill: parent anchors.fill: parent
hoverEnabled: true hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
+5 -13
View File
@@ -7,10 +7,9 @@ import qs.Modals.Common
import qs.Services import qs.Services
import qs.Widgets import qs.Widgets
FloatingWindow { DankFloatingWindow {
id: root id: root
property bool disablePopupTransparency: true
property var allPlugins: [] property var allPlugins: []
property string searchQuery: "" property string searchQuery: ""
property var filteredPlugins: [] property var filteredPlugins: []
@@ -608,7 +607,6 @@ FloatingWindow {
return Math.round(Math.min(maxHeight, Math.max(540, parentModal.height * 0.8))); return Math.round(Math.min(maxHeight, Math.max(540, parentModal.height * 0.8)));
return Math.min(maxHeight, 760); return Math.min(maxHeight, 760);
} }
color: Theme.surfaceContainer
visible: false visible: false
onClosed: hide() onClosed: hide()
@@ -838,10 +836,6 @@ FloatingWindow {
anchors.top: headerArea.bottom anchors.top: headerArea.bottom
anchors.topMargin: Theme.spacingM anchors.topMargin: Theme.spacingM
height: 48 height: 48
cornerRadius: Theme.cornerRadius
backgroundColor: Theme.surfaceContainerHigh
normalBorderColor: Theme.outlineMedium
focusedBorderColor: Theme.primary
leftIconName: "search" leftIconName: "search"
leftIconSize: Theme.iconSize leftIconSize: Theme.iconSize
leftIconColor: Theme.surfaceVariantText leftIconColor: Theme.surfaceVariantText
@@ -1102,7 +1096,7 @@ FloatingWindow {
ClippingRectangle { ClippingRectangle {
anchors.fill: parent anchors.fill: parent
radius: Theme.cornerRadius - 2 radius: Theme.cornerRadius - 2
color: Theme.surfaceContainerHigh color: Theme.floatingWindowNestedSurface
CachingImage { CachingImage {
id: cardPreview id: cardPreview
@@ -1362,7 +1356,7 @@ FloatingWindow {
anchors.topMargin: Theme.spacingM anchors.topMargin: Theme.spacingM
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
z: 10 z: 10
color: Theme.surfaceContainer color: Theme.floatingWindowSurface
opacity: root.detailPluginId !== "" ? 1 : 0 opacity: root.detailPluginId !== "" ? 1 : 0
visible: opacity > 0 visible: opacity > 0
@@ -1555,7 +1549,7 @@ FloatingWindow {
width: parent.width width: parent.width
height: Math.round(width * 0.52) height: Math.round(width * 0.52)
radius: Theme.cornerRadius radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh color: Theme.floatingWindowNestedSurface
border.color: Theme.withAlpha(Theme.outline, 0.2) border.color: Theme.withAlpha(Theme.outline, 0.2)
border.width: 1 border.width: 1
@@ -1829,10 +1823,9 @@ FloatingWindow {
id: thirdPartyConfirmLoader id: thirdPartyConfirmLoader
active: false active: false
FloatingWindow { DankFloatingWindow {
id: thirdPartyConfirmModal id: thirdPartyConfirmModal
property bool disablePopupTransparency: true
parentWindow: root parentWindow: root
function show() { function show() {
@@ -1847,7 +1840,6 @@ FloatingWindow {
title: I18n.tr("Third-Party Plugin Warning") title: I18n.tr("Third-Party Plugin Warning")
implicitWidth: 500 implicitWidth: 500
implicitHeight: 350 implicitHeight: 350
color: Theme.surfaceContainer
visible: false visible: false
FocusScope { FocusScope {
+134
View File
@@ -328,6 +328,134 @@ FocusScope {
} }
} }
StyledRect {
width: parent.width
height: registriesColumn.implicitHeight + Theme.spacingL * 2
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
border.width: 0
visible: DMSService.dmsAvailable && DMSService.apiVersion >= 29
Column {
id: registriesColumn
anchors.fill: parent
anchors.margins: Theme.spacingL
spacing: Theme.spacingM
StyledText {
text: I18n.tr("Registries")
font.pixelSize: Theme.fontSizeLarge
color: Theme.surfaceText
font.weight: Font.Medium
width: parent.width
horizontalAlignment: Text.AlignLeft
}
StyledText {
text: I18n.tr("Sources for plugins and themes. Registries are git repositories with a plugins/ or themes/ directory.")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
width: parent.width
horizontalAlignment: Text.AlignLeft
}
Repeater {
model: DMSService.registries
Item {
required property var modelData
width: parent.width
height: registryInfo.implicitHeight + Theme.spacingXS
Column {
id: registryInfo
anchors.left: parent.left
anchors.right: removeRegistryBtn.left
anchors.rightMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Row {
spacing: Theme.spacingXS
StyledText {
text: modelData.name
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
font.weight: Font.Medium
}
StyledText {
text: I18n.tr("official")
font.pixelSize: Theme.fontSizeSmall
color: Theme.primary
visible: modelData.official
anchors.verticalCenter: parent.verticalCenter
}
}
StyledText {
text: modelData.url
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
font.family: "monospace"
elide: Text.ElideMiddle
width: parent.width
}
}
DankActionButton {
id: removeRegistryBtn
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
iconName: "delete"
iconSize: 18
visible: !modelData.official
onClicked: DMSService.removeRegistry(modelData.name, response => {
if (response.error)
ToastService.showError(response.error);
})
}
}
}
Row {
width: parent.width
spacing: Theme.spacingM
DankTextField {
id: registryNameField
width: 140
placeholderText: I18n.tr("Name")
}
DankTextField {
id: registryUrlField
width: parent.width - 140 - addRegistryBtn.width - Theme.spacingM * 2
placeholderText: "https://github.com/user/registry.git"
}
DankButton {
id: addRegistryBtn
text: I18n.tr("Add")
enabled: registryNameField.text.trim() !== "" && registryUrlField.text.trim() !== ""
anchors.verticalCenter: parent.verticalCenter
onClicked: DMSService.addRegistry(registryNameField.text.trim(), registryUrlField.text.trim(), response => {
if (response.error) {
ToastService.showError(response.error);
return;
}
registryNameField.text = "";
registryUrlField.text = "";
})
}
}
}
}
StyledRect { StyledRect {
width: parent.width width: parent.width
height: Math.max(200, availableColumn.implicitHeight + Theme.spacingL * 2) height: Math.max(200, availableColumn.implicitHeight + Theme.spacingL * 2)
@@ -517,12 +645,18 @@ FocusScope {
function onOperationError(error) { function onOperationError(error) {
ToastService.showError(error); ToastService.showError(error);
} }
function onDmsAvailableChanged() {
if (DMSService.dmsAvailable && DMSService.apiVersion >= 29)
DMSService.listRegistries();
}
} }
Component.onCompleted: { Component.onCompleted: {
updateFilteredPlugins(); updateFilteredPlugins();
if (DMSService.dmsAvailable && DMSService.apiVersion >= 8) if (DMSService.dmsAvailable && DMSService.apiVersion >= 8)
DMSService.listInstalled(); DMSService.listInstalled();
if (DMSService.dmsAvailable && DMSService.apiVersion >= 29)
DMSService.listRegistries();
if (PopoutService.pendingPluginInstall) if (PopoutService.pendingPluginInstall)
Qt.callLater(showPluginBrowser); Qt.callLater(showPluginBrowser);
} }
+15 -3
View File
@@ -410,8 +410,8 @@ Item {
settingKey: "powerMenuDefaultAction" settingKey: "powerMenuDefaultAction"
tags: ["power", "menu", "default", "action", "reboot", "logout", "shutdown"] tags: ["power", "menu", "default", "action", "reboot", "logout", "shutdown"]
text: I18n.tr("Default selected action") text: I18n.tr("Default selected action")
options: [I18n.tr("Reboot"), I18n.tr("Log Out"), I18n.tr("Power Off"), I18n.tr("Lock"), I18n.tr("Suspend"), I18n.tr("Restart DMS"), I18n.tr("Hibernate")] options: [I18n.tr("Reboot"), I18n.tr("Log Out"), I18n.tr("Power Off"), I18n.tr("Lock"), I18n.tr("Suspend"), I18n.tr("Restart DMS"), I18n.tr("Hibernate"), I18n.tr("Soft Reboot")]
property var actionValues: ["reboot", "logout", "poweroff", "lock", "suspend", "restart", "hibernate"] property var actionValues: ["reboot", "logout", "poweroff", "lock", "suspend", "restart", "hibernate", "softreboot"]
Component.onCompleted: { Component.onCompleted: {
const currentAction = SettingsData.powerMenuDefaultAction || "logout"; const currentAction = SettingsData.powerMenuDefaultAction || "logout";
@@ -475,6 +475,12 @@ Item {
label: I18n.tr("Show Hibernate"), label: I18n.tr("Show Hibernate"),
desc: I18n.tr("Only visible if hibernate is supported by your system"), desc: I18n.tr("Only visible if hibernate is supported by your system"),
hibernate: true hibernate: true
},
{
key: "softreboot",
label: I18n.tr("Show Soft Reboot"),
desc: I18n.tr("Restart userspace without rebooting the kernel, requires systemd"),
softreboot: true
} }
] ]
@@ -484,7 +490,13 @@ Item {
tags: ["power", "menu", "action", "show", modelData.key] tags: ["power", "menu", "action", "show", modelData.key]
text: modelData.label text: modelData.label
description: modelData.desc || "" description: modelData.desc || ""
visible: !modelData.hibernate || SessionService.hibernateSupported visible: {
if (modelData.hibernate)
return SessionService.hibernateSupported;
if (modelData.softreboot)
return SessionService.softRebootSupported;
return true;
}
checked: SettingsData.powerMenuActions.includes(modelData.key) checked: SettingsData.powerMenuActions.includes(modelData.key)
onToggled: checked => { onToggled: checked => {
let actions = [...SettingsData.powerMenuActions]; let actions = [...SettingsData.powerMenuActions];
@@ -32,7 +32,6 @@ Item {
iconName: "restart_alt" iconName: "restart_alt"
iconSize: 20 iconSize: 20
visible: JSON.stringify(SettingsData.appIdSubstitutions) !== JSON.stringify(SettingsData.getDefaultAppIdSubstitutions()) visible: JSON.stringify(SettingsData.appIdSubstitutions) !== JSON.stringify(SettingsData.getDefaultAppIdSubstitutions())
backgroundColor: Theme.surfaceContainer
iconColor: Theme.surfaceVariantText iconColor: Theme.surfaceVariantText
onClicked: SettingsData.resetAppIdSubstitutions() onClicked: SettingsData.resetAppIdSubstitutions()
}, },
@@ -40,7 +39,6 @@ Item {
buttonSize: 36 buttonSize: 36
iconName: "add" iconName: "add"
iconSize: 20 iconSize: 20
backgroundColor: Theme.surfaceContainer
iconColor: Theme.primary iconColor: Theme.primary
onClicked: SettingsData.addAppIdSubstitution("", "", "exact") onClicked: SettingsData.addAppIdSubstitution("", "", "exact")
} }
+3 -9
View File
@@ -6,10 +6,9 @@ import qs.Modals.Common
import qs.Services import qs.Services
import qs.Widgets import qs.Widgets
FloatingWindow { DankFloatingWindow {
id: root id: root
property bool disablePopupTransparency: true
property var allThemes: [] property var allThemes: []
property string searchQuery: "" property string searchQuery: ""
property var filteredThemes: [] property var filteredThemes: []
@@ -148,7 +147,6 @@ FloatingWindow {
minimumSize: Qt.size(550, 450) minimumSize: Qt.size(550, 450)
implicitWidth: 700 implicitWidth: 700
implicitHeight: 700 implicitHeight: 700
color: Theme.surfaceContainer
visible: false visible: false
onVisibleChanged: { onVisibleChanged: {
@@ -301,10 +299,6 @@ FloatingWindow {
anchors.top: descriptionText.bottom anchors.top: descriptionText.bottom
anchors.topMargin: Theme.spacingM anchors.topMargin: Theme.spacingM
height: 48 height: 48
cornerRadius: Theme.cornerRadius
backgroundColor: Theme.surfaceContainerHigh
normalBorderColor: Theme.outlineMedium
focusedBorderColor: Theme.primary
leftIconName: "search" leftIconName: "search"
leftIconSize: Theme.iconSize leftIconSize: Theme.iconSize
leftIconColor: Theme.surfaceVariantText leftIconColor: Theme.surfaceVariantText
@@ -401,7 +395,7 @@ FloatingWindow {
width: hasPreview ? 180 : 0 width: hasPreview ? 180 : 0
height: parent.height height: parent.height
radius: Theme.cornerRadius - 2 radius: Theme.cornerRadius - 2
color: Theme.surfaceContainerHigh color: Theme.floatingWindowNestedSurface
visible: hasPreview visible: hasPreview
Image { Image {
@@ -596,7 +590,7 @@ FloatingWindow {
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutsideParent closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutsideParent
background: Rectangle { background: Rectangle {
color: Theme.surfaceContainerHigh color: Theme.floatingWindowNestedSurface
radius: Theme.cornerRadius radius: Theme.cornerRadius
border.width: 1 border.width: 1
border.color: Theme.outlineMedium border.color: Theme.outlineMedium
+352 -161
View File
@@ -65,112 +65,173 @@ Item {
"label": I18n.tr("Custom", "widget background color option") "label": I18n.tr("Custom", "widget background color option")
})] })]
property var cursorIncludeStatus: ({ property var cursorIncludeStatus: defaultIncludeStatus()
"exists": false,
"included": false,
"configFormat": "",
"readOnly": false
})
readonly property bool cursorReadOnly: CompositorService.isHyprland && cursorIncludeStatus.readOnly === true readonly property bool cursorReadOnly: CompositorService.isHyprland && cursorIncludeStatus.readOnly === true
property bool checkingCursorInclude: false property bool checkingCursorInclude: false
property bool fixingCursorInclude: false property bool fixingCursorInclude: false
function getCursorConfigPaths() { property var windowRulesIncludeStatus: defaultIncludeStatus()
const configDir = Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation)); readonly property bool windowRulesReadOnly: CompositorService.isHyprland && windowRulesIncludeStatus.readOnly === true
switch (CompositorService.compositor) { property bool checkingWindowRulesInclude: false
case "niri": property bool fixingWindowRulesInclude: false
return {
"configFile": configDir + "/niri/config.kdl", readonly property var includeConfigSpecs: ({
"cursorFile": configDir + "/niri/dms/cursor.kdl", "cursor": ({
"niri": {
"configName": "config.kdl",
"fragmentName": "cursor.kdl",
"grepPattern": 'include.*"dms/cursor.kdl"', "grepPattern": 'include.*"dms/cursor.kdl"',
"includeLine": 'include "dms/cursor.kdl"' "includeLine": 'include "dms/cursor.kdl"'
}; },
case "hyprland": "hyprland": {
return { "configName": "hyprland.lua",
"configFile": configDir + "/hypr/hyprland.lua", "fragmentName": "cursor.lua",
"cursorFile": configDir + "/hypr/dms/cursor.lua",
"grepPattern": "dms.cursor", "grepPattern": "dms.cursor",
"includeLine": "require(\"dms.cursor\")" "includeLine": "require(\"dms.cursor\")"
}; },
case "mango": "mango": {
return { "configName": "config.conf",
"configFile": configDir + "/mango/config.conf", "fragmentName": "cursor.conf",
"cursorFile": configDir + "/mango/dms/cursor.conf", "grepPattern": "source.*dms/cursor.conf",
"grepPattern": 'source.*dms/cursor.conf',
"includeLine": "source=./dms/cursor.conf" "includeLine": "source=./dms/cursor.conf"
};
default:
return null;
} }
}),
"windowrules": ({
"niri": {
"configName": "config.kdl",
"fragmentName": "windowrules.kdl",
"grepPattern": 'include.*"dms/windowrules.kdl"',
"includeLine": 'include "dms/windowrules.kdl"'
},
"hyprland": {
"configName": "hyprland.lua",
"fragmentName": "windowrules.lua",
"grepPattern": "dms.windowrules",
"includeLine": "require(\"dms.windowrules\")"
},
"mango": {
"configName": "config.conf",
"fragmentName": "windowrules.conf",
"grepPattern": "dms/windowrules.conf",
"includeLine": "source=./dms/windowrules.conf"
} }
})
})
function checkCursorIncludeStatus() { function defaultIncludeStatus() {
const compositor = CompositorService.compositor; return {
if (compositor !== "niri" && compositor !== "hyprland" && compositor !== "mango") {
cursorIncludeStatus = {
"exists": false, "exists": false,
"included": false, "included": false,
"configFormat": "", "configFormat": "",
"readOnly": false "readOnly": false
}; };
}
function getIncludeConfigPaths(includeKind) {
const spec = includeConfigSpecs[includeKind]?.[CompositorService.compositor];
if (!spec)
return null;
const configDir = Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation)) + "/" + CompositorService.compositor;
return {
"configFile": configDir + "/" + spec.configName,
"fragmentFile": configDir + "/dms/" + spec.fragmentName,
"grepPattern": spec.grepPattern,
"includeLine": spec.includeLine
};
}
function checkIncludeStatus(includeKind, procTag, onFinished) {
const spec = includeConfigSpecs[includeKind]?.[CompositorService.compositor];
if (!spec) {
onFinished(defaultIncludeStatus());
return; return;
} }
const compositor = CompositorService.compositor;
const filename = (compositor === "niri") ? "cursor.kdl" : ((compositor === "hyprland") ? "cursor.lua" : "cursor.conf");
const compositorArg = (compositor === "mango") ? "mangowc" : compositor; const compositorArg = (compositor === "mango") ? "mangowc" : compositor;
Proc.runCommand(procTag, [Proc.dmsBin, "config", "resolve-include", compositorArg, spec.fragmentName], (output, exitCode) => {
checkingCursorInclude = true;
Proc.runCommand("check-cursor-include", [Proc.dmsBin, "config", "resolve-include", compositorArg, filename], (output, exitCode) => {
checkingCursorInclude = false;
if (exitCode !== 0) { if (exitCode !== 0) {
cursorIncludeStatus = { onFinished(defaultIncludeStatus());
"exists": false,
"included": false,
"configFormat": "",
"readOnly": false
};
return; return;
} }
try { try {
cursorIncludeStatus = JSON.parse(output.trim()); onFinished(JSON.parse(output.trim()));
} catch (e) { } catch (e) {
cursorIncludeStatus = { onFinished(defaultIncludeStatus());
"exists": false,
"included": false,
"configFormat": "",
"readOnly": false
};
} }
}); });
} }
function fixCursorInclude() { function checkCursorIncludeStatus() {
if (cursorReadOnly) { checkingCursorInclude = true;
checkIncludeStatus("cursor", "check-cursor-include", status => {
checkingCursorInclude = false;
cursorIncludeStatus = status;
});
}
function checkWindowRulesIncludeStatus() {
checkingWindowRulesInclude = true;
checkIncludeStatus("windowrules", "check-windowrules-include-theme", status => {
checkingWindowRulesInclude = false;
windowRulesIncludeStatus = status;
});
}
function fixInclude(includeKind, procTag, readOnly, onFinished) {
if (readOnly) {
ToastService.showWarning(I18n.tr("Hyprland conf mode"), I18n.tr("This install is still using hyprland.conf. Run dms setup to migrate before changing these settings."), "dms setup", "hyprland-migration"); ToastService.showWarning(I18n.tr("Hyprland conf mode"), I18n.tr("This install is still using hyprland.conf. Run dms setup to migrate before changing these settings."), "dms setup", "hyprland-migration");
onFinished(false);
return; return;
} }
const paths = getCursorConfigPaths(); const paths = getIncludeConfigPaths(includeKind);
if (!paths) if (!paths) {
onFinished(false);
return; return;
fixingCursorInclude = true; }
const unixTime = Math.floor(Date.now() / 1000); const unixTime = Math.floor(Date.now() / 1000);
const backupFile = paths.configFile + ".backup" + unixTime;
const script = ConfigIncludeResolve.buildRepairScript({ const script = ConfigIncludeResolve.buildRepairScript({
configFile: paths.configFile, configFile: paths.configFile,
backupFile: backupFile, backupFile: paths.configFile + ".backup" + unixTime,
fragmentFile: paths.cursorFile, fragmentFile: paths.fragmentFile,
grepPattern: paths.grepPattern, grepPattern: paths.grepPattern,
includeLine: paths.includeLine includeLine: paths.includeLine
}); });
Proc.runCommand("fix-cursor-include", ["sh", "-c", script], (output, exitCode) => { Proc.runCommand(procTag, ["sh", "-c", script], (output, exitCode) => onFinished(exitCode === 0));
}
function fixCursorInclude() {
fixingCursorInclude = true;
fixInclude("cursor", "fix-cursor-include", cursorReadOnly, success => {
fixingCursorInclude = false; fixingCursorInclude = false;
if (exitCode !== 0) if (!success)
return; return;
checkCursorIncludeStatus(); checkCursorIncludeStatus();
SettingsData.updateCompositorCursor(); SettingsData.updateCompositorCursor();
}); });
} }
function fixWindowRulesInclude() {
fixingWindowRulesInclude = true;
fixInclude("windowrules", "fix-windowrules-include-theme", windowRulesReadOnly, success => {
fixingWindowRulesInclude = false;
if (!success)
return;
if (CompositorService.isMango)
MangoService.reloadConfig();
checkWindowRulesIncludeStatus();
CompositorService.applyDmsWindowFloatingRule();
});
}
function unsyncFloatingWindowSettings() {
if (!SettingsData.floatingWindowSyncGlobal)
return;
SettingsData.set("floatingWindowTransparency", SettingsData.popupTransparency);
SettingsData.set("floatingWindowForegroundLayers", SettingsData.blurForegroundLayers ?? true);
SettingsData.set("floatingWindowForegroundTransparency", SettingsData.foregroundLayerTransparency ?? 1.0);
SettingsData.set("floatingWindowSyncGlobal", false);
}
function isTemplateDetected(templateId) { function isTemplateDetected(templateId) {
if (!templateDetection || templateDetection.length === 0) if (!templateDetection || templateDetection.length === 0)
return true; return true;
@@ -259,8 +320,10 @@ Item {
themeColorsTab.templateDetection = JSON.parse(output.trim()); themeColorsTab.templateDetection = JSON.parse(output.trim());
} catch (e) {} } catch (e) {}
}); });
if (CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango) if (CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango) {
checkCursorIncludeStatus(); checkCursorIncludeStatus();
checkWindowRulesIncludeStatus();
}
refreshMatugenSchemePreviews(); refreshMatugenSchemePreviews();
} }
@@ -296,6 +359,78 @@ Item {
} }
} }
component IncludeWarningBox: StyledRect {
id: includeWarningBox
property bool checking: false
property bool fixing: false
property bool includeReadOnly: false
property bool alreadyIncluded: false
property bool visibleCondition: true
property string fragmentPath: ""
property var onSetup: function () {}
readonly property bool showLegacy: includeReadOnly
readonly property bool showSetup: !showLegacy && !alreadyIncluded
width: parent.width
height: includeWarningContent.implicitHeight + Theme.spacingL * 2
radius: Theme.cornerRadius
color: Theme.withAlpha(Theme.primary, 0.15)
border.color: Theme.withAlpha(Theme.primary, 0.3)
border.width: 1
visible: visibleCondition && (showLegacy || showSetup) && !checking
Row {
id: includeWarningContent
anchors.fill: parent
anchors.margins: Theme.spacingL
spacing: Theme.spacingM
DankIcon {
name: "warning"
size: Theme.iconSize
color: Theme.primary
anchors.verticalCenter: parent.verticalCenter
}
Column {
width: parent.width - Theme.iconSize - (includeFixButton.visible ? includeFixButton.width + Theme.spacingM : 0) - Theme.spacingM
spacing: Theme.spacingXS
anchors.verticalCenter: parent.verticalCenter
StyledText {
text: includeWarningBox.showLegacy ? I18n.tr("Hyprland conf mode") : I18n.tr("First Time Setup")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.primary
width: parent.width
horizontalAlignment: Text.AlignLeft
}
StyledText {
text: includeWarningBox.showLegacy ? I18n.tr("This install is still using hyprland.conf. Run dms setup to migrate before changing these settings.") : I18n.tr("Click 'Setup' to create %1 and add include to your compositor config.").arg(includeWarningBox.fragmentPath)
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
width: parent.width
horizontalAlignment: Text.AlignLeft
}
}
DankButton {
id: includeFixButton
visible: !includeWarningBox.showLegacy && includeWarningBox.showSetup
text: includeWarningBox.fixing ? I18n.tr("Setting up...") : I18n.tr("Setup")
backgroundColor: Theme.primary
textColor: Theme.primaryText
enabled: !includeWarningBox.fixing
anchors.verticalCenter: parent.verticalCenter
onClicked: includeWarningBox.onSetup()
}
}
}
DankFlickable { DankFlickable {
anchors.fill: parent anchors.fill: parent
clip: true clip: true
@@ -1137,15 +1272,28 @@ Item {
SettingsCard { SettingsCard {
tab: "theme" tab: "theme"
tags: ["automatic", "color", "mode", "schedule", "sunrise", "sunset"] tags: ["light", "dark", "mode", "appearance", "automatic", "color", "schedule", "sunrise", "sunset"]
title: I18n.tr("Automatic Color Mode") title: I18n.tr("Color Mode")
settingKey: "automaticColorMode" settingKey: "colorMode"
iconName: "schedule" iconName: "contrast"
Column { Column {
width: parent.width width: parent.width
spacing: Theme.spacingM spacing: Theme.spacingM
SettingsToggleRow {
tab: "theme"
tags: ["light", "dark", "mode"]
settingKey: "isLightMode"
text: I18n.tr("Light Mode")
description: I18n.tr("Use light theme instead of dark theme")
checked: SessionData.isLightMode
onToggled: checked => {
Theme.screenTransition();
Theme.setLightMode(checked);
}
}
DankToggle { DankToggle {
id: themeModeAutoToggle id: themeModeAutoToggle
width: parent.width width: parent.width
@@ -1568,27 +1716,6 @@ Item {
} }
} }
SettingsCard {
tab: "theme"
tags: ["light", "dark", "mode", "appearance"]
title: I18n.tr("Color Mode")
settingKey: "colorMode"
iconName: "contrast"
SettingsToggleRow {
tab: "theme"
tags: ["light", "dark", "mode"]
settingKey: "isLightMode"
text: I18n.tr("Light Mode")
description: I18n.tr("Use light theme instead of dark theme")
checked: SessionData.isLightMode
onToggled: checked => {
Theme.screenTransition();
Theme.setLightMode(checked);
}
}
}
SettingsCard { SettingsCard {
tab: "theme" tab: "theme"
tags: ["transparency", "opacity", "widget", "styling"] tags: ["transparency", "opacity", "widget", "styling"]
@@ -1723,12 +1850,21 @@ Item {
} }
} }
} }
}
SettingsCard {
tab: "theme"
tags: ["surface", "popup", "transparency", "opacity", "modal", "border", "outline", "corner", "radius"]
title: I18n.tr("Surface Styling")
settingKey: "surfaceStyling"
iconName: "layers"
SettingsSliderRow { SettingsSliderRow {
tab: "theme" tab: "theme"
tags: ["surface", "popup", "transparency", "opacity", "modal"] tags: ["surface", "popup", "transparency", "opacity", "modal"]
settingKey: "popupTransparency" settingKey: "popupTransparency"
text: I18n.tr("Surface Opacity") text: I18n.tr("Surface Opacity")
description: I18n.tr("Controls opacity of shell surfaces, popouts, and modals") description: I18n.tr("Controls opacity of shell surfaces, popouts, and modals", "Surface Opacity setting description")
visible: !themeColorsTab.connectedFrameModeActive visible: !themeColorsTab.connectedFrameModeActive
value: Math.round(SettingsData.popupTransparency * 100) value: Math.round(SettingsData.popupTransparency * 100)
minimum: 0 minimum: 0
@@ -1748,6 +1884,21 @@ Item {
onToggled: checked => SettingsData.set("blurForegroundLayers", checked) onToggled: checked => SettingsData.set("blurForegroundLayers", checked)
} }
SettingsSliderRow {
tab: "theme"
tags: ["foreground", "layers", "opacity", "transparency", "contrast", "cards"]
settingKey: "foregroundLayerTransparency"
text: I18n.tr("Foreground Opacity")
description: I18n.tr("Opacity of foreground cards and nested surfaces on shell panels")
visible: SettingsData.blurForegroundLayers ?? true
value: Math.round((SettingsData.foregroundLayerTransparency ?? 1.0) * 100)
minimum: 0
maximum: 100
unit: "%"
defaultValue: 100
onSliderValueChanged: newValue => SettingsData.set("foregroundLayerTransparency", newValue / 100)
}
SettingsSliderRow { SettingsSliderRow {
tab: "theme" tab: "theme"
tags: ["foreground", "layers", "outline", "border", "cards", "widgets", "notifications", "control center"] tags: ["foreground", "layers", "outline", "border", "cards", "widgets", "notifications", "control center"]
@@ -1854,6 +2005,113 @@ Item {
} }
} }
SettingsCard {
tab: "theme"
tags: ["floating", "window", "settings", "notepad", "authentication", "polkit", "opacity", "transparency", "foreground", "tile", "tiling"]
title: I18n.tr("Floating Windows")
settingKey: "floatingWindows"
iconName: "open_in_new"
SettingsToggleRow {
tab: "theme"
tags: ["floating", "window", "sync", "global", "surface", "opacity"]
settingKey: "floatingWindowSyncGlobal"
text: I18n.tr("Sync with Global Settings")
description: I18n.tr("Floating windows follow Surface Styling settings")
checked: SettingsData.floatingWindowSyncGlobal ?? true
onToggled: checked => SettingsData.set("floatingWindowSyncGlobal", checked)
}
SettingsSliderRow {
id: floatingWindowOpacitySlider
tab: "theme"
tags: ["floating", "window", "opacity", "transparency"]
settingKey: "floatingWindowTransparency"
text: I18n.tr("Window Opacity")
description: I18n.tr("Opacity of floating DMS windows like Settings, Notepad, and authentication prompts")
value: Math.round(Theme.floatingWindowTransparency * 100)
minimum: 0
maximum: 100
unit: "%"
defaultValue: 100
onSliderValueChanged: newValue => {
themeColorsTab.unsyncFloatingWindowSettings();
SettingsData.set("floatingWindowTransparency", newValue / 100);
}
Binding {
target: floatingWindowOpacitySlider
property: "value"
value: Math.round(Theme.floatingWindowTransparency * 100)
restoreMode: Binding.RestoreBinding
}
}
SettingsToggleRow {
tab: "theme"
tags: ["floating", "window", "foreground", "layers", "contrast", "cards", "blur", "glass"]
settingKey: "floatingWindowForegroundLayers"
text: I18n.tr("Foreground Layers")
description: I18n.tr("Show foreground surfaces on cards inside floating windows")
checked: Theme.floatingWindowForegroundLayers
onToggled: checked => {
themeColorsTab.unsyncFloatingWindowSettings();
SettingsData.set("floatingWindowForegroundLayers", checked);
}
}
SettingsSliderRow {
id: floatingWindowForegroundOpacitySlider
tab: "theme"
tags: ["floating", "window", "foreground", "layers", "opacity", "transparency", "cards"]
settingKey: "floatingWindowForegroundTransparency"
text: I18n.tr("Foreground Opacity")
description: I18n.tr("Opacity of cards and nested surfaces inside floating windows")
visible: Theme.floatingWindowForegroundLayers
value: Math.round(Theme.floatingWindowForegroundTransparency * 100)
minimum: 0
maximum: 100
unit: "%"
defaultValue: 100
onSliderValueChanged: newValue => {
themeColorsTab.unsyncFloatingWindowSettings();
SettingsData.set("floatingWindowForegroundTransparency", newValue / 100);
}
Binding {
target: floatingWindowForegroundOpacitySlider
property: "value"
value: Math.round(Theme.floatingWindowForegroundTransparency * 100)
restoreMode: Binding.RestoreBinding
}
}
SettingsToggleRow {
tab: "theme"
tags: ["floating", "window", "tile", "tiling", "compositor", "rule", "niri", "hyprland", "mango"]
settingKey: "dmsWindowsFloating"
text: I18n.tr("Open Windows Floating")
description: I18n.tr("Open DMS windows floating instead of tiled")
visible: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango
checked: SettingsData.dmsWindowsFloating ?? true
onToggled: checked => {
SettingsData.set("dmsWindowsFloating", checked);
if (checked)
themeColorsTab.checkWindowRulesIncludeStatus();
}
}
IncludeWarningBox {
visibleCondition: (CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango) && (SettingsData.dmsWindowsFloating ?? true)
checking: themeColorsTab.checkingWindowRulesInclude
fixing: themeColorsTab.fixingWindowRulesInclude
includeReadOnly: themeColorsTab.windowRulesReadOnly
alreadyIncluded: themeColorsTab.windowRulesIncludeStatus.included
fragmentPath: "dms/windowrules"
onSetup: themeColorsTab.fixWindowRulesInclude
}
}
SettingsCard { SettingsCard {
tab: "theme" tab: "theme"
tags: ["blur", "background", "transparency", "glass", "frosted"] tags: ["blur", "background", "transparency", "glass", "frosted"]
@@ -2144,80 +2402,13 @@ Item {
width: parent.width width: parent.width
spacing: Theme.spacingM spacing: Theme.spacingM
StyledRect { IncludeWarningBox {
id: cursorWarningBox checking: themeColorsTab.checkingCursorInclude
width: parent.width fixing: themeColorsTab.fixingCursorInclude
height: cursorWarningContent.implicitHeight + Theme.spacingL * 2 includeReadOnly: themeColorsTab.cursorReadOnly
radius: Theme.cornerRadius alreadyIncluded: themeColorsTab.cursorIncludeStatus.included
fragmentPath: "dms/cursor"
readonly property bool showLegacy: themeColorsTab.cursorReadOnly onSetup: themeColorsTab.fixCursorInclude
readonly property bool showSetup: !showLegacy && !themeColorsTab.cursorIncludeStatus.included
color: (showLegacy || showSetup) ? Theme.withAlpha(Theme.primary, 0.15) : Theme.withAlpha(Theme.primary, 0)
border.color: (showLegacy || showSetup) ? Theme.withAlpha(Theme.primary, 0.3) : Theme.withAlpha(Theme.primary, 0)
border.width: 1
visible: (showLegacy || showSetup) && !themeColorsTab.checkingCursorInclude
Row {
id: cursorWarningContent
anchors.fill: parent
anchors.margins: Theme.spacingL
spacing: Theme.spacingM
DankIcon {
name: "warning"
size: Theme.iconSize
color: Theme.primary
anchors.verticalCenter: parent.verticalCenter
}
Column {
width: parent.width - Theme.iconSize - (cursorFixButton.visible ? cursorFixButton.width + Theme.spacingM : 0) - Theme.spacingM
spacing: Theme.spacingXS
anchors.verticalCenter: parent.verticalCenter
StyledText {
text: {
if (cursorWarningBox.showLegacy)
return I18n.tr("Hyprland conf mode");
if (cursorWarningBox.showSetup)
return I18n.tr("First Time Setup");
return "";
}
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.primary
width: parent.width
horizontalAlignment: Text.AlignLeft
}
StyledText {
text: {
if (cursorWarningBox.showLegacy)
return I18n.tr("This install is still using hyprland.conf. Run dms setup to migrate before changing these settings.");
if (cursorWarningBox.showSetup)
return I18n.tr("Click 'Setup' to create %1 and add include to your compositor config.").arg("dms/cursor");
return "";
}
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
width: parent.width
horizontalAlignment: Text.AlignLeft
}
}
DankButton {
id: cursorFixButton
visible: !cursorWarningBox.showLegacy && cursorWarningBox.showSetup
text: themeColorsTab.fixingCursorInclude ? I18n.tr("Setting up...") : I18n.tr("Setup")
backgroundColor: Theme.primary
textColor: Theme.primaryText
enabled: !themeColorsTab.fixingCursorInclude
anchors.verticalCenter: parent.verticalCenter
onClicked: themeColorsTab.fixCursorInclude()
}
}
} }
SettingsDropdownRow { SettingsDropdownRow {
@@ -977,6 +977,33 @@ Item {
} }
} }
SettingsToggleRow {
id: randomToggle
tab: "wallpaper"
tags: ["cycling", "automatic", "random", "shuffle"]
settingKey: "wallpaperCyclingRandom"
width: parent.width - Theme.spacingM * 2
text: I18n.tr("Random Order")
description: I18n.tr("Select a random wallpaper instead of cycling in alphabetical order")
checked: SessionData.perMonitorWallpaper ? SessionData.getMonitorCyclingSettings(selectedMonitorName).random : SessionData.wallpaperCyclingRandom
onToggled: toggled => {
if (SessionData.perMonitorWallpaper) {
SessionData.setMonitorCyclingRandom(selectedMonitorName, toggled);
} else {
SessionData.setWallpaperCyclingRandom(toggled);
}
}
Connections {
target: root
function onSelectedMonitorNameChanged() {
randomToggle.checked = Qt.binding(() => {
return SessionData.perMonitorWallpaper ? SessionData.getMonitorCyclingSettings(selectedMonitorName).random : SessionData.wallpaperCyclingRandom;
});
}
}
}
SettingsDropdownRow { SettingsDropdownRow {
id: intervalDropdown id: intervalDropdown
property var intervalOptions: [I18n.tr("5 seconds", "wallpaper interval"), I18n.tr("10 seconds", "wallpaper interval"), I18n.tr("15 seconds", "wallpaper interval"), I18n.tr("20 seconds", "wallpaper interval"), I18n.tr("25 seconds", "wallpaper interval"), I18n.tr("30 seconds", "wallpaper interval"), I18n.tr("35 seconds", "wallpaper interval"), I18n.tr("40 seconds", "wallpaper interval"), I18n.tr("45 seconds", "wallpaper interval"), I18n.tr("50 seconds", "wallpaper interval"), I18n.tr("55 seconds", "wallpaper interval"), I18n.tr("1 minute", "wallpaper interval"), I18n.tr("5 minutes", "wallpaper interval"), I18n.tr("15 minutes", "wallpaper interval"), I18n.tr("30 minutes", "wallpaper interval"), I18n.tr("1 hour", "wallpaper interval"), I18n.tr("1 hour 30 minutes", "wallpaper interval"), I18n.tr("2 hours", "wallpaper interval"), I18n.tr("3 hours", "wallpaper interval"), I18n.tr("4 hours", "wallpaper interval"), I18n.tr("6 hours", "wallpaper interval"), I18n.tr("8 hours", "wallpaper interval"), I18n.tr("12 hours", "wallpaper interval")] property var intervalOptions: [I18n.tr("5 seconds", "wallpaper interval"), I18n.tr("10 seconds", "wallpaper interval"), I18n.tr("15 seconds", "wallpaper interval"), I18n.tr("20 seconds", "wallpaper interval"), I18n.tr("25 seconds", "wallpaper interval"), I18n.tr("30 seconds", "wallpaper interval"), I18n.tr("35 seconds", "wallpaper interval"), I18n.tr("40 seconds", "wallpaper interval"), I18n.tr("45 seconds", "wallpaper interval"), I18n.tr("50 seconds", "wallpaper interval"), I18n.tr("55 seconds", "wallpaper interval"), I18n.tr("1 minute", "wallpaper interval"), I18n.tr("5 minutes", "wallpaper interval"), I18n.tr("15 minutes", "wallpaper interval"), I18n.tr("30 minutes", "wallpaper interval"), I18n.tr("1 hour", "wallpaper interval"), I18n.tr("1 hour 30 minutes", "wallpaper interval"), I18n.tr("2 hours", "wallpaper interval"), I18n.tr("3 hours", "wallpaper interval"), I18n.tr("4 hours", "wallpaper interval"), I18n.tr("6 hours", "wallpaper interval"), I18n.tr("8 hours", "wallpaper interval"), I18n.tr("12 hours", "wallpaper interval")]
@@ -3,10 +3,9 @@ import Quickshell
import qs.Common import qs.Common
import qs.Widgets import qs.Widgets
FloatingWindow { DankFloatingWindow {
id: root id: root
property bool disablePopupTransparency: true
property var allWidgets: [] property var allWidgets: []
property string targetSection: "" property string targetSection: ""
property string searchQuery: "" property string searchQuery: ""
@@ -15,10 +14,10 @@ FloatingWindow {
property bool keyboardNavigationActive: false property bool keyboardNavigationActive: false
property var parentModal: null property var parentModal: null
parentWindow: parentModal parentWindow: parentModal
readonly property bool blurActive: Theme.blurForegroundLayers || Theme.transparentBlurLayers readonly property bool blurActive: Theme.blurLayersActive
readonly property real surfaceAlpha: blurActive ? Math.min(Theme.popupTransparency, Theme.transparentBlurLayers ? 0.36 : 0.78) : 1.0 readonly property bool floatingForegroundLayers: Theme.floatingWindowForegroundLayers
readonly property real fieldAlpha: blurActive ? Math.min(Theme.popupTransparency, Theme.transparentBlurLayers ? 0.18 : 0.62) : 1.0 readonly property bool transparentBlurLayers: Theme.blurLayersActive && !floatingForegroundLayers
readonly property real rowAlpha: blurActive ? Math.min(Theme.popupTransparency, Theme.transparentBlurLayers ? 0.12 : 0.52) : 0.30 readonly property real rowAlpha: blurActive ? Math.min(Theme.floatingWindowTransparency, transparentBlurLayers ? 0.12 : 0.52) : 0.30
signal widgetSelected(string widgetId, string targetSection) signal widgetSelected(string widgetId, string targetSection)
@@ -112,7 +111,6 @@ FloatingWindow {
minimumSize: Qt.size(400, 350) minimumSize: Qt.size(400, 350)
implicitWidth: 500 implicitWidth: 500
implicitHeight: 550 implicitHeight: 550
color: blurActive ? Theme.withAlpha(Theme.surfaceContainer, 0) : Theme.surfaceContainer
visible: false visible: false
onClosed: hide() onClosed: hide()
@@ -139,24 +137,6 @@ FloatingWindow {
}); });
} }
WindowBlur {
targetWindow: root
blurX: 0
blurY: 0
blurWidth: root.visible ? root.width : 0
blurHeight: root.visible ? root.height : 0
blurRadius: Theme.cornerRadius
}
Rectangle {
anchors.fill: parent
radius: Theme.cornerRadius
color: Theme.withAlpha(Theme.surfaceContainer, root.surfaceAlpha)
border.color: root.blurActive ? Theme.outlineMedium : Theme.withAlpha(Theme.outlineMedium, 0)
border.width: root.blurActive ? Theme.layerOutlineWidth : 0
antialiasing: true
}
FocusScope { FocusScope {
id: widgetKeyHandler id: widgetKeyHandler
@@ -294,10 +274,6 @@ FloatingWindow {
id: searchField id: searchField
width: parent.width width: parent.width
height: 48 height: 48
cornerRadius: Theme.cornerRadius
backgroundColor: Theme.withAlpha(Theme.surfaceContainerHigh, root.fieldAlpha)
normalBorderColor: Theme.outlineMedium
focusedBorderColor: Theme.primary
leftIconName: "search" leftIconName: "search"
leftIconSize: Theme.iconSize leftIconSize: Theme.iconSize
leftIconColor: Theme.surfaceVariantText leftIconColor: Theme.surfaceVariantText
@@ -1,13 +1,11 @@
import QtQuick import QtQuick
import Quickshell import Quickshell
import qs.Common import qs.Common
import qs.Services
import qs.Widgets import qs.Widgets
FloatingWindow { DankFloatingWindow {
id: root id: root
property bool disablePopupTransparency: true
property string searchQuery: "" property string searchQuery: ""
property var filteredApps: [] property var filteredApps: []
property int selectedIndex: -1 property int selectedIndex: -1
@@ -23,28 +21,10 @@ FloatingWindow {
minimumSize: Qt.size(400, 350) minimumSize: Qt.size(400, 350)
implicitWidth: 500 implicitWidth: 500
implicitHeight: 550 implicitHeight: 550
color: "transparent"
visible: false visible: false
onClosed: hide() onClosed: hide()
WindowBlur {
targetWindow: root
blurX: 0
blurY: 0
blurWidth: root.visible ? root.width : 0
blurHeight: root.visible ? root.height : 0
blurRadius: Theme.cornerRadius
}
Rectangle {
anchors.fill: parent
radius: Theme.cornerRadius
color: Theme.withAlpha(Theme.surfaceContainer, 0.95)
border.color: Theme.outlineMedium
border.width: Theme.layerOutlineWidth
}
FocusScope { FocusScope {
anchors.fill: parent anchors.fill: parent
focus: true focus: true
@@ -143,10 +123,6 @@ FloatingWindow {
id: searchField id: searchField
width: parent.width width: parent.width
height: 48 height: 48
cornerRadius: Theme.cornerRadius
backgroundColor: Theme.withAlpha(Theme.surfaceContainerHigh, 0.8)
normalBorderColor: Theme.outlineMedium
focusedBorderColor: Theme.primary
leftIconName: "search" leftIconName: "search"
leftIconSize: Theme.iconSize leftIconSize: Theme.iconSize
leftIconColor: Theme.surfaceVariantText leftIconColor: Theme.surfaceVariantText
@@ -122,7 +122,6 @@ Rectangle {
buttonSize: 36 buttonSize: 36
iconName: "restart_alt" iconName: "restart_alt"
iconSize: 20 iconSize: 20
backgroundColor: Theme.surfaceContainerHigh
iconColor: Theme.surfaceVariantText iconColor: Theme.surfaceVariantText
tooltipText: I18n.tr("Reset to default name") tooltipText: I18n.tr("Reset to default name")
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
@@ -137,7 +136,6 @@ Rectangle {
buttonSize: 36 buttonSize: 36
iconName: root.isHidden ? "visibility" : "visibility_off" iconName: root.isHidden ? "visibility" : "visibility_off"
iconSize: 20 iconSize: 20
backgroundColor: Theme.surfaceContainerHigh
iconColor: root.isHidden ? Theme.primary : Theme.surfaceVariantText iconColor: root.isHidden ? Theme.primary : Theme.surfaceVariantText
tooltipText: root.isHidden ? I18n.tr("Show device") : I18n.tr("Hide device") tooltipText: root.isHidden ? I18n.tr("Show device") : I18n.tr("Hide device")
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
@@ -0,0 +1,156 @@
import QtCore
import QtQuick
import qs.Common
import qs.Services
import qs.Widgets
import "../../../Common/ConfigIncludeResolve.js" as ConfigIncludeResolve
StyledRect {
id: root
property var includeStatus: ({
"exists": false,
"included": false,
"configFormat": "",
"readOnly": false
})
property bool checking: false
property bool fixing: false
readonly property bool showSetup: !includeStatus.included
function getInputConfigPaths() {
if (CompositorService.compositor !== "niri")
return null;
const configDir = Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation));
return {
"configFile": configDir + "/niri/config.kdl",
"layoutFile": configDir + "/niri/dms/input.kdl",
"grepPattern": 'include.*"dms/input.kdl"',
"includeLine": 'include "dms/input.kdl"'
};
}
function checkIncludeStatus() {
if (CompositorService.compositor !== "niri") {
includeStatus = {
"exists": false,
"included": false,
"configFormat": "",
"readOnly": false
};
return;
}
checking = true;
Proc.runCommand("check-input-include", [Proc.dmsBin, "config", "resolve-include", "niri", "input.kdl"], (output, exitCode) => {
checking = false;
if (exitCode !== 0) {
includeStatus = {
"exists": false,
"included": false,
"configFormat": "",
"readOnly": false
};
return;
}
try {
includeStatus = JSON.parse(output.trim());
} catch (e) {
includeStatus = {
"exists": false,
"included": false,
"configFormat": "",
"readOnly": false
};
}
});
}
function fixInclude() {
const paths = getInputConfigPaths();
if (!paths)
return;
fixing = true;
const unixTime = Math.floor(Date.now() / 1000);
const backupFile = paths.configFile + ".backup" + unixTime;
const script = ConfigIncludeResolve.buildRepairScript({
configFile: paths.configFile,
backupFile: backupFile,
fragmentFile: paths.layoutFile,
grepPattern: paths.grepPattern,
includeLine: paths.includeLine
});
Proc.runCommand("fix-input-include", ["sh", "-c", script], (output, exitCode) => {
fixing = false;
if (exitCode !== 0)
return;
checkIncludeStatus();
SettingsData.updateCompositorInput();
});
}
Component.onCompleted: {
if (CompositorService.isNiri) {
checkIncludeStatus();
}
}
height: warningContent.implicitHeight + Theme.spacingL * 2
radius: Theme.cornerRadius
color: showSetup ? Theme.withAlpha(Theme.primary, 0.15) : Theme.withAlpha(Theme.primary, 0)
border.color: showSetup ? Theme.withAlpha(Theme.primary, 0.3) : Theme.withAlpha(Theme.primary, 0)
border.width: 1
visible: showSetup && !checking && CompositorService.isNiri
Row {
id: warningContent
anchors.fill: parent
anchors.margins: Theme.spacingL
spacing: Theme.spacingM
DankIcon {
name: "warning"
size: Theme.iconSize
color: Theme.primary
anchors.verticalCenter: parent.verticalCenter
}
Column {
width: parent.width - Theme.iconSize - (fixButton.visible ? fixButton.width + Theme.spacingM : 0) - Theme.spacingM
spacing: Theme.spacingXS
anchors.verticalCenter: parent.verticalCenter
StyledText {
text: I18n.tr("First Time Setup")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.primary
width: parent.width
horizontalAlignment: Text.AlignLeft
}
StyledText {
text: I18n.tr("Click 'Setup' to create %1 and add include to your compositor config.").arg("dms/input")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
width: parent.width
horizontalAlignment: Text.AlignLeft
}
}
DankButton {
id: fixButton
visible: root.showSetup
text: root.fixing ? I18n.tr("Setting up...") : I18n.tr("Setup")
backgroundColor: Theme.primary
textColor: Theme.primaryText
enabled: !root.fixing
anchors.verticalCenter: parent.verticalCenter
onClicked: root.fixInclude()
}
}
}
@@ -38,7 +38,9 @@ StyledRect {
return h; return h;
} }
radius: Theme.cornerRadius radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh color: Theme.floatingWindowNestedSurface
border.color: Theme.outlineMedium
border.width: Theme.layerOutlineWidth
readonly property bool collapsed: collapsible && !expanded readonly property bool collapsed: collapsible && !expanded
readonly property bool hasHeader: root.title !== "" || root.iconName !== "" readonly property bool hasHeader: root.title !== "" || root.iconName !== ""
@@ -20,7 +20,6 @@ DankDropdown {
width: parent?.width ?? 0 width: parent?.width ?? 0
addHorizontalPadding: true addHorizontalPadding: true
usePopupTransparency: false
Rectangle { Rectangle {
anchors.fill: parent anchors.fill: parent
@@ -35,7 +35,9 @@ StyledRect {
width: parent?.width ?? 0 width: parent?.width ?? 0
height: Theme.spacingL * 2 + contentColumn.height height: Theme.spacingL * 2 + contentColumn.height
radius: Theme.cornerRadius radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh color: Theme.floatingWindowNestedSurface
border.color: Theme.outlineMedium
border.width: Theme.layerOutlineWidth
Component.onCompleted: { Component.onCompleted: {
if (!settingKey) if (!settingKey)
@@ -110,7 +112,6 @@ StyledRect {
iconName: "restart_alt" iconName: "restart_alt"
iconSize: 20 iconSize: 20
visible: root.defaultValue >= 0 && slider.value !== root.defaultValue visible: root.defaultValue >= 0 && slider.value !== root.defaultValue
backgroundColor: Theme.surfaceContainerHigh
iconColor: Theme.surfaceVariantText iconColor: Theme.surfaceVariantText
onClicked: { onClicked: {
slider.value = root.defaultValue; slider.value = root.defaultValue;
@@ -124,7 +124,6 @@ Item {
iconName: "restart_alt" iconName: "restart_alt"
iconSize: 20 iconSize: 20
visible: root.defaultValue >= 0 && slider.value !== root.defaultValue visible: root.defaultValue >= 0 && slider.value !== root.defaultValue
backgroundColor: Theme.surfaceContainerHigh
iconColor: Theme.surfaceVariantText iconColor: Theme.surfaceVariantText
onClicked: { onClicked: {
slider.value = root.defaultValue; slider.value = root.defaultValue;
@@ -29,7 +29,9 @@ StyledRect {
width: parent?.width ?? 0 width: parent?.width ?? 0
height: Theme.spacingL * 2 + mainColumn.height height: Theme.spacingL * 2 + mainColumn.height
radius: Theme.cornerRadius radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh color: Theme.floatingWindowNestedSurface
border.color: Theme.outlineMedium
border.width: Theme.layerOutlineWidth
Component.onCompleted: { Component.onCompleted: {
if (!settingKey) if (!settingKey)
@@ -27,7 +27,7 @@ Rectangle {
width: parent?.width ?? 0 width: parent?.width ?? 0
height: variantColumn.height height: variantColumn.height
radius: Theme.cornerRadius radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh color: Theme.floatingWindowNestedSurface
clip: true clip: true
Column { Column {
@@ -18,6 +18,8 @@ Column {
id: sharedTooltip id: sharedTooltip
} }
Component.onDestruction: sharedTooltip.hide()
signal itemEnabledChanged(string sectionId, string itemId, bool enabled) signal itemEnabledChanged(string sectionId, string itemId, bool enabled)
signal itemOrderChanged(string sectionId, var orderedIds) signal itemOrderChanged(string sectionId, var orderedIds)
signal addWidget(string sectionId) signal addWidget(string sectionId)
@@ -1215,6 +1217,7 @@ Column {
iconSize: 18 iconSize: 18
iconColor: Theme.error iconColor: Theme.error
onClicked: { onClicked: {
sharedTooltip.hide();
root.removeWidget(root.sectionId, index); root.removeWidget(root.sectionId, index);
} }
onEntered: { onEntered: {
+17 -21
View File
@@ -785,16 +785,24 @@ Singleton {
if (category === I18n.tr("All")) if (category === I18n.tr("All"))
return visibleApps; return visibleApps;
const pluginItems = getPluginItems(category, "");
if (pluginItems.length > 0)
return pluginItems;
return visibleApps.filter(app => { return visibleApps.filter(app => {
const appCategories = getCategoriesForApp(app); const appCategories = getCategoriesForApp(app);
return appCategories.includes(category); return appCategories.includes(category);
}); });
} }
function getPluginIdForCategory(category) {
if (typeof PluginService === "undefined")
return null;
const launchers = PluginService.getLauncherPlugins();
for (const pluginId in launchers) {
if ((launchers[pluginId].name || pluginId) === category)
return pluginId;
}
return null;
}
// Plugin launcher support functions // Plugin launcher support functions
function getPluginCategories() { function getPluginCategories() {
if (typeof PluginService === "undefined") { if (typeof PluginService === "undefined") {
@@ -814,32 +822,20 @@ Singleton {
} }
function getPluginCategoryIcon(category) { function getPluginCategoryIcon(category) {
if (typeof PluginService === "undefined") const pluginId = getPluginIdForCategory(category);
if (!pluginId)
return null; return null;
const launchers = PluginService.getLauncherPlugins(); return PluginService.getLauncherPlugins()[pluginId].icon || "extension";
for (const pluginId in launchers) {
const plugin = launchers[pluginId];
if ((plugin.name || pluginId) === category) {
return plugin.icon || "extension";
}
}
return null;
} }
function getPluginItems(category, query) { function getPluginItems(category, query) {
if (typeof PluginService === "undefined") const pluginId = getPluginIdForCategory(category);
if (!pluginId)
return []; return [];
const launchers = PluginService.getLauncherPlugins();
for (const pluginId in launchers) {
const plugin = launchers[pluginId];
if ((plugin.name || pluginId) === category) {
return getPluginItemsForPlugin(pluginId, query); return getPluginItemsForPlugin(pluginId, query);
} }
}
return [];
}
function getPluginItemsForPlugin(pluginId, query) { function getPluginItemsForPlugin(pluginId, query) {
if (typeof PluginService === "undefined") { if (typeof PluginService === "undefined") {
@@ -0,0 +1,265 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import qs.Common
Singleton {
id: root
readonly property var log: Log.scoped("AppleMusicArt")
readonly property bool enabled: SettingsData.appleMusicAnimatedArtEnabled
// file:// url of the current album's downloaded animated cover, or empty.
property string animatedArtUrl: ""
readonly property var _curlCmd: ["curl", "-sS", "--fail", "--connect-timeout", "5", "--max-time", "15", "--compressed"]
readonly property string _artDir: Paths.strip(Paths.cache) + "/applemusic-art"
property string _token: ""
// artist\nalbum (lowercased) -> file url or "" for a known miss; never refetched this session.
property var _cache: ({})
property int _serial: 0
readonly property string _artist: MprisController.activePlayer?.trackArtist || ""
readonly property string _album: MprisController.activePlayer?.trackAlbum || ""
readonly property string _cacheKey: _artist !== "" && _album !== "" ? (_artist + "\n" + _album).toLowerCase() : ""
on_CacheKeyChanged: _schedule()
onEnabledChanged: _schedule()
Component.onCompleted: {
_prune();
_schedule();
}
// Downloaded covers are a few MB each; drop anything untouched for 30 days so the
// cache can't grow without bound.
function _prune() {
Proc.runCommand(null, ["sh", "-c", 'test -d "$1" && find "$1" -type f -mtime +30 -delete', "sh", _artDir], () => {}, 50, 10000);
}
function _schedule() {
_serial++;
_debounce.stop();
if (!enabled || _cacheKey === "") {
animatedArtUrl = "";
return;
}
if (_cacheKey in _cache) {
animatedArtUrl = _cache[_cacheKey];
return;
}
animatedArtUrl = "";
_debounce.restart();
}
Timer {
id: _debounce
interval: 1500
onTriggered: root._lookup()
}
function _artPath(key) {
return _artDir + "/" + Qt.md5(key) + ".mp4";
}
// Disk cache first: a hit needs no network; touching keeps _prune from evicting active covers.
function _lookup() {
const key = _cacheKey;
const serial = _serial;
Proc.runCommand(null, ["sh", "-c", 'test -s "$1" && touch "$1"', "sh", _artPath(key)], (output, exitCode) => {
if (serial !== _serial)
return;
if (exitCode === 0) {
_store(key, serial, "file://" + _artPath(key));
return;
}
_search(key, serial);
}, 50, 5000);
}
// Bidirectional contains, so "Album" still matches "Album (Deluxe Edition)" either way round.
function _looseMatch(a, b) {
if (a === "" || b === "")
return false;
const la = a.toLowerCase();
const lb = b.toLowerCase();
return la.includes(lb) || lb.includes(la);
}
// US storefront only (search default and the catalog paths below); albums absent there become misses.
function _search(key, serial) {
const term = encodeURIComponent(_artist + " " + _album);
Proc.runCommand(null, _curlCmd.concat(["https://itunes.apple.com/search?media=music&entity=album&limit=1&term=" + term]), (output, exitCode) => {
if (serial !== _serial)
return;
if (exitCode !== 0) {
log.warn("itunes search failed");
return;
}
let result;
try {
result = JSON.parse(output).results[0] || null;
} catch (e) {
log.warn("itunes search parse failed");
return;
}
// A fuzzy first hit for a different record would cache the wrong video under this key.
if (!result || !result.collectionId || !_looseMatch(result.artistName || "", _artist) || !_looseMatch(result.collectionName || "", _album)) {
_store(key, serial, "");
return;
}
if (_token !== "") {
_fetchEditorialVideo(key, serial, result.collectionId);
return;
}
_fetchToken("https://music.apple.com/us/album/" + result.collectionId, token => {
if (serial !== _serial)
return;
_token = token;
_fetchEditorialVideo(key, serial, result.collectionId);
});
}, 50, 20000);
}
// The anonymous web-player JWT sits in the main JS bundle referenced by any album page.
function _fetchToken(pageUrl, callback) {
const script = "p=$(curl -sSfL --compressed \"$1\" | grep -oE '/assets/index~[a-zA-Z0-9]+\\.js' | head -1) && curl -sSfL --compressed \"https://music.apple.com$p\" | grep -oE '\"eyJ[A-Za-z0-9._-]+\"' | head -1 | tr -d '\"'";
Proc.runCommand(null, ["sh", "-c", script, "sh", pageUrl], (output, exitCode) => {
const token = (output || "").trim();
if (exitCode !== 0 || token === "") {
log.warn("failed to obtain web-player token");
return;
}
callback(token);
}, 50, 30000);
}
function _fetchEditorialVideo(key, serial, albumId) {
const url = "https://amp-api.music.apple.com/v1/catalog/us/albums/" + albumId + "?extend=editorialVideo";
Proc.runCommand(null, _curlCmd.concat(["-H", "Authorization: Bearer " + _token, "-H", "Origin: https://music.apple.com", url]), (output, exitCode) => {
if (serial !== _serial)
return;
if (exitCode !== 0) {
// Token may have expired; rescrape on the next lookup.
_token = "";
log.warn("editorial video lookup failed");
return;
}
let video;
try {
const ev = JSON.parse(output).data[0].attributes.editorialVideo;
video = ev ? (ev.motionDetailSquare?.video || ev.motionSquareVideo1x1?.video || "") : "";
} catch (e) {
log.warn("editorial video parse failed");
return;
}
if (video === "") {
_store(key, serial, "");
return;
}
_fetchMaster(key, serial, video);
}, 50, 20000);
}
function _fetchMaster(key, serial, m3u8Url) {
Proc.runCommand(null, _curlCmd.concat([m3u8Url]), (output, exitCode) => {
if (serial !== _serial)
return;
if (exitCode !== 0) {
log.warn("master playlist fetch failed");
return;
}
const variant = _pickVariant(output);
if (!variant) {
_store(key, serial, "");
return;
}
const variantUrl = variant.startsWith("http") ? variant : m3u8Url.slice(0, m3u8Url.lastIndexOf("/") + 1) + variant;
_fetchVariant(key, serial, variantUrl);
}, 50, 20000);
}
// Highest-bandwidth avc1 rendition at or below 768px, else the smallest one above;
// hvc1 is skipped for decoder compatibility.
function _pickVariant(master) {
const lines = master.split("\n");
let best = null;
let bestBw = -1;
let smallestOver = null;
let smallestOverW = Infinity;
for (let i = 0; i < lines.length; i++) {
const l = lines[i];
if (!l.startsWith("#EXT-X-STREAM-INF:") || l.indexOf("avc1") === -1)
continue;
const res = /RESOLUTION=(\d+)x/.exec(l);
if (!res)
continue;
let j = i + 1;
while (j < lines.length && (lines[j].startsWith("#") || lines[j].trim() === ""))
j++;
if (j >= lines.length)
continue;
const uri = lines[j].trim();
const w = parseInt(res[1], 10);
if (w > 768) {
if (w < smallestOverW) {
smallestOverW = w;
smallestOver = uri;
}
continue;
}
const bw = /AVERAGE-BANDWIDTH=(\d+)/.exec(l);
const bwv = bw ? parseInt(bw[1], 10) : 0;
if (bwv > bestBw) {
bestBw = bwv;
best = uri;
}
}
return best || smallestOver;
}
// The rendition playlist is BYTERANGE segments over one progressive mp4 (EXT-X-MAP).
function _fetchVariant(key, serial, variantUrl) {
Proc.runCommand(null, _curlCmd.concat([variantUrl]), (output, exitCode) => {
if (serial !== _serial)
return;
if (exitCode !== 0) {
log.warn("rendition playlist fetch failed");
return;
}
const m = /#EXT-X-MAP:URI="([^"]+)"/.exec(output);
if (!m) {
_store(key, serial, "");
return;
}
const mp4 = m[1].startsWith("http") ? m[1] : variantUrl.slice(0, variantUrl.lastIndexOf("/") + 1) + m[1];
_download(key, serial, mp4);
}, 50, 20000);
}
// Download once and play the local file: streaming the HLS through the ffmpeg
// backend truncates on some TLS stacks, and the cache survives restarts.
function _download(key, serial, url) {
const path = _artPath(key);
// Per-process temp then atomic rename, so a concurrent download for the same
// album (rapid track flip-flop) can't interleave writes into one file; the
// temp is removed on failure rather than stranded as a .part.
const script = 'mkdir -p "${1%/*}" && { test -s "$1" || { t="$1.$$.part"; curl -sSf --connect-timeout 5 --max-time 60 -o "$t" "$2" && mv -f "$t" "$1" || { rm -f "$t"; exit 1; }; }; }';
Proc.runCommand(null, ["sh", "-c", script, "sh", path, url], (output, exitCode) => {
if (serial !== _serial)
return;
if (exitCode !== 0) {
log.warn("artwork download failed");
return;
}
_store(key, serial, "file://" + path);
}, 50, 90000);
}
function _store(key, serial, url) {
_cache[key] = url;
if (serial === _serial)
animatedArtUrl = url;
}
}
+8 -1
View File
@@ -23,6 +23,13 @@ Singleton {
} }
} }
Connections {
target: typeof PowerProfiles !== "undefined" ? PowerProfiles : null
function onHasPerformanceProfileChanged() {
root.applyPowerProfile();
}
}
function applyPowerProfile() { function applyPowerProfile() {
if (!batteryAvailable) if (!batteryAvailable)
return; return;
@@ -32,7 +39,7 @@ Singleton {
const targetProfile = parseInt(profileValue); const targetProfile = parseInt(profileValue);
if (isNaN(targetProfile) || PowerProfiles.profile === targetProfile) if (isNaN(targetProfile) || PowerProfiles.profile === targetProfile)
return; return;
PowerProfiles.profile = targetProfile; PowerProfileWatcher.applyProfile(targetProfile);
} }
readonly property string preferredBatteryOverride: Quickshell.env("DMS_PREFERRED_BATTERY") readonly property string preferredBatteryOverride: Quickshell.env("DMS_PREFERRED_BATTERY")
+43
View File
@@ -975,6 +975,45 @@ Singleton {
compositorDetected = true; compositorDetected = true;
if (isNiri) if (isNiri)
NiriService.generateNiriBlurrule(); NiriService.generateNiriBlurrule();
Qt.callLater(applyDmsWindowFloatingRule);
}
function applyDmsWindowFloatingRule() {
if (!compositorDetected || (!isNiri && !isHyprland && !isMango))
return;
const floating = typeof SettingsData === "undefined" || (SettingsData.dmsWindowsFloating ?? true);
if (!floating) {
Proc.runCommand("dms-windowrule-float-remove", [Proc.dmsBin, "config", "windowrules", "remove", compositor, "dms-floating-windows"], (output, exitCode) => {
if (exitCode !== 0) {
log.warn("failed to remove DMS floating window rule", exitCode, output);
return;
}
if (isMango)
MangoService.reloadConfig();
});
return;
}
const ruleJson = JSON.stringify({
"id": "dms-floating-windows",
"name": "DMS Floating Windows",
"enabled": true,
"matchCriteria": {
"appId": "^com.danklinux.dms$"
},
"actions": {
"openFloating": true
}
});
Proc.runCommand("dms-windowrule-float-add", [Proc.dmsBin, "config", "windowrules", "add", compositor, ruleJson], (output, exitCode) => {
if (exitCode !== 0) {
log.warn("failed to add DMS floating window rule", exitCode, output);
return;
}
if (isNiri)
NiriService.validate();
if (isMango)
MangoService.reloadConfig();
});
} }
// Fallback when the socket owner can't be resolved (no ss, unrecognized // Fallback when the socket owner can't be resolved (no ss, unrecognized
@@ -1140,5 +1179,9 @@ Singleton {
return; return;
} }
} }
function onDmsWindowsFloatingChanged() {
root.applyDmsWindowFloatingRule();
}
} }
} }
+39
View File
@@ -18,6 +18,7 @@ Singleton {
readonly property int expectedApiVersion: 1 readonly property int expectedApiVersion: 1
property var availablePlugins: [] property var availablePlugins: []
property var installedPlugins: [] property var installedPlugins: []
property var registries: []
property var availableThemes: [] property var availableThemes: []
property var installedThemes: [] property var installedThemes: []
property bool isConnected: false property bool isConnected: false
@@ -479,6 +480,44 @@ Singleton {
}); });
} }
function listRegistries(callback) {
sendRequest("registries.list", null, response => {
if (response.result) {
registries = response.result;
}
if (callback) {
callback(response);
}
});
}
function addRegistry(name, url, callback) {
sendRequest("registries.add", {
"name": name,
"url": url
}, response => {
if (callback) {
callback(response);
}
if (!response.error) {
listRegistries();
}
});
}
function removeRegistry(name, callback) {
sendRequest("registries.remove", {
"name": name
}, response => {
if (callback) {
callback(response);
}
if (!response.error) {
listRegistries();
}
});
}
function listThemes(callback) { function listThemes(callback) {
sendRequest("themes.list", null, response => { sendRequest("themes.list", null, response => {
if (response.result) { if (response.result) {
+4
View File
@@ -1721,6 +1721,8 @@ Singleton {
return "Temperature must be between 2500K and 6000K"; return "Temperature must be between 2500K and 6000K";
const rounded = Math.round(temp / 500) * 500; const rounded = Math.round(temp / 500) * 500;
if (rounded > SessionData.nightModeHighTemperature)
return "Night temperature must not exceed the day temperature (" + SessionData.nightModeHighTemperature + "K)";
SessionData.setNightModeTemperature(rounded); SessionData.setNightModeTemperature(rounded);
if (root.nightModeEnabled) { if (root.nightModeEnabled) {
@@ -1750,6 +1752,8 @@ Singleton {
return "Temperature must be between 2500K and 6500K"; return "Temperature must be between 2500K and 6500K";
const rounded = Math.round(temp / 500) * 500; const rounded = Math.round(temp / 500) * 500;
if (rounded < SessionData.nightModeTemperature)
return "Day temperature must be at least the night temperature (" + SessionData.nightModeTemperature + "K)";
SessionData.setNightModeHighTemperature(rounded); SessionData.setNightModeHighTemperature(rounded);
if (root.nightModeEnabled && SessionData.nightModeAutoEnabled) if (root.nightModeEnabled && SessionData.nightModeAutoEnabled)
+139 -9
View File
@@ -47,6 +47,7 @@ Singleton {
// Chromium can report blank metadata between tracks // Chromium can report blank metadata between tracks
property string stableTitle: "" property string stableTitle: ""
property string stableArtist: "" property string stableArtist: ""
property string stableAlbum: ""
Connections { Connections {
target: root.activePlayer target: root.activePlayer
@@ -59,6 +60,9 @@ Singleton {
root._syncStableMeta(); root._syncStableMeta();
root._checkIdle(); root._checkIdle();
} }
function onTrackAlbumChanged() {
root._syncStableMeta();
}
function onLengthChanged() { function onLengthChanged() {
if (root.activePlayer && root.activePlayer.lengthSupported && root.activePlayer.length > 1) { if (root.activePlayer && root.activePlayer.lengthSupported && root.activePlayer.length > 1) {
root.activePlayerStableLength = root.activePlayer.length; root.activePlayerStableLength = root.activePlayer.length;
@@ -72,8 +76,10 @@ Singleton {
onActivePlayerChanged: { onActivePlayerChanged: {
activePlayerStableLength = (activePlayer && activePlayer.lengthSupported && activePlayer.length > 1) ? activePlayer.length : 0; activePlayerStableLength = (activePlayer && activePlayer.lengthSupported && activePlayer.length > 1) ? activePlayer.length : 0;
stableTitle = activePlayer?.trackTitle || ""; stableTitle = "";
stableArtist = activePlayer?.trackArtist || ""; stableArtist = "";
stableAlbum = "";
_syncStableMeta();
_checkIdle(); _checkIdle();
} }
@@ -82,14 +88,24 @@ Singleton {
if (!p) { if (!p) {
stableTitle = ""; stableTitle = "";
stableArtist = ""; stableArtist = "";
stableAlbum = "";
return; return;
} }
if (isFirefoxYoutubeHoverPreview(p)) if (isFirefoxYoutubeHoverPreview(p))
return; return;
if (p.trackTitle) const metadataPlayer = bestMetadataPlayer(p);
stableTitle = p.trackTitle; const nextTitle = displayTrackTitle(metadataPlayer);
if (p.trackArtist) const trackChanged = nextTitle && stableTitle && nextTitle.toLowerCase() !== stableTitle.toLowerCase();
stableArtist = p.trackArtist; if (trackChanged) {
stableArtist = "";
stableAlbum = "";
}
if (nextTitle)
stableTitle = nextTitle;
if (metadataPlayer.trackArtist)
stableArtist = metadataPlayer.trackArtist;
if (metadataPlayer.trackAlbum)
stableAlbum = metadataPlayer.trackAlbum;
} }
// Chromium reports stopped media w/blank metadata, resolve by checking idle status // Chromium reports stopped media w/blank metadata, resolve by checking idle status
@@ -101,6 +117,7 @@ Singleton {
return; return;
root.stableTitle = ""; root.stableTitle = "";
root.stableArtist = ""; root.stableArtist = "";
root.stableAlbum = "";
root._resolveActivePlayer(); root._resolveActivePlayer();
} }
} }
@@ -122,20 +139,133 @@ Singleton {
delegate: Connections { delegate: Connections {
required property MprisPlayer modelData required property MprisPlayer modelData
target: modelData target: modelData
ignoreUnknownSignals: true
function onIsPlayingChanged() { function onIsPlayingChanged() {
root._resolveActivePlayer();
root._syncStableMeta();
}
function onTrackTitleChanged() {
if (modelData.isPlaying) if (modelData.isPlaying)
root._resolveActivePlayer(); root._resolveActivePlayer();
root._syncStableMeta();
}
function onTrackArtistChanged() {
if (modelData.isPlaying)
root._resolveActivePlayer();
root._syncStableMeta();
}
function onTrackAlbumChanged() {
if (modelData.isPlaying)
root._resolveActivePlayer();
root._syncStableMeta();
}
function onMetadataChanged() {
if (modelData.isPlaying)
root._resolveActivePlayer();
root._syncStableMeta();
} }
} }
} }
function isIdle(player: MprisPlayer): bool { function isIdle(player: MprisPlayer): bool {
return player && player.playbackState === MprisPlaybackState.Stopped && !player.trackTitle && !player.trackArtist; return player && player.playbackState === MprisPlaybackState.Stopped;
}
// Known "<title> | <App>" suffixes stripped for matching only; display keeps the full title
readonly property var _appTitleSuffixes: ["youtube", "youtube music", "soundcloud", "spotify", "chrome", "chromium", "firefox", "brave", "vivaldi", "twitch"]
function _stripAppTitleSuffix(title: string): string {
const idx = title.lastIndexOf(" | ");
if (idx <= 0)
return title;
const suffix = title.substring(idx + 3).trim().toLowerCase();
return _appTitleSuffixes.indexOf(suffix) !== -1 ? title.substring(0, idx).trim() : title;
}
function normalizedTrackTitle(player: MprisPlayer): string {
return _stripAppTitleSuffix((player?.trackTitle || "").trim()).toLowerCase();
}
function displayTrackTitle(player: MprisPlayer): string {
return (player?.trackTitle || "").trim();
}
function normalizedTrackArtist(player: MprisPlayer): string {
return (player?.trackArtist || "").trim().toLowerCase();
}
// Artist missing on either side: fall back to URL, then album, before trusting a title-only match
function isSameTrack(first: MprisPlayer, second: MprisPlayer): bool {
const firstTitle = normalizedTrackTitle(first);
if (!firstTitle || firstTitle !== normalizedTrackTitle(second))
return false;
const firstArtist = normalizedTrackArtist(first);
const secondArtist = normalizedTrackArtist(second);
if (firstArtist && secondArtist)
return firstArtist === secondArtist;
const firstUrl = (first?.metadata?.["xesam:url"] || "").toString();
const secondUrl = (second?.metadata?.["xesam:url"] || "").toString();
if (firstUrl && secondUrl)
return firstUrl === secondUrl;
const firstAlbum = (first?.trackAlbum || "").trim().toLowerCase();
const secondAlbum = (second?.trackAlbum || "").trim().toLowerCase();
if (firstAlbum && secondAlbum)
return firstAlbum === secondAlbum;
return true;
}
function metadataQuality(player: MprisPlayer): int {
if (!player)
return -1;
let quality = player.trackArtist ? 100 : 0;
quality += player.trackTitle ? 40 : 0;
quality += player.trackAlbum ? 20 : 0;
quality += player.trackArtUrl || player.metadata?.["mpris:artUrl"] ? 10 : 0;
quality += player.metadata?.["xesam:url"] ? 5 : 0;
return quality;
}
function equivalentPlayers(player: MprisPlayer): var {
if (!player)
return [];
return availablePlayers.filter(candidate => {
return candidate.playbackState !== MprisPlaybackState.Stopped && isSameTrack(player, candidate);
});
}
function bestMetadataPlayer(player: MprisPlayer): MprisPlayer {
const equivalents = equivalentPlayers(player);
if (equivalents.length === 0)
return player;
return equivalents.reduce((best, candidate) => {
return metadataQuality(candidate) > metadataQuality(best) ? candidate : best;
}, player);
}
function _bestPlayingPlayer(): MprisPlayer {
const playing = availablePlayers.filter(player => player.isPlaying);
if (playing.length === 0)
return null;
const controllable = playing.filter(player => player.canControl);
if (activePlayer?.isPlaying) {
if (activePlayer.canControl || controllable.length === 0)
return activePlayer;
// Playing but not controllable: only a same-track controllable peer may take over
const mirror = controllable.find(player => isSameTrack(activePlayer, player));
return mirror || activePlayer;
}
if (activePlayer?.canControl && activePlayer.playbackState === MprisPlaybackState.Paused) {
const onlyEquivalentMirrors = playing.every(player => isSameTrack(activePlayer, player));
if (onlyEquivalentMirrors)
return null;
}
return controllable[0] || playing[0];
} }
function _resolveActivePlayer(): void { function _resolveActivePlayer(): void {
// A playing player always wins; otherwise keep the selection stable w/idle const playing = _bestPlayingPlayer();
const playing = availablePlayers.find(p => p.isPlaying);
if (playing) { if (playing) {
if (activePlayer !== playing) { if (activePlayer !== playing) {
activePlayer = playing; activePlayer = playing;

Some files were not shown because too many files have changed in this diff Show More