From 84212764e4f769a7f6f87d0b882026592db29801 Mon Sep 17 00:00:00 2001 From: mixeme Date: Sun, 26 Jul 2026 10:12:28 +0300 Subject: [PATCH] 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 --- src/app/operations.go | 5 ++ src/domain/config.go | 16 ++++++ src/storage/store.go | 4 ++ src/storage/store_test.go | 3 ++ src/ui/run.go | 3 ++ src/ui/settings_view.go | 36 ++++++++++++- src/ui/theme.go | 108 ++++++++++++++++++++++++++++++++++++++ src/ui/theme_test.go | 83 +++++++++++++++++++++++++++++ 8 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 src/ui/theme.go create mode 100644 src/ui/theme_test.go diff --git a/src/app/operations.go b/src/app/operations.go index de5ad2a..f2193df 100644 --- a/src/app/operations.go +++ b/src/app/operations.go @@ -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 } diff --git a/src/domain/config.go b/src/domain/config.go index e13da31..6c76356 100644 --- a/src/domain/config.go +++ b/src/domain/config.go @@ -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 diff --git a/src/storage/store.go b/src/storage/store.go index 4878c1d..4509a68 100644 --- a/src/storage/store.go +++ b/src/storage/store.go @@ -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 } diff --git a/src/storage/store_test.go b/src/storage/store_test.go index a75db6d..a95f2a1 100644 --- a/src/storage/store_test.go +++ b/src/storage/store_test.go @@ -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) diff --git a/src/ui/run.go b/src/ui/run.go index dcf898e..40c36ec 100644 --- a/src/ui/run.go +++ b/src/ui/run.go @@ -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) diff --git a/src/ui/settings_view.go b/src/ui/settings_view.go index 1edc80b..7dc6a02 100644 --- a/src/ui/settings_view.go +++ b/src/ui/settings_view.go @@ -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 diff --git a/src/ui/theme.go b/src/ui/theme.go new file mode 100644 index 0000000..94e9ada --- /dev/null +++ b/src/ui/theme.go @@ -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)) +} diff --git a/src/ui/theme_test.go b/src/ui/theme_test.go new file mode 100644 index 0000000..da118e4 --- /dev/null +++ b/src/ui/theme_test.go @@ -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) + } +}