summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-08 22:25:36 +0300
committerPaul Buetow <paul@buetow.org>2026-04-08 22:25:36 +0300
commit38b276bb3d5be12a63d9bab3fe927803e2c4315c (patch)
treee8820c3508b5b9350a90d05508f3317c9dec0a3e
parenta2335b65b2ccf7e6ffc440ca3d61dd6bec9e9163 (diff)
Fix task f random theme contrast
-rw-r--r--internal/ui/theme.go104
-rw-r--r--internal/ui/theme_test.go35
2 files changed, 97 insertions, 42 deletions
diff --git a/internal/ui/theme.go b/internal/ui/theme.go
index b7b3490..9165c1c 100644
--- a/internal/ui/theme.go
+++ b/internal/ui/theme.go
@@ -1,63 +1,64 @@
package ui
import (
+ "math"
"math/rand"
"strconv"
)
// Theme holds color configuration for the UI.
type Theme struct {
- HeaderFG string
- SelectedFG string
- SelectedBG string
- RowFG string
- RowBG string
- StatusFG string
- StatusBG string
- StartBG string
+ HeaderFG string
+ SelectedFG string
+ SelectedBG string
+ RowFG string
+ RowBG string
+ StatusFG string
+ StatusBG string
+ StartBG string
UltraStartedBG string // background for started tasks in ultra mode
- OverdueBG string
- PrioLowBG string
- PrioMedBG string
- PrioHighBG string
- SearchFG string
- SearchBG string
+ OverdueBG string
+ PrioLowBG string
+ PrioMedBG string
+ PrioHighBG string
+ SearchFG string
+ SearchBG string
}
// DefaultTheme returns the color theme used by Task Samurai.
func DefaultTheme() Theme {
return Theme{
- HeaderFG: "75", // steel blue — labels in ultra cards
- SelectedFG: "255", // bright white — text on selected card
- SelectedBG: "238", // dark grey — clean selection highlight on black background
- RowFG: "0",
- RowBG: "57",
- StatusFG: "229", // light yellow
- StatusBG: "57", // dark purple — status bar background
+ HeaderFG: "75", // steel blue — labels in ultra cards
+ SelectedFG: "255", // bright white — text on selected card
+ SelectedBG: "238", // dark grey — clean selection highlight on black background
+ RowFG: "0",
+ RowBG: "57",
+ StatusFG: "229", // light yellow
+ StatusBG: "57", // dark purple — status bar background
StartBG: "6",
UltraStartedBG: "220", // amber yellow — visually distinct "in progress" indicator
- OverdueBG: "1",
- PrioLowBG: "28", // dark green — subtler than bright 10
- PrioMedBG: "33", // medium blue — subtler than bright 12
- PrioHighBG: "160", // dark red — subtler than bright 9
- SearchFG: "16",
- SearchBG: "220", // amber — easier on eyes than pure yellow 226
+ OverdueBG: "1",
+ PrioLowBG: "28", // dark green — subtler than bright 10
+ PrioMedBG: "33", // medium blue — subtler than bright 12
+ PrioHighBG: "160", // dark red — subtler than bright 9
+ SearchFG: "16",
+ SearchBG: "220", // amber — easier on eyes than pure yellow 226
}
}
func RandomTheme() Theme {
th := Theme{
- HeaderFG: randColor(),
- SelectedBG: randColor(),
- RowBG: randColor(),
- StatusBG: randColor(),
+ HeaderFG: randColor(),
+ SelectedBG: randColor(),
+ RowBG: randColor(),
+ StatusBG: randColor(),
StartBG: randColor(),
UltraStartedBG: randColor(),
- OverdueBG: randColor(),
- PrioLowBG: randColor(),
- PrioMedBG: randColor(),
- PrioHighBG: randColor(),
- SearchBG: randColor(),
+ OverdueBG: randColor(),
+ PrioLowBG: randColor(),
+ PrioMedBG: randColor(),
+ PrioHighBG: randColor(),
+ SearchBG: randColor(),
}
th.SelectedFG = contrastColor(th.SelectedBG)
th.RowFG = contrastColor(th.RowBG)
@@ -72,18 +73,37 @@ func randColor() string {
func contrastColor(bg string) string {
i, err := strconv.Atoi(bg)
- if err != nil {
- return "0"
+ if err != nil || i < 0 || i > 255 {
+ return "15"
}
- if brightness(i) > 128 {
+ r, g, b := xtermRGB(i)
+ if contrastRatio(r, g, b, 0, 0, 0) >= contrastRatio(r, g, b, 255, 255, 255) {
return "0"
}
return "15"
}
-func brightness(i int) float64 {
- r, g, b := xtermRGB(i)
- return 0.299*float64(r) + 0.587*float64(g) + 0.114*float64(b)
+func contrastRatio(r1, g1, b1, r2, g2, b2 int) float64 {
+ l1 := relativeLuminance(r1, g1, b1)
+ l2 := relativeLuminance(r2, g2, b2)
+ if l1 < l2 {
+ l1, l2 = l2, l1
+ }
+ return (l1 + 0.05) / (l2 + 0.05)
+}
+
+func relativeLuminance(r, g, b int) float64 {
+ return 0.2126*linearizeColorChannel(r) +
+ 0.7152*linearizeColorChannel(g) +
+ 0.0722*linearizeColorChannel(b)
+}
+
+func linearizeColorChannel(v int) float64 {
+ c := float64(v) / 255.0
+ if c <= 0.04045 {
+ return c / 12.92
+ }
+ return math.Pow((c+0.055)/1.055, 2.4)
}
func xtermRGB(i int) (int, int, int) {
diff --git a/internal/ui/theme_test.go b/internal/ui/theme_test.go
new file mode 100644
index 0000000..5fa6955
--- /dev/null
+++ b/internal/ui/theme_test.go
@@ -0,0 +1,35 @@
+package ui
+
+import (
+ "strconv"
+ "testing"
+)
+
+func TestContrastColorUsesHighContrastForeground(t *testing.T) {
+ for bg := 0; bg < 256; bg++ {
+ fg := contrastColor(strconv.Itoa(bg))
+ if fg != "0" && fg != "15" {
+ t.Fatalf("contrastColor(%d) = %q, want black or white foreground", bg, fg)
+ }
+
+ r, g, b := xtermRGB(bg)
+ var ratio float64
+ if fg == "0" {
+ ratio = contrastRatio(r, g, b, 0, 0, 0)
+ } else {
+ ratio = contrastRatio(r, g, b, 255, 255, 255)
+ }
+ if ratio < 4.5 {
+ t.Fatalf("contrastColor(%d) = %q with contrast ratio %.2f, want >= 4.5", bg, fg, ratio)
+ }
+ }
+}
+
+func TestContrastColorFallsBackForInvalidInput(t *testing.T) {
+ tests := []string{"not-a-color", "-1", "256"}
+ for _, input := range tests {
+ if got := contrastColor(input); got != "15" {
+ t.Fatalf("contrastColor(%q) = %q, want 15", input, got)
+ }
+ }
+}