Compare commits

...

4 Commits

Author SHA1 Message Date
mixeme 3992b40eda docs: changelog and version bump for 0.13.0
Documents the branded GoSentry theme and Settings Cancel/Defaults buttons.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 11:26:10 +03:00
mixeme 2c684532da feat: make GoSentry light theme boldly branded
Give the light variant a soft teal window canvas with white inputs,
menus, dialogs, and buttons on top, plus teal-tinted separators, input
borders, and table headers. Cards and fields now lift off a branded
background instead of reading as a plain accent swap on gray. Text stays
dark (delegated to the base foreground) for high contrast on both the
teal canvas and the white surfaces.

Lock the teal canvas and white input background into the theme tests so
the light look cannot silently regress.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 11:26:10 +03:00
mixeme d3e3d30b42 feat: add Cancel and Defaults buttons to Settings
Cancel discards unsaved edits by reloading the saved config; Defaults
loads built-in default values into the form for review before saving.
Extracts the default config into domain.DefaultConfig() so the store
and settings UI share one source of truth.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 11:26:10 +03:00
mixeme 84212764e4 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>
2026-07-26 11:26:09 +03:00
10 changed files with 358 additions and 19 deletions
+27
View File
@@ -2,6 +2,33 @@
All notable GoSentry changes are recorded in this file. All notable GoSentry changes are recorded in this file.
## 0.13.0 - 2026-07-26
**Branded GoSentry color theme; Cancel/Defaults buttons in Settings.**
**Theme:**
- Added 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; users can switch between it and Fyne's default theme from
Settings. 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.
- The light variant is boldly branded: a soft teal window canvas with
white inputs, menus, dialogs, and buttons on top, plus teal-tinted
separators, input borders, and table headers, so cards and fields lift
off the background instead of reading as a plain accent swap on gray.
The dark variant uses deep-teal surfaces to echo the app icon. Text
stays dark/light per the base foreground for contrast in both variants.
**Settings tab:**
- Added Cancel and Defaults buttons. Cancel discards unsaved edits by
reloading the saved config; Defaults loads built-in default values into
the form for review before saving. `domain.DefaultConfig()` is now the
single source of truth for default values, shared by storage and the
Settings UI.
## 0.12.0 - 2026-07-25 ## 0.12.0 - 2026-07-25
**Per-job command timeout:** **Per-job command timeout:**
+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
} }
+1 -1
View File
@@ -3,4 +3,4 @@ package app
// Version is the application version shown in the GUI and used by build // Version is the application version shown in the GUI and used by build
// scripts in artifact names. It is a var rather than a const so release builds // scripts in artifact names. It is a var rather than a const so release builds
// can override it with Go ldflags when CI tags a build. // can override it with Go ldflags when CI tags a build.
var Version = "0.12.0" var Version = "0.13.0"
+35
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,28 @@ 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"`
}
// DefaultConfig returns the built-in default settings. It is the config used
// when gosentry.json does not yet exist, and is also what the Settings UI
// offers to restore via its "Defaults" button.
func DefaultConfig() Config {
return Config{
JobsDir: ".",
LogsDir: "logs",
MaxLogFiles: 100,
MaxLogAgeDays: 30,
StartOnLogin: false,
KeepRunningInTray: true,
NotifyOnFailure: true,
ExecutionMode: ExecutionModeParallel,
OverlapPolicy: OverlapPolicySkip,
Theme: ThemeDefault,
DefaultTimeoutSeconds: 30,
}
} }
// 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 -13
View File
@@ -68,19 +68,7 @@ func (s *Store) SaveJobs(jobs []domain.Job) error {
func loadOrCreateConfig(paths Paths) (domain.Config, error) { func loadOrCreateConfig(paths Paths) (domain.Config, error) {
// Defaults favor a portable installation: settings and jobs begin next to the // Defaults favor a portable installation: settings and jobs begin next to the
// executable, while logs are grouped under a dedicated subdirectory. // executable, while logs are grouped under a dedicated subdirectory.
config := domain.Config{ config := domain.DefaultConfig()
JobsDir: ".",
LogsDir: "logs",
MaxLogFiles: 100,
MaxLogAgeDays: 30,
StartOnLogin: false,
KeepRunningInTray: true,
NotifyOnFailure: true,
ExecutionMode: domain.ExecutionModeParallel,
OverlapPolicy: domain.OverlapPolicySkip,
DefaultTimeoutSeconds: 30,
}
if _, err := os.Stat(paths.ConfigPath); errors.Is(err, os.ErrNotExist) { if _, err := os.Stat(paths.ConfigPath); errors.Is(err, os.ErrNotExist) {
return config, writeJSON(paths.ConfigPath, config) return config, writeJSON(paths.ConfigPath, config)
@@ -117,6 +105,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)
+74 -5
View File
@@ -35,6 +35,9 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
// when something differs. It is defined below (once Save and every field // when something differs. It is defined below (once Save and every field
// exist) but declared here so the field change handlers can reference it. // exist) but declared here so the field change handlers can reference it.
var updateSaveState func() var updateSaveState func()
// loadFields populates every form control from the given config. It backs
// both the initial load and the Cancel/Defaults buttons below.
var loadFields func(domain.Config)
startOnLogin := widget.NewCheck("Start on login", nil) startOnLogin := widget.NewCheck("Start on login", nil)
startOnLogin.SetChecked(store.Config.StartOnLogin) startOnLogin.SetChecked(store.Config.StartOnLogin)
autostartStatus := widget.NewLabel("") autostartStatus := widget.NewLabel("")
@@ -61,6 +64,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 +150,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 +181,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 {
@@ -177,6 +191,38 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
} }
updateSaveState() updateSaveState()
// loadFields populates every form control from a config without saving it,
// backing both the Cancel button (reload the saved config, discarding edits)
// and the Defaults button (load the built-in defaults for review before
// Save is clicked).
loadFields = func(c domain.Config) {
startOnLogin.SetChecked(c.StartOnLogin)
minimizeToTray.SetChecked(c.KeepRunningInTray)
notifications.SetChecked(c.NotifyOnFailure)
themeSelect.SetSelected(themeLabel(c.Theme))
applyTheme(fyne.CurrentApp(), themeFromLabel(themeSelect.Selected))
executionModeSelect.SetSelected(string(c.ExecutionMode))
overlapPolicySelect.SetSelected(string(c.OverlapPolicy))
defaultTimeout.SetText(strconv.Itoa(c.DefaultTimeoutSeconds))
jobsDir.SetText(c.JobsDir)
logsDir.SetText(c.LogsDir)
maxLogFiles.SetText(strconv.Itoa(c.MaxLogFiles))
maxLogAgeDays.SetText(strconv.Itoa(c.MaxLogAgeDays))
if startOnLogin.Checked != store.Config.StartOnLogin {
autostartStatus.SetText("Pending: save settings to apply")
} else {
refreshAutostartStatus()
}
settingsStatus.SetText("")
updateSaveState()
}
cancelSettings := widget.NewButtonWithIcon("Cancel", theme.CancelIcon(), func() {
loadFields(store.Config)
})
restoreDefaults := widget.NewButtonWithIcon("Defaults", theme.MediaReplayIcon(), func() {
loadFields(domain.DefaultConfig())
})
// The form is split into two columns so a wide window uses its horizontal // The form is split into two columns so a wide window uses its horizontal
// space instead of stretching into one tall strip. The left column holds the // space instead of stretching into one tall strip. The left column holds the
// toggles (Application, Queue); the right holds the editable Storage fields and // toggles (Application, Queue); the right holds the editable Storage fields and
@@ -189,6 +235,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
@@ -229,10 +276,10 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
return container.NewVScroll(container.NewPadded(container.NewVBox( return container.NewVScroll(container.NewPadded(container.NewVBox(
container.NewGridWithColumns(2, leftColumn, rightColumn), container.NewGridWithColumns(2, leftColumn, rightColumn),
widget.NewSeparator(), widget.NewSeparator(),
// Save button and its status share one row so an empty status (the common // Save/Cancel/Defaults share one row with the status so an empty status
// case) does not leave a blank line above the separator. The status appears // (the common case) does not leave a blank line above the separator. The
// beside the button once a save reports a result. // status appears beside the buttons once a save reports a result.
container.NewHBox(saveSettings, settingsStatus), container.NewHBox(saveSettings, cancelSettings, restoreDefaults, settingsStatus),
))) )))
} }
@@ -298,6 +345,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
+121
View File
@@ -0,0 +1,121 @@
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.
//
// The light variant is intentionally more than an accent swap: the window canvas
// is a soft teal while inputs, menus, and dialogs stay white, so cards and fields
// lift off a branded background instead of sitting on plain gray. Text stays dark
// (delegated to the base foreground), which keeps high contrast on both the teal
// canvas and the white surfaces.
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: 0xDC, G: 0xEA, B: 0xED, A: 0xFF}, // teal canvas
theme.ColorNameButton: color.NRGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF},
theme.ColorNameInputBackground: color.NRGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF},
theme.ColorNameMenuBackground: color.NRGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF},
theme.ColorNameOverlayBackground: color.NRGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF},
theme.ColorNameHeaderBackground: color.NRGBA{R: 0xC7, G: 0xDE, B: 0xE2, A: 0xFF}, // deeper teal for table headers
theme.ColorNameSeparator: color.NRGBA{R: 0xB4, G: 0xD0, B: 0xD6, A: 0xFF},
theme.ColorNameInputBorder: color.NRGBA{R: 0xB4, G: 0xD0, B: 0xD6, 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))
}
+85
View File
@@ -0,0 +1,85 @@
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},
{"light canvas is a teal tint", theme.ColorNameBackground, 0, color.NRGBA{R: 0xDC, G: 0xEA, B: 0xED, A: 0xFF}},
{"light inputs stay white", theme.ColorNameInputBackground, 0, color.NRGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF}},
{"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)
}
}