mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-22 03:04:01 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2db7646fe3 | ||
|
|
097de493bb | ||
|
|
a7db0354ae | ||
|
|
2d2502cc67 | ||
|
|
402a8515ee | ||
|
|
4cf0f0c898 | ||
|
|
fd0564a3ec | ||
|
|
07f8675a7b | ||
|
|
cab2a1a3bc | ||
|
|
41dd91fe75 | ||
|
|
c074b220ee | ||
|
|
243d716703 | ||
|
|
fde2181c3b | ||
|
|
a5ffd18596 | ||
|
|
e0aa1f0177 | ||
|
|
e73b9c3ce1 | ||
|
|
69955286a7 | ||
|
|
d3a02f6342 | ||
|
|
86234e5b09 | ||
|
|
938fabe27d | ||
|
|
1b34aa0ce1 | ||
|
|
76ed5d68fa | ||
|
|
b69496dbcd | ||
|
|
ef179f9c92 | ||
|
|
b451d4d8e0 | ||
|
|
12f7675950 | ||
|
|
06cd88e990 | ||
|
|
aa4b99def4 | ||
|
|
4015f634c4 | ||
|
|
b2d1ec6690 | ||
|
|
1efc10dc7e | ||
|
|
94710cf643 | ||
|
|
3711cb11fa | ||
|
|
40a0eb6841 | ||
|
|
0a643028b3 | ||
|
|
0189eb0dbb | ||
|
|
ed26bdebdc | ||
|
|
2e2c74e355 | ||
|
|
c1a3be60c3 | ||
|
|
f8260513b1 | ||
|
|
ed1855946f | ||
|
|
c0bc1d8885 | ||
|
|
3f09d7b1c8 | ||
|
|
710f20d085 | ||
|
|
6261e580aa | ||
|
|
42912ebe74 | ||
|
|
e8a755b85d | ||
|
|
6713f05e02 | ||
|
|
5d39702136 | ||
|
|
166b82df1b | ||
|
|
bca1466162 | ||
|
|
75b8932b20 | ||
|
|
a1b51b191f | ||
|
|
5defc26929 | ||
|
|
71e8a85cd1 | ||
|
|
95b2750e5f | ||
|
|
7f1a423174 | ||
|
|
33e599100c | ||
|
|
cb85620777 | ||
|
|
faf25e98a5 | ||
|
|
d7b264701e | ||
|
|
c929a8952b | ||
|
|
82089c588a | ||
|
|
19a56d1835 | ||
|
|
8371fa8ef5 | ||
|
|
acca7fae68 | ||
|
|
a478c27fd0 | ||
|
|
094dcdd035 | ||
|
|
344c1e610c | ||
|
|
fdf8722818 | ||
|
|
f2114c1a01 | ||
|
|
6ed79d06df | ||
|
|
8ff677d2eb | ||
|
|
700f43a194 | ||
|
|
53a5582858 |
@@ -531,8 +531,8 @@ jobs:
|
||||
repo:
|
||||
- dgop
|
||||
- dankgo
|
||||
- dank-qml-common
|
||||
- dankcalendar
|
||||
# - dank-qml-common
|
||||
# - dankcalendar
|
||||
- danksearch
|
||||
- dank-greeter
|
||||
steps:
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"},
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -58,7 +58,7 @@ func init() {
|
||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateDry, "dry", false, "Simulate the upgrade without applying changes")
|
||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateJSON, "json", false, "Output as JSON (with --check)")
|
||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoFlatpak, "no-flatpak", false, "Skip the Flatpak overlay")
|
||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoAUR, "no-aur", false, "Skip the AUR (paru/yay only)")
|
||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoAUR, "no-aur", false, "Skip AUR updates")
|
||||
systemUpdateCmd.Flags().StringSliceVar(&sysUpdateIgnore, "ignore", nil, "Skip specific packages (repeatable or comma-separated)")
|
||||
systemUpdateCmd.Flags().IntVar(&sysUpdateIntervalS, "interval", -1, "Set the DMS server poll interval in seconds and exit (requires running server)")
|
||||
|
||||
|
||||
+8
-9
@@ -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,23 +71,23 @@ 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
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/AvengeMedia/dankgo v0.0.0-20260902003743-aeb6467c214e
|
||||
github.com/AvengeMedia/dankgo v1.6.1-0.20260908011626-07e1ef7caaea
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
+22
-24
@@ -1,9 +1,9 @@
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/AvengeMedia/dankgo v0.0.0-20260902003743-aeb6467c214e h1:AUCBM5EF+hxCG55uJl30waRQHhR5+Wij9u8AfoTkQvs=
|
||||
github.com/AvengeMedia/dankgo v0.0.0-20260902003743-aeb6467c214e/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/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 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=
|
||||
|
||||
@@ -661,9 +661,12 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
|
||||
if existingData != "" {
|
||||
existingConfig = existingData
|
||||
cd.log(fmt.Sprintf("Found existing Hyprland configuration at %s", existingPath))
|
||||
|
||||
result.BackupPath = filepath.Join(backupDir, filepath.Base(existingPath))
|
||||
if err := backupHyprlandConfigFile(existingPath, result.BackupPath, []byte(existingData), strings.EqualFold(filepath.Ext(existingPath), ".conf")); err != nil {
|
||||
}
|
||||
// A legacy hyprland.conf is only backed up once hyprland.lua is in place; Hyprland
|
||||
// watches the live config and regenerates a stub one if it disappears first.
|
||||
if existingData != "" && !strings.EqualFold(filepath.Ext(existingPath), ".conf") {
|
||||
result.BackupPath, err = backupHyprlandConfigFile(filepath.Join(backupDir, filepath.Base(existingPath)), []byte(existingData))
|
||||
if err != nil {
|
||||
result.Error = fmt.Errorf("failed to create backup: %w", err)
|
||||
return result, result.Error
|
||||
}
|
||||
@@ -711,11 +714,15 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
|
||||
}
|
||||
}
|
||||
|
||||
movedLegacy, err := backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir)
|
||||
movedLegacy, mainConfBackup, err := backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir)
|
||||
if err != nil {
|
||||
result.Error = fmt.Errorf("failed to back up legacy hyprlang configs: %w", err)
|
||||
return result, result.Error
|
||||
}
|
||||
if mainConfBackup != "" && result.BackupPath == "" {
|
||||
result.BackupPath = mainConfBackup
|
||||
cd.log(fmt.Sprintf("Backed up existing config to %s", mainConfBackup))
|
||||
}
|
||||
if movedLegacy > 0 {
|
||||
if result.BackupPath == "" {
|
||||
result.BackupPath = backupDir
|
||||
@@ -737,42 +744,53 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func backupHyprlandConfigFile(src, dst string, data []byte, removeSource bool) error {
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
func uniqueBackupPath(dst string) string {
|
||||
if _, err := os.Lstat(dst); err != nil {
|
||||
return dst
|
||||
}
|
||||
if err := os.WriteFile(dst, data, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if removeSource {
|
||||
if err := os.Remove(src); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
for i := 1; i < 1000; i++ {
|
||||
candidate := fmt.Sprintf("%s.%d", dst, i)
|
||||
if _, err := os.Lstat(candidate); err != nil {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return fmt.Sprintf("%s.%d", dst, time.Now().UnixNano())
|
||||
}
|
||||
|
||||
func backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir string) (int, error) {
|
||||
legacyPaths := []string{filepath.Join(configDir, "hyprland.conf")}
|
||||
func backupHyprlandConfigFile(dst string, data []byte) (string, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
dst = uniqueBackupPath(dst)
|
||||
if err := os.WriteFile(dst, data, 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
func backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir string) (int, string, error) {
|
||||
mainConf := filepath.Join(configDir, "hyprland.conf")
|
||||
legacyPaths := []string{mainConf}
|
||||
dmsConfPaths, err := filepath.Glob(filepath.Join(dmsDir, "*.conf"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return 0, "", err
|
||||
}
|
||||
legacyPaths = append(legacyPaths, dmsConfPaths...)
|
||||
backupPaths, err := adjacentHyprlandBackupFiles(configDir, dmsDir)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return 0, "", err
|
||||
}
|
||||
legacyPaths = append(legacyPaths, backupPaths...)
|
||||
|
||||
moved := 0
|
||||
mainConfBackup := ""
|
||||
for _, src := range legacyPaths {
|
||||
info, err := os.Lstat(src)
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return moved, err
|
||||
return moved, mainConfBackup, err
|
||||
}
|
||||
if info.IsDir() {
|
||||
continue
|
||||
@@ -782,21 +800,28 @@ func backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir string) (int, er
|
||||
if err != nil {
|
||||
rel = filepath.Base(src)
|
||||
}
|
||||
dst := filepath.Join(backupDir, rel)
|
||||
if err := moveHyprlandConfigFile(src, dst); err != nil {
|
||||
return moved, err
|
||||
dst, err := moveHyprlandConfigFile(src, filepath.Join(backupDir, rel))
|
||||
if err != nil {
|
||||
return moved, mainConfBackup, err
|
||||
}
|
||||
if src == mainConf {
|
||||
mainConfBackup = dst
|
||||
}
|
||||
moved++
|
||||
}
|
||||
|
||||
return moved, nil
|
||||
return moved, mainConfBackup, nil
|
||||
}
|
||||
|
||||
func moveHyprlandConfigFile(src, dst string) error {
|
||||
func moveHyprlandConfigFile(src, dst string) (string, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
return os.Rename(src, dst)
|
||||
dst = uniqueBackupPath(dst)
|
||||
if err := os.Rename(src, dst); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
func adjacentHyprlandBackupFiles(configDir, dmsDir string) ([]string, error) {
|
||||
|
||||
@@ -890,3 +890,72 @@ func TestShouldReplaceConfigDeployIfMissing(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBackupLegacyHyprlandConfFilesKeepsExistingBackups(t *testing.T) {
|
||||
td := t.TempDir()
|
||||
configDir := filepath.Join(td, ".config", "hypr")
|
||||
dmsDir := filepath.Join(configDir, "dms")
|
||||
require.NoError(t, os.MkdirAll(dmsDir, 0o755))
|
||||
|
||||
userConfig := "# the config the user actually wrote\nmonitor = DP-1, 1920x1080@144, 0x0, 1\n"
|
||||
backupDir := filepath.Join(configDir, hyprlandBackupDirName, "2026-01-02_03-04-05")
|
||||
require.NoError(t, os.MkdirAll(backupDir, 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(backupDir, "hyprland.conf"), []byte(userConfig), 0o644))
|
||||
|
||||
autogenerated := "autogenerated = 1\n$mainMod = SUPER\n"
|
||||
require.NoError(t, os.WriteFile(filepath.Join(configDir, "hyprland.conf"), []byte(autogenerated), 0o644))
|
||||
|
||||
moved, mainConfBackup, err := backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, moved)
|
||||
assert.Equal(t, filepath.Join(backupDir, "hyprland.conf.1"), mainConfBackup)
|
||||
|
||||
kept, err := os.ReadFile(filepath.Join(backupDir, "hyprland.conf"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, userConfig, string(kept))
|
||||
|
||||
stray, err := os.ReadFile(mainConfBackup)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, autogenerated, string(stray))
|
||||
}
|
||||
|
||||
func TestBackupHyprlandConfigFileKeepsExistingBackups(t *testing.T) {
|
||||
backupDir := t.TempDir()
|
||||
dst := filepath.Join(backupDir, "hyprland.lua")
|
||||
require.NoError(t, os.WriteFile(dst, []byte("-- first\n"), 0o644))
|
||||
|
||||
written, err := backupHyprlandConfigFile(dst, []byte("-- second\n"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, dst+".1", written)
|
||||
|
||||
first, err := os.ReadFile(dst)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "-- first\n", string(first))
|
||||
}
|
||||
|
||||
func TestDeployHyprlandConfigWritesLuaBeforeRemovingLegacyConf(t *testing.T) {
|
||||
td := t.TempDir()
|
||||
t.Setenv("HOME", td)
|
||||
configDir := filepath.Join(td, ".config", "hypr")
|
||||
require.NoError(t, os.MkdirAll(configDir, 0o755))
|
||||
confPath := filepath.Join(configDir, "hyprland.conf")
|
||||
userConfig := "# user config\nmonitor = DP-1, 1920x1080@144, 0x0, 1\n"
|
||||
require.NoError(t, os.WriteFile(confPath, []byte(userConfig), 0o644))
|
||||
|
||||
logChan := make(chan string, 100)
|
||||
cd := NewConfigDeployer(logChan)
|
||||
go func() {
|
||||
for range logChan {
|
||||
}
|
||||
}()
|
||||
defer close(logChan)
|
||||
|
||||
result, err := cd.deployHyprlandConfig(deps.TerminalGhostty, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NoFileExists(t, confPath)
|
||||
backup, err := os.ReadFile(result.BackupPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, userConfig, string(backup))
|
||||
assert.Equal(t, filepath.Join(filepath.Dir(result.BackupPath), "hyprland.conf"), result.BackupPath)
|
||||
}
|
||||
|
||||
@@ -179,8 +179,8 @@ func CleanupStrayHyprlandConfFile(logFn func(format string, v ...any)) {
|
||||
if err != nil {
|
||||
rel = filepath.Base(src)
|
||||
}
|
||||
dst := filepath.Join(configDir, hyprlandBackupDirName, ts, rel)
|
||||
if err := moveHyprlandConfigFile(src, dst); err != nil {
|
||||
dst, err := moveHyprlandConfigFile(src, filepath.Join(configDir, hyprlandBackupDirName, ts, rel))
|
||||
if err != nil {
|
||||
if logFn != nil {
|
||||
logFn("Could not move stray Hyprland conf file %s: %v", src, err)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -85,9 +85,10 @@ type OutputSurface struct {
|
||||
slots [3]*RenderSlot
|
||||
slotsReady bool
|
||||
|
||||
shown *overlay
|
||||
framePending bool
|
||||
redrawQueued bool
|
||||
shown *overlay
|
||||
framePending bool
|
||||
redrawQueued bool
|
||||
committedCursor bool
|
||||
}
|
||||
|
||||
type PreCapture struct {
|
||||
@@ -108,6 +109,7 @@ type RegionSelector struct {
|
||||
shm *client.Shm
|
||||
seat *client.Seat
|
||||
pointer *client.Pointer
|
||||
touch *client.Touch
|
||||
keyboard *client.Keyboard
|
||||
keymap *keymap.Keymap
|
||||
layerShell *wlr_layer_shell.ZwlrLayerShellV1
|
||||
@@ -138,6 +140,8 @@ type RegionSelector struct {
|
||||
selection SelectionState
|
||||
pointerX float64
|
||||
pointerY float64
|
||||
hasTouchPoint bool
|
||||
touchPointId int32
|
||||
preSelect Region
|
||||
showCapturedCursor bool
|
||||
shiftHeld bool
|
||||
@@ -969,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
|
||||
@@ -985,7 +990,10 @@ func (r *RegionSelector) renderSurface(os *OutputSurface) {
|
||||
case r.compositorVersion >= 4:
|
||||
fullDamage = false
|
||||
damage = overlayDamage(os.shown, cur)
|
||||
if len(damage) == 0 {
|
||||
if os.committedCursor != r.showCapturedCursor {
|
||||
fullDamage = true
|
||||
damage = nil
|
||||
} else if len(damage) == 0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1029,6 +1037,7 @@ func (r *RegionSelector) renderSurface(os *OutputSurface) {
|
||||
})
|
||||
}
|
||||
_ = os.wlSurface.Commit()
|
||||
os.committedCursor = r.showCapturedCursor
|
||||
|
||||
// Mark this slot as busy until compositor releases it
|
||||
slot.busy = true
|
||||
@@ -1103,6 +1112,9 @@ func (r *RegionSelector) cleanup() {
|
||||
if r.pointer != nil {
|
||||
r.pointer.Release()
|
||||
}
|
||||
if r.touch != nil {
|
||||
_ = r.touch.Release()
|
||||
}
|
||||
if r.keyboard != nil {
|
||||
r.keyboard.Release()
|
||||
}
|
||||
|
||||
@@ -19,6 +19,12 @@ func (r *RegionSelector) setupInput() {
|
||||
r.setupPointerHandlers()
|
||||
}
|
||||
}
|
||||
if e.Capabilities&uint32(client.SeatCapabilityTouch) != 0 && r.touch == nil {
|
||||
if touch, err := r.seat.GetTouch(); err == nil {
|
||||
r.touch = touch
|
||||
r.setupTouchHandlers()
|
||||
}
|
||||
}
|
||||
if e.Capabilities&uint32(client.SeatCapabilityKeyboard) != 0 && r.keyboard == nil {
|
||||
if keyboard, err := r.seat.GetKeyboard(); err == nil {
|
||||
r.keyboard = keyboard
|
||||
@@ -146,6 +152,143 @@ func (r *RegionSelector) setupPointerHandlers() {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *RegionSelector) setupTouchHandlers() {
|
||||
r.touch.SetDownHandler(func(e client.TouchDownEvent) {
|
||||
r.handleTouchDown(e.Surface, e.Id, e.X, e.Y)
|
||||
})
|
||||
|
||||
r.touch.SetMotionHandler(func(e client.TouchMotionEvent) {
|
||||
r.handleTouchMotion(e.Id, e.X, e.Y)
|
||||
})
|
||||
|
||||
r.touch.SetUpHandler(func(e client.TouchUpEvent) {
|
||||
r.handleTouchUp(e.Id)
|
||||
})
|
||||
|
||||
r.touch.SetCancelHandler(func(e client.TouchCancelEvent) {
|
||||
r.handleTouchCancel()
|
||||
})
|
||||
}
|
||||
|
||||
func (r *RegionSelector) handleTouchDown(surface *client.Surface, touchId int32, x, y float64) {
|
||||
if r.hasTouchPoint {
|
||||
return
|
||||
}
|
||||
|
||||
r.activeSurface = nil
|
||||
for _, os := range r.surfaces {
|
||||
if os.wlSurface != nil && surface != nil && os.wlSurface.ID() == surface.ID() {
|
||||
r.activeSurface = os
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if r.activeSurface == nil {
|
||||
return
|
||||
}
|
||||
|
||||
r.hasTouchPoint = true
|
||||
r.touchPointId = touchId
|
||||
|
||||
if r.phase == phaseScroll {
|
||||
if r.activeSurface != r.selection.surface {
|
||||
return
|
||||
}
|
||||
switch r.scrollBarHit(x, y) {
|
||||
case "done", "preview":
|
||||
r.finishScroll()
|
||||
case "cancel":
|
||||
r.cancelled = true
|
||||
r.running = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
touchX := x + float64(r.activeSurface.output.x)
|
||||
touchY := y + float64(r.activeSurface.output.y)
|
||||
|
||||
if r.ctrlHeld && r.selection.hasSelection {
|
||||
if handle := r.resizeHandleAt(touchX, touchY); handle != handleNone {
|
||||
if r.beginSelectionResize(handle, touchX, touchY) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if r.beginSelectionMove(touchX, touchY) {
|
||||
r.selection.dragging = true
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
r.preSelect = Region{}
|
||||
r.movingSelection = false
|
||||
r.resizingHandle = handleNone
|
||||
r.selection.hasSelection = true
|
||||
r.selection.dragging = true
|
||||
r.selection.surface = r.activeSurface
|
||||
r.selection.anchorX = touchX
|
||||
r.selection.anchorY = touchY
|
||||
r.selection.currentX = r.selection.anchorX
|
||||
r.selection.currentY = r.selection.anchorY
|
||||
|
||||
for _, os := range r.surfaces {
|
||||
r.redrawSurface(os)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RegionSelector) handleTouchMotion(touchId int32, x, y float64) {
|
||||
if !r.hasTouchPoint || touchId != r.touchPointId || r.activeSurface == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if r.phase == phaseScroll {
|
||||
return
|
||||
}
|
||||
|
||||
if !r.selection.dragging {
|
||||
return
|
||||
}
|
||||
|
||||
r.updateSelectionCurrent(r.activeSurface, x, y)
|
||||
}
|
||||
|
||||
func (r *RegionSelector) handleTouchUp(touchId int32) {
|
||||
if !r.hasTouchPoint || touchId != r.touchPointId {
|
||||
return
|
||||
}
|
||||
|
||||
r.hasTouchPoint = false
|
||||
r.selection.dragging = false
|
||||
r.movingSelection = false
|
||||
r.resizingHandle = handleNone
|
||||
|
||||
if r.phase == phaseScroll {
|
||||
return
|
||||
}
|
||||
|
||||
for _, os := range r.surfaces {
|
||||
r.redrawSurface(os)
|
||||
}
|
||||
|
||||
if r.screenshoter != nil && r.screenshoter.config.NoConfirm && r.selection.hasSelection {
|
||||
r.finishSelection()
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RegionSelector) handleTouchCancel() {
|
||||
if !r.hasTouchPoint {
|
||||
return
|
||||
}
|
||||
|
||||
r.hasTouchPoint = false
|
||||
r.selection.dragging = false
|
||||
r.movingSelection = false
|
||||
r.resizingHandle = handleNone
|
||||
|
||||
for _, os := range r.surfaces {
|
||||
r.redrawSurface(os)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RegionSelector) updateSelectionCurrent(os *OutputSurface, surfaceX, surfaceY float64) {
|
||||
if os == nil || os.output == nil || !r.selection.dragging {
|
||||
return
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package screenshot
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/AvengeMedia/dankgo/wayland/client"
|
||||
)
|
||||
|
||||
func newTestSurface(id uint32) *client.Surface {
|
||||
s := &client.Surface{}
|
||||
s.SetID(id)
|
||||
return s
|
||||
}
|
||||
|
||||
func TestTouchInputSequence(t *testing.T) {
|
||||
surface := newTestSurface(100)
|
||||
|
||||
os := &OutputSurface{
|
||||
output: &WaylandOutput{
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
logicalW: 1920,
|
||||
logicalH: 1080,
|
||||
wlSurface: surface,
|
||||
}
|
||||
|
||||
r := &RegionSelector{
|
||||
running: true,
|
||||
surfaces: []*OutputSurface{os},
|
||||
}
|
||||
|
||||
// 1. Touch Down
|
||||
r.handleTouchDown(surface, 1, 100, 200)
|
||||
|
||||
if !r.hasTouchPoint || r.touchPointId != 1 {
|
||||
t.Fatalf("expected hasTouchPoint=true, touchPointId=1, got hasTouchPoint=%v, id=%d", r.hasTouchPoint, r.touchPointId)
|
||||
}
|
||||
if !r.selection.dragging || !r.selection.hasSelection {
|
||||
t.Fatalf("expected dragging=true, hasSelection=true")
|
||||
}
|
||||
if r.selection.anchorX != 100 || r.selection.anchorY != 200 {
|
||||
t.Fatalf("expected anchor (100, 200), got (%.1f, %.1f)", r.selection.anchorX, r.selection.anchorY)
|
||||
}
|
||||
|
||||
// 2. Ignore secondary touch point while primary is active
|
||||
surface2 := newTestSurface(200)
|
||||
r.handleTouchDown(surface2, 2, 500, 600)
|
||||
if r.touchPointId != 1 {
|
||||
t.Fatalf("expected touchPointId to stay 1, got %d", r.touchPointId)
|
||||
}
|
||||
|
||||
// 3. Touch Motion with matching id
|
||||
r.handleTouchMotion(1, 400, 500)
|
||||
if r.selection.currentX != 400 || r.selection.currentY != 500 {
|
||||
t.Fatalf("expected current (400, 500), got (%.1f, %.1f)", r.selection.currentX, r.selection.currentY)
|
||||
}
|
||||
|
||||
// 4. Touch Motion with mismatched id should be ignored
|
||||
r.handleTouchMotion(2, 900, 900)
|
||||
if r.selection.currentX != 400 || r.selection.currentY != 500 {
|
||||
t.Fatalf("expected current to remain (400, 500), got (%.1f, %.1f)", r.selection.currentX, r.selection.currentY)
|
||||
}
|
||||
|
||||
// 5. Touch Up with mismatched id should not release
|
||||
r.handleTouchUp(2)
|
||||
if !r.hasTouchPoint || !r.selection.dragging {
|
||||
t.Fatalf("expected touch point to remain active on mismatched touch up")
|
||||
}
|
||||
|
||||
// 6. Touch Up with matching id
|
||||
r.handleTouchUp(1)
|
||||
if r.hasTouchPoint || r.selection.dragging {
|
||||
t.Fatalf("expected hasTouchPoint=false, dragging=false after touch up")
|
||||
}
|
||||
if !r.selection.hasSelection {
|
||||
t.Fatalf("expected selection to be preserved after touch up")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTouchCancel(t *testing.T) {
|
||||
surface := newTestSurface(100)
|
||||
|
||||
os := &OutputSurface{
|
||||
output: &WaylandOutput{
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
logicalW: 1920,
|
||||
logicalH: 1080,
|
||||
wlSurface: surface,
|
||||
}
|
||||
|
||||
r := &RegionSelector{
|
||||
running: true,
|
||||
surfaces: []*OutputSurface{os},
|
||||
}
|
||||
|
||||
r.handleTouchDown(surface, 5, 50, 60)
|
||||
if !r.hasTouchPoint || !r.selection.dragging {
|
||||
t.Fatalf("expected active touch point")
|
||||
}
|
||||
|
||||
r.handleTouchCancel()
|
||||
if r.hasTouchPoint || r.selection.dragging {
|
||||
t.Fatalf("expected touch point and dragging to be cleared on cancel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTouchUpScrollPhase(t *testing.T) {
|
||||
surface := newTestSurface(100)
|
||||
|
||||
os := &OutputSurface{
|
||||
output: &WaylandOutput{
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
logicalW: 1920,
|
||||
logicalH: 1080,
|
||||
wlSurface: surface,
|
||||
}
|
||||
|
||||
sc := &Screenshoter{
|
||||
config: Config{NoConfirm: true},
|
||||
}
|
||||
|
||||
r := &RegionSelector{
|
||||
screenshoter: sc,
|
||||
phase: phaseScroll,
|
||||
running: true,
|
||||
surfaces: []*OutputSurface{os},
|
||||
selection: SelectionState{
|
||||
hasSelection: true,
|
||||
surface: os,
|
||||
},
|
||||
}
|
||||
|
||||
r.hasTouchPoint = true
|
||||
r.touchPointId = 3
|
||||
r.selection.dragging = true
|
||||
|
||||
r.handleTouchUp(3)
|
||||
|
||||
if r.hasTouchPoint || r.selection.dragging {
|
||||
t.Fatalf("expected touch point and dragging cleared")
|
||||
}
|
||||
|
||||
// In scroll phase with NoConfirm, handleTouchUp must not call finishSelection / change running state
|
||||
if !r.running {
|
||||
t.Fatalf("expected running to stay true, did not expect finishSelection side-effects")
|
||||
}
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
func init() {
|
||||
RegisterSystemBackend(func() Backend { return &archHelperBackend{id: "paru"} })
|
||||
RegisterSystemBackend(func() Backend { return &archHelperBackend{id: "yay"} })
|
||||
RegisterSystemBackend(func() Backend { return &shellyBackend{} })
|
||||
RegisterSystemBackend(func() Backend { return &pacmanBackend{} })
|
||||
}
|
||||
|
||||
@@ -53,9 +54,7 @@ func pacmanUpgradeArgv(opts UpgradeOptions) []string {
|
||||
return privilegedArgv(opts, "pacman", "-Syu", "--noconfirm", "--needed")
|
||||
}
|
||||
|
||||
// Dont allow partial updates on arch, if they wanna break their system they can do it outside of DMS:
|
||||
// https://wiki.archlinux.org/title/System_maintenance#Partial_upgrades_are_unsupported
|
||||
// AUR packages are exempt — holding those cannot break the repo dependency graph.
|
||||
// Arch repository packages must upgrade together; AUR packages may be held.
|
||||
func dropPacmanRepoIgnores(ignored []string, pending []Package) []string {
|
||||
if len(ignored) == 0 {
|
||||
return ignored
|
||||
@@ -81,7 +80,7 @@ func isPacmanFamily(b Backend) bool {
|
||||
return false
|
||||
}
|
||||
switch b.ID() {
|
||||
case "pacman", "paru", "yay":
|
||||
case "pacman", "paru", "yay", "shelly":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package sysupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type shellyBackend struct{}
|
||||
|
||||
func (shellyBackend) ID() string { return "shelly" }
|
||||
func (shellyBackend) DisplayName() string { return "Shelly (AUR)" }
|
||||
func (shellyBackend) Repo() RepoKind { return RepoSystem }
|
||||
func (shellyBackend) NeedsAuth() bool { return true }
|
||||
func (shellyBackend) RunsInTerminal() bool {
|
||||
return os.Getenv("DMS_FORCE_PKEXEC") != "1"
|
||||
}
|
||||
|
||||
func (shellyBackend) IsAvailable(ctx context.Context) bool {
|
||||
if !commandExists("shelly") {
|
||||
return false
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
out, err := Capture(ctx, []string{"shelly", "--version", "--json"})
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
var version struct {
|
||||
Name string `json:"name"`
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
}
|
||||
// Shelly 3.1.3 introduced this CLI identity response.
|
||||
return json.Unmarshal([]byte(out), &version) == nil && version.Name == "shelly" && version.SchemaVersion == 1
|
||||
}
|
||||
|
||||
func (b shellyBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
|
||||
pkgs, err := b.checkUpdates(ctx, RepoSystem)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
aur, err := b.checkUpdates(ctx, RepoAUR)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(pkgs, aur...), nil
|
||||
}
|
||||
|
||||
func (b shellyBackend) checkUpdates(ctx context.Context, repo RepoKind) ([]Package, error) {
|
||||
kind := "standard"
|
||||
if repo == RepoAUR {
|
||||
kind = "aur"
|
||||
}
|
||||
out, err := Capture(ctx, []string{"shelly", "list-updates", kind, "--json"})
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
if err != nil {
|
||||
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
|
||||
return nil, fmt.Errorf("shelly %s update check: %w: %s", kind, err, strings.TrimSpace(string(exitErr.Stderr)))
|
||||
}
|
||||
return nil, fmt.Errorf("shelly %s update check: %w", kind, err)
|
||||
}
|
||||
return parseShellyUpdates(out, repo)
|
||||
}
|
||||
|
||||
func parseShellyUpdates(out string, repo RepoKind) ([]Package, error) {
|
||||
var updates []struct {
|
||||
Name string
|
||||
CurrentVersion string
|
||||
Version string
|
||||
NewVersion string
|
||||
DownloadSize int64
|
||||
PackageBase string
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out), &updates); err != nil {
|
||||
return nil, fmt.Errorf("shelly %s updates: invalid JSON: %w", repo, err)
|
||||
}
|
||||
if updates == nil {
|
||||
return nil, fmt.Errorf("shelly %s updates: expected a JSON array", repo)
|
||||
}
|
||||
var pkgs []Package
|
||||
for _, update := range updates {
|
||||
fromVersion := update.CurrentVersion
|
||||
if repo == RepoAUR {
|
||||
fromVersion = update.Version
|
||||
}
|
||||
if update.Name == "" || fromVersion == "" || update.NewVersion == "" {
|
||||
return nil, fmt.Errorf("shelly %s updates: missing package name or version", repo)
|
||||
}
|
||||
pkg := Package{
|
||||
Name: update.Name,
|
||||
Backend: "shelly",
|
||||
Repo: repo,
|
||||
FromVersion: fromVersion,
|
||||
ToVersion: update.NewVersion,
|
||||
SizeBytes: max(0, update.DownloadSize),
|
||||
}
|
||||
if repo == RepoAUR {
|
||||
base := update.PackageBase
|
||||
if base == "" {
|
||||
base = update.Name
|
||||
}
|
||||
pkg.ChangelogURL = "https://aur.archlinux.org/packages/" + url.PathEscape(base)
|
||||
}
|
||||
pkgs = append(pkgs, pkg)
|
||||
}
|
||||
return pkgs, nil
|
||||
}
|
||||
|
||||
func (b shellyBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
|
||||
if !BackendHasTargets(b, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) {
|
||||
return nil
|
||||
}
|
||||
var aur []Package
|
||||
if opts.IncludeAUR && (opts.DryRun || len(opts.Ignored) > 0) {
|
||||
// The manager has already removed held packages from opts.Targets.
|
||||
var err error
|
||||
aur, err = b.checkUpdates(ctx, RepoAUR)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, pkg := range aur {
|
||||
if slices.Contains(opts.Ignored, pkg.Name) {
|
||||
return fmt.Errorf("shelly cannot exclude held AUR package %q; disable AUR updates or remove its DMS hold before updating", pkg.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if opts.DryRun {
|
||||
pkgs, err := b.checkUpdates(ctx, RepoSystem)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pkgs = append(pkgs, aur...)
|
||||
for _, pkg := range pkgs {
|
||||
if onLine != nil {
|
||||
onLine(fmt.Sprintf("%s %s -> %s", pkg.Name, pkg.FromVersion, pkg.ToVersion))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
argv := shellyUpgradeArgv(opts.IncludeAUR)
|
||||
if opts.AttachStdio {
|
||||
return Run(ctx, argv, RunOptions{OnLine: onLine, AttachStdio: true})
|
||||
}
|
||||
if !b.RunsInTerminal() {
|
||||
return Run(ctx, argv, RunOptions{OnLine: onLine, Env: []string{"SHELLY_ELEVATOR=pkexec"}})
|
||||
}
|
||||
term := findTerminal(opts.Terminal)
|
||||
if term == "" {
|
||||
return fmt.Errorf("no terminal found (pick one in DMS settings, set $TERMINAL, or install kitty/ghostty/foot/alacritty)")
|
||||
}
|
||||
return Run(ctx, wrapInTerminal(term, "DMS — System Update (shelly)", strings.Join(argv, " "), opts.TerminalArgs), RunOptions{OnLine: onLine})
|
||||
}
|
||||
|
||||
func shellyUpgradeArgv(includeAUR bool) []string {
|
||||
if !includeAUR {
|
||||
return []string{"shelly", "upgrade", "standard", "--no-confirm"}
|
||||
}
|
||||
return []string{"shelly", "upgrade", "all", "--no-flatpak", "--no-appimage", "--no-confirm"}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package sysupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const shellyRepoFixture = `[{"Name":"linux","CurrentVersion":"2:6.18-1","NewVersion":"2:6.19-1","DownloadSize":1024,"Repository":"core"}]`
|
||||
const shellyAURFixture = `[{"Name":"example-git","Version":"r1-1","NewVersion":"r2-1","DownloadSize":-1,"PackageBase":"example"}]`
|
||||
|
||||
func TestParseShellyUpdates(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
out string
|
||||
repo RepoKind
|
||||
want []Package
|
||||
}{
|
||||
{"repository", shellyRepoFixture, RepoSystem, []Package{{Name: "linux", Backend: "shelly", Repo: RepoSystem, FromVersion: "2:6.18-1", ToVersion: "2:6.19-1", SizeBytes: 1024}}},
|
||||
{"aur package base", shellyAURFixture, RepoAUR, []Package{{Name: "example-git", Backend: "shelly", Repo: RepoAUR, FromVersion: "r1-1", ToVersion: "r2-1", ChangelogURL: "https://aur.archlinux.org/packages/example"}}},
|
||||
{"aur name fallback", `[{"Name":"example","Version":"1","NewVersion":"2"}]`, RepoAUR, []Package{{Name: "example", Backend: "shelly", Repo: RepoAUR, FromVersion: "1", ToVersion: "2", ChangelogURL: "https://aur.archlinux.org/packages/example"}}},
|
||||
{"empty", "[]\n", RepoSystem, nil},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := parseShellyUpdates(tt.out, tt.repo)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Fatalf("packages = %#v, want %#v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, out := range []string{"", "null", "{}", "[", "[] trailing", "[null]", `[{"Name":"linux"}]`, `[{"Name":"linux","CurrentVersion":"1","NewVersion":"2","DownloadSize":"large"}]`} {
|
||||
t.Run(out, func(t *testing.T) {
|
||||
if _, err := parseShellyUpdates(out, RepoSystem); err == nil {
|
||||
t.Fatalf("expected error for %q", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func writeUpdateExecutable(t *testing.T, dir, name, body string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellySelection(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
bins []string
|
||||
version string
|
||||
want string
|
||||
}{
|
||||
{"shelly only", []string{"shelly"}, `{"schemaVersion":1,"name":"shelly","version":"3.1.3"}`, "shelly"},
|
||||
{"shelly before pacman", []string{"shelly", "pacman"}, `{"schemaVersion":1,"name":"shelly"}`, "shelly"},
|
||||
{"paru preferred", []string{"paru", "yay", "shelly", "pacman"}, "", "paru"},
|
||||
{"yay preferred", []string{"yay", "shelly", "pacman"}, "", "yay"},
|
||||
{"legacy shelly", []string{"shelly", "pacman"}, "2.4.1", "pacman"},
|
||||
{"wrong binary", []string{"shelly", "pacman"}, `{"schemaVersion":1,"name":"shelly-ui"}`, "pacman"},
|
||||
{"unknown schema", []string{"shelly", "pacman"}, `{"schemaVersion":2,"name":"shelly"}`, "pacman"},
|
||||
{"pacman only", []string{"pacman"}, "", "pacman"},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("PATH", dir)
|
||||
for _, bin := range tt.bins {
|
||||
writeUpdateExecutable(t, dir, bin, "printf '%s\\n' '"+tt.version+"'")
|
||||
}
|
||||
selection := Select(t.Context())
|
||||
if selection.System == nil || selection.System.ID() != tt.want {
|
||||
t.Fatalf("selection = %#v, want %s", selection.System, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func fakeShelly(t *testing.T) (string, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
log := filepath.Join(dir, "calls")
|
||||
t.Setenv("PATH", dir)
|
||||
t.Setenv("TERMINAL", "")
|
||||
t.Setenv("DMS_FORCE_PKEXEC", "")
|
||||
t.Setenv("DMS_TEST_SHELLY_LOG", log)
|
||||
t.Setenv("DMS_TEST_SHELLY_EXIT", "0")
|
||||
t.Setenv("SHELLY_ELEVATOR", "")
|
||||
writeUpdateExecutable(t, dir, "shelly", `printf '%s|%s\n' "$*" "$SHELLY_ELEVATOR" >> "$DMS_TEST_SHELLY_LOG"
|
||||
case "$*" in
|
||||
'list-updates standard --json') printf '%s\n' '`+shellyRepoFixture+`' ;;
|
||||
'list-updates aur --json') printf '%s\n' '`+shellyAURFixture+`' ;;
|
||||
'upgrade standard --no-confirm'|'upgrade all --no-flatpak --no-appimage --no-confirm') exit "$DMS_TEST_SHELLY_EXIT" ;;
|
||||
*) echo "unexpected arguments: $*" >&2; exit 99 ;;
|
||||
esac`)
|
||||
return dir, log
|
||||
}
|
||||
|
||||
func readUpdateCalls(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return ""
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func TestShellyCheckUpdates(t *testing.T) {
|
||||
_, log := fakeShelly(t)
|
||||
pkgs, err := (shellyBackend{}).CheckUpdates(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(pkgs) != 2 || pkgs[0].Repo != RepoSystem || pkgs[1].Repo != RepoAUR {
|
||||
t.Fatalf("unexpected packages: %#v", pkgs)
|
||||
}
|
||||
if got, want := readUpdateCalls(t, log), "list-updates standard --json|\nlist-updates aur --json|\n"; got != want {
|
||||
t.Fatalf("calls = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellyCheckFailures(t *testing.T) {
|
||||
for _, code := range []string{"1", "2"} {
|
||||
t.Run(code, func(t *testing.T) {
|
||||
dir, _ := fakeShelly(t)
|
||||
writeUpdateExecutable(t, dir, "shelly", "echo 'database unavailable' >&2; exit "+code)
|
||||
if _, err := (shellyBackend{}).CheckUpdates(t.Context()); err == nil || !strings.Contains(err.Error(), "database unavailable") {
|
||||
t.Fatalf("expected query failure with stderr, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("cancellation", func(t *testing.T) {
|
||||
dir, _ := fakeShelly(t)
|
||||
writeUpdateExecutable(t, dir, "shelly", "exec /bin/sleep 30")
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
if _, err := (shellyBackend{}).CheckUpdates(ctx); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("expected deadline error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestShellyUpgrade(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
includeAUR bool
|
||||
attached bool
|
||||
terminal bool
|
||||
}{
|
||||
{"pkexec repository", false, false, false},
|
||||
{"pkexec aur", true, false, false},
|
||||
{"attached repository", false, true, false},
|
||||
{"attached aur", true, true, false},
|
||||
{"terminal repository", false, false, true},
|
||||
{"terminal aur", true, false, true},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dir, log := fakeShelly(t)
|
||||
opts := UpgradeOptions{IncludeAUR: tt.includeAUR, AttachStdio: tt.attached, Targets: []Package{{Name: "linux", Backend: "shelly", Repo: RepoSystem}}}
|
||||
elevator := ""
|
||||
if tt.terminal {
|
||||
opts.Terminal = "fake-terminal"
|
||||
writeUpdateExecutable(t, dir, opts.Terminal, `while [ "$1" != sh ] && [ "$#" -gt 0 ]; do shift; done
|
||||
shift
|
||||
exec /bin/sh "$@"`)
|
||||
} else if !tt.attached {
|
||||
t.Setenv("DMS_FORCE_PKEXEC", "1")
|
||||
elevator = "pkexec"
|
||||
}
|
||||
if err := (shellyBackend{}).Upgrade(t.Context(), opts, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
command := "upgrade standard --no-confirm"
|
||||
if tt.includeAUR {
|
||||
command = "upgrade all --no-flatpak --no-appimage --no-confirm"
|
||||
}
|
||||
if got, want := readUpdateCalls(t, log), command+"|"+elevator+"\n"; got != want {
|
||||
t.Fatalf("calls = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellyUpgradeHolds(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
includeAUR bool
|
||||
ignored []string
|
||||
wantError bool
|
||||
dryRun bool
|
||||
}{
|
||||
{"held aur", true, []string{"example-git"}, true, false},
|
||||
{"repository only", false, []string{"example-git"}, false, false},
|
||||
{"repository hold cannot exclude system upgrade", true, []string{"linux"}, false, false},
|
||||
{"unrelated hold", true, []string{"org.example.Flatpak"}, false, false},
|
||||
{"dry held aur", true, []string{"example-git"}, true, true},
|
||||
{"dry repository only", false, []string{"example-git"}, false, true},
|
||||
{"dry repository hold", true, []string{"linux"}, false, true},
|
||||
{"dry unrelated hold", true, []string{"org.example.Flatpak"}, false, true},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, log := fakeShelly(t)
|
||||
t.Setenv("DMS_FORCE_PKEXEC", "1")
|
||||
opts := UpgradeOptions{DryRun: tt.dryRun, IncludeAUR: tt.includeAUR, Ignored: tt.ignored, Targets: []Package{{Name: "linux", Backend: "shelly", Repo: RepoSystem}}}
|
||||
err := (shellyBackend{}).Upgrade(t.Context(), opts, nil)
|
||||
calls := readUpdateCalls(t, log)
|
||||
if strings.Count(calls, "list-updates aur --json") > 1 {
|
||||
t.Fatalf("duplicate AUR check: %q", calls)
|
||||
}
|
||||
if tt.wantError {
|
||||
if err == nil || err.Error() != `shelly cannot exclude held AUR package "example-git"; disable AUR updates or remove its DMS hold before updating` || strings.Contains(calls, "upgrade ") {
|
||||
t.Fatalf("held AUR package must block upgrade, got %v, calls %q", err, readUpdateCalls(t, log))
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || strings.Contains(calls, "upgrade ") == tt.dryRun {
|
||||
t.Fatalf("unexpected result for dryRun=%v: %v, calls %q", tt.dryRun, err, calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
if !isPacmanFamily(shellyBackend{}) {
|
||||
t.Fatal("Shelly must enforce repository hold restrictions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellyHoldCheckFailure(t *testing.T) {
|
||||
for _, dryRun := range []bool{false, true} {
|
||||
dir, log := fakeShelly(t)
|
||||
writeUpdateExecutable(t, dir, "shelly", `printf '%s\n' "$*" >> "$DMS_TEST_SHELLY_LOG"
|
||||
echo 'AUR unavailable' >&2
|
||||
exit 1`)
|
||||
opts := UpgradeOptions{DryRun: dryRun, IncludeAUR: true, Ignored: []string{"example-git"}, Targets: []Package{{Name: "linux", Backend: "shelly", Repo: RepoSystem}}}
|
||||
err := (shellyBackend{}).Upgrade(t.Context(), opts, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "AUR unavailable") {
|
||||
t.Fatalf("dryRun=%v: expected AUR query error, got %v", dryRun, err)
|
||||
}
|
||||
if calls := readUpdateCalls(t, log); calls != "list-updates aur --json\n" {
|
||||
t.Fatalf("query failure must stop the operation: %q", calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellyDryRun(t *testing.T) {
|
||||
for _, includeAUR := range []bool{false, true} {
|
||||
_, log := fakeShelly(t)
|
||||
var lines []string
|
||||
opts := UpgradeOptions{DryRun: true, IncludeAUR: includeAUR, Targets: []Package{{Name: "linux", Backend: "shelly", Repo: RepoSystem}}}
|
||||
if err := (shellyBackend{}).Upgrade(t.Context(), opts, func(line string) { lines = append(lines, line) }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantCalls := "list-updates standard --json|\n"
|
||||
wantLines := []string{"linux 2:6.18-1 -> 2:6.19-1"}
|
||||
if includeAUR {
|
||||
wantCalls = "list-updates aur --json|\n" + wantCalls
|
||||
wantLines = append(wantLines, "example-git r1-1 -> r2-1")
|
||||
}
|
||||
if got := readUpdateCalls(t, log); got != wantCalls || !reflect.DeepEqual(lines, wantLines) {
|
||||
t.Fatalf("calls = %q, lines = %q", got, lines)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellySkipsExcludedTargets(t *testing.T) {
|
||||
for _, targets := range [][]Package{
|
||||
nil,
|
||||
{{Name: "example-git", Backend: "shelly", Repo: RepoAUR}},
|
||||
{{Name: "org.example.App", Backend: "flatpak", Repo: RepoFlatpak}},
|
||||
{{Name: "linux", Backend: "pacman", Repo: RepoSystem}},
|
||||
} {
|
||||
_, log := fakeShelly(t)
|
||||
if err := (shellyBackend{}).Upgrade(t.Context(), UpgradeOptions{Targets: targets, IncludeFlatpak: true}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := readUpdateCalls(t, log); got != "" {
|
||||
t.Fatalf("excluded targets executed commands: %q", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellyUpgradeFailure(t *testing.T) {
|
||||
_, _ = fakeShelly(t)
|
||||
t.Setenv("DMS_FORCE_PKEXEC", "1")
|
||||
t.Setenv("DMS_TEST_SHELLY_EXIT", "126")
|
||||
opts := UpgradeOptions{Targets: []Package{{Name: "linux", Backend: "shelly", Repo: RepoSystem}}}
|
||||
if err := (shellyBackend{}).Upgrade(t.Context(), opts, nil); err == nil {
|
||||
t.Fatal("authentication or upgrade failure must propagate")
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ func Run(ctx context.Context, argv []string, opts RunOptions) error {
|
||||
}
|
||||
return cmd.Process.Kill()
|
||||
}
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
@@ -125,7 +126,14 @@ func wrapInTerminal(term, title, shellCmd string, extraArgs []string) []string {
|
||||
`printf '\033[1;36m=== %s ===\033[0m\n'; printf '\033[2m$ %s\033[0m\n'; printf '\033[33mYou may be prompted for your sudo password to apply system updates.\033[0m\n\n'`,
|
||||
title, shellCmd,
|
||||
)
|
||||
closer := `printf '\n\033[1;32m=== Done. Press Enter to close. ===\033[0m\n'; read`
|
||||
closer := `dms_update_status=$?
|
||||
if [ "$dms_update_status" -eq 0 ]; then
|
||||
printf '\n\033[1;32m=== Done. Press Enter to close. ===\033[0m\n'
|
||||
else
|
||||
printf '\n\033[1;31m=== Update failed (exit %s). Press Enter to close. ===\033[0m\n' "$dms_update_status"
|
||||
fi
|
||||
read -r dms_update_reply
|
||||
exit "$dms_update_status"`
|
||||
export := `export SUDO_PROMPT="[DMS] sudo password for %u: "; `
|
||||
full := export + banner + "; " + shellCmd + "; " + closer
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package sysupdate
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTerminalWrapperPreservesExitStatus(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
command string
|
||||
status int
|
||||
message string
|
||||
}{
|
||||
{"true", 0, "Done."},
|
||||
{"false", 1, "Update failed (exit 1)"},
|
||||
{"sh -c 'exit 42'", 42, "Update failed (exit 42)"},
|
||||
} {
|
||||
t.Run(tt.command, func(t *testing.T) {
|
||||
argv := wrapInTerminal("xterm", "Test", tt.command, nil)
|
||||
cmd := exec.Command("sh", "-c", argv[len(argv)-1])
|
||||
cmd.Stdin = strings.NewReader("\n")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if tt.status == 0 && err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tt.status != 0 {
|
||||
exitErr, ok := errors.AsType[*exec.ExitError](err)
|
||||
if !ok || exitErr.ExitCode() != tt.status {
|
||||
t.Fatalf("expected exit %d, got %v (%s)", tt.status, err, out)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(string(out), tt.message) {
|
||||
t.Fatalf("missing status message %q: %s", tt.message, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAttachesStdin(t *testing.T) {
|
||||
reader, writer, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer reader.Close()
|
||||
if _, err := writer.WriteString("confirmation\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writer.Close()
|
||||
previous := os.Stdin
|
||||
os.Stdin = reader
|
||||
defer func() { os.Stdin = previous }()
|
||||
if err := Run(t.Context(), []string{"sh", "-c", `read -r answer && [ "$answer" = confirmation ]`}, RunOptions{AttachStdio: true}); err != nil {
|
||||
t.Fatalf("attached command did not receive stdin: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -420,8 +420,8 @@ func (m *Manager) runCustomUpgrade(ctx context.Context, opts UpgradeOptions) {
|
||||
m.failCustomUpgrade(ErrCodeCancelled, err)
|
||||
return
|
||||
}
|
||||
// exit status reflects the trailing `read`, not the update command
|
||||
m.appendLog(fmt.Sprintf("Terminal exited early: %v", err))
|
||||
m.failCustomUpgrade(ErrCodeBackendFailed, err)
|
||||
return
|
||||
}
|
||||
|
||||
m.finishSuccessfulUpgrade(false)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package sysupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type customUpgradeTestBackend struct {
|
||||
pacmanBackend
|
||||
checks int
|
||||
}
|
||||
|
||||
func (b *customUpgradeTestBackend) CheckUpdates(context.Context) ([]Package, error) {
|
||||
b.checks++
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestCustomUpgradeFailure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("PATH", dir)
|
||||
writeUpdateExecutable(t, dir, "fake-terminal", `while [ "$#" -gt 0 ] && [ "$1" != sh ]; do shift; done
|
||||
[ "$#" -gt 0 ] || exit 99
|
||||
shift
|
||||
exec /bin/sh "$@"`)
|
||||
|
||||
backend := &customUpgradeTestBackend{}
|
||||
m := &Manager{
|
||||
selection: Selection{System: backend},
|
||||
notifyDirty: make(chan struct{}, 1),
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
m.runCustomUpgrade(t.Context(), UpgradeOptions{
|
||||
Terminal: "fake-terminal",
|
||||
CustomCommand: "false",
|
||||
})
|
||||
|
||||
state := m.GetState()
|
||||
if state.Phase != PhaseError {
|
||||
t.Errorf("phase = %s, want %s", state.Phase, PhaseError)
|
||||
}
|
||||
if state.Error == nil || state.Error.Code != ErrCodeBackendFailed || !strings.Contains(state.Error.Message, "exit status 1") {
|
||||
t.Errorf("error = %#v, want backend-failed with exit status 1", state.Error)
|
||||
}
|
||||
if slices.Contains(state.RecentLog, "Upgrade complete.") {
|
||||
t.Error("failed custom upgrade logged success")
|
||||
}
|
||||
if backend.checks != 0 {
|
||||
t.Errorf("failed custom upgrade refreshed %d times", backend.checks)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
inherit version;
|
||||
pname = "dms-shell";
|
||||
src = ./core;
|
||||
vendorHash = "sha256-nswS/ygp0oYMXp6YHbbIxBha4xvzjKAxcAS5yOpbslg=";
|
||||
vendorHash = "sha256-ejDi+TzstUtxtWpcl5yYw4UZuTnZraOsIdJuXPKxJ6s=";
|
||||
|
||||
subPackages = [ "cmd/dms" ];
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
Heisenberg
|
||||
Marble Tabby
|
||||
|
||||
@@ -189,6 +189,26 @@ Singleton {
|
||||
}
|
||||
}
|
||||
|
||||
readonly property var _termIndexes: new WeakMap()
|
||||
|
||||
function _termIndex(table) {
|
||||
const cached = _termIndexes.get(table);
|
||||
if (cached)
|
||||
return cached;
|
||||
const index = {};
|
||||
for (const c in table) {
|
||||
const bucket = table[c];
|
||||
if (!bucket)
|
||||
continue;
|
||||
for (const term in bucket) {
|
||||
if (!index[term] && bucket[term])
|
||||
index[term] = bucket[term];
|
||||
}
|
||||
}
|
||||
_termIndexes.set(table, index);
|
||||
return index;
|
||||
}
|
||||
|
||||
function _lookup(table, term, context) {
|
||||
if (!table)
|
||||
return "";
|
||||
@@ -196,11 +216,7 @@ Singleton {
|
||||
return table[context][term];
|
||||
if (table[term] && table[term][term])
|
||||
return table[term][term];
|
||||
for (const c in table) {
|
||||
if (table[c] && table[c][term])
|
||||
return table[c][term];
|
||||
}
|
||||
return "";
|
||||
return _termIndex(table)[term] || "";
|
||||
}
|
||||
|
||||
// isRealContext is consumed by translations/extract_translations.py only:
|
||||
|
||||
@@ -3,6 +3,7 @@ import Quickshell
|
||||
|
||||
QtObject {
|
||||
required property Singleton service
|
||||
property var modules: null
|
||||
property bool active: true
|
||||
|
||||
property bool _held: false
|
||||
@@ -11,7 +12,15 @@ QtObject {
|
||||
if (wanted === _held)
|
||||
return;
|
||||
_held = wanted;
|
||||
service.refCount += wanted ? 1 : -1;
|
||||
if (modules === null) {
|
||||
service.refCount += wanted ? 1 : -1;
|
||||
return;
|
||||
}
|
||||
if (wanted) {
|
||||
service.addRef(modules);
|
||||
return;
|
||||
}
|
||||
service.removeRef(modules);
|
||||
}
|
||||
|
||||
onActiveChanged: sync(active)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -37,7 +37,7 @@ Item {
|
||||
|
||||
readonly property string resolvedConnectedBarSide: frameConnectedMode ? preferredConnectedBarSide : ""
|
||||
|
||||
readonly property bool frameOwnsConnectedChrome: frameConnectedMode && resolvedConnectedBarSide !== "" && !allowStacking && CompositorService.usesConnectedFrameChromeForScreen(effectiveScreen)
|
||||
readonly property bool frameOwnsConnectedChrome: frameConnectedMode && resolvedConnectedBarSide !== "" && !allowStacking && effectiveModalLayer === WlrLayer.Top && CompositorService.canShareConnectedFrameChromeForScreen(effectiveScreen)
|
||||
|
||||
function _dockOccupiesSide(side) {
|
||||
if (!SettingsData.showDock)
|
||||
@@ -83,6 +83,12 @@ Item {
|
||||
property bool keepPopoutsOpen: false
|
||||
property var customKeyboardFocus: null
|
||||
property bool useOverlayLayer: false
|
||||
readonly property var effectiveModalLayer: root.useOverlayLayer ? WlrLayer.Overlay : LayerShell.fromEnv("DMS_MODAL_LAYER", WlrLayer.Top, {
|
||||
"allow": ["top", "overlay"],
|
||||
"invalidLayer": WlrLayer.Top,
|
||||
"label": "modals",
|
||||
"error": true
|
||||
})
|
||||
property real frozenMotionOffsetX: 0
|
||||
property real frozenMotionOffsetY: 0
|
||||
readonly property alias contentWindow: contentWindow
|
||||
@@ -140,7 +146,7 @@ Item {
|
||||
"phase": phase,
|
||||
"visible": presented,
|
||||
"presented": presented,
|
||||
"layer": root.useOverlayLayer ? "overlay" : "top",
|
||||
"layer": root.effectiveModalLayer === WlrLayer.Overlay ? "overlay" : "top",
|
||||
"barSide": resolvedConnectedBarSide,
|
||||
"bodyRect": bodyRect,
|
||||
"animationOffset": animationOffset,
|
||||
@@ -397,12 +403,7 @@ Item {
|
||||
}
|
||||
|
||||
WlrLayershell.namespace: root.layerNamespace
|
||||
WlrLayershell.layer: root.useOverlayLayer ? WlrLayer.Overlay : LayerShell.fromEnv("DMS_MODAL_LAYER", WlrLayer.Top, {
|
||||
"allow": ["top", "overlay"],
|
||||
"invalidLayer": WlrLayer.Top,
|
||||
"label": "modals",
|
||||
"error": true
|
||||
})
|
||||
WlrLayershell.layer: root.effectiveModalLayer
|
||||
WlrLayershell.exclusiveZone: -1
|
||||
WlrLayershell.keyboardFocus: KeyboardFocus.keyboardFocus(shouldHaveFocus, customKeyboardFocus)
|
||||
|
||||
|
||||
@@ -821,24 +821,15 @@ Item {
|
||||
}
|
||||
|
||||
if (isCategoryFiltered) {
|
||||
var categoryPluginId = AppSearchService.getPluginIdForCategory(appCategory);
|
||||
if (categoryPluginId) {
|
||||
var pluginCategoryItems = getPluginItems(categoryPluginId, "");
|
||||
for (var i = 0; i < pluginCategoryItems.length; i++) {
|
||||
allItems.push(pluginCategoryItems[i]);
|
||||
}
|
||||
} else {
|
||||
var rawApps = AppSearchService.getAppsInCategory(appCategory);
|
||||
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 rawApps = AppSearchService.getAppsInCategory(appCategory);
|
||||
for (var i = 0; i < rawApps.length; i++) {
|
||||
allItems.push(getOrTransformApp(rawApps[i]));
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -85,7 +85,7 @@ Item {
|
||||
|
||||
readonly property string resolvedConnectedBarSide: frameConnectedMode ? preferredConnectedBarSide : ""
|
||||
|
||||
readonly property bool frameOwnsConnectedChrome: frameConnectedMode && resolvedConnectedBarSide !== "" && CompositorService.usesConnectedFrameChromeForScreen(effectiveScreen)
|
||||
readonly property bool frameOwnsConnectedChrome: frameConnectedMode && resolvedConnectedBarSide !== "" && effectiveLauncherLayer === WlrLayer.Top && CompositorService.canShareConnectedFrameChromeForScreen(effectiveScreen)
|
||||
readonly property bool launcherArcExtenderActive: frameOwnsConnectedChrome && SettingsData.frameLauncherArcExtender && (resolvedConnectedBarSide === "top" || resolvedConnectedBarSide === "bottom")
|
||||
|
||||
function _dockOccupiesSide(side) {
|
||||
@@ -246,7 +246,7 @@ Item {
|
||||
"phase": phase,
|
||||
"visible": presented,
|
||||
"presented": presented,
|
||||
"layer": root.usesOverlayLayer ? "overlay" : "top",
|
||||
"layer": root.effectiveLauncherLayer === WlrLayer.Overlay ? "overlay" : "top",
|
||||
"barSide": resolvedConnectedBarSide,
|
||||
"bodyRect": bodyRect,
|
||||
"animationOffset": animationOffset,
|
||||
|
||||
@@ -47,23 +47,20 @@ Rectangle {
|
||||
Row {
|
||||
id: leftContent
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Theme.spacingXS
|
||||
anchors.leftMargin: hasAppCategories ? 0 : Theme.spacingXS
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Theme.spacingS
|
||||
|
||||
// Whether the apps category picker should replace the plain title
|
||||
readonly property bool hasAppCategories: root.section?.id === "apps" && (root.controller?.appCategories?.length ?? 0) > 0
|
||||
|
||||
DankIcon {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
// Hide section icon when the category chip already shows one
|
||||
visible: !leftContent.hasAppCategories
|
||||
name: root.section?.icon ?? "folder"
|
||||
size: 16
|
||||
color: Theme.surfaceVariantText
|
||||
}
|
||||
|
||||
// Plain title — hidden when the category chip is shown
|
||||
StyledText {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: !leftContent.hasAppCategories
|
||||
@@ -73,12 +70,10 @@ Rectangle {
|
||||
color: Theme.surfaceVariantText
|
||||
}
|
||||
|
||||
// Compact inline category chip — only visible on the apps section
|
||||
Item {
|
||||
id: categoryChip
|
||||
visible: leftContent.hasAppCategories
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
// Size to content with a fixed-min width so it doesn't jump around
|
||||
width: chipRow.implicitWidth + Theme.spacingM * 2
|
||||
height: 24
|
||||
|
||||
@@ -237,7 +232,6 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// Size to list content, cap at 10 visible items
|
||||
height: Math.min((root.controller?.appCategories?.length ?? 0) * 34, 10 * 34) + Theme.spacingS * 2 + 4
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,10 @@ PluginComponent {
|
||||
TailscaleService.connectTailscale(null);
|
||||
}
|
||||
|
||||
onCcWidgetExpanded: {
|
||||
TailscaleService.refresh(null);
|
||||
}
|
||||
|
||||
ccDetailContent: Component {
|
||||
Rectangle {
|
||||
id: detailRoot
|
||||
|
||||
@@ -19,8 +19,11 @@ Rectangle {
|
||||
signal settingsButtonClicked
|
||||
signal headerTapped
|
||||
|
||||
Component.onCompleted: DgopService.addRef("system")
|
||||
Component.onDestruction: DgopService.removeRef("system")
|
||||
Ref {
|
||||
service: DgopService
|
||||
modules: "system"
|
||||
active: root.visible && (root.Window.window?.visible ?? false)
|
||||
}
|
||||
|
||||
implicitHeight: 70
|
||||
radius: Theme.cornerRadius
|
||||
|
||||
@@ -19,7 +19,10 @@ Rectangle {
|
||||
|
||||
signal showPortSelector(var node)
|
||||
|
||||
Component.onCompleted: AudioService.refreshSinkPorts()
|
||||
Component.onCompleted: {
|
||||
AudioService.refreshSinkPorts();
|
||||
AudioService.refreshCards();
|
||||
}
|
||||
|
||||
implicitHeight: headerRow.height + (!hasVolumeSliderInCC ? volumeSlider.height : 0) + audioContent.height + Theme.spacingM
|
||||
radius: Theme.cornerRadius
|
||||
@@ -365,6 +368,78 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
Repeater {
|
||||
model: ScriptModel {
|
||||
values: AudioService.switchableOutputPorts
|
||||
}
|
||||
|
||||
delegate: Rectangle {
|
||||
id: portDelegate
|
||||
required property var modelData
|
||||
|
||||
width: parent.width
|
||||
height: 50
|
||||
radius: Theme.cornerRadius
|
||||
color: portMouseArea.containsMouse ? Theme.primaryHoverLight : Theme.surfaceLight
|
||||
border.color: Theme.outlineLight
|
||||
border.width: 1
|
||||
|
||||
DankRipple {
|
||||
id: portRipple
|
||||
cornerRadius: portDelegate.radius
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.leftMargin: Theme.spacingM
|
||||
anchors.rightMargin: Theme.spacingM
|
||||
spacing: Theme.spacingS
|
||||
|
||||
DankIcon {
|
||||
name: portDelegate.modelData.icon
|
||||
size: Theme.iconSize - 4
|
||||
color: Theme.surfaceText
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: parent.width - Theme.iconSize - parent.spacing
|
||||
|
||||
StyledText {
|
||||
text: portDelegate.modelData.title
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: Theme.surfaceText
|
||||
elide: Text.ElideRight
|
||||
width: parent.width
|
||||
wrapMode: Text.NoWrap
|
||||
horizontalAlignment: Text.AlignLeft
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: portDelegate.modelData.cardDescription
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
elide: Text.ElideRight
|
||||
width: parent.width
|
||||
wrapMode: Text.NoWrap
|
||||
horizontalAlignment: Text.AlignLeft
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: portMouseArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onPressed: mouse => portRipple.trigger(mouse.x, mouse.y)
|
||||
onClicked: AudioService.activateOutputPort(portDelegate.modelData, (ok, message) => ToastService.showToast(message, ok ? ToastService.levelInfo : ToastService.levelError))
|
||||
}
|
||||
}
|
||||
}
|
||||
Row {
|
||||
id: playbackHeaderRow
|
||||
anchors.left: parent.left
|
||||
|
||||
@@ -20,12 +20,10 @@ Rectangle {
|
||||
border.color: Theme.outlineMedium
|
||||
border.width: Theme.layerOutlineWidth
|
||||
|
||||
Component.onCompleted: {
|
||||
DgopService.addRef(["diskmounts"]);
|
||||
}
|
||||
|
||||
Component.onDestruction: {
|
||||
DgopService.removeRef(["diskmounts"]);
|
||||
Ref {
|
||||
service: DgopService
|
||||
modules: ["diskmounts"]
|
||||
active: root.visible && (root.Window.window?.visible ?? false)
|
||||
}
|
||||
|
||||
DankFlickable {
|
||||
|
||||
@@ -67,11 +67,10 @@ CompoundPill {
|
||||
return Theme.surfaceText;
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
DgopService.addRef(["diskmounts"]);
|
||||
}
|
||||
Component.onDestruction: {
|
||||
DgopService.removeRef(["diskmounts"]);
|
||||
Ref {
|
||||
service: DgopService
|
||||
modules: ["diskmounts"]
|
||||
active: root.visible && (root.Window.window?.visible ?? false)
|
||||
}
|
||||
|
||||
onToggled: {
|
||||
|
||||
@@ -117,10 +117,9 @@ Rectangle {
|
||||
onClicked: root.clicked()
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
DgopService.addRef(["diskmounts"]);
|
||||
}
|
||||
Component.onDestruction: {
|
||||
DgopService.removeRef(["diskmounts"]);
|
||||
Ref {
|
||||
service: DgopService
|
||||
modules: ["diskmounts"]
|
||||
active: root.visible && (root.Window.window?.visible ?? false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ Item {
|
||||
applyIndexLayout();
|
||||
}
|
||||
updateContentExtent();
|
||||
blurBarWindow?.refreshBlurRegion?.();
|
||||
}
|
||||
|
||||
function updateContentExtent() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -164,6 +164,12 @@ Item {
|
||||
_blurRebuildTimer.restart();
|
||||
}
|
||||
|
||||
function refreshBlurRegion() {
|
||||
if (!blurRegion)
|
||||
return;
|
||||
blurRegion.changed();
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: _blurRebuildTimer
|
||||
interval: 1
|
||||
@@ -386,12 +392,26 @@ Item {
|
||||
Connections {
|
||||
target: topBarSlide
|
||||
function onXChanged() {
|
||||
if (barWindow.blurRegion)
|
||||
barWindow.blurRegion.changed();
|
||||
barWindow.refreshBlurRegion();
|
||||
}
|
||||
function onYChanged() {
|
||||
if (barWindow.blurRegion)
|
||||
barWindow.blurRegion.changed();
|
||||
barWindow.refreshBlurRegion();
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: barUnitInset
|
||||
function onXChanged() {
|
||||
barWindow.refreshBlurRegion();
|
||||
}
|
||||
function onYChanged() {
|
||||
barWindow.refreshBlurRegion();
|
||||
}
|
||||
function onWidthChanged() {
|
||||
barWindow.refreshBlurRegion();
|
||||
}
|
||||
function onHeightChanged() {
|
||||
barWindow.refreshBlurRegion();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,8 +421,7 @@ Item {
|
||||
_blurRebuildTimer.restart();
|
||||
}
|
||||
function onWingChanged() {
|
||||
if (barWindow.blurRegion)
|
||||
barWindow.blurRegion.changed();
|
||||
barWindow.refreshBlurRegion();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1149,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -25,6 +25,13 @@ Item {
|
||||
readonly property bool isVertical: overrideAxisLayout ? forceVerticalLayout : (axis?.isVertical ?? false)
|
||||
property alias widgetLayoutLoader: layoutLoader
|
||||
|
||||
function refreshBlur() {
|
||||
blurBarWindow?.refreshBlurRegion?.();
|
||||
}
|
||||
|
||||
onXChanged: refreshBlur()
|
||||
onYChanged: refreshBlur()
|
||||
|
||||
implicitHeight: layoutLoader.item ? layoutLoader.item.implicitHeight : 0
|
||||
implicitWidth: layoutLoader.item ? layoutLoader.item.implicitWidth : 0
|
||||
|
||||
@@ -52,6 +59,7 @@ Item {
|
||||
visible: widgetLoader.active && widgetLoader.widgetEnabled
|
||||
width: widgetLoader.item ? widgetLoader.item.width : 0
|
||||
height: widgetLoader.item ? widgetLoader.item.height : 0
|
||||
onXChanged: root.refreshBlur()
|
||||
WidgetHost {
|
||||
id: widgetLoader
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
@@ -100,6 +108,7 @@ Item {
|
||||
property var itemData: modelData
|
||||
visible: widgetLoader.active && widgetLoader.widgetEnabled
|
||||
height: widgetLoader.item ? widgetLoader.item.height : 0
|
||||
onYChanged: root.refreshBlur()
|
||||
WidgetHost {
|
||||
id: widgetLoader
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
|
||||
@@ -25,6 +25,13 @@ Item {
|
||||
readonly property bool isVertical: overrideAxisLayout ? forceVerticalLayout : (axis?.isVertical ?? false)
|
||||
property alias widgetLayoutLoader: layoutLoader
|
||||
|
||||
function refreshBlur() {
|
||||
blurBarWindow?.refreshBlurRegion?.();
|
||||
}
|
||||
|
||||
onXChanged: refreshBlur()
|
||||
onYChanged: refreshBlur()
|
||||
|
||||
implicitHeight: layoutLoader.item ? layoutLoader.item.implicitHeight : 0
|
||||
implicitWidth: layoutLoader.item ? layoutLoader.item.implicitWidth : 0
|
||||
|
||||
@@ -54,6 +61,7 @@ Item {
|
||||
visible: widgetLoader.active && widgetLoader.widgetEnabled
|
||||
width: widgetLoader.item ? widgetLoader.item.width : 0
|
||||
height: widgetLoader.item ? widgetLoader.item.height : 0
|
||||
onXChanged: root.refreshBlur()
|
||||
WidgetHost {
|
||||
id: widgetLoader
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
@@ -102,6 +110,7 @@ Item {
|
||||
property var itemData: modelData
|
||||
visible: widgetLoader.active && widgetLoader.widgetEnabled
|
||||
height: widgetLoader.item ? widgetLoader.item.height : 0
|
||||
onYChanged: root.refreshBlur()
|
||||
WidgetHost {
|
||||
id: widgetLoader
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
|
||||
@@ -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
|
||||
@@ -1899,6 +1900,8 @@ BasePill {
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceTextMedium
|
||||
elide: Text.ElideMiddle
|
||||
wrapMode: Text.NoWrap
|
||||
maximumLineCount: 1
|
||||
width: parent.width - Theme.spacingS * 2 - (Theme.iconSizeSmall + Theme.spacingS)
|
||||
}
|
||||
|
||||
@@ -2026,7 +2029,7 @@ BasePill {
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
RowLayout {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Theme.spacingS
|
||||
anchors.right: parent.right
|
||||
@@ -2036,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
|
||||
@@ -2064,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 {
|
||||
@@ -2084,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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,44 +26,19 @@ Item {
|
||||
readonly property bool isMango: CompositorService.isMango
|
||||
readonly property bool useAqueous: CompositorService.isAqueous && AqueousService.available && Quickshell.env("DMS_FORCE_EXTWS") !== "1"
|
||||
|
||||
readonly property real _leftMargin: {
|
||||
if (isVertical)
|
||||
return 0;
|
||||
root.x;
|
||||
if (!root.parent)
|
||||
return 0;
|
||||
const gap = root.mapToItem(null, 0, 0).x;
|
||||
return (gap > 0 && gap < 30) ? gap + 5 : 0;
|
||||
}
|
||||
readonly property real _rightMargin: {
|
||||
if (isVertical)
|
||||
return 0;
|
||||
root.x;
|
||||
root.width;
|
||||
if (!root.parent || !blurBarWindow)
|
||||
return 0;
|
||||
const gap = blurBarWindow.width - root.mapToItem(null, root.width, 0).x;
|
||||
return (gap > 0 && gap < 30) ? gap + 5 : 0;
|
||||
}
|
||||
readonly property real _topMargin: {
|
||||
if (!isVertical)
|
||||
return axis?.edge === "top" ? crossEdgeExtension : 0;
|
||||
root.y;
|
||||
if (!root.parent)
|
||||
return 0;
|
||||
const gap = root.mapToItem(null, 0, 0).y;
|
||||
return (gap > 0 && gap < 30) ? gap + 5 : 0;
|
||||
}
|
||||
readonly property real _bottomMargin: {
|
||||
if (!isVertical)
|
||||
return axis?.edge === "bottom" ? crossEdgeExtension : 0;
|
||||
root.y;
|
||||
root.height;
|
||||
if (!root.parent || !blurBarWindow)
|
||||
return 0;
|
||||
const gap = blurBarWindow.height - root.mapToItem(null, 0, root.height).y;
|
||||
return (gap > 0 && gap < 30) ? gap + 5 : 0;
|
||||
}
|
||||
property bool isFirst: false
|
||||
property bool isLast: false
|
||||
property real sectionSpacing: 0
|
||||
property bool isLeftBarEdge: false
|
||||
property bool isRightBarEdge: false
|
||||
property bool isTopBarEdge: false
|
||||
property bool isBottomBarEdge: false
|
||||
|
||||
readonly property real barEdgeExtension: 1000
|
||||
readonly property real _leftMargin: isVertical ? 0 : (isLeftBarEdge && isFirst ? barEdgeExtension : (isFirst ? sectionSpacing : sectionSpacing / 2))
|
||||
readonly property real _rightMargin: isVertical ? 0 : (isRightBarEdge && isLast ? barEdgeExtension : (isLast ? sectionSpacing : sectionSpacing / 2))
|
||||
readonly property real _topMargin: isVertical ? (isTopBarEdge && isFirst ? barEdgeExtension : (isFirst ? sectionSpacing : sectionSpacing / 2)) : (axis?.edge === "top" ? crossEdgeExtension : 0)
|
||||
readonly property real _bottomMargin: isVertical ? (isBottomBarEdge && isLast ? barEdgeExtension : (isLast ? sectionSpacing : sectionSpacing / 2)) : (axis?.edge === "bottom" ? crossEdgeExtension : 0)
|
||||
|
||||
property int _desktopEntriesUpdateTrigger: 0
|
||||
readonly property var sortedToplevels: {
|
||||
|
||||
@@ -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
|
||||
@@ -166,6 +169,9 @@ Item {
|
||||
function unregisterBlurWidget(item) {
|
||||
root.unregisterBlurWidget(item);
|
||||
}
|
||||
function refreshBlurRegion() {
|
||||
root.kickBlur();
|
||||
}
|
||||
}
|
||||
|
||||
property var _blurWidgetItems: []
|
||||
@@ -266,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;
|
||||
@@ -409,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
|
||||
|
||||
@@ -426,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 {
|
||||
@@ -441,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 {
|
||||
@@ -497,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
|
||||
@@ -518,6 +560,12 @@ Item {
|
||||
crossEdgeExtension: root.crossEdgeExtension
|
||||
edgeIsScreenEdge: root.edgeAligned
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
id: leadingWidgetHoverHandler
|
||||
enabled: root.satelliteHoverTracking
|
||||
onPointChanged: componentProvider.queueHoverFromItem(leadingInput, point)
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
@@ -531,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
|
||||
@@ -552,5 +602,11 @@ Item {
|
||||
crossEdgeExtension: root.crossEdgeExtension
|
||||
edgeIsScreenEdge: root.edgeAligned
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
id: trailingWidgetHoverHandler
|
||||
enabled: root.satelliteHoverTracking
|
||||
onPointChanged: componentProvider.queueHoverFromItem(trailingInput, point)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -179,10 +179,10 @@ Item {
|
||||
|
||||
SettingsToggleRow {
|
||||
settingKey: "systemUpdaterAUR"
|
||||
tags: ["aur", "paru", "yay"]
|
||||
tags: ["aur", "paru", "yay", "shelly"]
|
||||
text: I18n.tr("Include AUR updates")
|
||||
description: I18n.tr("Run paru/yay with AUR enabled when 'Update All' is clicked.")
|
||||
visible: (SystemUpdateService.backends || []).some(b => b.id === "paru" || b.id === "yay")
|
||||
description: I18n.tr("Apply AUR updates alongside system updates when running 'Update All'.")
|
||||
visible: (SystemUpdateService.backends || []).some(b => ["paru", "yay", "shelly"].includes(b.id))
|
||||
checked: SettingsData.updaterAllowAUR
|
||||
onToggled: checked => SettingsData.set("updaterAllowAUR", checked)
|
||||
}
|
||||
@@ -198,17 +198,21 @@ Item {
|
||||
settingKey: "systemUpdaterIgnoredPackages"
|
||||
tags: ["system", "update", "package", "ignore"]
|
||||
|
||||
property bool errorIsInvalidName: false
|
||||
|
||||
function addIgnoredPackage() {
|
||||
const name = newIgnoredPackageField.text.trim();
|
||||
if (name === "") {
|
||||
return;
|
||||
}
|
||||
if (!/^[A-Za-z0-9@._+:-]+$/.test(name)) {
|
||||
errorIsInvalidName = !/^[A-Za-z0-9@._+:-]+$/.test(name);
|
||||
if (errorIsInvalidName) {
|
||||
ignoredPackageError.visible = true;
|
||||
return;
|
||||
}
|
||||
ignoredPackageError.visible = false;
|
||||
SystemUpdateService.ignorePackage(name);
|
||||
ignoredPackageError.visible = !SystemUpdateService.ignorePackage(name);
|
||||
if (ignoredPackageError.visible)
|
||||
return;
|
||||
newIgnoredPackageField.text = "";
|
||||
}
|
||||
|
||||
@@ -222,6 +226,9 @@ Item {
|
||||
if (SettingsData.updaterUseCustomCommand) {
|
||||
return I18n.tr("Ignored packages only apply to the built-in updater. Your custom command controls its own exclusions.");
|
||||
}
|
||||
if (SystemUpdateService.pkgManager === "shelly") {
|
||||
return I18n.tr("With Shelly, only Flatpak packages in the current update list can be ignored.");
|
||||
}
|
||||
return (SettingsData.updaterIgnoredPackages || []).length > 0 ? I18n.tr("Ignored packages are hidden from the updater and skipped by 'Update All'.") : I18n.tr("No packages ignored. Add one here or hover an update in the popout and click the hide button.");
|
||||
}
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
@@ -258,10 +265,12 @@ Item {
|
||||
|
||||
StyledText {
|
||||
id: ignoredPackageError
|
||||
width: parent.width
|
||||
visible: false
|
||||
text: I18n.tr("Invalid package name — letters, digits and @._+:- only.")
|
||||
text: ignoredPackagesCard.errorIsInvalidName ? I18n.tr("Invalid package name — letters, digits and @._+:- only.") : I18n.tr("With Shelly, only Flatpak packages in the current update list can be ignored.")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.error
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
|
||||
@@ -769,11 +769,7 @@ Singleton {
|
||||
return results;
|
||||
}
|
||||
|
||||
function getCategoriesForApp(app) {
|
||||
if (!app?.categories)
|
||||
return [];
|
||||
|
||||
const categoryMap = {
|
||||
readonly property var _categoryMap: ({
|
||||
"AudioVideo": I18n.tr("Media"),
|
||||
"Audio": I18n.tr("Media"),
|
||||
"Video": I18n.tr("Media"),
|
||||
@@ -798,15 +794,20 @@ Singleton {
|
||||
"Accessories": I18n.tr("Utilities"),
|
||||
"FileManager": I18n.tr("Utilities"),
|
||||
"TerminalEmulator": I18n.tr("Utilities")
|
||||
};
|
||||
})
|
||||
|
||||
on_CategoryMapChanged: _cachedCategories = null
|
||||
|
||||
function getCategoriesForApp(app) {
|
||||
if (!app?.categories)
|
||||
return [];
|
||||
|
||||
const mappedCategories = new Set();
|
||||
|
||||
for (const cat of app.categories) {
|
||||
if (categoryMap[cat])
|
||||
mappedCategories.add(categoryMap[cat]);
|
||||
const mapped = _categoryMap[cat];
|
||||
if (mapped)
|
||||
mappedCategories.add(mapped);
|
||||
}
|
||||
|
||||
return Array.from(mappedCategories);
|
||||
}
|
||||
|
||||
@@ -842,15 +843,11 @@ Singleton {
|
||||
appCategories.forEach(cat => categories.add(cat));
|
||||
}
|
||||
|
||||
// Include categories from core apps (e.g. DMS Settings)
|
||||
for (const app of coreApps) {
|
||||
const appCategories = getCategoriesForApp(app);
|
||||
appCategories.forEach(cat => categories.add(cat));
|
||||
}
|
||||
|
||||
const pluginCategories = getPluginCategories();
|
||||
pluginCategories.forEach(cat => categories.add(cat));
|
||||
|
||||
_cachedCategories = Array.from(categories).sort();
|
||||
return _cachedCategories;
|
||||
}
|
||||
|
||||
@@ -56,6 +56,9 @@ Singleton {
|
||||
property bool wireplumberReloading: false
|
||||
|
||||
property var sinkPorts: ({})
|
||||
property var cards: []
|
||||
property var pendingCardSwitch: null
|
||||
readonly property var switchableOutputPorts: root.collectSwitchableOutputPorts(root.cards)
|
||||
|
||||
readonly property int sinkMaxVolume: {
|
||||
const name = sink?.name ?? "";
|
||||
@@ -127,6 +130,7 @@ Singleton {
|
||||
target: Pipewire.nodes
|
||||
function onValuesChanged() {
|
||||
root.rebuildTypedNodeLists();
|
||||
root.adoptPendingCardSink();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,6 +275,225 @@ Singleton {
|
||||
return result;
|
||||
}
|
||||
|
||||
function refreshCards(callback) {
|
||||
Proc.runCommand("audio-list-cards", ["env", "LC_ALL=C", "pactl", "list", "cards"], (output, exitCode) => {
|
||||
root.cards = exitCode === 0 ? root.parseCards(output) : [];
|
||||
if (callback)
|
||||
callback();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function parseCards(text) {
|
||||
const cards = [];
|
||||
let card = null;
|
||||
let port = null;
|
||||
let section = "";
|
||||
|
||||
function commit() {
|
||||
if (!card?.name)
|
||||
return;
|
||||
const props = card.props;
|
||||
card.description = props["device.description"] || props["device.nick"] || props["alsa.card_name"] || card.name;
|
||||
cards.push(card);
|
||||
}
|
||||
|
||||
function applyProperty(target, line) {
|
||||
const match = line.match(/^([^=\s]+)\s*=\s*"(.*)"$/);
|
||||
if (match)
|
||||
target[match[1]] = match[2];
|
||||
}
|
||||
|
||||
function addProfile(target, line) {
|
||||
const match = line.match(/^(.+?):\s+(.*)\s+\(([^()]*)\)$/);
|
||||
if (!match)
|
||||
return;
|
||||
const meta = match[3];
|
||||
target.profiles[match[1]] = {
|
||||
description: match[2],
|
||||
priority: parseInt(meta.match(/priority:?\s*(\d+)/)?.[1] ?? "0", 10),
|
||||
available: !/available:\s*no/.test(meta)
|
||||
};
|
||||
}
|
||||
|
||||
function addPort(target, line) {
|
||||
const match = line.match(/^(.+?):\s+(.*)\s+\(([^()]*)\)$/);
|
||||
if (!match)
|
||||
return null;
|
||||
const meta = match[3];
|
||||
let availability = "unknown";
|
||||
if (meta.includes("not available"))
|
||||
availability = "no";
|
||||
else if (/\bavailable\b/.test(meta))
|
||||
availability = "yes";
|
||||
const entry = {
|
||||
name: match[1],
|
||||
description: match[2],
|
||||
type: (meta.match(/type:\s*([^,]+)/)?.[1] ?? "").trim().toLowerCase(),
|
||||
availability: availability,
|
||||
profiles: [],
|
||||
props: {}
|
||||
};
|
||||
target.ports.push(entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
for (const rawLine of (text || "").split("\n")) {
|
||||
const line = rawLine.trim();
|
||||
|
||||
if (/^Card #\d+/.test(line)) {
|
||||
commit();
|
||||
card = {
|
||||
name: "",
|
||||
description: "",
|
||||
activeProfile: "",
|
||||
profiles: {},
|
||||
ports: [],
|
||||
props: {}
|
||||
};
|
||||
port = null;
|
||||
section = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!card)
|
||||
continue;
|
||||
|
||||
if (line.startsWith("Name:")) {
|
||||
card.name = line.substring(5).trim();
|
||||
section = "";
|
||||
continue;
|
||||
}
|
||||
if (line === "Properties:") {
|
||||
section = port ? "portProps" : "cardProps";
|
||||
continue;
|
||||
}
|
||||
if (line === "Profiles:") {
|
||||
section = "profiles";
|
||||
continue;
|
||||
}
|
||||
if (line === "Ports:") {
|
||||
section = "ports";
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("Active Profile:")) {
|
||||
card.activeProfile = line.substring(15).trim();
|
||||
section = "";
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("Part of profile(s):")) {
|
||||
if (port)
|
||||
port.profiles = line.substring(19).split(",").map(s => s.trim()).filter(s => s);
|
||||
section = "ports";
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (section) {
|
||||
case "cardProps":
|
||||
applyProperty(card.props, line);
|
||||
break;
|
||||
case "portProps":
|
||||
if (port)
|
||||
applyProperty(port.props, line);
|
||||
break;
|
||||
case "profiles":
|
||||
addProfile(card, line);
|
||||
break;
|
||||
case "ports":
|
||||
port = addPort(card, line) ?? port;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
commit();
|
||||
return cards;
|
||||
}
|
||||
|
||||
function bestOutputProfile(card, port) {
|
||||
const candidates = port.profiles.filter(name => name.startsWith("output:") && card.profiles[name]?.available);
|
||||
if (candidates.length === 0)
|
||||
return "";
|
||||
candidates.sort((a, b) => card.profiles[b].priority - card.profiles[a].priority);
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
function collectSwitchableOutputPorts(cards) {
|
||||
const entries = [];
|
||||
for (const card of cards || []) {
|
||||
if (!card.activeProfile || card.activeProfile === "pro-audio")
|
||||
continue;
|
||||
for (const port of card.ports) {
|
||||
if (port.availability === "no" || port.profiles.includes(card.activeProfile))
|
||||
continue;
|
||||
const profile = bestOutputProfile(card, port);
|
||||
if (!profile)
|
||||
continue;
|
||||
const product = port.props["device.product.name"] || "";
|
||||
entries.push({
|
||||
cardName: card.name,
|
||||
cardDescription: card.description,
|
||||
portName: port.name,
|
||||
profile: profile,
|
||||
icon: port.type === "hdmi" ? "tv" : "speaker",
|
||||
title: product ? `${port.description} [${product}]` : port.description
|
||||
});
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function sinkBelongsToCard(node, cardName) {
|
||||
if (!node || node.isStream || !cardName)
|
||||
return false;
|
||||
if (node.properties?.["device.name"] === cardName)
|
||||
return true;
|
||||
if (!cardName.startsWith("alsa_card."))
|
||||
return false;
|
||||
return (node.name || "").startsWith("alsa_output." + cardName.substring(10) + ".");
|
||||
}
|
||||
|
||||
function activateOutputPort(entry, callback) {
|
||||
if (!entry?.cardName || !entry.profile)
|
||||
return;
|
||||
const previousSinks = Pipewire.nodes.values.filter(n => n.isSink && sinkBelongsToCard(n, entry.cardName)).map(n => n.name);
|
||||
Proc.runCommand("audio-set-card-profile", ["env", "LC_ALL=C", "pactl", "set-card-profile", entry.cardName, entry.profile], (output, exitCode) => {
|
||||
const ok = exitCode === 0;
|
||||
if (ok) {
|
||||
root.pendingCardSwitch = {
|
||||
cardName: entry.cardName,
|
||||
portName: entry.portName,
|
||||
exclude: previousSinks
|
||||
};
|
||||
pendingCardSwitchTimer.restart();
|
||||
root.adoptPendingCardSink();
|
||||
}
|
||||
if (callback)
|
||||
callback(ok, ok ? I18n.tr("Output switched", "audio card profile switched successful message") : (output || I18n.tr("Failed to switch output", "audio card profile switch failure message")));
|
||||
Qt.callLater(() => {
|
||||
root.refreshCards();
|
||||
root.refreshSinkPorts();
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function adoptPendingCardSink() {
|
||||
const pending = pendingCardSwitch;
|
||||
if (!pending)
|
||||
return;
|
||||
const node = Pipewire.nodes.values.find(n => n.isSink && sinkBelongsToCard(n, pending.cardName) && !pending.exclude.includes(n.name));
|
||||
if (!node)
|
||||
return;
|
||||
pendingCardSwitch = null;
|
||||
pendingCardSwitchTimer.stop();
|
||||
setSink(node);
|
||||
setSinkPort(node.name, pending.portName);
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: pendingCardSwitchTimer
|
||||
interval: 5000
|
||||
onTriggered: root.pendingCardSwitch = null
|
||||
}
|
||||
|
||||
function cycleAudioOutputDirection(forward) {
|
||||
const sinks = getAvailableSinks();
|
||||
if (sinks.length < 2)
|
||||
@@ -658,6 +881,7 @@ EOFCONFIG
|
||||
[ $found -eq 1 ] && break
|
||||
done
|
||||
done
|
||||
exit 0
|
||||
`;
|
||||
|
||||
Proc.runCommand("discoverSoundFiles", ["sh", "-c", script], (output, exitCode) => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -13,7 +13,7 @@ Singleton {
|
||||
readonly property var log: Log.scoped("ChangelogService")
|
||||
|
||||
readonly property string currentVersion: "1.6"
|
||||
readonly property bool changelogEnabled: false
|
||||
readonly property bool changelogEnabled: true
|
||||
|
||||
readonly property string configDir: Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation)) + "/DankMaterialShell"
|
||||
readonly property string changelogMarkerPath: configDir + "/.changelog-" + currentVersion
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -730,6 +730,21 @@ Singleton {
|
||||
return FrameTransitionState.effectiveConnectedFrameModeActive && frameWindowVisibleForScreen(screenOrName);
|
||||
}
|
||||
|
||||
function canShareConnectedFrameChromeForScreen(screenOrName) {
|
||||
if (!usesConnectedFrameChromeForScreen(screenOrName))
|
||||
return false;
|
||||
if (!isHyprland)
|
||||
return true;
|
||||
|
||||
const screenName = _screenName(screenOrName);
|
||||
const monitor = Hyprland.monitors.values.find(m => m.name === screenName);
|
||||
const specialWorkspace = monitor?.lastIpcObject?.specialWorkspace?.name;
|
||||
const workspace = specialWorkspace ? Hyprland.workspaces.values.find(w => w.name === specialWorkspace) : monitor?.activeWorkspace;
|
||||
if (!workspace)
|
||||
return true;
|
||||
return !workspace.toplevels.values.some(t => t.lastIpcObject?.fullscreen === 2);
|
||||
}
|
||||
|
||||
// Connected mode renders the bar inside the frame surface.
|
||||
function frameHostsSurfacesForScreen(screenOrName) {
|
||||
return FrameTransitionState.effectiveConnectedFrameModeActive && frameConfiguredForScreen(screenOrName);
|
||||
|
||||
@@ -78,7 +78,10 @@ Singleton {
|
||||
function onLowerDisplayRefreshRateOnBatteryChanged() {
|
||||
root.requestSync("setting-change");
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: SessionData
|
||||
function onActiveDisplayProfileChanged() {
|
||||
root.requestSync("profile-change");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -35,14 +35,14 @@ Singleton {
|
||||
readonly property bool helperAvailable: sysupdateAvailable && backends.length > 0
|
||||
readonly property bool useCustomCommand: SettingsData.updaterUseCustomCommand && (SettingsData.updaterCustomCommand || "").trim().length > 0
|
||||
|
||||
// Dont allow partial updates on arch, if they wanna break their system they can do it outside of DMS:
|
||||
// https://wiki.archlinux.org/title/System_maintenance#Partial_upgrades_are_unsupported
|
||||
// AUR/Flatpak packages stay ignorable — holding those cannot break the repo dependency graph.
|
||||
readonly property bool systemHoldsAllowed: !["pacman", "paru", "yay"].includes(pkgManager)
|
||||
// Arch repository packages must upgrade together.
|
||||
readonly property bool systemHoldsAllowed: !["pacman", "paru", "yay", "shelly"].includes(pkgManager)
|
||||
|
||||
function canIgnorePackage(pkg) {
|
||||
if (!pkg)
|
||||
return false;
|
||||
if (pkgManager === "shelly")
|
||||
return pkg.repo === "flatpak";
|
||||
return systemHoldsAllowed || pkg.repo !== "system";
|
||||
}
|
||||
|
||||
@@ -181,12 +181,15 @@ Singleton {
|
||||
|
||||
function ignorePackage(name) {
|
||||
if (!name)
|
||||
return;
|
||||
return false;
|
||||
if (pkgManager === "shelly" && !_rawUpdates.some(p => p.name === name && canIgnorePackage(p)))
|
||||
return false;
|
||||
const list = (SettingsData.updaterIgnoredPackages || []).slice();
|
||||
if (list.indexOf(name) !== -1)
|
||||
return;
|
||||
return true;
|
||||
list.push(name);
|
||||
SettingsData.set("updaterIgnoredPackages", list);
|
||||
return true;
|
||||
}
|
||||
|
||||
function unignorePackage(name) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -1 +1 @@
|
||||
v1.7-beta
|
||||
v1.6.2
|
||||
|
||||
@@ -266,7 +266,7 @@ Item {
|
||||
"phase": phase,
|
||||
"visible": visible,
|
||||
"presented": presented,
|
||||
"layer": root.triggerUsesOverlayLayer ? "overlay" : "top",
|
||||
"layer": root.effectivePopoutLayer === WlrLayer.Overlay ? "overlay" : "top",
|
||||
"barSide": contentContainer.connectedBarSide,
|
||||
"bodyRect": bodyRect,
|
||||
"animationOffset": animationOffset,
|
||||
@@ -423,7 +423,7 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
readonly property bool frameOwnsConnectedChrome: CompositorService.usesConnectedFrameChromeForScreen(root.screen)
|
||||
readonly property bool frameOwnsConnectedChrome: effectivePopoutLayer === WlrLayer.Top && CompositorService.canShareConnectedFrameChromeForScreen(root.screen)
|
||||
readonly property bool usesConnectedSurfaceChrome: Theme.isConnectedEffect
|
||||
readonly property bool usesLocalConnectedSurfaceChrome: usesConnectedSurfaceChrome && !frameOwnsConnectedChrome
|
||||
onFrameOwnsConnectedChromeChanged: _syncPopoutChromeState()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
+325
-51
@@ -4,6 +4,7 @@ import sys
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib import request, parse
|
||||
|
||||
@@ -22,13 +23,28 @@ COMMON_POEXPORTS_DIR = COMMON_ROOT / "DankCommon" / "translations" / "poexports"
|
||||
|
||||
# Plugin checkouts under quickshell/ are scanned by extraction (terms tagged
|
||||
# plugin-<dir>); their per-language exports are written back into each
|
||||
# checkout's translations/ dir, which ships with the plugin repo.
|
||||
PLUGIN_CHECKOUT_DIRS = [REPO_ROOT / "dms-plugins", REPO_ROOT / "dms-plugins-external"]
|
||||
# checkout's translations/ dir, which ships with the plugin repo. sync
|
||||
# clones and fast-forwards them: the official monorepo plus every registry
|
||||
# plugin flagged "i18n": true, keyed by plugin id.
|
||||
OFFICIAL_PLUGINS_REPO = "https://github.com/AvengeMedia/dms-plugins.git"
|
||||
PLUGIN_REGISTRY_REPO = "https://github.com/AvengeMedia/dms-plugin-registry.git"
|
||||
OFFICIAL_PLUGINS_DIR = REPO_ROOT / "dms-plugins"
|
||||
EXTERNAL_PLUGINS_DIR = REPO_ROOT / "dms-plugins-external"
|
||||
REGISTRY_DIR = EXTERNAL_PLUGINS_DIR / ".registry"
|
||||
PLUGIN_CHECKOUT_DIRS = [OFFICIAL_PLUGINS_DIR, EXTERNAL_PLUGINS_DIR]
|
||||
|
||||
PLUGIN_PR_BRANCH = "i18n/poeditor-sync"
|
||||
PLUGIN_PR_TITLE = "i18n: sync translations from POEditor"
|
||||
|
||||
# Flip once official plugins ship their own translations/ dirs: app poexports
|
||||
# then stop carrying terms owned exclusively by plugins.
|
||||
EXCLUDE_PLUGIN_ONLY_TERMS = False
|
||||
|
||||
RATE_LIMIT_CODE = '4048'
|
||||
UPLOAD_MIN_INTERVAL = 25
|
||||
UPLOAD_RETRIES = 4
|
||||
_last_upload = 0.0
|
||||
|
||||
LANGUAGES = {
|
||||
"ja": "ja.json",
|
||||
"zh-Hans": "zh_CN.json",
|
||||
@@ -138,6 +154,45 @@ def load_greeter_entries(api_token, project_id):
|
||||
def entry_keys(entries):
|
||||
return {(e.get('context') or e['term'], e['term']) for e in entries}
|
||||
|
||||
def git(args, cwd):
|
||||
result = subprocess.run(['git', *args], cwd=cwd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
error(f"git {' '.join(args)} in {cwd} failed:\n{result.stderr.strip()}")
|
||||
|
||||
def clone_or_update(url, path):
|
||||
rel = path.relative_to(REPO_ROOT)
|
||||
if not (path / '.git').is_dir():
|
||||
info(f"Cloning {url} -> {rel}")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
git(['clone', '--quiet', url, str(path)], REPO_ROOT)
|
||||
return
|
||||
info(f"Updating {rel}")
|
||||
git(['pull', '--quiet', '--ff-only'], path)
|
||||
|
||||
def i18n_registry_plugins():
|
||||
plugins = []
|
||||
for entry_file in sorted((REGISTRY_DIR / 'plugins').glob('*.json')):
|
||||
with open(entry_file) as f:
|
||||
entry = json.load(f)
|
||||
if entry.get('i18n') is not True:
|
||||
continue
|
||||
if not entry.get('id') or not entry.get('repo'):
|
||||
error(f"Registry entry {entry_file.name} has i18n set but no id or repo")
|
||||
plugins.append((entry['id'], entry['repo']))
|
||||
return plugins
|
||||
|
||||
def update_plugin_checkouts():
|
||||
clone_or_update(OFFICIAL_PLUGINS_REPO, OFFICIAL_PLUGINS_DIR)
|
||||
clone_or_update(PLUGIN_REGISTRY_REPO, REGISTRY_DIR)
|
||||
plugins = i18n_registry_plugins()
|
||||
for plugin_id, repo in plugins:
|
||||
clone_or_update(repo, EXTERNAL_PLUGINS_DIR / plugin_id)
|
||||
approved = {plugin_id for plugin_id, _ in plugins}
|
||||
for child in EXTERNAL_PLUGINS_DIR.iterdir():
|
||||
if child.is_dir() and not child.name.startswith('.') and child.name not in approved:
|
||||
warn(f"dms-plugins-external/{child.name} is not flagged i18n in the registry but its terms still get uploaded; remove it if it was unapproved")
|
||||
success(f"Plugin checkouts current: official + {len(plugins)} registry plugins")
|
||||
|
||||
def plugin_checkouts():
|
||||
checkouts = {}
|
||||
for base in PLUGIN_CHECKOUT_DIRS:
|
||||
@@ -148,6 +203,134 @@ def plugin_checkouts():
|
||||
checkouts['plugin-' + child.name.lower()] = child
|
||||
return checkouts
|
||||
|
||||
def gh(args, cwd=None, required=True):
|
||||
result = subprocess.run(['gh', *args], cwd=cwd, capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
if required:
|
||||
error(f"gh {' '.join(args)} failed:\n{result.stderr.strip()}")
|
||||
return None
|
||||
|
||||
def git_output(args, cwd):
|
||||
result = subprocess.run(['git', *args], cwd=cwd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
return result.stdout.strip()
|
||||
|
||||
def repo_slug(checkout):
|
||||
url = git_output(['remote', 'get-url', 'origin'], checkout) or ""
|
||||
if 'github.com' not in url:
|
||||
return None
|
||||
return url.removesuffix('.git').split('github.com', 1)[1].lstrip(':/')
|
||||
|
||||
def default_branch(checkout):
|
||||
ref = git_output(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], checkout)
|
||||
if not ref:
|
||||
return 'main'
|
||||
return ref.split('/', 1)[-1]
|
||||
|
||||
def pending_translation_files(checkout):
|
||||
out = git_output(['status', '--porcelain', '--untracked-files=all', '--', 'translations'], checkout)
|
||||
if not out:
|
||||
return []
|
||||
names = (line[3:].rsplit('/', 1)[-1] for line in out.splitlines())
|
||||
return sorted(name for name in names if name.endswith('.json'))
|
||||
|
||||
def pending_plugin_prs():
|
||||
external = []
|
||||
official = []
|
||||
for checkout in sorted(plugin_checkouts().values(), key=lambda path: path.name):
|
||||
files = pending_translation_files(checkout)
|
||||
if not files:
|
||||
continue
|
||||
if checkout.parent == EXTERNAL_PLUGINS_DIR:
|
||||
external.append((checkout, files))
|
||||
else:
|
||||
official.append((checkout, files))
|
||||
return external, official
|
||||
|
||||
def plugin_pr_body(files):
|
||||
return (
|
||||
"Translations synced from the DMS POEditor project, where this plugin's strings "
|
||||
"are tagged and translated alongside the shell.\n\n"
|
||||
f"Updated: {', '.join(sorted(files))}\n\n"
|
||||
"Strings this repo already shipped were uploaded to POEditor before the export, "
|
||||
"so existing translations are preserved rather than overwritten.\n"
|
||||
)
|
||||
|
||||
def open_plugin_pr(checkout, files):
|
||||
slug = repo_slug(checkout)
|
||||
if not slug:
|
||||
warn(f"{checkout.name}: origin is not a github remote, skipping PR")
|
||||
return
|
||||
|
||||
login = gh(['api', 'user', '-q', '.login'])
|
||||
fork = f"{login}/{slug.split('/', 1)[1]}"
|
||||
if gh(['repo', 'view', fork, '--json', 'name'], required=False) is None:
|
||||
info(f"Forking {slug} -> {fork}")
|
||||
gh(['repo', 'fork', slug, '--clone=false', '--remote=false'])
|
||||
|
||||
base = default_branch(checkout)
|
||||
git(['checkout', '--quiet', '-B', PLUGIN_PR_BRANCH], checkout)
|
||||
git(['add', 'translations'], checkout)
|
||||
|
||||
committed = subprocess.run(
|
||||
['git', 'commit', '--quiet', '-m', PLUGIN_PR_TITLE],
|
||||
cwd=checkout, capture_output=True, text=True
|
||||
)
|
||||
if committed.returncode != 0:
|
||||
git(['checkout', '--quiet', base], checkout)
|
||||
warn(f"{checkout.name}: nothing to commit, skipping PR")
|
||||
return
|
||||
|
||||
git(['push', '--quiet', '--force', f'[email protected]:{fork}.git', PLUGIN_PR_BRANCH], checkout)
|
||||
git(['checkout', '--quiet', base], checkout)
|
||||
|
||||
existing = gh([
|
||||
'pr', 'list', '--repo', slug, '--head', f'{login}:{PLUGIN_PR_BRANCH}',
|
||||
'--state', 'open', '--json', 'url', '-q', '.[0].url'
|
||||
], required=False)
|
||||
if existing:
|
||||
success(f"{checkout.name}: updated {existing}")
|
||||
return
|
||||
|
||||
url = gh([
|
||||
'pr', 'create', '--repo', slug, '--base', base, '--head', f'{login}:{PLUGIN_PR_BRANCH}',
|
||||
'--title', PLUGIN_PR_TITLE, '--body', plugin_pr_body(files)
|
||||
])
|
||||
success(f"{checkout.name}: opened {url}")
|
||||
|
||||
def checkout_translations(checkout, filename):
|
||||
result = subprocess.run(
|
||||
['git', 'show', f'HEAD:translations/{filename}'],
|
||||
cwd=checkout, capture_output=True, text=True
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(result.stdout)
|
||||
except json.JSONDecodeError:
|
||||
warn(f"{checkout.name}/translations/{filename} is not valid JSON in git HEAD")
|
||||
return {}
|
||||
|
||||
def keep_existing_translations(existing, incoming):
|
||||
return {
|
||||
context: {term: value or existing.get(context, {}).get(term, "") for term, value in bucket.items()}
|
||||
for context, bucket in incoming.items()
|
||||
}
|
||||
|
||||
def missing_from_poeditor(existing, incoming):
|
||||
gaps = {}
|
||||
for context, bucket in incoming.items():
|
||||
for term, value in bucket.items():
|
||||
if value:
|
||||
continue
|
||||
local = existing.get(context, {}).get(term, "")
|
||||
if not local:
|
||||
continue
|
||||
gaps[(context, term)] = local
|
||||
return gaps
|
||||
|
||||
def plugin_term_owners(entries):
|
||||
owners = {}
|
||||
excluded = set()
|
||||
@@ -194,6 +377,63 @@ def split_export(data, common_keys, greeter_keys, plugin_owners, plugin_excluded
|
||||
app_part.setdefault(context, {})[term] = value
|
||||
return app_part, common_part, plugin_parts
|
||||
|
||||
def _throttle_upload():
|
||||
global _last_upload
|
||||
gap = UPLOAD_MIN_INTERVAL - (time.monotonic() - _last_upload)
|
||||
if gap <= 0:
|
||||
return
|
||||
info(f"Waiting {gap:.0f}s for the POEditor upload rate limit...")
|
||||
time.sleep(gap)
|
||||
|
||||
def poeditor_upload(fields, payload, filename, required=True):
|
||||
global _last_upload
|
||||
boundary = '----WebKitFormBoundary7MA4YWxkTrZu0gW'
|
||||
head = ''.join(
|
||||
f'--{boundary}\r\n'
|
||||
f'Content-Disposition: form-data; name="{name}"\r\n\r\n'
|
||||
f'{value}\r\n'
|
||||
for name, value in fields.items()
|
||||
) + (
|
||||
f'--{boundary}\r\n'
|
||||
f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'
|
||||
f'Content-Type: application/json\r\n\r\n'
|
||||
)
|
||||
body = head.encode() + json.dumps(payload, ensure_ascii=False).encode() + f'\r\n--{boundary}--\r\n'.encode()
|
||||
|
||||
for attempt in range(UPLOAD_RETRIES):
|
||||
_throttle_upload()
|
||||
req = request.Request(
|
||||
'https://api.poeditor.com/v2/projects/upload',
|
||||
data=body,
|
||||
headers={'Content-Type': f'multipart/form-data; boundary={boundary}'}
|
||||
)
|
||||
|
||||
try:
|
||||
with request.urlopen(req) as response:
|
||||
result = json.loads(response.read().decode())
|
||||
except Exception as e:
|
||||
_last_upload = time.monotonic()
|
||||
if required:
|
||||
error(f"Upload failed: {e}")
|
||||
warn(f"Upload failed: {e}")
|
||||
return None
|
||||
|
||||
_last_upload = time.monotonic()
|
||||
if result.get('response', {}).get('status') == 'success':
|
||||
return result.get('result', {})
|
||||
|
||||
if result.get('response', {}).get('code') != RATE_LIMIT_CODE:
|
||||
break
|
||||
|
||||
if attempt + 1 < UPLOAD_RETRIES:
|
||||
_last_upload += UPLOAD_MIN_INTERVAL * (attempt + 1)
|
||||
warn(f"POEditor rate limited the upload, retrying ({attempt + 2}/{UPLOAD_RETRIES})")
|
||||
|
||||
if required:
|
||||
error(f"POEditor upload failed: {result}")
|
||||
warn(f"POEditor upload failed: {result}")
|
||||
return None
|
||||
|
||||
def upload_source_strings(api_token, project_id, entries, prune=False):
|
||||
if not entries:
|
||||
warn("No terms to upload")
|
||||
@@ -201,56 +441,48 @@ def upload_source_strings(api_token, project_id, entries, prune=False):
|
||||
|
||||
info("Uploading source strings to POEditor..." + (" (pruning terms not present locally)" if prune else ""))
|
||||
|
||||
upload_bytes = json.dumps(entries, ensure_ascii=False).encode()
|
||||
boundary = '----WebKitFormBoundary7MA4YWxkTrZu0gW'
|
||||
sync_part = (
|
||||
f'--{boundary}\r\n'
|
||||
f'Content-Disposition: form-data; name="sync_terms"\r\n\r\n'
|
||||
f'1\r\n'
|
||||
) if prune else ''
|
||||
body = (
|
||||
f'--{boundary}\r\n'
|
||||
f'Content-Disposition: form-data; name="api_token"\r\n\r\n'
|
||||
f'{api_token}\r\n'
|
||||
f'--{boundary}\r\n'
|
||||
f'Content-Disposition: form-data; name="id"\r\n\r\n'
|
||||
f'{project_id}\r\n'
|
||||
f'--{boundary}\r\n'
|
||||
f'Content-Disposition: form-data; name="updating"\r\n\r\n'
|
||||
f'terms\r\n'
|
||||
f'{sync_part}'
|
||||
f'--{boundary}\r\n'
|
||||
f'Content-Disposition: form-data; name="file"; filename="en.json"\r\n'
|
||||
f'Content-Type: application/json\r\n\r\n'
|
||||
).encode() + upload_bytes + f'\r\n--{boundary}--\r\n'.encode()
|
||||
fields = {'api_token': api_token, 'id': project_id, 'updating': 'terms'}
|
||||
if prune:
|
||||
fields['sync_terms'] = '1'
|
||||
|
||||
req = request.Request(
|
||||
'https://api.poeditor.com/v2/projects/upload',
|
||||
data=body,
|
||||
headers={'Content-Type': f'multipart/form-data; boundary={boundary}'}
|
||||
)
|
||||
|
||||
try:
|
||||
with request.urlopen(req) as response:
|
||||
result = json.loads(response.read().decode())
|
||||
except Exception as e:
|
||||
error(f"Upload failed: {e}")
|
||||
|
||||
if result.get('response', {}).get('status') != 'success':
|
||||
error(f"POEditor upload failed: {result}")
|
||||
|
||||
terms = result.get('result', {}).get('terms', {})
|
||||
terms = poeditor_upload(fields, entries, 'en.json').get('terms', {})
|
||||
added = terms.get('added', 0)
|
||||
updated = terms.get('updated', 0)
|
||||
deleted = terms.get('deleted', 0)
|
||||
|
||||
if added or updated or deleted:
|
||||
success(f"POEditor updated: {added} added, {updated} updated, {deleted} deleted")
|
||||
return True
|
||||
else:
|
||||
if not (added or updated or deleted):
|
||||
info("No changes uploaded to POEditor")
|
||||
return False
|
||||
|
||||
success(f"POEditor updated: {added} added, {updated} updated, {deleted} deleted")
|
||||
return True
|
||||
|
||||
def seed_plugin_translations(api_token, project_id, plugin_seed):
|
||||
seeded = {}
|
||||
for po_lang, values in sorted(plugin_seed.items()):
|
||||
entries = [
|
||||
{'term': term, 'context': context, 'definition': value}
|
||||
for (context, term), value in sorted(values.items())
|
||||
]
|
||||
info(f"Seeding {len(entries)} plugin-authored translations into POEditor ({po_lang})...")
|
||||
result = poeditor_upload({
|
||||
'api_token': api_token,
|
||||
'id': project_id,
|
||||
'updating': 'translations',
|
||||
'language': po_lang,
|
||||
'overwrite': '0',
|
||||
'fuzzy_trigger': '0'
|
||||
}, entries, LANGUAGES[po_lang], required=False)
|
||||
if not result:
|
||||
continue
|
||||
translations = result.get('translations', {})
|
||||
applied = translations.get('added', 0) + translations.get('updated', 0)
|
||||
if not applied:
|
||||
warn(f"POEditor accepted the {po_lang} seed but applied nothing: {translations}")
|
||||
continue
|
||||
seeded[po_lang] = applied
|
||||
return seeded
|
||||
|
||||
def write_if_changed(repo_file, new_data):
|
||||
if not json_changed(repo_file, new_data):
|
||||
return False
|
||||
@@ -268,6 +500,7 @@ def download_translations(api_token, project_id, common_keys, greeter_keys, plug
|
||||
any_changed = False
|
||||
common_changed = []
|
||||
plugin_changed = {}
|
||||
plugin_seed = {}
|
||||
|
||||
for po_lang, filename in LANGUAGES.items():
|
||||
repo_file = POEXPORTS_DIR / filename
|
||||
@@ -316,11 +549,15 @@ def download_translations(api_token, project_id, common_keys, greeter_keys, plug
|
||||
continue
|
||||
target_dir = checkout / "translations"
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
if write_if_changed(target_dir / filename, part):
|
||||
existing = checkout_translations(checkout, filename)
|
||||
gaps = missing_from_poeditor(existing, part)
|
||||
if gaps:
|
||||
plugin_seed.setdefault(po_lang, {}).update(gaps)
|
||||
if write_if_changed(target_dir / filename, keep_existing_translations(existing, part)):
|
||||
success(f"Updated {checkout.name} {filename}")
|
||||
plugin_changed.setdefault(checkout.name, []).append(filename)
|
||||
|
||||
return any_changed, common_changed, plugin_changed
|
||||
return any_changed, common_changed, plugin_changed, plugin_seed
|
||||
|
||||
def check_sync_status():
|
||||
api_token = get_env_or_error('POEDITOR_API_TOKEN')
|
||||
@@ -400,7 +637,7 @@ def save_sync_state():
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
error("Usage: i18nsync.py [check|sync [--prune]|test|local]")
|
||||
error("Usage: i18nsync.py [check|sync [--prune] [--pr] [--seed]|pr|test|local]")
|
||||
|
||||
command = sys.argv[1]
|
||||
|
||||
@@ -438,9 +675,11 @@ def main():
|
||||
api_token = get_env_or_error('POEDITOR_API_TOKEN')
|
||||
project_id = get_env_or_error('POEDITOR_PROJECT_ID')
|
||||
prune = "--prune" in sys.argv[2:]
|
||||
open_prs = "--pr" in sys.argv[2:]
|
||||
seed = "--seed" in sys.argv[2:]
|
||||
if prune:
|
||||
warn("--prune deletes every POEditor term missing from the local en.json, including its translations.")
|
||||
warn("Terms from dms-plugins/ and dms-plugins-external/ are machine-dependent: make sure all official and approved external plugins are present before pruning.")
|
||||
warn("Plugin checkouts are refreshed from the registry first, so pruning keeps terms of official and i18n-approved plugins; a plugin removed from the registry loses its terms.")
|
||||
warn("dank-qml-common terms are included from the submodule, so pruning keeps them as long as the submodule is current.")
|
||||
warn("dms-greeter terms are fetched from POEditor and re-included, so pruning keeps them.")
|
||||
|
||||
@@ -449,6 +688,7 @@ def main():
|
||||
greeter_entries = load_greeter_entries(api_token, project_id)
|
||||
greeter_keys = entry_keys(greeter_entries)
|
||||
|
||||
update_plugin_checkouts()
|
||||
extract_strings()
|
||||
|
||||
current_en = normalize_json(EN_JSON)
|
||||
@@ -482,7 +722,7 @@ def main():
|
||||
info("No changes in source strings")
|
||||
|
||||
plugin_owners, plugin_excluded = plugin_term_owners(current_en)
|
||||
translations_changed, common_files_changed, plugin_files_changed = download_translations(api_token, project_id, common_keys, greeter_keys, plugin_owners, plugin_excluded)
|
||||
translations_changed, common_files_changed, plugin_files_changed, plugin_seed = download_translations(api_token, project_id, common_keys, greeter_keys, plugin_owners, plugin_excluded)
|
||||
|
||||
if strings_changed or translations_changed:
|
||||
subprocess.run(['git', 'add', 'translations/'], cwd=REPO_ROOT)
|
||||
@@ -498,8 +738,42 @@ def main():
|
||||
|
||||
for checkout_name, files in sorted(plugin_files_changed.items()):
|
||||
info(f"{checkout_name} translations updated: {', '.join(files)}")
|
||||
if plugin_files_changed:
|
||||
info("Commit those in each plugin checkout - they ship with the plugin repo, not with DMS.")
|
||||
|
||||
pending_prs, official_pending = pending_plugin_prs()
|
||||
|
||||
for checkout, files in official_pending:
|
||||
info(f"{checkout.name}: {len(files)} translation files to commit in dms-plugins")
|
||||
|
||||
if open_prs:
|
||||
for checkout, files in pending_prs:
|
||||
open_plugin_pr(checkout, files)
|
||||
elif pending_prs:
|
||||
for checkout, files in pending_prs:
|
||||
info(f"{checkout.name} has {len(files)} uncommitted translation files")
|
||||
info("Re-run with --pr to push them to your fork and open the PRs upstream.")
|
||||
|
||||
pending_seed = sum(len(values) for values in plugin_seed.values())
|
||||
if pending_seed and not seed:
|
||||
info(f"{pending_seed} plugin-authored translations are missing from POEditor across {len(plugin_seed)} languages.")
|
||||
info("Re-run with --seed to adopt them (one throttled upload per language).")
|
||||
|
||||
if pending_seed and seed:
|
||||
seeded = seed_plugin_translations(api_token, project_id, plugin_seed)
|
||||
if seeded:
|
||||
info("Plugin-authored translations adopted into POEditor: " + ", ".join(f"{lang} +{count}" for lang, count in sorted(seeded.items())))
|
||||
|
||||
elif command == "pr":
|
||||
pending_prs, official_pending = pending_plugin_prs()
|
||||
|
||||
for checkout, files in official_pending:
|
||||
info(f"{checkout.name}: {len(files)} translation files to commit in dms-plugins")
|
||||
|
||||
if not pending_prs:
|
||||
info("No plugin checkout has uncommitted translations")
|
||||
sys.exit(0)
|
||||
|
||||
for checkout, files in pending_prs:
|
||||
open_plugin_pr(checkout, files)
|
||||
|
||||
elif command == "local":
|
||||
info("Updating en.json locally (no POEditor sync)")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ from pathlib import Path
|
||||
|
||||
ROOT_DIR = Path(__file__).parent.parent
|
||||
|
||||
EXTERNAL_PLUGIN_PREFIX = "dms-plugins-external/"
|
||||
|
||||
# Intentional pairs that survive normalization on purpose.
|
||||
ALLOWED = [
|
||||
{"PIN", "Pin"}, # WPS PIN acronym vs the verb "pin"
|
||||
@@ -33,8 +35,19 @@ def normalize(term):
|
||||
return t + "..." if ellipsis else t
|
||||
|
||||
|
||||
def owned_occurrences(data):
|
||||
return [occ for occ in data["occurrences"]
|
||||
if not occ["file"].startswith(EXTERNAL_PLUGIN_PREFIX)]
|
||||
|
||||
|
||||
def main():
|
||||
translations = extract_qstr_strings(ROOT_DIR)
|
||||
extracted = extract_qstr_strings(ROOT_DIR)
|
||||
translations = {}
|
||||
for term, data in extracted.items():
|
||||
occurrences = owned_occurrences(data)
|
||||
if occurrences:
|
||||
translations[term] = occurrences
|
||||
|
||||
groups = defaultdict(set)
|
||||
for term in translations:
|
||||
groups[normalize(term)].add(term)
|
||||
@@ -59,7 +72,7 @@ def main():
|
||||
for variants in sorted(failures):
|
||||
print(f" {variants}", file=sys.stderr)
|
||||
for v in variants:
|
||||
for occ in translations[v]["occurrences"][:3]:
|
||||
for occ in translations[v][:3]:
|
||||
print(f" {v!r}: {occ['file']}:{occ['line']}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
+5203
-1172
File diff suppressed because it is too large
Load Diff
@@ -203,12 +203,14 @@ def main():
|
||||
en_json_path = translations_dir / 'en.json'
|
||||
with open(en_json_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(poeditor_data, f, indent=2, ensure_ascii=False)
|
||||
f.write('\n')
|
||||
print(f"Created source language file: {en_json_path}")
|
||||
|
||||
template_data = create_template_json(translations)
|
||||
template_json_path = translations_dir / 'template.json'
|
||||
with open(template_json_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(template_data, f, indent=2, ensure_ascii=False)
|
||||
f.write('\n')
|
||||
print(f"Created template file: {template_json_path}")
|
||||
|
||||
print("\nSummary:")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user