mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-16 03:39:07 +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:
@@ -0,0 +1,99 @@
|
||||
//go:build linux && cgo && !android && !ios
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// setAppAppearance points GTK at the light or dark variant of the current theme
|
||||
// so the decorations match the webview. Without it a forced Light theme keeps
|
||||
// dark decorations on a dark desktop, and the reverse.
|
||||
//
|
||||
// The theme name is switched, not just gtk-application-prefer-dark-theme:
|
||||
// desktops such as Ubuntu implement dark mode as a separate theme (Yaru-dark),
|
||||
// which that flag cannot lighten. The flag is still set for themes that do
|
||||
// carry both variants under one name. Both are per-process settings, so this
|
||||
// changes only our own decorations; GTK re-reads the desktop value on a change,
|
||||
// which is why Theme.apply re-asserts. Must run on the main thread.
|
||||
//
|
||||
// GTK styling is app-wide, which is why this is separate from
|
||||
// setWindowAppearance: it must be applied even when no window exists yet, since
|
||||
// windows created later inherit it rather than carrying it in their options.
|
||||
func setAppAppearance(dark bool) {
|
||||
target := baseGtkTheme(gtkThemeName())
|
||||
if dark {
|
||||
if variant, ok := darkGtkVariant(target); ok {
|
||||
target = variant
|
||||
}
|
||||
}
|
||||
// An unknown name would leave GTK with no theme at all, so fall back to
|
||||
// changing nothing and let the prefer-dark flag do what it can.
|
||||
if target != "" && !gtkThemeExists(target) {
|
||||
target = ""
|
||||
}
|
||||
applyGtkTheme(target, dark)
|
||||
}
|
||||
|
||||
// baseGtkTheme strips a dark-variant suffix, so "Yaru-dark" becomes "Yaru".
|
||||
func baseGtkTheme(name string) string {
|
||||
for _, suffix := range []string{"-dark", "-Dark"} {
|
||||
if len(name) > len(suffix) && strings.EqualFold(name[len(name)-len(suffix):], suffix) {
|
||||
return name[:len(name)-len(suffix)]
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// darkGtkVariant reports the installed dark counterpart of a base theme name.
|
||||
// Themes that carry both variants under one name have none, and rely on
|
||||
// gtk-application-prefer-dark-theme instead.
|
||||
func darkGtkVariant(base string) (string, bool) {
|
||||
if base == "" {
|
||||
return "", false
|
||||
}
|
||||
for _, suffix := range []string{"-dark", "-Dark"} {
|
||||
if candidate := base + suffix; gtkThemeExists(candidate) {
|
||||
return candidate, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// gtkThemeExists reports whether a theme of that name is installed, searching
|
||||
// the same locations GTK does.
|
||||
func gtkThemeExists(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
for _, dir := range gtkThemeDirs() {
|
||||
if info, err := os.Stat(filepath.Join(dir, name)); err == nil && info.IsDir() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func gtkThemeDirs() []string {
|
||||
var dirs []string
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
dirs = append(dirs, filepath.Join(home, ".themes"))
|
||||
}
|
||||
if dataHome := os.Getenv("XDG_DATA_HOME"); dataHome != "" {
|
||||
dirs = append(dirs, filepath.Join(dataHome, "themes"))
|
||||
} else if home, err := os.UserHomeDir(); err == nil {
|
||||
dirs = append(dirs, filepath.Join(home, ".local", "share", "themes"))
|
||||
}
|
||||
dataDirs := os.Getenv("XDG_DATA_DIRS")
|
||||
if dataDirs == "" {
|
||||
dataDirs = "/usr/local/share:/usr/share"
|
||||
}
|
||||
for _, dir := range strings.Split(dataDirs, ":") {
|
||||
if dir != "" {
|
||||
dirs = append(dirs, filepath.Join(dir, "themes"))
|
||||
}
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//go:build linux && cgo && !android && !ios
|
||||
|
||||
package services
|
||||
|
||||
/*
|
||||
// The GTK major version is the only difference between the two Linux builds, so
|
||||
// it is selected by these two directives rather than by keeping a second copy of
|
||||
// this file per version: the C below and the Go wrappers under it are identical
|
||||
// for GTK3 and GTK4, and both resolve <gtk/gtk.h> through pkg-config.
|
||||
#cgo gtk3 pkg-config: gtk+-3.0
|
||||
#cgo !gtk3 pkg-config: gtk4
|
||||
#include <stdlib.h>
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
static char *nbGetGtkThemeName(void) {
|
||||
GtkSettings *settings = gtk_settings_get_default();
|
||||
if (settings == NULL) {
|
||||
return NULL;
|
||||
}
|
||||
char *name = NULL;
|
||||
g_object_get(settings, "gtk-theme-name", &name, NULL);
|
||||
return name;
|
||||
}
|
||||
|
||||
// name may be NULL to leave the theme name untouched.
|
||||
static void nbSetGtkTheme(const char *name, int dark) {
|
||||
GtkSettings *settings = gtk_settings_get_default();
|
||||
if (settings == NULL) {
|
||||
return;
|
||||
}
|
||||
if (name != NULL && name[0] != '\0') {
|
||||
g_object_set(settings, "gtk-theme-name", name, NULL);
|
||||
}
|
||||
g_object_set(settings, "gtk-application-prefer-dark-theme", dark ? TRUE : FALSE, NULL);
|
||||
}
|
||||
|
||||
static void nbFreeGtkString(char *s) { g_free(s); }
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import "unsafe"
|
||||
|
||||
func gtkThemeName() string {
|
||||
c := C.nbGetGtkThemeName()
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
defer C.nbFreeGtkString(c)
|
||||
return C.GoString(c)
|
||||
}
|
||||
|
||||
func applyGtkTheme(name string, dark bool) {
|
||||
var cName *C.char
|
||||
if name != "" {
|
||||
cName = C.CString(name)
|
||||
defer C.free(unsafe.Pointer(cName))
|
||||
}
|
||||
var forced C.int
|
||||
if dark {
|
||||
forced = 1
|
||||
}
|
||||
C.nbSetGtkTheme(cName, forced)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !(linux && cgo)
|
||||
|
||||
package services
|
||||
|
||||
// setAppAppearance is a no-op where the platform has no app-wide appearance to
|
||||
// set; macOS and Windows theme each window instead, via setWindowAppearance.
|
||||
func setAppAppearance(bool) {}
|
||||
@@ -31,6 +31,10 @@ func (s *Preferences) SetViewMode(_ context.Context, mode preferences.ViewMode)
|
||||
return s.store.SetViewMode(mode)
|
||||
}
|
||||
|
||||
func (s *Preferences) SetTheme(_ context.Context, theme preferences.Theme) error {
|
||||
return s.store.SetTheme(theme)
|
||||
}
|
||||
|
||||
func (s *Preferences) SetOnboardingCompleted(_ context.Context, done bool) error {
|
||||
return s.store.SetOnboardingCompleted(done)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
"github.com/wailsapp/wails/v3/pkg/events"
|
||||
|
||||
"github.com/netbirdio/netbird/client/ui/preferences"
|
||||
)
|
||||
|
||||
// EventSystemThemeChanged fires when the OS appearance flips, payload SystemTheme.
|
||||
// The frontend resolves the "system" preference against it.
|
||||
const EventSystemThemeChanged = "netbird:system-theme:changed"
|
||||
|
||||
// SystemTheme is the EventSystemThemeChanged payload.
|
||||
type SystemTheme struct {
|
||||
Dark bool `json:"dark"`
|
||||
}
|
||||
|
||||
// Theme keeps native window background colours in step with the persisted
|
||||
// theme preference so no window flashes the wrong surface before the webview
|
||||
// paints. The frontend applies the matching .dark class via ThemeContext.
|
||||
type Theme struct {
|
||||
app *application.App
|
||||
store *preferences.Store
|
||||
// mu serializes apply: concurrent callers could otherwise enqueue a stale
|
||||
// pref's native updates after a newer one's.
|
||||
mu sync.Mutex
|
||||
// started gates the main-thread dispatch in apply: Run installs the platform
|
||||
// layer InvokeAsync needs, and the store subscription can fire before that.
|
||||
started atomic.Bool
|
||||
}
|
||||
|
||||
// NewTheme wires the store subscription and OS theme-change listener. Call
|
||||
// before any window is created so creation-time colours are already themed.
|
||||
func NewTheme(app *application.App, store *preferences.Store) *Theme {
|
||||
t := &Theme{app: app, store: store}
|
||||
pref := store.Get().Theme
|
||||
setAppearance(pref, resolveDark(pref, app.Env.IsDarkMode()))
|
||||
|
||||
// Window creation resolves through this rather than the seed above, which
|
||||
// is wrong until Run installs the platform layer: Env.IsDarkMode reports
|
||||
// light before that, so a "system" launch on a dark OS would build the
|
||||
// first window light. The ApplicationStarted apply below cannot be relied
|
||||
// on to land first because Wails runs each listener in its own goroutine.
|
||||
// One store read backs both fields, so the snapshot is always self-consistent.
|
||||
setAppearanceResolver(func() Appearance {
|
||||
p := t.store.Get().Theme
|
||||
return Appearance{Pref: p, Dark: resolveDark(p, t.app.Env.IsDarkMode())}
|
||||
})
|
||||
|
||||
ch, _ := store.Subscribe()
|
||||
go func() {
|
||||
var last preferences.Theme
|
||||
for p := range ch {
|
||||
if p.Theme == last {
|
||||
continue
|
||||
}
|
||||
last = p.Theme
|
||||
t.apply()
|
||||
}
|
||||
}()
|
||||
|
||||
// Re-apply on every OS flip, not just for ThemeSystem: Windows re-evaluates
|
||||
// process-level theme state on WM_SETTINGCHANGE, so a forced theme has to be
|
||||
// re-asserted or the native chrome drifts to the OS appearance. The event's
|
||||
// own IsDarkMode is deliberately unused: Wails runs each application event
|
||||
// handler in its own goroutine, so two rapid flips race, and apply re-reads
|
||||
// the appearance under mu instead.
|
||||
app.Event.OnApplicationEvent(events.Common.ThemeChanged, func(*application.ApplicationEvent) {
|
||||
t.apply()
|
||||
})
|
||||
|
||||
// Startup is split in two because Wails runs every application-event
|
||||
// listener in its own goroutine, so a listener cannot be ordered against the
|
||||
// one that opens the first-launch window. Hooks can: they run sequentially,
|
||||
// in registration order, and all of them before any listener is spawned.
|
||||
//
|
||||
// The app-wide GTK theme goes in the hook because it is the part a window
|
||||
// must not be created without. On Linux it draws the decorations and
|
||||
// application.LinuxWindow carries no theme of its own, so a window built
|
||||
// before it lands shows OS-coloured decorations until it does. It is applied
|
||||
// synchronously for the same reason -- returning from the hook has to mean
|
||||
// the theme is live. This relies on the listener below existing: Wails skips
|
||||
// an event's hooks entirely when it has no listeners.
|
||||
app.Event.RegisterApplicationEventHook(events.Common.ApplicationStarted, func(*application.ApplicationEvent) {
|
||||
t.started.Store(true)
|
||||
t.syncAppAppearance()
|
||||
})
|
||||
|
||||
// The rest of the startup apply. Env.IsDarkMode is a stub until the platform
|
||||
// layer is up, so re-resolve once the app has started or a "system" launch on
|
||||
// a light OS stays seeded dark.
|
||||
app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) {
|
||||
t.apply()
|
||||
})
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
// syncAppAppearance applies the app-wide appearance and waits for the UI thread
|
||||
// to have done it. Use it where a window is about to be created and must not be
|
||||
// built against the OS appearance: apply dispatches its own native work
|
||||
// asynchronously, so on Linux the GTK theme behind the decorations can otherwise
|
||||
// land after the window exists.
|
||||
//
|
||||
// No-op before the app has started, where InvokeSync has no platform layer to
|
||||
// dispatch to. Reads the appearance under mu like apply, so the two cannot
|
||||
// interleave into a torn update.
|
||||
func (t *Theme) syncAppAppearance() {
|
||||
if !t.started.Load() {
|
||||
return
|
||||
}
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
pref := t.store.Get().Theme
|
||||
dark := resolveDark(pref, t.app.Env.IsDarkMode())
|
||||
setAppearance(pref, dark)
|
||||
application.InvokeSync(func() { setAppAppearance(dark) })
|
||||
}
|
||||
|
||||
// SystemDarkMode reports the OS appearance; bound so the frontend can resolve
|
||||
// the "system" preference from the same source as the native layer.
|
||||
func (t *Theme) SystemDarkMode(_ context.Context) (bool, error) {
|
||||
return t.app.Env.IsDarkMode(), nil
|
||||
}
|
||||
|
||||
// resolveDark maps a preference to an effective appearance against a system
|
||||
// reading the caller already took.
|
||||
func resolveDark(pref preferences.Theme, systemDark bool) bool {
|
||||
switch pref {
|
||||
case preferences.ThemeDark:
|
||||
return true
|
||||
case preferences.ThemeLight:
|
||||
return false
|
||||
default:
|
||||
return systemDark
|
||||
}
|
||||
}
|
||||
|
||||
// apply recomputes the effective appearance, re-tints every live window
|
||||
// (including the macOS NSWindow appearance so the frame matches the webview)
|
||||
// and publishes the system appearance the frontend resolves "system" against.
|
||||
//
|
||||
// Everything runs under mu and reads the appearance here rather than taking it
|
||||
// from a caller, so a later apply always carries the fresher state and the
|
||||
// frontend event is ordered by the same lock as the native assignments. Emit
|
||||
// only appends to a FIFO mailbox, so holding mu across it cannot block.
|
||||
//
|
||||
// The OS is read exactly once per update and the resolved value is passed on to
|
||||
// the background and the native chrome, so those cannot land on either side of
|
||||
// an OS flip that happens mid-apply. The event carries the raw system reading,
|
||||
// not the resolved one, because the frontend resolves "system" itself.
|
||||
func (t *Theme) apply() {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
pref := t.store.Get().Theme
|
||||
systemDark := t.app.Env.IsDarkMode()
|
||||
dark := resolveDark(pref, systemDark)
|
||||
setAppearance(pref, dark)
|
||||
t.app.Event.Emit(EventSystemThemeChanged, SystemTheme{Dark: systemDark})
|
||||
|
||||
// Before Run there is no platform layer for InvokeAsync to dispatch to.
|
||||
// Windows created later read the globals set above.
|
||||
if !t.started.Load() {
|
||||
return
|
||||
}
|
||||
|
||||
colour := windowBackgroundColour(dark)
|
||||
// Re-tint on the UI thread and resolve each native handle there. Window
|
||||
// teardown (markAsDestroyed then impl.close) runs as UI-thread work too, so
|
||||
// a window closed meanwhile is either gone from GetAll or yields a nil
|
||||
// handle -- never a freed handle the OS may already have reused.
|
||||
application.InvokeAsync(func() {
|
||||
// App-wide first, and unconditionally: on Linux this is the GTK theme
|
||||
// that draws the decorations, and it must be set even with no window
|
||||
// open because later windows inherit it instead of carrying it.
|
||||
setAppAppearance(dark)
|
||||
for _, w := range t.app.Window.GetAll() {
|
||||
if w == nil {
|
||||
continue
|
||||
}
|
||||
w.SetBackgroundColour(colour)
|
||||
setWindowAppearance(w.NativeWindow(), pref, dark)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package services
|
||||
|
||||
/*
|
||||
#cgo CFLAGS: -x objective-c
|
||||
#cgo LDFLAGS: -framework AppKit
|
||||
|
||||
#import <AppKit/AppKit.h>
|
||||
|
||||
// forced < 0: follow the OS (appearance nil); 0: light; 1: dark.
|
||||
//
|
||||
// Assigns directly rather than dispatching: Theme.apply already runs this on
|
||||
// the main thread. Deferring would outlive the caller's check that the window
|
||||
// is alive, and the __bridge cast does not retain it, so the block could touch
|
||||
// a freed NSWindow.
|
||||
static void nbSetWindowAppearance(void *nsWindow, int forced) {
|
||||
NSWindow *window = (__bridge NSWindow *)nsWindow;
|
||||
if (forced < 0) {
|
||||
window.appearance = nil;
|
||||
} else {
|
||||
NSAppearanceName name = forced == 1 ? NSAppearanceNameDarkAqua : NSAppearanceNameAqua;
|
||||
window.appearance = [NSAppearance appearanceNamed:name];
|
||||
}
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"unsafe"
|
||||
|
||||
"github.com/netbirdio/netbird/client/ui/preferences"
|
||||
)
|
||||
|
||||
// setWindowAppearance pins the NSWindow appearance to the forced theme, or
|
||||
// hands it back to the OS for ThemeSystem. Without this, a window created
|
||||
// under one OS appearance keeps its dark/light frame after a manual theme
|
||||
// flip, leaving a mismatched border around the webview. Must run on the main
|
||||
// thread, which Theme.apply guarantees.
|
||||
//
|
||||
// The resolved appearance is unused: for ThemeSystem a nil NSAppearance lets
|
||||
// AppKit track the OS itself, which cannot drift from a snapshot we took.
|
||||
func setWindowAppearance(nsWindow unsafe.Pointer, pref preferences.Theme, _ bool) {
|
||||
if nsWindow == nil {
|
||||
return
|
||||
}
|
||||
forced := C.int(-1)
|
||||
switch pref {
|
||||
case preferences.ThemeLight:
|
||||
forced = 0
|
||||
case preferences.ThemeDark:
|
||||
forced = 1
|
||||
}
|
||||
C.nbSetWindowAppearance(nsWindow, forced)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//go:build !darwin && !windows && !android && !ios && !freebsd && !js
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"unsafe"
|
||||
|
||||
"github.com/netbirdio/netbird/client/ui/preferences"
|
||||
)
|
||||
|
||||
// setWindowAppearance is a no-op wherever there is no per-window appearance to
|
||||
// set, which is every target this file covers. On Linux the appearance is real
|
||||
// but app-wide, so setAppAppearance owns it instead; on the remaining Unix
|
||||
// targets there is no native theming to apply at all and setAppAppearance is
|
||||
// itself a stub (appappearance_other.go).
|
||||
func setWindowAppearance(unsafe.Pointer, preferences.Theme, bool) {}
|
||||
@@ -0,0 +1,54 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"unsafe"
|
||||
|
||||
"github.com/wailsapp/wails/v3/pkg/w32"
|
||||
|
||||
"github.com/netbirdio/netbird/client/ui/preferences"
|
||||
)
|
||||
|
||||
// setWindowAppearance re-themes a live window's chrome; Wails only does this
|
||||
// itself on OS flips for SystemDefault windows.
|
||||
//
|
||||
// Must run on the UI thread, which Theme.apply guarantees: the uxtheme and
|
||||
// repaint calls behind w32.SetTheme belong to the window's thread, and hwnd is
|
||||
// only known live while we hold that thread. Re-dispatching here would let the
|
||||
// window be destroyed first and hand these writes a reused handle.
|
||||
//
|
||||
// dark is the appearance Theme.apply already resolved. Re-reading the OS here
|
||||
// would let the chrome land on the other side of an OS flip from the window
|
||||
// background and the webview.
|
||||
func setWindowAppearance(hwnd unsafe.Pointer, _ preferences.Theme, dark bool) {
|
||||
if hwnd == nil || !w32.SupportsThemes() || w32.IsCurrentlyHighContrastMode() {
|
||||
return
|
||||
}
|
||||
|
||||
h := uintptr(hwnd)
|
||||
w32.SetTheme(h, dark)
|
||||
|
||||
// After SetTheme, not before: its menu helper regates dark on the
|
||||
// process-level ShouldAppsUseDarkMode and rewrites the per-window opt-in
|
||||
// with that gated value, so forcing Dark on a light OS would lose it --
|
||||
// and builds below 18985 need the opt-in for the pre-20H1 dark frame. The
|
||||
// gated menu theme name is left alone on purpose: these windows carry no
|
||||
// native menu, and popup-menu text follows the process policy, so forcing
|
||||
// it dark gives dark text on dark.
|
||||
if w32.AllowDarkModeForWindow != nil {
|
||||
w32.AllowDarkModeForWindow(h, dark)
|
||||
}
|
||||
|
||||
chrome := microsoftWindowsLightTheme
|
||||
if dark {
|
||||
chrome = microsoftWindowsDarkTheme
|
||||
}
|
||||
if chrome.TitleBarColour != nil {
|
||||
w32.SetTitleBarColour(h, *chrome.TitleBarColour)
|
||||
}
|
||||
if chrome.TitleTextColour != nil {
|
||||
w32.SetTitleTextColour(h, *chrome.TitleTextColour)
|
||||
}
|
||||
if chrome.BorderColour != nil {
|
||||
w32.SetBorderColour(h, *chrome.BorderColour)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
@@ -36,39 +37,123 @@ const paintedFallback = 2 * time.Second
|
||||
|
||||
const headlessTeardownDelay = 2 * time.Second
|
||||
|
||||
var WindowBackgroundColour = application.NewRGB(24, 26, 29) // bg-nb-gray-950
|
||||
// Window background per effective appearance. Both match the body background
|
||||
// (bg-nb-gray DEFAULT) in globals.css so opaque native pixels and the webview
|
||||
// paint the same surface; keep the three in sync.
|
||||
var (
|
||||
windowBackgroundDark = application.NewRGB(24, 26, 29) // dark nb-gray DEFAULT
|
||||
windowBackgroundLight = application.NewRGB(243, 243, 243) // light nb-gray DEFAULT
|
||||
)
|
||||
|
||||
// Appearance is one view of the theme state: the preference and the appearance
|
||||
// it resolves to. Take it once per window with CurrentAppearance and pass the
|
||||
// same value to every option builder -- Pref drives the macOS frame while Dark
|
||||
// drives the background and the Windows chrome, so reading them separately can
|
||||
// build a window with a new background behind the previous native frame.
|
||||
type Appearance struct {
|
||||
Pref preferences.Theme
|
||||
Dark bool
|
||||
}
|
||||
|
||||
// storedAppearance is the snapshot maintained by services.Theme, published as
|
||||
// one value so the pair can never tear. It is the fallback for window creation
|
||||
// until resolveAppearance is installed.
|
||||
var storedAppearance atomic.Value // Appearance
|
||||
|
||||
// resolveAppearance re-resolves against the live OS state. Theme installs it so
|
||||
// window creation never reads a stale seed: app.Env.IsDarkMode reports light
|
||||
// until Run installs the platform layer, and Wails runs every
|
||||
// ApplicationStarted listener in its own goroutine, so a startup window can be
|
||||
// created before Theme's listener has corrected the seed.
|
||||
var resolveAppearance atomic.Value // func() Appearance
|
||||
|
||||
func init() {
|
||||
storedAppearance.Store(Appearance{Pref: preferences.DefaultTheme, Dark: true})
|
||||
}
|
||||
|
||||
func setAppearance(pref preferences.Theme, dark bool) {
|
||||
storedAppearance.Store(Appearance{Pref: pref, Dark: dark})
|
||||
}
|
||||
|
||||
func setAppearanceResolver(f func() Appearance) { resolveAppearance.Store(f) }
|
||||
|
||||
// CurrentAppearance returns the snapshot every window creation must build from.
|
||||
func CurrentAppearance() Appearance {
|
||||
if f, _ := resolveAppearance.Load().(func() Appearance); f != nil {
|
||||
return f()
|
||||
}
|
||||
a, _ := storedAppearance.Load().(Appearance)
|
||||
return a
|
||||
}
|
||||
|
||||
// WindowBackgroundColour returns the background for a snapshot; use it for
|
||||
// every WebviewWindowOptions.BackgroundColour.
|
||||
func WindowBackgroundColour(a Appearance) application.RGBA {
|
||||
return windowBackgroundColour(a.Dark)
|
||||
}
|
||||
|
||||
// windowBackgroundColour maps a resolved appearance to its window background.
|
||||
func windowBackgroundColour(dark bool) application.RGBA {
|
||||
if dark {
|
||||
return windowBackgroundDark
|
||||
}
|
||||
return windowBackgroundLight
|
||||
}
|
||||
|
||||
// WindowHeight is shared by the main and Settings windows.
|
||||
const WindowHeight = 660
|
||||
|
||||
// Wails reads CustomTheme colours as 0x00BBGGRR (RGB byte order reversed).
|
||||
var microsoftWindowsTheme = &application.WindowTheme{
|
||||
BorderColour: u32ptr(0x00211E1C),
|
||||
var microsoftWindowsDarkTheme = &application.WindowTheme{
|
||||
BorderColour: u32ptr(0x00211E1C), // #1C1E21 nb-gray-940
|
||||
TitleBarColour: u32ptr(0x00211E1C),
|
||||
TitleTextColour: u32ptr(0x00E9E7E4),
|
||||
TitleTextColour: u32ptr(0x00E9E7E4), // #E4E7E9 nb-gray-100
|
||||
}
|
||||
|
||||
// MicrosoftWindowsAppearanceOptions is the shared Windows chrome (Mica + dark + custom title bar).
|
||||
func MicrosoftWindowsAppearanceOptions() application.WindowsWindow {
|
||||
var microsoftWindowsLightTheme = &application.WindowTheme{
|
||||
BorderColour: u32ptr(0x00F3F3F3), // #F3F3F3 light nb-gray DEFAULT
|
||||
TitleBarColour: u32ptr(0x00F3F3F3),
|
||||
TitleTextColour: u32ptr(0x00212121), // #212121 light nb-gray-100
|
||||
}
|
||||
|
||||
// MicrosoftWindowsAppearanceOptions is the shared Windows chrome (Mica +
|
||||
// custom title bar), resolved at creation; setWindowAppearance re-themes live
|
||||
// windows on later changes. Never SystemDefault: Wails gives those windows a
|
||||
// SystemThemeChanged handler that re-themes chrome from the OS appearance,
|
||||
// which outlives a switch to a forced theme and fights it on the next OS flip.
|
||||
// Both CustomTheme slots hold one colour set for the same reason.
|
||||
func MicrosoftWindowsAppearanceOptions(a Appearance) application.WindowsWindow {
|
||||
theme, chrome := application.Light, microsoftWindowsLightTheme
|
||||
if a.Dark {
|
||||
theme, chrome = application.Dark, microsoftWindowsDarkTheme
|
||||
}
|
||||
return application.WindowsWindow{
|
||||
BackdropType: application.Mica,
|
||||
Theme: application.Dark,
|
||||
Theme: theme,
|
||||
CustomTheme: application.ThemeSettings{
|
||||
DarkModeActive: microsoftWindowsTheme,
|
||||
DarkModeInactive: microsoftWindowsTheme,
|
||||
LightModeActive: microsoftWindowsTheme,
|
||||
LightModeInactive: microsoftWindowsTheme,
|
||||
DarkModeActive: chrome,
|
||||
DarkModeInactive: chrome,
|
||||
LightModeActive: chrome,
|
||||
LightModeInactive: chrome,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// AppleMacOSAppearanceOptions is the shared macOS chrome; FullScreenNone keeps the fixed-size layout.
|
||||
func AppleMacOSAppearanceOptions() application.MacWindow {
|
||||
func AppleMacOSAppearanceOptions(a Appearance) application.MacWindow {
|
||||
appearance := application.DefaultAppearance
|
||||
switch a.Pref {
|
||||
case preferences.ThemeLight:
|
||||
appearance = application.NSAppearanceNameAqua
|
||||
case preferences.ThemeDark:
|
||||
appearance = application.NSAppearanceNameDarkAqua
|
||||
}
|
||||
return application.MacWindow{
|
||||
InvisibleTitleBarHeight: 38,
|
||||
Backdrop: application.MacBackdropNormal,
|
||||
TitleBar: application.MacTitleBarHiddenInset,
|
||||
CollectionBehavior: application.MacWindowCollectionBehaviorFullScreenNone,
|
||||
Appearance: appearance,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +167,7 @@ func LinuxAppearanceOptions(icon []byte) application.LinuxWindow {
|
||||
|
||||
// DialogWindowOptions is the baseline for every auxiliary dialog window; callers override per-dialog.
|
||||
func DialogWindowOptions(name, title, url string, linuxIcon []byte) application.WebviewWindowOptions {
|
||||
a := CurrentAppearance()
|
||||
return application.WebviewWindowOptions{
|
||||
Name: name,
|
||||
Title: title,
|
||||
@@ -93,10 +179,10 @@ func DialogWindowOptions(name, title, url string, linuxIcon []byte) application.
|
||||
MinimiseButtonState: application.ButtonHidden,
|
||||
MaximiseButtonState: application.ButtonHidden,
|
||||
CloseButtonState: application.ButtonEnabled,
|
||||
BackgroundColour: WindowBackgroundColour,
|
||||
BackgroundColour: WindowBackgroundColour(a),
|
||||
URL: url,
|
||||
Mac: AppleMacOSAppearanceOptions(),
|
||||
Windows: MicrosoftWindowsAppearanceOptions(),
|
||||
Mac: AppleMacOSAppearanceOptions(a),
|
||||
Windows: MicrosoftWindowsAppearanceOptions(a),
|
||||
Linux: LinuxAppearanceOptions(linuxIcon),
|
||||
}
|
||||
}
|
||||
@@ -164,6 +250,7 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo
|
||||
}
|
||||
|
||||
func (s *WindowManager) newSettingsWindow() *application.WebviewWindow {
|
||||
a := CurrentAppearance()
|
||||
w := s.app.Window.NewWithOptions(application.WebviewWindowOptions{
|
||||
Name: "settings",
|
||||
Title: s.title("window.title.settings"),
|
||||
@@ -174,10 +261,10 @@ func (s *WindowManager) newSettingsWindow() *application.WebviewWindow {
|
||||
MinimiseButtonState: application.ButtonHidden,
|
||||
MaximiseButtonState: application.ButtonHidden,
|
||||
CloseButtonState: application.ButtonEnabled,
|
||||
BackgroundColour: WindowBackgroundColour,
|
||||
BackgroundColour: WindowBackgroundColour(a),
|
||||
URL: "/#/settings",
|
||||
Mac: AppleMacOSAppearanceOptions(),
|
||||
Windows: MicrosoftWindowsAppearanceOptions(),
|
||||
Mac: AppleMacOSAppearanceOptions(a),
|
||||
Windows: MicrosoftWindowsAppearanceOptions(a),
|
||||
Linux: LinuxAppearanceOptions(s.linuxIcon),
|
||||
})
|
||||
w.RegisterHook(events.Common.WindowClosing, func(_ *application.WindowEvent) {
|
||||
|
||||
Reference in New Issue
Block a user