[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:
Brandon Hopkins
2026-09-11 08:25:10 -07:00
committed by GitHub
parent f422c41654
commit ec0c36b0e7
68 changed files with 1853 additions and 259 deletions
+123 -24
View File
@@ -17,6 +17,13 @@ import (
log "github.com/sirupsen/logrus"
)
// The two KDE files that decide the panel's appearance. Both live in the user
// config dir, so one directory watch covers them (see watchKdeConfig).
const (
kdeglobalsFile = "kdeglobals"
plasmarcFile = "plasmarc"
)
// startTrayTheme seeds t.panelDark and repaints on colour-scheme flips. Must
// run before the first applyIcon so the initial paint uses the right silhouette.
func (t *Tray) startTrayTheme() {
@@ -35,50 +42,142 @@ func isKDE() bool {
return false
}
// kdeglobalsPath returns the user kdeglobals path. We read only this file, not
// the full XDG_CONFIG_DIRS cascade: Plasma writes the active scheme here, and a
// missing Complementary group falls back to the portal.
func kdeglobalsPath() string {
// kdeConfigPath locates one of KDE's user config files. We read only the user
// file, not the full XDG_CONFIG_DIRS cascade: Plasma writes the active scheme
// and style there, and anything missing falls back to the portal.
func kdeConfigPath(name string) string {
if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" {
return filepath.Join(dir, "kdeglobals")
return filepath.Join(dir, name)
}
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".config", "kdeglobals")
return filepath.Join(home, ".config", name)
}
// kdePanelIsDark reports whether the KDE Plasma panel is dark by the luma of
// its "Complementary" background (the colour Plasma paints the tray with). ok
// is false when this isn't KDE or the colour can't be read, so the caller falls
// through to the portal/GTK path.
func kdeglobalsPath() string { return kdeConfigPath(kdeglobalsFile) }
func plasmarcPath() string { return kdeConfigPath(plasmarcFile) }
// kdePanelIsDark reports whether the KDE Plasma panel the tray icon sits on is
// dark. ok is false when this isn't KDE or neither source was conclusive, so the
// caller falls through to the portal/GTK path.
func kdePanelIsDark() (dark, ok bool) {
if !isKDE() {
return false, false
}
path := kdeglobalsPath()
if path == "" {
return false, false
// A pinned Plasma style paints the panel itself, so it outranks the
// application colour scheme: a style with dark colours under a Light scheme
// is still a dark panel and still needs the white silhouette.
if dark, ok := plasmaStyleIsDark(); ok {
return dark, true
}
rgb, ok := readKdeComplementaryBackground(path)
// The default style follows the colour scheme, so decide by the luma of the
// window background Plasma derives the panel from. Deliberately not
// Complementary: that group is dark under Breeze *and* BreezeLight
// (42,46,50 measured on Plasma 6.7.4), so reading it kept the white icon on
// a light panel, where it is all but invisible.
rgb, ok := readKdeColour(kdeglobalsPath(), "[Colors:Window]")
if !ok {
return false, false
}
return isDarkRGB(rgb[0], rgb[1], rgb[2]), true
}
// readKdeComplementaryBackground parses kdeglobals for
// [Colors:Complementary] BackgroundNormal and returns its R,G,B (0-255).
func readKdeComplementaryBackground(path string) (rgb [3]uint8, ok bool) {
// plasmaStyleIsDark reports the appearance the pinned Plasma style paints the
// panel with, decided by the style's own colours rather than by its name. A
// style that ships a colours file overrides the colour scheme for the shell, and
// nothing requires the name to admit it: breeze-dark happens to, but a style
// named neutrally can carry dark colours just as well (measured on Plasma 6.7.4:
// panel luma 38 while kdeglobals and the portal both reported light).
//
// ok is false when no style is pinned, when the pinned style ships no colours --
// the "default" style, which is exactly the case that follows the colour scheme
// -- and when its colours cannot be read; all three fall through to kdeglobals.
func plasmaStyleIsDark() (dark, ok bool) {
name, found := readIniValue(plasmarcPath(), "[Theme]", "name")
if !found || name == "" {
return false, false
}
if !isBareStyleName(name) {
log.Debugf("tray theme: ignoring plasma style name %q, not a bare directory name", name)
return false, false
}
for _, dir := range plasmaStyleDirs(name) {
rgb, found := readKdeColour(filepath.Join(dir, "colors"), "[Colors:Window]")
if !found {
continue
}
return isDarkRGB(rgb[0], rgb[1], rgb[2]), true
}
return false, false
}
// isBareStyleName reports whether name is safe to index a directory with. The
// name comes from a config file and is joined into a path, so it has to be a
// single ordinary element: "../" in it would point the read anywhere, and ".",
// ".." and "/" all survive filepath.Base(filepath.Clean(name)) unchanged, so
// they need rejecting by name -- ".." alone resolves a level above desktoptheme.
func isBareStyleName(name string) bool {
if name == "" || name == "." || name == ".." || filepath.IsAbs(name) {
return false
}
return name == filepath.Base(filepath.Clean(name))
}
// plasmaStyleDirs lists where a Plasma style of that name may live, in the order
// Plasma itself resolves them: the user data dir first, so a local style shadows
// a system one of the same name.
func plasmaStyleDirs(name string) []string {
var dirs []string
for _, base := range xdgDataDirs() {
dirs = append(dirs, filepath.Join(base, "plasma", "desktoptheme", name))
}
return dirs
}
// xdgDataDirs returns XDG_DATA_HOME (or its default) followed by XDG_DATA_DIRS.
func xdgDataDirs() []string {
var dirs []string
if home := os.Getenv("XDG_DATA_HOME"); home != "" {
dirs = append(dirs, home)
} else if h, err := os.UserHomeDir(); err == nil {
dirs = append(dirs, filepath.Join(h, ".local", "share"))
}
system := os.Getenv("XDG_DATA_DIRS")
if system == "" {
system = "/usr/local/share:/usr/share"
}
for _, dir := range strings.Split(system, ":") {
if dir != "" {
dirs = append(dirs, dir)
}
}
return dirs
}
// readKdeColour reads group's BackgroundNormal as an R,G,B triple.
func readKdeColour(path, group string) (rgb [3]uint8, ok bool) {
val, found := readIniValue(path, group, "BackgroundNormal")
if !found {
return rgb, false
}
return parseRGB(val)
}
// readIniValue returns key's value inside group from a KDE-style INI file.
// group carries its own brackets, e.g. "[Colors:Window]".
func readIniValue(path, group, key string) (value string, ok bool) {
if path == "" {
return "", false
}
f, err := os.Open(path)
if err != nil {
log.Debugf("tray theme: kdeglobals open failed, using portal: %v", err)
return rgb, false
log.Debugf("tray theme: %s open failed, using portal: %v", filepath.Base(path), err)
return "", false
}
defer func() { _ = f.Close() }()
const group = "[Colors:Complementary]"
inGroup := false
scanner := bufio.NewScanner(f)
for scanner.Scan() {
@@ -90,13 +189,13 @@ func readKdeComplementaryBackground(path string) (rgb [3]uint8, ok bool) {
if !inGroup {
continue
}
key, val, found := strings.Cut(line, "=")
if !found || strings.TrimSpace(key) != "BackgroundNormal" {
k, v, found := strings.Cut(line, "=")
if !found || strings.TrimSpace(k) != key {
continue
}
return parseRGB(strings.TrimSpace(val))
return strings.TrimSpace(v), true
}
return rgb, false
return "", false
}
// parseRGB parses KDE's "r,g,b" colour triple into bytes.