mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-16 11:49:06 +02:00
[client] Add light mode with system, light, and dark theme options (#7344)
* desktop UI light mode * Theme review fixes plus macOS window outline fix * Windows runtime chrome re-theming plus apply serialization * Windows chrome threading and theme event ordering fixes * Darken toggle and setting sidebar text * resolve theme appearance, apply on UI thread * read theme once per window * Re-assert Windows dark opt-in after SetTheme * split app-wide GTK theming from per-window chrome * Update Wails dependency and checksums * KDE tray icon panel fix * Five review fixes: theme ordering, cgo dedup, KDE panel resolution * Path guard hardening, toggle contrast, windows comment * non-vacuous escape tests * Default view edits * Polish settings nav, controls, borders, and disc * Profiles settings boarder, modals, and buttons * Additional edits based on feedback * Switch colors away from slight blue hue * Update missing lang * Fix vertical tab active view
This commit is contained in:
@@ -8,49 +8,346 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadKdeComplementaryBackground(t *testing.T) {
|
||||
// Mirrors the KDE test VM's kdeglobals: Window light, Complementary dark.
|
||||
// The tray sits on the panel, which Plasma paints from Complementary, so
|
||||
// the panel is dark even though the global color-scheme is Light.
|
||||
content := `[Colors:Window]
|
||||
BackgroundNormal=239,240,241
|
||||
|
||||
[Colors:Complementary]
|
||||
// Values measured on Plasma 6.7.4 (Fedora 44) under each Breeze scheme. The
|
||||
// Complementary group is dark under both, which is why it can't decide the
|
||||
// panel; Window tracks it.
|
||||
const (
|
||||
kdeglobalsLight = `[Colors:Complementary]
|
||||
BackgroundAlternate=27,30,32
|
||||
BackgroundNormal=42,46,50
|
||||
|
||||
[Colors:Window]
|
||||
BackgroundNormal=239,240,241
|
||||
|
||||
[General]
|
||||
ColorSchemeHash=0be804dba87e3512aeb4be3d78ed981f59f0f2f4
|
||||
`
|
||||
path := filepath.Join(t.TempDir(), "kdeglobals")
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
kdeglobalsDark = `[Colors:Complementary]
|
||||
BackgroundNormal=32,35,38
|
||||
|
||||
[Colors:Window]
|
||||
BackgroundNormal=32,35,38
|
||||
|
||||
[General]
|
||||
ColorScheme=BreezeDark
|
||||
`
|
||||
)
|
||||
|
||||
// plasmaStyle installs a Plasma style of that name under XDG_DATA_HOME. An
|
||||
// empty colours body installs the style without a colours file, which is what
|
||||
// the stock "default" style looks like.
|
||||
func plasmaStyle(t *testing.T, name, colours string) string {
|
||||
t.Helper()
|
||||
data := t.TempDir()
|
||||
dir := filepath.Join(data, "plasma", "desktoptheme", name)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if colours != "" {
|
||||
if err := os.WriteFile(filepath.Join(dir, "colors"), []byte(colours), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
t.Setenv("XDG_DATA_HOME", data)
|
||||
// Keep the system dirs out of it so an installed breeze-dark cannot answer.
|
||||
t.Setenv("XDG_DATA_DIRS", filepath.Join(data, "empty"))
|
||||
return data
|
||||
}
|
||||
|
||||
rgb, ok := readKdeComplementaryBackground(path)
|
||||
if !ok {
|
||||
t.Fatal("expected to find Complementary BackgroundNormal")
|
||||
// kdeConfig points the KDE readers at a temp dir holding the given files, and
|
||||
// makes isKDE report KDE. An empty body skips the file.
|
||||
func kdeConfig(t *testing.T, kdeglobals, plasmarc string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
for name, body := range map[string]string{kdeglobalsFile: kdeglobals, plasmarcFile: plasmarc} {
|
||||
if body == "" {
|
||||
continue
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if rgb != [3]uint8{42, 46, 50} {
|
||||
t.Fatalf("rgb = %v, want [42 46 50]", rgb)
|
||||
}
|
||||
if !isDarkRGB(rgb[0], rgb[1], rgb[2]) {
|
||||
t.Fatal("panel colour 42,46,50 should be dark")
|
||||
}
|
||||
// The Window background (what color-scheme reflects) is light — the bug
|
||||
// this fix addresses is picking the icon from that instead of the panel.
|
||||
if isDarkRGB(239, 240, 241) {
|
||||
t.Fatal("window colour 239,240,241 should be light")
|
||||
t.Setenv("XDG_CONFIG_HOME", dir)
|
||||
t.Setenv("XDG_CURRENT_DESKTOP", "KDE")
|
||||
// Point the Plasma style lookup at empty dirs so a style installed on the
|
||||
// host cannot answer for a test that did not install one itself. Tests that
|
||||
// want a style call plasmaStyle, which overrides these.
|
||||
t.Setenv("XDG_DATA_HOME", filepath.Join(dir, "empty-data-home"))
|
||||
t.Setenv("XDG_DATA_DIRS", filepath.Join(dir, "empty-data-dirs"))
|
||||
}
|
||||
|
||||
// The reported bug: a Light global scheme left the panel reported dark, so the
|
||||
// tray kept the white silhouette on a light panel.
|
||||
func TestKdePanelIsDarkFollowsColourScheme(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
kdeglobals string
|
||||
wantDark bool
|
||||
}{
|
||||
{"light scheme", kdeglobalsLight, false},
|
||||
{"dark scheme", kdeglobalsDark, true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
kdeConfig(t, tc.kdeglobals, "")
|
||||
dark, ok := kdePanelIsDark()
|
||||
if !ok {
|
||||
t.Fatal("expected a conclusive answer from kdeglobals")
|
||||
}
|
||||
if dark != tc.wantDark {
|
||||
t.Fatalf("dark = %v, want %v", dark, tc.wantDark)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadKdeComplementaryBackgroundMissingGroup(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "kdeglobals")
|
||||
if err := os.WriteFile(path, []byte("[Colors:Window]\nBackgroundNormal=1,2,3\n"), 0o600); err != nil {
|
||||
// A pinned Plasma style paints the panel regardless of the colour scheme, so it
|
||||
// has to outrank it in both directions. The styles are installed into the test's
|
||||
// own XDG_DATA_HOME: reading whatever the host happens to ship would make the
|
||||
// result depend on the machine.
|
||||
func TestKdePanelIsDarkPinnedStyleOutranksScheme(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
kdeglobals string
|
||||
style string
|
||||
styleColours string
|
||||
wantDark bool
|
||||
}{
|
||||
{"dark style, light scheme", kdeglobalsLight, "breeze-dark", kdeglobalsDark, true},
|
||||
{"light style, dark scheme", kdeglobalsDark, "breeze-light", kdeglobalsLight, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
kdeConfig(t, tc.kdeglobals, "[Theme]\nname="+tc.style+"\n")
|
||||
plasmaStyle(t, tc.style, tc.styleColours)
|
||||
dark, ok := kdePanelIsDark()
|
||||
if !ok {
|
||||
t.Fatal("expected a conclusive answer from the pinned style")
|
||||
}
|
||||
if dark != tc.wantDark {
|
||||
t.Fatalf("dark = %v, want %v", dark, tc.wantDark)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// "default" fixes nothing, so the colour scheme still decides.
|
||||
func TestKdePanelIsDarkDefaultStyleDefersToScheme(t *testing.T) {
|
||||
kdeConfig(t, kdeglobalsLight, "[Theme]\nname=default\n")
|
||||
dark, ok := kdePanelIsDark()
|
||||
if !ok {
|
||||
t.Fatal("expected the colour scheme to answer")
|
||||
}
|
||||
if dark {
|
||||
t.Fatal("default style on a Light scheme is a light panel")
|
||||
}
|
||||
}
|
||||
|
||||
// No colours and no style: stay inconclusive so readDarkMode uses the portal
|
||||
// rather than guessing.
|
||||
func TestKdePanelIsDarkInconclusive(t *testing.T) {
|
||||
t.Run("no window group", func(t *testing.T) {
|
||||
kdeConfig(t, "[Colors:Complementary]\nBackgroundNormal=42,46,50\n", "")
|
||||
if _, ok := kdePanelIsDark(); ok {
|
||||
t.Fatal("expected not-ok without a Window group")
|
||||
}
|
||||
})
|
||||
t.Run("no kde files", func(t *testing.T) {
|
||||
kdeConfig(t, "", "")
|
||||
if _, ok := kdePanelIsDark(); ok {
|
||||
t.Fatal("expected not-ok with no kdeglobals at all")
|
||||
}
|
||||
})
|
||||
t.Run("not kde", func(t *testing.T) {
|
||||
kdeConfig(t, kdeglobalsDark, "")
|
||||
t.Setenv("XDG_CURRENT_DESKTOP", "ubuntu:GNOME")
|
||||
if _, ok := kdePanelIsDark(); ok {
|
||||
t.Fatal("expected not-ok off KDE")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The panel follows the pinned style's own colours, not its name. Reproduces
|
||||
// the measured case: a neutrally named style shipping dark colours while
|
||||
// kdeglobals reports light.
|
||||
func TestPlasmaStyleIsDarkUsesStyleColours(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
style string
|
||||
colours string
|
||||
wantDark bool
|
||||
}{
|
||||
{"neutral name, dark colours", "nbtestneutral", kdeglobalsDark, true},
|
||||
{"neutral name, light colours", "nbtestneutral", kdeglobalsLight, false},
|
||||
{"name says dark, colours are light", "midnight-dark", kdeglobalsLight, false},
|
||||
{"name says light, colours are dark", "daylight", kdeglobalsDark, true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
kdeConfig(t, kdeglobalsLight, "[Theme]\nname="+tc.style+"\n")
|
||||
plasmaStyle(t, tc.style, tc.colours)
|
||||
dark, ok := plasmaStyleIsDark()
|
||||
if !ok {
|
||||
t.Fatal("a style shipping colours should be conclusive")
|
||||
}
|
||||
if dark != tc.wantDark {
|
||||
t.Fatalf("dark = %v, want %v", dark, tc.wantDark)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// End to end through kdePanelIsDark: the style's colours must beat kdeglobals.
|
||||
func TestKdePanelIsDarkStyleColoursBeatColourScheme(t *testing.T) {
|
||||
kdeConfig(t, kdeglobalsLight, "[Theme]\nname=nbtestneutral\n")
|
||||
plasmaStyle(t, "nbtestneutral", kdeglobalsDark)
|
||||
dark, ok := kdePanelIsDark()
|
||||
if !ok {
|
||||
t.Fatal("expected a conclusive answer")
|
||||
}
|
||||
if !dark {
|
||||
t.Fatal("a dark-coloured style on a Light scheme is a dark panel")
|
||||
}
|
||||
}
|
||||
|
||||
// A style with no colours file is the "default" case: it follows the scheme.
|
||||
func TestPlasmaStyleIsDarkNoColoursFile(t *testing.T) {
|
||||
kdeConfig(t, kdeglobalsLight, "[Theme]\nname=default\n")
|
||||
plasmaStyle(t, "default", "")
|
||||
if _, ok := plasmaStyleIsDark(); ok {
|
||||
t.Fatal("a style without colours must defer to the colour scheme")
|
||||
}
|
||||
// and the whole resolution then lands on the light scheme
|
||||
dark, ok := kdePanelIsDark()
|
||||
if !ok || dark {
|
||||
t.Fatalf("kdePanelIsDark() = (%v, %v), want (false, true)", dark, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlasmaStyleIsDarkInconclusive(t *testing.T) {
|
||||
t.Run("no plasmarc", func(t *testing.T) {
|
||||
kdeConfig(t, kdeglobalsLight, "")
|
||||
plasmaStyle(t, "unused", kdeglobalsDark)
|
||||
if _, ok := plasmaStyleIsDark(); ok {
|
||||
t.Fatal("expected not-ok with no plasmarc")
|
||||
}
|
||||
})
|
||||
t.Run("empty style name", func(t *testing.T) {
|
||||
kdeConfig(t, kdeglobalsLight, "[Theme]\nname=\n")
|
||||
if _, ok := plasmaStyleIsDark(); ok {
|
||||
t.Fatal("expected not-ok for an empty style name")
|
||||
}
|
||||
})
|
||||
t.Run("style not installed", func(t *testing.T) {
|
||||
kdeConfig(t, kdeglobalsLight, "[Theme]\nname=absent\n")
|
||||
plasmaStyle(t, "somethingelse", kdeglobalsDark)
|
||||
if _, ok := plasmaStyleIsDark(); ok {
|
||||
t.Fatal("expected not-ok when the style is not installed")
|
||||
}
|
||||
})
|
||||
// ... but a leading dot in an ordinary name is fine.
|
||||
for _, good := range []string{".hidden", "..."} {
|
||||
t.Run("accepts "+good, func(t *testing.T) {
|
||||
kdeConfig(t, kdeglobalsLight, "[Theme]\nname="+good+"\n")
|
||||
plasmaStyle(t, good, kdeglobalsDark)
|
||||
dark, ok := plasmaStyleIsDark()
|
||||
if !ok || !dark {
|
||||
t.Fatalf("plasmaStyleIsDark() = (%v, %v) for %q, want (true, true)", dark, ok, good)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A traversing name must be rejected outright, not merely fail to find a file.
|
||||
// Each case plants colours at exactly the path the unguarded lookup would read,
|
||||
// so removing the name check makes plasmaStyleIsDark answer from the planted
|
||||
// file and these fail. Without the planted file the test would pass either way.
|
||||
func TestPlasmaStyleIsDarkRejectsPlantedEscape(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
// where filepath.Join(data, "plasma", "desktoptheme", name) lands
|
||||
escaped []string
|
||||
}{
|
||||
{"..", []string{"plasma"}},
|
||||
{".", []string{"plasma", "desktoptheme"}},
|
||||
{"/", []string{"plasma", "desktoptheme"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
kdeConfig(t, kdeglobalsLight, "[Theme]\nname="+tc.name+"\n")
|
||||
data := plasmaStyle(t, "unused", kdeglobalsLight)
|
||||
|
||||
target := filepath.Join(append([]string{data}, tc.escaped...)...)
|
||||
// Guard the fixture itself: if Join ever stops landing here the
|
||||
// test would go quietly vacuous again.
|
||||
want := filepath.Clean(filepath.Join(data, "plasma", "desktoptheme", tc.name))
|
||||
if target != want {
|
||||
t.Fatalf("fixture targets %q but the lookup resolves %q", target, want)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(target, "colors"), []byte(kdeglobalsDark), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if dark, ok := plasmaStyleIsDark(); ok {
|
||||
t.Fatalf("plasmaStyleIsDark() = (%v, true) for %q: the name must be rejected, "+
|
||||
"not resolved against %s", dark, tc.name, target)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The rejection condition on its own, so every case is checked whether or not a
|
||||
// file happens to exist at the path it would resolve to.
|
||||
func TestIsBareStyleName(t *testing.T) {
|
||||
for _, bad := range []string{"", ".", "..", "/", "//", "/etc", "../../../../etc", "a/b", "a/", "./x", "../x"} {
|
||||
if isBareStyleName(bad) {
|
||||
t.Errorf("isBareStyleName(%q) = true, want false", bad)
|
||||
}
|
||||
}
|
||||
for _, good := range []string{"breeze-dark", "default", ".hidden", "...", "Breeze Dark", "a.b"} {
|
||||
if !isBareStyleName(good) {
|
||||
t.Errorf("isBareStyleName(%q) = false, want true", good)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A local style of the same name shadows the system one, as in Plasma.
|
||||
func TestPlasmaStyleDirsPreferUserData(t *testing.T) {
|
||||
t.Setenv("XDG_DATA_HOME", "/home/someone/.local/share")
|
||||
t.Setenv("XDG_DATA_DIRS", "/usr/local/share:/usr/share")
|
||||
got := plasmaStyleDirs("breeze-dark")
|
||||
want := []string{
|
||||
"/home/someone/.local/share/plasma/desktoptheme/breeze-dark",
|
||||
"/usr/local/share/plasma/desktoptheme/breeze-dark",
|
||||
"/usr/share/plasma/desktoptheme/breeze-dark",
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("plasmaStyleDirs() = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("plasmaStyleDirs()[%d] = %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadIniValue(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), kdeglobalsFile)
|
||||
if err := os.WriteFile(path, []byte(kdeglobalsLight), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := readKdeComplementaryBackground(path); ok {
|
||||
t.Fatal("expected not-ok when Complementary group is absent")
|
||||
// The same key exists in two groups, so a group-blind reader would return
|
||||
// whichever came first.
|
||||
if v, ok := readIniValue(path, "[Colors:Window]", "BackgroundNormal"); !ok || v != "239,240,241" {
|
||||
t.Fatalf("Window BackgroundNormal = %q ok=%v, want \"239,240,241\" true", v, ok)
|
||||
}
|
||||
if v, ok := readIniValue(path, "[Colors:Complementary]", "BackgroundNormal"); !ok || v != "42,46,50" {
|
||||
t.Fatalf("Complementary BackgroundNormal = %q ok=%v, want \"42,46,50\" true", v, ok)
|
||||
}
|
||||
if _, ok := readIniValue(path, "[Colors:Window]", "ColorSchemeHash"); ok {
|
||||
t.Fatal("a key from another group should not be found")
|
||||
}
|
||||
if _, ok := readIniValue(filepath.Join(t.TempDir(), "absent"), "[Theme]", "name"); ok {
|
||||
t.Fatal("a missing file should not be found")
|
||||
}
|
||||
if _, ok := readIniValue("", "[Theme]", "name"); ok {
|
||||
t.Fatal("an empty path should not be found")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,8 +374,8 @@ func TestIsDarkRGB(t *testing.T) {
|
||||
if isDarkRGB(255, 255, 255) {
|
||||
t.Fatal("white is light")
|
||||
}
|
||||
if !isDarkRGB(42, 46, 50) {
|
||||
t.Fatal("Breeze panel grey is dark")
|
||||
if !isDarkRGB(32, 35, 38) {
|
||||
t.Fatal("BreezeDark window grey is dark")
|
||||
}
|
||||
if isDarkRGB(239, 240, 241) {
|
||||
t.Fatal("Breeze window grey is light")
|
||||
|
||||
Reference in New Issue
Block a user