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:
mixeme
2026-07-26 10:12:28 +03:00
parent eedae3b9f0
commit 84212764e4
8 changed files with 257 additions and 1 deletions
+5
View File
@@ -396,5 +396,10 @@ func validateConfig(config domain.Config) error {
if config.DefaultTimeoutSeconds <= 0 { if config.DefaultTimeoutSeconds <= 0 {
return errors.New("default timeout must be a positive number of seconds") 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 return nil
} }
+16
View File
@@ -15,6 +15,19 @@ const (
ExecutionModeSequential ExecutionMode = "sequential" 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 // OverlapPolicy decides what happens when a job's next run fires while the
// previous run is still active. // previous run is still active.
type OverlapPolicy string type OverlapPolicy string
@@ -44,6 +57,9 @@ type Config struct {
// their own Job.TimeoutSeconds. It carries the formerly hard-coded 30s guard. // their own Job.TimeoutSeconds. It carries the formerly hard-coded 30s guard.
DefaultTimeoutSeconds int `json:"default_timeout_seconds,omitempty"` DefaultTimeoutSeconds int `json:"default_timeout_seconds,omitempty"`
Paused bool `json:"paused,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 // JobsFile is the on-disk shape of jobs.json. Wrapping the slice in a top-level
+4
View File
@@ -78,6 +78,7 @@ func loadOrCreateConfig(paths Paths) (domain.Config, error) {
NotifyOnFailure: true, NotifyOnFailure: true,
ExecutionMode: domain.ExecutionModeParallel, ExecutionMode: domain.ExecutionModeParallel,
OverlapPolicy: domain.OverlapPolicySkip, OverlapPolicy: domain.OverlapPolicySkip,
Theme: domain.ThemeDefault,
DefaultTimeoutSeconds: 30, DefaultTimeoutSeconds: 30,
} }
@@ -117,6 +118,9 @@ func loadOrCreateConfig(paths Paths) (domain.Config, error) {
if config.DefaultTimeoutSeconds <= 0 { if config.DefaultTimeoutSeconds <= 0 {
config.DefaultTimeoutSeconds = 30 config.DefaultTimeoutSeconds = 30
} }
if config.Theme == "" {
config.Theme = domain.ThemeDefault
}
return config, nil return config, nil
} }
+3
View File
@@ -171,6 +171,9 @@ func TestLoadOrCreateConfigCreatesDefaultsOnFirstRun(t *testing.T) {
if got.DefaultTimeoutSeconds != 30 { if got.DefaultTimeoutSeconds != 30 {
t.Errorf("default DefaultTimeoutSeconds = %d, want 30", got.DefaultTimeoutSeconds) 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. // The function must have written the defaults to gosentry.json.
if _, err := os.Stat(paths.ConfigPath); err != nil { if _, err := os.Stat(paths.ConfigPath); err != nil {
t.Errorf("gosentry.json should have been created: %v", err) t.Errorf("gosentry.json should have been created: %v", err)
+3
View File
@@ -60,6 +60,9 @@ func Run(startInTray bool) {
a.Run() a.Run()
return 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) content, recordStartup := newMainView(w, svc)
w.SetContent(content) w.SetContent(content)
serveSingleInstance(instanceListener, w) serveSingleInstance(instanceListener, w)
+35 -1
View File
@@ -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 := widget.NewCheck("Show desktop notifications for failed jobs", nil)
notifications.SetChecked(store.Config.NotifyOnFailure) notifications.SetChecked(store.Config.NotifyOnFailure)
notifications.OnChanged = func(bool) { updateSaveState() } 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( executionModeSelect := widget.NewSelect(
[]string{string(domain.ExecutionModeParallel), string(domain.ExecutionModeSequential)}, []string{string(domain.ExecutionModeParallel), string(domain.ExecutionModeSequential)},
nil, nil,
@@ -138,6 +147,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
config.ExecutionMode = domain.ExecutionMode(executionModeSelect.Selected) config.ExecutionMode = domain.ExecutionMode(executionModeSelect.Selected)
config.OverlapPolicy = domain.OverlapPolicy(overlapPolicySelect.Selected) config.OverlapPolicy = domain.OverlapPolicy(overlapPolicySelect.Selected)
config.DefaultTimeoutSeconds = timeout config.DefaultTimeoutSeconds = timeout
config.Theme = themeFromLabel(themeSelect.Selected)
if err := svc.UpdateSettings(config); err != nil { if err := svc.UpdateSettings(config); err != nil {
settingsStatus.SetText("Save failed: " + err.Error()) settingsStatus.SetText("Save failed: " + err.Error())
return return
@@ -168,7 +178,8 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
strings.TrimSpace(jobsDir.Text) != c.JobsDir || strings.TrimSpace(jobsDir.Text) != c.JobsDir ||
strings.TrimSpace(logsDir.Text) != c.LogsDir || strings.TrimSpace(logsDir.Text) != c.LogsDir ||
strings.TrimSpace(maxLogFiles.Text) != strconv.Itoa(c.MaxLogFiles) || 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 { if changed {
saveSettings.Enable() saveSettings.Enable()
} else { } else {
@@ -189,6 +200,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
settingsRow("", autostartStatus), settingsRow("", autostartStatus),
settingsRow("Tray", container.New(minWidthLayout{width: settingsControlWidth}, minimizeToTray)), settingsRow("Tray", container.New(minWidthLayout{width: settingsControlWidth}, minimizeToTray)),
settingsRow("Notifications", container.New(minWidthLayout{width: settingsControlWidth}, notifications)), settingsRow("Notifications", container.New(minWidthLayout{width: settingsControlWidth}, notifications)),
settingsRow("Theme", container.New(minWidthLayout{width: settingsControlWidth}, themeSelect)),
), ),
widget.NewSeparator(), widget.NewSeparator(),
// Queue holds the execution mode and overlap policy comboboxes. Like // Queue holds the execution mode and overlap policy comboboxes. Like
@@ -298,6 +310,28 @@ func chooseFolder(w fyne.Window, target *widget.Entry) {
folderDialog.Show() 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 { func settingsRow(label string, value fyne.CanvasObject) fyne.CanvasObject {
caption := widget.NewLabelWithStyle(label, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) caption := widget.NewLabelWithStyle(label, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
caption.Wrapping = fyne.TextTruncate caption.Wrapping = fyne.TextTruncate
+108
View File
@@ -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))
}
+83
View File
@@ -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)
}
}