Compare commits

...
27 Commits
Author SHA1 Message Date
dms-ci[bot] 2db7646fe3 bump VERSION to v1.6.2 2026-09-17 15:07:40 +00:00
bbedward 097de493bb displayconfig: fix custom scale option 2026-09-17 10:33:09 -04:00
bbedward a7db0354ae niri: fix workspace updates never applying
fixes #3462
port 1.6

(cherry picked from commit 36bb62ac66)
2026-09-17 14:03:55 +00:00
bbedward 2d2502cc67 qt: write real newlines in new qt5ct/qt6ct config
fixes #3457
port 1.6

(cherry picked from commit 49a639a358)
2026-09-17 14:03:53 +00:00
Huỳnh Thiện Lộc 402a8515ee feat(screenshot): add flag to control HUD scale and visibility (#3398)
* feat(screenshot): add --hud flag to control HUD scale and visibility

* fix(screenshot): clamp HUD scale to 1-4 and clarify flag description

(cherry picked from commit 13192370f5)
2026-09-17 09:44:00 -04:00
phoropter 4cf0f0c898 Update vesktop.css (#3410)
Fix text being the same color as mention badge background.

(cherry picked from commit b4f6da25ee)
2026-09-17 09:43:51 -04:00
Domen Kožar fd0564a3ec tray: use available width for menu labels (#3456)
(cherry picked from commit 1bf35426ea)
2026-09-17 09:43:22 -04:00
Marlon Kranz 07f8675a7b fix(frame): keep launcher edge-hover on the Top layer (#3453)
Opening the launcher by hovering the frame edge requested the Overlay
layer via CompositorService.framePeerSurfacesUseOverlayForScreen(),
which resolves to whether the frame is configured for the screen and
is true in normal use. DankLauncherV2ModalConnected's
frameOwnsConnectedChrome requires the Top layer, so the overlay
request silently dropped connected-chrome anchoring: the launcher
opened dead-centered with a different animation instead of emerging
from the configured frame side.

The keybind/bar-button path already opens on the Top layer by passing
no overlay argument. The edge-hover trigger only runs in connected
frame mode to begin with (gated by usesConnectedFrameChromeForScreen
in this file's own eligible check), so there's no case where it needs
standalone/overlay behavior. Match the keybind path.

(cherry picked from commit 579d6f7b5c)
2026-09-17 09:41:33 -04:00
Alexander cab2a1a3bc dock: fix overview visibility on niri (#3460)
(cherry picked from commit 81d5962650)
2026-09-17 09:41:21 -04:00
bbedward 41dd91fe75 dank16: use the real primary_container and fix light-mode derivation
fixes #3405
fixes #3406
ref #3408
port 1.6

(cherry picked from commit 641d65cbd9)
2026-09-16 14:06:18 +00:00
dms-ci[bot] c074b220ee nix: update vendorHash for go.mod changes 2026-09-16 09:32:22 -04:00
bbedward 243d716703 core: bump dankgop
port 1.6

(cherry picked from commit c9506bc847)
2026-09-16 13:28:09 +00:00
Giacomo Di Clerico fde2181c3b tailscale: handle profile switches and bus updates reactively (#3427)
* tailscale: handle profile switches and bus updates reactively

* tailscale: address review feedback on test coverage and dead code

(cherry picked from commit 218f7f752f)
2026-09-15 16:46:47 -04:00
Harsh Kumar a5ffd18596 display config: read live layout from hyprland instead of the cached wlr snapshot (#3418)
(cherry picked from commit d7b5812561)
2026-09-15 16:46:47 -04:00
bbedward e0aa1f0177 frame: close hairline gap at the screen edge
ref #3389

(cherry picked from commit 4d5bac28c9)
2026-09-15 16:46:47 -04:00
bbedward e73b9c3ce1 evdev: open input devices read-only
go-evdev default is O_RDWR but we dont need to open evdev devices RW

(cherry picked from commit 6e9a657132)
2026-09-15 16:46:47 -04:00
Mattias 69955286a7 privacy: stop counting screen captures as camera use (#3428)
Apps capturing the screen through PipeWire, such as OBS, create a live
video input stream, which the camera check counted as camera use. Skip
streams tagged with the Screen media role.

(cherry picked from commit 6ecdd6d45d)
2026-09-15 16:46:47 -04:00
Timofey d3a02f6342 hyprland: use focusedMonitor instead of focusedWorkspace.monitor (#3424)
Hyprland's workspace refactor (hyprwm/Hyprland#16140) broke
Hyprland.focusedWorkspace.monitor as a way to find the focused screen
(quickshell-mirror/quickshell#1119). Quickshell already exposes
Hyprland.focusedMonitor directly, so read the monitor from there

(cherry picked from commit 05c807b7cd)
2026-09-15 16:46:47 -04:00
gibbert 86234e5b09 fix(audio): avoid false negative when some event sounds can't be found (#3437)
(cherry picked from commit 6af2b1fbc3)
2026-09-15 16:46:47 -04:00
deepfriedret 938fabe27d fix(media widget): prevent title from truncating with scroll song title on (#3436)
(cherry picked from commit 0133962936)
2026-09-15 16:46:47 -04:00
Mario Mohar 1b34aa0ce1 fix(battery): report when no charge threshold exists instead of faking success (#3385)
* fix(battery): report when no charge threshold exists instead of faking success

* fix(battery): hide the charge limit controls when there is no battery

Follow-up to the review on #3385: the new explanation showed on
desktops and VMs with no battery at all, where it reads as a fault
report about hardware the machine was never going to have.

Both the explanation and the apply button now require
BatteryService.batteryAvailable. Hiding the button there is the same
point the pull request makes elsewhere: offering a control that cannot
work is what produced the false success in the first place. A machine
without a battery has no charge threshold to write to, so the limit was
never applicable.

BatteryService.batteryAvailable is already the gate used twice in this
file for the same reason.

(cherry picked from commit 6b50696b8a)
2026-09-15 16:46:47 -04:00
Huỳnh Thiện Lộc 76ed5d68fa fix(clipboard): preserve pinned state when editing entries (#3412)
* fix(clipboard): preserve pinned state when editing entries

* fix(clipboard): address PR review feedback on editEntry

* fix(clipboard): validate empty text and entry size in EditEntry

* fix(clipboard): gate saving entry on full text load completion

* fix(clipboard): add user feedback and error fallback for entry loading

* fix(clipboard): keep entry and gate save with error toast on load failure

* fix(clipboard): reject non-text edits and add translator contexts

* fix(clipboard): support alt text mime types in EditEntry and UI guards

* fix(clipboard): pre-fetch entry before opening editor and allow retry on load failure

(cherry picked from commit 4141089e63)
2026-09-15 16:46:47 -04:00
Mario Mohar b69496dbcd fix(settings): size the plugin update spinner to fit its row (#3383)
(cherry picked from commit cf74c10561)
2026-09-15 16:46:46 -04:00
bbedward ef179f9c92 disable dcal from release dispatch 2026-09-15 16:41:16 -04:00
bbedward b451d4d8e0 doctor: add aqueous and scroll
port 1.6

(cherry picked from commit 09ddd6da82)
2026-09-11 13:47:33 +00:00
purian23 12f7675950 refactor(island): add island satellite hover option
fixes #3296
port 1.6

(cherry picked from commit 60675ecb39)
2026-09-10 03:36:41 +00:00
dms-ci[bot] 06cd88e990 nix: update vendorHash for go.mod changes 2026-09-09 09:10:51 -04:00
55 changed files with 1174 additions and 303 deletions
+1 -1
View File
@@ -532,7 +532,7 @@ jobs:
- dgop
- dankgo
# - dank-qml-common
- dankcalendar
# - dankcalendar
- danksearch
- dank-greeter
steps:
+5 -5
View File
@@ -79,11 +79,11 @@ func runDank16(cmd *cobra.Command, args []string) {
primaryLight = primaryColor
}
variantOpts := dank16.VariantOptions{
PrimaryDark: primaryDark,
PrimaryLight: primaryLight,
Background: background,
UseDPS: contrastAlgo == "dps",
IsLightMode: isLight,
PrimaryDark: primaryDark,
PrimaryLight: primaryLight,
BackgroundDark: background,
UseDPS: contrastAlgo == "dps",
IsLightMode: isLight,
}
variantColors := dank16.GenerateVariantPalette(variantOpts)
fmt.Print(dank16.GenerateVariantJSON(variantColors))
+25 -2
View File
@@ -95,6 +95,8 @@ var (
labwcVersionRegex = regexp.MustCompile(`labwc (\d+\.\d+\.\d+)`)
mangowcVersionRegex = regexp.MustCompile(`mango (\d+\.\d+\.\d+)`)
miracleVersionRegex = regexp.MustCompile(`miracle-wm v?(\d+\.\d+\.\d+)`)
scrollVersionRegex = regexp.MustCompile(`scroll version (\d+\.\d+)`)
aqueousVersionRegex = regexp.MustCompile(`(?i)aqueous v?(\d+\.\d+(?:\.\d+)?)`)
)
var doctorCmd = &cobra.Command{
@@ -739,6 +741,8 @@ func checkWindowManagers() []checkResult {
{"labwc", "labwc", "--version", labwcVersionRegex, []string{"labwc"}},
{"mangowc", "mango", "-v", mangowcVersionRegex, []string{"mango"}},
{"Miracle WM", "miracle-wm", "--version", miracleVersionRegex, []string{"miracle-wm"}},
{"Scroll", "scroll", "--version", scrollVersionRegex, []string{"scroll"}},
{"Aqueous", "aqueous", "-version", aqueousVersionRegex, []string{"aqueous"}},
}
var results []checkResult
@@ -771,7 +775,7 @@ func checkWindowManagers() []checkResult {
results = append(results, checkResult{
catCompositor, "Compositor", statusError,
"No supported Wayland compositor found",
"Install Hyprland, niri, Sway, River, Wayfire, or miracle-wm",
"Install Hyprland, niri, Sway, River, Wayfire, labwc, mangowc, miracle-wm, Scroll, or Aqueous",
doctorDocsURL + "#compositor-checks",
})
}
@@ -780,11 +784,28 @@ func checkWindowManagers() []checkResult {
results = append(results, checkResult{catCompositor, "Active", statusInfo, wm, "", doctorDocsURL + "#compositor"})
}
if os.Getenv("AQUEOUS_SOCKET") != "" {
results = append(results, checkAqueousConfigHelper())
}
results = append(results, checkCompositorBlurSupport())
return results
}
func checkAqueousConfigHelper() checkResult {
url := doctorDocsURL + "#compositor-checks"
path, err := exec.LookPath("aqueous-config")
if err != nil {
return checkResult{catCompositor, "aqueous-config", statusWarn, "Not found", "Keybind and display settings edits from the shell need the aqueous-config helper", url}
}
details := "Keybind and display settings edits from the shell"
if doctorVerbose {
details = path
}
return checkResult{catCompositor, "aqueous-config", statusOK, "Installed", details, url}
}
func checkCompositorBlurSupport() checkResult {
supported, err := blur.ProbeSupport()
if err != nil {
@@ -817,6 +838,8 @@ func getVersionFromCommand(cmd, arg string, regex *regexp.Regexp) string {
func detectRunningWM() string {
switch {
case os.Getenv("AQUEOUS_SOCKET") != "":
return "Aqueous"
case os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "":
return "Hyprland"
case os.Getenv("NIRI_SOCKET") != "":
@@ -911,7 +934,7 @@ ShellRoot {
{"Polkit", "Authentication prompts"},
{"IdleMonitor", "Idle detection"},
{"IdleInhibitor", "Prevent idle/sleep"},
{"ShortcutInhibitor", "Allow shortcut management (niri)"},
{"ShortcutInhibitor", "Allow shortcut management (niri, Aqueous)"},
{"BackgroundBlur", "Background blur API support in Quickshell"},
}
+3
View File
@@ -35,6 +35,7 @@ var (
ssJSON bool
ssGeometry bool
ssAllowMulti bool
ssHUD string
)
type screenshotMetadata struct {
@@ -181,6 +182,7 @@ func init() {
screenshotCmd.PersistentFlags().BoolVar(&ssJSON, "json", false, "Print capture metadata as JSON")
screenshotCmd.PersistentFlags().BoolVarP(&ssGeometry, "geometry", "g", false, "Print selected region geometry (X,Y WxH) to stdout without capturing an image")
screenshotCmd.PersistentFlags().BoolVar(&ssAllowMulti, "allow-multiple", false, "Open a selector even when another one is already open")
screenshotCmd.PersistentFlags().StringVar(&ssHUD, "hud", "auto", "HUD overlay scale in region selector (auto, off/0, or integer scale 1-4)")
ssScrollCmd.Flags().IntVar(&ssScrollInterval, "interval", 45, "Capture interval in milliseconds (30-1000)")
@@ -212,6 +214,7 @@ func getScreenshotConfig(mode screenshot.Mode) screenshot.Config {
config.Stdout = ssStdout
config.Geometry = ssGeometry
config.AllowMultiple = ssAllowMulti
config.HUD = ssHUD
if ssGeometry {
config.Clipboard = false
+7 -8
View File
@@ -3,7 +3,7 @@ module github.com/AvengeMedia/DankMaterialShell/core
go 1.26.5
require (
github.com/AvengeMedia/dgop v0.2.4-0.20260819141338-085d828cf577
github.com/AvengeMedia/dgop v1.6.1-0.20260916132520-8a12dbc6e287
github.com/Nadim147c/material/v3 v3.1.1
github.com/Wifx/gonetworkmanager/v2 v2.2.0
github.com/alecthomas/chroma/v2 v2.27.0
@@ -18,7 +18,7 @@ require (
github.com/pilebones/go-udev v0.9.1
github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
github.com/stretchr/testify v1.12.1
github.com/yeqown/go-qrcode/v2 v2.2.5
github.com/yeqown/go-qrcode/writer/standard v1.3.0
github.com/yuin/goldmark v1.8.4
@@ -71,9 +71,10 @@ require (
github.com/x448/float16 v0.8.4 // indirect
github.com/yeqown/reedsolomon v1.0.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 // indirect
golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
@@ -84,10 +85,9 @@ require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/x/ansi v0.11.7 // indirect
github.com/charmbracelet/x/ansi v0.11.8 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-git/go-git/v6 v6.0.0-alpha.4
github.com/inconshreveable/mousetrap v1.1.0 // indirect
@@ -98,13 +98,12 @@ require (
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/afero v1.15.0
github.com/spf13/pflag v1.0.10 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/xo/terminfo v1.0.0 // indirect
golang.org/x/sys v0.47.0
golang.org/x/text v0.40.0
golang.org/x/text v0.41.0
gopkg.in/yaml.v3 v3.0.1
)
+20 -22
View File
@@ -2,8 +2,8 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/AvengeMedia/dankgo v1.6.1-0.20260908011626-07e1ef7caaea h1:A2IuXIzhTQwvBZiS8pxU5RmWSszMd16Oh11gUlJ9OeU=
github.com/AvengeMedia/dankgo v1.6.1-0.20260908011626-07e1ef7caaea/go.mod h1:w1W/vDrWJCLdjVI4E/mURiKUQLMCmiGiPLxZEeP5kp8=
github.com/AvengeMedia/dgop v0.2.4-0.20260819141338-085d828cf577 h1:z7daEy56wLH6R7PGS3MKIVoDHNdVoDptY/f2i61EDCQ=
github.com/AvengeMedia/dgop v0.2.4-0.20260819141338-085d828cf577/go.mod h1:2kt1u9YgHwi2npqB5ago8kwZdFrHlPGXv3PM/vwRHfY=
github.com/AvengeMedia/dgop v1.6.1-0.20260916132520-8a12dbc6e287 h1:wzQqP16zxfUcfFltJelKg6pTdRyiLY2l2NJLT0Par+Q=
github.com/AvengeMedia/dgop v1.6.1-0.20260916132520-8a12dbc6e287/go.mod h1:lwzO5Y4BO5z0I7cLMp5A7sw/THliwGz1TNv6xoUehY4=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/Nadim147c/material/v3 v3.1.1 h1:3ZuTPWBJWUpzzWW1rtqe28RMZXhA4f0/mg/Jk3JG94Y=
@@ -38,8 +38,8 @@ github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoF
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/log v1.0.0 h1:HVVVMmfOorfj3BA9i8X8UL69Hoz9lI0PYwXfJvOdRc4=
github.com/charmbracelet/log v1.0.0/go.mod h1:uYgY3SmLpwJWxmlrPwXvzVYujxis1vAKRV/0VQB7yWA=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=
github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
@@ -61,8 +61,6 @@ github.com/danielgtaylor/huma/v2 v2.39.1 h1:0kwF4ltQoYZ+IU55VPy+BcGekzgF44R64daT
github.com/danielgtaylor/huma/v2 v2.39.1/go.mod h1:zcnQ38duIJ3VUHwFaBoZ6x8T+KN/mr33oyqxcj0HTug=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dblohm7/wingoes v0.0.0-20260526185140-fb298caac7ca h1:h1Awca4lQOspNR/2eeo04Ricn5NixDX9mb17WSAgLhQ=
github.com/dblohm7/wingoes v0.0.0-20260526185140-fb298caac7ca/go.mod h1:2TGl1jRJrRpbzykmg7asHm3h08TqutUgQqY5v9k/g3c=
github.com/dlclark/regexp2/v2 v2.5.1 h1:E5Ug7Dh264W1ymdySmiHNcDG7fmsR307APCE5R07a20=
@@ -85,8 +83,8 @@ github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo=
github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo=
github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs=
github.com/go-git/go-billy/v6 v6.0.0-alpha.1 h1:xVjAR4oUvrKy7/Xuw/lLlV3gkxR3KO2H8W+MamuVVsQ=
@@ -166,8 +164,6 @@ github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 h1:jL3a8soXdzuTCcRnKhOmtcsVOObdDTFf4O2B403HPRU=
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
@@ -192,8 +188,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4=
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg=
github.com/tailscale/wireguard-go v0.0.0-20260715223240-2e01ba5b00f0 h1:CnIEL2n7Xql6Ux1k+Vu5S5ubDHCT/kxFgkKCY8FjefU=
@@ -204,8 +200,8 @@ github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqo
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY=
github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yeqown/go-qrcode/v2 v2.2.5 h1:HCOe2bSjkhZyYoyyNaXNzh4DJZll6inVJQQw+8228Zk=
github.com/yeqown/go-qrcode/v2 v2.2.5/go.mod h1:uHpt9CM0V1HeXLz+Wg5MN50/sI/fQhfkZlOM+cOTHxw=
github.com/yeqown/go-qrcode/writer/standard v1.3.0 h1:chdyhEfRtUPgQtuPeaWVGQ/TQx4rE1PqeoW3U+53t34=
@@ -219,18 +215,20 @@ github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQ
go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek=
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 h1:ex206bKw+v3K0dm3andkrIF+ijyQKJG1pLgwQ2PYdQM=
golang.org/x/exp v0.0.0-20260727155853-b88d891fe743/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q=
golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa h1:QSyA8ishJCyT21kER9KwNt0b7BM3iRK4x9QXhjN5Fdk=
golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa/go.mod h1:zeBbvyFKDaLwa7CH/zI8KXt7gTl14SF7sO08Pl5jBCM=
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/mod v0.39.0 h1:UF5zwQdCRRUpHfyPwr7d4UrGiVeldIsogtzWVnczL74=
golang.org/x/mod v0.39.0/go.mod h1:bvIbwjQ0HUFFf5AKukeeYQG4ZBUG9yxQbR9aEweIwYY=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
@@ -243,10 +241,10 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH8ysFIbw8=
+40 -24
View File
@@ -455,6 +455,7 @@ func EnsureContrastDPSBidirectional(hexColor, hexBg string, minLc float64, isLig
type PaletteOptions struct {
IsLight bool
Background string
Container string
UseDPS bool
}
@@ -520,8 +521,18 @@ func DeriveContainer(primary string, isLight bool) string {
return RGBToHex(HSVToRGB(HSV{H: hsv.H, S: containerS, V: containerV}))
}
// DeriveContainer only approximates the Material tone pairing, so a caller that
// has the real primary_container should pass it: color5 and color6 are the
// primary/container pair and their contrast with each other is the invariant.
func resolveContainer(primaryColor string, opts PaletteOptions) string {
if opts.Container != "" {
return opts.Container
}
return DeriveContainer(primaryColor, opts.IsLight)
}
func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
baseColor := DeriveContainer(primaryColor, opts.IsLight)
baseColor := resolveContainer(primaryColor, opts)
rgb := HexToRGB(baseColor)
hsv := RGBToHSV(rgb)
@@ -531,13 +542,20 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
var palette Palette
var normalTextTarget, secondaryTarget float64
if opts.UseDPS {
// Delta Phi Star is not symmetric across polarity: on a light background the
// same Lc buys far less real contrast, so the light targets are the numbers
// that land on the same measured WCAG ratios the dark ones already deliver.
var normalTextTarget, accentTarget float64
switch {
case opts.UseDPS && opts.IsLight:
normalTextTarget = 70.0
accentTarget = 58.0
case opts.UseDPS:
normalTextTarget = 40.0
secondaryTarget = 35.0
} else {
accentTarget = 24.5
default:
normalTextTarget = 4.5
secondaryTarget = 3.0
accentTarget = 2.1
}
var bgColor string
@@ -557,8 +575,6 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
greenH := blendHue(0.33, ph.H, 0.10)
yellowH := blendHue(0.14, ph.H, 0.04)
accentTarget := secondaryTarget * 0.7
if opts.IsLight {
redS := math.Min(baseSat*1.2, 1.0)
redV := baseVal * 0.95
@@ -570,15 +586,13 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
yellowS := math.Min(baseSat*1.5, 1.0)
yellowV := math.Min(baseVal*1.2, 1.0)
palette.Color3 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: yellowH, S: yellowS, V: yellowV})), bgColor, accentTarget, opts))
palette.Color3 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: yellowH, S: yellowS, V: yellowV})), bgColor, normalTextTarget, opts))
blueS := math.Min(ph.S*1.05, 1.0)
blueV := math.Min(ph.V*1.05, 1.0)
palette.Color4 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: ph.H, S: blueS, V: blueV})), bgColor, normalTextTarget, opts))
// Color5 matches primary_container exactly (light container in light mode)
container5 := DeriveContainer(primaryColor, true)
palette.Color5 = NewColorInfo(container5)
palette.Color5 = NewColorInfo(baseColor)
palette.Color6 = NewColorInfo(primaryColor)
@@ -604,8 +618,9 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
brightBlueV := math.Min(ph.V*1.15, 1.0)
palette.Color12 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: ph.H, S: brightBlueS, V: brightBlueV})), bgColor, accentTarget, opts))
lightContainer := DeriveContainer(primaryColor, true)
palette.Color13 = NewColorInfo(lightContainer)
color13S := ph.S * 0.7
color13V := math.Min(ph.V*1.3, 1.0)
palette.Color13 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: ph.H, S: color13S, V: color13V})), bgColor, accentTarget, opts))
brightCyanS := ph.S * 0.5
brightCyanV := math.Min(ph.V*1.3, 1.0)
@@ -632,9 +647,7 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
blueV := ph.V * 0.95
palette.Color4 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: ph.H, S: blueS, V: blueV})), bgColor, normalTextTarget, opts))
// Color5 matches primary_container exactly (dark container in dark mode)
darkContainer := DeriveContainer(primaryColor, false)
palette.Color5 = NewColorInfo(darkContainer)
palette.Color5 = NewColorInfo(baseColor)
palette.Color6 = NewColorInfo(primaryColor)
@@ -681,11 +694,14 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
}
type VariantOptions struct {
PrimaryDark string
PrimaryLight string
Background string
UseDPS bool
IsLightMode bool
PrimaryDark string
PrimaryLight string
BackgroundDark string
BackgroundLight string
ContainerDark string
ContainerLight string
UseDPS bool
IsLightMode bool
}
func mergeColorInfo(dark, light ColorInfo, isLightMode bool) VariantColorInfo {
@@ -702,8 +718,8 @@ func mergeColorInfo(dark, light ColorInfo, isLightMode bool) VariantColorInfo {
}
func GenerateVariantPalette(opts VariantOptions) VariantPalette {
darkOpts := PaletteOptions{IsLight: false, Background: opts.Background, UseDPS: opts.UseDPS}
lightOpts := PaletteOptions{IsLight: true, Background: opts.Background, UseDPS: opts.UseDPS}
darkOpts := PaletteOptions{IsLight: false, Background: opts.BackgroundDark, Container: opts.ContainerDark, UseDPS: opts.UseDPS}
lightOpts := PaletteOptions{IsLight: true, Background: opts.BackgroundLight, Container: opts.ContainerLight, UseDPS: opts.UseDPS}
dark := GeneratePalette(opts.PrimaryDark, darkOpts)
light := GeneratePalette(opts.PrimaryLight, lightOpts)
+111 -1
View File
@@ -701,7 +701,8 @@ func TestGeneratePaletteWithDPS(t *testing.T) {
bgColor := result.Color0.Hex
for i := 1; i < 8; i++ {
// Skip Color5 (container) and Color6 (exact primary) - intentionally not contrast-adjusted
// Color5 is the container and Color6 the exact primary: they pair
// with each other, not with the background
if i == 5 || i == 6 {
continue
}
@@ -862,3 +863,112 @@ func TestGeneratePaletteLightColor8StaysLight(t *testing.T) {
palette.Color8.Hex, palette.Color0.Hex, bgRatio)
}
}
func TestGeneratePaletteLightYellowMeetsNormalTextContrast(t *testing.T) {
primaries := []string{"#63568f", "#7d5800", "#116d2b", "#1c5070", "#8b3f68"}
for _, primary := range primaries {
t.Run(primary, func(t *testing.T) {
palette := GeneratePalette(primary, PaletteOptions{IsLight: true, Background: "#f5fafc", UseDPS: true})
ratio := ContrastRatio(palette.Color3.Hex, palette.Color0.Hex)
if ratio < 4.5 {
t.Errorf("light Color3 %s vs bg %s ratio %.2f, expected >= 4.5 (yellow must clear the normal-text target)",
palette.Color3.Hex, palette.Color0.Hex, ratio)
}
})
}
}
func TestGenerateVariantPaletteUsesPerVariantBackground(t *testing.T) {
const darkBg = "#0f1417"
const lightBg = "#f5fafc"
variants := GenerateVariantPalette(VariantOptions{
PrimaryDark: "#8ecff2",
PrimaryLight: "#1c5070",
BackgroundDark: darkBg,
BackgroundLight: lightBg,
UseDPS: true,
})
if variants.Color0.Dark.Hex != darkBg {
t.Errorf("dark Color0 = %s, expected the dark background %s", variants.Color0.Dark.Hex, darkBg)
}
if variants.Color0.Light.Hex != lightBg {
t.Errorf("light Color0 = %s, expected the light background %s", variants.Color0.Light.Hex, lightBg)
}
lightDim := variants.Color8.Light.Hex
if lstar := getLstar(lightDim); lstar < 60.0 {
t.Errorf("light Color8 %s has L* %.2f, expected >= 60 (derived from the dark surface it collapses to near-black)",
lightDim, lstar)
}
if ratio := ContrastRatio(lightDim, lightBg); ratio > 3.0 {
t.Errorf("light Color8 %s vs bg %s ratio %.2f, expected <= 3.0 (bright black stays dim)", lightDim, lightBg, ratio)
}
darkDim := variants.Color8.Dark.Hex
if ratio := ContrastRatio(darkDim, darkBg); ratio > 3.0 {
t.Errorf("dark Color8 %s vs bg %s ratio %.2f, expected <= 3.0", darkDim, darkBg, ratio)
}
}
func TestGeneratePaletteColor5PairsWithColor6(t *testing.T) {
tests := []struct {
name string
primary string
container string
bg string
isLight bool
}{
{"violet dark", "#cdbdff", "#4b3e76", "#141318", false},
{"violet light", "#63568f", "#e8deff", "#fdf7ff", true},
{"green dark", "#9ed49d", "#1f5027", "#101510", false},
{"green light", "#38693c", "#b9f0b8", "#f7fbf2", true},
{"amber light", "#805610", "#ffddb3", "#fff8f4", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
palette := GeneratePalette(tt.primary, PaletteOptions{
IsLight: tt.isLight,
Background: tt.bg,
Container: tt.container,
UseDPS: true,
})
if palette.Color5.Hex != tt.container {
t.Errorf("Color5 = %s, expected the supplied container %s", palette.Color5.Hex, tt.container)
}
if palette.Color6.Hex != tt.primary {
t.Errorf("Color6 = %s, expected the primary %s", palette.Color6.Hex, tt.primary)
}
pair := ContrastRatio(palette.Color5.Hex, palette.Color6.Hex)
if pair < 4.5 {
t.Errorf("Color5 %s vs Color6 %s ratio %.2f, expected >= 4.5 (the container/primary pair carries the prompt themes that use ANSI 5 on 6)",
palette.Color5.Hex, palette.Color6.Hex, pair)
}
if palette.Color5.Hex == palette.Color13.Hex {
t.Errorf("Color5 and Color13 are both %s, expected a distinct bright variant", palette.Color5.Hex)
}
if sep := ContrastRatio(palette.Color5.Hex, palette.Color13.Hex); sep < 2.0 {
t.Errorf("Color5 %s vs Color13 %s ratio %.2f, expected >= 2.0 (normal and bright must be tellable apart)",
palette.Color5.Hex, palette.Color13.Hex, sep)
}
})
}
}
func TestGeneratePaletteFallsBackToDerivedContainer(t *testing.T) {
const primary = "#cdbdff"
palette := GeneratePalette(primary, PaletteOptions{Background: "#141318", UseDPS: true})
want := DeriveContainer(primary, false)
if palette.Color5.Hex != want {
t.Errorf("Color5 = %s with no container supplied, expected the derived %s", palette.Color5.Hex, want)
}
}
+20 -11
View File
@@ -343,7 +343,7 @@ func buildOnce(opts *Options) (bool, error) {
oldColors, _ := os.ReadFile(opts.ColorsOutput())
var primaryDark, primaryLight, surface string
var primaryDark, primaryLight, surfaceDark, surfaceLight, containerDark, containerLight string
var dank16JSON string
var importArgs []string
var sourceImage string
@@ -373,7 +373,10 @@ func buildOnce(opts *Options) (bool, error) {
log.Info("Using stock/custom theme colors with matugen base")
primaryDark = extractNestedColor(opts.StockColors, "primary", "dark")
primaryLight = extractNestedColor(opts.StockColors, "primary", "light")
surface = extractNestedColor(opts.StockColors, "surface", "dark")
surfaceDark = extractNestedColor(opts.StockColors, "surface", "dark")
surfaceLight = extractNestedColor(opts.StockColors, "surface", "light")
containerDark = extractNestedColor(opts.StockColors, "primary_container", "dark")
containerLight = extractNestedColor(opts.StockColors, "primary_container", "light")
if primaryDark == "" {
return false, fmt.Errorf("failed to extract primary dark from stock colors")
@@ -382,7 +385,7 @@ func buildOnce(opts *Options) (bool, error) {
primaryLight = primaryDark
}
dank16JSON = generateDank16Variants(primaryDark, primaryLight, surface, opts.Mode)
dank16JSON = generateDank16Variants(primaryDark, primaryLight, surfaceDark, surfaceLight, containerDark, containerLight, opts.Mode)
importData := fmt.Sprintf(`{"colors": %s, "dank16": %s}`, opts.StockColors, dank16JSON)
importArgs = []string{"--import-json-string", importData}
@@ -403,7 +406,10 @@ func buildOnce(opts *Options) (bool, error) {
primaryDark = extractMatugenColor(matJSON, "primary", "dark")
primaryLight = extractMatugenColor(matJSON, "primary", "light")
surface = extractMatugenColor(matJSON, "surface", "dark")
surfaceDark = extractMatugenColor(matJSON, "surface", "dark")
surfaceLight = extractMatugenColor(matJSON, "surface", "light")
containerDark = extractMatugenColor(matJSON, "primary_container", "dark")
containerLight = extractMatugenColor(matJSON, "primary_container", "light")
if primaryDark == "" {
return false, fmt.Errorf("failed to extract primary color")
@@ -412,7 +418,7 @@ func buildOnce(opts *Options) (bool, error) {
primaryLight = primaryDark
}
dank16JSON = generateDank16Variants(primaryDark, primaryLight, surface, opts.Mode)
dank16JSON = generateDank16Variants(primaryDark, primaryLight, surfaceDark, surfaceLight, containerDark, containerLight, opts.Mode)
importArgs = []string{"--import-json-string", buildImportData(dank16JSON, sourceImage)}
log.Infof("Running matugen %s with dank16 injection", opts.Kind)
@@ -1125,13 +1131,16 @@ func extractNestedColor(jsonStr, colorName, variant string) string {
return color
}
func generateDank16Variants(primaryDark, primaryLight, surface string, mode ColorMode) string {
func generateDank16Variants(primaryDark, primaryLight, surfaceDark, surfaceLight, containerDark, containerLight string, mode ColorMode) string {
variantOpts := dank16.VariantOptions{
PrimaryDark: primaryDark,
PrimaryLight: primaryLight,
Background: surface,
UseDPS: true,
IsLightMode: mode == ColorModeLight,
PrimaryDark: primaryDark,
PrimaryLight: primaryLight,
BackgroundDark: surfaceDark,
BackgroundLight: surfaceLight,
ContainerDark: containerDark,
ContainerLight: containerLight,
UseDPS: true,
IsLightMode: mode == ColorModeLight,
}
variantColors := dank16.GenerateVariantPalette(variantOpts)
return dank16.GenerateVariantJSON(variantColors)
+4 -3
View File
@@ -973,11 +973,12 @@ func (r *RegionSelector) renderSurface(os *OutputSurface) {
case !slot.cacheValid(srcBuf, r.selection.dragging, r.showCapturedCursor, r.phase, handles, shift):
slot.shm.CopyFrom(srcBuf)
r.dimBackground(slot.shm)
scale := 1
effectiveScale := 1.0
if os.output != nil {
scale = scaleFactor(os.output.effectiveScale())
effectiveScale = os.output.effectiveScale()
}
r.drawHUD(slot.shm.Data(), slot.shm.Stride, slot.shm.Width, slot.shm.Height, os.screenFormat, scale)
hudScale := r.hudScale(effectiveScale)
r.drawHUD(slot.shm.Data(), slot.shm.Stride, slot.shm.Width, slot.shm.Height, os.screenFormat, hudScale)
slot.backgroundInitialized = true
slot.backgroundSource = srcBuf
slot.backgroundDragging = r.selection.dragging
+32 -1
View File
@@ -4,6 +4,8 @@ import (
"fmt"
"math"
"slices"
"strconv"
"strings"
)
const (
@@ -630,6 +632,32 @@ func (r *RegionSelector) drawScrollBar(data []byte, stride, bufW, bufH int, form
style.TextR, style.TextG, style.TextB, format)
}
const (
minHUDScale = 1
maxHUDScale = 4
)
func (r *RegionSelector) hudScale(effectiveScale float64) int {
hudOpt := "auto"
if r.screenshoter != nil && r.screenshoter.config.HUD != "" {
hudOpt = strings.ToLower(strings.TrimSpace(r.screenshoter.config.HUD))
}
switch hudOpt {
case "off", "none", "false", "0":
return 0
case "", "auto":
return min(scaleFactor(effectiveScale), maxHUDScale)
default:
if val, err := strconv.ParseFloat(hudOpt, 64); err == nil {
if val <= 0 {
return 0
}
return min(max(scaleFactor(val), minHUDScale), maxHUDScale)
}
return min(scaleFactor(effectiveScale), maxHUDScale)
}
}
type hudItem struct {
key, desc string
}
@@ -657,6 +685,9 @@ func (r *RegionSelector) hudItems() []hudItem {
}
func (r *RegionSelector) hudDimensions(bufW, bufH, scale int) (hudX, hudY, hudW, hudH int) {
if scale <= 0 {
return 0, 0, 0, 0
}
scale = scaleFactor(float64(scale))
charH := fontCharH * scale
padding, itemSpacing := 12*scale, 24*scale
@@ -680,7 +711,7 @@ func (r *RegionSelector) hudDimensions(bufW, bufH, scale int) (hudX, hudY, hudW,
}
func (r *RegionSelector) drawHUD(data []byte, stride, bufW, bufH int, format uint32, scale int) {
if r.selection.dragging {
if r.selection.dragging || scale <= 0 {
return
}
@@ -238,6 +238,12 @@ func TestHUDDimensionsContainText(t *testing.T) {
},
}
// When scale <= 0, hudDimensions should return zero
hx, hy, hw, hh := r.hudDimensions(bufW, bufH, 0)
if hx != 0 || hy != 0 || hw != 0 || hh != 0 {
t.Fatalf("expected 0 dimensions for scale 0, got %d, %d, %d, %d", hx, hy, hw, hh)
}
for _, scale := range []int{1, 2} {
hudX, hudY, hudW, hudH := r.hudDimensions(bufW, bufH, scale)
data := make([]byte, bufH*stride)
@@ -260,6 +266,47 @@ func TestHUDDimensionsContainText(t *testing.T) {
}
}
func TestHUDScale(t *testing.T) {
tests := []struct {
hudOpt string
effectiveScale float64
expectedScale int
}{
{"auto", 1.0, 1},
{"auto", 1.8, 2},
{"auto", 2.4, 2},
{"", 1.0, 1},
{"", 2.0, 2},
{"off", 1.0, 0},
{"OFF", 2.0, 0},
{"none", 1.5, 0},
{"false", 1.0, 0},
{"0", 2.0, 0},
{"0.0", 1.0, 0},
{"-1", 1.0, 0},
{"1", 1.0, 1},
{"1.5", 1.0, 2},
{"2", 1.0, 2},
{"3", 1.0, 3},
{"4", 1.0, 4},
{"1000", 1.0, 4},
{"0.2", 1.0, 1},
{"invalid", 1.7, 2},
}
for _, tc := range tests {
r := &RegionSelector{
screenshoter: &Screenshoter{
config: Config{HUD: tc.hudOpt},
},
}
got := r.hudScale(tc.effectiveScale)
if got != tc.expectedScale {
t.Errorf("hudScale(%q, %v) = %d, expected %d", tc.hudOpt, tc.effectiveScale, got, tc.expectedScale)
}
}
}
func TestOverlayDeltaHandlesClampedMonitorEdge(t *testing.T) {
prev := &overlay{
interior: dirtyRect{100, 100, 1920, 1080},
+2
View File
@@ -73,6 +73,7 @@ type Config struct {
Geometry bool
AllowMultiple bool
IntervalMs int
HUD string
// SelectorHook runs as the interactive selector starts (true) and ends (false).
SelectorHook func(begin bool)
}
@@ -90,5 +91,6 @@ func DefaultConfig() Config {
Clipboard: true,
SaveFile: true,
Notify: true,
HUD: "auto",
}
}
@@ -49,6 +49,8 @@ func HandleRequest(conn *models.Conn, req models.Request, m *Manager) {
handlePinEntry(conn, req, m)
case "clipboard.unpinEntry":
handleUnpinEntry(conn, req, m)
case "clipboard.editEntry":
handleEditEntry(conn, req, m)
case "clipboard.getPinnedEntries":
handleGetPinnedEntries(conn, req, m)
case "clipboard.getPinnedCount":
@@ -422,3 +424,24 @@ func handleCopyFile(conn *models.Conn, req models.Request, m *Manager) {
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "copied"})
}
func handleEditEntry(conn *models.Conn, req models.Request, m *Manager) {
id, err := params.Int(req.Params, "id")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
text, err := params.String(req.Params, "text")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
if err := m.EditEntry(uint64(id), text); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "entry updated"})
}
+114
View File
@@ -795,6 +795,13 @@ func selectAltTextMimeType(mimes []string) string {
return ""
}
func isTextMimeType(mime string) bool {
if mime == "" || strings.HasPrefix(mime, "text/plain") {
return true
}
return slices.Contains(altTextMimeTypes, mime)
}
func (m *Manager) isImageMimeType(mime string) bool {
return strings.HasPrefix(mime, "image/")
}
@@ -1963,6 +1970,113 @@ func (m *Manager) UnpinEntry(id uint64) error {
return err
}
func (m *Manager) EditEntry(id uint64, text string) error {
if m.db == nil {
return fmt.Errorf("database not available")
}
data := []byte(text)
mimeType := "text/plain;charset=utf-8"
if len(bytes.TrimSpace(data)) == 0 {
return fmt.Errorf("cannot save empty entry")
}
cfg := m.getConfig()
if int64(len(data)) > cfg.MaxEntrySize {
return fmt.Errorf("data too large")
}
newHash := computeHash(data)
preview := m.textPreview(data)
err := m.dbUpdate(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("clipboard"))
if b == nil {
return fmt.Errorf("clipboard bucket missing")
}
oldKey := itob(id)
v := b.Get(oldKey)
if v == nil {
return errEntryNotFound
}
existing, err := decodeEntry(v)
if err != nil {
return err
}
if existing.IsImage {
return errors.New("cannot edit image entry")
}
if !isTextMimeType(existing.MimeType) {
return errors.New("cannot edit non-text entry")
}
wasPinned := existing.Pinned
if err := b.Delete(oldKey); err != nil {
return err
}
if err := m.deduplicateInTx(b, newHash); err != nil {
return err
}
if wasPinned {
c := b.Cursor()
for k, val := c.First(); k != nil; k, val = c.Next() {
if extractHash(val) == newHash {
meta, err := decodeEntryMeta(val)
if err == nil && meta.Pinned {
return nil
}
}
}
}
newID, err := b.NextSequence()
if err != nil {
return err
}
newEntry := Entry{
ID: newID,
Data: data,
MimeType: mimeType,
Preview: preview,
Size: len(data),
Timestamp: time.Now(),
IsImage: false,
Hash: newHash,
Pinned: wasPinned,
}
encoded, err := encodeEntry(newEntry)
if err != nil {
return err
}
if err := b.Put(itob(newID), encoded); err != nil {
return err
}
return m.trimLengthInTx(b)
})
if err == nil {
if clipErr := m.SetClipboard(data, mimeType); clipErr != nil {
log.Errorf("Failed to set clipboard selection: %v", clipErr)
}
m.updateState()
m.notifySubscribers()
}
return err
}
func (m *Manager) GetPinnedEntries() []Entry {
if m.db == nil {
return nil
@@ -45,9 +45,15 @@ func newTestManagerWithDB(t *testing.T) *Manager {
db.Close()
})
mockCtx := mocks_wlcontext.NewMockWaylandContext(t)
mockCtx.EXPECT().Post(mock.AnythingOfType("func()")).Run(func(fn func()) {
fn()
}).Maybe()
return &Manager{
config: DefaultConfig(),
db: db,
wlCtx: mockCtx,
}
}
@@ -578,6 +584,175 @@ func TestCreateHistoryEntryFromPinned_KeepsLatestUnpinnedDuplicate(t *testing.T)
assert.NotEqual(t, firstDuplicate.ID, latestDuplicate.ID)
}
func TestEditEntry_UnpinnedEntry(t *testing.T) {
m := newTestManagerWithDB(t)
id := storeTestEntry(t, m, "original unpinned")
require.NoError(t, m.EditEntry(id, "edited unpinned"))
history := m.GetHistory()
require.Len(t, history, 1)
assert.Equal(t, "edited unpinned", history[0].Preview)
assert.False(t, history[0].Pinned)
assert.NotEqual(t, id, history[0].ID)
// Old entry should not exist
oldEntry, err := m.GetEntry(id)
assert.ErrorIs(t, err, errEntryNotFound)
assert.Nil(t, oldEntry)
}
func TestEditEntry_PinnedEntryRemainsPinned(t *testing.T) {
m := newTestManagerWithDB(t)
id := storeTestEntry(t, m, "original pinned")
require.NoError(t, m.PinEntry(id))
assert.Equal(t, 1, m.GetPinnedCount())
require.NoError(t, m.EditEntry(id, "edited pinned"))
history := m.GetHistory()
require.Len(t, history, 1)
assert.Equal(t, "edited pinned", history[0].Preview)
assert.True(t, history[0].Pinned)
pinnedEntries := m.GetPinnedEntries()
require.Len(t, pinnedEntries, 1)
assert.Equal(t, "edited pinned", pinnedEntries[0].Preview)
assert.Equal(t, 1, m.GetPinnedCount())
// Old pinned entry should be deleted
oldEntry, err := m.GetEntry(id)
assert.ErrorIs(t, err, errEntryNotFound)
assert.Nil(t, oldEntry)
}
func TestEditEntry_NotFound(t *testing.T) {
m := newTestManagerWithDB(t)
err := m.EditEntry(99999, "new text")
assert.ErrorIs(t, err, errEntryNotFound)
}
func TestEditEntry_ImageReturnsError(t *testing.T) {
m := newTestManagerWithDB(t)
imgEntry := Entry{
Data: []byte{0x89, 0x50, 0x4E, 0x47},
MimeType: "image/png",
Preview: "[[ image ]]",
Size: 4,
Timestamp: time.Now().Truncate(time.Second),
IsImage: true,
}
require.NoError(t, m.storeEntry(imgEntry))
history := m.GetHistory()
require.Len(t, history, 1)
id := history[0].ID
err := m.EditEntry(id, "replacement text")
assert.Error(t, err)
assert.Contains(t, err.Error(), "cannot edit image entry")
}
func TestEditEntry_NonTextReturnsError(t *testing.T) {
m := newTestManagerWithDB(t)
uriEntry := Entry{
Data: []byte("file:///path/to/file\n"),
MimeType: "text/uri-list",
Preview: "file:///path/to/file",
Size: 21,
Timestamp: time.Now().Truncate(time.Second),
IsImage: false,
}
require.NoError(t, m.storeEntry(uriEntry))
history := m.GetHistory()
require.Len(t, history, 1)
id := history[0].ID
err := m.EditEntry(id, "replacement text")
assert.Error(t, err)
assert.Contains(t, err.Error(), "cannot edit non-text entry")
}
func TestEditEntry_AltTextMimeTypesAllowed(t *testing.T) {
for _, mime := range []string{"UTF8_STRING", "STRING", "TEXT", "text/plain;charset=utf-8", "text/plain"} {
t.Run(mime, func(t *testing.T) {
m := newTestManagerWithDB(t)
entry := Entry{
Data: []byte("old text"),
MimeType: mime,
Preview: "old text",
Size: 8,
Timestamp: time.Now().Truncate(time.Second),
IsImage: false,
}
require.NoError(t, m.storeEntry(entry))
history := m.GetHistory()
require.Len(t, history, 1)
id := history[0].ID
err := m.EditEntry(id, "replacement text")
assert.NoError(t, err)
})
}
}
func TestEditEntry_EmptyOrWhitespaceReturnsError(t *testing.T) {
m := newTestManagerWithDB(t)
id := storeTestEntry(t, m, "keep me")
for _, badText := range []string{"", " ", "\t\n\r"} {
err := m.EditEntry(id, badText)
assert.Error(t, err)
assert.Contains(t, err.Error(), "cannot save empty entry")
}
history := m.GetHistory()
require.Len(t, history, 1)
assert.Equal(t, "keep me", history[0].Preview)
}
func TestEditEntry_DataTooLargeReturnsError(t *testing.T) {
m := newTestManagerWithDB(t)
m.config.MaxEntrySize = 10
id := storeTestEntry(t, m, "small")
err := m.EditEntry(id, "this text exceeds the 10-byte limit")
assert.Error(t, err)
assert.Contains(t, err.Error(), "data too large")
history := m.GetHistory()
require.Len(t, history, 1)
assert.Equal(t, "small", history[0].Preview)
}
func TestHandleEditEntry_SuccessAndValidation(t *testing.T) {
m := newTestManagerWithDB(t)
id := storeTestEntry(t, m, "before edit")
mc := newClipboardTestConn()
conn := models.NewConn(mc)
handleEditEntry(conn, models.Request{
ID: 1,
Params: map[string]any{
"id": float64(id),
"text": "after edit",
},
}, m)
var resp models.Response[models.SuccessResult]
require.NoError(t, json.NewDecoder(mc.writeBuf).Decode(&resp))
assert.Empty(t, resp.Error)
require.NotNil(t, resp.Result)
assert.True(t, resp.Result.Success)
history := m.GetHistory()
require.Len(t, history, 1)
assert.Equal(t, "after edit", history[0].Preview)
}
func TestManager_ConcurrentSubscriberAccess(t *testing.T) {
m := &Manager{
subscribers: make(map[string]chan State),
+2 -2
View File
@@ -126,7 +126,7 @@ func findKeyboards() ([]EvdevDevice, error) {
var keyboards []EvdevDevice
for _, path := range matches {
device, err := evdev.Open(path)
device, err := evdev.OpenWithFlags(path, os.O_RDONLY)
if err != nil {
continue
}
@@ -207,7 +207,7 @@ func (m *Manager) watchForNewKeyboards() {
continue
}
device, err := evdev.Open(event.Name)
device, err := evdev.OpenWithFlags(event.Name, os.O_RDONLY)
if err != nil {
m.devicesMutex.Unlock()
continue
+1
View File
@@ -33,6 +33,7 @@ func convertStatus(status *ipnstate.Status) *TailscaleState {
if status.Self != nil {
state.Self = convertPeerStatus(status.Self, users)
state.Self.Online = true
}
if len(status.Peer) > 0 {
+7 -5
View File
@@ -95,7 +95,7 @@ func newManager(client tailscaleClient) *Manager {
func (m *Manager) watchLoop(ctx context.Context) {
defer m.watchWG.Done()
mask := ipn.NotifyInitialState | ipn.NotifyInitialNetMap | ipn.NotifyRateLimit
mask := ipn.NotifyInitialState | ipn.NotifyInitialPrefs | ipn.NotifyInitialNetMap | ipn.NotifyRateLimit
backoff := time.Second
unreachableSent := false
@@ -122,8 +122,13 @@ func (m *Manager) watchLoop(ctx context.Context) {
log.Info("[Tailscale] Connected to IPN bus")
m.markAvailable()
select {
case m.dirty <- struct{}{}:
default:
}
for {
notify, err := watcher.Next()
_, err := watcher.Next()
if err != nil {
log.Warnf("[Tailscale] IPN bus error: %v", err)
break
@@ -131,9 +136,6 @@ func (m *Manager) watchLoop(ctx context.Context) {
backoff = time.Second
if notify.State == nil && notify.NetMap == nil { //nolint:staticcheck // NetMap is deprecated upstream but still the only activity signal on some platforms
continue
}
select {
case m.dirty <- struct{}{}:
default:
@@ -418,3 +418,55 @@ func TestManager_Actions_PropagateError(t *testing.T) {
assert.Error(t, m.SetExitNode("nABC123"))
assert.Error(t, m.SetAllowLANAccess(true))
}
type dynamicWatcher struct {
ctx context.Context
ch chan ipn.Notify
}
func (w *dynamicWatcher) Next() (ipn.Notify, error) {
select {
case n, ok := <-w.ch:
if !ok {
return ipn.Notify{}, fmt.Errorf("watcher closed")
}
return n, nil
case <-w.ctx.Done():
return ipn.Notify{}, w.ctx.Err()
}
}
func (w *dynamicWatcher) Close() error {
return nil
}
func TestWatchLoop_NotifyWithoutStateOrNetMap(t *testing.T) {
var statusCalls atomic.Int32
notifyCh := make(chan ipn.Notify, 1)
client := &mockClient{
watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
return &dynamicWatcher{ctx: ctx, ch: notifyCh}, nil
},
statusFn: func(ctx context.Context) (*ipnstate.Status, error) {
statusCalls.Add(1)
return runningStatus(), nil
},
}
m := newManager(client)
defer m.Close()
// Wait for the initial connect-time status call to complete.
require.Eventually(t, func() bool {
return statusCalls.Load() == 1
}, 2*time.Second, 10*time.Millisecond)
// Send an event with State == nil and NetMap == nil.
notifyCh <- ipn.Notify{Engine: &ipn.EngineStatus{}}
// Verify that the notification without State or NetMap triggers a second status call.
require.Eventually(t, func() bool {
return statusCalls.Load() >= 2
}, 2*time.Second, 10*time.Millisecond)
}
+1 -1
View File
@@ -111,7 +111,7 @@
inherit version;
pname = "dms-shell";
src = ./core;
vendorHash = "sha256-nswS/ygp0oYMXp6YHbbIxBha4xvzjKAxcAS5yOpbslg=";
vendorHash = "sha256-ejDi+TzstUtxtWpcl5yYw4UZuTnZraOsIdJuXPKxJ6s=";
subPackages = [ "cmd/dms" ];
+2 -2
View File
@@ -398,8 +398,8 @@ Item {
IpcHandler {
function getFocusedScreenName() {
if (CompositorService.isHyprland && Hyprland.focusedWorkspace && Hyprland.focusedWorkspace.monitor) {
return Hyprland.focusedWorkspace.monitor.name;
if (CompositorService.isHyprland && Hyprland.focusedMonitor) {
return Hyprland.focusedMonitor.name;
}
if (CompositorService.isNiri && NiriService.currentOutput) {
return NiriService.currentOutput;
@@ -28,7 +28,7 @@ Item {
readonly property bool blurActive: renderActive && openState && BlurService.enabled && Theme.connectedSurfaceBlurEnabled
readonly property bool hasPinnedDuplicate: !!entry && !entry.pinned && ClipboardService.getPinnedEntryByHash(entry.hash) !== null
readonly property bool canEditEntry: !!entry && !(entry.isImage ?? false)
readonly property bool canEditEntry: ClipboardService.canEditEntry(entry)
readonly property bool hasTextAlternative: !!entry && (entry.altMimeType ?? "") !== ""
readonly property string pinText: entry?.pinned || hasPinnedDuplicate ? I18n.tr("Unpin") : I18n.tr("Pin")
readonly property string pinIcon: entry?.pinned || hasPinnedDuplicate ? "keep_off" : "push_pin"
+88 -29
View File
@@ -14,6 +14,20 @@ Item {
property var entry: null
property string editorText: ""
property bool textLoaded: false
property bool loadFailed: false
Timer {
id: loadTimeoutTimer
interval: 5000
repeat: false
onTriggered: {
if (!root.textLoaded) {
root.loadFailed = true;
ToastService.showError(I18n.tr("Failed to load clipboard entry", "clipboard editor: fetching the entry's full text failed"));
}
}
}
function releaseTextInputFocus() {
if (editField) {
@@ -68,35 +82,22 @@ Item {
}
}
function setEntry(newEntry) {
entry = newEntry;
editorText = newEntry?.text ?? newEntry?.preview ?? "";
if (editField) {
editField.text = editorText;
}
Qt.callLater(function () {
if (editField) {
editField.forceActiveFocus();
editField.cursorPosition = editField.text.length;
}
});
if (!newEntry || newEntry.isImage) {
return;
}
const requestedId = newEntry.id;
function fetchEntry(requestedId) {
loadFailed = false;
loadTimeoutTimer.restart();
DMSService.sendRequest("clipboard.getEntry", {
"id": requestedId
}, function (response) {
if (response.error) {
return;
}
loadTimeoutTimer.stop();
if (!root.entry || root.entry.id !== requestedId) {
return;
}
if (!response.result) {
ClipboardService.refresh();
if (response.error || !response.result) {
root.loadFailed = true;
ToastService.showError(I18n.tr("Failed to load clipboard entry", "clipboard editor: fetching the entry's full text failed"));
if (!response.result) {
ClipboardService.refresh();
}
return;
}
const result = response.result;
@@ -108,8 +109,12 @@ Item {
}
if (!fullText || fullText.length === 0) {
root.loadFailed = true;
ToastService.showError(I18n.tr("Failed to load clipboard entry", "clipboard editor: fetching the entry's full text failed"));
return;
}
root.loadFailed = false;
root.textLoaded = true;
root.editorText = fullText;
if (editField) {
if (fullText.length > 50000) {
@@ -127,15 +132,47 @@ Item {
});
}
function setEntry(newEntry) {
entry = newEntry;
loadFailed = false;
const hasFullText = typeof newEntry?.text === "string";
textLoaded = !newEntry || !ClipboardService.canEditEntry(newEntry) || !(newEntry.id > 0) || hasFullText;
editorText = newEntry?.text ?? newEntry?.preview ?? "";
if (editField) {
editField.text = editorText;
}
Qt.callLater(function () {
if (editField) {
editField.forceActiveFocus();
editField.cursorPosition = editField.text.length;
}
});
if (hasFullText || !newEntry || !ClipboardService.canEditEntry(newEntry) || !(newEntry.id > 0)) {
loadTimeoutTimer.stop();
return;
}
fetchEntry(newEntry.id);
}
function saveEntry(action) {
const saveAction = action ?? "history";
DMSService.sendRequest("clipboard.copy", {
"text": root.editorText
}, function (response) {
if (response.error) {
ToastService.showError(I18n.tr("Failed to update clipboard"));
return;
const entryId = root.entry?.id ?? 0;
if (entryId > 0 && !root.textLoaded) {
if (root.loadFailed) {
ToastService.showError(I18n.tr("Failed to load clipboard entry", "clipboard editor: fetching the entry's full text failed"));
root.fetchEntry(entryId);
} else {
ToastService.showWarning(I18n.tr("Loading full text, please wait...", "clipboard editor: save blocked while the entry's full text is still being fetched"));
}
return;
}
loadTimeoutTimer.stop();
const onComplete = function () {
if (saveAction === "history") {
modal.mode = "history";
Qt.callLater(function () {
@@ -154,6 +191,27 @@ Item {
if (saveAction === "paste") {
ClipboardService.pasteClipboard(modal.hide);
}
};
if (entryId > 0) {
ClipboardService.editEntry(root.entry, root.editorText, function (response) {
if (response.error) {
ToastService.showError(I18n.tr("Failed to update clipboard"));
return;
}
onComplete();
});
return;
}
DMSService.sendRequest("clipboard.copy", {
"text": root.editorText
}, function (response) {
if (response.error) {
ToastService.showError(I18n.tr("Failed to update clipboard"));
return;
}
onComplete();
});
}
@@ -292,6 +350,7 @@ Item {
width: cancelButton.width
height: buttonHeight
opacity: root.textLoaded ? 1 : 0.6
Rectangle {
anchors.fill: parent
@@ -181,11 +181,44 @@ FocusScope {
}
function editEntry(entry) {
if (!entry || entry.isImage) {
if (!ClipboardService.canEditEntry(entry)) {
return;
}
editorView.setEntry(entry);
mode = "editor";
const requestedId = entry.id;
if (!requestedId) {
editorView.setEntry(entry);
mode = "editor";
return;
}
DMSService.sendRequest("clipboard.getEntry", {
"id": requestedId
}, function (response) {
if (response.error || !response.result) {
ToastService.showError(I18n.tr("Failed to load clipboard entry", "clipboard editor: fetching the entry's full text failed"));
if (!response.result) {
ClipboardService.refresh();
}
return;
}
const result = response.result;
let fullText = "";
if (result?.data) {
fullText = editorView.decodeEntryData(result.data);
} else {
fullText = result?.preview ?? "";
}
if (!fullText && (entry.preview ?? "").length > 0) {
ToastService.showError(I18n.tr("Failed to load clipboard entry", "clipboard editor: fetching the entry's full text failed"));
return;
}
const entryWithText = Object.assign({}, entry, { "text": fullText });
editorView.setEntry(entryWithText);
mode = "editor";
});
}
function resetState() {
@@ -34,6 +34,10 @@ PluginComponent {
TailscaleService.connectTailscale(null);
}
onCcWidgetExpanded: {
TailscaleService.refresh(null);
}
ccDetailContent: Component {
Rectangle {
id: detailRoot
+4 -4
View File
@@ -103,8 +103,8 @@ Item {
function triggerControlCenterOnFocusedScreen() {
let focusedScreenName = "";
if (CompositorService.isHyprland && Hyprland.focusedWorkspace && Hyprland.focusedWorkspace.monitor) {
focusedScreenName = Hyprland.focusedWorkspace.monitor.name;
if (CompositorService.isHyprland && Hyprland.focusedMonitor) {
focusedScreenName = Hyprland.focusedMonitor.name;
} else if (CompositorService.isNiri && NiriService.currentOutput) {
focusedScreenName = NiriService.currentOutput;
} else if (CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle) {
@@ -132,8 +132,8 @@ Item {
function triggerWallpaperBrowserOnFocusedScreen() {
let focusedScreenName = "";
if (CompositorService.isHyprland && Hyprland.focusedWorkspace && Hyprland.focusedWorkspace.monitor) {
focusedScreenName = Hyprland.focusedWorkspace.monitor.name;
if (CompositorService.isHyprland && Hyprland.focusedMonitor) {
focusedScreenName = Hyprland.focusedMonitor.name;
} else if (CompositorService.isNiri && NiriService.currentOutput) {
focusedScreenName = NiriService.currentOutput;
} else if (CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle) {
+2 -14
View File
@@ -1168,20 +1168,8 @@ Item {
HoverHandler {
id: hoverPopoutHandler
enabled: (barConfig?.hoverPopouts ?? false) && !barWindow.clickThroughEnabled
property real lastGlobalX: 0
property real lastGlobalY: 0
onPointChanged: {
const gp = barUnitInset.mapToItem(null, point.position.x, point.position.y);
lastGlobalX = gp.x;
lastGlobalY = gp.y;
topBarContent.queueHoverPopout(gp.x, gp.y);
}
onHoveredChanged: {
topBarContent.updateHoverBarHovered(hovered);
}
onPointChanged: topBarContent.queueHoverFromItem(barUnitInset, point)
onHoveredChanged: topBarContent.updateHoverBarHovered(hovered)
}
}
}
+64 -11
View File
@@ -19,6 +19,8 @@ Item {
property var leftWidgetsModel
property var centerWidgetsModel
property var rightWidgetsModel
property var widgetOwner: null
property var hoverSections: null
property bool _animateFrameInsets: false
readonly property real innerPadding: barConfig?.innerPadding ?? 4
@@ -85,6 +87,34 @@ Item {
property alias vCenterSection: vCenterSection
property alias vRightSection: vRightSection
readonly property var _defaultHoverSections: barWindow.isVertical ? [
{
section: vLeftSection,
name: "left"
},
{
section: vCenterSection,
name: "center"
},
{
section: vRightSection,
name: "right"
}
] : [
{
section: hLeftSection,
name: "left"
},
{
section: hCenterSection,
name: "center"
},
{
section: hRightSection,
name: "right"
}
]
anchors.fill: parent
anchors.leftMargin: _leftMargin
anchors.rightMargin: _rightMargin
@@ -413,12 +443,8 @@ Item {
barContent: topBarContent
barWindow: topBarContent.barWindow
barConfig: topBarContent.barConfig
hLeftSection: topBarContent.hLeftSection
hCenterSection: topBarContent.hCenterSection
hRightSection: topBarContent.hRightSection
vLeftSection: topBarContent.vLeftSection
vCenterSection: topBarContent.vCenterSection
vRightSection: topBarContent.vRightSection
widgetOwner: topBarContent.widgetOwner
sections: topBarContent.hoverSections || topBarContent._defaultHoverSections
leftWidgetsModel: topBarContent.leftWidgetsModel
centerWidgetsModel: topBarContent.centerWidgetsModel
rightWidgetsModel: topBarContent.rightWidgetsModel
@@ -427,6 +453,28 @@ Item {
readonly property string activeHoverTrigger: hoverController.activeHoverTrigger
readonly property bool hoverPopoutsEnabled: hoverController.hoverPopoutsEnabled
function mapItemToScreen(item, x, y) {
if (!item || typeof item.mapToItem !== "function")
return null;
try {
const raw = item.mapToItem(null, x ?? 0, y ?? 0);
if (!raw)
return null;
return Qt.point(raw.x + (barWindow?.hostOriginX ?? 0), raw.y + (barWindow?.hostOriginY ?? 0));
} catch (e) {
return null;
}
}
function queueHoverFromItem(item, point) {
if (!point)
return;
const gp = mapItemToScreen(item, point.position.x, point.position.y);
if (!gp)
return;
queueHoverPopout(gp.x, gp.y);
}
function queueHoverPopout(gx, gy) {
hoverController.queueHoverPoint(gx, gy);
}
@@ -447,6 +495,10 @@ Item {
hoverController.updateBarHovered(hovered);
}
function invalidateHoverCandidateCache() {
hoverController.invalidateCandidateCache();
}
function resetHoverForBarGeometryChange() {
hoverController.resetForBarGeometryChange();
}
@@ -465,14 +517,13 @@ Item {
const centerSection = barWindow.isVertical ? vCenterSection : hCenterSection;
if (centerSection) {
if (barWindow.isVertical) {
const centerY = centerSection.height / 2;
return {
triggerPos: centerSection.mapToItem(null, 0, centerY),
triggerPos: mapItemToScreen(centerSection, 0, centerSection.height / 2),
triggerWidth: centerSection.height
};
}
return {
triggerPos: centerSection.mapToItem(null, 0, 0),
triggerPos: mapItemToScreen(centerSection, 0, 0),
triggerWidth: centerSection.width
};
}
@@ -480,7 +531,7 @@ Item {
const ref = opts.visualItem || widgetItem.visualContent || widgetItem;
const w = opts.triggerWidth !== undefined ? opts.triggerWidth : (widgetItem.visualWidth !== undefined ? widgetItem.visualWidth : widgetItem.width);
return {
triggerPos: ref.mapToItem(null, 0, 0),
triggerPos: mapItemToScreen(ref, 0, 0),
triggerWidth: w
};
}
@@ -987,7 +1038,9 @@ Item {
if (loader.item.setBarContext)
loader.item.setBarContext(barPosition, effectiveBarConfig?.bottomGap ?? 0);
if (loader.item.setTriggerPosition) {
const globalPos = launcherButton.visualContent.mapToItem(null, 0, 0);
const globalPos = topBarContent.mapItemToScreen(launcherButton.visualContent, 0, 0);
if (!globalPos)
return false;
const currentScreen = barWindow.screen;
const pos = SettingsData.getPopupTriggerPosition(globalPos, currentScreen, barWindow.effectiveBarThickness, launcherButton.visualWidth, effectiveBarConfig?.spacing ?? 4, barPosition, effectiveBarConfig);
loader.item.setTriggerPosition(pos.x, pos.y, pos.width, launcherButton.section, currentScreen, barPosition, barWindow.effectiveBarThickness, effectiveBarConfig?.spacing ?? 4, effectiveBarConfig);
@@ -10,12 +10,8 @@ Item {
required property var barContent
required property var barWindow
required property var barConfig
required property var hLeftSection
required property var hCenterSection
required property var hRightSection
required property var vLeftSection
required property var vCenterSection
required property var vRightSection
property var widgetOwner: null
property var sections: []
property var leftWidgetsModel
property var centerWidgetsModel
@@ -46,6 +42,7 @@ Item {
onLeftWidgetsModelChanged: invalidateCandidateCache()
onCenterWidgetsModelChanged: invalidateCandidateCache()
onRightWidgetsModelChanged: invalidateCandidateCache()
onSectionsChanged: invalidateCandidateCache()
onHoverPopoutsEnabledChanged: {
if (hoverPopoutsEnabled)
@@ -62,7 +59,7 @@ Item {
Component.onDestruction: _disconnectCandidateWatchers()
Connections {
target: root.barContent
target: root.widgetOwner || root.barContent
function onWidthChanged() {
root.invalidateCandidateCache();
@@ -227,41 +224,12 @@ Item {
}
function _getBarSections() {
if (barWindow.isVertical) {
return [
{
section: vLeftSection,
name: "left"
},
{
section: vCenterSection,
name: "center"
},
{
section: vRightSection,
name: "right"
}
];
}
return [
{
section: hLeftSection,
name: "left"
},
{
section: hCenterSection,
name: "center"
},
{
section: hRightSection,
name: "right"
}
];
return root.sections || [];
}
// The widget registry is keyed by (widgetId, screenName)
function _itemBelongsToThisBar(item) {
const owner = barContent;
const owner = root.widgetOwner || barContent;
if (!owner || !item)
return true;
let node = item;
@@ -489,17 +457,15 @@ Item {
}
function _globalItemBounds(item) {
try {
const topLeft = item.mapToItem(null, 0, 0);
return {
x: topLeft.x,
y: topLeft.y,
width: item.width,
height: item.height
};
} catch (e) {
const topLeft = barContent.mapItemToScreen(item, 0, 0);
if (!topLeft)
return null;
}
return {
x: topLeft.x,
y: topLeft.y,
width: item.width,
height: item.height
};
}
function _hitBoundsForWidget(widgetItem, wrapper) {
@@ -10,7 +10,7 @@ import qs.Widgets
BasePill {
id: root
readonly property string focusedScreenName: (CompositorService.isHyprland && typeof Hyprland !== "undefined" && Hyprland.focusedWorkspace && Hyprland.focusedWorkspace.monitor ? (Hyprland.focusedWorkspace.monitor.name || "") : CompositorService.isNiri && typeof NiriService !== "undefined" && NiriService.currentOutput ? NiriService.currentOutput : "")
readonly property string focusedScreenName: (CompositorService.isHyprland && typeof Hyprland !== "undefined" && Hyprland.focusedMonitor ? (Hyprland.focusedMonitor.name || "") : CompositorService.isNiri && typeof NiriService !== "undefined" && NiriService.currentOutput ? NiriService.currentOutput : "")
readonly property string targetScreenName: parentScreen?.name || focusedScreenName
function resolveNotepadInstance() {
@@ -1,5 +1,6 @@
import QtQuick
import QtQuick.Effects
import QtQuick.Layouts
import Quickshell
import Quickshell.Services.SystemTray
import Quickshell.Wayland
@@ -2028,7 +2029,7 @@ BasePill {
}
}
Row {
RowLayout {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.right: parent.right
@@ -2038,9 +2039,9 @@ BasePill {
visible: !menuEntry?.isSeparator
Rectangle {
width: Theme.iconSizeSmall
height: Theme.iconSizeSmall
anchors.verticalCenter: parent.verticalCenter
Layout.preferredWidth: Theme.iconSizeSmall
Layout.preferredHeight: Theme.iconSizeSmall
Layout.alignment: Qt.AlignVCenter
visible: menuEntry?.buttonType !== undefined && menuEntry.buttonType !== 0
radius: menuEntry?.buttonType === 2 ? width / 2 : 2
border.width: 1
@@ -2066,9 +2067,9 @@ BasePill {
}
Item {
width: Theme.iconSizeSmall
height: Theme.iconSizeSmall
anchors.verticalCenter: parent.verticalCenter
Layout.preferredWidth: Theme.iconSizeSmall
Layout.preferredHeight: Theme.iconSizeSmall
Layout.alignment: Qt.AlignVCenter
visible: (menuEntry?.icon ?? "") !== ""
Image {
@@ -2086,22 +2087,23 @@ BasePill {
font.pixelSize: Theme.fontSizeSmall
color: (menuEntry?.enabled !== false) ? Theme.surfaceText : Theme.surfaceTextMedium
elide: Text.ElideRight
anchors.verticalCenter: parent.verticalCenter
width: Math.max(150, parent.width - 64)
Layout.alignment: Qt.AlignVCenter
Layout.fillWidth: true
Layout.minimumWidth: 0
wrapMode: Text.NoWrap
}
Item {
width: Theme.iconSizeSmall
height: Theme.iconSizeSmall
anchors.verticalCenter: parent.verticalCenter
Layout.preferredWidth: Theme.iconSizeSmall
Layout.preferredHeight: Theme.iconSizeSmall
Layout.alignment: Qt.AlignVCenter
visible: menuEntry?.hasChildren ?? false
DankIcon {
anchors.centerIn: parent
name: "chevron_right"
size: Theme.iconSizeSmall - 2
color: Theme.widgetTextColor
visible: menuEntry?.hasChildren ?? false
}
}
}
@@ -153,6 +153,9 @@ Item {
property bool hasAdjacentLeftBar: false
property bool hasAdjacentRightBar: false
property var hyprlandOverviewLoader: root.hyprlandOverviewLoader
property var hostWindow: root.hostWindow
readonly property int hostOriginX: hostWindow?.hostOriginX ?? 0
readonly property int hostOriginY: hostWindow?.hostOriginY ?? 0
property var controlCenterButtonRef: null
property var clockButtonRef: null
property var systemUpdateButtonRef: null
@@ -269,9 +272,16 @@ Item {
return;
blurRebuildTimer.restart();
blurTrailTimer.restart();
componentProvider.invalidateHoverCandidateCache();
}
}
function onSatelliteRowMoved() {
kickBlur();
if (!root.motionRunning)
componentProvider.invalidateHoverCandidateCache();
}
function kickBlur() {
if (root.motionRunning)
return;
@@ -412,11 +422,26 @@ Item {
barWindow: satelliteBarWindow
rootWindow: root.hostWindow
barConfig: root.satelliteConfig
widgetOwner: root
hoverSections: [
{
section: leadingWidgetSection,
name: "left"
},
{
section: trailingWidgetSection,
name: "right"
}
]
leftWidgetsModel: emptyWidgetsModel
centerWidgetsModel: emptyWidgetsModel
rightWidgetsModel: emptyWidgetsModel
}
readonly property bool satelliteHoverTracking: root.visible && componentProvider.hoverPopoutsEnabled && !root.controller.inputSuspended
readonly property bool satellitesHovered: satelliteHoverTracking && (leadingEnvelopeHoverHandler.hovered || trailingEnvelopeHoverHandler.hovered || leadingWidgetHoverHandler.hovered || trailingWidgetHoverHandler.hovered)
onSatellitesHoveredChanged: componentProvider.updateHoverBarHovered(satellitesHovered)
Item {
id: leadingInputEnvelope
@@ -429,6 +454,12 @@ Item {
y: root.isVertical ? alongPos : crossPos
width: root.isVertical ? crossSize : alongSize
height: root.isVertical ? alongSize : crossSize
HoverHandler {
id: leadingEnvelopeHoverHandler
enabled: root.satelliteHoverTracking
onPointChanged: componentProvider.queueHoverFromItem(leadingInputEnvelope, point)
}
}
Item {
@@ -444,6 +475,12 @@ Item {
y: root.isVertical ? alongPos : crossPos
width: root.isVertical ? crossSize : alongSize
height: root.isVertical ? alongSize : crossSize
HoverHandler {
id: trailingEnvelopeHoverHandler
enabled: root.satelliteHoverTracking
onPointChanged: componentProvider.queueHoverFromItem(trailingInputEnvelope, point)
}
}
IslandSatelliteChrome {
@@ -500,8 +537,10 @@ Item {
y: root.isVertical ? alongPos : root.rowCross
width: root.isVertical ? crossSize : alongSize
height: root.isVertical ? alongSize : crossSize
onAlongPosChanged: root.kickBlur()
onAlongSizeChanged: root.kickBlur()
onXChanged: root.onSatelliteRowMoved()
onYChanged: root.onSatelliteRowMoved()
onWidthChanged: root.onSatelliteRowMoved()
onHeightChanged: root.onSatelliteRowMoved()
LeftSection {
id: leadingWidgetSection
@@ -521,6 +560,12 @@ Item {
crossEdgeExtension: root.crossEdgeExtension
edgeIsScreenEdge: root.edgeAligned
}
HoverHandler {
id: leadingWidgetHoverHandler
enabled: root.satelliteHoverTracking
onPointChanged: componentProvider.queueHoverFromItem(leadingInput, point)
}
}
Item {
@@ -534,8 +579,10 @@ Item {
y: root.isVertical ? alongPos : root.rowCross
width: root.isVertical ? crossSize : alongSize
height: root.isVertical ? alongSize : crossSize
onAlongPosChanged: root.kickBlur()
onAlongSizeChanged: root.kickBlur()
onXChanged: root.onSatelliteRowMoved()
onYChanged: root.onSatelliteRowMoved()
onWidthChanged: root.onSatelliteRowMoved()
onHeightChanged: root.onSatelliteRowMoved()
RightSection {
id: trailingWidgetSection
@@ -555,5 +602,11 @@ Item {
crossEdgeExtension: root.crossEdgeExtension
edgeIsScreenEdge: root.edgeAligned
}
HoverHandler {
id: trailingWidgetHoverHandler
enabled: root.satelliteHoverTracking
onPointChanged: componentProvider.queueHoverFromItem(trailingInput, point)
}
}
}
+1 -1
View File
@@ -351,7 +351,7 @@ Item {
onTriggered: dock.startupRevealDone = true
}
readonly property bool overviewReveal: CompositorService.overviewActiveOnScreen(screenName) && SettingsData.dockOpenOnOverview
readonly property bool overviewReveal: CompositorService.overviewActiveOnScreen(dock._dockScreenName) && SettingsData.dockOpenOnOverview
readonly property bool hoverOrActive: dockMouseArea.containsMouse || dockApps.requestDockShow || contextMenuOpen || revealSticky
onOverviewRevealChanged: {
@@ -43,7 +43,7 @@ Variants {
if (launcherOpen || _openedForCurrentHover)
return;
_openedForCurrentHover = true;
PopoutService.openDankLauncherV2(CompositorService.framePeerSurfacesUseOverlayForScreen(zoneLoader.modelData), true);
PopoutService.openDankLauncherV2(false, true);
}
screen: zoneLoader.modelData
+46 -10
View File
@@ -16,22 +16,43 @@ Item {
return Theme.surfaceText;
}
// The sysfs names DMS can write, in the order they are tried. Hardware that
// exposes none of them, a Lenovo IdeaPad on ideapad_laptop for instance,
// used to run the apply script to completion without writing anything, and
// sh exiting 0 was indistinguishable from a successful write.
readonly property string thresholdFileList: "charge_control_limit_max charge_stop_threshold charge_control_end_threshold"
readonly property int noThresholdExitCode: 2
property bool chargeLimitSupported: true
Process {
id: thresholdProbe
running: Qt.platform.os === "linux"
command: ["sh", "-c", "for bat in /sys/class/power_supply/BAT*; do for file in " + root.thresholdFileList + "; do [ -f \"$bat/$file\" ] && exit 0; done; done; exit 1"]
onExited: exitCode => root.chargeLimitSupported = exitCode === 0
}
Process {
id: applyLimitProcess
command: ["pkexec", "sh", "-c", "
found=0
for bat in /sys/class/power_supply/BAT*; do
if [ -f \"$bat/charge_control_limit_max\" ]; then
echo " + SettingsData.batteryChargeLimit + " > \"$bat/charge_control_limit_max\"
elif [ -f \"$bat/charge_stop_threshold\" ]; then
echo " + SettingsData.batteryChargeLimit + " > \"$bat/charge_stop_threshold\"
elif [ -f \"$bat/charge_control_end_threshold\" ]; then
echo " + SettingsData.batteryChargeLimit + " > \"$bat/charge_control_end_threshold\"
fi
for file in " + root.thresholdFileList + "; do
if [ -f \"$bat/$file\" ]; then
found=1
echo " + SettingsData.batteryChargeLimit + " > \"$bat/$file\" || exit 1
break
fi
done
done
[ \"$found\" = 1 ] || exit " + root.noThresholdExitCode + "
"]
running: false
onExited: exitCode => {
if (exitCode !== 0) {
if (exitCode === root.noThresholdExitCode) {
root.chargeLimitSupported = false;
ToastService.showError(I18n.tr("Charge limit not supported on this hardware", "battery settings: toast title when sysfs has no charge threshold"), I18n.tr("No writable charge threshold file was found under /sys/class/power_supply.", "battery settings: why the charge limit could not be applied"));
} else if (exitCode !== 0) {
ToastService.showError(I18n.tr("Failed to apply charge limit to system"), I18n.tr("Process exited with code %1").arg(exitCode));
} else {
ToastService.showInfo(I18n.tr("Charge limit applied successfully"), I18n.tr("Limit set to %1%").arg(SettingsData.batteryChargeLimit));
@@ -214,9 +235,24 @@ done
onSliderValueChanged: newValue => SettingsData.set("batteryChargeLimit", newValue)
}
StyledText {
width: parent.width
// A machine without a battery has no charge threshold to
// write to either, but saying so there would be noise: the
// limit was never applicable in the first place.
visible: Qt.platform.os === "linux" && BatteryService.batteryAvailable && !root.chargeLimitSupported
text: I18n.tr("No writable charge threshold file was found under /sys/class/power_supply.", "battery settings: why the charge limit could not be applied")
wrapMode: Text.WordWrap
color: Theme.surfaceVariantText
font.pixelSize: Theme.fontSizeMedium
}
Row {
// charge_control_* live in Linux sysfs; no BSD equivalent
visible: Qt.platform.os === "linux"
// charge_control_* live in Linux sysfs; no BSD equivalent.
// Without a battery the apply button cannot do anything,
// and offering it anyway is what produced the false
// success this change is about.
visible: Qt.platform.os === "linux" && BatteryService.batteryAvailable && root.chargeLimitSupported
width: parent.width
height: applyButton.height
layoutDirection: I18n.isRtl ? Qt.LeftToRight : Qt.RightToLeft
@@ -1113,6 +1113,13 @@ Singleton {
}
}
Connections {
target: HyprlandService
function onMonitorLayoutChanged() {
root.outputs = root.buildOutputsMap();
}
}
Connections {
target: CompositorService
function onCompositorChanged() {
@@ -1806,6 +1813,13 @@ Singleton {
"transform": mapWlrTransform(output.transform)
}
};
const live = HyprlandService.liveMonitor(output.name);
if (!live)
continue;
map[output.name].logical.x = live.x;
map[output.name].logical.y = live.y;
map[output.name].logical.scale = live.scale || 1.0;
map[output.name].logical.transform = hyprlandToTransform(live.lastIpcObject?.transform ?? 0);
}
return map;
}
@@ -244,14 +244,13 @@ StyledRect {
width: parent.width
dropdownWidth: parent.width
visible: !scaleContainer.customMode
currentValue: scaleContainer.currentScale
options: scaleContainer.scaleOptionsData.labels
onValueChanged: value => {
if (value === I18n.tr("Custom...")) {
scaleContainer.customMode = true;
currentValue = scaleContainer.currentScale;
scaleInput.text = scaleContainer.currentScale;
scaleInput.forceActiveFocus();
scaleInput.selectAll();
scaleContainer.customMode = true;
customFocusTimer.restart();
return;
}
const mapped = scaleContainer.scaleOptionsData.valueByLabel[value];
@@ -259,6 +258,22 @@ StyledRect {
}
}
Binding {
target: scaleDropdown
property: "currentValue"
value: scaleContainer.currentScale
}
// the dropdown takes focus back when its menu finishes closing
Timer {
id: customFocusTimer
interval: Theme.shortDuration + 100
onTriggered: {
scaleInput.forceActiveFocus();
scaleInput.selectAll();
}
}
DankTextField {
id: scaleInput
width: parent.width
@@ -162,6 +162,7 @@ StyledRect {
spacing: Theme.spacingM
DankSpinner {
size: Theme.iconSize
running: root.isUpdating
anchors.verticalCenter: parent.verticalCenter
}
+1
View File
@@ -881,6 +881,7 @@ EOFCONFIG
[ $found -eq 1 ] && break
done
done
exit 0
`;
Proc.runCommand("discoverSoundFiles", ["sh", "-c", script], (output, exitCode) => {
+2 -2
View File
@@ -140,8 +140,8 @@ Singleton {
function getFocusedScreenName() {
if (CompositorService.isAqueous && AqueousService.available)
return AqueousService.focusedOutput;
if (CompositorService.isHyprland && Hyprland.focusedWorkspace?.monitor)
return Hyprland.focusedWorkspace.monitor.name;
if (CompositorService.isHyprland && Hyprland.focusedMonitor)
return Hyprland.focusedMonitor.name;
if (CompositorService.isNiri && NiriService.currentOutput)
return NiriService.currentOutput;
if (CompositorService.isMango && MangoService.activeOutput)
+39
View File
@@ -200,6 +200,7 @@ Singleton {
internalEntries = [];
clipboardEntries = [];
unpinnedEntries = [];
pinnedEntries = [];
}
function copyEntry(entry, closeCallback, textOnly) {
@@ -334,6 +335,33 @@ Singleton {
});
}
function editEntry(entry, text, callback) {
if (!entry || typeof entry.id !== "number") {
if (callback) {
callback({
"error": "Invalid entry"
});
}
return;
}
DMSService.sendRequest("clipboard.editEntry", {
"id": entry.id,
"text": text
}, function (response) {
if (response.error) {
log.warn("Failed to edit entry:", response.error);
if (callback) {
callback(response);
}
return;
}
refresh();
if (callback) {
callback(response);
}
});
}
function clearAll() {
const hasPinned = pinnedCount > 0;
const savedCount = pinnedCount;
@@ -371,6 +399,17 @@ Singleton {
return entry.preview || "";
}
function isTextMimeType(mimeType) {
if (!mimeType || mimeType.startsWith("text/plain")) {
return true;
}
return mimeType === "UTF8_STRING" || mimeType === "STRING" || mimeType === "TEXT";
}
function canEditEntry(entry) {
return !!entry && !(entry.isImage ?? false) && isTextMimeType(entry.mimeType);
}
function getEntryType(entry) {
if (entry.isImage) {
return "image";
+3 -3
View File
@@ -202,8 +202,8 @@ Singleton {
let screenName = "";
if (isAqueous && AqueousService.available)
screenName = AqueousService.focusedOutput;
else if (isHyprland && Hyprland.focusedWorkspace?.monitor)
screenName = Hyprland.focusedWorkspace.monitor.name;
else if (isHyprland && Hyprland.focusedMonitor)
screenName = Hyprland.focusedMonitor.name;
else if (isNiri && NiriService.currentOutput)
screenName = NiriService.currentOutput;
else if (isSway || isScroll || isMiracle) {
@@ -275,7 +275,7 @@ Singleton {
enabled: isHyprland
function onRawEvent(event) {
if (event.name === "monitoraddedv2" || event.name === "monitorremoved") {
if (event.name === "monitoraddedv2" || event.name === "monitorremoved" || event.name === "configreloaded") {
root.refreshHyprlandMonitorLayout();
return;
}
+29
View File
@@ -21,6 +21,8 @@ Singleton {
readonly property bool luaConfigActive: CompositorService.isHyprland && (Hyprland.usingLua === true || luaConfigDetected)
property int _lastGapValue: -1
property string _monitorLayoutSignature: ""
signal monitorLayoutChanged
property bool luaConfigDetected: false
property bool luaConfigStatusReady: false
property bool luaConfigStatusLoading: false
@@ -296,11 +298,38 @@ Singleton {
Proc.runCommand("hyprctl-reload", ["hyprctl", "reload"], (output, exitCode) => {
if (exitCode !== 0)
log.warn("hyprctl reload failed:", output);
else
Hyprland.refreshMonitors();
if (callback)
callback(exitCode === 0);
});
}
function liveMonitor(name) {
if (!CompositorService.isHyprland)
return null;
return Hyprland.monitors.values.find(m => m.name === name) ?? null;
}
function _syncMonitorLayout() {
const signature = Hyprland.monitors.values.map(m => `${m.name}:${m.x},${m.y},${m.scale},${m.lastIpcObject?.transform ?? 0}`).join("|");
if (signature === _monitorLayoutSignature)
return;
_monitorLayoutSignature = signature;
monitorLayoutChanged();
}
Instantiator {
model: CompositorService.isHyprland ? Hyprland.monitors : null
delegate: Connections {
required property HyprlandMonitor modelData
target: modelData
function onLastIpcObjectChanged() {
root._syncMonitorLayout();
}
}
}
function setLayoutXray(enabled) {
layoutXrayEnabled = enabled;
_layoutXrayLoaded = true;
+24 -42
View File
@@ -67,6 +67,17 @@ Singleton {
root.allWorkspaces = Object.values(newMap).sort((a, b) => a.idx - b.idx);
}
function updateWorkspace(workspaceId, changes) {
const ws = root.workspaces[workspaceId];
if (!ws)
return;
if (Object.keys(changes).every(key => ws[key] === changes[key]))
return;
const updatedWorkspaces = Object.assign({}, root.workspaces);
updatedWorkspaces[workspaceId] = Object.assign({}, ws, changes);
setWorkspaces(updatedWorkspaces);
}
function validate() {
validateProcess.running = true;
}
@@ -512,40 +523,20 @@ Singleton {
if (changed)
windows = updatedWindows;
if (focusedWindow) {
const ws = root.workspaces[focusedWindow.workspace_id];
if (ws && ws.active_window_id !== focusedWindowId) {
const updatedWs = {};
for (let prop in ws) {
updatedWs[prop] = ws[prop];
}
updatedWs.active_window_id = focusedWindowId;
const updatedWorkspaces = {};
for (const id in root.workspaces) {
updatedWorkspaces[id] = id === focusedWindow.workspace_id ? updatedWs : root.workspaces[id];
}
setWorkspaces(updatedWorkspaces);
}
}
if (!focusedWindow)
return;
updateWorkspace(focusedWindow.workspace_id, {
"active_window_id": focusedWindowId
});
}
function handleWorkspaceActiveWindowChanged(data) {
const ws = root.workspaces[data.workspace_id];
if (ws) {
const updatedWs = {};
for (let prop in ws) {
updatedWs[prop] = ws[prop];
}
updatedWs.active_window_id = data.active_window_id;
if (root.workspaces[data.workspace_id]) {
if (data.active_window_id !== null && data.active_window_id !== undefined)
lastFocusedWindowId = data.active_window_id;
const updatedWorkspaces = {};
for (const id in root.workspaces) {
updatedWorkspaces[id] = id === data.workspace_id ? updatedWs : root.workspaces[id];
}
setWorkspaces(updatedWorkspaces);
updateWorkspace(data.workspace_id, {
"active_window_id": data.active_window_id
});
}
let changed = false;
@@ -682,20 +673,11 @@ Singleton {
}
function handleWorkspaceUrgencyChanged(data) {
const ws = root.workspaces[data.id];
if (!ws)
if (!root.workspaces[data.id])
return;
const updatedWs = {};
for (let prop in ws) {
updatedWs[prop] = ws[prop];
}
updatedWs.is_urgent = data.urgent;
const updatedWorkspaces = {};
for (const id in root.workspaces) {
updatedWorkspaces[id] = id === data.id ? updatedWs : root.workspaces[id];
}
setWorkspaces(updatedWorkspaces);
updateWorkspace(data.id, {
"is_urgent": data.urgent
});
windowUrgentChanged();
}
+1 -1
View File
@@ -45,7 +45,7 @@ Singleton {
for (let i = 0; i < Pipewire.nodes.values.length; i++) {
const node = Pipewire.nodes.values[i]
if (!node || !node.ready) {
if (!node || !node.ready || node.properties?.["media.role"] === "Screen") {
continue
}
+4
View File
@@ -207,6 +207,10 @@ Singleton {
}
function isMine(peer) {
if (!peer)
return false;
if (selfNode && (peer === selfNode || peer.id === selfNode.id))
return true;
const myOwner = selfNode ? (selfNode.owner || "") : "";
if (peer.owner === myOwner && myOwner !== "")
return true;
+1 -9
View File
@@ -37,11 +37,6 @@ layout(std140, binding = 0) uniform buf {
vec4 chromeParam3;
} ubuf;
float sdBox(vec2 p, vec2 c, vec2 hs) {
vec2 q = abs(p - c) - hs;
return min(max(q.x, q.y), 0.0) + length(max(q, vec2(0.0)));
}
float sdRoundBox(vec2 p, vec2 c, vec2 hs, float r) {
r = min(r, min(hs.x, hs.y));
vec2 q = abs(p - c) - hs + r;
@@ -73,12 +68,9 @@ float chromeK(vec2 px, vec4 rect, vec4 ks) {
}
float sceneDist(vec2 px) {
vec2 sc = vec2(ubuf.widthPx, ubuf.heightPx) * 0.5;
float dOuter = sdBox(px, sc, sc);
vec2 cutC = vec2((ubuf.cutout.x + ubuf.cutout.z) * 0.5, (ubuf.cutout.y + ubuf.cutout.w) * 0.5);
vec2 cutH = vec2((ubuf.cutout.z - ubuf.cutout.x) * 0.5, (ubuf.cutout.w - ubuf.cutout.y) * 0.5);
float dCut = sdRoundBox(px, cutC, cutH, ubuf.cutoutRadius);
float d = max(dOuter, -dCut);
float d = -sdRoundBox(px, cutC, cutH, ubuf.cutoutRadius);
if (ubuf.chromeParam0.x > 0.5)
d = smin(d, chromeDist(px, ubuf.chromeRect0, ubuf.chromeCorner0), chromeK(px, ubuf.chromeRect0, ubuf.chromeK0));
+1 -9
View File
@@ -15,11 +15,6 @@ layout(std140, binding = 0) uniform buf {
vec4 surfaceColor; // straight (non-premultiplied) rgba
} ubuf;
float sdBox(vec2 p, vec2 c, vec2 hs) {
vec2 q = abs(p - c) - hs;
return min(max(q.x, q.y), 0.0) + length(max(q, vec2(0.0)));
}
float sdRoundBox(vec2 p, vec2 c, vec2 hs, float r) {
r = min(r, min(hs.x, hs.y));
vec2 q = abs(p - c) - hs + r;
@@ -28,12 +23,9 @@ float sdRoundBox(vec2 p, vec2 c, vec2 hs, float r) {
void main() {
vec2 px = qt_TexCoord0 * vec2(ubuf.widthPx, ubuf.heightPx);
vec2 sc = vec2(ubuf.widthPx, ubuf.heightPx) * 0.5;
float dOuter = sdBox(px, sc, sc);
vec2 cutC = vec2((ubuf.cutout.x + ubuf.cutout.z) * 0.5, (ubuf.cutout.y + ubuf.cutout.w) * 0.5);
vec2 cutH = vec2((ubuf.cutout.z - ubuf.cutout.x) * 0.5, (ubuf.cutout.w - ubuf.cutout.y) * 0.5);
float dCut = sdRoundBox(px, cutC, cutH, ubuf.cutoutRadius);
float d = max(dOuter, -dCut);
float d = -sdRoundBox(px, cutC, cutH, ubuf.cutoutRadius);
float fw = max(fwidth(d), 1e-4);
float cov = 1.0 - smoothstep(-fw, fw, d);
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
v1.6.1
v1.6.2
+2 -2
View File
@@ -68,8 +68,8 @@ Item {
anchors.verticalCenter: parent.verticalCenter
wrapMode: Text.NoWrap
width: root.needsScrolling ? implicitWidth : root.width
elide: root.needsScrolling ? Text.ElideNone : Text.ElideRight
width: Math.min(implicitWidth, root.width)
elide: SettingsData.scrollTitleEnabled ? Text.ElideNone : Text.ElideRight
x: Math.round((root.needsScrolling ? -Math.min(root.scrollOffset, root.maxScrollOffset) : 0) + root.textShift)
opacity: 1
+1 -1
View File
@@ -94,7 +94,7 @@
--text-brand: {{colors.primary.default.hex}} !important;
--control-brand-foreground: {{colors.primary.default.hex}} !important;
--control-brand-foreground-new: {{colors.primary.default.hex}} !important;
--badge-text-brand: {{colors.primary.default.hex}} !important;
--badge-text-brand: {{colors.surface.default.hex}} !important;
--text-danger: {{colors.error.default.hex}} !important;
--text-feedback-critical: {{colors.error.default.hex}} !important;
+3 -1
View File
@@ -22,6 +22,8 @@ apply_qt_colors() {
local config_file="$1"
if [ -f "$config_file" ]; then
sed -i 's/^\[Appearance\]\\ncustom_palette=.*/[Appearance]/' "$config_file"
if grep -q '^\[Appearance\]' "$config_file"; then
if grep -q '^custom_palette=' "$config_file"; then
sed -i 's/^custom_palette=.*/custom_palette=true/' "$config_file"
@@ -43,7 +45,7 @@ apply_qt_colors() {
} >>"$config_file"
fi
else
printf '[Appearance]\\ncustom_palette=true\\ncolor_scheme_path=%s\\n' "$color_scheme_path" >"$config_file"
printf '[Appearance]\ncustom_palette=true\ncolor_scheme_path=%s\n' "$color_scheme_path" >"$config_file"
fi
}