feat: selectable branded GoSentry color theme
Add a custom Fyne theme derived from the logo and app icon (deep teal
primary, amber accent, branded job-status colors) with light and dark
variants, and let the user choose between it and Fyne's default theme
from Settings. The dark variant uses deep-teal surfaces to echo the app
icon.
The choice is persisted as a new Config.Theme field ("default" /
"gosentry"), applied at startup before the first frame and live-previewed
when picked in Settings. Empty/legacy configs normalize to the default
theme so existing installs keep the original look.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -396,5 +396,10 @@ func validateConfig(config domain.Config) error {
|
||||
if config.DefaultTimeoutSeconds <= 0 {
|
||||
return errors.New("default timeout must be a positive number of seconds")
|
||||
}
|
||||
// Empty Theme is accepted and normalized to the default on load, so older
|
||||
// configs (and hand-built ones) stay valid without an explicit theme.
|
||||
if config.Theme != "" && config.Theme != domain.ThemeDefault && config.Theme != domain.ThemeGoSentry {
|
||||
return errors.New("theme must be 'default' or 'gosentry'")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -15,6 +15,19 @@ const (
|
||||
ExecutionModeSequential ExecutionMode = "sequential"
|
||||
)
|
||||
|
||||
// Theme selects the application's visual appearance. It is a UI-only choice with
|
||||
// no effect on scheduling; it is stored in Config so it persists across launches
|
||||
// alongside the other desktop-shell preferences.
|
||||
type Theme string
|
||||
|
||||
const (
|
||||
// ThemeDefault keeps Fyne's built-in theme — the original look.
|
||||
ThemeDefault Theme = "default"
|
||||
// ThemeGoSentry applies the branded teal/amber theme derived from the logo
|
||||
// and app icon.
|
||||
ThemeGoSentry Theme = "gosentry"
|
||||
)
|
||||
|
||||
// OverlapPolicy decides what happens when a job's next run fires while the
|
||||
// previous run is still active.
|
||||
type OverlapPolicy string
|
||||
@@ -44,6 +57,9 @@ type Config struct {
|
||||
// their own Job.TimeoutSeconds. It carries the formerly hard-coded 30s guard.
|
||||
DefaultTimeoutSeconds int `json:"default_timeout_seconds,omitempty"`
|
||||
Paused bool `json:"paused,omitempty"`
|
||||
// Theme selects the visual appearance. Empty is treated as ThemeDefault so
|
||||
// configs written before this field existed keep the original look.
|
||||
Theme Theme `json:"theme,omitempty"`
|
||||
}
|
||||
|
||||
// JobsFile is the on-disk shape of jobs.json. Wrapping the slice in a top-level
|
||||
|
||||
@@ -78,6 +78,7 @@ func loadOrCreateConfig(paths Paths) (domain.Config, error) {
|
||||
NotifyOnFailure: true,
|
||||
ExecutionMode: domain.ExecutionModeParallel,
|
||||
OverlapPolicy: domain.OverlapPolicySkip,
|
||||
Theme: domain.ThemeDefault,
|
||||
|
||||
DefaultTimeoutSeconds: 30,
|
||||
}
|
||||
@@ -117,6 +118,9 @@ func loadOrCreateConfig(paths Paths) (domain.Config, error) {
|
||||
if config.DefaultTimeoutSeconds <= 0 {
|
||||
config.DefaultTimeoutSeconds = 30
|
||||
}
|
||||
if config.Theme == "" {
|
||||
config.Theme = domain.ThemeDefault
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -171,6 +171,9 @@ func TestLoadOrCreateConfigCreatesDefaultsOnFirstRun(t *testing.T) {
|
||||
if got.DefaultTimeoutSeconds != 30 {
|
||||
t.Errorf("default DefaultTimeoutSeconds = %d, want 30", got.DefaultTimeoutSeconds)
|
||||
}
|
||||
if got.Theme != domain.ThemeDefault {
|
||||
t.Errorf("default Theme = %q, want %q", got.Theme, domain.ThemeDefault)
|
||||
}
|
||||
// The function must have written the defaults to gosentry.json.
|
||||
if _, err := os.Stat(paths.ConfigPath); err != nil {
|
||||
t.Errorf("gosentry.json should have been created: %v", err)
|
||||
|
||||
@@ -60,6 +60,9 @@ func Run(startInTray bool) {
|
||||
a.Run()
|
||||
return
|
||||
}
|
||||
// Apply the persisted theme before building content so the window renders in
|
||||
// the chosen theme from the first frame rather than flashing the default one.
|
||||
applyTheme(a, svc.Store().Config.Theme)
|
||||
content, recordStartup := newMainView(w, svc)
|
||||
w.SetContent(content)
|
||||
serveSingleInstance(instanceListener, w)
|
||||
|
||||
+35
-1
@@ -61,6 +61,15 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
||||
notifications := widget.NewCheck("Show desktop notifications for failed jobs", nil)
|
||||
notifications.SetChecked(store.Config.NotifyOnFailure)
|
||||
notifications.OnChanged = func(bool) { updateSaveState() }
|
||||
themeSelect := widget.NewSelect([]string{themeLabelDefault, themeLabelGoSentry}, nil)
|
||||
themeSelect.SetSelected(themeLabel(store.Config.Theme))
|
||||
// Preview the theme the moment it is picked so the choice is visible before
|
||||
// saving; Save persists it. Reverting the selection reverts the preview, and
|
||||
// closing without saving falls back to the stored theme on next launch.
|
||||
themeSelect.OnChanged = func(string) {
|
||||
applyTheme(fyne.CurrentApp(), themeFromLabel(themeSelect.Selected))
|
||||
updateSaveState()
|
||||
}
|
||||
executionModeSelect := widget.NewSelect(
|
||||
[]string{string(domain.ExecutionModeParallel), string(domain.ExecutionModeSequential)},
|
||||
nil,
|
||||
@@ -138,6 +147,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
||||
config.ExecutionMode = domain.ExecutionMode(executionModeSelect.Selected)
|
||||
config.OverlapPolicy = domain.OverlapPolicy(overlapPolicySelect.Selected)
|
||||
config.DefaultTimeoutSeconds = timeout
|
||||
config.Theme = themeFromLabel(themeSelect.Selected)
|
||||
if err := svc.UpdateSettings(config); err != nil {
|
||||
settingsStatus.SetText("Save failed: " + err.Error())
|
||||
return
|
||||
@@ -168,7 +178,8 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
||||
strings.TrimSpace(jobsDir.Text) != c.JobsDir ||
|
||||
strings.TrimSpace(logsDir.Text) != c.LogsDir ||
|
||||
strings.TrimSpace(maxLogFiles.Text) != strconv.Itoa(c.MaxLogFiles) ||
|
||||
strings.TrimSpace(maxLogAgeDays.Text) != strconv.Itoa(c.MaxLogAgeDays)
|
||||
strings.TrimSpace(maxLogAgeDays.Text) != strconv.Itoa(c.MaxLogAgeDays) ||
|
||||
themeSelect.Selected != themeLabel(c.Theme)
|
||||
if changed {
|
||||
saveSettings.Enable()
|
||||
} else {
|
||||
@@ -189,6 +200,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
||||
settingsRow("", autostartStatus),
|
||||
settingsRow("Tray", container.New(minWidthLayout{width: settingsControlWidth}, minimizeToTray)),
|
||||
settingsRow("Notifications", container.New(minWidthLayout{width: settingsControlWidth}, notifications)),
|
||||
settingsRow("Theme", container.New(minWidthLayout{width: settingsControlWidth}, themeSelect)),
|
||||
),
|
||||
widget.NewSeparator(),
|
||||
// Queue holds the execution mode and overlap policy comboboxes. Like
|
||||
@@ -298,6 +310,28 @@ func chooseFolder(w fyne.Window, target *widget.Entry) {
|
||||
folderDialog.Show()
|
||||
}
|
||||
|
||||
// Theme dropdown labels. These are the human-facing captions; themeLabel and
|
||||
// themeFromLabel translate between them and the stored domain.Theme values so the
|
||||
// select never leaks the on-disk "default"/"gosentry" strings to the user.
|
||||
const (
|
||||
themeLabelDefault = "Default"
|
||||
themeLabelGoSentry = "GoSentry"
|
||||
)
|
||||
|
||||
func themeLabel(choice domain.Theme) string {
|
||||
if choice == domain.ThemeGoSentry {
|
||||
return themeLabelGoSentry
|
||||
}
|
||||
return themeLabelDefault
|
||||
}
|
||||
|
||||
func themeFromLabel(label string) domain.Theme {
|
||||
if label == themeLabelGoSentry {
|
||||
return domain.ThemeGoSentry
|
||||
}
|
||||
return domain.ThemeDefault
|
||||
}
|
||||
|
||||
func settingsRow(label string, value fyne.CanvasObject) fyne.CanvasObject {
|
||||
caption := widget.NewLabelWithStyle(label, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
|
||||
caption.Wrapping = fyne.TextTruncate
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
)
|
||||
|
||||
// The GoSentry theme derives its palette from the logo and app icon, which use
|
||||
// exactly two brand colors on white: deep teal (the wordmark and icon tile) and
|
||||
// amber (the "G" gauge and the terminal prompt). Everything below extends those
|
||||
// two into a working UI palette:
|
||||
// - teal is the primary color (buttons, selection, the active tab indicator),
|
||||
// - amber is the focus accent,
|
||||
// - success/warning/error carry the job run states a scheduler needs.
|
||||
//
|
||||
// The dark variant leans into the app icon: deep teal surfaces so the window
|
||||
// reads as the icon "come to life", with a lifted teal primary and a brightened
|
||||
// error red so both stay legible against the dark teal.
|
||||
var (
|
||||
brandTeal = color.NRGBA{R: 0x0A, G: 0x4A, B: 0x58, A: 0xFF} // wordmark + icon tile
|
||||
brandTealMid = color.NRGBA{R: 0x0F, G: 0x6E, B: 0x82, A: 0xFF} // link on light
|
||||
brandTealLight = color.NRGBA{R: 0x3D, G: 0x97, B: 0xA9, A: 0xFF} // primary on dark
|
||||
brandAmber = color.NRGBA{R: 0xF7, G: 0xA8, B: 0x0C, A: 0xFF} // the "G" + prompt
|
||||
)
|
||||
|
||||
// gosentryLight and gosentryDark hold only the colors the brand theme overrides;
|
||||
// every other ColorName falls through to the base theme, which keeps neutral
|
||||
// surfaces and text contrast correct in both variants.
|
||||
var gosentryLight = map[fyne.ThemeColorName]color.Color{
|
||||
theme.ColorNamePrimary: brandTeal,
|
||||
theme.ColorNameFocus: brandAmber,
|
||||
theme.ColorNameHyperlink: brandTealMid,
|
||||
theme.ColorNameSuccess: color.NRGBA{R: 0x2E, G: 0x9E, B: 0x5B, A: 0xFF},
|
||||
theme.ColorNameWarning: brandAmber,
|
||||
theme.ColorNameError: color.NRGBA{R: 0xD6, G: 0x45, B: 0x45, A: 0xFF},
|
||||
theme.ColorNameBackground: color.NRGBA{R: 0xF5, G: 0xF7, B: 0xF8, A: 0xFF},
|
||||
theme.ColorNameSelection: color.NRGBA{R: 0x0A, G: 0x4A, B: 0x58, A: 0x33},
|
||||
theme.ColorNameHover: color.NRGBA{R: 0x0A, G: 0x4A, B: 0x58, A: 0x14},
|
||||
}
|
||||
|
||||
var gosentryDark = map[fyne.ThemeColorName]color.Color{
|
||||
theme.ColorNamePrimary: brandTealLight,
|
||||
theme.ColorNameFocus: brandAmber,
|
||||
theme.ColorNameHyperlink: color.NRGBA{R: 0x6B, G: 0xB8, B: 0xCA, A: 0xFF},
|
||||
theme.ColorNameSuccess: color.NRGBA{R: 0x46, G: 0xB8, B: 0x7A, A: 0xFF},
|
||||
theme.ColorNameWarning: brandAmber,
|
||||
theme.ColorNameError: color.NRGBA{R: 0xF2, G: 0x6D, B: 0x6D, A: 0xFF},
|
||||
theme.ColorNameForeground: color.NRGBA{R: 0xEA, G: 0xF2, B: 0xF4, A: 0xFF},
|
||||
theme.ColorNamePlaceHolder: color.NRGBA{R: 0x9B, G: 0xB4, B: 0xBC, A: 0xFF},
|
||||
theme.ColorNameBackground: color.NRGBA{R: 0x0B, G: 0x20, B: 0x27, A: 0xFF},
|
||||
theme.ColorNameButton: color.NRGBA{R: 0x14, G: 0x3A, B: 0x45, A: 0xFF},
|
||||
theme.ColorNameInputBackground: color.NRGBA{R: 0x0F, G: 0x2E, B: 0x37, A: 0xFF},
|
||||
theme.ColorNameMenuBackground: color.NRGBA{R: 0x0F, G: 0x2E, B: 0x37, A: 0xFF},
|
||||
theme.ColorNameOverlayBackground: color.NRGBA{R: 0x0F, G: 0x2E, B: 0x37, A: 0xFF},
|
||||
theme.ColorNameHeaderBackground: color.NRGBA{R: 0x0B, G: 0x20, B: 0x27, A: 0xFF},
|
||||
theme.ColorNameSeparator: color.NRGBA{R: 0x20, G: 0x50, B: 0x5C, A: 0xFF},
|
||||
theme.ColorNameInputBorder: color.NRGBA{R: 0x20, G: 0x50, B: 0x5C, A: 0xFF},
|
||||
theme.ColorNameSelection: color.NRGBA{R: 0x3D, G: 0x97, B: 0xA9, A: 0x55},
|
||||
theme.ColorNameHover: color.NRGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0x14},
|
||||
}
|
||||
|
||||
// gosentryTheme wraps the default theme, overriding only brand colors and
|
||||
// delegating fonts, icons, sizes, and unbranded colors to it. Embedding the base
|
||||
// keeps the theme robust against Fyne adding new ColorNames — anything not in the
|
||||
// override maps still resolves to a sensible default.
|
||||
type gosentryTheme struct {
|
||||
base fyne.Theme
|
||||
}
|
||||
|
||||
func newGoSentryTheme() fyne.Theme {
|
||||
return gosentryTheme{base: theme.DefaultTheme()}
|
||||
}
|
||||
|
||||
func (t gosentryTheme) Color(name fyne.ThemeColorName, variant fyne.ThemeVariant) color.Color {
|
||||
overrides := gosentryLight
|
||||
if variant == theme.VariantDark {
|
||||
overrides = gosentryDark
|
||||
}
|
||||
if c, ok := overrides[name]; ok {
|
||||
return c
|
||||
}
|
||||
return t.base.Color(name, variant)
|
||||
}
|
||||
|
||||
func (t gosentryTheme) Font(style fyne.TextStyle) fyne.Resource { return t.base.Font(style) }
|
||||
func (t gosentryTheme) Icon(name fyne.ThemeIconName) fyne.Resource { return t.base.Icon(name) }
|
||||
func (t gosentryTheme) Size(name fyne.ThemeSizeName) float32 { return t.base.Size(name) }
|
||||
|
||||
// themeFor maps a stored Theme choice to a concrete fyne.Theme. Anything other
|
||||
// than the explicit GoSentry choice (including the empty/legacy value) keeps
|
||||
// Fyne's built-in theme.
|
||||
func themeFor(choice domain.Theme) fyne.Theme {
|
||||
if choice == domain.ThemeGoSentry {
|
||||
return newGoSentryTheme()
|
||||
}
|
||||
return theme.DefaultTheme()
|
||||
}
|
||||
|
||||
// applyTheme installs the theme for the given choice on the running app. Fyne
|
||||
// refreshes every canvas when the theme changes, so this works both at startup
|
||||
// and when the user switches themes from Settings.
|
||||
func applyTheme(a fyne.App, choice domain.Theme) {
|
||||
a.Settings().SetTheme(themeFor(choice))
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"testing"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
)
|
||||
|
||||
// The GoSentry theme must expose the brand colors on the semantically correct
|
||||
// ColorNames in each variant. These are the touchpoints a user actually sees —
|
||||
// the primary color drives buttons and the active tab, focus drives the accent —
|
||||
// so they are worth pinning against accidental edits to the palette maps.
|
||||
func TestGoSentryThemeBrandColors(t *testing.T) {
|
||||
th := newGoSentryTheme()
|
||||
cases := []struct {
|
||||
name string
|
||||
color fyne.ThemeColorName
|
||||
variant int
|
||||
want color.Color
|
||||
}{
|
||||
{"light primary is teal", theme.ColorNamePrimary, 0, brandTeal},
|
||||
{"light focus is amber", theme.ColorNameFocus, 0, brandAmber},
|
||||
{"dark primary is lifted teal", theme.ColorNamePrimary, 1, brandTealLight},
|
||||
{"dark focus is amber", theme.ColorNameFocus, 1, brandAmber},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
variant := theme.VariantLight
|
||||
if tc.variant == 1 {
|
||||
variant = theme.VariantDark
|
||||
}
|
||||
got := th.Color(tc.color, variant)
|
||||
if got != tc.want {
|
||||
t.Errorf("%s: Color(%s) = %v, want %v", tc.name, tc.color, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unbranded color names must fall through to the base theme rather than render as
|
||||
// zero-value (transparent) colors, so the theme only recolors what it intends to.
|
||||
func TestGoSentryThemeDelegatesUnbrandedColors(t *testing.T) {
|
||||
th := newGoSentryTheme()
|
||||
base := theme.DefaultTheme()
|
||||
// ScrollBar is not in either override map, so it must match the base theme.
|
||||
got := th.Color(theme.ColorNameScrollBar, theme.VariantDark)
|
||||
want := base.Color(theme.ColorNameScrollBar, theme.VariantDark)
|
||||
if got != want {
|
||||
t.Errorf("unbranded ColorNameScrollBar = %v, want base %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// themeFor maps the stored choice to the right theme: the GoSentry choice yields
|
||||
// the branded teal primary; every other value (including the empty legacy value)
|
||||
// yields the default theme, whose primary is not the brand teal.
|
||||
func TestThemeForChoice(t *testing.T) {
|
||||
gosentry := themeFor(domain.ThemeGoSentry)
|
||||
if got := gosentry.Color(theme.ColorNamePrimary, theme.VariantLight); got != brandTeal {
|
||||
t.Errorf("themeFor(gosentry) primary = %v, want brand teal %v", got, brandTeal)
|
||||
}
|
||||
for _, choice := range []domain.Theme{domain.ThemeDefault, ""} {
|
||||
def := themeFor(choice)
|
||||
if got := def.Color(theme.ColorNamePrimary, theme.VariantLight); got == brandTeal {
|
||||
t.Errorf("themeFor(%q) should not use the brand teal primary", choice)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The dropdown label helpers must round-trip, and the empty/legacy value must map
|
||||
// to the Default label so the select never shows a blank option.
|
||||
func TestThemeLabelRoundTrip(t *testing.T) {
|
||||
if got := themeFromLabel(themeLabel(domain.ThemeGoSentry)); got != domain.ThemeGoSentry {
|
||||
t.Errorf("round-trip gosentry = %q", got)
|
||||
}
|
||||
if got := themeFromLabel(themeLabel(domain.ThemeDefault)); got != domain.ThemeDefault {
|
||||
t.Errorf("round-trip default = %q", got)
|
||||
}
|
||||
if got := themeLabel(""); got != themeLabelDefault {
|
||||
t.Errorf("empty theme label = %q, want %q", got, themeLabelDefault)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user