v0.1.2 moved xterm color functionality to color package, adapters added for compatibility

This commit is contained in:
2026-07-19 05:48:02 -04:00
parent 9853ac0270
commit e5c78a87f2
12 changed files with 210 additions and 228 deletions
+13 -16
View File
@@ -6,8 +6,8 @@ keeps owning the terminal. Intended for CLI tools (package managers, service
tooling, build scripts) that want color and live status without a full-screen
TUI.
Unix only (`//go:build unix`). Depends on the parent `terminal` package for
color types, capability detection, and RGB → 256 mapping.
Cross-platform (Unix, Windows). Depends only on the `color` package for
24-bit RGB inputs and 256-color automatic degradation.
## Model
@@ -43,17 +43,17 @@ Value type, zero value is unstyled, builder-composable:
| Function | Description |
|---|---|
| `Fg(c terminal.RGB) Style` | Starts a style with foreground color. |
| `(s Style) Bg(c terminal.RGB) Style` | Adds background color. |
| `(s Style) Attr(a terminal.Attr) Style` | Adds attribute bits (`AttrBold`, `AttrDim`, ...). |
| `Fg(c color.RGB) Style` | Starts a style with foreground color. |
| `(s Style) Bg(c color.RGB) Style` | Adds background color. |
| `(s Style) Bold() Style` | Adds bold attribute (also: `Dim`, `Italic`, `Underline`, `Blink`, `Reverse`). |
```go
warn := inline.Fg(terminal.Amber).Attr(terminal.AttrBold)
warn := inline.Fg(color.Amber).Bold()
p.Log("%s low disk space", p.Paint("warning:", warn))
```
True color terminals get `38;2;R;G;B`; 256-color terminals get `38;5;N` via
Redmean mapping — same degradation path as the parent package.
Redmean mapping dynamically handled by the `color` package.
### Progress helpers
@@ -90,16 +90,16 @@ import (
"os"
"time"
"github.com/lixenwraith/terminal"
"github.com/lixenwraith/color"
"github.com/lixenwraith/terminal/inline"
)
func main() {
p := inline.New(os.Stdout)
name := inline.Fg(terminal.LightSkyBlue).Attr(terminal.AttrBold)
okSt := inline.Fg(terminal.LimeGreen).Attr(terminal.AttrBold)
dim := inline.Fg(terminal.IronGray)
name := inline.Fg(color.LightSkyBlue).Bold()
okSt := inline.Fg(color.LimeGreen).Bold()
dim := inline.Fg(color.IronGray)
pkgs := []string{"openssl", "zlib", "curl", "git", "go"}
frame := 0
@@ -123,14 +123,10 @@ func main() {
}
```
Run in a terminal: the two-line status block animates in place while
completion lines accumulate above it. Piped (`go run . | cat`): only the
completion lines and the final summary appear, unstyled.
## Notes
- Width is measured in runes (`unicode/utf8`); East Asian wide characters and
combining marks are not width-aware — same limitation as `tui`.
combining marks are not width-aware.
- Live block lines must occupy one visual row each: no `\n`, tabs, or control
characters. Lines are truncated to terminal width automatically; embedded
SGR from `Paint` is preserved through truncation.
@@ -139,3 +135,4 @@ completion lines and the final summary appear, unstyled.
the format string.
- Ctrl-C mid-update leaves the live block on screen but the terminal in a
normal state — no raw mode or screen buffer to restore.
+17 -11
View File
@@ -1,5 +1,3 @@
//go:build unix
// Package inline renders styled text and in-place progress in the normal
// terminal scrollback: no raw mode, no alternate screen, no input handling,
// no cursor hiding. Intended for CLI tools that want color and live status
@@ -13,7 +11,7 @@
// unless overridden with SetColor(true), Log and Done print plainly.
//
// Width is measured in runes (unicode/utf8); wide and combining characters
// are not width-aware — same documented limitation as tui.
// are not width-aware.
package inline
import (
@@ -23,7 +21,15 @@ import (
"os"
"sync"
"github.com/lixenwraith/terminal"
"github.com/lixenwraith/color"
)
// Internal color capability representation
type colorMode uint8
const (
colorMode256 colorMode = iota
colorModeTrueColor
)
// Printer manages styled output and the live block. Safe for concurrent use.
@@ -32,25 +38,25 @@ type Printer struct {
w *bufio.Writer
tty *os.File // non-nil when output is a terminal
color bool
mode terminal.ColorMode
mode colorMode
live []string // desired live block content
drawn int // lines currently on screen (may be clamped below len(live))
}
// New creates a Printer for w. Terminal detection via WindowSize probe;
// New creates a Printer for w. Terminal detection via size probe;
// styling defaults on for terminals with NO_COLOR unset.
func New(w io.Writer) *Printer {
p := &Printer{w: bufio.NewWriter(w)}
if f, isFile := w.(*os.File); isFile {
if _, _, ok := terminal.WindowSize(f); ok {
if _, _, ok := windowSize(f); ok {
p.tty = f
}
}
p.color = p.tty != nil && os.Getenv("NO_COLOR") == ""
p.mode = terminal.DetectColorMode()
p.mode = detectColorMode()
// Keep the LUT build out of the first Paint call
if p.color && p.mode == terminal.ColorMode256 {
terminal.WarmPalette256()
if p.color && p.mode == colorMode256 {
color.WarmXterm256()
}
return p
}
@@ -66,7 +72,7 @@ func (p *Printer) SetColor(on bool) {
// Size returns terminal dimensions, 80×24 when unknown
func (p *Printer) Size() (w, h int) {
if p.tty != nil {
if w, h, ok := terminal.WindowSize(p.tty); ok {
if w, h, ok := windowSize(p.tty); ok {
return w, h
}
}
+1 -3
View File
@@ -1,5 +1,3 @@
//go:build unix
package inline
import "strings"
@@ -36,7 +34,7 @@ func Bar(width int, pct float64, chars [3]rune) string {
return b.String()
}
// Braille frames; intentionally duplicated from tui (no tui dependency)
// Braille frames; standard monotonic iteration.
var spinnerFrames = [...]string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
// Spinner returns the frame for a monotonic counter
+40 -16
View File
@@ -1,5 +1,3 @@
//go:build unix
package inline
import (
@@ -8,15 +6,27 @@ import (
"unicode/utf8"
"github.com/lixenwraith/color"
"github.com/lixenwraith/terminal"
)
// Attribute represents visual text modifiers for inline styling
type Attribute uint8
const (
AttributeNone Attribute = 0
Bold Attribute = 1 << 0
Dim Attribute = 1 << 1
Italic Attribute = 1 << 2
Underline Attribute = 1 << 3
Blink Attribute = 1 << 4
Reverse Attribute = 1 << 5
)
// Style describes text appearance; zero value is unstyled.
// Composable: inline.Fg(terminal.Amber).Attr(terminal.AttrBold)
// Composable: inline.Fg(color.Amber).Bold().Underline()
type Style struct {
fg, bg color.RGB
hasFg, hasBg bool
attr terminal.Attr
attr Attribute
}
// Fg starts a style with foreground color
@@ -25,8 +35,23 @@ func Fg(c color.RGB) Style { return Style{fg: c, hasFg: true} }
// Bg sets background color
func (s Style) Bg(c color.RGB) Style { s.bg, s.hasBg = c, true; return s }
// Attr adds attribute bits
func (s Style) Attr(a terminal.Attr) Style { s.attr |= a; return s }
// Bold applies the bold attribute
func (s Style) Bold() Style { s.attr |= Bold; return s }
// Dim applies the dim/faint attribute
func (s Style) Dim() Style { s.attr |= Dim; return s }
// Italic applies the italic attribute
func (s Style) Italic() Style { s.attr |= Italic; return s }
// Underline applies the underline attribute
func (s Style) Underline() Style { s.attr |= Underline; return s }
// Blink applies the blink attribute
func (s Style) Blink() Style { s.attr |= Blink; return s }
// Reverse applies the reverse video attribute
func (s Style) Reverse() Style { s.attr |= Reverse; return s }
// Paint returns s styled for the detected terminal, unchanged when color
// is disabled. Composes with Log: p.Log("%s %s", p.Paint("ok", st), name)
@@ -44,36 +69,35 @@ func (p *Printer) Paint(s string, st Style) string {
func (p *Printer) writeSGR(b *strings.Builder, s Style) {
b.WriteString("\x1b[0")
for _, m := range [...]struct {
bit terminal.Attr
bit Attribute
code string
}{
{terminal.AttrBold, ";1"}, {terminal.AttrDim, ";2"},
{terminal.AttrItalic, ";3"}, {terminal.AttrUnderline, ";4"},
{terminal.AttrBlink, ";5"}, {terminal.AttrReverse, ";7"},
{Bold, ";1"}, {Dim, ";2"},
{Italic, ";3"}, {Underline, ";4"},
{Blink, ";5"}, {Reverse, ";7"},
} {
if s.attr&m.bit != 0 {
b.WriteString(m.code)
}
}
if s.hasFg {
if p.mode == terminal.ColorModeTrueColor {
if p.mode == colorModeTrueColor {
fmt.Fprintf(b, ";38;2;%d;%d;%d", s.fg.R, s.fg.G, s.fg.B)
} else {
fmt.Fprintf(b, ";38;5;%d", terminal.RGBTo256(s.fg))
fmt.Fprintf(b, ";38;5;%d", color.RGBTo256(s.fg))
}
}
if s.hasBg {
if p.mode == terminal.ColorModeTrueColor {
if p.mode == colorModeTrueColor {
fmt.Fprintf(b, ";48;2;%d;%d;%d", s.bg.R, s.bg.G, s.bg.B)
} else {
fmt.Fprintf(b, ";48;5;%d", terminal.RGBTo256(s.bg))
fmt.Fprintf(b, ";48;5;%d", color.RGBTo256(s.bg))
}
}
b.WriteByte('m')
}
// --- Width handling (internal, rune-count semantics) ---
// Handles only 'm'-terminated escapes — this package's own SGR output.
// visibleLen counts runes excluding SGR sequences
func visibleLen(s string) int {
+43
View File
@@ -0,0 +1,43 @@
//go:build unix
package inline
import (
"os"
"strings"
"golang.org/x/sys/unix"
)
func detectColorMode() colorMode {
colorterm := os.Getenv("COLORTERM")
if colorterm == "truecolor" || colorterm == "24bit" {
return colorModeTrueColor
}
if os.Getenv("KITTY_WINDOW_ID") != "" ||
os.Getenv("KONSOLE_VERSION") != "" ||
os.Getenv("ITERM_SESSION_ID") != "" ||
os.Getenv("ALACRITTY_WINDOW_ID") != "" ||
os.Getenv("ALACRITTY_LOG") != "" ||
os.Getenv("WEZTERM_PANE") != "" {
return colorModeTrueColor
}
term := os.Getenv("TERM")
if strings.Contains(term, "truecolor") ||
strings.Contains(term, "24bit") ||
strings.Contains(term, "direct") {
return colorModeTrueColor
}
return colorMode256
}
func windowSize(f *os.File) (w, h int, ok bool) {
ws, err := unix.IoctlGetWinsize(int(f.Fd()), unix.TIOCGWINSZ)
if err != nil || ws.Col == 0 {
return 0, 0, false
}
return int(ws.Col), int(ws.Row), true
}
+13
View File
@@ -0,0 +1,13 @@
//go:build !unix && !windows
package inline
import "os"
func detectColorMode() colorMode {
return colorMode256
}
func windowSize(f *os.File) (w, h int, ok bool) {
return 0, 0, false
}
+32
View File
@@ -0,0 +1,32 @@
//go:build windows
package inline
import (
"os"
"golang.org/x/sys/windows"
)
func detectColorMode() colorMode {
if os.Getenv("WT_SESSION") != "" || os.Getenv("WT_PROFILE_ID") != "" {
return colorModeTrueColor
}
if ct := os.Getenv("COLORTERM"); ct == "truecolor" || ct == "24bit" {
return colorModeTrueColor
}
return colorMode256
}
func windowSize(f *os.File) (w, h int, ok bool) {
var info windows.ConsoleScreenBufferInfo
if err := windows.GetConsoleScreenBufferInfo(windows.Handle(f.Fd()), &info); err != nil {
return 0, 0, false
}
width := int(info.Window.Right-info.Window.Left) + 1
height := int(info.Window.Bottom-info.Window.Top) + 1
if width < 1 || height < 1 {
return 0, 0, false
}
return width, height, true
}