1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-08-09 06:58:29 -04:00

Compare commits

...

40 Commits

Author SHA1 Message Date
Huỳnh Thiện Lộc 83e2b89393 feat(dock): cycle grouped app windows (#3023)
(cherry picked from commit 80bad610ce)
2026-08-08 22:47:19 +00:00
bbedward 8200c72f8c fix(windowrules): preserve niri exclude clauses
fixes #2996
port 1.5

(cherry picked from commit 8e2168ecf8)
2026-08-08 17:12:41 +00:00
bbedward a34037f49c fix(hyprland): don't restore focus to a toplevel on another workspace when a popout closes
fixes #2963
port 1.5

(cherry picked from commit 0b526d9b05)
2026-08-08 14:58:42 +00:00
Huỳnh Thiện Lộc f971b6bcff feat(theme): add extract dynamic theme to JSON file (#3009)
* feat(theme): add extract current theme to JSON file

Adds an 'Extract' button in Settings > Theme Color that exports the
current theme (generic/dynamic/custom/registry) to a dual-mode dark+light
JSON file via the DMS file save dialog.

* refactor(theme): simplify extract button to single DankButton

* refactor(theme): move extract button inside dynamic theme section

* fix(theme): address review feedback on extract theme save

* refactor: utilize dankActionButton

---------

(cherry picked from commit bde4decc5d)
2026-08-08 04:32:15 +00:00
purian23 0313e96e8e fix(doctor): correctly report built-in font status
Port 1.5

(cherry picked from commit 3c817dc0d0)
2026-08-07 23:47:49 -04:00
bbedward c38b161f01 fix(geolocation): fetch IP location lazily so startup never contacts ip-api.com
fixes #3008
port 1.5

(cherry picked from commit a713485599)
2026-08-07 23:55:18 +00:00
bbedward acd82589e2 fix(displays): auto-select restores named profiles matching the connected output set
fixes #3012
port 1.5

(cherry picked from commit f16bdda969)
2026-08-07 22:57:31 +00:00
purian23 170721a1f1 fix(notifWidget): restore hand cursor on hover
Port 1.5

(cherry picked from commit a575363f89)
2026-08-07 03:45:00 +00:00
Huỳnh Thiện Lộc 7e0af52d5b fix(launcher): honor source badge setting (#3002)
(cherry picked from commit 7cb97f4462)
2026-08-06 00:42:38 -04:00
Huỳnh Thiện Lộc e1b6427929 fix(settings): hide removed widget tooltip (#3003)
(cherry picked from commit 82b1b36b1a)
2026-08-06 00:42:38 -04:00
Huỳnh Thiện Lộc 47bf6a8dfd 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.

(cherry picked from commit 9981fcf529)
2026-08-06 00:42:38 -04:00
Thomas Kroll 84ac36295f 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>
(cherry picked from commit 34626070af)
2026-08-06 00:42:38 -04:00
jack-mil ca839487de 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
(cherry picked from commit 49f968d26b)
2026-08-06 00:42:38 -04:00
bbedward dabbec1dce keybinds: fix labeling of spawn keybinds
fixes #2957

(cherry picked from commit 5bb884db57)
2026-08-06 00:42:37 -04:00
euletheia bd4d35d215 fix(BatteryService): fix typo in notification check (#2944)
(cherry picked from commit 8594a414ec)
2026-08-06 00:42:37 -04:00
euletheia cfdacd3ea3 fix(BatteryService): fix unresolved icons for notifications and dismiss alerts when plugged in (#2943)
* feat(BatteryService): dismiss sticky critical battery notifications

If critical notifications do not timeout, dismiss the battery-related
ones automatically when battery is plugged in.

* fix(BatteryService): use material icons for battery alerts

Since the previously specified icons are not resolved, instead reuse
icons from DMS first-party plugin for critical battery alerts.

(cherry picked from commit df396bfa43)
2026-08-06 00:42:37 -04:00
bbedward 2e558f2489 display config/niri: fix nested kdl blocks as being attributed to output
state

fixes #2966
port 1.5

(cherry picked from commit cffc33e14a)
2026-08-05 21:03:47 +00:00
bbedward 8535e3a1d7 gamma: validate config before write and publish state every scheduler
wakeup

fixes #2967
port 1.5

(cherry picked from commit 0c811e3417)
2026-08-05 20:57:22 +00:00
bbedward c2a7f767c2 bar: fix shadow from tinting goth corners
fixes #2975
port 1.5

(cherry picked from commit 6ad46cf2c2)
2026-08-05 02:15:57 +00:00
bbedward c9c11f8a27 powermenu: add soft reboot option for systemd systems
fixes #2986
port 1.5

(cherry picked from commit e089948225)
2026-08-04 22:05:11 +00:00
bbedward 8cffdab4d7 dank16: correct DPS lab lightness scale
fixes #2987
port 1.5

(cherry picked from commit 7f2ba56e06)
2026-08-04 21:56:09 +00:00
bbedward 0e8a4c0cc1 launcher: dispatch plugin-category items as plugin, not app
fixes #2988
port 1.5

(cherry picked from commit 365474b0d9)
2026-08-04 00:05:48 +00:00
bbedward 1db6381c78 evdev: resolve capslock state from any available keyboard
fixes #2991
port 1.5

(cherry picked from commit dc8a47644a)
2026-08-04 00:00:15 +00:00
bbedward d5c7efc861 dankinstall/gentoo: perform autounmask on install
fixes #2992
port 1.5

(cherry picked from commit 27483e68dc)
2026-08-03 23:40:50 +00:00
purian23 943ffb432c fix(battery): reliably apply configured power profiles at shell startup
Port 1.5

(cherry picked from commit 400a18a8ed)
2026-08-02 23:02:00 +00:00
purian23 7063448b80 refactor(media OSD): add previous/next UI controls
Port 1.5

(cherry picked from commit 32ddf614c3)
2026-08-02 22:10:33 +00:00
purian23 52afbc9801 refactor(mpris): consolidate equivalent-player resolution & update track matching
Port 1.5

(cherry picked from commit 19d919ed5c)
2026-08-02 22:09:37 +00:00
Rubén García 6edb985847 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

(cherry picked from commit 594a2cde19)
2026-08-02 21:34:49 +00:00
bbedward 41efe6ad15 tailscale: add missing ID
related #2969
port 1.5

(cherry picked from commit ef191babb7)
2026-07-31 13:37:43 +00:00
bbedward 4fb6a17e1b launcher: fix regex escaping launch args
fixes #2961
port 1.5

(cherry picked from commit a710d6d7cc)
2026-07-30 18:35:04 +00:00
bbedward e771e3b675 greeter: lua for hypr fallback 2026-07-30 14:31:39 -04:00
bbedward b1b7aa7aa2 Revert "dock: use required properties in dock item delegate, fixes context injection on Qt 6.8 (#2926)"
This reverts commit b8e2ce1da8.
2026-07-30 14:30:51 -04:00
bbedward c128793239 qs/socket: improve resilience of socket detection and connection
related #2369
port 1.5

(cherry picked from commit 64461c534f)
2026-07-27 17:41:17 +00:00
dms-ci[bot] 069ddab041 bump VERSION to v1.5.3 2026-07-27 00:17:35 +00:00
bbedward 45cf6ecefd sysupdate: fix gnome-terminal title
(cherry picked from commit c367153bac)
2026-07-26 19:51:46 -04:00
bbedward 7058b00091 cups: fix sub/unsub race
port 1.5

(cherry picked from commit bab2078dfd)
2026-07-26 23:43:53 +00:00
bbedward 6ba4b79039 port: record hand-ported master equivalents
(cherry picked from commit e9bc0169f6)
(cherry picked from commit 9b7d3c64fe)
(cherry picked from commit ea03fb2788)
(cherry picked from commit 0815e48465)
(cherry picked from commit 3938e60ce4)
(cherry picked from commit 3c688cfbd3)
(cherry picked from commit e63d210358)
(cherry picked from commit fa094db127)
2026-07-26 19:32:23 -04:00
bbedward b8e2ce1da8 dock: use required properties in dock item delegate, fixes context injection on Qt 6.8 (#2926)
(cherry picked from commit fa629fbf99)
2026-07-26 19:15:44 -04:00
bbedward c58d55db7d bluetooth: add soft rfkill unblock, wait for adapter to become available
instead of forfeiting subscription

fixes #2922
fixes #1537

(cherry picked from commit 77a357109a)
2026-07-26 19:15:02 -04:00
bbedward e15db00714 cc: migrate some settings to cache
port 1.5

(cherry picked from commit e9bc0169f6)
2026-07-26 21:46:22 +00:00
72 changed files with 1647 additions and 457 deletions
+164 -86
View File
@@ -449,12 +449,7 @@ func getQuickshellVersionInfo(missingFeatures bool) (string, status, string) {
func checkDMSInstallation() []checkResult {
var results []checkResult
dmsPath := ""
if err := findConfig(nil, nil); err == nil && configPath != "" {
dmsPath = configPath
} else if path, err := config.LocateDMSConfig(); err == nil {
dmsPath = path
}
dmsPath := resolveDoctorShellPath()
if dmsPath == "" {
return []checkResult{{catInstallation, "DMS Configuration", statusError, "Not found", "shell.qml not found in any config path", doctorDocsURL + "#dms-configuration"}}
@@ -1167,99 +1162,182 @@ func formatResultsPlain(results []checkResult) string {
return sb.String()
}
const (
defaultDoctorFontFamily = "Inter Variable"
defaultDoctorMonoFontFamily = "Fira Code"
)
// bundledFontRelPaths maps settings/default family names to font files shipped with
// the shell and loaded via Qt FontLoader (not registered with fontconfig).
var bundledFontRelPaths = map[string][]string{
"inter variable": {
"DankCommon/assets/fonts/inter/InterVariable.ttf",
"assets/fonts/inter/InterVariable.ttf",
},
"fira code": {
"DankCommon/assets/fonts/nerd-fonts/FiraCodeNerdFont-Regular.ttf",
"assets/fonts/nerd-fonts/FiraCodeNerdFont-Regular.ttf",
},
"firacode nerd font": {
"DankCommon/assets/fonts/nerd-fonts/FiraCodeNerdFont-Regular.ttf",
"assets/fonts/nerd-fonts/FiraCodeNerdFont-Regular.ttf",
},
}
func resolveDoctorShellPath() string {
if err := findConfig(nil, nil); err == nil && configPath != "" {
return configPath
}
if path, err := config.LocateDMSConfig(); err == nil {
return path
}
return ""
}
func findBundledFontFile(shellPath, family string) string {
if shellPath == "" {
return ""
}
relPaths, ok := bundledFontRelPaths[strings.ToLower(strings.TrimSpace(family))]
if !ok {
return ""
}
for _, rel := range relPaths {
path := filepath.Join(shellPath, rel)
if info, err := os.Stat(path); err == nil && !info.IsDir() {
return path
}
}
return ""
}
func isBundledDefaultFont(family string) bool {
_, ok := bundledFontRelPaths[strings.ToLower(strings.TrimSpace(family))]
return ok
}
func fontInFcList(name, cacheLower string) bool {
target := strings.ToLower(strings.TrimSpace(name))
if target == "" {
return false
}
for _, line := range strings.Split(cacheLower, "\n") {
for _, fam := range strings.Split(strings.TrimSpace(line), ",") {
if strings.TrimSpace(fam) == target {
return true
}
}
}
return false
}
func checkConfiguredFont(label, family, shellPath, fcCache string, fcListAvailable bool, url string) checkResult {
if bundled := findBundledFontFile(shellPath, family); bundled != "" {
details := "Bundled (Qt FontLoader)"
if doctorVerbose {
details = bundled
}
return checkResult{catFonts, label, statusOK, family, details, url}
}
if isBundledDefaultFont(family) {
if shellPath == "" {
return checkResult{
catFonts, label, statusWarn,
fmt.Sprintf("'%s' not verified", family),
"Could not locate shell config to verify bundled font files.",
url,
}
}
return checkResult{
catFonts, label, statusWarn,
fmt.Sprintf("'%s' bundled file missing", family),
"Expected font file missing from shell install. Reinstall DMS or check DankCommon assets.",
url,
}
}
if !fcListAvailable {
return checkResult{
catFonts, label, statusWarn,
fmt.Sprintf("'%s' not verified", family),
"fc-list not installed; cannot verify custom fonts in fontconfig.",
url,
}
}
if fcCache == "" {
return checkResult{
catFonts, label, statusWarn,
fmt.Sprintf("'%s' not found", family),
"Fontconfig cache is empty or unreadable. Try running 'fc-cache -fv'.",
url,
}
}
if fontInFcList(family, fcCache) {
return checkResult{catFonts, label, statusOK, family, "Available via fontconfig", url}
}
return checkResult{
catFonts, label, statusWarn,
fmt.Sprintf("'%s' not found", family),
"Font is not registered with fontconfig. Try running 'fc-cache -fv' or install the font.",
url,
}
}
func checkFonts() []checkResult {
var results []checkResult
url := doctorDocsURL + "#fonts"
configDir, err := os.UserConfigDir()
if err != nil {
return nil
}
settingsPath := filepath.Join(configDir, "DankMaterialShell", "settings.json")
fontFamily := defaultDoctorFontFamily
monoFontFamily := defaultDoctorMonoFontFamily
fontFamily := "Inter Variable"
monoFontFamily := "Fira Code"
if data, err := os.ReadFile(settingsPath); err == nil {
var settings struct {
FontFamily string `json:"fontFamily"`
MonoFontFamily string `json:"monoFontFamily"`
}
if err := json.Unmarshal(data, &settings); err == nil {
if settings.FontFamily != "" {
fontFamily = settings.FontFamily
if configDir, err := os.UserConfigDir(); err == nil {
settingsPath := filepath.Join(configDir, "DankMaterialShell", "settings.json")
if data, err := os.ReadFile(settingsPath); err == nil {
var settings struct {
FontFamily string `json:"fontFamily"`
MonoFontFamily string `json:"monoFontFamily"`
}
if settings.MonoFontFamily != "" {
monoFontFamily = settings.MonoFontFamily
}
}
}
if !utils.CommandExists("fc-list") {
results = append(results, checkResult{catFonts, "Fontconfig Tools", statusWarn, "fc-list not installed", "Cannot verify if fonts are cached.", url})
return results
}
// Retrieve font list
output, err := exec.Command("fc-list", ":", "family").Output()
if err != nil {
results = append(results, checkResult{catFonts, "Fontconfig Cache", statusError, "Failed to query font list", "Fontconfig cache query failed. Try running 'fc-cache -fv'.", url})
return results
}
outStr := string(output)
if len(strings.TrimSpace(outStr)) == 0 {
results = append(results, checkResult{catFonts, "Fontconfig Cache", statusError, "Cache is empty", "No fonts found in fontconfig cache. Try running 'fc-cache -fv'.", url})
return results
}
lowerFonts := strings.ToLower(outStr)
// Helper to check if a font exists
hasFont := func(name string) bool {
target := strings.ToLower(strings.TrimSpace(name))
if target == "" {
return false
}
for _, line := range strings.Split(lowerFonts, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Each line can have comma-separated families
families := strings.Split(line, ",")
for _, fam := range families {
if strings.TrimSpace(fam) == target {
return true
if err := json.Unmarshal(data, &settings); err == nil {
if settings.FontFamily != "" {
fontFamily = settings.FontFamily
}
if settings.MonoFontFamily != "" {
monoFontFamily = settings.MonoFontFamily
}
}
}
return false
}
// Normal Font Check
if hasFont(fontFamily) {
results = append(results, checkResult{catFonts, "Normal Font", statusOK, fontFamily, "Available", url})
} else {
results = append(results, checkResult{
catFonts, "Normal Font", statusWarn,
fmt.Sprintf("'%s' not found", fontFamily),
"Font is not registered. Try running 'fc-cache -fv' or install the font.",
url,
})
shellPath := resolveDoctorShellPath()
needFontconfig := !isBundledDefaultFont(fontFamily) || !isBundledDefaultFont(monoFontFamily)
fcListAvailable := utils.CommandExists("fc-list")
fcCache := ""
if needFontconfig {
if !fcListAvailable {
results = append(results, checkResult{catFonts, "Fontconfig Tools", statusWarn, "fc-list not installed", "Cannot verify custom fonts in fontconfig cache.", url})
} else {
output, err := exec.Command("fc-list", ":", "family").Output()
if err != nil {
results = append(results, checkResult{catFonts, "Fontconfig Cache", statusError, "Failed to query font list", "Fontconfig cache query failed. Try running 'fc-cache -fv'.", url})
} else {
fcCache = strings.ToLower(string(output))
if len(strings.TrimSpace(fcCache)) == 0 {
results = append(results, checkResult{catFonts, "Fontconfig Cache", statusError, "Cache is empty", "No fonts found in fontconfig cache. Try running 'fc-cache -fv'.", url})
}
}
}
}
// Monospace Font Check
if hasFont(monoFontFamily) {
results = append(results, checkResult{catFonts, "Monospace Font", statusOK, monoFontFamily, "Available", url})
} else {
results = append(results, checkResult{
catFonts, "Monospace Font", statusWarn,
fmt.Sprintf("'%s' not found", monoFontFamily),
"Font is not registered. Try running 'fc-cache -fv' or install the font.",
url,
})
}
results = append(results,
checkConfiguredFont("Normal Font", fontFamily, shellPath, fcCache, fcListAvailable, url),
checkConfiguredFont("Monospace Font", monoFontFamily, shellPath, fcCache, fcListAvailable, url),
)
return results
}
+22 -7
View File
@@ -313,6 +313,7 @@ func EnsureContrastDPSLstar(hexColor, hexBg string, minLc float64, isLightMode b
fg := HexToRGB(hexColor)
cf := colorful.Color{R: fg.R, G: fg.G, B: fg.B}
Lf, af, bf := cf.Lab()
Lf *= 100.0
dir := 1.0
if isLightMode {
@@ -341,6 +342,7 @@ func EnsureContrastDPSBidirectional(hexColor, hexBg string, minLc float64, isLig
fg := HexToRGB(hexColor)
cf := colorful.Color{R: fg.R, G: fg.G, B: fg.B}
origL, af, bf := cf.Lab()
origL *= 100.0
var darkerResult, lighterResult string
darkerL, lighterL := origL, origL
@@ -420,6 +422,24 @@ func blendHue(base, target, factor float64) float64 {
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 {
rgb := HexToRGB(primary)
hsv := RGBToHSV(rgb)
@@ -500,10 +520,7 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
gray7V := baseVal * 0.28
palette.Color7 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray7S, V: gray7V})), bgColor, normalTextTarget, opts))
gray8S := baseSat * 0.05
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))
palette.Color8 = NewColorInfo(DeriveDim(bgColor, hsv.H, baseSat*0.05, true))
brightRedS := math.Min(baseSat*1.0, 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)
palette.Color7 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray7S, V: gray7V})), bgColor, normalTextTarget, opts))
gray8S := baseSat * 0.15
gray8V := baseVal * 0.65
palette.Color8 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray8S, V: gray8V})), bgColor, secondaryTarget, opts))
palette.Color8 = NewColorInfo(DeriveDim(bgColor, hsv.H, baseSat*0.15, false))
brightRedS := math.Min(baseSat*0.75, 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)
}
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 {
arch := runtime.GOARCH
switch arch {
@@ -319,6 +324,7 @@ func (g *GentooDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
}
g.log("Portage tree synced successfully")
args := emergeInstallArgs(missingPkgs)
g.log(fmt.Sprintf("Installing prerequisites: %s", strings.Join(missingPkgs, ", ")))
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
@@ -326,12 +332,10 @@ func (g *GentooDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
Step: fmt.Sprintf("Installing %d prerequisites...", len(missingPkgs)),
IsComplete: false,
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, ", ")),
}
args := []string{"emerge", "--ask=n", "--quiet"}
args = append(args, missingPkgs...)
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
output, err := cmd.CombinedOutput()
if err != nil {
@@ -521,8 +525,7 @@ func (g *GentooDistribution) installPortagePackages(ctx context.Context, package
}
}
args := []string{"emerge", "--ask=n", "--quiet"}
args = append(args, packageNames...)
args := emergeInstallArgs(packageNames)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
@@ -713,8 +716,7 @@ func (g *GentooDistribution) installGURUPackages(ctx context.Context, packages [
guruPackages[i] = pkg + "::guru"
}
args := []string{"emerge", "--ask=n", "--quiet"}
args = append(args, guruPackages...)
args := emergeInstallArgs(guruPackages)
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
+1 -31
View File
@@ -6,37 +6,7 @@ func NewClient() Client {
geoclueClient, err := newGeoClueClient()
if err != nil {
log.Warnf("GeoClue2 unavailable: %v", err)
return newSeededIpClient()
return newIpClient()
}
loc, _ := geoclueClient.GetLocation()
if loc.Latitude != 0 || loc.Longitude != 0 {
log.Info("Using GeoClue2 location")
return geoclueClient
}
log.Info("GeoClue2 has no fix yet, seeding with IP location")
ipLoc, err := fetchIPLocation()
if err != nil {
log.Warnf("IP location seed failed: %v", err)
return geoclueClient
}
log.Info("Seeded GeoClue2 with IP location")
geoclueClient.SeedLocation(Location{Latitude: ipLoc.Latitude, Longitude: ipLoc.Longitude})
return geoclueClient
}
func newSeededIpClient() *IpClient {
client := newIpClient()
ipLoc, err := fetchIPLocation()
if err != nil {
log.Warnf("IP location also failed: %v", err)
return client
}
log.Info("Using IP location")
client.currLocation.Latitude = ipLoc.Latitude
client.currLocation.Longitude = ipLoc.Longitude
return client
}
+22 -6
View File
@@ -34,6 +34,7 @@ const (
type GeoClueClient struct {
currLocation *Location
locationMutex sync.RWMutex
seedOnce sync.Once
dbusConn *dbus.Conn
clientPath dbus.ObjectPath
@@ -230,14 +231,29 @@ func (c *GeoClueClient) SeedLocation(loc Location) {
}
func (c *GeoClueClient) GetLocation() (Location, error) {
loc := c.currentLocation()
if loc.Latitude != 0 || loc.Longitude != 0 {
return loc, nil
}
c.seedOnce.Do(func() {
ipLoc, err := fetchIPLocation()
if err != nil {
log.Warnf("GeoClue2 has no fix, IP location seed failed: %v", err)
return
}
log.Info("Seeded GeoClue2 with IP location")
c.SeedLocation(Location{Latitude: ipLoc.Latitude, Longitude: ipLoc.Longitude})
})
return c.currentLocation(), nil
}
func (c *GeoClueClient) currentLocation() Location {
c.locationMutex.RLock()
defer c.locationMutex.RUnlock()
if c.currLocation == nil {
return Location{
Latitude: 0.0,
Longitude: 0.0,
}, nil
return Location{}
}
stateCopy := *c.currLocation
return stateCopy, nil
return *c.currLocation
}
+11 -6
View File
@@ -43,10 +43,10 @@ func NewManager() (*Manager, error) {
broker := NewSubscriptionBroker(m.broadcastPairingPrompt)
m.promptBroker = broker
adapter, err := m.findAdapter()
adapter, err := findAdapter(conn)
if err != nil {
conn.Close()
return nil, fmt.Errorf("no bluetooth adapter found: %w", err)
return nil, err
}
m.adapterPath = adapter
@@ -74,12 +74,12 @@ func NewManager() (*Manager, error) {
return m, nil
}
func (m *Manager) findAdapter() (dbus.ObjectPath, error) {
obj := m.dbusConn.Object(bluezService, dbus.ObjectPath("/"))
func findAdapter(conn *dbus.Conn) (dbus.ObjectPath, error) {
obj := conn.Object(bluezService, dbus.ObjectPath("/"))
var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
if err := obj.Call(objectMgrIface+".GetManagedObjects", 0).Store(&objects); err != nil {
return "", err
return "", fmt.Errorf("%w: %v", ErrNoAdapter, err)
}
for path, interfaces := range objects {
@@ -89,7 +89,7 @@ func (m *Manager) findAdapter() (dbus.ObjectPath, error) {
}
}
return "", fmt.Errorf("no adapter found")
return "", ErrNoAdapter
}
func (m *Manager) initialize() error {
@@ -487,6 +487,11 @@ func (m *Manager) StopDiscovery() error {
}
func (m *Manager) SetPowered(powered bool) error {
if powered {
if err := rfkillUnblockBluetooth(); err != nil {
log.Debugf("[BluezManager] rfkill unblock failed: %v", err)
}
}
obj := m.dbusConn.Object(bluezService, m.adapterPath)
return obj.Call(propertiesIface+".Set", 0, adapter1Iface, "Powered", dbus.MakeVariant(powered)).Err
}
@@ -0,0 +1,5 @@
package bluez
func rfkillUnblockBluetooth() error {
return nil
}
@@ -0,0 +1,19 @@
package bluez
import "os"
// linux/rfkill.h: struct rfkill_event { __u32 idx; __u8 type; __u8 op; __u8 soft; __u8 hard; },
// RFKILL_TYPE_BLUETOOTH=2, RFKILL_OP_CHANGE_ALL=3
func rfkillUnblockBluetooth() error {
f, err := os.OpenFile("/dev/rfkill", os.O_WRONLY, 0)
if err != nil {
return err
}
defer f.Close()
var event [8]byte
event[4] = 2
event[5] = 3
_, err = f.Write(event[:])
return err
}
+46
View File
@@ -0,0 +1,46 @@
package bluez
import (
"errors"
"fmt"
"github.com/godbus/dbus/v5"
)
var ErrNoAdapter = errors.New("no bluetooth adapter found")
func WaitForAdapter() error {
conn, err := dbus.ConnectSystemBus()
if err != nil {
return err
}
defer conn.Close()
if err := conn.AddMatchSignal(
dbus.WithMatchInterface(objectMgrIface),
dbus.WithMatchMember("InterfacesAdded"),
); err != nil {
return err
}
signals := make(chan *dbus.Signal, 64)
conn.Signal(signals)
if _, err := findAdapter(conn); err == nil {
return nil
}
for sig := range signals {
if sig == nil || sig.Name != objectMgrIface+".InterfacesAdded" || len(sig.Body) < 2 {
continue
}
ifaces, ok := sig.Body[1].(map[string]map[string]dbus.Variant)
if !ok {
continue
}
if _, ok := ifaces[adapter1Iface]; ok {
return nil
}
}
return fmt.Errorf("dbus signal stream closed")
}
+23 -10
View File
@@ -229,6 +229,9 @@ func (m *Manager) snapshotState() CUPSState {
func (m *Manager) Subscribe(id string) chan CUPSState {
ch := make(chan CUPSState, 64)
m.subLifecycleMu.Lock()
defer m.subLifecycleMu.Unlock()
wasEmpty := true
m.subscribers.Range(func(key string, ch chan CUPSState) bool {
wasEmpty = false
@@ -237,19 +240,25 @@ func (m *Manager) Subscribe(id string) chan CUPSState {
m.subscribers.Store(id, ch)
if wasEmpty && m.subscription != nil {
if err := m.subscription.Start(); err != nil {
log.Warnf("[CUPS] Failed to start subscription manager: %v", err)
} else {
m.eventWG.Add(1)
go m.eventHandler()
}
if !wasEmpty || m.subscription == nil {
return ch
}
if err := m.subscription.Start(); err != nil {
log.Warnf("[CUPS] Failed to start subscription manager: %v", err)
return ch
}
m.eventWG.Add(1)
go m.eventHandler()
return ch
}
func (m *Manager) Unsubscribe(id string) {
m.subLifecycleMu.Lock()
defer m.subLifecycleMu.Unlock()
if val, ok := m.subscribers.LoadAndDelete(id); ok {
close(val)
}
@@ -260,18 +269,22 @@ func (m *Manager) Unsubscribe(id string) {
return false
})
if isEmpty && m.subscription != nil {
m.subscription.Stop()
m.eventWG.Wait()
if !isEmpty || m.subscription == nil {
return
}
m.subscription.Stop()
m.eventWG.Wait()
}
func (m *Manager) Close() {
close(m.stopChan)
m.subLifecycleMu.Lock()
if m.subscription != nil {
m.subscription.Stop()
}
m.subLifecycleMu.Unlock()
m.eventWG.Wait()
m.notifierWg.Wait()
+64
View File
@@ -1,6 +1,9 @@
package cups
import (
"errors"
"fmt"
"sync"
"testing"
mocks_cups "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/cups"
@@ -75,6 +78,67 @@ func TestManager_Subscribe(t *testing.T) {
assert.Equal(t, 0, count)
}
// mirrors the real managers: eventChan guarded by mu, conn/running deliberately
// unsynchronized so overlapping Start/Stop trips the race detector
type stubSubscription struct {
mu sync.Mutex
events chan SubscriptionEvent
conn *int
running bool
}
func (s *stubSubscription) Start() error {
if s.running {
return errors.New("already running")
}
s.running = true
s.mu.Lock()
s.events = make(chan SubscriptionEvent)
s.mu.Unlock()
v := 0
s.conn = &v
*s.conn++
return nil
}
func (s *stubSubscription) Stop() {
if !s.running {
return
}
s.running = false
s.conn = nil
s.mu.Lock()
close(s.events)
s.mu.Unlock()
}
func (s *stubSubscription) Events() <-chan SubscriptionEvent {
s.mu.Lock()
defer s.mu.Unlock()
return s.events
}
func TestManager_SubscribeUnsubscribeRace(t *testing.T) {
m := NewTestManager(mocks_cups.NewMockCUPSClientInterface(t), nil)
m.subscription = &stubSubscription{}
var wg sync.WaitGroup
for i := range 8 {
wg.Go(func() {
id := fmt.Sprintf("client-%d", i)
for range 50 {
m.Subscribe(id)
m.Unsubscribe(id)
}
})
}
wg.Wait()
}
func TestManager_Close(t *testing.T) {
mockClient := mocks_cups.NewMockCUPSClientInterface(t)
+1
View File
@@ -79,6 +79,7 @@ type Manager struct {
client CUPSClientInterface
pkHelper PkHelper
subscription SubscriptionManagerInterface
subLifecycleMu sync.Mutex
stateMutex sync.RWMutex
subscribers syncmap.Map[string, chan CUPSState]
stopChan chan struct{}
+25 -21
View File
@@ -48,7 +48,7 @@ func NewManager() (*Manager, error) {
return nil, fmt.Errorf("failed to find keyboards: %w", err)
}
initialCapsLock := readInitialCapsLockState(devices[0])
initialCapsLock, _ := capsLockFromDevices(devices)
watcher, err := fsnotify.NewWatcher()
if err != nil {
@@ -85,14 +85,21 @@ func NewManager() (*Manager, error) {
return m, nil
}
func readInitialCapsLockState(device EvdevDevice) bool {
ledStates, err := device.State(evLedType)
if err != nil {
log.Debugf("Could not read LED state: %v", err)
return false
func capsLockFromDevices(devices []EvdevDevice) (bool, bool) {
for _, device := range devices {
if device == nil {
continue
}
ledStates, err := device.State(evLedType)
if err != nil || len(ledStates) == 0 {
continue
}
return ledStates[ledCapslockKey], true
}
return ledStates[ledCapslockKey]
return false, false
}
func findKeyboards() ([]EvdevDevice, error) {
@@ -297,25 +304,22 @@ func (m *Manager) readAndUpdateCapsLockState(deviceIndex int) {
m.devicesMutex.RUnlock()
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()
ledStates, err := device.State(evLedType)
if err != nil {
log.Warnf("Failed to read LED state: %v", err)
capsLockState, ok := capsLockFromDevices(ordered)
if !ok {
log.Debug("No LED-capable device available for caps lock state")
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)
}
+22 -4
View File
@@ -306,7 +306,7 @@ func TestNotifySubscribers(t *testing.T) {
m.Close()
}
func TestReadInitialCapsLockState(t *testing.T) {
func TestCapsLockFromDevices(t *testing.T) {
t.Run("caps lock is on", func(t *testing.T) {
mockDevice := mocks.NewMockEvdevDevice(t)
ledState := evdev.StateMap{
@@ -314,7 +314,8 @@ func TestReadInitialCapsLockState(t *testing.T) {
}
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)
})
@@ -325,7 +326,8 @@ func TestReadInitialCapsLockState(t *testing.T) {
}
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)
})
@@ -333,9 +335,25 @@ func TestReadInitialCapsLockState(t *testing.T) {
mockDevice := mocks.NewMockEvdevDevice(t)
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)
})
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) {
+20 -17
View File
@@ -8,24 +8,11 @@ import (
)
func NewManager(client geolocation.Client) (*Manager, error) {
currLocation, err := client.GetLocation()
if err != nil {
log.Warnf("Failed to get initial location: %v", err)
}
m := &Manager{
client: client,
dirty: make(chan struct{}),
stopChan: make(chan struct{}),
state: &State{
Latitude: currLocation.Latitude,
Longitude: currLocation.Longitude,
},
}
if err := m.startSignalPump(); err != nil {
return nil, err
state: &State{},
}
m.notifierWg.Add(1)
@@ -34,6 +21,22 @@ func NewManager(client geolocation.Client) (*Manager, error) {
return m, nil
}
// The geolocation client may fetch IP location on first use, so nothing
// touches it until a consumer actually asks for location data.
func (m *Manager) ensureStarted() {
m.startOnce.Do(func() {
go func() {
currLocation, err := m.client.GetLocation()
if err != nil {
log.Warnf("Failed to get initial location: %v", err)
} else {
m.handleLocationChange(currLocation)
}
m.startSignalPump()
}()
})
}
func (m *Manager) Close() {
close(m.stopChan)
m.notifierWg.Wait()
@@ -48,6 +51,7 @@ func (m *Manager) Close() {
}
func (m *Manager) Subscribe(id string) chan State {
m.ensureStarted()
ch := make(chan State, 64)
m.subscribers.Store(id, ch)
return ch
@@ -59,7 +63,7 @@ func (m *Manager) Unsubscribe(id string) {
}
}
func (m *Manager) startSignalPump() error {
func (m *Manager) startSignalPump() {
m.sigWG.Add(1)
go func() {
defer m.sigWG.Done()
@@ -80,8 +84,6 @@ func (m *Manager) startSignalPump() error {
}
}
}()
return nil
}
func (m *Manager) handleLocationChange(location geolocation.Location) {
@@ -102,6 +104,7 @@ func (m *Manager) notifySubscribers() {
}
func (m *Manager) GetState() State {
m.ensureStarted()
m.stateMutex.RLock()
defer m.stateMutex.RUnlock()
if m.state == nil {
+2 -1
View File
@@ -16,7 +16,8 @@ type Manager struct {
state *State
stateMutex sync.RWMutex
client geolocation.Client
client geolocation.Client
startOnce sync.Once
stopChan chan struct{}
sigWG sync.WaitGroup
+14 -3
View File
@@ -1731,10 +1731,21 @@ func Start(printDocs bool) error {
}()
go func() {
if err := InitializeBluezManager(); err != nil {
for {
err := InitializeBluezManager()
if err == nil {
notifyCapabilityChange()
return
}
log.Warnf("Bluez manager unavailable: %v", err)
} else {
notifyCapabilityChange()
if !errors.Is(err, bluez.ErrNoAdapter) {
return
}
if err := bluez.WaitForAdapter(); err != nil {
log.Warnf("Bluetooth adapter watch failed: %v", err)
return
}
log.Info("Bluetooth adapter appeared, initializing bluez manager")
}
}()
+2 -1
View File
@@ -143,7 +143,8 @@ func wrapInTerminal(term, title, shellCmd string, extraArgs []string) []string {
case "konsole":
argv = []string{term, "-p", "tabtitle=" + title}
case "gnome-terminal":
argv = []string{term, "--title=" + title}
// --wait: the factory process otherwise returns immediately
argv = []string{term, "--wait", "--title=" + title}
execFlag = "--"
default:
argv = []string{term}
+14 -9
View File
@@ -412,25 +412,30 @@ func (m *Manager) runCustomUpgrade(ctx context.Context, opts UpgradeOptions) {
onLine := func(line string) { m.appendLog(line) }
argv := wrapInTerminal(term, "DMS — System Update (custom)", opts.CustomCommand, opts.TerminalArgs)
if err := Run(ctx, argv, RunOptions{OnLine: onLine}); err != nil {
code := ErrCodeBackendFailed
switch {
case errors.Is(ctx.Err(), context.DeadlineExceeded):
code = ErrCodeTimeout
m.failCustomUpgrade(ErrCodeTimeout, err)
return
case errors.Is(ctx.Err(), context.Canceled):
code = ErrCodeCancelled
m.failCustomUpgrade(ErrCodeCancelled, err)
return
}
m.mu.Lock()
m.state.Phase = PhaseError
m.state.Error = &ErrorInfo{Code: code, Message: err.Error()}
m.mu.Unlock()
m.markDirty()
return
// exit status reflects the trailing `read`, not the update command
m.appendLog(fmt.Sprintf("Terminal exited early: %v", err))
}
m.finishSuccessfulUpgrade(false)
m.runRefresh(context.Background(), false)
}
func (m *Manager) failCustomUpgrade(code ErrorCode, err error) {
m.mu.Lock()
m.state.Phase = PhaseError
m.state.Error = &ErrorInfo{Code: code, Message: err.Error()}
m.mu.Unlock()
m.markDirty()
}
func (m *Manager) finishSuccessfulUpgrade(clearPackages bool) {
m.appendLog("Upgrade complete.")
@@ -233,3 +233,27 @@ func TestUpgradeBackendsFiltersFlatpakOnly(t *testing.T) {
t.Fatalf("upgradeBackends(mixed) = %#v, want dnf5 then flatpak", got)
}
}
func TestWrapInTerminal(t *testing.T) {
tests := []struct {
term string
wantPrefix []string
}{
{"kitty", []string{"kitty", "--class", "com.danklinux.dms", "-T", "Title"}},
{"gnome-terminal", []string{"gnome-terminal", "--wait", "--title=Title"}},
{"foot", []string{"foot", "--app-id=com.danklinux.dms", "--title=Title"}},
}
for _, tt := range tests {
got := wrapInTerminal(tt.term, "Title", "echo hi", nil)
if len(got) < len(tt.wantPrefix) || !reflect.DeepEqual(got[:len(tt.wantPrefix)], tt.wantPrefix) {
t.Errorf("wrapInTerminal(%q) = %#v, want prefix %#v", tt.term, got, tt.wantPrefix)
}
tail := got[len(got)-3:]
if tail[0] != "sh" || tail[1] != "-c" {
t.Errorf("wrapInTerminal(%q) tail = %#v, want [sh -c <cmd>]", tt.term, tail)
}
if !strings.Contains(tail[2], "echo hi") {
t.Errorf("wrapInTerminal(%q) command %q does not contain shell command", tt.term, tail[2])
}
}
}
+31 -20
View File
@@ -760,6 +760,9 @@ func (m *Manager) schedulerLoop() {
now := time.Now()
m.recalcSchedule(now)
// publish independent of output readiness so night status never
// presents a stale schedule while applies are blocked (#2967)
m.updateStateFromSchedule()
var waitDur time.Duration
if enabled {
@@ -1120,13 +1123,15 @@ func (m *Manager) SetTemperature(low, high int) error {
m.configMutex.Unlock()
return nil
}
m.config.LowTemp = low
m.config.HighTemp = high
err := m.config.Validate()
m.configMutex.Unlock()
if err != nil {
updated := m.config
updated.LowTemp = low
updated.HighTemp = high
if err := updated.Validate(); err != nil {
m.configMutex.Unlock()
return err
}
m.config = updated
m.configMutex.Unlock()
m.triggerUpdate()
return nil
}
@@ -1138,14 +1143,16 @@ func (m *Manager) SetLocation(lat, lon float64) error {
m.configMutex.Unlock()
return nil
}
m.config.Latitude = &lat
m.config.Longitude = &lon
m.config.UseIPLocation = false
err := m.config.Validate()
m.configMutex.Unlock()
if err != nil {
updated := m.config
updated.Latitude = &lat
updated.Longitude = &lon
updated.UseIPLocation = false
if err := updated.Validate(); err != nil {
m.configMutex.Unlock()
return err
}
m.config = updated
m.configMutex.Unlock()
m.triggerUpdate()
return nil
}
@@ -1180,13 +1187,15 @@ func (m *Manager) SetManualTimes(sunrise, sunset time.Time) error {
m.configMutex.Unlock()
return nil
}
m.config.ManualSunrise = &sunrise
m.config.ManualSunset = &sunset
err := m.config.Validate()
m.configMutex.Unlock()
if err != nil {
updated := m.config
updated.ManualSunrise = &sunrise
updated.ManualSunset = &sunset
if err := updated.Validate(); err != nil {
m.configMutex.Unlock()
return err
}
m.config = updated
m.configMutex.Unlock()
m.triggerUpdate()
return nil
}
@@ -1209,12 +1218,14 @@ func (m *Manager) SetGamma(gamma float64) error {
m.configMutex.Unlock()
return nil
}
m.config.Gamma = gamma
err := m.config.Validate()
m.configMutex.Unlock()
if err != nil {
updated := m.config
updated.Gamma = gamma
if err := updated.Validate(); err != nil {
m.configMutex.Unlock()
return err
}
m.config = updated
m.configMutex.Unlock()
m.triggerUpdate()
return nil
}
@@ -9,6 +9,7 @@ import (
"github.com/stretchr/testify/assert"
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) {
@@ -412,3 +413,75 @@ func TestNewManager_InvalidConfig(t *testing.T) {
_, err := NewManager(mockDisplay, config)
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)
}
@@ -37,6 +37,7 @@ type NiriWindowRule struct {
MatchIsUrgent *bool
MatchAtStartup *bool
Matches []NiriMatch
Excludes []NiriMatch
Opacity *float64
OpenFloating *bool
OpenMaximized *bool
@@ -212,6 +213,8 @@ func (p *NiriRulesParser) parseWindowRuleNode(node *document.Node) {
switch childName {
case "match":
rule.Matches = append(rule.Matches, p.parseMatchNode(child))
case "exclude":
rule.Excludes = append(rule.Excludes, p.parseMatchNode(child))
case "opacity":
if len(child.Arguments) > 0 {
val := child.Arguments[0].ResolvedValue()
@@ -597,7 +600,8 @@ func ConvertNiriRulesToWindowRules(niriRules []NiriWindowRule) []windowrules.Win
IsUrgent: nr.MatchIsUrgent,
AtStartup: nr.MatchAtStartup,
},
Matches: convertNiriMatches(nr.Matches),
Matches: convertNiriMatches(nr.Matches),
Excludes: convertNiriMatches(nr.Excludes),
Actions: windowrules.Actions{
Opacity: nr.Opacity,
OpenFloating: nr.OpenFloating,
@@ -810,7 +814,8 @@ func (p *NiriWritableProvider) LoadDMSRules() ([]windowrules.WindowRule, error)
IsUrgent: nr.MatchIsUrgent,
AtStartup: nr.MatchAtStartup,
},
Matches: convertNiriMatches(nr.Matches),
Matches: convertNiriMatches(nr.Matches),
Excludes: convertNiriMatches(nr.Excludes),
Actions: windowrules.Actions{
Opacity: nr.Opacity,
OpenFloating: nr.OpenFloating,
@@ -874,7 +879,7 @@ func (p *NiriWritableProvider) writeDMSRules(rules []windowrules.WindowRule) err
return os.WriteFile(rulesPath, []byte(strings.Join(lines, "\n")), 0644)
}
func formatNiriMatchLine(m windowrules.MatchCriteria) (string, bool) {
func formatNiriMatchLine(keyword string, m windowrules.MatchCriteria) (string, bool) {
var matchProps []string
if m.AppID != "" {
matchProps = append(matchProps, fmt.Sprintf("app-id=%q", m.AppID))
@@ -906,7 +911,7 @@ func formatNiriMatchLine(m windowrules.MatchCriteria) (string, bool) {
if len(matchProps) == 0 {
return "", false
}
return " match " + strings.Join(matchProps, " "), true
return " " + keyword + " " + strings.Join(matchProps, " "), true
}
func (p *NiriWritableProvider) formatRule(rule windowrules.WindowRule) string {
@@ -919,7 +924,12 @@ func (p *NiriWritableProvider) formatRule(rule windowrules.WindowRule) string {
matches = []windowrules.MatchCriteria{rule.MatchCriteria}
}
for _, m := range matches {
if line, ok := formatNiriMatchLine(m); ok {
if line, ok := formatNiriMatchLine("match", m); ok {
lines = append(lines, line)
}
}
for _, m := range rule.Excludes {
if line, ok := formatNiriMatchLine("exclude", m); ok {
lines = append(lines, line)
}
}
@@ -333,3 +333,56 @@ window-rule {
t.Error("DMSStatus.Exists should be false when dms rules file doesn't exist")
}
}
func TestNiriExcludesSurviveEditOfOtherRule(t *testing.T) {
tmpDir := t.TempDir()
provider := NewNiriWritableProvider(tmpDir)
dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0755); err != nil {
t.Fatal(err)
}
existing := `// @id=thunderbird @name=Thunderbird
window-rule {
match app-id="^org.mozilla.Thunderbird$"
open-floating true
exclude title="^Mozilla Thunderbird$"
exclude title="^Verfassen:.*"
}
// @id=firefox @name=Firefox
window-rule {
match app-id="^firefox$"
open-floating true
}
`
if err := os.WriteFile(filepath.Join(dmsDir, "windowrules.kdl"), []byte(existing), 0644); err != nil {
t.Fatal(err)
}
edited := newTestWindowRule("firefox", "Firefox", "^firefox$")
edited.Actions.OpenFloating = boolPtr(false)
if err := provider.SetRule(edited); err != nil {
t.Fatalf("SetRule failed: %v", err)
}
rules, err := provider.LoadDMSRules()
if err != nil {
t.Fatalf("LoadDMSRules failed: %v", err)
}
if len(rules) != 2 {
t.Fatalf("expected 2 rules, got %d", len(rules))
}
if len(rules[0].Excludes) != 2 {
t.Fatalf("expected 2 excludes on untouched rule, got %d", len(rules[0].Excludes))
}
if rules[0].Excludes[0].Title != "^Mozilla Thunderbird$" {
t.Errorf("Excludes[0].Title = %q", rules[0].Excludes[0].Title)
}
if rules[0].Excludes[1].Title != "^Verfassen:.*" {
t.Errorf("Excludes[1].Title = %q", rules[0].Excludes[1].Title)
}
if rules[1].Actions.OpenFloating == nil || *rules[1].Actions.OpenFloating {
t.Error("edited rule should have open-floating false")
}
}
+1
View File
@@ -77,6 +77,7 @@ type WindowRule struct {
Enabled bool `json:"enabled"`
MatchCriteria MatchCriteria `json:"matchCriteria"`
Matches []MatchCriteria `json:"matches,omitempty"`
Excludes []MatchCriteria `json:"excludes,omitempty"`
Actions Actions `json:"actions"`
Source string `json:"source,omitempty"`
}
+64 -6
View File
@@ -11,7 +11,7 @@ Singleton {
id: root
readonly property var log: Log.scoped("CacheData")
readonly property int cacheConfigVersion: 1
readonly property int cacheConfigVersion: 2
readonly property bool isGreeterMode: Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true"
@@ -19,10 +19,21 @@ Singleton {
readonly property string _stateDir: Paths.strip(_stateUrl)
property bool _loading: false
property bool _hasLoaded: false
property int _loadedCacheVersion: 0
readonly property var _pinKeys: ["brightnessDevicePins", "wifiNetworkPins", "bluetoothDevicePins", "audioInputDevicePins", "audioOutputDevicePins"]
readonly property var _dataKeys: ["wallpaperLastPath", "profileLastPath", "fileBrowserSettings"].concat(_pinKeys)
property string wallpaperLastPath: ""
property string profileLastPath: ""
property var brightnessDevicePins: ({})
property var wifiNetworkPins: ({})
property var bluetoothDevicePins: ({})
property var audioInputDevicePins: ({})
property var audioOutputDevicePins: ({})
property var fileBrowserSettings: ({
"wallpaper": {
"lastPath": "",
@@ -82,8 +93,46 @@ Singleton {
function loadCache() {
_loading = true;
parseCache(cacheFile.text());
_loading = false;
try {
parseCache(cacheFile.text());
} finally {
_loading = false;
_hasLoaded = true;
}
}
function set(key, value) {
if (_dataKeys.indexOf(key) < 0) {
log.warn("Unknown cache key:", key);
return;
}
root[key] = value;
saveCache();
}
function migratePins(pins) {
if (!pins)
return;
if (!_hasLoaded)
loadCache();
if (_loadedCacheVersion >= cacheConfigVersion)
return;
let migrated = false;
for (const key of _pinKeys) {
const legacy = pins[key];
if (!legacy || Object.keys(legacy).length === 0)
continue;
if (Object.keys(root[key] || {}).length > 0)
continue;
root[key] = legacy;
migrated = true;
}
if (!migrated)
return;
log.info("Migrated device pins from settings.json");
saveCache();
}
function parseCache(content) {
@@ -91,6 +140,7 @@ Singleton {
try {
if (content && content.trim()) {
const cache = JSON.parse(content);
_loadedCacheVersion = cache.configVersion || 0;
wallpaperLastPath = cache.wallpaperLastPath !== undefined ? cache.wallpaperLastPath : "";
profileLastPath = cache.profileLastPath !== undefined ? cache.profileLastPath : "";
@@ -126,6 +176,10 @@ Singleton {
};
}
for (const key of _pinKeys) {
root[key] = cache[key] !== undefined ? cache[key] : {};
}
if (cache.configVersion === undefined) {
migrateFromUndefinedToV1(cache);
cleanupUnusedKeys();
@@ -142,12 +196,16 @@ Singleton {
function saveCache() {
if (_loading)
return;
cacheFile.setText(JSON.stringify({
const data = {
"wallpaperLastPath": wallpaperLastPath,
"profileLastPath": profileLastPath,
"fileBrowserSettings": fileBrowserSettings,
"configVersion": cacheConfigVersion
}, null, 2));
};
for (const key of _pinKeys) {
data[key] = root[key];
}
cacheFile.setText(JSON.stringify(data, null, 2));
}
function migrateFromUndefinedToV1(cache) {
@@ -155,7 +213,7 @@ Singleton {
}
function cleanupUnusedKeys() {
const validKeys = ["wallpaperLastPath", "profileLastPath", "fileBrowserSettings", "configVersion"];
const validKeys = _dataKeys.concat(["configVersion"]);
try {
const content = cacheFile.text();
+11 -2
View File
@@ -15,7 +15,11 @@ Singleton {
function openModal(modal) {
PopoutManager.screenshotActive = false;
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();
Qt.callLater(() => {
if (!modal.allowStacking)
@@ -34,7 +38,12 @@ Singleton {
function closeModal(modal) {
const screenName = modal.effectiveScreen?.name ?? "unknown";
if (currentModalsByScreen[screenName] === modal) {
delete currentModalsByScreen[screenName];
var next = {};
for (var k in currentModalsByScreen) {
if (k !== screenName)
next[k] = currentModalsByScreen[k];
}
currentModalsByScreen = next;
modalChanged();
}
}
+4 -6
View File
@@ -15,7 +15,7 @@ Singleton {
id: root
readonly property var log: Log.scoped("SettingsData")
readonly property int settingsConfigVersion: 12
readonly property int settingsConfigVersion: 13
readonly property bool isGreeterMode: Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true"
@@ -773,11 +773,6 @@ Singleton {
property bool fadeToDpmsEnabled: true
property int fadeToDpmsGracePeriod: 5
property string launchPrefix: ""
property var brightnessDevicePins: ({})
property var wifiNetworkPins: ({})
property var bluetoothDevicePins: ({})
property var audioInputDevicePins: ({})
property var audioOutputDevicePins: ({})
property bool gtkThemingEnabled: false
property bool qtThemingEnabled: false
@@ -1741,6 +1736,7 @@ Singleton {
let obj = (txt && txt.trim()) ? JSON.parse(txt) : null;
const oldVersion = obj?.configVersion ?? 0;
const legacyPins = oldVersion < 13 ? Store.extractPins(obj) : null;
if (oldVersion < settingsConfigVersion) {
const migrated = Store.migrateToVersion(obj, settingsConfigVersion);
if (migrated) {
@@ -1748,6 +1744,8 @@ Singleton {
obj = migrated;
}
}
if (legacyPins)
Qt.callLater(() => CacheData.migratePins(legacyPins));
if (obj?.lockScreenActiveMonitor !== undefined) {
var oldVal = obj.lockScreenActiveMonitor;
+80
View File
@@ -436,6 +436,10 @@ Singleton {
function getMatugenColor(path, fallback) {
const colorMode = (typeof SessionData !== "undefined" && SessionData.isLightMode) ? "light" : "dark";
return getMatugenColorForMode(colorMode, path, fallback);
}
function getMatugenColorForMode(colorMode, path, fallback) {
let cur = matugenColors && matugenColors.colors && matugenColors.colors[colorMode];
for (const part of path.split(".")) {
if (!cur || typeof cur !== "object" || !(part in cur))
@@ -445,6 +449,82 @@ Singleton {
return cur || fallback;
}
function extractCurrentTheme(themeName) {
var name = themeName || "Extracted Theme";
var dark = {};
var light = {};
if (currentTheme === dynamic) {
dark = buildExtractedDynamicMode("dark", name + " Dark");
light = buildExtractedDynamicMode("light", name + " Light");
} else if (currentTheme === custom && customThemeRawData) {
var rawDark = customThemeRawData.dark || null;
var rawLight = customThemeRawData.light || null;
if (rawDark) {
dark = JSON.parse(JSON.stringify(rawDark));
if (!dark.name)
dark.name = name + " Dark";
} else if (rawLight) {
dark = buildExtractedDynamicMode("dark", name + " Dark");
} else {
dark = currentThemeData ? JSON.parse(JSON.stringify(currentThemeData)) : {};
dark.name = name + " Dark";
}
if (rawLight) {
light = JSON.parse(JSON.stringify(rawLight));
if (!light.name)
light.name = name + " Light";
} else if (rawDark) {
light = buildExtractedDynamicMode("light", name + " Light");
} else {
light = currentThemeData ? JSON.parse(JSON.stringify(currentThemeData)) : {};
light.name = name + " Light";
}
} else {
var darkTheme = StockThemes.getThemeByName(currentTheme, false);
var lightTheme = StockThemes.getThemeByName(currentTheme, true);
dark = darkTheme ? JSON.parse(JSON.stringify(darkTheme)) : {};
light = lightTheme ? JSON.parse(JSON.stringify(lightTheme)) : {};
dark.name = name + " Dark";
light.name = name + " Light";
}
return JSON.stringify({
dark: dark,
light: light
}, null, 2);
}
function buildExtractedDynamicMode(colorMode, name) {
return {
"name": name,
"primary": getMatugenColorForMode(colorMode, "primary", "#42a5f5"),
"primaryText": getMatugenColorForMode(colorMode, "on_primary", "#ffffff"),
"primaryContainer": getMatugenColorForMode(colorMode, "primary_container", "#1976d2"),
"secondary": getMatugenColorForMode(colorMode, "secondary", "#8ab4f8"),
"secondaryContainer": getMatugenColorForMode(colorMode, "secondary_container", getMatugenColorForMode(colorMode, "surface_container_high", "#292b2f")),
"tertiary": getMatugenColorForMode(colorMode, "tertiary", "#efb8c8"),
"tertiaryContainer": getMatugenColorForMode(colorMode, "tertiary_container", getMatugenColorForMode(colorMode, "surface_container_high", "#292b2f")),
"surface": getMatugenColorForMode(colorMode, "surface", "#1a1c1e"),
"surfaceText": getMatugenColorForMode(colorMode, "on_background", "#e3e8ef"),
"surfaceVariant": getMatugenColorForMode(colorMode, "surface_variant", "#44464f"),
"surfaceVariantText": getMatugenColorForMode(colorMode, "on_surface_variant", "#c4c7c5"),
"surfaceTint": getMatugenColorForMode(colorMode, "surface_tint", "#8ab4f8"),
"background": getMatugenColorForMode(colorMode, "background", "#1a1c1e"),
"backgroundText": getMatugenColorForMode(colorMode, "on_background", "#e3e8ef"),
"outline": getMatugenColorForMode(colorMode, "outline", "#8e918f"),
"surfaceContainerLowest": getMatugenColorForMode(colorMode, "surface_container_lowest", "#0e1013"),
"surfaceContainerLow": getMatugenColorForMode(colorMode, "surface_container_low", "#181a1d"),
"surfaceContainer": getMatugenColorForMode(colorMode, "surface_container", "#1e2023"),
"surfaceContainerHigh": getMatugenColorForMode(colorMode, "surface_container_high", "#292b2f"),
"surfaceContainerHighest": getMatugenColorForMode(colorMode, "surface_container_highest", "#343740"),
"error": getMatugenColorForMode(colorMode, "error", "#F2B8B5"),
"warning": "#FF9800",
"info": "#2196F3",
"success": "#4CAF50"
};
}
readonly property var currentThemeData: {
if (currentTheme === "custom") {
return customThemeData || StockThemes.getThemeByName("purple", isLightMode);
+12
View File
@@ -22,6 +22,18 @@ Singleton {
activeTrayMenus = newMenus
}
function closeHoverMenus() {
for (const screenName in activeTrayMenus) {
const menu = activeTrayMenus[screenName]
if (!menu || menu.openedByHover !== true) continue
if (typeof menu.close === "function") {
menu.close()
} else if (menu.showMenu !== undefined) {
menu.showMenu = false
}
}
}
function closeAllMenus() {
for (const screenName in activeTrayMenus) {
const menu = activeTrayMenus[screenName]
@@ -354,11 +354,6 @@ var SPEC = {
fadeToDpmsEnabled: { def: true },
fadeToDpmsGracePeriod: { def: 5 },
launchPrefix: { def: "" },
brightnessDevicePins: { def: {} },
wifiNetworkPins: { def: {} },
bluetoothDevicePins: { def: {} },
audioInputDevicePins: { def: {} },
audioOutputDevicePins: { def: {} },
gtkThemingEnabled: { def: false, onChange: "regenSystemThemes" },
qtThemingEnabled: { def: false, onChange: "regenSystemThemes" },
@@ -2,6 +2,21 @@
.import "./SettingsSpec.js" as SpecModule
var PIN_KEYS = ["brightnessDevicePins", "wifiNetworkPins", "bluetoothDevicePins", "audioInputDevicePins", "audioOutputDevicePins"];
function extractPins(obj) {
if (!obj) return null;
var pins = null;
for (var i = 0; i < PIN_KEYS.length; i++) {
var value = obj[PIN_KEYS[i]];
if (!value || Object.keys(value).length === 0) continue;
if (!pins) pins = {};
pins[PIN_KEYS[i]] = value;
}
return pins;
}
function parse(root, jsonObj) {
var SPEC = SpecModule.SPEC;
@@ -263,6 +278,17 @@ function migrateToVersion(obj, targetVersion) {
settings.configVersion = 12;
}
if (currentVersion < 13) {
console.info("Migrating settings from version", currentVersion, "to version 13");
console.info("Moving device and network pins to cache.json");
for (var p = 0; p < PIN_KEYS.length; p++) {
delete settings[PIN_KEYS[p]];
}
settings.configVersion = 13;
}
return settings;
}
+3
View File
@@ -982,6 +982,9 @@ Item {
case "reboot":
SessionService.reboot();
break;
case "softreboot":
SessionService.softReboot();
break;
case "poweroff":
SessionService.poweroff();
break;
+19 -11
View File
@@ -819,16 +819,24 @@ Item {
}
if (isCategoryFiltered) {
var rawApps = AppSearchService.getAppsInCategory(appCategory);
for (var i = 0; i < rawApps.length; i++) {
allItems.push(getOrTransformApp(rawApps[i]));
}
// Also include core apps (DMS Settings etc.) that match this category
var allCoreApps = AppSearchService.getCoreApps("");
for (var i = 0; i < allCoreApps.length; i++) {
var coreAppCats = AppSearchService.getCategoriesForApp(allCoreApps[i]);
if (coreAppCats.indexOf(appCategory) !== -1)
allItems.push(transformCoreApp(allCoreApps[i]));
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);
for (var i = 0; i < rawApps.length; i++) {
allItems.push(getOrTransformApp(rawApps[i]));
}
// Also include core apps (DMS Settings etc.) that match this category
var allCoreApps = AppSearchService.getCoreApps("");
for (var i = 0; i < allCoreApps.length; i++) {
var coreAppCats = AppSearchService.getCategoriesForApp(allCoreApps[i]);
if (coreAppCats.indexOf(appCategory) !== -1)
allItems.push(transformCoreApp(allCoreApps[i]));
}
}
} else {
var apps = searchApps(searchQuery);
@@ -839,7 +847,7 @@ Item {
var scoredItems = Scorer.scoreItems(allItems, searchQuery, getFrecencyForItem);
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 i = 0; i < newSections.length; i++) {
@@ -7,6 +7,7 @@ Item {
property string source: ""
property int glyphSize: 14
property bool badgeVisible: true
readonly property var sourceAsset: ({
"flatpak": "../../assets/package-sources/flatpak.svg",
@@ -17,7 +18,7 @@ Item {
readonly property string assetPath: sourceAsset[source] || ""
visible: SettingsData.dankLauncherV2ShowSourceBadges && assetPath.length > 0
visible: badgeVisible && SettingsData.dankLauncherV2ShowSourceBadges && assetPath.length > 0
implicitWidth: glyphSize
implicitHeight: glyphSize
@@ -178,7 +178,7 @@ Rectangle {
anchors.margins: Theme.spacingXS
source: root.item?.type === "app" ? (root.item.source || "") : ""
glyphSize: 16
visible: !root.isSelected && !!source
badgeVisible: !root.isSelected
}
}
}
+26 -6
View File
@@ -175,6 +175,8 @@ DankModal {
visibleActions = allActions.filter(action => {
if (action === "hibernate" && !SessionService.hibernateSupported)
return false;
if (action === "softreboot" && !SessionService.softRebootSupported)
return false;
return true;
});
@@ -223,6 +225,12 @@ DankModal {
"label": I18n.tr("Reboot"),
"key": "R"
};
case "softreboot":
return {
"icon": "autorenew",
"label": I18n.tr("Soft Reboot"),
"key": "B"
};
case "logout":
return {
"icon": "logout",
@@ -370,7 +378,7 @@ DankModal {
function handleListNavigation(event, 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();
event.accepted = true;
}
@@ -429,6 +437,12 @@ DankModal {
event.accepted = true;
}
break;
case Qt.Key_B:
if (visibleActions.includes("softreboot")) {
startHold("softreboot", visibleActions.indexOf("softreboot"));
event.accepted = true;
}
break;
case Qt.Key_X:
if (visibleActions.includes("logout")) {
startHold("logout", visibleActions.indexOf("logout"));
@@ -464,7 +478,7 @@ DankModal {
function handleGridNavigation(event, 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();
event.accepted = true;
}
@@ -539,6 +553,12 @@ DankModal {
event.accepted = true;
}
break;
case Qt.Key_B:
if (visibleActions.includes("softreboot")) {
startHold("softreboot", visibleActions.indexOf("softreboot"));
event.accepted = true;
}
break;
case Qt.Key_X:
if (visibleActions.includes("logout")) {
startHold("logout", visibleActions.indexOf("logout"));
@@ -597,7 +617,7 @@ DankModal {
readonly property var actionData: root.getActionData(modelData)
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
width: (root.modalWidth - Theme.spacingL * 2 - Theme.spacingS * (root.gridColumns - 1)) / root.gridColumns
@@ -627,7 +647,7 @@ DankModal {
color: {
if (gridButtonRect.modelData === "poweroff")
return Theme.errorSelected;
if (gridButtonRect.modelData === "reboot")
if (gridButtonRect.modelData === "reboot" || gridButtonRect.modelData === "softreboot")
return Theme.withAlpha(Theme.warning, 0.3);
return Theme.primarySelected;
}
@@ -722,7 +742,7 @@ DankModal {
readonly property var actionData: root.getActionData(modelData)
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
width: parent.width
@@ -752,7 +772,7 @@ DankModal {
color: {
if (listButtonRect.modelData === "poweroff")
return Theme.errorSelected;
if (listButtonRect.modelData === "reboot")
if (listButtonRect.modelData === "reboot" || listButtonRect.modelData === "softreboot")
return Theme.withAlpha(Theme.warning, 0.3);
return Theme.primarySelected;
}
+3
View File
@@ -457,6 +457,9 @@ FloatingWindow {
};
if (isNiri && extraMatchModel.count > 0)
ruleData.matches = matches;
// No exclude editor yet carry existing excludes through so an edit doesn't delete them (#2996)
if (isEditMode && (editingRule.excludes?.length ?? 0) > 0)
ruleData.excludes = editingRule.excludes;
submitting = true;
@@ -469,6 +469,8 @@ PluginComponent {
}
MouseArea {
id: peerMouseArea
z: -1
anchors.fill: parent
hoverEnabled: true
@@ -159,7 +159,7 @@ Rectangle {
}
function getPinnedInputs() {
const pins = SettingsData.audioInputDevicePins || {};
const pins = CacheData.audioInputDevicePins || {};
return normalizePinList(pins["preferredInput"]);
}
@@ -315,7 +315,7 @@ Rectangle {
cursorShape: Qt.PointingHandCursor
onPressed: mouse => pinRipple.trigger(mouse.x, mouse.y)
onClicked: {
const pins = JSON.parse(JSON.stringify(SettingsData.audioInputDevicePins || {}));
const pins = JSON.parse(JSON.stringify(CacheData.audioInputDevicePins || {}));
let pinnedList = audioContent.normalizePinList(pins["preferredInput"]);
const pinIndex = pinnedList.indexOf(modelData.name);
@@ -332,7 +332,7 @@ Rectangle {
else
delete pins["preferredInput"];
SettingsData.set("audioInputDevicePins", pins);
CacheData.set("audioInputDevicePins", pins);
}
}
}
@@ -169,7 +169,7 @@ Rectangle {
}
function getPinnedOutputs() {
const pins = SettingsData.audioOutputDevicePins || {};
const pins = CacheData.audioOutputDevicePins || {};
return normalizePinList(pins["preferredOutput"]);
}
@@ -324,7 +324,7 @@ Rectangle {
cursorShape: Qt.PointingHandCursor
onPressed: mouse => pinRipple.trigger(mouse.x, mouse.y)
onClicked: {
const pins = JSON.parse(JSON.stringify(SettingsData.audioOutputDevicePins || {}));
const pins = JSON.parse(JSON.stringify(CacheData.audioOutputDevicePins || {}));
let pinnedList = audioContent.normalizePinList(pins["preferredOutput"]);
const pinIndex = pinnedList.indexOf(modelData.name);
@@ -341,7 +341,7 @@ Rectangle {
else
delete pins["preferredOutput"];
SettingsData.set("audioOutputDevicePins", pins);
CacheData.set("audioOutputDevicePins", pins);
}
}
}
@@ -112,7 +112,7 @@ Rectangle {
}
function getPinnedDevices() {
const pins = SettingsData.bluetoothDevicePins || {};
const pins = CacheData.bluetoothDevicePins || {};
return normalizePinList(pins["preferredDevice"]);
}
@@ -400,7 +400,7 @@ Rectangle {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
const pins = JSON.parse(JSON.stringify(SettingsData.bluetoothDevicePins || {}));
const pins = JSON.parse(JSON.stringify(CacheData.bluetoothDevicePins || {}));
let pinnedList = root.normalizePinList(pins["preferredDevice"]);
const pinIndex = pinnedList.indexOf(pairedDelegate.modelData.address);
@@ -418,7 +418,7 @@ Rectangle {
delete pins["preferredDevice"];
}
SettingsData.set("bluetoothDevicePins", pins);
CacheData.set("bluetoothDevicePins", pins);
}
}
}
@@ -37,7 +37,7 @@ Rectangle {
const pinKey = getScreenPinKey();
if (pinKey.length > 0) {
const pins = SettingsData.brightnessDevicePins || {};
const pins = CacheData.brightnessDevicePins || {};
const pinnedDevice = pins[pinKey];
if (pinnedDevice && pinnedDevice.length > 0) {
const found = devices.find(d => d.name === pinnedDevice);
@@ -85,12 +85,12 @@ Rectangle {
}
const pinKey = getScreenPinKey();
if (pinKey.length > 0) {
const pins = SettingsData.brightnessDevicePins || {};
const pins = CacheData.brightnessDevicePins || {};
const existing = pins[pinKey];
if (existing && existing !== deviceName) {
const next = JSON.parse(JSON.stringify(pins));
delete next[pinKey];
SettingsData.set("brightnessDevicePins", next);
CacheData.set("brightnessDevicePins", next);
}
}
root.currentDeviceName = deviceName;
@@ -106,7 +106,7 @@ Rectangle {
const pinKey = getScreenPinKey();
if (!pinKey || !deviceName)
return false;
const pins = SettingsData.brightnessDevicePins || {};
const pins = CacheData.brightnessDevicePins || {};
return pins[pinKey] === deviceName;
}
@@ -114,13 +114,13 @@ Rectangle {
const pinKey = getScreenPinKey();
if (!pinKey || !deviceName)
return;
const pins = JSON.parse(JSON.stringify(SettingsData.brightnessDevicePins || {}));
const pins = JSON.parse(JSON.stringify(CacheData.brightnessDevicePins || {}));
if (pins[pinKey] === deviceName) {
delete pins[pinKey];
} else {
pins[pinKey] = deviceName;
}
SettingsData.set("brightnessDevicePins", pins);
CacheData.set("brightnessDevicePins", pins);
}
implicitHeight: {
@@ -269,7 +269,7 @@ Rectangle {
readonly property bool selected: !!(modelData && modelData.name === root.currentDeviceName)
readonly property bool devicePinnedHere: {
SettingsData.brightnessDevicePins;
CacheData.brightnessDevicePins;
return root.isDevicePinnedToScreen(modelData ? modelData.name : "");
}
@@ -49,7 +49,7 @@ Rectangle {
}
function getPinnedNetworks() {
const pins = SettingsData.wifiNetworkPins || {};
const pins = CacheData.wifiNetworkPins || {};
return normalizePinList(pins["preferredWifi"]);
}
@@ -710,7 +710,7 @@ Rectangle {
cursorShape: Qt.PointingHandCursor
onPressed: mouse => pinRipple.trigger(mouse.x, mouse.y)
onClicked: {
const pins = JSON.parse(JSON.stringify(SettingsData.wifiNetworkPins || {}));
const pins = JSON.parse(JSON.stringify(CacheData.wifiNetworkPins || {}));
let pinnedList = root.normalizePinList(pins["preferredWifi"]);
const pinIndex = pinnedList.indexOf(modelData.ssid);
@@ -727,7 +727,7 @@ Rectangle {
else
delete pins["preferredWifi"];
SettingsData.set("wifiNetworkPins", pins);
CacheData.set("wifiNetworkPins", pins);
}
}
}
@@ -34,7 +34,7 @@ Row {
if (screenName && screenName.length > 0) {
const screen = Quickshell.screens.find(s => s.name === screenName);
const pinKey = screen ? SettingsData.getScreenDisplayName(screen) : screenName;
const pins = SettingsData.brightnessDevicePins || {};
const pins = CacheData.brightnessDevicePins || {};
const pinnedDevice = pins[pinKey];
if (pinnedDevice && pinnedDevice.length > 0) {
const found = DisplayService.devices.find(dev => dev.name === pinnedDevice);
+10 -1
View File
@@ -201,7 +201,16 @@ Item {
direction: root.effectiveShadowDirection
fallbackOffset: 4
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
shadowOffsetX: root.shadowOffsetX
@@ -609,7 +609,7 @@ Item {
_closeHoverNotepad();
activeHoverTrigger = "";
PopoutManager.dismissHoverPopoutForScreen(barWindow?.screen);
TrayMenuManager.closeAllMenus();
TrayMenuManager.closeHoverMenus();
}
function _beginSupersededCloseForActive() {
@@ -203,7 +203,7 @@ BasePill {
const pinKey = getScreenPinKey();
if (!pinKey)
return "";
const pins = SettingsData.brightnessDevicePins || {};
const pins = CacheData.brightnessDevicePins || {};
return pins[pinKey] || "";
}
@@ -67,6 +67,7 @@ BasePill {
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.MiddleButton
cursorShape: Qt.PointingHandCursor
onPressed: mouse => {
root.triggerRipple(this, mouse.x, mouse.y);
SessionData.setDoNotDisturb(!SessionData.doNotDisturb);
@@ -1484,6 +1484,7 @@ BasePill {
property bool isVertical: false
property var axis: null
property bool showMenu: false
property bool openedByHover: false
property var menuHandle: null
ListModel {
@@ -1493,7 +1494,8 @@ BasePill {
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;
anchorItem = anchor;
parentScreen = screen;
@@ -2084,7 +2086,7 @@ BasePill {
}
}
function showForTrayItem(item, anchor, screen, atBottom, vertical, axisObj) {
function showForTrayItem(item, anchor, screen, atBottom, vertical, axisObj, byHover) {
if (!screen)
return;
if (currentTrayMenu) {
@@ -2099,7 +2101,7 @@ BasePill {
currentTrayMenu = trayMenuComponent.createObject(null);
if (!currentTrayMenu)
return;
currentTrayMenu.showForTrayItem(item, anchor, screen, atBottom, vertical ?? false, axisObj);
currentTrayMenu.showForTrayItem(item, anchor, screen, atBottom, vertical ?? false, axisObj, byHover === true);
}
function _trayLayoutRoot() {
@@ -2147,7 +2149,7 @@ BasePill {
if (!hit?.trayItem?.hasMenu)
return false;
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;
}
}
@@ -490,7 +490,7 @@ Item {
}
StyledText {
text: activePlayer?.trackAlbum || ""
text: MprisController.stableAlbum
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceTextSecondary
width: parent.width
+21 -3
View File
@@ -168,6 +168,25 @@ Item {
Qt.callLater(() => waylandToplevel.activate());
return true;
}
function cycleGroupedToplevels() {
const toplevels = getGroupedToplevels();
if (toplevels.length === 0)
return;
let currentIndex = -1;
for (let i = 0; i < toplevels.length; i++) {
if (toplevels[i].activated) {
currentIndex = i;
break;
}
}
const nextToplevel = toplevels[(currentIndex + 1) % toplevels.length];
if (restoreSpecialWorkspaceWindow(nextToplevel))
return;
nextToplevel.activate();
}
onIsHoveredChanged: {
if (mouseArea.pressed || dragging)
return;
@@ -343,9 +362,8 @@ Item {
return;
groupedToplevel.activate();
}
} else if (contextMenu) {
const shouldHidePin = appData.appId === "org.quickshell" || appData.appId === "com.danklinux.dms";
contextMenu.showForButton(root, appData, root.height + 25, shouldHidePin, cachedDesktopEntry, parentDockScreen, dockApps);
} else {
cycleGroupedToplevels();
}
break;
}
+12 -8
View File
@@ -386,16 +386,20 @@ end)
HYPRLAND_LUA_EOF
COMPOSITOR_CONFIG="$TEMP_CONFIG"
elif [[ -z "$COMPOSITOR_CONFIG" ]]; then
TEMP_CONFIG=$(mktemp)
cat > "$TEMP_CONFIG" << HYPRLAND_EOF
env = DMS_RUN_GREETER,1
TEMP_CONFIG=$(mktemp --suffix=.lua)
cat > "$TEMP_CONFIG" << HYPRLAND_LUA_EOF
hl.env("DMS_RUN_GREETER", "1")
misc {
disable_hyprland_logo = true
}
hl.config({
misc = {
disable_hyprland_logo = true,
},
})
exec-once = sh -c "$QS_CMD; hyprctl dispatch exit"
HYPRLAND_EOF
hl.on("hyprland.start", function()
hl.exec_cmd('sh -c "$QS_CMD; hyprctl dispatch exit"')
end)
HYPRLAND_LUA_EOF
COMPOSITOR_CONFIG="$TEMP_CONFIG"
else
TEMP_CONFIG=$(mktemp)
+89 -23
View File
@@ -156,9 +156,10 @@ DankOSD {
if (MprisController.isFirefoxYoutubeHoverPreview(player))
return;
const newTitle = player.trackTitle || "";
const newArtist = player.trackArtist || "";
const newAlbum = player.trackAlbum || "";
const metaPlayer = MprisController.bestMetadataPlayer(player);
const newTitle = MprisController.displayTrackTitle(metaPlayer);
const newArtist = metaPlayer.trackArtist || "";
const newAlbum = metaPlayer.trackAlbum || "";
const trackChanged = newTitle !== root._displayTitle || newArtist !== root._displayArtist || newAlbum !== root._displayAlbum;
root._displayTitle = newTitle;
@@ -263,37 +264,102 @@ DankOSD {
}
}
Rectangle {
width: Theme.iconSize
height: Theme.iconSize
radius: Theme.iconSize / 2
color: "transparent"
Row {
id: transportControls
x: parent.gap
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingXXS
DankIcon {
anchors.centerIn: parent
name: root._displayIcon
size: Theme.iconSize
color: playPauseButton.containsMouse ? Theme.primary : Theme.surfaceText
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();
}
}
}
MouseArea {
id: playPauseButton
Rectangle {
width: Theme.iconSize
height: Theme.iconSize
radius: Theme.iconSize / 2
anchors.verticalCenter: parent.verticalCenter
color: "transparent"
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
togglePlaying();
root.hide();
DankIcon {
anchors.centerIn: parent
name: root._displayIcon
size: Theme.iconSize
color: playPauseButton.containsMouse ? Theme.primary : Theme.surfaceText
}
MouseArea {
id: playPauseButton
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
togglePlaying();
root.hide();
}
}
}
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 {
x: parent.gap * 2 + Theme.iconSize
width: parent.width - Theme.iconSize - parent.gap * 3
x: parent.gap * 2 + transportControls.width
width: parent.width - transportControls.width - parent.gap * 3
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingXXS
@@ -302,17 +302,22 @@ Singleton {
function findConfigEntryByFingerprint(data, outputIdentifiers, autoOnly) {
const targetKey = outputSetFingerprint(outputIdentifiers);
const configs = data.configurations || [];
let firstUnnamed = null;
for (let i = 0; i < configs.length; i++) {
if (configFingerprint(configs[i]) === targetKey) {
if (autoOnly && configs[i].name)
continue;
if (configFingerprint(configs[i]) !== targetKey)
continue;
if (configs[i].name && !autoOnly)
return {
entry: configs[i],
index: i
};
}
if (!configs[i].name && !firstUnnamed)
firstUnnamed = {
entry: configs[i],
index: i
};
}
return null;
return firstUnnamed;
}
function getProfileMonitorInclusion(profileId) {
@@ -751,7 +756,7 @@ Singleton {
return;
readMonitorsJson(data => {
const match = findConfigEntryByFingerprint(data, currentOutputSet, true);
const match = findConfigEntryByFingerprint(data, currentOutputSet, false);
if (match) {
if (configEntryMatchesLiveLayout(match.entry)) {
SettingsData.setActiveDisplayProfile(CompositorService.compositor, match.entry.id);
@@ -1088,15 +1093,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) {
const result = {};
const outputRegex = /output\s+"([^"]+)"\s*\{([^}]*)\}/g;
let match;
while ((match = outputRegex.exec(content)) !== null) {
const name = match[1];
const body = match[2];
for (const block of extractNiriOutputBlocks(content)) {
const name = block.name;
const body = block.body;
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 posMatch = body.match(/position\s+x=(-?\d+)\s+y=(-?\d+)/);
const scaleMatch = body.match(/scale\s+([\d.]+)/);
@@ -55,12 +55,12 @@ Item {
}
function getPinnedWifiNetworks() {
const pins = SettingsData.wifiNetworkPins || {};
const pins = CacheData.wifiNetworkPins || {};
return normalizePinList(pins["preferredWifi"]);
}
function toggleWifiPin(ssid) {
const pins = JSON.parse(JSON.stringify(SettingsData.wifiNetworkPins || {}));
const pins = JSON.parse(JSON.stringify(CacheData.wifiNetworkPins || {}));
let pinnedList = normalizePinList(pins["preferredWifi"]);
const pinIndex = pinnedList.indexOf(ssid);
@@ -77,7 +77,7 @@ Item {
else
delete pins["preferredWifi"];
SettingsData.set("wifiNetworkPins", pins);
CacheData.set("wifiNetworkPins", pins);
}
property var forgetNetworkConfirm: ConfirmModal {}
+15 -3
View File
@@ -400,8 +400,8 @@ Item {
settingKey: "powerMenuDefaultAction"
tags: ["power", "menu", "default", "action", "reboot", "logout", "shutdown"]
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")]
property var actionValues: ["reboot", "logout", "poweroff", "lock", "suspend", "restart", "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", "softreboot"]
Component.onCompleted: {
const currentAction = SettingsData.powerMenuDefaultAction || "logout";
@@ -465,6 +465,12 @@ Item {
label: I18n.tr("Show Hibernate"),
desc: I18n.tr("Only visible if hibernate is supported by your system"),
hibernate: true
},
{
key: "softreboot",
label: I18n.tr("Show Soft Reboot"),
desc: I18n.tr("Restart userspace without rebooting the kernel, requires systemd"),
softreboot: true
}
]
@@ -474,7 +480,13 @@ Item {
tags: ["power", "menu", "action", "show", modelData.key]
text: modelData.label
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)
onToggled: checked => {
let actions = [...SettingsData.powerMenuActions];
+64 -1
View File
@@ -1,6 +1,7 @@
import QtCore
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Widgets
import qs.Common
import qs.Modals.FileBrowser
@@ -13,6 +14,7 @@ Item {
id: themeColorsTab
property var parentModal: null
property string pendingExtractJson: ""
readonly property bool connectedFrameModeActive: SettingsData.connectedFrameModeActive
readonly property bool frameModeActive: SettingsData.frameEnabled
property var cachedIconThemes: SettingsData.availableIconThemes
@@ -564,7 +566,7 @@ Item {
}
Column {
width: parent.width - 120 - Theme.spacingM
width: parent.width - 120 - Theme.spacingM - 36 - Theme.spacingM
spacing: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
@@ -586,6 +588,7 @@ Item {
}
StyledText {
id: wallpaperPathText
text: {
if (ToastService.wallpaperErrorStatus === "error")
return I18n.tr("Wallpaper processing failed", "wallpaper processing error");
@@ -603,6 +606,22 @@ Item {
wrapMode: Text.WordWrap
}
}
DankActionButton {
buttonSize: 36
iconName: "download"
iconSize: Theme.iconSize
backgroundColor: Theme.primaryHover
iconColor: Theme.primary
tooltipText: I18n.tr("Extract theme to JSON", "extract theme tooltip")
anchors.bottom: wallpaperPathText.bottom
onClicked: {
pendingExtractJson = Theme.extractCurrentTheme();
saveBrowserLoader.active = true;
if (saveBrowserLoader.item)
saveBrowserLoader.item.open();
}
}
}
SettingsDropdownRow {
@@ -2977,6 +2996,28 @@ Item {
}
}
LazyLoader {
id: saveBrowserLoader
active: false
FileBrowserSurfaceModal {
id: saveBrowser
browserTitle: I18n.tr("Save Extracted Theme", "extract theme save dialog title")
browserIcon: "download"
browserType: "default"
fileExtensions: ["*.json"]
allowStacking: true
saveMode: true
defaultFileName: "dms-extracted-theme.json"
onFileSelected: path => {
saveExtractedTheme(pendingExtractJson, Paths.strip(path));
close();
}
}
}
LazyLoader {
id: themeBrowserLoader
active: false
@@ -2992,4 +3033,26 @@ Item {
if (themeBrowserLoader.item)
themeBrowserLoader.item.show();
}
FileView {
id: extractSaveFileView
blockWrites: true
preload: false
atomicWrites: true
printErrors: true
onSaved: {
ToastService.showInfo(I18n.tr("Theme extracted to: %1", "extract theme success").arg(Paths.strip(extractSaveFileView.path)));
}
onSaveFailed: error => {
ToastService.showError(I18n.tr("Failed to extract theme", "extract theme error"));
log.warn("Failed to write extracted theme to " + extractSaveFileView.path + ": " + error);
}
}
function saveExtractedTheme(json, outputPath) {
extractSaveFileView.path = outputPath;
extractSaveFileView.setText(json);
}
}
@@ -18,6 +18,8 @@ Column {
id: sharedTooltip
}
Component.onDestruction: sharedTooltip.hide()
signal itemEnabledChanged(string sectionId, string itemId, bool enabled)
signal itemOrderChanged(string sectionId, var orderedIds)
signal addWidget(string sectionId)
@@ -1215,6 +1217,7 @@ Column {
iconSize: 18
iconColor: Theme.error
onClicked: {
sharedTooltip.hide();
root.removeWidget(root.sectionId, index);
}
onEntered: {
+18 -22
View File
@@ -749,16 +749,24 @@ Singleton {
if (category === I18n.tr("All"))
return visibleApps;
const pluginItems = getPluginItems(category, "");
if (pluginItems.length > 0)
return pluginItems;
return visibleApps.filter(app => {
const appCategories = getCategoriesForApp(app);
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
function getPluginCategories() {
if (typeof PluginService === "undefined") {
@@ -778,17 +786,11 @@ Singleton {
}
function getPluginCategoryIcon(category) {
if (typeof PluginService === "undefined")
const pluginId = getPluginIdForCategory(category);
if (!pluginId)
return null;
const launchers = PluginService.getLauncherPlugins();
for (const pluginId in launchers) {
const plugin = launchers[pluginId];
if ((plugin.name || pluginId) === category) {
return plugin.icon || "extension";
}
}
return null;
return PluginService.getLauncherPlugins()[pluginId].icon || "extension";
}
function getAllPluginItems() {
@@ -809,17 +811,11 @@ Singleton {
}
function getPluginItems(category, query) {
if (typeof PluginService === "undefined")
const pluginId = getPluginIdForCategory(category);
if (!pluginId)
return [];
const launchers = PluginService.getLauncherPlugins();
for (const pluginId in launchers) {
const plugin = launchers[pluginId];
if ((plugin.name || pluginId) === category) {
return getPluginItemsForPlugin(pluginId, query);
}
}
return [];
return getPluginItemsForPlugin(pluginId, query);
}
function getPluginItemsForPlugin(pluginId, query) {
+33 -5
View File
@@ -23,6 +23,13 @@ Singleton {
}
}
Connections {
target: typeof PowerProfiles !== "undefined" ? PowerProfiles : null
function onHasPerformanceProfileChanged() {
root.applyPowerProfile();
}
}
function applyPowerProfile() {
if (!batteryAvailable)
return;
@@ -32,7 +39,7 @@ Singleton {
const targetProfile = parseInt(profileValue);
if (isNaN(targetProfile) || PowerProfiles.profile === targetProfile)
return;
PowerProfiles.profile = targetProfile;
PowerProfileWatcher.applyProfile(targetProfile);
}
readonly property string preferredBatteryOverride: Quickshell.env("DMS_PREFERRED_BATTERY")
@@ -136,7 +143,7 @@ Singleton {
function sendAlert(title, message, isWarning, category, notificationType) {
if (notificationType === 1) {
Quickshell.execDetached(["notify-send", "-u", isWarning ? "critical" : "normal", "-a", "DMS", "-i", isWarning ? "battery-caution" : "battery-charging", title, message]);
Quickshell.execDetached(["notify-send", "-u", isWarning ? "critical" : "normal", "-a", "DMS", "-i", isWarning ? "material:battery_alert" : "material:battery_charging_full", title, message]);
} else {
if (isWarning) {
ToastService.showWarning(title, message, "", category);
@@ -150,7 +157,7 @@ Singleton {
if (isCharging && batteryLevel >= SettingsData.batteryChargeLimit) {
if (!_hasNotifiedChargeLimit && SettingsData.batteryNotifyChargeLimit) {
_hasNotifiedChargeLimit = true;
sendAlert(I18n.tr("Charge Limit Reached"), I18n.tr("Battery has charged to your set limit of %1%").arg(SettingsData.batteryChargeLimit), false, "battery-charge-limit", SettingsData.batteryChargeLimitNotificationType);
sendAlert(I18n.tr("Charge Limit Reached"), I18n.tr("Battery has charged to your set limit of %1%").arg(SettingsData.batteryChargeLimit), false, "material:battery_profile", SettingsData.batteryChargeLimitNotificationType);
}
} else if (!isCharging || batteryLevel < SettingsData.batteryChargeLimit - 2) {
_hasNotifiedChargeLimit = false;
@@ -166,7 +173,7 @@ Singleton {
if (isCriticalBattery) {
if (!_hasNotifiedCriticalBattery && SettingsData.batteryNotifyCritical) {
_hasNotifiedCriticalBattery = true;
sendAlert(I18n.tr("Critical Battery"), I18n.tr("Battery is at %1% - Connect charger immediately!").arg(batteryLevel), true, "battery-critical", SettingsData.batteryCriticalNotificationType);
sendAlert(I18n.tr("Critical Battery"), I18n.tr("Battery is at %1% - Connect charger immediately!").arg(batteryLevel), true, "material:battery_alert", SettingsData.batteryCriticalNotificationType);
}
return;
}
@@ -179,7 +186,7 @@ Singleton {
if (isLowBattery) {
if (!_hasNotifiedLowBattery && SettingsData.batteryNotifyLow) {
_hasNotifiedLowBattery = true;
sendAlert(I18n.tr("Low Battery"), I18n.tr("Battery is at %1% - Consider charging soon").arg(batteryLevel), true, "battery-low", SettingsData.batteryLowNotificationType);
sendAlert(I18n.tr("Low Battery"), I18n.tr("Battery is at %1% - Consider charging soon").arg(batteryLevel), true, "material:battery_0_bar", SettingsData.batteryLowNotificationType);
}
if (SettingsData.batteryAutoPowerSaver && PowerProfileWatcher.available) {
@@ -223,6 +230,27 @@ Singleton {
applyPowerProfile();
if (isPluggedIn) {
const dismissLow = SettingsData.batteryLowNotificationType === 1 && SettingsData.notificationTimeoutNormal === 0;
const dismissCritical = SettingsData.batteryCriticalNotificationType === 1 && SettingsData.notificationTimeoutCritical === 0;
if (dismissLow || dismissCritical) {
const lowSummary = I18n.tr("Low Battery");
const criticalSummary = I18n.tr("Critical Battery");
for (const w of NotificationService.visibleNotifications) {
if (!w || !w.notification)
continue;
const summary = w.notification.summary;
if ((dismissLow && summary === lowSummary) || (dismissCritical && summary === criticalSummary)) {
NotificationService.dismissNotification(w);
}
}
}
}
previousPluggedState = isPluggedIn;
}
+37 -53
View File
@@ -11,7 +11,7 @@ Singleton {
id: root
readonly property var log: Log.scoped("DMSService")
property bool dmsAvailable: false
readonly property bool dmsAvailable: isConnected
property var capabilities: []
property int apiVersion: 0
property string cliVersion: ""
@@ -21,7 +21,7 @@ Singleton {
property var availableThemes: []
property var installedThemes: []
property bool isConnected: false
property bool isConnecting: false
readonly property bool isConnecting: requestSocket.connected && !requestSocket.linkUp
property bool subscribeConnected: false
readonly property string socketPath: Quickshell.env("DMS_SOCKET")
@@ -72,9 +72,10 @@ Singleton {
property var activeSubscriptions: ["network", "network.credentials", "loginctl", "freedesktop", "freedesktop.screensaver", "gamma", "theme.auto", "wallpaper", "bluetooth", "bluetooth.pairing", "brightness", "wlroutput", "evdev", "browser", "dbus", "clipboard", "sysupdate"]
Component.onCompleted: {
if (socketPath && socketPath.length > 0) {
detectUpdateCommand();
}
if (!socketPath || socketPath.length === 0)
return;
detectUpdateCommand();
requestSocket.connected = true;
}
function detectUpdateCommand() {
@@ -82,12 +83,6 @@ Singleton {
checkAurHelper.running = true;
}
function startSocketConnection() {
if (socketPath && socketPath.length > 0) {
testProcess.running = true;
}
}
Process {
id: checkAurHelper
command: ["sh", "-c", "command -v paru || command -v yay"]
@@ -105,7 +100,6 @@ Singleton {
} else {
updateCommand = "dms update";
checkingUpdateCommand = false;
startSocketConnection();
}
}
}
@@ -114,7 +108,6 @@ Singleton {
if (exitCode !== 0) {
updateCommand = "dms update";
checkingUpdateCommand = false;
startSocketConnection();
}
}
}
@@ -135,7 +128,6 @@ Singleton {
updateCommand = "dms update";
}
checkingUpdateCommand = false;
startSocketConnection();
}
}
@@ -143,52 +135,27 @@ Singleton {
if (exitCode !== 0) {
updateCommand = "dms update";
checkingUpdateCommand = false;
startSocketConnection();
}
}
}
Process {
id: testProcess
command: ["test", "-S", root.socketPath]
onExited: exitCode => {
if (exitCode === 0) {
root.dmsAvailable = true;
connectSocket();
} else {
root.dmsAvailable = false;
}
}
}
function connectSocket() {
if (!dmsAvailable || isConnected || isConnecting) {
return;
}
isConnecting = true;
requestSocket.connected = true;
}
DankSocket {
id: requestSocket
path: root.socketPath
connected: false
onConnectionStateChanged: {
if (connected) {
if (linkUp) {
root.isConnected = true;
root.isConnecting = false;
root.connectionStateChanged();
subscribeSocket.connected = true;
} else {
root.isConnected = false;
root.isConnecting = false;
root.apiVersion = 0;
root.capabilities = [];
root.connectionStateChanged();
return;
}
root.isConnected = false;
root.apiVersion = 0;
root.capabilities = [];
root.failPendingRequests();
root.connectionStateChanged();
}
parser: SplitParser {
@@ -219,10 +186,10 @@ Singleton {
connected: false
onConnectionStateChanged: {
root.subscribeConnected = connected;
if (connected) {
sendSubscribeRequest();
}
root.subscribeConnected = linkUp;
if (!linkUp)
return;
sendSubscribeRequest();
}
parser: SplitParser {
@@ -335,6 +302,7 @@ Singleton {
const data = response.result.data;
if (service === "server") {
const prevCapabilities = capabilities;
apiVersion = data.apiVersion || 0;
cliVersion = data.cliVersion || "";
capabilities = data.capabilities || [];
@@ -346,6 +314,12 @@ Singleton {
}
capabilitiesReceived();
const capabilitiesChanged = prevCapabilities.length !== capabilities.length || capabilities.some(c => !prevCapabilities.includes(c));
if (prevCapabilities.length > 0 && capabilitiesChanged) {
log.info("Capabilities changed, resubscribing");
subscribe(activeSubscriptions);
}
} else if (service === "network") {
networkStateUpdate(data);
} else if (service === "network.credentials") {
@@ -439,10 +413,20 @@ Singleton {
function handleResponse(response) {
const callback = pendingRequests[response.id];
if (!callback)
return;
delete pendingRequests[response.id];
callback(response);
}
if (callback) {
delete pendingRequests[response.id];
callback(response);
function failPendingRequests() {
const pending = pendingRequests;
pendingRequests = {};
clipboardRequestIds = {};
for (const id in pending) {
pending[id]({
"error": "not connected to DMS socket"
});
}
}
+5 -1
View File
@@ -386,7 +386,7 @@ Singleton {
if (!focusedScreen)
return "";
const pins = SettingsData.brightnessDevicePins || {};
const pins = CacheData.brightnessDevicePins || {};
const screenKey = SettingsData.getScreenDisplayName(focusedScreen);
if (!screenKey)
return "";
@@ -1155,6 +1155,8 @@ Singleton {
return "Temperature must be between 2500K and 6000K";
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);
if (root.nightModeEnabled) {
@@ -1184,6 +1186,8 @@ Singleton {
return "Temperature must be between 2500K and 6500K";
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);
if (root.nightModeEnabled && SessionData.nightModeAutoEnabled)
+2
View File
@@ -388,6 +388,8 @@ Singleton {
const binds = bindsData[cat];
for (var i = 0; i < binds.length; i++) {
const bind = binds[i];
if (currentProvider === "hyprland" && bind.action && bind.action.startsWith("exec "))
bind.action = "spawn " + bind.action.slice(5);
const targetCat = Actions.isDmsAction(bind.action) ? "DMS" : cat;
if (!processed[targetCat])
processed[targetCat] = [];
+139 -9
View File
@@ -47,6 +47,7 @@ Singleton {
// Chromium can report blank metadata between tracks
property string stableTitle: ""
property string stableArtist: ""
property string stableAlbum: ""
Connections {
target: root.activePlayer
@@ -59,6 +60,9 @@ Singleton {
root._syncStableMeta();
root._checkIdle();
}
function onTrackAlbumChanged() {
root._syncStableMeta();
}
function onLengthChanged() {
if (root.activePlayer && root.activePlayer.lengthSupported && root.activePlayer.length > 1) {
root.activePlayerStableLength = root.activePlayer.length;
@@ -72,8 +76,10 @@ Singleton {
onActivePlayerChanged: {
activePlayerStableLength = (activePlayer && activePlayer.lengthSupported && activePlayer.length > 1) ? activePlayer.length : 0;
stableTitle = activePlayer?.trackTitle || "";
stableArtist = activePlayer?.trackArtist || "";
stableTitle = "";
stableArtist = "";
stableAlbum = "";
_syncStableMeta();
_checkIdle();
}
@@ -82,14 +88,24 @@ Singleton {
if (!p) {
stableTitle = "";
stableArtist = "";
stableAlbum = "";
return;
}
if (isFirefoxYoutubeHoverPreview(p))
return;
if (p.trackTitle)
stableTitle = p.trackTitle;
if (p.trackArtist)
stableArtist = p.trackArtist;
const metadataPlayer = bestMetadataPlayer(p);
const nextTitle = displayTrackTitle(metadataPlayer);
const trackChanged = nextTitle && stableTitle && nextTitle.toLowerCase() !== stableTitle.toLowerCase();
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
@@ -101,6 +117,7 @@ Singleton {
return;
root.stableTitle = "";
root.stableArtist = "";
root.stableAlbum = "";
root._resolveActivePlayer();
}
}
@@ -122,20 +139,133 @@ Singleton {
delegate: Connections {
required property MprisPlayer modelData
target: modelData
ignoreUnknownSignals: true
function onIsPlayingChanged() {
root._resolveActivePlayer();
root._syncStableMeta();
}
function onTrackTitleChanged() {
if (modelData.isPlaying)
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 {
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 {
// A playing player always wins; otherwise keep the selection stable w/idle
const playing = availablePlayers.find(p => p.isPlaying);
const playing = _bestPlayingPlayer();
if (playing) {
if (activePlayer !== playing) {
activePlayer = playing;
+64 -2
View File
@@ -17,6 +17,7 @@ Singleton {
property bool loginctlCommandAvailable: false
property bool systemctlCommandAvailable: false
property bool hibernateSupported: false
readonly property bool softRebootSupported: systemctlCommandAvailable
property bool inhibitorAvailable: true
property bool idleInhibited: false
property string inhibitReason: "Keep system awake"
@@ -217,6 +218,64 @@ Singleton {
return envObj;
}
function splitShellArgs(str) {
const args = [];
let current = "";
let hasToken = false;
let quote = "";
let escaped = false;
for (const ch of str) {
if (escaped) {
current += ch;
escaped = false;
continue;
}
switch (quote) {
case "'":
if (ch === "'") {
quote = "";
continue;
}
current += ch;
continue;
case "\"":
switch (ch) {
case "\"":
quote = "";
continue;
case "\\":
escaped = true;
continue;
}
current += ch;
continue;
}
switch (ch) {
case "\\":
escaped = true;
continue;
case "'":
case "\"":
quote = ch;
hasToken = true;
continue;
case " ":
case "\t":
case "\n":
if (!hasToken && current.length === 0)
continue;
args.push(current);
current = "";
hasToken = false;
continue;
}
current += ch;
}
if (current.length > 0 || hasToken)
args.push(current);
return args;
}
function launchDesktopEntry(desktopEntry, useNvidia) {
if (!desktopEntry || !desktopEntry.command)
return;
@@ -230,8 +289,7 @@ Singleton {
cmd = [nvidiaCommand].concat(cmd);
if (override?.extraFlags) {
const extraArgs = override.extraFlags.trim().split(/\s+/).filter(arg => arg.length > 0);
cmd = cmd.concat(extraArgs);
cmd = cmd.concat(splitShellArgs(override.extraFlags));
}
const userPrefix = SettingsData.launchPrefix?.trim() || "";
@@ -395,6 +453,10 @@ Singleton {
}
}
function softReboot() {
Quickshell.execDetached(["systemctl", "soft-reboot"]);
}
function poweroff() {
if (SettingsData.customPowerActionPowerOff.length === 0) {
Quickshell.execDetached(powerManagerCommand("poweroff"));
+33 -8
View File
@@ -26,7 +26,7 @@ Singleton {
return hash.toString(16).padStart(8, '0');
}
function getArtworkUrl(player) {
function _directArtworkUrl(player) {
if (!player) return "";
let artUrl = player.trackArtUrl || "";
@@ -54,6 +54,17 @@ Singleton {
return "";
}
function getArtworkUrl(player) {
const directUrl = _directArtworkUrl(player);
if (directUrl !== "")
return directUrl;
const equivalent = MprisController.equivalentPlayers(player).find(candidate => {
return candidate !== player && _directArtworkUrl(candidate) !== "";
});
return _directArtworkUrl(equivalent);
}
function _commit(u, artKey, srcUrl) {
resolvedArtUrl = u;
_committedArtKey = u !== "" ? artKey : "";
@@ -171,11 +182,25 @@ Singleton {
onActivePlayerChanged: _updateArtUrl()
Connections {
target: root.activePlayer
ignoreUnknownSignals: true
function onTrackTitleChanged() { root._updateArtUrl(); }
function onTrackArtUrlChanged() { root._updateArtUrl(); }
function onMetadataChanged() { root._updateArtUrl(); }
target: MprisController
function onAvailablePlayersChanged() {
root._updateArtUrl();
}
}
Instantiator {
model: MprisController.availablePlayers
delegate: Connections {
required property MprisPlayer modelData
target: modelData
ignoreUnknownSignals: true
function onIsPlayingChanged() { root._updateArtUrl(); }
function onTrackTitleChanged() { root._updateArtUrl(); }
function onTrackArtistChanged() { root._updateArtUrl(); }
function onTrackAlbumChanged() { root._updateArtUrl(); }
function onTrackArtUrlChanged() { root._updateArtUrl(); }
function onMetadataChanged() { root._updateArtUrl(); }
}
}
function _trackKey() {
@@ -201,8 +226,8 @@ Singleton {
}
_pendingArtKey = key;
const url = getArtworkUrl(activePlayer);
// Ignore Chrome's same-track thumbnail size updates.
if (key !== "" && key === _committedArtKey)
// Ignore duplicate notifications, but let a richer peer replace same-track art
if (key !== "" && key === _committedArtKey && url === _committedSrcUrl)
return;
if (key !== "" && url !== "" && url === _committedSrcUrl) {
// Chrome can publish track metadata before its new artwork URL.
+1 -1
View File
@@ -1 +1 @@
v1.5.2
v1.5.3
+6 -1
View File
@@ -11,13 +11,17 @@ HyprlandFocusGrab {
property bool _held: false
property bool _compositorCleared: false
property var _restoreToplevel: null
property int _restoreWorkspaceId: -1
property Timer _releaseTimer: Timer {
interval: 50
onTriggered: {
root._held = false;
root.active = false;
root._restoreToplevel = root._compositorCleared ? null : KeyboardFocus.restoreToplevel(root._restoreToplevel);
// Restoring a toplevel from another workspace would drag the user back
// to the workspace they just navigated away from (#2963)
const workspaceChanged = (Hyprland.focusedWorkspace?.id ?? -1) !== root._restoreWorkspaceId;
root._restoreToplevel = (root._compositorCleared || workspaceChanged) ? null : KeyboardFocus.restoreToplevel(root._restoreToplevel);
}
}
@@ -34,6 +38,7 @@ HyprlandFocusGrab {
_held = true;
_compositorCleared = false;
_restoreToplevel = KeyboardFocus.captureActiveToplevel();
_restoreWorkspaceId = Hyprland.focusedWorkspace?.id ?? -1;
active = true;
}
+3 -1
View File
@@ -105,9 +105,11 @@ PanelWindow {
readonly property bool slideoutBlurActive: root.visible && BlurService.enabled && Theme.connectedSurfaceBlurEnabled
readonly property string _slideoutScreenName: modelData?.name ?? ""
WlrLayershell.layer: (!suppressOverlayLayer && (triggerUsesOverlayLayer || CompositorService.framePeerSurfacesUseOverlayForScreen(modelData))) ? WlrLayershell.Overlay : WlrLayershell.Top
WlrLayershell.exclusiveZone: 0
WlrLayershell.keyboardFocus: isVisible ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None
WlrLayershell.keyboardFocus: isVisible && !ModalManager.currentModalsByScreen[_slideoutScreenName] ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None
readonly property real dpr: CompositorService.getScreenScale(root.screen)
readonly property real alignedWidth: Theme.px(expandable && expandedWidth ? expandedWidthValue : slideoutWidth, dpr)
+1 -1
View File
@@ -869,7 +869,7 @@ Item {
readonly property var tooltipTexts: ({
"dms": I18n.tr("DMS shell actions (launcher, clipboard, etc.)"),
"compositor": I18n.tr("Niri compositor actions (focus, move, etc.)"),
"compositor": I18n.tr("Compositor actions (focus, move, etc.)", "keybind action type tooltip"),
"spawn": I18n.tr("Run a program (e.g., firefox, kitty)"),
"shell": I18n.tr("Run a shell command (e.g., notify-send)")
})