Files
gitea-tea/internal/terminal/color.go
T
bircni 55574a9fd0 feat: fit list tables to the terminal and write TSV when piped
Lists had no header, wrapped every row once a title was longer than
the terminal, and were space-padded when piped, so cut -f and awk -F'\t'
could not split them and empty cells collapsed.

terminal.Table now takes column specs and a Display. On a terminal it
prints an uppercase header, aligns columns two spaces apart and shrinks
flexible columns, widest first, to Streams.Width (the terminal, then
$COLUMNS, then 80), cutting by display width with "…". States use the
terminal's ANSI 16 colors, hex colors fall back to the 256-color palette
unless COLORTERM advertises 24-bit, and OSC 8 links appear only on the
first column, independently of color (TEA_HYPERLINKS=auto|always|never).
Piped tables are tab-separated without header, color or links, and times
are RFC3339 UTC instead of "about 2 hours ago".

terminal.View lays out single resources (title, badges, metadata, fields,
body, footer URL) and renders markdown bodies with glamour on a terminal
only (TEA_MARKDOWN=raw keeps them raw). Shared formatters cover relative
times, timestamps, sizes, durations and status icons.

In the app, writeTable and writeView are the new list and view writers:
a "Showing N of M …" context line, an empty-state message on stderr and
the next-page hint. writeList, writeItem and batch keep working and now
render through the new table: tab-separated when piped, truncated at the
widest column on a terminal, with headers for issue list and pr list.

Signed-off-by: bircni <bircni@icloud.com>
2026-10-11 22:43:10 +02:00

134 lines
4.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package terminal
import (
"fmt"
"strconv"
"strings"
"unicode"
)
// ColorEnabled resolves the color preference against terminal and environment policy.
// auto colors TTY stdout unless NO_COLOR is set or TERM is dumb; always forces color; never disables it.
func ColorEnabled(preference string, outTTY bool, getenv func(string) string) bool {
switch preference {
case "always":
return true
case "never":
return false
default: // auto and unknown fall back to terminal detection
if getenv != nil && (getenv("NO_COLOR") != "" || getenv("TERM") == "dumb") {
return false
}
return outTTY
}
}
// HyperlinksEnabled resolves the hyperlinks preference like ColorEnabled, but independently of color:
// NO_COLOR keeps links, and auto links only on TTY stdout whose TERM is not dumb.
func HyperlinksEnabled(preference string, outTTY bool, getenv func(string) string) bool {
switch preference {
case "always":
return true
case "never":
return false
default:
return outTTY && (getenv == nil || getenv("TERM") != "dumb")
}
}
// TrueColor reports whether the terminal advertises 24-bit color through COLORTERM.
func TrueColor(getenv func(string) string) bool {
if getenv == nil {
return false
}
value := getenv("COLORTERM")
return value == "truecolor" || value == "24bit"
}
// namedColors are the ANSI 16 colors for states and status, so they follow the terminal's own theme.
var namedColors = map[string]string{"red": "31", "green": "32", "yellow": "33", "blue": "34", "magenta": "35", "cyan": "36", "muted": "90"}
// Colorize colors text by name (red, green, yellow, blue, magenta, cyan or muted) from the terminal's palette,
// or by six-digit RGB hex with 24-bit color. Unknown colors leave the text plain.
func Colorize(color, text string, enabled bool) string {
if !enabled {
return text
}
return paint(color, text, true)
}
// paint is Colorize for enabled color; without trueColor, hex colors use the nearest 256-color index.
func paint(color, text string, trueColor bool) string {
if text == "" {
return text
}
if code, ok := namedColors[color]; ok {
return "\x1b[" + code + "m" + text + "\x1b[0m"
}
r, g, b, ok := parseHex(color)
switch {
case !ok:
return text
case trueColor:
return fmt.Sprintf("\x1b[38;2;%d;%d;%dm%s\x1b[0m", r, g, b, text)
default:
return fmt.Sprintf("\x1b[38;5;%dm%s\x1b[0m", ansi256(r, g, b), text)
}
}
func parseHex(hex string) (r, g, b int, ok bool) {
hex = strings.TrimPrefix(strings.TrimSpace(hex), "#")
if len(hex) != 6 {
return 0, 0, 0, false
}
value, err := strconv.ParseUint(hex, 16, 24)
if err != nil {
return 0, 0, 0, false
}
return int(value >> 16), int(value >> 8 & 0xff), int(value & 0xff), true
}
// ansi256 returns the 256-color palette index nearest to an RGB color: one of the 6×6×6 cube colors
// (16–231) or one of the 24 grays (232–255).
func ansi256(r, g, b int) int {
levels := [6]int{0, 95, 135, 175, 215, 255}
level := func(v int) int {
switch {
case v < 48:
return 0
case v < 115:
return 1
default:
return (v - 35) / 40
}
}
ri, gi, bi := level(r), level(g), level(b)
gray := min(max((r+g+b)/3-3, 0)/10, 23)
grayValue := 8 + 10*gray
distance := func(cr, cg, cb int) int { return (r-cr)*(r-cr) + (g-cg)*(g-cg) + (b-cb)*(b-cb) }
if distance(levels[ri], levels[gi], levels[bi]) <= distance(grayValue, grayValue, grayValue) {
return 16 + 36*ri + 6*gi + bi
}
return 232 + gray
}
// Emphasis applies simple bold styling for TTY human tables.
func Emphasis(text string, enabled bool) string {
if !enabled || text == "" {
return text
}
return "\x1b[1m" + text + "\x1b[0m"
}
// Hyperlink wraps text in an OSC 8 terminal hyperlink to url. URLs with control
// characters are left unlinked so server data cannot inject escape sequences.
func Hyperlink(url, text string, enabled bool) string {
if !enabled || url == "" || text == "" || strings.ContainsFunc(url, unicode.IsControl) {
return text
}
return "\x1b]8;;" + url + "\x1b\\" + text + "\x1b]8;;\x1b\\"
}