Compare commits
4 Commits
eedae3b9f0
...
3992b40eda
| Author | SHA1 | Date | |
|---|---|---|---|
| 3992b40eda | |||
| 2c684532da | |||
| d3e3d30b42 | |||
| 84212764e4 |
@@ -2,6 +2,33 @@
|
||||
|
||||
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
|
||||
|
||||
**Per-job command timeout:**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+1
-1
@@ -3,4 +3,4 @@ package app
|
||||
// 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
|
||||
// can override it with Go ldflags when CI tags a build.
|
||||
var Version = "0.12.0"
|
||||
var Version = "0.13.0"
|
||||
|
||||
@@ -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,28 @@ 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"`
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
+4
-13
@@ -68,19 +68,7 @@ func (s *Store) SaveJobs(jobs []domain.Job) error {
|
||||
func loadOrCreateConfig(paths Paths) (domain.Config, error) {
|
||||
// Defaults favor a portable installation: settings and jobs begin next to the
|
||||
// executable, while logs are grouped under a dedicated subdirectory.
|
||||
config := domain.Config{
|
||||
JobsDir: ".",
|
||||
LogsDir: "logs",
|
||||
MaxLogFiles: 100,
|
||||
MaxLogAgeDays: 30,
|
||||
StartOnLogin: false,
|
||||
KeepRunningInTray: true,
|
||||
NotifyOnFailure: true,
|
||||
ExecutionMode: domain.ExecutionModeParallel,
|
||||
OverlapPolicy: domain.OverlapPolicySkip,
|
||||
|
||||
DefaultTimeoutSeconds: 30,
|
||||
}
|
||||
config := domain.DefaultConfig()
|
||||
|
||||
if _, err := os.Stat(paths.ConfigPath); errors.Is(err, os.ErrNotExist) {
|
||||
return config, writeJSON(paths.ConfigPath, config)
|
||||
@@ -117,6 +105,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)
|
||||
|
||||
+74
-5
@@ -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
|
||||
// exist) but declared here so the field change handlers can reference it.
|
||||
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.SetChecked(store.Config.StartOnLogin)
|
||||
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.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 +150,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 +181,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 {
|
||||
@@ -177,6 +191,38 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
||||
}
|
||||
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
|
||||
// space instead of stretching into one tall strip. The left column holds the
|
||||
// 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("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
|
||||
@@ -229,10 +276,10 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
||||
return container.NewVScroll(container.NewPadded(container.NewVBox(
|
||||
container.NewGridWithColumns(2, leftColumn, rightColumn),
|
||||
widget.NewSeparator(),
|
||||
// Save button and its status share one row so an empty status (the common
|
||||
// case) does not leave a blank line above the separator. The status appears
|
||||
// beside the button once a save reports a result.
|
||||
container.NewHBox(saveSettings, settingsStatus),
|
||||
// Save/Cancel/Defaults share one row with the status so an empty status
|
||||
// (the common case) does not leave a blank line above the separator. The
|
||||
// status appears beside the buttons once a save reports a result.
|
||||
container.NewHBox(saveSettings, cancelSettings, restoreDefaults, settingsStatus),
|
||||
)))
|
||||
}
|
||||
|
||||
@@ -298,6 +345,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
|
||||
|
||||
+121
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user