diff --git a/modules/interact/print.go b/modules/interact/print.go index 56fc6e2f..7e06dc2d 100644 --- a/modules/interact/print.go +++ b/modules/interact/print.go @@ -5,7 +5,6 @@ package interact import ( "fmt" - "os" "gitea.dev/tea/modules/theme" @@ -14,7 +13,7 @@ import ( // printTitleAndContent prints a title and content with the gitea theme func printTitleAndContent(title, content string) { - hasDarkBG := lipgloss.HasDarkBackground(os.Stdin, os.Stdout) + hasDarkBG := theme.HasDarkBackground() style := lipgloss.NewStyle(). Foreground(theme.GetTheme().Theme(hasDarkBG).Blurred.Title.GetForeground()).Bold(true). Padding(0, 1) diff --git a/modules/theme/background.go b/modules/theme/background.go new file mode 100644 index 00000000..b6b2ac43 --- /dev/null +++ b/modules/theme/background.go @@ -0,0 +1,31 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package theme + +import ( + "os" + + "charm.land/lipgloss/v2" + "golang.org/x/term" +) + +// defaultDarkBackground is the background to assume when we cannot detect one. It +// matches the default lipgloss falls back to. +const defaultDarkBackground = true + +// HasDarkBackground reports whether the terminal has a dark background. +// +// It only asks the terminal when stdin and stdout are both terminals. Detection +// works by writing an escape sequence to the output and waiting for the terminal +// to answer on the input, and nothing answers when stdio is redirected, so asking +// means waiting on a reply that never comes. On Windows that wait is unbounded: +// lipgloss opens the console directly rather than giving up, which is why tea used +// to hang at start-up under a service or a CI runner. +func HasDarkBackground() bool { + if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) { + return defaultDarkBackground + } + + return lipgloss.HasDarkBackground(os.Stdin, os.Stdout) +} diff --git a/modules/theme/theme.go b/modules/theme/theme.go index 68198354..b25ac543 100644 --- a/modules/theme/theme.go +++ b/modules/theme/theme.go @@ -6,7 +6,6 @@ package theme import ( "charm.land/huh/v2" "charm.land/lipgloss/v2" - "charm.land/lipgloss/v2/compat" ) // TeaTheme implements the huh.Theme interface with tea-cli styling. @@ -16,7 +15,8 @@ type TeaTheme struct{} func (t TeaTheme) Theme(isDark bool) *huh.Styles { theme := huh.ThemeCharm(isDark) - title := compat.AdaptiveColor{Light: lipgloss.Color("#02BA84"), Dark: lipgloss.Color("#02BF87")} + lightDark := lipgloss.LightDark(isDark) + title := lightDark(lipgloss.Color("#02BA84"), lipgloss.Color("#02BF87")) theme.Focused.Title = theme.Focused.Title.Foreground(title).Bold(true) theme.Blurred = theme.Focused return theme diff --git a/modules/theme/theme_test.go b/modules/theme/theme_test.go new file mode 100644 index 00000000..25c994d9 --- /dev/null +++ b/modules/theme/theme_test.go @@ -0,0 +1,64 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package theme + +import ( + "os/exec" + "slices" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// compatPkg detects the terminal background from package-level vars, so importing +// it anywhere makes tea query the terminal before main() runs. On Windows that +// query can block forever when stdio is redirected, which hung every tea command, +// including tea --version. +// +// lipgloss.LightDark covers what we need without the package-level detection, so +// nothing in tea should depend on compat again. +const compatPkg = "charm.land/lipgloss/v2/compat" + +func TestBinaryDoesNotImportLipglossCompat(t *testing.T) { + if _, err := exec.LookPath("go"); err != nil { + t.Skip("go is not on PATH") + } + + out, err := exec.Command("go", "list", "-deps", "gitea.dev/tea").Output() + require.NoError(t, err, "go list -deps") + + imported := slices.Contains(strings.Fields(string(out)), compatPkg) + assert.False(t, imported, + "%s is back in tea's import graph. It detects the terminal background from "+ + "package-level vars, so tea queries the terminal before main() runs, and on "+ + "Windows that hangs at start-up when stdio is redirected.", compatPkg) +} + +// Under go test neither stdin nor stdout is a terminal, so HasDarkBackground must +// take the default and return, rather than querying and waiting for an answer. +func TestHasDarkBackgroundDoesNotBlockWithoutTTY(t *testing.T) { + done := make(chan bool, 1) + go func() { + done <- HasDarkBackground() + }() + + select { + case got := <-done: + assert.Equal(t, defaultDarkBackground, got) + case <-time.After(5 * time.Second): + t.Fatal("HasDarkBackground blocked when stdio is not a terminal") + } +} + +// The title color has to come from the isDark we are handed. It used to come from +// a process-wide value that compat detected at init, which ignored this argument. +func TestThemeHonorsIsDark(t *testing.T) { + dark := GetTheme().Theme(true).Focused.Title.GetForeground() + light := GetTheme().Theme(false).Focused.Title.GetForeground() + + assert.NotEqual(t, dark, light, "Theme ignored isDark when picking the title color") +}