Merge pull request #203 from dweymouth/feature/custom-themes

Add support for custom themes
This commit is contained in:
Drew Weymouth
2023-06-20 18:42:32 -07:00
committed by GitHub
11 changed files with 318 additions and 65 deletions
+1
View File
@@ -89,6 +89,7 @@ type ReplayGainConfig struct {
}
type ThemeConfig struct {
ThemeFile string
Appearance string
}
+1 -1
View File
@@ -37,7 +37,7 @@ func main() {
if h <= 1 {
h = 800
}
mainWindow := ui.NewMainWindow(fyneApp, displayName, appVersion, myApp, fyne.NewSize(w, h))
mainWindow := ui.NewMainWindow(fyneApp, appname, displayName, appVersion, myApp, fyne.NewSize(w, h))
myApp.OnReactivate = mainWindow.Show
go func() {
+5
View File
@@ -140,6 +140,11 @@ var ResFilterSvg = &fyne.StaticResource{
StaticContent: []byte(
"<svg fill=\"#000000\" version=\"1.1\" id=\"Capa_1\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" \r\n\t width=\"800px\" height=\"800px\" viewBox=\"0 0 971.986 971.986\"\r\n\t xml:space=\"preserve\">\r\n<g>\r\n\t<path d=\"M370.216,459.3c10.2,11.1,15.8,25.6,15.8,40.6v442c0,26.601,32.1,40.101,51.1,21.4l123.3-141.3\r\n\t\tc16.5-19.8,25.6-29.601,25.6-49.2V500c0-15,5.7-29.5,15.8-40.601L955.615,75.5c26.5-28.8,6.101-75.5-33.1-75.5h-873\r\n\t\tc-39.2,0-59.7,46.6-33.1,75.5L370.216,459.3z\"/>\r\n</g>\r\n</svg>\r\n"),
}
var ResDefaultToml = &fyne.StaticResource{
StaticName: "default.toml",
StaticContent: []byte(
"[SupersonicTheme]\nName = \"Default\"\nVersion = \"0.1\"\nSupportsDark = true\nSupportsLight = true\n\n[DarkColors]\nPageBackground = \"#0F0F0F\"\nListHeader = \"#232323\"\nBackground = \"#232323\"\nScrollBar = \"#F3F3F3\"\nButton = \"#14141432\"\nInputBackground = \"#14141432\"\n\n[LightColors]\nPageBackground = \"#FAFAFA\"\nListHeader = \"#E1DFE1\"\nBackground = \"#E1DFE1\"\nScrollBar = \"#565656\"\nButton = \"#C8C8C8F0\"\nDisabledButton = \"#CDCDCDF0\"\nForeground = \"#0A0A0A\"\nPrimary = \"#1919FA\""),
}
var ResLICENSE = &fyne.StaticResource{
StaticName: "LICENSE",
StaticContent: []byte(
+2
View File
@@ -28,6 +28,8 @@ fyne bundle -append -prefix Res icons/publicdomain/grid.svg >> bundled.go
fyne bundle -append -prefix Res icons/publicdomain/list.svg >> bundled.go
fyne bundle -append -prefix Res icons/publicdomain/filter.svg >> bundled.go
fyne bundle -append -prefix Res themes/default.toml >> bundled.go
fyne bundle -append -prefix Res ../LICENSE >> bundled.go
fyne bundle -append -prefix Res licenses/BSDLICENSE >> bundled.go
fyne bundle -append -prefix Res licenses/MITLICENSE >> bundled.go
+23
View File
@@ -0,0 +1,23 @@
[SupersonicTheme]
Name = "Default"
Version = "0.1"
SupportsDark = true
SupportsLight = true
[DarkColors]
PageBackground = "#0F0F0F"
ListHeader = "#232323"
Background = "#232323"
ScrollBar = "#F3F3F3"
Button = "#14141432"
InputBackground = "#14141432"
[LightColors]
PageBackground = "#FAFAFA"
ListHeader = "#E1DFE1"
Background = "#E1DFE1"
ScrollBar = "#565656"
Button = "#C8C8C8F0"
DisabledButton = "#CDCDCDF0"
Foreground = "#0A0A0A"
Primary = "#1919FA"
+2 -2
View File
@@ -441,14 +441,14 @@ func (c *Controller) ShowAboutDialog() {
pop.Show()
}
func (c *Controller) ShowSettingsDialog(themeUpdateCallbk func()) {
func (c *Controller) ShowSettingsDialog(themeUpdateCallbk func(), themeFiles map[string]string) {
devs, err := c.App.Player.ListAudioDevices()
if err != nil {
log.Printf("error listing audio devices: %v", err)
devs = []player.AudioDevice{{Name: "auto", Description: "Autoselect device"}}
}
dlg := dialogs.NewSettingsDialog(c.App.Config, devs, c.MainWindow)
dlg := dialogs.NewSettingsDialog(c.App.Config, devs, themeFiles, c.MainWindow)
dlg.OnReplayGainSettingsChanged = func() {
c.App.PlaybackManager.SetReplayGainOptions(c.App.Config.ReplayGain)
}
+37 -13
View File
@@ -38,14 +38,15 @@ type SettingsDialog struct {
config *backend.Config
audioDevices []player.AudioDevice
themeFiles map[string]string // filename -> displayName
promptText *widget.RichText
content fyne.CanvasObject
}
// TODO: having this depend on the player package for the AudioDevice type is kinda gross. Refactor.
func NewSettingsDialog(config *backend.Config, audioDeviceList []player.AudioDevice, window fyne.Window) *SettingsDialog {
s := &SettingsDialog{config: config, audioDevices: audioDeviceList}
func NewSettingsDialog(config *backend.Config, audioDeviceList []player.AudioDevice, themeFileList map[string]string, window fyne.Window) *SettingsDialog {
s := &SettingsDialog{config: config, audioDevices: audioDeviceList, themeFiles: themeFileList}
s.ExtendBaseWidget(s)
tabs := container.NewAppTabs(
@@ -69,19 +70,39 @@ func NewSettingsDialog(config *backend.Config, audioDeviceList []player.AudioDev
}
func (s *SettingsDialog) createGeneralTab() *container.TabItem {
themeSelect := widget.NewSelect([]string{
string(myTheme.AppearanceDark),
string(myTheme.AppearanceLight),
string(myTheme.AppearanceAuto)}, nil)
themeSelect.OnChanged = func(_ string) {
s.config.Theme.Appearance = themeSelect.Options[themeSelect.SelectedIndex()]
themeNames := []string{"Default"}
themeFileNames := []string{""}
i, selIndex := 1, 0
for filename, displayname := range s.themeFiles {
themeFileNames = append(themeFileNames, filename)
themeNames = append(themeNames, displayname)
if strings.EqualFold(filename, s.config.Theme.ThemeFile) {
selIndex = i
}
i++
}
themeFileSelect := widget.NewSelect(themeNames, nil)
themeFileSelect.SetSelectedIndex(selIndex)
themeFileSelect.OnChanged = func(_ string) {
s.config.Theme.ThemeFile = themeFileNames[themeFileSelect.SelectedIndex()]
if s.OnThemeSettingChanged != nil {
s.OnThemeSettingChanged()
}
}
themeSelect.SetSelected(s.config.Theme.Appearance)
if themeSelect.Selected == "" {
themeSelect.SetSelectedIndex(0)
themeModeSelect := widget.NewSelect([]string{
string(myTheme.AppearanceDark),
string(myTheme.AppearanceLight),
string(myTheme.AppearanceAuto)}, nil)
themeModeSelect.OnChanged = func(_ string) {
s.config.Theme.Appearance = themeModeSelect.Options[themeModeSelect.SelectedIndex()]
if s.OnThemeSettingChanged != nil {
s.OnThemeSettingChanged()
}
}
themeModeSelect.SetSelected(s.config.Theme.Appearance)
if themeModeSelect.Selected == "" {
themeModeSelect.SetSelectedIndex(0)
}
startupPage := widget.NewSelect(backend.SupportedStartupPages, func(choice string) {
@@ -183,8 +204,11 @@ func (s *SettingsDialog) createGeneralTab() *container.TabItem {
scrobbleEnabled.Checked = s.config.Scrobbling.Enabled
return container.NewTabItem("General", container.NewVBox(
container.New(layout.NewFormLayout(),
widget.NewLabel("Appearance"), container.NewGridWithColumns(2, themeSelect),
container.NewBorder(nil, nil, widget.NewLabel("Theme"), /*left*/
container.NewHBox(widget.NewLabel("Mode"), themeModeSelect, util.NewHSpace(5)), // right
themeFileSelect, // center
),
container.NewHBox(
widget.NewLabel("Startup page"), container.NewGridWithColumns(2, startupPage),
),
container.NewHBox(systemTrayEnable, closeToTray),
+12 -11
View File
@@ -3,6 +3,7 @@ package ui
import (
"fmt"
"github.com/20after4/configdir"
"github.com/dweymouth/supersonic/backend"
"github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/res"
@@ -49,12 +50,12 @@ type MainWindow struct {
container *fyne.Container
}
func NewMainWindow(fyneApp fyne.App, appName, appVersion string, app *backend.App, size fyne.Size) MainWindow {
func NewMainWindow(fyneApp fyne.App, appName, displayAppName, appVersion string, app *backend.App, size fyne.Size) MainWindow {
m := MainWindow{
App: app,
Window: fyneApp.NewWindow(appName),
Window: fyneApp.NewWindow(displayAppName),
BrowsingPane: browsing.NewBrowsingPane(app),
theme: theme.NewMyTheme(&app.Config.Theme),
theme: theme.NewMyTheme(&app.Config.Theme, configdir.LocalConfig(appName, "themes")),
}
m.theme.NormalFont = app.Config.Application.FontNormalTTF
@@ -62,7 +63,7 @@ func NewMainWindow(fyneApp fyne.App, appName, appVersion string, app *backend.Ap
fyneApp.Settings().SetTheme(m.theme)
if app.Config.Application.EnableSystemTray {
m.SetupSystemTrayMenu(appName, fyneApp)
m.SetupSystemTrayMenu(displayAppName, fyneApp)
}
m.Controller = &controller.Controller{
AppVersion: appVersion,
@@ -82,10 +83,10 @@ func NewMainWindow(fyneApp fyne.App, appName, appVersion string, app *backend.Ap
m.Window.Resize(size)
app.PlaybackManager.OnSongChange(func(song, _ *mediaprovider.Track) {
if song == nil {
m.Window.SetTitle(appName)
m.Window.SetTitle(displayAppName)
return
}
m.Window.SetTitle(fmt.Sprintf("%s %s · %s", song.Name, song.ArtistNames[0], appName))
m.Window.SetTitle(fmt.Sprintf("%s %s · %s", song.Name, song.ArtistNames[0], displayAppName))
})
app.ServerManager.OnServerConnected(func() {
m.BrowsingPane.EnableNavigationButtons()
@@ -93,7 +94,7 @@ func NewMainWindow(fyneApp fyne.App, appName, appVersion string, app *backend.Ap
// check if found new version on startup
if t := app.UpdateChecker.VersionTagFound(); t != "" && t != app.Config.Application.LastCheckedVersion {
if t != app.VersionTag() {
m.ShowNewVersionDialog(appName, t)
m.ShowNewVersionDialog(displayAppName, t)
}
m.App.Config.Application.LastCheckedVersion = t
}
@@ -101,7 +102,7 @@ func NewMainWindow(fyneApp fyne.App, appName, appVersion string, app *backend.Ap
m.App.UpdateChecker.OnUpdatedVersionFound = func() {
t := m.App.UpdateChecker.VersionTagFound()
if t != app.VersionTag() {
m.ShowNewVersionDialog(appName, t)
m.ShowNewVersionDialog(displayAppName, t)
}
m.App.Config.Application.LastCheckedVersion = t
}
@@ -117,10 +118,10 @@ func NewMainWindow(fyneApp fyne.App, appName, appVersion string, app *backend.Ap
m.BrowsingPane.AddSettingsMenuItem("Check for Updates", func() {
go func() {
if t := app.UpdateChecker.CheckLatestVersionTag(); t != "" && t != app.VersionTag() {
m.ShowNewVersionDialog(appName, t)
m.ShowNewVersionDialog(displayAppName, t)
} else {
dialog.ShowInformation("No new version found",
"You are running the latest version of "+appName,
"You are running the latest version of "+displayAppName,
m.Window)
}
}()
@@ -128,7 +129,7 @@ func NewMainWindow(fyneApp fyne.App, appName, appVersion string, app *backend.Ap
m.BrowsingPane.AddSettingsMenuItem("Settings...", func() {
m.Controller.ShowSettingsDialog(func() {
fyneApp.Settings().SetTheme(m.theme)
})
}, m.theme.ListThemeFiles())
})
m.BrowsingPane.AddSettingsMenuItem("About...", m.Controller.ShowAboutDialog)
m.addNavigationButtons()
+103 -37
View File
@@ -1,10 +1,13 @@
package theme
import (
"bytes"
"errors"
"image/color"
"io/ioutil"
"log"
"path"
"path/filepath"
"strings"
"github.com/dweymouth/supersonic/backend"
@@ -15,7 +18,10 @@ import (
"fyne.io/fyne/v2/theme"
)
const ColorNamePageBackground fyne.ThemeColorName = "PageBackground"
const (
ColorNameListHeader fyne.ThemeColorName = "ListHeader"
ColorNamePageBackground fyne.ThemeColorName = "PageBackground"
)
type AppearanceMode string
@@ -33,59 +39,107 @@ var (
)
type MyTheme struct {
NormalFont string
BoldFont string
config *backend.ThemeConfig
NormalFont string
BoldFont string
config *backend.ThemeConfig
themeFileDir string
loadedThemeFilename string
loadedThemeFile *ThemeFile
defaultThemeFile *ThemeFile
}
var _ fyne.Theme = (*MyTheme)(nil)
func NewMyTheme(config *backend.ThemeConfig) *MyTheme {
m := &MyTheme{config: config}
func NewMyTheme(config *backend.ThemeConfig, themeFileDir string) *MyTheme {
m := &MyTheme{config: config, themeFileDir: themeFileDir}
m.defaultThemeFile, _ = DecodeThemeFile(bytes.NewReader(res.ResDefaultToml.StaticContent))
m.createThemeIcons()
return m
}
func (m *MyTheme) Color(name fyne.ThemeColorName, _ fyne.ThemeVariant) color.Color {
// load theme file if necessary
if m.loadedThemeFile == nil || m.config.ThemeFile != m.loadedThemeFilename {
t, err := ReadThemeFile(path.Join(m.themeFileDir, m.config.ThemeFile))
if err == nil {
m.loadedThemeFile = t
} else {
log.Printf("failed to load theme file %q: %s", m.config.ThemeFile, err.Error())
m.loadedThemeFile = m.defaultThemeFile
}
m.loadedThemeFilename = m.config.ThemeFile
}
variant := m.getVariant()
thFile := m.loadedThemeFile
if !thFile.SupportsVariant(variant) {
thFile = m.defaultThemeFile
}
colors := thFile.DarkColors
defColors := m.defaultThemeFile.DarkColors
if variant == theme.VariantLight {
colors = thFile.LightColors
defColors = m.defaultThemeFile.LightColors
}
switch name {
case ColorNameListHeader:
return colorOrDefault(colors.ListHeader, defColors.ListHeader, name, variant)
case ColorNamePageBackground:
if variant == theme.VariantDark {
return color.RGBA{R: 15, G: 15, B: 15, A: 255}
}
return color.RGBA{R: 250, G: 250, B: 250, A: 255}
return colorOrDefault(colors.PageBackground, defColors.PageBackground, name, variant)
case theme.ColorNameBackground:
if variant == theme.VariantDark {
return color.RGBA{R: 35, G: 35, B: 35, A: 255}
}
return color.RGBA{R: 225, G: 223, B: 225, A: 255}
case theme.ColorNameScrollBar:
if variant == theme.VariantDark {
return theme.DarkTheme().Color(theme.ColorNameForeground, variant)
}
return theme.LightTheme().Color(theme.ColorNameForeground, variant)
return colorOrDefault(colors.Background, defColors.Background, name, variant)
case theme.ColorNameButton:
if variant == theme.VariantDark {
return color.RGBA{R: 20, G: 20, B: 20, A: 50}
}
return color.RGBA{R: 200, G: 200, B: 200, A: 240}
return colorOrDefault(colors.Button, defColors.Button, name, variant)
case theme.ColorNameDisabled:
return colorOrDefault(colors.Disabled, defColors.Disabled, name, variant)
case theme.ColorNameDisabledButton:
if variant == theme.VariantLight {
return color.RGBA{R: 205, G: 205, B: 205, A: 240}
}
return theme.DefaultTheme().Color(theme.ColorNameDisabledButton, variant)
case theme.ColorNameInputBackground:
if variant == theme.VariantDark {
return color.RGBA{R: 20, G: 20, B: 20, A: 50}
}
return colorOrDefault(colors.DisabledButton, defColors.DisabledButton, name, variant)
case theme.ColorNameError:
return colorOrDefault(colors.Error, defColors.Error, name, variant)
case theme.ColorNameFocus:
return colorOrDefault(colors.Focus, defColors.Focus, name, variant)
case theme.ColorNameForeground:
if variant == theme.VariantLight {
return color.RGBA{R: 10, G: 10, B: 10, A: 255}
}
return colorOrDefault(colors.Foreground, defColors.Foreground, name, variant)
case theme.ColorNameHover:
return colorOrDefault(colors.Hover, defColors.Hover, name, variant)
case theme.ColorNameInputBackground:
return colorOrDefault(colors.InputBackground, defColors.InputBackground, name, variant)
case theme.ColorNameInputBorder:
return colorOrDefault(colors.InputBorder, defColors.InputBorder, name, variant)
case theme.ColorNameMenuBackground:
return colorOrDefault(colors.MenuBackground, defColors.MenuBackground, name, variant)
case theme.ColorNameOverlayBackground:
return colorOrDefault(colors.OverlayBackground, defColors.OverlayBackground, name, variant)
case theme.ColorNamePlaceHolder:
return colorOrDefault(colors.Placeholder, defColors.Placeholder, name, variant)
case theme.ColorNamePressed:
return colorOrDefault(colors.Pressed, defColors.Pressed, name, variant)
case theme.ColorNamePrimary:
if variant == theme.VariantLight {
return color.RGBA{R: 25, G: 25, B: 250, A: 255}
}
return colorOrDefault(colors.Primary, defColors.Primary, name, variant)
case theme.ColorNameScrollBar:
return colorOrDefault(colors.ScrollBar, defColors.ScrollBar, name, variant)
case theme.ColorNameSelection:
return colorOrDefault(colors.Selection, defColors.Selection, name, variant)
case theme.ColorNameSeparator:
return colorOrDefault(colors.Separator, defColors.Separator, name, variant)
case theme.ColorNameShadow:
return colorOrDefault(colors.Shadow, defColors.Shadow, name, variant)
case theme.ColorNameSuccess:
return colorOrDefault(colors.Success, defColors.Success, name, variant)
case theme.ColorNameWarning:
return colorOrDefault(colors.Warning, defColors.Warning, name, variant)
default:
return colorOrDefault("", "", name, variant)
}
}
func colorOrDefault(colorStr, defColorStr string, name fyne.ThemeColorName, variant fyne.ThemeVariant) color.Color {
if c, err := ColorStringToColor(colorStr); err == nil {
return c
}
if c, err := ColorStringToColor(defColorStr); err == nil {
return c
}
return theme.DefaultTheme().Color(name, variant)
}
@@ -94,6 +148,18 @@ func (m *MyTheme) Icon(name fyne.ThemeIconName) fyne.Resource {
return theme.DefaultTheme().Icon(name)
}
// Returns a map [themeFileName] -> displayName
func (m *MyTheme) ListThemeFiles() map[string]string {
files, _ := filepath.Glob(m.themeFileDir + "/*.toml")
result := make(map[string]string)
for _, filepath := range files {
if themeFile, err := ReadThemeFile(filepath); err == nil {
result[path.Base(filepath)] = themeFile.SupersonicTheme.Name
}
}
return result
}
type myThemedResource struct {
myTheme MyTheme
darkVariant *fyne.StaticResource
+131
View File
@@ -0,0 +1,131 @@
package theme
import (
"encoding/hex"
"errors"
"fmt"
"image/color"
"io"
"os"
"strings"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/theme"
"github.com/dweymouth/supersonic/sharedutil"
"github.com/pelletier/go-toml/v2"
)
type ThemeFileHeader struct {
Name string
Version string
SupportsDark bool
SupportsLight bool
}
type ThemeFile struct {
SupersonicTheme ThemeFileHeader
DarkColors ThemeColors
LightColors ThemeColors
}
type ThemeColors struct {
// Supersonic-specific colors
ListHeader string
PageBackground string
// Fyne colors
Background string
Button string
DisabledButton string
Disabled string
Error string
Focus string
Foreground string
Hover string
InputBackground string
InputBorder string
MenuBackground string
OverlayBackground string
Placeholder string
Pressed string
Primary string
ScrollBar string
Selection string
Separator string
Shadow string
Success string
Warning string
}
func ReadThemeFile(filePath string) (*ThemeFile, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer f.Close()
return DecodeThemeFile(f)
}
func DecodeThemeFile(reader io.Reader) (*ThemeFile, error) {
theme := &ThemeFile{}
if err := toml.NewDecoder(reader).Decode(theme); err != nil {
return nil, err
}
if theme.SupersonicTheme.Name == "" || theme.SupersonicTheme.Version != "0.1" {
return nil, errors.New("invalid theme file name or version")
}
if !(theme.SupersonicTheme.SupportsDark || theme.SupersonicTheme.SupportsLight) {
return nil, errors.New("invalid theme file: must support one or both of light/dark")
}
return theme, nil
}
func (t *ThemeFile) SupportsVariant(v fyne.ThemeVariant) bool {
if v == theme.VariantDark {
return t.SupersonicTheme.SupportsDark
}
return t.SupersonicTheme.SupportsLight
}
// Parses a CSS-style #RRGGBB or #RRGGBBAA string
func ColorStringToColor(colorStr string) (color.Color, error) {
if !strings.HasPrefix(colorStr, "#") || !sharedutil.SliceContains([]int{7, 9}, len(colorStr)) {
return color.Black, errors.New("invalid color string")
}
colorBytes := make([]byte, 4)
n, err := hex.Decode(colorBytes, []byte(colorStr[1:]))
if err != nil {
return color.Black, fmt.Errorf("invalid color string: %s", err.Error())
}
if n == 3 {
colorBytes[3] = 255 // opaque alpha
}
return color.RGBA{R: colorBytes[0], G: colorBytes[1], B: colorBytes[2], A: colorBytes[3]}, nil
}
+1 -1
View File
@@ -62,7 +62,7 @@ func NewListHeader(cols []ListColumn, layout *layouts.ColumnsLayout) *ListHeader
for i := range l.columnVisible {
l.columnVisible[i] = true
}
l.container = container.NewMax(myTheme.NewThemedRectangle(theme.ColorNameBackground), l.columnsContainer)
l.container = container.NewMax(myTheme.NewThemedRectangle(myTheme.ColorNameListHeader), l.columnsContainer)
l.ExtendBaseWidget(l)
l.buildColumns()
return l