diff --git a/backend/config.go b/backend/config.go index 891a3fe..c58a7bd 100644 --- a/backend/config.go +++ b/backend/config.go @@ -89,6 +89,7 @@ type ReplayGainConfig struct { } type ThemeConfig struct { + ThemeFile string Appearance string } diff --git a/main.go b/main.go index 63dfbe3..d2556d1 100644 --- a/main.go +++ b/main.go @@ -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() { diff --git a/res/bundled.go b/res/bundled.go index e7f4497..b55797c 100644 --- a/res/bundled.go +++ b/res/bundled.go @@ -140,6 +140,11 @@ var ResFilterSvg = &fyne.StaticResource{ StaticContent: []byte( "\r\n\r\n\t\r\n\r\n\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( diff --git a/res/bundled_gen.sh b/res/bundled_gen.sh index cc2ee27..91d8f88 100755 --- a/res/bundled_gen.sh +++ b/res/bundled_gen.sh @@ -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 diff --git a/res/themes/default.toml b/res/themes/default.toml new file mode 100644 index 0000000..a7c0dc7 --- /dev/null +++ b/res/themes/default.toml @@ -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" \ No newline at end of file diff --git a/ui/controller/controller.go b/ui/controller/controller.go index 951f7ca..200de35 100644 --- a/ui/controller/controller.go +++ b/ui/controller/controller.go @@ -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) } diff --git a/ui/dialogs/settingsdialog.go b/ui/dialogs/settingsdialog.go index 0e8dbc2..b1020a4 100644 --- a/ui/dialogs/settingsdialog.go +++ b/ui/dialogs/settingsdialog.go @@ -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), diff --git a/ui/mainwindow.go b/ui/mainwindow.go index 1522215..be0d54e 100644 --- a/ui/mainwindow.go +++ b/ui/mainwindow.go @@ -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() diff --git a/ui/theme/theme.go b/ui/theme/theme.go index c739156..8b2d484 100644 --- a/ui/theme/theme.go +++ b/ui/theme/theme.go @@ -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 diff --git a/ui/theme/themefile.go b/ui/theme/themefile.go new file mode 100644 index 0000000..6cf2112 --- /dev/null +++ b/ui/theme/themefile.go @@ -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 +} diff --git a/ui/widgets/listheader.go b/ui/widgets/listheader.go index 7f3d418..bbd7551 100644 --- a/ui/widgets/listheader.go +++ b/ui/widgets/listheader.go @@ -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