From 67623366f5cb9d78fbbb7bee7eabd6c26da2f215 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Wed, 24 Jun 2026 13:46:09 -0700 Subject: [PATCH] Match window border color to app theme rather than OS theme (#958) * MacOS: match window chrome to app light/dark mode when app setting is different from OS * match window border to app theme on Windows --- main.go | 5 +++ ui/controller/controller.go | 21 ++++++++++ ui/controller/visualizations.go | 2 + ui/controller/windowtheme.m | 20 ++++++++++ ui/controller/windowtheme_darwin.go | 16 ++++++++ ui/controller/windowtheme_other.go | 5 +++ ui/controller/windowtheme_windows.go | 57 ++++++++++++++++++++++++++++ ui/mainwindow.go | 3 ++ ui/theme/theme.go | 10 +++++ 9 files changed, 139 insertions(+) create mode 100644 ui/controller/windowtheme.m create mode 100644 ui/controller/windowtheme_darwin.go create mode 100644 ui/controller/windowtheme_other.go create mode 100644 ui/controller/windowtheme_windows.go diff --git a/main.go b/main.go index 0f972fa..c598507 100644 --- a/main.go +++ b/main.go @@ -16,6 +16,8 @@ import ( "github.com/dweymouth/supersonic/res" "github.com/dweymouth/supersonic/res/wintaskbarthumbs" "github.com/dweymouth/supersonic/ui" + "github.com/dweymouth/supersonic/ui/controller" + myTheme "github.com/dweymouth/supersonic/ui/theme" "github.com/dweymouth/supersonic/ui/util" "golang.org/x/term" @@ -133,6 +135,9 @@ func main() { fyneApp.Run() } else { fyneApp.Lifecycle().SetOnStarted(func() { + if mode := fyne.CurrentApp().Settings().Theme().(*myTheme.MyTheme).AppearanceMode(); mode != myTheme.AppearanceAuto { + controller.SetWindowThemeMode(mainWindow.Window, mode) + } defaultServer := myApp.ServerManager.GetDefaultServer() if defaultServer == nil { mainWindow.Controller.PromptForFirstServer() diff --git a/ui/controller/controller.go b/ui/controller/controller.go index 3c87808..6b0f2d1 100644 --- a/ui/controller/controller.go +++ b/ui/controller/controller.go @@ -10,6 +10,7 @@ import ( "log" "os" "path/filepath" + "runtime" "time" fynetooltip "github.com/dweymouth/fyne-tooltip" @@ -26,6 +27,7 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/driver" "fyne.io/fyne/v2/lang" "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/theme" @@ -655,3 +657,22 @@ func (c *Controller) GetSongRadioTracks(sourceTrack *mediaprovider.Track) ([]*me tracks = append(tracks, filteredTracks...) return tracks, nil } + +func SetWindowThemeMode(win fyne.Window, mode myTheme.AppearanceMode) { + arg := 0 + switch fyne.CurrentApp().Settings().Theme().(*myTheme.MyTheme).AppearanceMode() { + case myTheme.AppearanceDark: + arg = 1 + case myTheme.AppearanceLight: + arg = 2 + } + win.(driver.NativeWindow).RunNative(func(ctx any) { + switch runtime.GOOS { + case "darwin": + // ensure dark mode setting is applied to window controls in title bar on Mac + setWindowDarkTheme(ctx.(driver.MacWindowContext).NSWindow, arg) + case "windows": + setWindowDarkTheme(ctx.(driver.WindowsWindowContext).HWND, arg) + } + }) +} diff --git a/ui/controller/visualizations.go b/ui/controller/visualizations.go index 3d0e30f..7dd55fb 100644 --- a/ui/controller/visualizations.go +++ b/ui/controller/visualizations.go @@ -9,6 +9,7 @@ import ( "github.com/dweymouth/supersonic/backend/player" "github.com/dweymouth/supersonic/backend/player/mpv" "github.com/dweymouth/supersonic/ui/shortcuts" + myTheme "github.com/dweymouth/supersonic/ui/theme" "github.com/dweymouth/supersonic/ui/util" "github.com/dweymouth/supersonic/ui/visualizations" ) @@ -68,6 +69,7 @@ func (c *Controller) ShowPeakMeter() { c.peakMeter.Refresh() } c.peakMeterWin.Show() + SetWindowThemeMode(c.peakMeterWin, fyne.CurrentApp().Settings().Theme().(*myTheme.MyTheme).AppearanceMode()) } func (c *Controller) stopVisualizationAnim() { diff --git a/ui/controller/windowtheme.m b/ui/controller/windowtheme.m new file mode 100644 index 0000000..aaa1683 --- /dev/null +++ b/ui/controller/windowtheme.m @@ -0,0 +1,20 @@ +//go:build darwin + +#import + +void setWindowDarkMode(void* windowPtr, int mode) { + if (windowPtr == NULL) return; + + NSWindow* window = (__bridge NSWindow*)windowPtr; + + if (mode == 1) { + // Forces the window chrome and borders into Dark Mode + window.appearance = [NSAppearance appearanceNamed:NSAppearanceNameVibrantDark]; + } else if (mode == 2) { + // Forces the window chrome and borders into Light Mode + window.appearance = [NSAppearance appearanceNamed:NSAppearanceNameVibrantLight]; + } else { + // Reverts the window back to standard/system default behavior + window.appearance = nil; // Fallback to inherited system style + } +} diff --git a/ui/controller/windowtheme_darwin.go b/ui/controller/windowtheme_darwin.go new file mode 100644 index 0000000..7f0b2f6 --- /dev/null +++ b/ui/controller/windowtheme_darwin.go @@ -0,0 +1,16 @@ +//go:build darwin + +package controller + +/* +void setWindowDarkMode(void* windowPtr, int useDarkMode); +*/ +import "C" +import "unsafe" + +// setWindowDarkTheme takes an unsafe.Pointer referencing the macOS NSWindow +// and explicitly applies or clears the dark mode appearance. +// Mode arg: 0 = auto, 1 = dark, 2 = light +func setWindowDarkTheme(nsWindowPtr uintptr, mode int) { + C.setWindowDarkMode(unsafe.Pointer(nsWindowPtr), C.int(mode)) +} diff --git a/ui/controller/windowtheme_other.go b/ui/controller/windowtheme_other.go new file mode 100644 index 0000000..6fa0fdf --- /dev/null +++ b/ui/controller/windowtheme_other.go @@ -0,0 +1,5 @@ +//go:build !darwin && !windows + +package controller + +func setWindowDarkTheme(ptr uintptr, mode int) {} diff --git a/ui/controller/windowtheme_windows.go b/ui/controller/windowtheme_windows.go new file mode 100644 index 0000000..5012b90 --- /dev/null +++ b/ui/controller/windowtheme_windows.go @@ -0,0 +1,57 @@ +//go:build windows + +package controller + +import ( + "runtime" + "syscall" + "unsafe" + + "fyne.io/fyne/v2" + "golang.org/x/sys/windows/registry" +) + +var ( + dwm = syscall.NewLazyDLL("dwmapi.dll") + setAtt = dwm.NewProc("DwmSetWindowAttribute") +) + +func setWindowDarkTheme(hwnd uintptr, mode int) { + if runtime.GOOS != "windows" { + return + } + arg := 0 + switch mode { + case 0: /*auto*/ + if isDark() { + arg = 1 + } + case 1: /*dark*/ + arg = 1 + } + + // copied from Fyne internals + ret, _, err := setAtt.Call(uintptr(unsafe.Pointer(hwnd)), // window handle + 20, // DWMWA_USE_IMMERSIVE_DARK_MODE + uintptr(unsafe.Pointer(&arg)), // on or off + 4) // sizeof(bool for windows)) + if ret != 0 && ret != 0x80070057 { // err is always non-nil, we check return value (except erroneous code) + fyne.LogError("Failed to set dark mode", err) + } +} + +// copied from Fyne internals +func isDark() bool { + k, err := registry.OpenKey(registry.CURRENT_USER, `SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize`, registry.QUERY_VALUE) + if err != nil { // older version of Windows will not have this key + return false + } + defer k.Close() + + useLight, _, err := k.GetIntegerValue("AppsUseLightTheme") + if err != nil { // older version of Windows will not have this value + return false + } + + return useLight == 0 +} diff --git a/ui/mainwindow.go b/ui/mainwindow.go index 1b48591..72a61e4 100644 --- a/ui/mainwindow.go +++ b/ui/mainwindow.go @@ -557,6 +557,9 @@ func (m *MainWindow) addShortcuts() { func (m *MainWindow) showSettingsDialog() { m.Controller.ShowSettingsDialog(func() { fyne.CurrentApp().Settings().SetTheme(m.theme) + for _, w := range fyne.CurrentApp().Driver().AllWindows() { + controller.SetWindowThemeMode(w, m.theme.AppearanceMode()) + } }, m.theme.ListThemeFiles()) } diff --git a/ui/theme/theme.go b/ui/theme/theme.go index 0c46dc2..9501d80 100644 --- a/ui/theme/theme.go +++ b/ui/theme/theme.go @@ -115,6 +115,16 @@ func (m *MyTheme) ReloadThemeFile() { m.loadedThemeFile = nil } +func (m *MyTheme) AppearanceMode() AppearanceMode { + v := DefaultAppearance // default if config has invalid or missing setting + if slices.Contains( + []string{string(AppearanceLight), string(AppearanceDark), string(AppearanceAuto)}, + m.config.Appearance) { + v = AppearanceMode(m.config.Appearance) + } + return v +} + func (m *MyTheme) Color(name fyne.ThemeColorName, defVariant fyne.ThemeVariant) color.Color { // load theme file if necessary if m.loadedThemeFile == nil || m.config.ThemeFile != m.loadedThemeFilename {