From d3c703129026f6485526e1cdedc2eea5fc4f34b5 Mon Sep 17 00:00:00 2001 From: Tiago Peczenyj Date: Tue, 26 May 2026 15:05:52 +0200 Subject: [PATCH] feat: themeable colors (cga/green/amber) via eggs and STRUCTALIGN_THEME Route ui's hardcoded ANSI constants through a Theme over semantic roles (Header/Added/Removed/Meta/Padding/Label); the default theme reproduces the historical palette byte-for-byte, so golden output is unchanged. Add built-in retro themes: cga (bright 16-color), green and amber (single-hue phosphor emulations distinguished by intensity + the +/- prefixes). Selection: hidden easter-egg flags -cga/-green/-amber (stripped pre-parse, so invisible in -help) take precedence over the documented STRUCTALIGN_THEME env var, else default. Unknown theme warns and falls back. Theme is orthogonal to -color: it only chooses which colors are used when color is on. Not a full theming system: a fixed set of built-ins and two ways to pick one. Closes #33 Co-Authored-By: Claude Opus 4.7 --- README.md | 6 ++++ internal/app/app.go | 42 ++++++++++++++++++++++++ internal/app/theme_test.go | 51 +++++++++++++++++++++++++++++ internal/ui/printer.go | 67 +++++++++++++++++++++++++++++--------- internal/ui/theme_test.go | 44 +++++++++++++++++++++++++ internal/ui/themes.go | 38 +++++++++++++++++++++ 6 files changed, 232 insertions(+), 16 deletions(-) create mode 100644 internal/app/theme_test.go create mode 100644 internal/ui/theme_test.go create mode 100644 internal/ui/themes.go diff --git a/README.md b/README.md index 52ace60..1e86867 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,12 @@ In the default `-color=auto`, color is emitted only when stdout is a terminal an the [`NO_COLOR`](https://no-color.org) environment variable is unset. `NO_COLOR` (any non-empty value) disables color; an explicit `-color=always` overrides it. +The palette can be switched with the `STRUCTALIGN_THEME` environment variable — +`default` (the standard colors), `cga` (a bright 16-color CGA look), or `green` / +`amber` (single-hue phosphor-monitor emulations). It only affects *which* colors +are used when color is on; it does not turn color on by itself. An unknown value +warns and falls back to `default`. + Exit code is **1 when reorderings are found**, **0 when none** — so it drops into CI as a check. Inspect mode is informational and always exits 0. diff --git a/internal/app/app.go b/internal/app/app.go index 51cbc23..2f55bce 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -144,6 +144,33 @@ func (a *App) Run(args []string) int { return 2 } } + + // Easter-egg theme flags: -cga/-green/-amber select a retro palette. Like + // -fix, they are caught before parsing and stripped from args, so they stay + // invisible in -help and never trip "flag provided but not defined". Last + // one wins; anything after "--" is left untouched (positional args). + themeName := "" + filtered := args[:0:0] + afterDD := false + for _, arg := range args { + switch { + case afterDD: + filtered = append(filtered, arg) + case arg == "--": + afterDD = true + filtered = append(filtered, arg) + case arg == "-cga" || arg == "--cga": + themeName = "cga" + case arg == "-green" || arg == "--green": + themeName = "green" + case arg == "-amber" || arg == "--amber": + themeName = "amber" + default: + filtered = append(filtered, arg) + } + } + args = filtered + if err := fs.Parse(args); err != nil { return 2 } @@ -167,10 +194,25 @@ func (a *App) Run(args []string) int { width = ui.ResolveWidth(stdoutFile(a.Stdout)) } patterns := match.ParsePatterns(opt.typeFilter) + + // Resolve the theme: egg flag wins over STRUCTALIGN_THEME, else default. + if themeName == "" { + themeName = os.Getenv("STRUCTALIGN_THEME") + } + theme := ui.DefaultTheme() + if themeName != "" && themeName != "default" { + if th, ok := ui.ThemeByName(themeName); ok { + theme = th + } else { + fmt.Fprintf(a.Stderr, "structalign: unknown theme %q, using default\n", themeName) + } + } + printer := &ui.Printer{ Out: a.Stdout, Color: ui.WantColor(opt.colorize, stdoutFile(a.Stdout)), Width: width, + Theme: theme, } ld := a.Loader diff --git a/internal/app/theme_test.go b/internal/app/theme_test.go new file mode 100644 index 0000000..c5624ce --- /dev/null +++ b/internal/app/theme_test.go @@ -0,0 +1,51 @@ +package app_test + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + + "github.com/peczenyj/structalign/internal/align" + "github.com/peczenyj/structalign/internal/app" + "github.com/peczenyj/structalign/internal/layout" + "github.com/peczenyj/structalign/internal/mocks" + "github.com/peczenyj/structalign/internal/testutil" + "github.com/peczenyj/structalign/pkg/common" +) + +func themeApp(t *testing.T, out, errb *bytes.Buffer) *app.App { + t.Helper() + tgt := testutil.Target(t, src) + ml := mocks.NewLoader(t) + ml.EXPECT().Load(mock.Anything).Return([]common.Target{tgt}, nil) + return &app.App{Loader: ml, Aligner: align.New(), Inspector: layout.New(), Stdout: out, Stderr: errb} +} + +func TestRunCgaEggAccepted(t *testing.T) { + var out, errb bytes.Buffer + a := themeApp(t, &out, &errb) + // The egg flag is stripped before flag parsing, so the run proceeds normally + // (exit 1 on findings) rather than failing with "flag provided but not defined". + code := a.Run([]string{"-cga", "-type=Mixed", "pkg"}) + assert.Equal(t, 1, code) + assert.NotContains(t, errb.String(), "not defined") +} + +func TestRunUnknownThemeEnvWarns(t *testing.T) { + var out, errb bytes.Buffer + a := themeApp(t, &out, &errb) + t.Setenv("STRUCTALIGN_THEME", "bogus") + a.Run([]string{"-type=Mixed", "pkg"}) + assert.Contains(t, errb.String(), `unknown theme "bogus"`) +} + +func TestEggFlagNotInUsage(t *testing.T) { + var out, errb bytes.Buffer + a := &app.App{Stdout: &out, Stderr: &errb} + a.Run(nil) // no args => prints usage to stderr + assert.NotContains(t, errb.String(), "-cga", "easter-egg flags stay invisible in usage") + assert.NotContains(t, errb.String(), "-green") + assert.NotContains(t, errb.String(), "-amber") +} diff --git a/internal/ui/printer.go b/internal/ui/printer.go index ff88d9f..f99c53b 100644 --- a/internal/ui/printer.go +++ b/internal/ui/printer.go @@ -23,11 +23,43 @@ const ( cBold = "\x1b[1m" ) +// Theme maps semantic roles to ANSI SGR sequences. The zero value resolves to +// DefaultTheme (see Printer.theme), which reproduces the historical palette. +type Theme struct { + Header string // finding header / inspect "type X struct {" line + Added string // "+" diff lines / added side cells + Removed string // "-" diff lines / removed side cells + Meta string // column titles, divider, layout note, "-- assume" marker + Padding string // inspect padding comment / "_" padding line + Label string // the -summary "Summary:" label +} + +// DefaultTheme is the byte-for-byte historical palette. +func DefaultTheme() Theme { + return Theme{ + Header: cBold + cCyan, + Added: cGreen, + Removed: cRed, + Meta: cDim, + Padding: cRed, + Label: cBold, + } +} + // Printer renders to Out using the given color/width settings. type Printer struct { Out io.Writer Color bool - Width int // per-side column width for side-by-side diffs + Width int // per-side column width for side-by-side diffs + Theme Theme // zero value resolves to DefaultTheme +} + +// theme returns the configured theme, or DefaultTheme when unset. +func (p *Printer) theme() Theme { + if (p.Theme == Theme{}) { + return DefaultTheme() + } + return p.Theme } // RenderFindings renders each finding in the chosen diff style. Returns the count. @@ -50,7 +82,7 @@ func (p *Printer) RenderLayouts(layouts []common.Layout, verbose, keepTags bool) // label is bold when color is on; counts are pluralized. func (p *Printer) RenderSummary(structs int, bytesSaved int64) { fmt.Fprintf(p.Out, "%s %d %s affected, %d %s saved\n", //nolint:errcheck - paint(p.Color, cBold, "Summary:"), + paint(p.Color, p.theme().Label, "Summary:"), structs, plural(int64(structs), "struct", "structs"), bytesSaved, plural(bytesSaved, "byte", "bytes")) } @@ -76,7 +108,7 @@ func (p *Printer) renderFinding(f common.Finding, style common.DiffStyle) { pct := float64(f.OldSize-f.NewSize) / float64(f.OldSize) * 100 header += fmt.Sprintf(" (%02.2f%% smaller)", pct) } - fmt.Fprintln(p.Out, paint(p.Color, cBold+cCyan, header)) //nolint:errcheck + fmt.Fprintln(p.Out, paint(p.Color, p.theme().Header, header)) //nolint:errcheck if f.Original == "" || f.Proposed == "" { fmt.Fprintln(p.Out, " (no suggested fix produced)") //nolint:errcheck @@ -102,6 +134,7 @@ func (p *Printer) renderFinding(f common.Finding, style common.DiffStyle) { } func (p *Printer) renderUnified(a, b string) { + th := p.theme() al := strings.Split(a, "\n") bl := strings.Split(b, "\n") ops := textdiff.Lines(al, bl) @@ -110,14 +143,15 @@ func (p *Printer) renderUnified(a, b string) { case textdiff.Equal: fmt.Fprintf(p.Out, " %s\n", op.Text) //nolint:errcheck case textdiff.Del: - fmt.Fprintln(p.Out, paint(p.Color, cRed, "- "+op.Text)) //nolint:errcheck + fmt.Fprintln(p.Out, paint(p.Color, th.Removed, "- "+op.Text)) //nolint:errcheck case textdiff.Add: - fmt.Fprintln(p.Out, paint(p.Color, cGreen, "+ "+op.Text)) //nolint:errcheck + fmt.Fprintln(p.Out, paint(p.Color, th.Added, "+ "+op.Text)) //nolint:errcheck } } } func (p *Printer) renderSideBySide(a, b string) { + th := p.theme() al := strings.Split(a, "\n") bl := strings.Split(b, "\n") ops := textdiff.Lines(al, bl) @@ -156,15 +190,15 @@ func (p *Printer) renderSideBySide(a, b string) { var l, r string var lc, rc string if k < len(dels) { - l, lc = dels[k], cRed + l, lc = dels[k], th.Removed } if k < len(adds) { - r, rc = adds[k], cGreen + r, rc = adds[k], th.Added } rows = append(rows, row{l, r, lc, rc}) } case textdiff.Add: - rows = append(rows, row{"", op.Text, "", cGreen}) + rows = append(rows, row{"", op.Text, "", th.Added}) i++ } _ = pendDel @@ -177,10 +211,10 @@ func (p *Printer) renderSideBySide(a, b string) { // Pad the header text manually (not via %-*s) so it stays correct even // when paint() wraps it in ANSI escapes, which %-*s would miscount. fmt.Fprintf(p.Out, " %s%s%s\n", //nolint:errcheck - paint(p.Color, cDim, truncPad("current", p.Width)), + paint(p.Color, th.Meta, truncPad("current", p.Width)), sep, - paint(p.Color, cDim, "proposed")) - fmt.Fprintf(p.Out, " %s\n", paint(p.Color, cDim, //nolint:errcheck + paint(p.Color, th.Meta, "proposed")) + fmt.Fprintf(p.Out, " %s\n", paint(p.Color, th.Meta, //nolint:errcheck strings.Repeat("─", p.Width)+"─┼─"+strings.Repeat("─", p.Width))) for _, r := range rows { left := truncPad(r.l, p.Width) @@ -196,6 +230,7 @@ func (p *Printer) renderSideBySide(a, b string) { } func (p *Printer) renderLayout(l common.Layout, verbose, keepTags bool) { + th := p.theme() // Field declaration is " " (plus tag when -tags is set); align // all comments to the widest declaration. decls := make([]string, len(l.Fields)) @@ -213,13 +248,13 @@ func (p *Printer) renderLayout(l common.Layout, verbose, keepTags bool) { // Optional caveat (e.g. the generic-type disclaimer) above the declaration. if l.Note != "" { - fmt.Fprintln(p.Out, paint(p.Color, cDim, "// "+l.Note)) //nolint:errcheck + fmt.Fprintln(p.Out, paint(p.Color, th.Meta, "// "+l.Note)) //nolint:errcheck } // Header: the struct opening line carries size/align/padding. header := fmt.Sprintf("type %s%s struct { // size: %d, align: %d, padding: %d", l.Name, l.TypeParams, l.Total, l.Align, l.Padding) - fmt.Fprintln(p.Out, paint(p.Color, cBold+cCyan, header)) //nolint:errcheck + fmt.Fprintln(p.Out, paint(p.Color, th.Header, header)) //nolint:errcheck comments, commentWidth := layoutComments(l.Fields, verbose) @@ -227,18 +262,18 @@ func (p *Printer) renderLayout(l common.Layout, verbose, keepTags bool) { comment := comments[i] rendered := comment if !verbose && f.Padding > 0 { - rendered = paint(p.Color, cRed, comment) + rendered = paint(p.Color, th.Padding, comment) } line := fmt.Sprintf("\t%-*s // %s", declWidth, decls[i], rendered) if f.Assume != "" { pad := strings.Repeat(" ", commentWidth-len(comment)) - line += pad + " " + paint(p.Color, cDim, "-- assume "+f.Assume) + line += pad + " " + paint(p.Color, th.Meta, "-- assume "+f.Assume) } fmt.Fprintln(p.Out, line) //nolint:errcheck if verbose && f.Padding > 0 { // Field line carries no padding; padding gets its own `_` line. pad := fmt.Sprintf("\t%-*s // %d byte padding", declWidth, "_", f.Padding) - fmt.Fprintln(p.Out, paint(p.Color, cRed, pad)) //nolint:errcheck + fmt.Fprintln(p.Out, paint(p.Color, th.Padding, pad)) //nolint:errcheck } } fmt.Fprintln(p.Out, "}") //nolint:errcheck diff --git a/internal/ui/theme_test.go b/internal/ui/theme_test.go new file mode 100644 index 0000000..ffdbfe9 --- /dev/null +++ b/internal/ui/theme_test.go @@ -0,0 +1,44 @@ +package ui_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/peczenyj/structalign/internal/ui" +) + +// The default theme must reproduce the historical hardcoded ANSI palette, so +// existing golden output is byte-for-byte unchanged. +func TestDefaultThemeMatchesLegacyConstants(t *testing.T) { + th := ui.DefaultTheme() + assert.Equal(t, "\x1b[1m\x1b[36m", th.Header, "header was bold+cyan") + assert.Equal(t, "\x1b[32m", th.Added, "added was green") + assert.Equal(t, "\x1b[31m", th.Removed, "removed was red") + assert.Equal(t, "\x1b[2m", th.Meta, "meta was dim") + assert.Equal(t, "\x1b[31m", th.Padding, "padding was red") + assert.Equal(t, "\x1b[1m", th.Label, "label was bold") +} + +func TestThemeByNameKnown(t *testing.T) { + for _, name := range []string{"default", "cga", "green", "amber"} { + _, ok := ui.ThemeByName(name) + assert.True(t, ok, "theme %q should exist", name) + } +} + +func TestThemeByNameUnknown(t *testing.T) { + _, ok := ui.ThemeByName("nope") + assert.False(t, ok) +} + +// The green (P1 phosphor) theme is monochrome: it must never use red (31), +// since add/removed are distinguished by intensity + the +/- prefixes. +func TestGreenThemeIsMonochrome(t *testing.T) { + th, ok := ui.ThemeByName("green") + assert.True(t, ok) + for _, sgr := range []string{th.Header, th.Added, th.Removed, th.Meta, th.Padding, th.Label} { + assert.NotContains(t, sgr, "31", "green theme must not use red") + assert.Contains(t, sgr, "32", "green theme uses the green family") + } +} diff --git a/internal/ui/themes.go b/internal/ui/themes.go new file mode 100644 index 0000000..05b1427 --- /dev/null +++ b/internal/ui/themes.go @@ -0,0 +1,38 @@ +package ui + +// builtinThemes holds the named palettes. "default" mirrors DefaultTheme(). +// The monochrome themes (green/amber) use a single hue and rely on intensity +// (bold vs dim) plus the +/- diff prefixes to distinguish added from removed. +var builtinThemes = map[string]Theme{ + "default": DefaultTheme(), + "cga": { + Header: "\x1b[1m\x1b[96m", // bright cyan + Added: "\x1b[92m", // bright green + Removed: "\x1b[91m", // bright red + Meta: "\x1b[90m", // bright black (gray) + Padding: "\x1b[91m", // bright red + Label: "\x1b[1m\x1b[97m", // bright white + }, + "green": { + Header: "\x1b[1m\x1b[32m", // bold green + Added: "\x1b[1m\x1b[32m", // bold green (distinguished by the "+" prefix) + Removed: "\x1b[2m\x1b[32m", // dim green (distinguished by the "-" prefix) + Meta: "\x1b[2m\x1b[32m", // dim green + Padding: "\x1b[2m\x1b[32m", // dim green + Label: "\x1b[1m\x1b[32m", // bold green + }, + "amber": { + Header: "\x1b[1m\x1b[38;5;214m", // bold amber (256-color) + Added: "\x1b[1m\x1b[38;5;214m", + Removed: "\x1b[2m\x1b[38;5;214m", // dim amber + Meta: "\x1b[2m\x1b[38;5;214m", + Padding: "\x1b[2m\x1b[38;5;214m", + Label: "\x1b[1m\x1b[38;5;214m", + }, +} + +// ThemeByName returns a built-in theme and whether it was found. +func ThemeByName(name string) (Theme, bool) { + th, ok := builtinThemes[name] + return th, ok +}