light theme and termenv-based auto-detection

This commit is contained in:
booleanmaybe 2026-04-12 21:02:04 -04:00
parent e93675c34f
commit 7656ae91ac
7 changed files with 108 additions and 104 deletions

View file

@ -7,68 +7,29 @@ import (
"strings"
)
//nolint:unused
const artFire = "▓▓▓▓▓▓╗ ▓▓ ▓▓ ▓▓ ▓▓\n╚═▒▒═╝ ▒▒ ▒▒ ▒▒ ▒▒\n ▒▒ ▒▒ ▒▒▒▒ ▒▒\n ░░ ░░ ░░ ░░ ░░\n ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝"
const artDots = "▒ ▒ ▒ ▒ ▒ ▒ ▒ ▒ ▒ ▒ ▒ ▒ ▒\n▒ ● ● ● ▓ ● ▓ ● ▓ ● ▓ ● ▒\n▒ ▓ ● ▓ ▓ ● ▓ ● ● ▓ ▓ ● ▒\n▒ ▓ ● ▓ ▓ ● ▓ ● ▓ ● ▓ ● ▒\n▒ ▓ ● ▓ ▓ ● ▓ ● ▓ ● ▓ ● ▒\n▒ ▒ ▒ ▒ ▒ ▒ ▒ ▒ ▒ ▒ ▒ ▒ ▒"
// fireGradient is the color scheme for artFire (yellow → orange → red)
//
//nolint:unused
var fireGradient = []string{"#FFDC00", "#FFAA00", "#FF7800", "#FF5000", "#B42800"}
// dotsGradient is the color scheme for artDots (bright cyan → blue gradient)
// Each character type gets a different color:
// ● (dot) = bright cyan (text)
// ▓ (dark shade) = medium blue (near)
// ▒ (medium shade) = dark blue (far)
var dotsGradient = []string{"#40E0D0", "#4682B4", "#324664"}
// var currentArt = artFire
// var currentGradient = fireGradient
var currentArt = artDots
var currentGradient = dotsGradient
// GetArtTView returns the art logo formatted for tview (with tview color codes)
// uses the current gradient colors
// GetArtTView returns the art logo formatted for tview (with tview color codes).
// Colors are sourced from the palette via ColorConfig.
func GetArtTView() string {
if currentArt == artDots {
// For dots art, color by character type, not by row
return getDotsArtTView()
}
colors := GetColors()
dotColor := colors.LogoDotColor.Hex()
shadeColor := colors.LogoShadeColor.Hex()
borderColor := colors.LogoBorderColor.Hex()
// For other art, color by row
lines := strings.Split(currentArt, "\n")
var result strings.Builder
for i, line := range lines {
// pick color based on line index (cycle if more lines than colors)
colorIdx := i
if colorIdx >= len(currentGradient) {
colorIdx = len(currentGradient) - 1
}
color := currentGradient[colorIdx]
fmt.Fprintf(&result, "[%s]%s[white]\n", color, line)
}
return result.String()
}
// getDotsArtTView colors the dots art by character type
func getDotsArtTView() string {
lines := strings.Split(artDots, "\n")
var result strings.Builder
// dotsGradient: [0]=● (text), [1]=▓ (near), [2]=▒ (far)
for _, line := range lines {
for _, char := range line {
var color string
switch char {
case '●':
color = dotsGradient[0] // bright cyan
color = dotColor
case '▓':
color = dotsGradient[1] // medium blue
color = shadeColor
case '▒':
color = dotsGradient[2] // dark blue
color = borderColor
default:
result.WriteRune(char)
continue
@ -79,8 +40,3 @@ func getDotsArtTView() string {
}
return result.String()
}
// GetFireIcon returns fire icon with tview color codes
func GetFireIcon() string {
return "[#FFDC00] ░ ▒ ░ \n[#FFAA00] ▒▓██▓█▒░ \n[#FF7800] ░▓████▓██▒░ \n[#FF5000] ▒▓██▓▓▒░ \n[#B42800] ▒▓░ \n[white]\n"
}

View file

@ -18,8 +18,6 @@ type ColorConfig struct {
CaptionFallbackGradient Gradient
// Task box colors
TaskBoxSelectedBackground Color
TaskBoxSelectedText Color
TaskBoxSelectedBorder Color
TaskBoxUnselectedBorder Color
TaskBoxUnselectedBackground Color
@ -100,6 +98,11 @@ type ColorConfig struct {
FallbackTaskIDColor Color // Deep Sky Blue (end of task ID gradient)
FallbackBurndownColor Color // Purple (start of burndown gradient)
// Logo colors (header art)
LogoDotColor Color // bright turquoise (● dots)
LogoShadeColor Color // medium blue (▓ shade)
LogoBorderColor Color // dark blue (▒ border)
// Statusline colors (bottom bar, powerline style)
StatuslineBg Color
StatuslineFg Color
@ -123,24 +126,28 @@ type Palette struct {
SoftTextColor Color // #b4b4b4 — secondary readable text (task box titles, action labels)
AccentColor Color // #008000 — label text (green)
ValueColor Color // #8c92ac — field values (cool gray)
TagFgColor Color // #b4c8dc — tag chip foreground (light blue-gray)
TagBgColor Color // #1e3278 — tag chip background (dark blue)
InfoLabelColor Color // #ffa500 — orange, header view name
// Selection
SelectionBgColor Color // #3a5f8a — steel blue selection row background
SelectionFgColor Color // ANSI 33 blue — selected task box background
SelectionText Color // ANSI 117 — selected task box text
// Action key / accent blue
AccentBlue Color // #5fafff — cyan-blue (action keys, points bar, chart bars)
SlateColor Color // #5f6982 — muted blue-gray (tag values, unfilled bar segments)
// Logo
LogoDotColor Color // #40e0d0 — bright turquoise (● in header art)
LogoShadeColor Color // #4682b4 — steel blue (▓ in header art)
LogoBorderColor Color // #324664 — dark navy (▒ in header art)
// Gradients
CaptionFallbackGradient Gradient // Midnight Blue → Royal Blue
DeepSkyBlue Color // #00bfff — task ID base color + gradient fallback
DeepPurple Color // #865ad6 — fallback for burndown gradient
// Content area
ContentBackgroundColor Color // canvas background (dark: black, light: transparent/default)
// Statusline (Nord palette)
NordPolarNight1 Color // #2e3440
NordPolarNight2 Color // #3b4252
@ -150,8 +157,8 @@ type Palette struct {
NordAuroraGreen Color // #a3be8c
}
// DefaultPalette returns the default color palette.
func DefaultPalette() Palette {
// DarkPalette returns the color palette for dark backgrounds.
func DarkPalette() Palette {
return Palette{
HighlightColor: NewColorHex("#ffff00"),
TextColor: NewColorHex("#ffffff"),
@ -160,17 +167,17 @@ func DefaultPalette() Palette {
SoftTextColor: NewColorHex("#b4b4b4"),
AccentColor: NewColor(tcell.ColorGreen),
ValueColor: NewColorHex("#8c92ac"),
TagFgColor: NewColorRGB(180, 200, 220),
TagBgColor: NewColorRGB(30, 50, 120),
InfoLabelColor: NewColorHex("#ffa500"),
SelectionBgColor: NewColorHex("#3a5f8a"),
SelectionFgColor: NewColor(tcell.PaletteColor(33)),
SelectionText: NewColor(tcell.PaletteColor(117)),
AccentBlue: NewColorHex("#5fafff"),
SlateColor: NewColorHex("#5f6982"),
LogoDotColor: NewColorHex("#40e0d0"),
LogoShadeColor: NewColorHex("#4682b4"),
LogoBorderColor: NewColorHex("#324664"),
CaptionFallbackGradient: Gradient{
Start: [3]int{25, 25, 112},
End: [3]int{65, 105, 225},
@ -178,6 +185,8 @@ func DefaultPalette() Palette {
DeepSkyBlue: NewColorRGB(0, 191, 255),
DeepPurple: NewColorRGB(134, 90, 214),
ContentBackgroundColor: NewColor(tcell.ColorBlack),
NordPolarNight1: NewColorHex("#2e3440"),
NordPolarNight2: NewColorHex("#3b4252"),
NordPolarNight3: NewColorHex("#434c5e"),
@ -187,9 +196,48 @@ func DefaultPalette() Palette {
}
}
// DefaultColors returns the default color configuration built from the default palette.
// LightPalette returns the color palette for light backgrounds.
func LightPalette() Palette {
return Palette{
HighlightColor: NewColorHex("#0055dd"), // vivid blue — accents, focus markers, key bindings
TextColor: NewColor(tcell.ColorBlack),
TransparentColor: DefaultColor(),
MutedColor: NewColorHex("#808080"), // medium gray — de-emphasized text, placeholders
SoftTextColor: NewColorHex("#404040"), // dark gray — secondary readable text
AccentColor: NewColorHex("#006400"), // dark green — labels
ValueColor: NewColorHex("#4a4e6a"), // dark cool gray — field values
InfoLabelColor: NewColorHex("#b85c00"), // darker orange — header view name
SelectionBgColor: NewColorHex("#b8d4f0"), // light blue — selection background
AccentBlue: NewColorHex("#0060c0"), // darker blue — action keys, points bar
SlateColor: NewColorHex("#7080a0"), // blue-gray — tag values, unfilled bar segments
LogoDotColor: NewColorHex("#20a090"), // darker turquoise
LogoShadeColor: NewColorHex("#3060a0"), // medium blue
LogoBorderColor: NewColorHex("#6080a0"), // lighter blue-gray (visible on light bg)
CaptionFallbackGradient: Gradient{
Start: [3]int{100, 140, 200},
End: [3]int{60, 100, 180},
},
DeepSkyBlue: NewColorRGB(0, 100, 180),
DeepPurple: NewColorRGB(90, 50, 160),
ContentBackgroundColor: DefaultColor(), // transparent — inherit terminal background
NordPolarNight1: NewColorHex("#eceff4"), // inverted: light background
NordPolarNight2: NewColorHex("#e5e9f0"),
NordPolarNight3: NewColorHex("#d8dee9"),
NordSnowStorm1: NewColorHex("#2e3440"), // inverted: dark text
NordFrostBlue: NewColorHex("#5e81ac"), // stays — good contrast on light
NordAuroraGreen: NewColorHex("#4c7a5a"), // darker green for light bg
}
}
// DefaultColors returns the default color configuration built from the dark palette.
func DefaultColors() *ColorConfig {
return ColorsFromPalette(DefaultPalette())
return ColorsFromPalette(DarkPalette())
}
// darkenRGB returns a darkened version of an RGB triple. ratio 0 = no change, 1 = black.
@ -220,8 +268,6 @@ func ColorsFromPalette(p Palette) *ColorConfig {
CaptionFallbackGradient: p.CaptionFallbackGradient,
// Task box
TaskBoxSelectedBackground: p.SelectionFgColor,
TaskBoxSelectedText: p.SelectionText,
TaskBoxSelectedBorder: p.HighlightColor,
TaskBoxUnselectedBorder: p.MutedColor,
TaskBoxUnselectedBackground: p.TransparentColor,
@ -246,12 +292,12 @@ func ColorsFromPalette(p Palette) *ColorConfig {
TaskDetailEditDimValueColor: p.SoftTextColor,
TaskDetailEditFocusMarker: p.HighlightColor,
TaskDetailEditFocusText: p.TextColor,
TaskDetailTagForeground: p.TagFgColor,
TaskDetailTagBackground: p.TagBgColor,
TaskDetailTagForeground: p.SoftTextColor,
TaskDetailTagBackground: p.SelectionBgColor,
TaskDetailPlaceholderColor: p.MutedColor,
// Content area
ContentBackgroundColor: NewColor(tcell.ColorBlack),
ContentBackgroundColor: p.ContentBackgroundColor,
ContentTextColor: p.TextColor,
// Search box
@ -302,6 +348,11 @@ func ColorsFromPalette(p Palette) *ColorConfig {
FallbackTaskIDColor: p.DeepSkyBlue,
FallbackBurndownColor: p.DeepPurple,
// Logo
LogoDotColor: p.LogoDotColor,
LogoShadeColor: p.LogoShadeColor,
LogoBorderColor: p.LogoBorderColor,
// Statusline
StatuslineBg: p.NordPolarNight3,
StatuslineFg: p.NordSnowStorm1,
@ -327,20 +378,13 @@ var UseGradients bool
// Screen-wide gradients show more banding on 256-color terminals, so require truecolor
var UseWideGradients bool
// GetColors returns the global color configuration with theme-aware overrides
// GetColors returns the global color configuration for the effective theme
func GetColors() *ColorConfig {
if !colorsInitialized {
globalColors = DefaultColors()
// Apply theme-aware overrides for critical text colors
if GetEffectiveTheme() == "light" {
black := NewColor(tcell.ColorBlack)
globalColors.ContentBackgroundColor = DefaultColor()
globalColors.ContentTextColor = black
globalColors.SearchBoxLabelColor = black
globalColors.SearchBoxTextColor = black
globalColors.InputFieldTextColor = black
globalColors.TaskDetailEditFocusText = black
globalColors.HeaderKeyText = black
globalColors = ColorsFromPalette(LightPalette())
} else {
globalColors = ColorsFromPalette(DarkPalette())
}
colorsInitialized = true
}

View file

@ -10,6 +10,7 @@ import (
"path/filepath"
"strings"
"github.com/muesli/termenv"
"github.com/spf13/pflag"
"github.com/spf13/viper"
"gopkg.in/yaml.v3"
@ -354,24 +355,19 @@ func GetTheme() string {
return theme
}
// GetEffectiveTheme resolves "auto" to actual theme based on terminal detection
// GetEffectiveTheme resolves "auto" to actual theme based on terminal detection.
// Uses termenv OSC 11 query to detect the terminal's actual background color,
// falling back to COLORFGBG env var, then dark.
func GetEffectiveTheme() string {
theme := GetTheme()
if theme != "auto" {
return theme
}
// Detect via COLORFGBG env var (format: "fg;bg")
if colorfgbg := os.Getenv("COLORFGBG"); colorfgbg != "" {
parts := strings.Split(colorfgbg, ";")
if len(parts) >= 2 {
bg := parts[len(parts)-1]
// 0-7 = dark colors, 8+ = light colors
if bg >= "8" {
return "light"
}
}
output := termenv.NewOutput(os.Stdout)
if output.HasDarkBackground() {
return "dark"
}
return "dark" // default fallback
return "light"
}
// GetGradientThreshold returns the minimum color count required for gradients

View file

@ -297,5 +297,9 @@ func InitColorAndGradientSupport(cfg *config.Config) *sysinfo.SystemInfo {
"wideGradients", config.UseWideGradients)
}
// set tview global background so all primitives inherit the theme background
colors := config.GetColors()
tview.Styles.PrimitiveBackgroundColor = colors.ContentBackgroundColor.TCell()
return systemInfo
}

View file

@ -201,21 +201,25 @@ func updateStatusBar(statusBar *tview.TextView, v *navtview.TextViewViewer) {
canBack := core.CanGoBack()
canForward := core.CanGoForward()
keyColor := "gray"
activeColor := "white"
status := fmt.Sprintf(" [yellow]%s[-] | Link:[%s]Tab/Shift-Tab[-] | Back:", fileName, keyColor)
colors := config.GetColors()
labelColor := colors.TaskBoxTitleColor.Hex()
keyColor := colors.CompletionHintColor.Hex()
activeColor := colors.ContentTextColor.Hex()
mutedColor := colors.CompletionHintColor.Hex()
accentColor := colors.HeaderInfoLabel.Tag().Bold().String()
status := fmt.Sprintf(" %s%s[-] | [%s]Link:[-][%s]Tab/Shift-Tab[-] | [%s]Back:[-]", accentColor, fileName, labelColor, keyColor, labelColor)
if canBack {
status += fmt.Sprintf("[%s]◀[-]", activeColor)
} else {
status += "[gray]◀[-]"
status += fmt.Sprintf("[%s]◀[-]", mutedColor)
}
status += " Fwd:"
status += fmt.Sprintf(" [%s]Fwd:[-]", labelColor)
if canForward {
status += fmt.Sprintf("[%s]▶[-]", activeColor)
} else {
status += "[gray]▶[-]"
status += fmt.Sprintf("[%s]▶[-]", mutedColor)
}
status += fmt.Sprintf(" | Scroll:[%s]j/k[-] Top/End:[%s]g/G[-] Refresh:[%s]r[-] Edit:[%s]e[-] Quit:[%s]q[-]", keyColor, keyColor, keyColor, keyColor, keyColor)
status += fmt.Sprintf(" | [%s]Scroll:[-][%s]j/k[-] [%s]Top/End:[-][%s]g/G[-] [%s]Refresh:[-][%s]r[-] [%s]Edit:[-][%s]e[-] [%s]Quit:[-][%s]q[-]", labelColor, keyColor, labelColor, keyColor, labelColor, keyColor, labelColor, keyColor, labelColor, keyColor)
statusBar.SetText(status)
}

View file

@ -66,7 +66,7 @@ func RenderTypeText(task *taskpkg.Task, ctx FieldRenderContext) tview.Primitive
focused := ctx.Mode == RenderModeEdit && ctx.FocusedField == model.EditFieldType
typeDisplay := taskpkg.TypeDisplay(task.Type)
if task.Type == "" {
typeDisplay = "[gray](none)[-]"
typeDisplay = ctx.Colors.TaskDetailPlaceholderColor.Tag().String() + "(none)[-]"
}
labelTag := getDimOrFullColor(ctx.Mode, focused, ctx.Colors.TaskDetailLabelText, ctx.Colors.TaskDetailEditDimLabelColor).Tag().String()

View file

@ -499,7 +499,7 @@ func (ev *TaskEditView) updateValidationState() {
// Update border color based on validation
if ev.metadataBox != nil {
colors := config.DefaultColors()
colors := config.GetColors()
if len(ev.validationErrors) > 0 {
ev.metadataBox.SetBorderColor(colors.TaskBoxSelectedBorder.TCell())
} else {