From a5a3744f186d37c07a16d9fc7972c9c5c84e0583 Mon Sep 17 00:00:00 2001 From: VeteranBV Date: Mon, 13 Jul 2026 21:52:08 +0000 Subject: [PATCH 01/12] feat: adapt banner to terminal width with graceful fallbacks The banner previously rendered at full FIGlet width and let the terminal wrap it, which turned narrow windows (tmux panes, phone SSH sessions) into garbled block characters. Sysgreet now detects the terminal width and walks a fallback ladder before printing: full hostname in the configured font, hostname without its domain (full name moves to an info line), progressively narrower fonts ending with the newly embedded Small font, and finally a single ruled header line that fits any width. Body lines that still overflow are clipped with an ellipsis. Color handling moves into a new internal/terminal package that owns the ANSI palette (previously duplicated and drifted between ascii and render) and detects capability once at startup. This fixes NO_COLOR only applying to resource highlights while the banner gradient kept its escapes, stops ANSI codes leaking into piped output, honors TERM=dumb, and enables virtual terminal processing on Windows so legacy consoles get plain text instead of raw escapes. Truecolor terminals now get a smooth 24-bit gradient interpolated from the configured stops. Also fixed along the way: --text and --demo previously ignored the loaded config entirely; compact mode embedded the multi-line art in its "single line" output; and a typo in ascii.font silently picked a random font every login instead of falling back to the default. New: --json for scripting (structured sections with raw percentages, never prompts), --font, --width, --no-color, --config, --list-fonts, and layout.max_width / SYSGREET_LAYOUT_MAX_WIDTH to cap the width. --- .gitignore | 2 +- assets/fonts/Small.flf | 1097 ++++++++++++++++++++++ cmd/sysgreet/console_unix.go | 11 + cmd/sysgreet/console_windows.go | 25 + cmd/sysgreet/main.go | 213 +++-- configs/example.toml | 7 +- configs/example.yaml | 7 +- go.mod | 4 +- go.sum | 2 + internal/ascii/ascii.go | 26 +- internal/ascii/ascii_test.go | 246 ++++- internal/ascii/fonts.go | 290 +++++- internal/banner/banner.go | 48 +- internal/banner/banner_test.go | 52 +- internal/config/config.go | 9 + internal/config/config_test.go | 51 + internal/config/defaults.go | 5 +- internal/render/color.go | 58 +- internal/render/color_test.go | 137 +-- internal/render/json.go | 50 + internal/render/layout.go | 44 +- internal/render/layout_test.go | 117 ++- internal/terminal/terminal.go | 168 ++++ internal/terminal/terminal_test.go | 135 +++ test/benchmarks/startup_bench_test.go | 3 +- test/integration/binary_test.go | 131 +++ test/integration/linux/config_test.go | 5 +- test/integration/windows/session_test.go | 5 +- 28 files changed, 2581 insertions(+), 367 deletions(-) create mode 100644 assets/fonts/Small.flf create mode 100644 cmd/sysgreet/console_unix.go create mode 100644 cmd/sysgreet/console_windows.go create mode 100644 internal/render/json.go create mode 100644 internal/terminal/terminal.go create mode 100644 internal/terminal/terminal_test.go diff --git a/.gitignore b/.gitignore index c1072c7..9e1babf 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,7 @@ RELEASE.md # Go build artifacts bin/ -sysgreet +/sysgreet *.test *.exe *.exe~ diff --git a/assets/fonts/Small.flf b/assets/fonts/Small.flf new file mode 100644 index 0000000..c6b5bfc --- /dev/null +++ b/assets/fonts/Small.flf @@ -0,0 +1,1097 @@ +flf2a$ 5 4 13 15 10 0 22415 96 +Small by Glenn Chappell 4/93 -- based on Standard +Includes ISO Latin-1 +figlet release 2.1 -- 12 Aug 1994 +Permission is hereby given to modify this font, as long as the +modifier's name is placed on a comment line. + +Modified by Paul Burton 12/96 to include new parameter +supported by FIGlet and FIGWin. May also be slightly modified for better use +of new full-width/kern/smush alternatives, but default output is NOT changed. + + $@ + $@ + $@ + $@ + $@@ + _ @ + | |@ + |_|@ + (_)@ + @@ + _ _ @ + ( | )@ + V V @ + $ @ + @@ + _ _ @ + _| | |_ @ + |_ . _|@ + |_ _|@ + |_|_| @@ + @ + ||_@ + (_-<@ + / _/@ + || @@ + _ __ @ + (_)/ / @ + / /_ @ + /_/(_)@ + @@ + __ @ + / _|___ @ + > _|_ _|@ + \_____| @ + @@ + _ @ + ( )@ + |/ @ + $ @ + @@ + __@ + / /@ + | | @ + | | @ + \_\@@ + __ @ + \ \ @ + | |@ + | |@ + /_/ @@ + @ + _/\_@ + > <@ + \/ @ + @@ + _ @ + _| |_ @ + |_ _|@ + |_| @ + @@ + @ + @ + _ @ + ( )@ + |/ @@ + @ + ___ @ + |___|@ + $ @ + @@ + @ + @ + _ @ + (_)@ + @@ + __@ + / /@ + / / @ + /_/ @ + @@ + __ @ + / \ @ + | () |@ + \__/ @ + @@ + _ @ + / |@ + | |@ + |_|@ + @@ + ___ @ + |_ )@ + / / @ + /___|@ + @@ + ____@ + |__ /@ + |_ \@ + |___/@ + @@ + _ _ @ + | | | @ + |_ _|@ + |_| @ + @@ + ___ @ + | __|@ + |__ \@ + |___/@ + @@ + __ @ + / / @ + / _ \@ + \___/@ + @@ + ____ @ + |__ |@ + / / @ + /_/ @ + @@ + ___ @ + ( _ )@ + / _ \@ + \___/@ + @@ + ___ @ + / _ \@ + \_, /@ + /_/ @ + @@ + _ @ + (_)@ + _ @ + (_)@ + @@ + _ @ + (_)@ + _ @ + ( )@ + |/ @@ + __@ + / /@ + < < @ + \_\@ + @@ + @ + ___ @ + |___|@ + |___|@ + @@ + __ @ + \ \ @ + > >@ + /_/ @ + @@ + ___ @ + |__ \@ + /_/@ + (_) @ + @@ + ____ @ + / __ \ @ + / / _` |@ + \ \__,_|@ + \____/ @@ + _ @ + /_\ @ + / _ \ @ + /_/ \_\@ + @@ + ___ @ + | _ )@ + | _ \@ + |___/@ + @@ + ___ @ + / __|@ + | (__ @ + \___|@ + @@ + ___ @ + | \ @ + | |) |@ + |___/ @ + @@ + ___ @ + | __|@ + | _| @ + |___|@ + @@ + ___ @ + | __|@ + | _| @ + |_| @ + @@ + ___ @ + / __|@ + | (_ |@ + \___|@ + @@ + _ _ @ + | || |@ + | __ |@ + |_||_|@ + @@ + ___ @ + |_ _|@ + | | @ + |___|@ + @@ + _ @ + _ | |@ + | || |@ + \__/ @ + @@ + _ __@ + | |/ /@ + | ' < @ + |_|\_\@ + @@ + _ @ + | | @ + | |__ @ + |____|@ + @@ + __ __ @ + | \/ |@ + | |\/| |@ + |_| |_|@ + @@ + _ _ @ + | \| |@ + | .` |@ + |_|\_|@ + @@ + ___ @ + / _ \ @ + | (_) |@ + \___/ @ + @@ + ___ @ + | _ \@ + | _/@ + |_| @ + @@ + ___ @ + / _ \ @ + | (_) |@ + \__\_\@ + @@ + ___ @ + | _ \@ + | /@ + |_|_\@ + @@ + ___ @ + / __|@ + \__ \@ + |___/@ + @@ + _____ @ + |_ _|@ + | | @ + |_| @ + @@ + _ _ @ + | | | |@ + | |_| |@ + \___/ @ + @@ + __ __@ + \ \ / /@ + \ V / @ + \_/ @ + @@ + __ __@ + \ \ / /@ + \ \/\/ / @ + \_/\_/ @ + @@ + __ __@ + \ \/ /@ + > < @ + /_/\_\@ + @@ + __ __@ + \ \ / /@ + \ V / @ + |_| @ + @@ + ____@ + |_ /@ + / / @ + /___|@ + @@ + __ @ + | _|@ + | | @ + | | @ + |__|@@ + __ @ + \ \ @ + \ \ @ + \_\@ + @@ + __ @ + |_ |@ + | |@ + | |@ + |__|@@ + /\ @ + |/\|@ + $ @ + $ @ + @@ + @ + @ + @ + ___ @ + |___|@@ + _ @ + ( )@ + \|@ + $ @ + @@ + @ + __ _ @ + / _` |@ + \__,_|@ + @@ + _ @ + | |__ @ + | '_ \@ + |_.__/@ + @@ + @ + __ @ + / _|@ + \__|@ + @@ + _ @ + __| |@ + / _` |@ + \__,_|@ + @@ + @ + ___ @ + / -_)@ + \___|@ + @@ + __ @ + / _|@ + | _|@ + |_| @ + @@ + @ + __ _ @ + / _` |@ + \__, |@ + |___/ @@ + _ @ + | |_ @ + | ' \ @ + |_||_|@ + @@ + _ @ + (_)@ + | |@ + |_|@ + @@ + _ @ + (_)@ + | |@ + _/ |@ + |__/ @@ + _ @ + | |__@ + | / /@ + |_\_\@ + @@ + _ @ + | |@ + | |@ + |_|@ + @@ + @ + _ __ @ + | ' \ @ + |_|_|_|@ + @@ + @ + _ _ @ + | ' \ @ + |_||_|@ + @@ + @ + ___ @ + / _ \@ + \___/@ + @@ + @ + _ __ @ + | '_ \@ + | .__/@ + |_| @@ + @ + __ _ @ + / _` |@ + \__, |@ + |_|@@ + @ + _ _ @ + | '_|@ + |_| @ + @@ + @ + ___@ + (_-<@ + /__/@ + @@ + _ @ + | |_ @ + | _|@ + \__|@ + @@ + @ + _ _ @ + | || |@ + \_,_|@ + @@ + @ + __ __@ + \ V /@ + \_/ @ + @@ + @ + __ __ __@ + \ V V /@ + \_/\_/ @ + @@ + @ + __ __@ + \ \ /@ + /_\_\@ + @@ + @ + _ _ @ + | || |@ + \_, |@ + |__/ @@ + @ + ___@ + |_ /@ + /__|@ + @@ + __@ + / /@ + _| | @ + | | @ + \_\@@ + _ @ + | |@ + | |@ + | |@ + |_|@@ + __ @ + \ \ @ + | |_@ + | | @ + /_/ @@ + /\/|@ + |/\/ @ + $ @ + $ @ + @@ + _ _ @ + (_)(_)@ + /--\ @ + /_/\_\@ + @@ + _ _ @ + (_)(_)@ + / __ \@ + \____/@ + @@ + _ _ @ + (_) (_)@ + | |_| |@ + \___/ @ + @@ + _ _ @ + (_)(_)@ + / _` |@ + \__,_|@ + @@ + _ _ @ + (_)_(_)@ + / _ \ @ + \___/ @ + @@ + _ _ @ + (_)(_)@ + | || |@ + \_,_|@ + @@ + ___ @ + / _ \@ + | |< <@ + | ||_/@ + |_| @@ +160 NO-BREAK SPACE + $@ + $@ + $@ + $@ + $@@ +161 INVERTED EXCLAMATION MARK + _ @ + (_)@ + | |@ + |_|@ + @@ +162 CENT SIGN + @ + || @ + / _)@ + \ _)@ + || @@ +163 POUND SIGN + __ @ + _/ _\ @ + |_ _|_ @ + (_,___|@ + @@ +164 CURRENCY SIGN + /\_/\@ + \ . /@ + / _ \@ + \/ \/@ + @@ +165 YEN SIGN + __ __ @ + \ V / @ + |__ __|@ + |__ __|@ + |_| @@ +166 BROKEN BAR + _ @ + | |@ + |_|@ + | |@ + |_|@@ +167 SECTION SIGN + __ @ + / _)@ + /\ \ @ + \ \/ @ + (__/ @@ +168 DIAERESIS + _ _ @ + (_)(_)@ + $ $ @ + $ $ @ + @@ +169 COPYRIGHT SIGN + ____ @ + / __ \ @ + / / _| \@ + \ \__| /@ + \____/ @@ +170 FEMININE ORDINAL INDICATOR + __ _ @ + / _` |@ + \__,_|@ + |____|@ + @@ +171 LEFT-POINTING DOUBLE ANGLE QUOTATION MARK + ____@ + / / /@ + < < < @ + \_\_\@ + @@ +172 NOT SIGN + ____ @ + |__ |@ + |_|@ + $ @ + @@ +173 SOFT HYPHEN + @ + __ @ + |__|@ + $ @ + @@ +174 REGISTERED SIGN + ____ @ + / __ \ @ + / | -) \@ + \ ||\\ /@ + \____/ @@ +175 MACRON + ___ @ + |___|@ + $ @ + $ @ + @@ +176 DEGREE SIGN + _ @ + /.\@ + \_/@ + $ @ + @@ +177 PLUS-MINUS SIGN + _ @ + _| |_ @ + |_ _|@ + _|_|_ @ + |_____|@@ +178 SUPERSCRIPT TWO + __ @ + |_ )@ + /__|@ + $ @ + @@ +179 SUPERSCRIPT THREE + ___@ + |_ /@ + |__)@ + $ @ + @@ +180 ACUTE ACCENT + __@ + /_/@ + $ @ + $ @ + @@ +181 MICRO SIGN + @ + _ _ @ + | || |@ + | .,_|@ + |_| @@ +182 PILCROW SIGN + ____ @ + / |@ + \_ | |@ + |_|_|@ + @@ +183 MIDDLE DOT + @ + _ @ + (_)@ + $ @ + @@ +184 CEDILLA + @ + @ + @ + _ @ + )_)@@ +185 SUPERSCRIPT ONE + _ @ + / |@ + |_|@ + $ @ + @@ +186 MASCULINE ORDINAL INDICATOR + ___ @ + / _ \@ + \___/@ + |___|@ + @@ +187 RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK + ____ @ + \ \ \ @ + > > >@ + /_/_/ @ + @@ +188 VULGAR FRACTION ONE QUARTER + _ __ @ + / |/ /__ @ + |_/ /_' |@ + /_/ |_|@ + @@ +189 VULGAR FRACTION ONE HALF + _ __ @ + / |/ /_ @ + |_/ /_ )@ + /_//__|@ + @@ +190 VULGAR FRACTION THREE QUARTERS + ___ __ @ + |_ // /__ @ + |__) /_' |@ + /_/ |_|@ + @@ +191 INVERTED QUESTION MARK + _ @ + (_) @ + / /_ @ + \___|@ + @@ +192 LATIN CAPITAL LETTER A WITH GRAVE + __ @ + \_\ @ + /--\ @ + /_/\_\@ + @@ +193 LATIN CAPITAL LETTER A WITH ACUTE + __ @ + /_/ @ + /--\ @ + /_/\_\@ + @@ +194 LATIN CAPITAL LETTER A WITH CIRCUMFLEX + /\ @ + |/\| @ + /--\ @ + /_/\_\@ + @@ +195 LATIN CAPITAL LETTER A WITH TILDE + /\/|@ + |/\/ @ + /--\ @ + /_/\_\@ + @@ +196 LATIN CAPITAL LETTER A WITH DIAERESIS + _ _ @ + (_)(_)@ + /--\ @ + /_/\_\@ + @@ +197 LATIN CAPITAL LETTER A WITH RING ABOVE + __ @ + (()) @ + /--\ @ + /_/\_\@ + @@ +198 LATIN CAPITAL LETTER AE + ____ @ + /, __|@ + / _ _| @ + /_/|___|@ + @@ +199 LATIN CAPITAL LETTER C WITH CEDILLA + ___ @ + / __|@ + | (__ @ + \___|@ + )_) @@ +200 LATIN CAPITAL LETTER E WITH GRAVE + __ @ + \_\@ + | -<@ + |__<@ + @@ +201 LATIN CAPITAL LETTER E WITH ACUTE + __@ + /_/@ + | -<@ + |__<@ + @@ +202 LATIN CAPITAL LETTER E WITH CIRCUMFLEX + /\ @ + |/\|@ + | -<@ + |__<@ + @@ +203 LATIN CAPITAL LETTER E WITH DIAERESIS + _ _ @ + (_)(_)@ + | -< @ + |__< @ + @@ +204 LATIN CAPITAL LETTER I WITH GRAVE + __ @ + \_\ @ + |_ _|@ + |___|@ + @@ +205 LATIN CAPITAL LETTER I WITH ACUTE + __ @ + /_/ @ + |_ _|@ + |___|@ + @@ +206 LATIN CAPITAL LETTER I WITH CIRCUMFLEX + //\ @ + |/_\|@ + |_ _|@ + |___|@ + @@ +207 LATIN CAPITAL LETTER I WITH DIAERESIS + _ _ @ + (_)_(_)@ + |_ _| @ + |___| @ + @@ +208 LATIN CAPITAL LETTER ETH + ____ @ + | __ \ @ + |_ _|) |@ + |____/ @ + @@ +209 LATIN CAPITAL LETTER N WITH TILDE + /\/|@ + |/\/ @ + | \| |@ + |_|\_|@ + @@ +210 LATIN CAPITAL LETTER O WITH GRAVE + __ @ + \_\_ @ + / __ \@ + \____/@ + @@ +211 LATIN CAPITAL LETTER O WITH ACUTE + __ @ + _/_/ @ + / __ \@ + \____/@ + @@ +212 LATIN CAPITAL LETTER O WITH CIRCUMFLEX + /\ @ + |/\| @ + / __ \@ + \____/@ + @@ +213 LATIN CAPITAL LETTER O WITH TILDE + /\/|@ + |/\/ @ + / __ \@ + \____/@ + @@ +214 LATIN CAPITAL LETTER O WITH DIAERESIS + _ _ @ + (_)(_)@ + / __ \@ + \____/@ + @@ +215 MULTIPLICATION SIGN + @ + /\/\@ + > <@ + \/\/@ + @@ +216 LATIN CAPITAL LETTER O WITH STROKE + ____ @ + / _//\ @ + | (//) |@ + \//__/ @ + @@ +217 LATIN CAPITAL LETTER U WITH GRAVE + __ @ + _\_\_ @ + | |_| |@ + \___/ @ + @@ +218 LATIN CAPITAL LETTER U WITH ACUTE + __ @ + _/_/_ @ + | |_| |@ + \___/ @ + @@ +219 LATIN CAPITAL LETTER U WITH CIRCUMFLEX + //\ @ + |/ \| @ + | |_| |@ + \___/ @ + @@ +220 LATIN CAPITAL LETTER U WITH DIAERESIS + _ _ @ + (_) (_)@ + | |_| |@ + \___/ @ + @@ +221 LATIN CAPITAL LETTER Y WITH ACUTE + __ @ + _/_/_@ + \ V /@ + |_| @ + @@ +222 LATIN CAPITAL LETTER THORN + _ @ + | |_ @ + | -_)@ + |_| @ + @@ +223 LATIN SMALL LETTER SHARP S + ___ @ + / _ \@ + | |< <@ + | ||_/@ + |_| @@ +224 LATIN SMALL LETTER A WITH GRAVE + __ @ + \_\_ @ + / _` |@ + \__,_|@ + @@ +225 LATIN SMALL LETTER A WITH ACUTE + __ @ + _/_/ @ + / _` |@ + \__,_|@ + @@ +226 LATIN SMALL LETTER A WITH CIRCUMFLEX + /\ @ + |/\| @ + / _` |@ + \__,_|@ + @@ +227 LATIN SMALL LETTER A WITH TILDE + /\/|@ + |/\/ @ + / _` |@ + \__,_|@ + @@ +228 LATIN SMALL LETTER A WITH DIAERESIS + _ _ @ + (_)(_)@ + / _` |@ + \__,_|@ + @@ +229 LATIN SMALL LETTER A WITH RING ABOVE + __ @ + (()) @ + / _` |@ + \__,_|@ + @@ +230 LATIN SMALL LETTER AE + @ + __ ___ @ + / _` -_)@ + \__,___|@ + @@ +231 LATIN SMALL LETTER C WITH CEDILLA + @ + __ @ + / _|@ + \__|@ + )_)@@ +232 LATIN SMALL LETTER E WITH GRAVE + __ @ + \_\ @ + / -_)@ + \___|@ + @@ +233 LATIN SMALL LETTER E WITH ACUTE + __ @ + /_/ @ + / -_)@ + \___|@ + @@ +234 LATIN SMALL LETTER E WITH CIRCUMFLEX + //\ @ + |/_\|@ + / -_)@ + \___|@ + @@ +235 LATIN SMALL LETTER E WITH DIAERESIS + _ _ @ + (_)_(_)@ + / -_) @ + \___| @ + @@ +236 LATIN SMALL LETTER I WITH GRAVE + __ @ + \_\@ + | |@ + |_|@ + @@ +237 LATIN SMALL LETTER I WITH ACUTE + __@ + /_/@ + | |@ + |_|@ + @@ +238 LATIN SMALL LETTER I WITH CIRCUMFLEX + //\ @ + |/_\|@ + | | @ + |_| @ + @@ +239 LATIN SMALL LETTER I WITH DIAERESIS + _ _ @ + (_)_(_)@ + | | @ + |_| @ + @@ +240 LATIN SMALL LETTER ETH + \\/\ @ + \/\\ @ + / _` |@ + \___/ @ + @@ +241 LATIN SMALL LETTER N WITH TILDE + /\/| @ + |/\/ @ + | ' \ @ + |_||_|@ + @@ +242 LATIN SMALL LETTER O WITH GRAVE + __ @ + \_\ @ + / _ \@ + \___/@ + @@ +243 LATIN SMALL LETTER O WITH ACUTE + __ @ + /_/ @ + / _ \@ + \___/@ + @@ +244 LATIN SMALL LETTER O WITH CIRCUMFLEX + //\ @ + |/_\|@ + / _ \@ + \___/@ + @@ +245 LATIN SMALL LETTER O WITH TILDE + /\/|@ + |/\/ @ + / _ \@ + \___/@ + @@ +246 LATIN SMALL LETTER O WITH DIAERESIS + _ _ @ + (_)_(_)@ + / _ \ @ + \___/ @ + @@ +247 DIVISION SIGN + _ @ + (_) @ + |___|@ + (_) @ + @@ +248 LATIN SMALL LETTER O WITH STROKE + @ + ___ @ + / //\@ + \//_/@ + @@ +249 LATIN SMALL LETTER U WITH GRAVE + __ @ + \_\_ @ + | || |@ + \_,_|@ + @@ +250 LATIN SMALL LETTER U WITH ACUTE + __ @ + _/_/ @ + | || |@ + \_,_|@ + @@ +251 LATIN SMALL LETTER U WITH CIRCUMFLEX + /\ @ + |/\| @ + | || |@ + \_,_|@ + @@ +252 LATIN SMALL LETTER U WITH DIAERESIS + _ _ @ + (_)(_)@ + | || |@ + \_,_|@ + @@ +253 LATIN SMALL LETTER Y WITH ACUTE + __ @ + _/_/ @ + | || |@ + \_, |@ + |__/ @@ +254 LATIN SMALL LETTER THORN + _ @ + | |__ @ + | '_ \@ + | .__/@ + |_| @@ +255 LATIN SMALL LETTER Y WITH DIAERESIS + _ _ @ + (_)(_)@ + | || |@ + \_, |@ + |__/ @@ diff --git a/cmd/sysgreet/console_unix.go b/cmd/sysgreet/console_unix.go new file mode 100644 index 0000000..e34b6c7 --- /dev/null +++ b/cmd/sysgreet/console_unix.go @@ -0,0 +1,11 @@ +//go:build !windows + +package main + +import "os" + +// enableVirtualTerminal is a no-op outside Windows; every supported Unix +// terminal understands ANSI escapes natively. +func enableVirtualTerminal(_ *os.File) bool { + return true +} diff --git a/cmd/sysgreet/console_windows.go b/cmd/sysgreet/console_windows.go new file mode 100644 index 0000000..dc2392b --- /dev/null +++ b/cmd/sysgreet/console_windows.go @@ -0,0 +1,25 @@ +//go:build windows + +package main + +import ( + "os" + + "golang.org/x/sys/windows" +) + +// enableVirtualTerminal switches the Windows console into VT processing so +// ANSI escapes render as colors instead of literal text. Returns false when +// the console refuses (legacy conhost), in which case output stays plain. +func enableVirtualTerminal(out *os.File) bool { + handle := windows.Handle(out.Fd()) + var mode uint32 + if err := windows.GetConsoleMode(handle, &mode); err != nil { + // Not a console (piped/redirected); nothing to enable. + return true + } + if mode&windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING != 0 { + return true + } + return windows.SetConsoleMode(handle, mode|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING) == nil +} diff --git a/cmd/sysgreet/main.go b/cmd/sysgreet/main.go index 149080c..b8cc56d 100644 --- a/cmd/sysgreet/main.go +++ b/cmd/sysgreet/main.go @@ -13,6 +13,7 @@ import ( "github.com/veteranbv/sysgreet/internal/collectors" "github.com/veteranbv/sysgreet/internal/config" "github.com/veteranbv/sysgreet/internal/render" + "github.com/veteranbv/sysgreet/internal/terminal" ) var ( @@ -22,101 +23,138 @@ var ( ) func main() { + if err := run(); err != nil { + if errors.Is(err, bootstrap.ErrUserCanceled) { + return + } + fmt.Fprintf(os.Stderr, "sysgreet: %v\n", err) + os.Exit(1) + } +} + +func run() error { ctx := context.Background() settings := parseFlags() if settings.Version { fmt.Printf("sysgreet %s (commit: %s, built: %s)\n", version, commit, date) - return + return nil } - - policyEnv := os.Getenv("SYSGREET_CONFIG_POLICY") - interactive := resolveInteractivity() if settings.Disable { - return + return nil + } + if settings.ConfigPath != "" { + // Reuse the SYSGREET_CONFIG plumbing so load and bootstrap agree + // on the path. + if err := os.Setenv("SYSGREET_CONFIG", settings.ConfigPath); err != nil { + return err + } } renderer, err := ascii.NewRenderer() if err != nil { - fmt.Fprintf(os.Stderr, "sysgreet: %v\n", err) - os.Exit(1) + return err } - if handled, err := runTextMode(renderer, settings.Text); err != nil { - fmt.Fprintf(os.Stderr, "sysgreet: %v\n", err) - os.Exit(1) - } else if handled { - return - } - - if handled, err := runDemoMode(renderer, settings.Demo); err != nil { - fmt.Fprintf(os.Stderr, "sysgreet: %v\n", err) - os.Exit(1) - } else if handled { - return + if settings.ListFonts { + for _, font := range renderer.Fonts() { + fmt.Println(font) + } + return nil } - // Normal mode: bootstrap config and collect real data - cfgPath := config.DefaultWritePath() - if err := maybeBootstrap(ctx, cfgPath, settings.PolicyFlag, policyEnv, interactive, bootstrap.IO{Stdin: os.Stdin, Stdout: os.Stdout, Stderr: os.Stderr}); err != nil { - if errors.Is(err, bootstrap.ErrUserCanceled) { - return - } - fmt.Fprintf(os.Stderr, "sysgreet: %v\n", err) - os.Exit(1) + // Legacy Windows consoles need virtual terminal processing switched on + // before any escape sequences are written; when that fails, fall back + // to plain output rather than printing raw escapes. + ansiOK := enableVirtualTerminal(os.Stdout) + env := terminal.DetectEnv(os.Stdout, settings.NoColor || !ansiOK) + if settings.Width > 0 { + env.Width = settings.Width } - cfg, _, err := config.Load() + cfg, err := loadConfig(ctx, settings) if err != nil { - fmt.Fprintf(os.Stderr, "sysgreet: %v\n", err) - os.Exit(1) + return err } - providers := collectors.Providers{ - System: collectors.NewSystemCollector(), - Network: collectors.NewNetworkCollector(cfg.Network.MaxInterfaces), - Resources: collectors.NewResourceCollector(), - Session: collectors.NewSessionCollector(), - LastLogin: collectors.NewLastLoginCollector(), + if settings.Text != "" { + return runTextMode(renderer, settings.Text, cfg, env) } - hostBanner, err := banner.New(providers, renderer, banner.BuildersForConfig(cfg)) + output, err := buildBanner(ctx, renderer, cfg, env, settings.Demo) if err != nil { - fmt.Fprintf(os.Stderr, "sysgreet: %v\n", err) - os.Exit(1) + return err } + return printBanner(output, cfg, env, settings.JSON) +} - output, _, err := hostBanner.Build(ctx, cfg) +// loadConfig bootstraps a config on first run and loads it. --text, --demo, +// and --json are one-shot or scripted invocations that must never prompt, +// so bootstrap only runs in normal mode. +func loadConfig(ctx context.Context, settings runSettings) (config.Config, error) { + if settings.Text == "" && !settings.Demo && !settings.JSON { + if err := maybeBootstrap(ctx, settings); err != nil { + return config.Config{}, err + } + } + cfg, _, err := config.Load() if err != nil { - fmt.Fprintf(os.Stderr, "sysgreet: %v\n", err) - os.Exit(1) + return config.Config{}, err } + if settings.Font != "" { + cfg.ASCII.Font = settings.Font + } + return cfg, nil +} - layout := render.NewRenderer(cfg.ASCII.Monochrome) - fmt.Println(layout.Render(output, cfg)) +func printBanner(output banner.Output, cfg config.Config, env terminal.Env, asJSON bool) error { + if asJSON { + doc, err := render.RenderJSON(output, cfg) + if err != nil { + return err + } + fmt.Println(doc) + return nil + } + fmt.Println(render.NewRenderer(env).Render(output, cfg)) + return nil } type runSettings struct { PolicyFlag string + ConfigPath string + Font string + Width int Disable bool Demo bool + JSON bool + ListFonts bool + NoColor bool Text string Version bool } func parseFlags() runSettings { policyFlag := flag.String("config-policy", "", "Config bootstrap policy: prompt, keep, or overwrite") + configPath := flag.String("config", "", "Path to a config file (overrides default lookup)") + font := flag.String("font", "", "Font override for this run (see --list-fonts)") + width := flag.Int("width", 0, "Assume this terminal width instead of detecting it") disable := flag.Bool("disable", false, "Disable sysgreet output") demo := flag.Bool("demo", false, "Demo mode with 'SYSGREET' banner and fake data") + jsonOut := flag.Bool("json", false, "Emit the banner as JSON for scripting") + listFonts := flag.Bool("list-fonts", false, "List embedded fonts and exit") + noColor := flag.Bool("no-color", false, "Disable colored output") text := flag.String("text", "", "Render custom text as ASCII art (e.g., --text \"Tea Pot\")") showVersion := flag.Bool("version", false, "Show version information") flag.Usage = func() { fmt.Fprintf(flag.CommandLine.Output(), "Usage of %s\n", os.Args[0]) flag.PrintDefaults() fmt.Fprintln(flag.CommandLine.Output(), "\nEnvironment variables:") + fmt.Fprintln(flag.CommandLine.Output(), " SYSGREET_CONFIG Config file path (same as --config)") fmt.Fprintln(flag.CommandLine.Output(), " SYSGREET_CONFIG_POLICY Config bootstrap policy (prompt|keep|overwrite)") fmt.Fprintln(flag.CommandLine.Output(), " SYSGREET_ASSUME_TTY Force interactive prompts (testing/support)") + fmt.Fprintln(flag.CommandLine.Output(), " NO_COLOR Disable colored output (same as --no-color)") fmt.Fprintln(flag.CommandLine.Output(), " CI When set, disables interactive prompts by default") fmt.Fprintln(flag.CommandLine.Output(), "\nBootstrap:") fmt.Fprintln(flag.CommandLine.Output(), " First run writes curated defaults (ANSI Regular font with gradient, metadata).") @@ -125,60 +163,78 @@ func parseFlags() runSettings { flag.Parse() return runSettings{ PolicyFlag: *policyFlag, + ConfigPath: *configPath, + Font: *font, + Width: *width, Disable: *disable, Demo: *demo, + JSON: *jsonOut, + ListFonts: *listFonts, + NoColor: *noColor, Text: *text, Version: *showVersion, } } -func resolveInteractivity() bool { - interactive := isInteractive() - if os.Getenv("CI") != "" { - interactive = false +func buildBanner(ctx context.Context, renderer *ascii.Renderer, cfg config.Config, env terminal.Env, demo bool) (banner.Output, error) { + if demo { + hostBanner, err := banner.New(collectors.Providers{}, renderer, banner.BuildersForConfig(cfg)) + if err != nil { + return banner.Output{}, err + } + return hostBanner.BuildWithSnapshot(collectors.DemoSnapshot(), cfg, env), nil } - if os.Getenv("SYSGREET_ASSUME_TTY") != "" { - interactive = true + + providers := collectors.Providers{ + System: collectors.NewSystemCollector(), + Network: collectors.NewNetworkCollector(cfg.Network.MaxInterfaces), + Resources: collectors.NewResourceCollector(), + Session: collectors.NewSessionCollector(), + LastLogin: collectors.NewLastLoginCollector(), } - return interactive + hostBanner, err := banner.New(providers, renderer, banner.BuildersForConfig(cfg)) + if err != nil { + return banner.Output{}, err + } + output, _, err := hostBanner.Build(ctx, cfg, env) + return output, err } -func runTextMode(renderer *ascii.Renderer, text string) (bool, error) { - if text == "" { - return false, nil - } - cfg := config.Default() - art, _, _, err := renderer.RenderWithGradient(text, cfg.ASCII.Font, cfg.ASCII.Color, cfg.ASCII.Gradient, cfg.ASCII.Monochrome) +func runTextMode(renderer *ascii.Renderer, text string, cfg config.Config, env terminal.Env) error { + art, err := renderer.Render(text, ascii.RenderOptions{ + Font: cfg.ASCII.Font, + Color: cfg.ASCII.Color, + Gradient: cfg.ASCII.Gradient, + Monochrome: cfg.ASCII.Monochrome, + MaxWidth: env.Width, + Profile: env.Profile, + }) if err != nil { - return false, err + return err } - fmt.Printf("\n%s\n\n", art) - return true, nil + fmt.Printf("\n%s\n\n", art.Text) + return nil } -func runDemoMode(renderer *ascii.Renderer, enabled bool) (bool, error) { - if !enabled { - return false, nil +func resolveInteractivity() bool { + interactive := isInteractive() + if os.Getenv("CI") != "" { + interactive = false } - cfg := config.Default() - demoSnap := collectors.DemoSnapshot() - providers := collectors.Providers{} // Empty providers for demo - hostBanner, err := banner.New(providers, renderer, banner.BuildersForConfig(cfg)) - if err != nil { - return false, err + if os.Getenv("SYSGREET_ASSUME_TTY") != "" { + interactive = true } - output := hostBanner.BuildWithSnapshot(demoSnap, cfg) - layout := render.NewRenderer(cfg.ASCII.Monochrome) - fmt.Println(layout.Render(output, cfg)) - return true, nil + return interactive } -func maybeBootstrap(ctx context.Context, cfgPath, policyFlag, policyEnv string, interactive bool, io bootstrap.IO) error { +func maybeBootstrap(ctx context.Context, settings runSettings) error { + cfgPath := config.DefaultWritePath() if cfgPath == "" { return nil } + policyEnv := os.Getenv("SYSGREET_CONFIG_POLICY") info, statErr := os.Stat(cfgPath) - policyProvided := policyFlag != "" || policyEnv != "" + policyProvided := settings.PolicyFlag != "" || policyEnv != "" configMissing := errors.Is(statErr, os.ErrNotExist) configIsDir := statErr == nil && info.IsDir() if statErr != nil && !configMissing { @@ -187,7 +243,12 @@ func maybeBootstrap(ctx context.Context, cfgPath, policyFlag, policyEnv string, if !policyProvided && !configMissing && !configIsDir { return nil } - _, err := bootstrap.Bootstrap(ctx, cfgPath, io, bootstrap.Options{FlagPolicy: policyFlag, EnvPolicy: policyEnv, Interactive: interactive}) + io := bootstrap.IO{Stdin: os.Stdin, Stdout: os.Stdout, Stderr: os.Stderr} + _, err := bootstrap.Bootstrap(ctx, cfgPath, io, bootstrap.Options{ + FlagPolicy: settings.PolicyFlag, + EnvPolicy: policyEnv, + Interactive: resolveInteractivity(), + }) return err } diff --git a/configs/example.toml b/configs/example.toml index 760d6bc..f17ee27 100644 --- a/configs/example.toml +++ b/configs/example.toml @@ -13,7 +13,10 @@ datetime = true last_login = true [ascii] -# Font options: "ANSI Regular", "ANSI Shadow", "Block", "DOS Rebel", "Basic", "standard", "slant" +# Font options: "ANSI Regular", "ANSI Shadow", "Banner", "Basic", "Block", +# "Blocks", "DOS Rebel", "Small", "standard", "slant" (or run --list-fonts). +# When the terminal is too narrow for this font, sysgreet automatically +# steps down: shorter hostname, then a narrower font, then a plain header. font = "ANSI Regular" # Gradient: cycle colors per line (overrides 'color' if set) @@ -27,6 +30,8 @@ monochrome = false [layout] compact = false +# Cap the banner width in columns. 0 means use the detected terminal width. +max_width = 0 sections = ["header", "network", "system", "resources"] [network] diff --git a/configs/example.yaml b/configs/example.yaml index 78a825a..b4035ab 100644 --- a/configs/example.yaml +++ b/configs/example.yaml @@ -13,7 +13,10 @@ display: last_login: true ascii: - # Font options: "ANSI Regular", "ANSI Shadow", "Block", "DOS Rebel", "Basic", "standard", "slant" + # Font options: "ANSI Regular", "ANSI Shadow", "Banner", "Basic", "Block", + # "Blocks", "DOS Rebel", "Small", "standard", "slant" (or run --list-fonts). + # When the terminal is too narrow for this font, sysgreet automatically + # steps down: shorter hostname, then a narrower font, then a plain header. font: "ANSI Regular" # Gradient: cycle colors per line (overrides 'color' if set) @@ -27,6 +30,8 @@ ascii: layout: compact: false + # Cap the banner width in columns. 0 means use the detected terminal width. + max_width: 0 sections: - header - system diff --git a/go.mod b/go.mod index b551025..7cbc08b 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,8 @@ require ( gopkg.in/yaml.v3 v3.0.1 ) +require golang.org/x/term v0.20.0 + require ( github.com/go-ole/go-ole v1.2.6 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect @@ -17,5 +19,5 @@ require ( github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect - golang.org/x/sys v0.20.0 // indirect + golang.org/x/sys v0.20.0 ) diff --git a/go.sum b/go.sum index 7574a2b..4301da7 100644 --- a/go.sum +++ b/go.sum @@ -42,6 +42,8 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/ascii/ascii.go b/internal/ascii/ascii.go index b36cff2..8b2384e 100644 --- a/internal/ascii/ascii.go +++ b/internal/ascii/ascii.go @@ -1,6 +1,6 @@ package ascii -import "strings" +import "github.com/veteranbv/sysgreet/internal/terminal" // RenderOptions describes how ASCII art should be generated. type RenderOptions struct { @@ -9,16 +9,20 @@ type RenderOptions struct { Gradient []string Monochrome bool Uppercase bool + // MaxWidth caps the art width in columns. Zero means unconstrained. + MaxWidth int + // Profile gates color output for the target terminal. + Profile terminal.Profile } -// RenderHostname renders the hostname into ASCII art using the configured options. -func (r *Renderer) RenderHostname(hostname string, opts RenderOptions) (string, string, string, error) { - text := strings.TrimSpace(hostname) - if text == "" { - text = "sysgreet" - } - if opts.Uppercase { - text = strings.ToUpper(text) - } - return r.RenderWithGradient(text, opts.Font, opts.Color, opts.Gradient, opts.Monochrome) +// Art is the result of rendering a headline, along with what the width +// ladder had to do to make it fit. +type Art struct { + Text string // final output, colored per Profile + Font string // font used; "plain" when the ladder hit bottom + Color string + Width int // widest line in columns, ignoring escape sequences + // Shortened reports that the domain was dropped from the name to fit + // the terminal, so callers can surface the full name elsewhere. + Shortened bool } diff --git a/internal/ascii/ascii_test.go b/internal/ascii/ascii_test.go index c343da2..b210ba3 100644 --- a/internal/ascii/ascii_test.go +++ b/internal/ascii/ascii_test.go @@ -1,48 +1,260 @@ package ascii -import "testing" +import ( + "strings" + "testing" -func TestRendererRenderSpecificFont(t *testing.T) { + "github.com/veteranbv/sysgreet/internal/terminal" +) + +func mustRenderer(t *testing.T) *Renderer { + t.Helper() r, err := NewRenderer() if err != nil { t.Fatalf("NewRenderer() error = %v", err) } - art, font, _, err := r.Render("host", "standard", "reset", true) + return r +} + +func TestRendererRenderSpecificFont(t *testing.T) { + r := mustRenderer(t) + art, err := r.Render("host", RenderOptions{Font: "standard", Monochrome: true}) if err != nil { t.Fatalf("Render() error = %v", err) } - if font != "standard" { - t.Fatalf("expected font 'standard', got %s", font) + if art.Font != "standard" { + t.Fatalf("expected font 'standard', got %s", art.Font) } - if len(art) == 0 { + if len(art.Text) == 0 { t.Fatalf("expected non-empty art output") } - if art == "host" { + if art.Text == "host" { t.Fatalf("expected ASCII art, got plain text") } + if art.Width <= 0 { + t.Fatalf("expected positive art width, got %d", art.Width) + } } func TestRendererRandomFontSelection(t *testing.T) { - r, err := NewRenderer() - if err != nil { - t.Fatalf("NewRenderer() error = %v", err) - } - _, font, color, err := r.Render("host", "random", "random", false) + r := mustRenderer(t) + art, err := r.Render("host", RenderOptions{Font: "random", Color: "random", Profile: terminal.ProfileANSI}) if err != nil { t.Fatalf("Render() error = %v", err) } - fonts := r.Fonts() found := false - for _, candidate := range fonts { - if font == candidate { + for _, candidate := range r.Fonts() { + if art.Font == candidate { found = true break } } if !found { - t.Fatalf("random font %s not in available list", font) + t.Fatalf("random font %s not in available list", art.Font) } - if color == "reset" { + if art.Color == "reset" { t.Fatalf("expected random color selection") } } + +func TestRendererUnknownFontFallsBackDeterministically(t *testing.T) { + r := mustRenderer(t) + first, err := r.Render("host", RenderOptions{Font: "no-such-font", Monochrome: true}) + if err != nil { + t.Fatalf("Render() error = %v", err) + } + if first.Font != defaultFont { + t.Fatalf("expected fallback to %q, got %q", defaultFont, first.Font) + } + // A typo must not roll a new font per invocation. + for i := 0; i < 3; i++ { + art, err := r.Render("host", RenderOptions{Font: "no-such-font", Monochrome: true}) + if err != nil { + t.Fatalf("Render() error = %v", err) + } + if art.Font != first.Font { + t.Fatalf("unknown font fallback not deterministic: %q vs %q", art.Font, first.Font) + } + } +} + +func TestRendererFitsWithinMaxWidth(t *testing.T) { + r := mustRenderer(t) + for _, width := range []int{40, 60, 80, 120} { + art, err := r.Render("proxmox-node-01", RenderOptions{ + Font: "ANSI Regular", + Uppercase: true, + MaxWidth: width, + Profile: terminal.ProfileNoColor, + }) + if err != nil { + t.Fatalf("Render() error = %v", err) + } + if art.Width > width { + t.Errorf("width %d: art is %d columns wide", width, art.Width) + } + for _, line := range strings.Split(art.Text, "\n") { + if n := len([]rune(terminal.Strip(line))); n > width { + t.Errorf("width %d: line overflows at %d columns: %q", width, n, line) + } + } + } +} + +func TestRendererShortensDomainToFit(t *testing.T) { + r := mustRenderer(t) + art, err := r.Render("pve1.home.lan", RenderOptions{ + Font: "ANSI Regular", + Uppercase: true, + MaxWidth: 60, + Profile: terminal.ProfileNoColor, + }) + if err != nil { + t.Fatalf("Render() error = %v", err) + } + if !art.Shortened { + t.Fatalf("expected domain to be dropped at width 60, got font %q width %d", art.Font, art.Width) + } + if art.Width > 60 { + t.Fatalf("shortened art still overflows: %d columns", art.Width) + } +} + +func TestRendererFallsBackToNarrowFont(t *testing.T) { + r := mustRenderer(t) + // Wide enough for the name in Small but not in ANSI Regular. + art, err := r.Render("media-server", RenderOptions{ + Font: "ANSI Regular", + Uppercase: true, + MaxWidth: 70, + Profile: terminal.ProfileNoColor, + }) + if err != nil { + t.Fatalf("Render() error = %v", err) + } + if art.Font == "ANSI Regular" { + t.Fatalf("expected a narrower font at width 70, art is %d columns", art.Width) + } + if art.Width > 70 { + t.Fatalf("fallback art still overflows: %d columns", art.Width) + } +} + +func TestRendererPlainHeaderAtTinyWidth(t *testing.T) { + r := mustRenderer(t) + art, err := r.Render("media-server-vault-01", RenderOptions{ + Font: "ANSI Regular", + Uppercase: true, + MaxWidth: 30, + Profile: terminal.ProfileNoColor, + }) + if err != nil { + t.Fatalf("Render() error = %v", err) + } + if art.Font != plainFont { + t.Fatalf("expected plain header at width 30, got font %q", art.Font) + } + if strings.Contains(art.Text, "\n") { + t.Fatalf("plain header must be a single line, got %q", art.Text) + } + if art.Width > 30 { + t.Fatalf("plain header overflows: %d columns", art.Width) + } + if !strings.Contains(art.Text, "MEDIA-SERVER-VAULT-01") { + t.Fatalf("plain header lost the hostname: %q", art.Text) + } +} + +func TestRendererUnconstrainedKeepsConfiguredFont(t *testing.T) { + r := mustRenderer(t) + art, err := r.Render("media-server-vault-01", RenderOptions{ + Font: "ANSI Regular", + Uppercase: true, + Monochrome: true, + }) + if err != nil { + t.Fatalf("Render() error = %v", err) + } + if art.Font != "ANSI Regular" { + t.Fatalf("unconstrained render should keep the configured font, got %q", art.Font) + } + if art.Shortened { + t.Fatalf("unconstrained render should not shorten the name") + } +} + +func TestRendererNoColorProfileEmitsNoEscapes(t *testing.T) { + r := mustRenderer(t) + art, err := r.Render("host", RenderOptions{ + Font: "standard", + Gradient: []string{"brightblue", "blue", "cyan"}, + Profile: terminal.ProfileNoColor, + }) + if err != nil { + t.Fatalf("Render() error = %v", err) + } + if strings.Contains(art.Text, "\033[") { + t.Fatalf("NO_COLOR output contains escape sequences: %q", art.Text) + } +} + +func TestRendererGradientProfiles(t *testing.T) { + r := mustRenderer(t) + gradient := []string{"brightblue", "blue", "cyan", "brightcyan", "white"} + + ansi, err := r.Render("host", RenderOptions{Font: "standard", Gradient: gradient, Profile: terminal.ProfileANSI}) + if err != nil { + t.Fatalf("Render() error = %v", err) + } + if !strings.Contains(ansi.Text, "\033[94m") { + t.Errorf("ANSI gradient missing 16-color escape: %q", ansi.Text) + } + if strings.Contains(ansi.Text, "\033[38;2;") { + t.Errorf("ANSI profile must not emit truecolor escapes") + } + + tc, err := r.Render("host", RenderOptions{Font: "standard", Gradient: gradient, Profile: terminal.ProfileTrueColor}) + if err != nil { + t.Fatalf("Render() error = %v", err) + } + if !strings.Contains(tc.Text, "\033[38;2;") { + t.Errorf("truecolor gradient missing 24-bit escape: %q", tc.Text) + } +} + +func TestShortName(t *testing.T) { + tests := []struct { + in string + want string + }{ + {"pve1.home.lan", "pve1"}, + {"plain-host", "plain-host"}, + {".weird", ".weird"}, + {"host.", "host"}, + } + for _, tt := range tests { + if got := shortName(tt.in); got != tt.want { + t.Errorf("shortName(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestLerpStops(t *testing.T) { + stops := []string{"blue", "white"} + start, ok := lerpStops(stops, 0) + if !ok { + t.Fatal("lerpStops failed at t=0") + } + blue, _ := terminal.RGB("blue") + if start != blue { + t.Errorf("t=0 should equal first stop, got %v want %v", start, blue) + } + end, ok := lerpStops(stops, 1) + if !ok { + t.Fatal("lerpStops failed at t=1") + } + white, _ := terminal.RGB("white") + if end != white { + t.Errorf("t=1 should equal last stop, got %v want %v", end, white) + } +} diff --git a/internal/ascii/fonts.go b/internal/ascii/fonts.go index 4448139..6571b51 100644 --- a/internal/ascii/fonts.go +++ b/internal/ascii/fonts.go @@ -4,34 +4,46 @@ import ( "bytes" "fmt" "io/fs" + "log" "math/rand" + "os" "path/filepath" "strings" "time" "github.com/common-nighthawk/go-figure" "github.com/veteranbv/sysgreet/assets" + "github.com/veteranbv/sysgreet/internal/terminal" ) -const resetColor = "reset" +const ( + resetColor = "reset" -var colorCodes = map[string]string{ - "reset": "\033[0m", - "red": "\033[31m", - "green": "\033[32m", - "yellow": "\033[33m", - "blue": "\033[34m", - "purple": "\033[35m", - "cyan": "\033[36m", - "gray": "\033[37m", - "white": "\033[97m", - "brightblue": "\033[94m", - "brightcyan": "\033[96m", - "brightwhite": "\033[97m", -} + // defaultFont is used when a configured font does not exist. A typo in + // the config should degrade predictably, not roll a random font on + // every login. + defaultFont = "ANSI Regular" + + // plainFont is reported when the width ladder falls all the way back + // to a single-line header. + plainFont = "plain" +) + +// narrowFonts are tried in order (narrowest last) when the configured font +// is too wide for the terminal. +var narrowFonts = []string{"standard", "Small"} var supportedColors = []string{"red", "green", "yellow", "blue", "purple", "cyan", "gray", "white"} +var debugEnabled = os.Getenv("SYSGREET_DEBUG") != "" + +func debugf(format string, args ...any) { + if !debugEnabled { + return + } + log.Printf("sysgreet ascii: "+format, args...) +} + // Renderer produces ASCII art banners from embedded FIGlet fonts. type Renderer struct { fonts map[string][]byte @@ -71,53 +83,231 @@ func (r *Renderer) Fonts() []string { return append([]string{}, r.order...) } -// Render creates ASCII art for the input string using the specified font and color. -// If fontName or colorName equal "random", selections are randomized from the embedded sets. -// If gradient is provided, it will apply different colors to each line of the banner. -func (r *Renderer) Render(text, fontName, colorName string, monochrome bool) (string, string, string, error) { - return r.RenderWithGradient(text, fontName, colorName, nil, monochrome) +// Render creates ASCII art for the input string. When opts.MaxWidth is set +// and the art would overflow, the renderer walks a fallback ladder — drop +// the domain from the name, try narrower fonts, and finally emit a plain +// single-line header — so output never wraps mid-glyph. +func (r *Renderer) Render(text string, opts RenderOptions) (Art, error) { + text = strings.TrimSpace(text) + if text == "" { + text = "sysgreet" + } + if opts.Uppercase { + text = strings.ToUpper(text) + } + + texts := []string{text} + if short := shortName(text); short != text { + texts = append(texts, short) + } + + for _, font := range r.fontLadder(opts.Font) { + for _, candidate := range texts { + rows := r.rows(candidate, font) + width := maxRowWidth(rows) + if opts.MaxWidth > 0 && width > opts.MaxWidth { + continue + } + art, color := r.colorize(rows, opts) + return Art{ + Text: art, + Font: font, + Color: color, + Width: width, + Shortened: candidate != text, + }, nil + } + } + + return r.plainHeader(texts[len(texts)-1], text, opts), nil +} + +// fontLadder returns the fonts to try, widest first: the requested font, +// then the built-in narrow fallbacks. +func (r *Renderer) fontLadder(requested string) []string { + ladder := []string{r.resolveFont(requested)} + for _, name := range narrowFonts { + if _, ok := r.fonts[name]; !ok { + continue + } + if name == ladder[0] { + continue + } + ladder = append(ladder, name) + } + return ladder } -// RenderWithGradient creates ASCII art with optional gradient coloring per line. -func (r *Renderer) RenderWithGradient(text, fontName, colorName string, gradient []string, monochrome bool) (string, string, string, error) { - if fontName == "" || fontName == "random" { - fontName = r.randomFont() - } else if _, ok := r.fonts[fontName]; !ok { - fontName = r.randomFont() +// resolveFont maps a configured font name to an embedded font. Unknown +// names fall back to the default deterministically. +func (r *Renderer) resolveFont(name string) string { + if name == "" || name == "random" { + return r.randomFont() + } + if _, ok := r.fonts[name]; ok { + return name + } + debugf("unknown font %q, falling back to %q", name, defaultFont) + if _, ok := r.fonts[defaultFont]; ok { + return defaultFont } + return r.order[0] +} + +func (r *Renderer) rows(text, font string) []string { + fig := figure.NewFigureWithFont(text, bytes.NewReader(r.fonts[font]), true) + return fig.Slicify() +} - fontData := r.fonts[fontName] - fig := figure.NewFigureWithFont(text, bytes.NewReader(fontData), true) - rows := fig.Slicify() +func maxRowWidth(rows []string) int { + widest := 0 + for _, row := range rows { + if n := len([]rune(row)); n > widest { + widest = n + } + } + return widest +} + +// shortName drops everything after the first dot, so pve1.home.lan renders +// as pve1. The full name stays available for an info line. +func shortName(text string) string { + if idx := strings.IndexByte(text, '.'); idx > 0 { + return text[:idx] + } + return text +} + +// plainHeader is the bottom of the ladder: a one-line ruled header that fits +// any terminal. Something like ═════ PVE1 ═════. +func (r *Renderer) plainHeader(name, fullText string, opts RenderOptions) Art { + const maxRule = 60 + width := opts.MaxWidth + if width <= 0 || width > maxRule { + width = maxRule + } + + label := name + if len([]rune(label))+4 > width { + label = clipRunes(label, width) + } + line := label + if fill := width - len([]rune(label)) - 2; fill >= 2 { + left := fill / 2 + right := fill - left + line = strings.Repeat("═", left) + " " + label + " " + strings.Repeat("═", right) + } color := resetColor - var asciiArt string + if !opts.Monochrome && opts.Profile != terminal.ProfileNoColor { + color = r.headerColor(opts) + line = terminal.Wrap(opts.Profile, color, line) + } + return Art{ + Text: line, + Font: plainFont, + Color: color, + Width: len([]rune(terminal.Strip(line))), + Shortened: name != fullText, + } +} + +// headerColor picks the accent color for the plain header: the first +// gradient stop when a gradient is configured, otherwise the single color. +func (r *Renderer) headerColor(opts RenderOptions) string { + if len(opts.Gradient) > 0 { + if _, ok := terminal.Code(opts.Gradient[0]); ok { + return strings.ToLower(opts.Gradient[0]) + } + } + return r.pickColor(opts.Color) +} + +func clipRunes(s string, width int) string { + runes := []rune(s) + if len(runes) <= width { + return s + } + if width <= 1 { + return "…" + } + return string(runes[:width-1]) + "…" +} - if !monochrome && len(gradient) > 0 { - // Apply gradient coloring per line - var coloredRows []string +// colorize joins the art rows, applying gradient or single-color styling +// according to the terminal profile. +func (r *Renderer) colorize(rows []string, opts RenderOptions) (string, string) { + if opts.Monochrome || opts.Profile == terminal.ProfileNoColor { + return strings.Join(rows, "\n"), resetColor + } + + if len(opts.Gradient) > 0 { + codes := gradientCodes(opts.Gradient, len(rows), opts.Profile) + colored := make([]string, len(rows)) for i, row := range rows { - gradientColor := gradient[i%len(gradient)] - if code, ok := colorCodes[strings.ToLower(gradientColor)]; ok { - coloredRows = append(coloredRows, fmt.Sprintf("%s%s%s", code, row, colorCodes[resetColor])) - } else { - coloredRows = append(coloredRows, row) + if codes[i] == "" { + colored[i] = row + continue + } + colored[i] = codes[i] + row + terminal.Reset + } + return strings.Join(colored, "\n"), strings.Join(opts.Gradient, ",") + } + + color := r.pickColor(opts.Color) + art := strings.Join(rows, "\n") + if code, ok := terminal.Code(color); ok && color != resetColor { + art = code + art + terminal.Reset + } + return art, color +} + +// gradientCodes returns one escape sequence per row. On truecolor terminals +// the configured stops are interpolated into a smooth 24-bit gradient; on +// 16-color terminals the stops cycle per row as before. +func gradientCodes(stops []string, n int, profile terminal.Profile) []string { + codes := make([]string, n) + if profile == terminal.ProfileTrueColor { + for i := range codes { + t := 0.0 + if n > 1 { + t = float64(i) / float64(n-1) + } + if rgb, ok := lerpStops(stops, t); ok { + codes[i] = terminal.ForegroundRGB(rgb[0], rgb[1], rgb[2]) } } - asciiArt = strings.Join(coloredRows, "\n") - color = strings.Join(gradient, ",") - } else if !monochrome { - // Single color mode - asciiArt = strings.Join(rows, "\n") - color = r.pickColor(colorName) - if code, ok := colorCodes[color]; ok && color != resetColor { - asciiArt = fmt.Sprintf("%s%s%s", code, asciiArt, colorCodes[resetColor]) + return codes + } + for i := range codes { + if code, ok := terminal.Code(stops[i%len(stops)]); ok { + codes[i] = code } - } else { - asciiArt = strings.Join(rows, "\n") } + return codes +} - return asciiArt, fontName, color, nil +// lerpStops interpolates the named gradient stops at position t in [0, 1]. +func lerpStops(stops []string, t float64) ([3]uint8, bool) { + if len(stops) == 1 { + return terminal.RGB(stops[0]) + } + pos := t * float64(len(stops)-1) + seg := int(pos) + if seg >= len(stops)-1 { + seg = len(stops) - 2 + } + frac := pos - float64(seg) + from, okFrom := terminal.RGB(stops[seg]) + to, okTo := terminal.RGB(stops[seg+1]) + if !okFrom || !okTo { + return [3]uint8{}, false + } + var rgb [3]uint8 + for c := 0; c < 3; c++ { + rgb[c] = uint8(float64(from[c]) + (float64(to[c])-float64(from[c]))*frac + 0.5) + } + return rgb, true } func (r *Renderer) randomFont() string { @@ -129,7 +319,7 @@ func (r *Renderer) pickColor(name string) string { return supportedColors[r.rnd.Intn(len(supportedColors))] } name = strings.ToLower(name) - if _, ok := colorCodes[name]; ok { + if _, ok := terminal.Code(name); ok { return name } return resetColor diff --git a/internal/banner/banner.go b/internal/banner/banner.go index 2bf23e2..eba9346 100644 --- a/internal/banner/banner.go +++ b/internal/banner/banner.go @@ -8,6 +8,7 @@ import ( "github.com/veteranbv/sysgreet/internal/ascii" "github.com/veteranbv/sysgreet/internal/collectors" "github.com/veteranbv/sysgreet/internal/config" + "github.com/veteranbv/sysgreet/internal/terminal" ) // Section represents a rendered section of the banner body. @@ -20,10 +21,11 @@ type Section struct { // Header captures ASCII art metadata for the hostname banner. type Header struct { - Art string - Font string - Color string - Lines []string + Hostname string // plain hostname, for compact and JSON output + Art string + Font string + Color string + Lines []string } // Output encapsulates the full banner content ready for layout rendering. @@ -54,38 +56,44 @@ func New(providers collectors.Providers, renderer *ascii.Renderer, builders []Bu return &Banner{providers: providers, ascii: renderer, builders: builders}, nil } -// Build produces the banner output using the provided configuration. -func (b *Banner) Build(ctx context.Context, cfg config.Config) (Output, collectors.Snapshot, error) { +// Build produces the banner output using the provided configuration and +// terminal environment. +func (b *Banner) Build(ctx context.Context, cfg config.Config, env terminal.Env) (Output, collectors.Snapshot, error) { snap := b.providers.Gather(ctx) - return b.BuildWithSnapshot(snap, cfg), snap, nil + return b.BuildWithSnapshot(snap, cfg, env), snap, nil } // BuildWithSnapshot renders banner output using a pre-collected snapshot (e.g., for demo mode). -func (b *Banner) BuildWithSnapshot(snap collectors.Snapshot, cfg config.Config) Output { - header := b.buildHeader(snap, cfg) +func (b *Banner) BuildWithSnapshot(snap collectors.Snapshot, cfg config.Config, env terminal.Env) Output { + header := b.buildHeader(snap, cfg, env) sections := b.buildSections(snap, cfg) return Output{Header: header, Sections: sections} } -func (b *Banner) buildHeader(snap collectors.Snapshot, cfg config.Config) Header { +func (b *Banner) buildHeader(snap collectors.Snapshot, cfg config.Config, env terminal.Env) Header { name := snap.System.Hostname if strings.TrimSpace(name) == "" { name = "sysgreet" } - art, font, color, err := b.ascii.RenderHostname(name, ascii.RenderOptions{ + art, err := b.ascii.Render(name, ascii.RenderOptions{ Font: cfg.ASCII.Font, Color: cfg.ASCII.Color, Gradient: cfg.ASCII.Gradient, Monochrome: cfg.ASCII.Monochrome, Uppercase: true, + MaxWidth: effectiveWidth(cfg, env), + Profile: env.Profile, }) if err != nil { // Fallback to plain text when ASCII rendering fails. - art = name - font = "plain" - color = "reset" + art = ascii.Art{Text: name, Font: "plain", Color: "reset"} } lines := []string{} + if art.Shortened { + // The domain was dropped from the art to fit the terminal; keep + // the full name visible on its own line. + lines = append(lines, name) + } if cfg.Display.OS { line := snap.System.OS if snap.System.OSVersion != "" { @@ -96,7 +104,17 @@ func (b *Banner) buildHeader(snap collectors.Snapshot, cfg config.Config) Header } lines = append(lines, line) } - return Header{Art: art, Font: font, Color: color, Lines: lines} + return Header{Hostname: name, Art: art.Text, Font: art.Font, Color: art.Color, Lines: lines} +} + +// effectiveWidth combines the detected terminal width with the configured +// layout.max_width cap; zero means unconstrained. +func effectiveWidth(cfg config.Config, env terminal.Env) int { + width := env.Width + if max := cfg.Layout.MaxWidth; max > 0 && (width == 0 || max < width) { + width = max + } + return width } func (b *Banner) buildSections(snap collectors.Snapshot, cfg config.Config) []Section { diff --git a/internal/banner/banner_test.go b/internal/banner/banner_test.go index 241645a..f807b81 100644 --- a/internal/banner/banner_test.go +++ b/internal/banner/banner_test.go @@ -2,12 +2,14 @@ package banner import ( "context" + "strings" "testing" "time" "github.com/veteranbv/sysgreet/internal/ascii" "github.com/veteranbv/sysgreet/internal/collectors" "github.com/veteranbv/sysgreet/internal/config" + "github.com/veteranbv/sysgreet/internal/terminal" ) func TestNew(t *testing.T) { @@ -140,7 +142,7 @@ func TestBanner_Build(t *testing.T) { t.Fatalf("New() error = %v", err) } - output, snap, err := banner.Build(context.Background(), tt.cfg) + output, snap, err := banner.Build(context.Background(), tt.cfg, terminal.Env{}) if err != nil { t.Fatalf("Build() error = %v", err) } @@ -233,7 +235,7 @@ func TestBanner_buildHeader(t *testing.T) { }, } - header := banner.buildHeader(snap, cfg) + header := banner.buildHeader(snap, cfg, terminal.Env{}) if header.Art == "" { t.Error("expected non-empty ASCII art") @@ -334,3 +336,49 @@ func mustRenderer(t *testing.T) *ascii.Renderer { } return r } + +func TestBanner_buildHeaderShortensForNarrowTerminal(t *testing.T) { + b, err := New(collectors.Providers{}, mustRenderer(t), nil) + if err != nil { + t.Fatalf("New() error = %v", err) + } + snap := collectors.Snapshot{ + System: collectors.SystemInfo{Hostname: "pve1.home.lan"}, + } + cfg := config.Default() + + header := b.buildHeader(snap, cfg, terminal.Env{Width: 60}) + + if header.Hostname != "pve1.home.lan" { + t.Errorf("header.Hostname = %q, want full name", header.Hostname) + } + found := false + for _, line := range header.Lines { + if line == "pve1.home.lan" { + found = true + } + } + if !found { + t.Errorf("expected full hostname line when art is shortened, lines = %v", header.Lines) + } +} + +func TestBanner_buildHeaderRespectsConfiguredMaxWidth(t *testing.T) { + b, err := New(collectors.Providers{}, mustRenderer(t), nil) + if err != nil { + t.Fatalf("New() error = %v", err) + } + snap := collectors.Snapshot{ + System: collectors.SystemInfo{Hostname: "media-server-vault-01"}, + } + cfg := config.Default() + cfg.Layout.MaxWidth = 40 + + header := b.buildHeader(snap, cfg, terminal.Env{Width: 200}) + + for _, line := range strings.Split(header.Art, "\n") { + if n := len([]rune(line)); n > 40 { + t.Errorf("art line exceeds layout.max_width 40 (%d): %q", n, line) + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go index c864f47..1170419 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -177,6 +177,9 @@ func mergeLayout(base *Config, layout *rawLayout) { if layout.Compact != nil { base.Layout.Compact = *layout.Compact } + if layout.MaxWidth != nil && *layout.MaxWidth >= 0 { + base.Layout.MaxWidth = *layout.MaxWidth + } } func mergeNetwork(base *Config, network *rawNetwork) { @@ -244,6 +247,11 @@ func applyLayoutOverrides(layout *LayoutConfig) { if v, ok := lookupBool("SYSGREET_LAYOUT_COMPACT"); ok { layout.Compact = v } + if max := os.Getenv("SYSGREET_LAYOUT_MAX_WIDTH"); max != "" { + if parsed, err := parseInt(max); err == nil && parsed >= 0 { + layout.MaxWidth = parsed + } + } if sections := os.Getenv("SYSGREET_LAYOUT_SECTIONS"); sections != "" { parts := strings.Split(sections, ",") var cleaned []string @@ -323,6 +331,7 @@ type rawASCII struct { type rawLayout struct { Compact *bool `yaml:"compact" toml:"compact"` + MaxWidth *int `yaml:"max_width" toml:"max_width"` Sections *[]string `yaml:"sections" toml:"sections"` } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 4bde443..0a3a3be 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -126,3 +126,54 @@ func TestDefaultWritePathFallsBackToHome(t *testing.T) { t.Fatalf("expected %s, got %s", expected, got) } } + +func TestLoad_MaxWidthFromYAML(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + cfgDir := dir + "/.config/sysgreet" + if err := os.MkdirAll(cfgDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + content := []byte(`layout: + max_width: 100 +`) + if err := os.WriteFile(cfgDir+"/config.yaml", content, 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + cfg, _, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg.Layout.MaxWidth != 100 { + t.Fatalf("expected max_width 100, got %d", cfg.Layout.MaxWidth) + } +} + +func TestLoad_MaxWidthFromEnv(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + t.Setenv("SYSGREET_LAYOUT_MAX_WIDTH", "90") + + cfg, _, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg.Layout.MaxWidth != 90 { + t.Fatalf("expected max_width 90 from env, got %d", cfg.Layout.MaxWidth) + } +} + +func TestLoad_MaxWidthRejectsNegative(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + t.Setenv("SYSGREET_LAYOUT_MAX_WIDTH", "-5") + + cfg, _, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg.Layout.MaxWidth != 0 { + t.Fatalf("negative max_width should be ignored, got %d", cfg.Layout.MaxWidth) + } +} diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 28cc9c2..905ef94 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -27,7 +27,10 @@ type ASCIIConfig struct { // LayoutConfig controls banner layout options. type LayoutConfig struct { - Compact bool `yaml:"compact" toml:"compact"` + Compact bool `yaml:"compact" toml:"compact"` + // MaxWidth caps the banner width in columns. Zero means use the + // detected terminal width. + MaxWidth int `yaml:"max_width" toml:"max_width"` Sections []string `yaml:"sections" toml:"sections"` } diff --git a/internal/render/color.go b/internal/render/color.go index 3d24189..16a4851 100644 --- a/internal/render/color.go +++ b/internal/render/color.go @@ -1,65 +1,25 @@ package render import ( - "os" - "strings" + "github.com/veteranbv/sysgreet/internal/terminal" ) -var ansiCodes = map[string]string{ - "red": "\033[31m", - "yellow": "\033[33m", - "green": "\033[32m", - "cyan": "\033[36m", - "reset": "\033[0m", -} - -// Colorizer wraps text in ANSI sequences when enabled. +// Colorizer wraps text in ANSI sequences when the terminal profile allows. type Colorizer struct { - enabled bool + profile terminal.Profile } -// NewColorizer creates a colorizer that may be disabled via NO_COLOR or explicit flag. -func NewColorizer(disable bool) Colorizer { - if disable { - return Colorizer{enabled: false} - } - if _, ok := os.LookupEnv("NO_COLOR"); ok { - return Colorizer{enabled: false} - } - return Colorizer{enabled: true} +// NewColorizer creates a colorizer for the given terminal profile. +func NewColorizer(profile terminal.Profile) Colorizer { + return Colorizer{profile: profile} } -// Wrap applies color when enabled. +// Wrap applies color when the profile supports it. func (c Colorizer) Wrap(color, text string) string { - if !c.enabled { - return text - } - code, ok := ansiCodes[color] - if !ok || color == "reset" { - return text - } - return code + text + ansiCodes["reset"] + return terminal.Wrap(c.profile, color, text) } // Strip removes ANSI escape sequences from a string. func Strip(input string) string { - var b strings.Builder - i := 0 - runes := []rune(input) - for i < len(runes) { - if runes[i] == '' { - // Skip until letter - i++ - for i < len(runes) && ((runes[i] >= '0' && runes[i] <= '9') || runes[i] == '[' || runes[i] == ';') { - i++ - } - if i < len(runes) { - i++ - } - continue - } - b.WriteRune(runes[i]) - i++ - } - return b.String() + return terminal.Strip(input) } diff --git a/internal/render/color_test.go b/internal/render/color_test.go index b0780cd..ce581b4 100644 --- a/internal/render/color_test.go +++ b/internal/render/color_test.go @@ -1,64 +1,16 @@ package render import ( - "os" "strings" "testing" -) - -func TestNewColorizer(t *testing.T) { - tests := []struct { - name string - disable bool - setNoColor bool - wantEnabled bool - }{ - { - name: "enabled by default", - disable: false, - setNoColor: false, - wantEnabled: true, - }, - { - name: "explicitly disabled", - disable: true, - setNoColor: false, - wantEnabled: false, - }, - { - name: "disabled via NO_COLOR env", - disable: false, - setNoColor: true, - wantEnabled: false, - }, - { - name: "both disabled", - disable: true, - setNoColor: true, - wantEnabled: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Setup NO_COLOR environment - if tt.setNoColor { - os.Setenv("NO_COLOR", "1") - defer os.Unsetenv("NO_COLOR") - } - - c := NewColorizer(tt.disable) - if c.enabled != tt.wantEnabled { - t.Errorf("NewColorizer(%v).enabled = %v, want %v", tt.disable, c.enabled, tt.wantEnabled) - } - }) - } -} + "github.com/veteranbv/sysgreet/internal/terminal" +) func TestColorizer_Wrap(t *testing.T) { tests := []struct { name string - enabled bool + profile terminal.Profile color string text string wantContains []string @@ -66,49 +18,42 @@ func TestColorizer_Wrap(t *testing.T) { }{ { name: "red color enabled", - enabled: true, + profile: terminal.ProfileANSI, color: "red", text: "ERROR", wantContains: []string{"\033[31m", "ERROR", "\033[0m"}, }, { name: "yellow color enabled", - enabled: true, + profile: terminal.ProfileANSI, color: "yellow", text: "WARNING", wantContains: []string{"\033[33m", "WARNING", "\033[0m"}, }, { name: "green color enabled", - enabled: true, + profile: terminal.ProfileANSI, color: "green", text: "OK", wantContains: []string{"\033[32m", "OK", "\033[0m"}, }, { name: "cyan color enabled", - enabled: true, + profile: terminal.ProfileANSI, color: "cyan", text: "INFO", wantContains: []string{"\033[36m", "INFO", "\033[0m"}, }, { - name: "disabled colorizer", - enabled: false, + name: "no-color profile", + profile: terminal.ProfileNoColor, color: "red", text: "ERROR", wantEqual: true, }, - { - name: "reset color is passthrough", - enabled: true, - color: "reset", - text: "TEXT", - wantEqual: true, - }, { name: "unknown color is passthrough", - enabled: true, + profile: terminal.ProfileANSI, color: "magenta", text: "TEXT", wantEqual: true, @@ -117,7 +62,7 @@ func TestColorizer_Wrap(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - c := Colorizer{enabled: tt.enabled} + c := NewColorizer(tt.profile) result := c.Wrap(tt.color, tt.text) if tt.wantEqual { @@ -137,62 +82,8 @@ func TestColorizer_Wrap(t *testing.T) { } func TestStrip(t *testing.T) { - tests := []struct { - name string - input string - want string - }{ - { - name: "red ANSI code", - input: "\033[31mERROR\033[0m", - want: "ERROR", - }, - { - name: "yellow ANSI code", - input: "\033[33mWARNING\033[0m", - want: "WARNING", - }, - { - name: "multiple ANSI codes", - input: "\033[31mRED\033[0m and \033[32mGREEN\033[0m", - want: "RED and GREEN", - }, - { - name: "no ANSI codes", - input: "plain text", - want: "plain text", - }, - { - name: "empty string", - input: "", - want: "", - }, - { - name: "complex ANSI sequence", - input: "\033[1;31mBOLD RED\033[0m", - want: "BOLD RED", - }, - { - name: "only ANSI codes", - input: "\033[31m\033[0m", - want: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := Strip(tt.input) - if result != tt.want { - t.Errorf("Strip(%q) = %q, want %q", tt.input, result, tt.want) - } - }) - } -} - -func TestStripPreservesNonANSI(t *testing.T) { - input := "Hello 世界 🌍" - result := Strip(input) - if result != input { - t.Errorf("Strip(%q) = %q, want %q", input, result, input) + input := "\033[31mRED\033[0m and \033[32mGREEN\033[0m" + if got := Strip(input); got != "RED and GREEN" { + t.Errorf("Strip(%q) = %q", input, got) } } diff --git a/internal/render/json.go b/internal/render/json.go new file mode 100644 index 0000000..5b04e96 --- /dev/null +++ b/internal/render/json.go @@ -0,0 +1,50 @@ +package render + +import ( + "encoding/json" + + "github.com/veteranbv/sysgreet/internal/banner" + "github.com/veteranbv/sysgreet/internal/config" +) + +type jsonSection struct { + Key string `json:"key"` + Title string `json:"title"` + Lines []string `json:"lines"` + Data map[string]any `json:"data,omitempty"` +} + +type jsonBanner struct { + Hostname string `json:"hostname"` + Header []string `json:"header"` + Sections []jsonSection `json:"sections"` +} + +// RenderJSON emits the banner as structured JSON for scripting. Sections +// follow the configured layout order, same as the text output. +func RenderJSON(out banner.Output, cfg config.Config) (string, error) { + doc := jsonBanner{ + Hostname: out.Header.Hostname, + Header: out.Header.Lines, + Sections: []jsonSection{}, + } + if doc.Header == nil { + doc.Header = []string{} + } + for _, section := range orderSections(out.Sections, cfg.Layout.Sections) { + if len(section.Lines) == 0 { + continue + } + doc.Sections = append(doc.Sections, jsonSection{ + Key: section.Key, + Title: section.Title, + Lines: section.Lines, + Data: section.Data, + }) + } + data, err := json.MarshalIndent(doc, "", " ") + if err != nil { + return "", err + } + return string(data), nil +} diff --git a/internal/render/layout.go b/internal/render/layout.go index 00ce7fa..efca0f0 100644 --- a/internal/render/layout.go +++ b/internal/render/layout.go @@ -6,16 +6,22 @@ import ( "github.com/veteranbv/sysgreet/internal/banner" "github.com/veteranbv/sysgreet/internal/config" + "github.com/veteranbv/sysgreet/internal/terminal" ) +// bodyIndent prefixes every section line in the full layout. +const bodyIndent = " " + // Renderer formats banner output into terminal-friendly text. type Renderer struct { colorizer Colorizer + width int } -// NewRenderer instantiates a renderer with optional color suppression. -func NewRenderer(disableColor bool) Renderer { - return Renderer{colorizer: NewColorizer(disableColor)} +// NewRenderer instantiates a renderer for the given terminal environment. +// A zero env.Width leaves lines unclipped. +func NewRenderer(env terminal.Env) Renderer { + return Renderer{colorizer: NewColorizer(env.Profile), width: env.Width} } // Render produces the final banner string. @@ -30,7 +36,7 @@ func (r Renderer) Render(out banner.Output, cfg config.Config) string { if len(out.Header.Lines) > 0 { builder.WriteString("\n") for _, line := range out.Header.Lines { - builder.WriteString(line) + builder.WriteString(r.clip(line, 0)) builder.WriteString("\n") } } @@ -44,11 +50,11 @@ func (r Renderer) Render(out banner.Output, cfg config.Config) string { builder.WriteString(section.Title) builder.WriteString("\n") for _, line := range section.Lines { - formatted := line + formatted := r.clip(line, len(bodyIndent)) if section.Key == "resources" { - formatted = r.highlightResource(section, line) + formatted = r.highlightResource(section, formatted) } - builder.WriteString(" ") + builder.WriteString(bodyIndent) builder.WriteString(formatted) builder.WriteString("\n") } @@ -56,8 +62,10 @@ func (r Renderer) Render(out banner.Output, cfg config.Config) string { return strings.TrimRight(builder.String(), "\n") } +// renderCompact emits a single pipe-separated line using the plain hostname +// rather than the multi-line art. func (r Renderer) renderCompact(out banner.Output, cfg config.Config) string { - parts := []string{Strip(out.Header.Art)} + parts := []string{strings.ToUpper(out.Header.Hostname)} parts = append(parts, out.Header.Lines...) sections := orderSections(out.Sections, cfg.Layout.Sections) for _, section := range sections { @@ -70,6 +78,26 @@ func (r Renderer) renderCompact(out banner.Output, cfg config.Config) string { return strings.Join(parts, " | ") } +// clip truncates a line to the terminal width, accounting for indent and +// marking the cut with an ellipsis. Zero width leaves the line untouched. +func (r Renderer) clip(line string, indent int) string { + if r.width <= 0 { + return line + } + limit := r.width - indent + if limit < 1 { + limit = 1 + } + runes := []rune(line) + if len(runes) <= limit { + return line + } + if limit == 1 { + return "…" + } + return string(runes[:limit-1]) + "…" +} + func orderSections(sections []banner.Section, desired []string) []banner.Section { lookup := make(map[string]banner.Section) keys := []string{} diff --git a/internal/render/layout_test.go b/internal/render/layout_test.go index 06950d8..708ff86 100644 --- a/internal/render/layout_test.go +++ b/internal/render/layout_test.go @@ -6,6 +6,7 @@ import ( "github.com/veteranbv/sysgreet/internal/banner" "github.com/veteranbv/sysgreet/internal/config" + "github.com/veteranbv/sysgreet/internal/terminal" ) func TestRenderer_Render(t *testing.T) { @@ -85,7 +86,7 @@ func TestRenderer_Render(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - r := NewRenderer(true) // Disable color for deterministic output + r := NewRenderer(terminal.Env{}) // No color for deterministic output result := r.Render(tt.output, tt.cfg) for _, want := range tt.wantContains { @@ -109,8 +110,9 @@ func TestRenderer_RenderCompact(t *testing.T) { name: "compact mode with separator", output: banner.Output{ Header: banner.Header{ - Art: "HOST", - Lines: []string{"Linux 6.0"}, + Hostname: "host", + Art: "line1\nline2\nline3", + Lines: []string{"Linux 6.0"}, }, Sections: []banner.Section{ { @@ -132,7 +134,7 @@ func TestRenderer_RenderCompact(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - r := NewRenderer(true) + r := NewRenderer(terminal.Env{}) result := r.Render(tt.output, tt.cfg) for _, want := range tt.wantContains { @@ -282,7 +284,7 @@ func TestRenderer_HighlightResource(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - r := NewRenderer(tt.disableColor) + r := NewRenderer(envFor(tt.disableColor)) result := r.highlightResource(tt.section, tt.line) if !strings.Contains(result, tt.wantContains) { @@ -314,7 +316,7 @@ func TestRenderer_WrapForPercent(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - r := NewRenderer(tt.disableColor) + r := NewRenderer(envFor(tt.disableColor)) result := r.wrapForPercent(tt.pct, tt.line) hasColor := result != tt.line @@ -324,3 +326,106 @@ func TestRenderer_WrapForPercent(t *testing.T) { }) } } + +// envFor maps the old disable-color boolean used across these tests to a +// terminal environment. +func envFor(disable bool) terminal.Env { + if disable { + return terminal.Env{} + } + return terminal.Env{Profile: terminal.ProfileANSI} +} + +func TestRenderer_CompactIsSingleLine(t *testing.T) { + out := banner.Output{ + Header: banner.Header{ + Hostname: "pve1", + Art: "██\n██\n██", + Lines: []string{"Linux 6.8"}, + }, + Sections: []banner.Section{ + {Key: "system", Title: "System", Lines: []string{"Uptime: 1d"}}, + }, + } + cfg := config.Config{Layout: config.LayoutConfig{Compact: true}} + result := NewRenderer(terminal.Env{}).Render(out, cfg) + + if strings.Contains(result, "\n") { + t.Fatalf("compact output must be a single line, got:\n%s", result) + } + if !strings.Contains(result, "PVE1") { + t.Errorf("compact output missing hostname, got: %s", result) + } + if strings.Contains(result, "██") { + t.Errorf("compact output must not include ASCII art, got: %s", result) + } +} + +func TestRenderer_ClipsBodyLinesToWidth(t *testing.T) { + out := banner.Output{ + Header: banner.Header{Hostname: "host", Art: "HOST"}, + Sections: []banner.Section{ + { + Key: "system", + Title: "System", + Lines: []string{"Last login: Fri, 10 Oct 2025 09:45:00 PDT from 203.0.113.10"}, + }, + }, + } + cfg := config.Default() + result := NewRenderer(terminal.Env{Width: 40}).Render(out, cfg) + + for _, line := range strings.Split(result, "\n") { + if n := len([]rune(line)); n > 40 { + t.Errorf("line exceeds width 40 (%d): %q", n, line) + } + } + if !strings.Contains(result, "…") { + t.Errorf("expected clipped line to end with ellipsis, got:\n%s", result) + } +} + +func TestRenderer_UnconstrainedLeavesLinesAlone(t *testing.T) { + long := "Last login: Fri, 10 Oct 2025 09:45:00 PDT from 203.0.113.10" + out := banner.Output{ + Header: banner.Header{Hostname: "host", Art: "HOST"}, + Sections: []banner.Section{ + {Key: "system", Title: "System", Lines: []string{long}}, + }, + } + result := NewRenderer(terminal.Env{}).Render(out, config.Default()) + if !strings.Contains(result, long) { + t.Errorf("unconstrained render should not clip lines, got:\n%s", result) + } +} + +func TestRenderJSON(t *testing.T) { + out := banner.Output{ + Header: banner.Header{ + Hostname: "pve1", + Art: "ART", + Lines: []string{"Linux 6.8 (x86_64)"}, + }, + Sections: []banner.Section{ + {Key: "resources", Title: "Resources", Lines: []string{"Mem: 23% used"}, Data: map[string]any{"memory_used_percent": 23}}, + {Key: "system", Title: "System", Lines: []string{"Uptime: 1d"}}, + }, + } + cfg := config.Default() + doc, err := RenderJSON(out, cfg) + if err != nil { + t.Fatalf("RenderJSON error: %v", err) + } + for _, want := range []string{`"hostname": "pve1"`, `"Linux 6.8 (x86_64)"`, `"memory_used_percent": 23`} { + if !strings.Contains(doc, want) { + t.Errorf("JSON missing %q:\n%s", want, doc) + } + } + if strings.Contains(doc, "ART") { + t.Errorf("JSON should not include ASCII art:\n%s", doc) + } + // Sections must follow the configured order: system before resources. + if strings.Index(doc, `"key": "system"`) > strings.Index(doc, `"key": "resources"`) { + t.Errorf("JSON sections not in layout order:\n%s", doc) + } +} diff --git a/internal/terminal/terminal.go b/internal/terminal/terminal.go new file mode 100644 index 0000000..765e51d --- /dev/null +++ b/internal/terminal/terminal.go @@ -0,0 +1,168 @@ +// Package terminal detects the capabilities of the terminal sysgreet writes +// to: how many columns are available and how much color it can display. It +// also owns the ANSI palette shared by the ascii and render packages. +package terminal + +import ( + "fmt" + "os" + "strconv" + "strings" + + "golang.org/x/term" +) + +// Profile describes the color capability of the output stream. +type Profile int + +const ( + // ProfileNoColor means no escape sequences should be emitted. + ProfileNoColor Profile = iota + // ProfileANSI means the classic 16-color palette is available. + ProfileANSI + // ProfileTrueColor means 24-bit foreground colors are available. + ProfileTrueColor +) + +// Env captures everything the renderers need to know about the output stream. +type Env struct { + // Width is the usable column count. Zero means unconstrained (for + // example when output is piped and COLUMNS is unset). + Width int + Profile Profile +} + +// DetectEnv inspects the output stream and environment once at startup. +// disableColor forces ProfileNoColor regardless of terminal capability. +func DetectEnv(out *os.File, disableColor bool) Env { + return Env{ + Width: Width(out), + Profile: DetectProfile(out, disableColor), + } +} + +// Width reports the column count of the terminal attached to out, falling +// back to the COLUMNS environment variable. Returns 0 when unknown so +// callers can render at natural width. +func Width(out *os.File) int { + if out != nil { + if w, _, err := term.GetSize(int(out.Fd())); err == nil && w > 0 { + return w + } + } + if cols := os.Getenv("COLUMNS"); cols != "" { + if w, err := strconv.Atoi(strings.TrimSpace(cols)); err == nil && w > 0 { + return w + } + } + return 0 +} + +// DetectProfile determines the color capability of out. Precedence: +// explicit disable, then NO_COLOR (https://no-color.org), then TERM=dumb, +// then whether out is a terminal at all, then COLORTERM for truecolor. +func DetectProfile(out *os.File, disableColor bool) Profile { + if disableColor { + return ProfileNoColor + } + if _, ok := os.LookupEnv("NO_COLOR"); ok { + return ProfileNoColor + } + if os.Getenv("TERM") == "dumb" { + return ProfileNoColor + } + if out == nil || !term.IsTerminal(int(out.Fd())) { + return ProfileNoColor + } + switch os.Getenv("COLORTERM") { + case "truecolor", "24bit": + return ProfileTrueColor + } + return ProfileANSI +} + +// Reset is the ANSI attribute reset sequence. +const Reset = "\033[0m" + +// ansiCodes maps the color names accepted in configuration to foreground +// escape sequences. +var ansiCodes = map[string]string{ + "red": "\033[31m", + "green": "\033[32m", + "yellow": "\033[33m", + "blue": "\033[34m", + "purple": "\033[35m", + "cyan": "\033[36m", + "gray": "\033[37m", + "white": "\033[97m", + "brightblue": "\033[94m", + "brightcyan": "\033[96m", + "brightwhite": "\033[97m", +} + +// rgbValues maps the same color names to 24-bit values for truecolor +// gradients. Values follow the common xterm palette. +var rgbValues = map[string][3]uint8{ + "red": {205, 49, 49}, + "green": {13, 188, 121}, + "yellow": {229, 229, 16}, + "blue": {36, 114, 200}, + "purple": {188, 63, 188}, + "cyan": {17, 168, 205}, + "gray": {229, 229, 229}, + "white": {255, 255, 255}, + "brightblue": {59, 142, 234}, + "brightcyan": {41, 184, 219}, + "brightwhite": {255, 255, 255}, +} + +// Code returns the ANSI escape sequence for a named color. +func Code(name string) (string, bool) { + code, ok := ansiCodes[strings.ToLower(name)] + return code, ok +} + +// RGB returns the 24-bit value for a named color. +func RGB(name string) ([3]uint8, bool) { + rgb, ok := rgbValues[strings.ToLower(name)] + return rgb, ok +} + +// ForegroundRGB builds a 24-bit foreground escape sequence. +func ForegroundRGB(r, g, b uint8) string { + return fmt.Sprintf("\033[38;2;%d;%d;%dm", r, g, b) +} + +// Wrap surrounds text with the named color when the profile allows it. +func Wrap(p Profile, color, text string) string { + if p == ProfileNoColor { + return text + } + code, ok := ansiCodes[strings.ToLower(color)] + if !ok { + return text + } + return code + text + Reset +} + +// Strip removes ANSI CSI escape sequences from a string. +func Strip(input string) string { + var b strings.Builder + runes := []rune(input) + i := 0 + for i < len(runes) { + if runes[i] == '\033' { + i++ + for i < len(runes) && ((runes[i] >= '0' && runes[i] <= '9') || runes[i] == '[' || runes[i] == ';') { + i++ + } + if i < len(runes) { + i++ + } + continue + } + b.WriteRune(runes[i]) + i++ + } + return b.String() +} diff --git a/internal/terminal/terminal_test.go b/internal/terminal/terminal_test.go new file mode 100644 index 0000000..8d2bb67 --- /dev/null +++ b/internal/terminal/terminal_test.go @@ -0,0 +1,135 @@ +package terminal + +import ( + "os" + "path/filepath" + "testing" +) + +// pipeFile returns a non-terminal file for detection tests. +func pipeFile(t *testing.T) *os.File { + t.Helper() + f, err := os.Create(filepath.Join(t.TempDir(), "out")) + if err != nil { + t.Fatalf("create temp file: %v", err) + } + t.Cleanup(func() { _ = f.Close() }) + return f +} + +func TestDetectProfileExplicitDisable(t *testing.T) { + if got := DetectProfile(pipeFile(t), true); got != ProfileNoColor { + t.Errorf("explicit disable: got %v, want ProfileNoColor", got) + } +} + +func TestDetectProfileNoColorEnv(t *testing.T) { + t.Setenv("NO_COLOR", "1") + if got := DetectProfile(pipeFile(t), false); got != ProfileNoColor { + t.Errorf("NO_COLOR set: got %v, want ProfileNoColor", got) + } +} + +func TestDetectProfileDumbTerm(t *testing.T) { + t.Setenv("TERM", "dumb") + if got := DetectProfile(pipeFile(t), false); got != ProfileNoColor { + t.Errorf("TERM=dumb: got %v, want ProfileNoColor", got) + } +} + +func TestDetectProfileNonTerminal(t *testing.T) { + // A regular file is not a terminal, so color must be off even with a + // color-friendly environment. + t.Setenv("TERM", "xterm-256color") + t.Setenv("COLORTERM", "truecolor") + if got := DetectProfile(pipeFile(t), false); got != ProfileNoColor { + t.Errorf("non-terminal output: got %v, want ProfileNoColor", got) + } +} + +func TestWidthFallsBackToColumnsEnv(t *testing.T) { + t.Setenv("COLUMNS", "72") + if got := Width(pipeFile(t)); got != 72 { + t.Errorf("COLUMNS fallback: got %d, want 72", got) + } +} + +func TestWidthUnknown(t *testing.T) { + t.Setenv("COLUMNS", "") + if got := Width(pipeFile(t)); got != 0 { + t.Errorf("unknown width: got %d, want 0", got) + } +} + +func TestWidthIgnoresGarbageColumns(t *testing.T) { + t.Setenv("COLUMNS", "not-a-number") + if got := Width(pipeFile(t)); got != 0 { + t.Errorf("garbage COLUMNS: got %d, want 0", got) + } +} + +func TestWrap(t *testing.T) { + tests := []struct { + name string + profile Profile + color string + text string + want string + }{ + {"ansi red", ProfileANSI, "red", "ERROR", "\033[31mERROR\033[0m"}, + {"truecolor uses named code", ProfileTrueColor, "cyan", "X", "\033[36mX\033[0m"}, + {"no color passthrough", ProfileNoColor, "red", "ERROR", "ERROR"}, + {"unknown color passthrough", ProfileANSI, "magenta", "TEXT", "TEXT"}, + {"case insensitive", ProfileANSI, "RED", "E", "\033[31mE\033[0m"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := Wrap(tt.profile, tt.color, tt.text); got != tt.want { + t.Errorf("Wrap(%v, %q, %q) = %q, want %q", tt.profile, tt.color, tt.text, got, tt.want) + } + }) + } +} + +func TestStrip(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"red code", "\033[31mERROR\033[0m", "ERROR"}, + {"multiple codes", "\033[31mRED\033[0m and \033[32mGREEN\033[0m", "RED and GREEN"}, + {"truecolor code", "\033[38;2;36;114;200mBLUE\033[0m", "BLUE"}, + {"no codes", "plain text", "plain text"}, + {"empty", "", ""}, + {"compound sequence", "\033[1;31mBOLD RED\033[0m", "BOLD RED"}, + {"only codes", "\033[31m\033[0m", ""}, + {"unicode preserved", "Hello 世界 🌍", "Hello 世界 🌍"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := Strip(tt.input); got != tt.want { + t.Errorf("Strip(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestForegroundRGB(t *testing.T) { + if got := ForegroundRGB(36, 114, 200); got != "\033[38;2;36;114;200m" { + t.Errorf("ForegroundRGB = %q", got) + } +} + +func TestCodeAndRGBCoverSameNames(t *testing.T) { + for name := range ansiCodes { + if _, ok := rgbValues[name]; !ok { + t.Errorf("color %q has an ANSI code but no RGB value", name) + } + } + for name := range rgbValues { + if _, ok := ansiCodes[name]; !ok { + t.Errorf("color %q has an RGB value but no ANSI code", name) + } + } +} diff --git a/test/benchmarks/startup_bench_test.go b/test/benchmarks/startup_bench_test.go index 5e27def..5bfbbcf 100644 --- a/test/benchmarks/startup_bench_test.go +++ b/test/benchmarks/startup_bench_test.go @@ -8,6 +8,7 @@ import ( "github.com/veteranbv/sysgreet/internal/banner" "github.com/veteranbv/sysgreet/internal/collectors" "github.com/veteranbv/sysgreet/internal/config" + "github.com/veteranbv/sysgreet/internal/terminal" ) // BenchmarkStartup is a high-level benchmark that builds a banner using stub collectors. @@ -26,7 +27,7 @@ func BenchmarkStartup(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - if _, _, err := nBanner.Build(ctx, cfg); err != nil { + if _, _, err := nBanner.Build(ctx, cfg, terminal.Env{}); err != nil { b.Fatalf("banner build failed: %v", err) } } diff --git a/test/integration/binary_test.go b/test/integration/binary_test.go index 3157968..00b3773 100644 --- a/test/integration/binary_test.go +++ b/test/integration/binary_test.go @@ -1,9 +1,11 @@ package integration import ( + "encoding/json" "os" "os/exec" "path/filepath" + "strconv" "strings" "testing" "time" @@ -230,3 +232,132 @@ func TestBinaryMemoryFootprint(t *testing.T) { t.Logf("Binary size: %.2f MB", sizeMB) } + +// buildTestBinary compiles sysgreet into dir and returns its path. +func buildTestBinary(t *testing.T, dir string) string { + t.Helper() + binaryPath := filepath.Join(dir, "sysgreet") + buildCmd := exec.Command("go", "build", "-o", binaryPath, "../../cmd/sysgreet") + if output, err := buildCmd.CombinedOutput(); err != nil { + t.Fatalf("failed to build binary: %v\nOutput: %s", err, output) + } + return binaryPath +} + +func TestBinaryJSONOutput(t *testing.T) { + if testing.Short() { + t.Skip("skipping binary build in short mode") + } + tmpDir := t.TempDir() + binaryPath := buildTestBinary(t, tmpDir) + + cmd := exec.Command(binaryPath, "--json") + cmd.Env = append(os.Environ(), "SYSGREET_CONFIG="+filepath.Join(tmpDir, "config.yaml"), "CI=1") + output, err := cmd.Output() + if err != nil { + t.Fatalf("--json run failed: %v", err) + } + + var doc struct { + Hostname string `json:"hostname"` + Sections []struct { + Key string `json:"key"` + Lines []string `json:"lines"` + } `json:"sections"` + } + if err := json.Unmarshal(output, &doc); err != nil { + t.Fatalf("--json output is not valid JSON: %v\nOutput: %s", err, output) + } + if doc.Hostname == "" { + t.Errorf("JSON output missing hostname: %s", output) + } + if strings.Contains(string(output), "\033") { + t.Errorf("JSON output contains escape sequences") + } +} + +func TestBinaryWidthNeverOverflows(t *testing.T) { + if testing.Short() { + t.Skip("skipping binary build in short mode") + } + tmpDir := t.TempDir() + binaryPath := buildTestBinary(t, tmpDir) + + for _, width := range []string{"30", "50", "80"} { + cmd := exec.Command(binaryPath, "--text", "media-server-vault-01", "--width", width) + output, err := cmd.Output() + if err != nil { + t.Fatalf("--width %s run failed: %v", width, err) + } + limit, _ := strconv.Atoi(width) + for _, line := range strings.Split(string(output), "\n") { + if n := len([]rune(line)); n > limit { + t.Errorf("--width %s: line has %d columns: %q", width, n, line) + } + } + } +} + +func TestBinaryListFonts(t *testing.T) { + if testing.Short() { + t.Skip("skipping binary build in short mode") + } + tmpDir := t.TempDir() + binaryPath := buildTestBinary(t, tmpDir) + + output, err := exec.Command(binaryPath, "--list-fonts").Output() + if err != nil { + t.Fatalf("--list-fonts run failed: %v", err) + } + for _, want := range []string{"ANSI Regular", "standard", "Small"} { + if !strings.Contains(string(output), want) { + t.Errorf("--list-fonts missing %q\nGot: %s", want, output) + } + } +} + +func TestBinaryTextModeHonorsConfigFont(t *testing.T) { + if testing.Short() { + t.Skip("skipping binary build in short mode") + } + tmpDir := t.TempDir() + binaryPath := buildTestBinary(t, tmpDir) + + configPath := filepath.Join(tmpDir, "config.yaml") + config := "ascii:\n font: \"standard\"\n monochrome: true\n" + if err := os.WriteFile(configPath, []byte(config), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + cmd := exec.Command(binaryPath, "--text", "hi") + cmd.Env = append(os.Environ(), "SYSGREET_CONFIG="+configPath, "CI=1") + output, err := cmd.Output() + if err != nil { + t.Fatalf("--text run failed: %v", err) + } + // The standard font draws with slashes and underscores, not the solid + // blocks of the default ANSI Regular. + if strings.Contains(string(output), "█") { + t.Errorf("--text ignored the configured font\nGot: %s", output) + } + if !strings.Contains(string(output), "_") { + t.Errorf("expected standard-font glyphs in output\nGot: %s", output) + } +} + +func TestBinaryNoColorFlag(t *testing.T) { + if testing.Short() { + t.Skip("skipping binary build in short mode") + } + tmpDir := t.TempDir() + binaryPath := buildTestBinary(t, tmpDir) + + cmd := exec.Command(binaryPath, "--demo", "--no-color") + output, err := cmd.Output() + if err != nil { + t.Fatalf("--no-color run failed: %v", err) + } + if strings.Contains(string(output), "\033") { + t.Errorf("--no-color output contains escape sequences") + } +} diff --git a/test/integration/linux/config_test.go b/test/integration/linux/config_test.go index 064d7fa..46ab4cc 100644 --- a/test/integration/linux/config_test.go +++ b/test/integration/linux/config_test.go @@ -12,6 +12,7 @@ import ( "github.com/veteranbv/sysgreet/internal/collectors" "github.com/veteranbv/sysgreet/internal/config" "github.com/veteranbv/sysgreet/internal/render" + "github.com/veteranbv/sysgreet/internal/terminal" ) func TestYAMLConfigDisablesNetworkSections(t *testing.T) { @@ -59,11 +60,11 @@ layout: if err != nil { t.Fatalf("banner.New: %v", err) } - out, _, err := b.Build(context.Background(), cfg) + out, _, err := b.Build(context.Background(), cfg, terminal.Env{}) if err != nil { t.Fatalf("Build: %v", err) } - txt := render.NewRenderer(true).Render(out, cfg) + txt := render.NewRenderer(terminal.Env{}).Render(out, cfg) if strings.Contains(txt, "System") { t.Fatalf("expected system section to be disabled, got %s", txt) } diff --git a/test/integration/windows/session_test.go b/test/integration/windows/session_test.go index 0db62d8..197cf83 100644 --- a/test/integration/windows/session_test.go +++ b/test/integration/windows/session_test.go @@ -13,6 +13,7 @@ import ( "github.com/veteranbv/sysgreet/internal/collectors" "github.com/veteranbv/sysgreet/internal/config" "github.com/veteranbv/sysgreet/internal/render" + "github.com/veteranbv/sysgreet/internal/terminal" ) func TestSessionCollectorFallsBackToSSHClient(t *testing.T) { @@ -103,11 +104,11 @@ layout.sections = ["network", "system"] if err != nil { t.Fatalf("banner.New: %v", err) } - out, _, err := b.Build(context.Background(), cfg) + out, _, err := b.Build(context.Background(), cfg, terminal.Env{}) if err != nil { t.Fatalf("Build: %v", err) } - result := render.NewRenderer(true).Render(out, cfg) + result := render.NewRenderer(terminal.Env{}).Render(out, cfg) if strings.Contains(result, "Mem:") { t.Fatalf("expected memory data suppressed, got %s", result) } From 8363fffceb7b2b5530595da129b1e0ccdffdd5df Mon Sep 17 00:00:00 2001 From: VeteranBV Date: Mon, 13 Jul 2026 21:53:04 +0000 Subject: [PATCH 02/12] perf: run collectors concurrently under a shared deadline Collectors previously ran one after another, so on Windows the 100 ms CPU sample alone blew the 50 ms startup budget, and any single slow metric source could hang the login shell indefinitely. Each collector now gathers in its own goroutine (they write disjoint Snapshot fields) and shares a 250 ms deadline; a collector that misses it just drops its section, which the banner already degrades around. --- internal/collectors/resources_test.go | 88 +++++++++++++++++++++++++++ internal/collectors/system.go | 87 +++++++++++++++++--------- 2 files changed, 145 insertions(+), 30 deletions(-) diff --git a/internal/collectors/resources_test.go b/internal/collectors/resources_test.go index a6a583d..d357af4 100644 --- a/internal/collectors/resources_test.go +++ b/internal/collectors/resources_test.go @@ -3,6 +3,7 @@ package collectors import ( "context" "testing" + "time" ) func TestResourceCollectorReturnsMetrics(t *testing.T) { @@ -27,3 +28,90 @@ func TestResourceCollectorReturnsMetrics(t *testing.T) { t.Fatalf("expected CPU mode set") } } + +func TestGatherRunsCollectorsConcurrently(t *testing.T) { + // Each stub sleeps 30ms; serial execution would take 150ms. + start := time.Now() + providers := Providers{ + System: slowSystemCollector{}, + Network: slowNetworkCollector{}, + Resources: slowResourceCollector{}, + Session: slowSessionCollector{}, + LastLogin: slowLastLoginCollector{}, + } + snap := providers.Gather(context.Background()) + elapsed := time.Since(start) + + if elapsed > 120*time.Millisecond { + t.Errorf("Gather took %v; collectors do not appear to run concurrently", elapsed) + } + if snap.System.Hostname != "slow" { + t.Errorf("system snapshot missing, got %+v", snap.System) + } + if snap.Network.Primary == nil { + t.Error("network snapshot missing") + } + if snap.Session.RemoteAddr != "203.0.113.7" { + t.Errorf("session snapshot missing, got %+v", snap.Session) + } + if snap.LastLogin == nil { + t.Error("last login snapshot missing") + } +} + +func TestGatherToleratesHangingCollector(t *testing.T) { + providers := Providers{ + System: hangingSystemCollector{}, + } + start := time.Now() + providers.Gather(context.Background()) + if elapsed := time.Since(start); elapsed > gatherTimeout+150*time.Millisecond { + t.Errorf("Gather took %v; timeout did not bound a hanging collector", elapsed) + } +} + +type slowSystemCollector struct{} + +func (slowSystemCollector) CollectSystem(ctx context.Context) (SystemInfo, error) { + time.Sleep(30 * time.Millisecond) + return SystemInfo{Hostname: "slow"}, nil +} + +type slowNetworkCollector struct{} + +func (slowNetworkCollector) CollectNetwork(ctx context.Context) (NetworkInfo, error) { + time.Sleep(30 * time.Millisecond) + return NetworkInfo{Primary: &Address{IP: "10.0.0.1", Interface: "eth0"}}, nil +} + +type slowResourceCollector struct{} + +func (slowResourceCollector) CollectResources(ctx context.Context) (ResourceInfo, error) { + time.Sleep(30 * time.Millisecond) + return ResourceInfo{}, nil +} + +type slowSessionCollector struct{} + +func (slowSessionCollector) CollectSession(ctx context.Context) (SessionInfo, error) { + time.Sleep(30 * time.Millisecond) + return SessionInfo{RemoteAddr: "203.0.113.7"}, nil +} + +type slowLastLoginCollector struct{} + +func (slowLastLoginCollector) CollectLastLogin(ctx context.Context) (*LastLoginInfo, error) { + time.Sleep(30 * time.Millisecond) + return &LastLoginInfo{Timestamp: time.Now()}, nil +} + +type hangingSystemCollector struct{} + +func (hangingSystemCollector) CollectSystem(ctx context.Context) (SystemInfo, error) { + select { + case <-ctx.Done(): + return SystemInfo{}, ctx.Err() + case <-time.After(10 * time.Second): + return SystemInfo{}, nil + } +} diff --git a/internal/collectors/system.go b/internal/collectors/system.go index e4b0b23..38b0fb4 100644 --- a/internal/collectors/system.go +++ b/internal/collectors/system.go @@ -2,9 +2,14 @@ package collectors import ( "context" + "sync" "time" ) +// gatherTimeout bounds total collection time. A login banner that is a few +// metrics short beats one that hangs the shell. +const gatherTimeout = 250 * time.Millisecond + // SystemInfo captures host identity and session metadata. type SystemInfo struct { Hostname string @@ -112,51 +117,73 @@ type Providers struct { LastLogin LastLoginCollector } -// Gather builds a Snapshot using the configured providers. Missing collectors are tolerated to allow graceful degradation. +// Gather builds a Snapshot using the configured providers. Collectors run +// concurrently — each writes a distinct Snapshot field — and share a single +// deadline. Missing collectors and failures are tolerated to allow graceful +// degradation. func (p Providers) Gather(ctx context.Context) Snapshot { + ctx, cancel := context.WithTimeout(ctx, gatherTimeout) + defer cancel() + var snap Snapshot + var wg sync.WaitGroup + collect := func(fn func()) { + wg.Add(1) + go func() { + defer wg.Done() + fn() + }() + } + if p.System != nil { - if sys, err := p.System.CollectSystem(ctx); err == nil { - fmtSystem(&snap.System, sys) - } else { - recordError("system", err) - } + collect(func() { + if sys, err := p.System.CollectSystem(ctx); err == nil { + snap.System = sys + } else { + recordError("system", err) + } + }) } if p.Network != nil { - if netInfo, err := p.Network.CollectNetwork(ctx); err == nil { - snap.Network = netInfo - } else { - recordError("network", err) - } + collect(func() { + if netInfo, err := p.Network.CollectNetwork(ctx); err == nil { + snap.Network = netInfo + } else { + recordError("network", err) + } + }) } if p.Resources != nil { - if res, err := p.Resources.CollectResources(ctx); err == nil { - snap.Resources = res - } else { - recordError("resources", err) - } + collect(func() { + if res, err := p.Resources.CollectResources(ctx); err == nil { + snap.Resources = res + } else { + recordError("resources", err) + } + }) } if p.Session != nil { - if session, err := p.Session.CollectSession(ctx); err == nil { - snap.Session = session - } else { - recordError("session", err) - } + collect(func() { + if session, err := p.Session.CollectSession(ctx); err == nil { + snap.Session = session + } else { + recordError("session", err) + } + }) } if p.LastLogin != nil { - if last, err := p.LastLogin.CollectLastLogin(ctx); err == nil { - snap.LastLogin = last - } else { - recordError("last_login", err) - } + collect(func() { + if last, err := p.LastLogin.CollectLastLogin(ctx); err == nil { + snap.LastLogin = last + } else { + recordError("last_login", err) + } + }) } + wg.Wait() return snap } -func fmtSystem(dst *SystemInfo, src SystemInfo) { - *dst = src -} - // DemoSnapshot returns a realistic demo snapshot for screenshots and testing. func DemoSnapshot() Snapshot { now := time.Now() From 2b3f803ca98d5e8b7d5e65eedbfe65a81b258cd5 Mon Sep 17 00:00:00 2001 From: VeteranBV Date: Mon, 13 Jul 2026 21:53:04 +0000 Subject: [PATCH 03/12] chore: migrate golangci-lint config to v2 The lint job pinned golangci-lint-action@v6 with version: latest, which now resolves to golangci-lint v2 and rejects the v1-format .golangci.yml outright. Migrated the config with `golangci-lint migrate` and pinned the action (v8) and linter (v2.5.0) so lint results stop depending on whatever "latest" means this week. --- .github/workflows/ci.yml | 4 +- .golangci.yml | 80 ++++++++++++++++++++++------------------ 2 files changed, 46 insertions(+), 38 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed3d23b..5eac50f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,9 +16,9 @@ jobs: with: go-version: '1.22' - name: golangci-lint - uses: golangci/golangci-lint-action@v6 + uses: golangci/golangci-lint-action@v8 with: - version: latest + version: v2.5.0 args: --timeout=5m - name: Check formatting run: | diff --git a/.golangci.yml b/.golangci.yml index cd1a4a7..36abddf 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,46 +1,54 @@ +version: "2" run: - timeout: 5m - tests: true modules-download-mode: readonly - + tests: true linters: enable: - - errcheck - - gosimple - - govet - - ineffassign - - staticcheck - - unused - - gofmt - - goimports - - misspell - - unconvert - - unparam + - copyloopvar - goconst - gocyclo - gosec - - copyloopvar - -linters-settings: - gocyclo: - min-complexity: 15 - goconst: - min-len: 3 - min-occurrences: 3 - misspell: - locale: US - + - misspell + - unconvert + - unparam + settings: + goconst: + min-len: 3 + min-occurrences: 3 + gocyclo: + min-complexity: 15 + misspell: + locale: US + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + rules: + - linters: + - gocyclo + - gosec + - unparam + path: _test\.go + - linters: + - staticcheck + text: cgo-gcc-prolog + paths: + - third_party$ + - builtin$ + - examples$ issues: - exclude-rules: - # Exclude some linters from running on tests files - - path: _test\.go - linters: - - gocyclo - - gosec - - unparam - # Ignore CGO warnings from dependencies - - text: "cgo-gcc-prolog" - linters: - - staticcheck max-issues-per-linter: 0 max-same-issues: 0 +formatters: + enable: + - gofmt + - goimports + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ From 174b7ef797536b1c9f941b124723db12fda20901 Mon Sep 17 00:00:00 2001 From: VeteranBV Date: Mon, 13 Jul 2026 21:53:04 +0000 Subject: [PATCH 04/12] docs: cover width handling, new flags, and JSON output README highlights the width degradation ladder and script-friendly output, documents the new flags (--json, --font, --width, --no-color, --config, --list-fonts) and layout.max_width. CHANGELOG gains a v1.1.0 entry. CONTRIBUTING notes the new internal/terminal package plus the output rules (width and color must flow through it), and the examples show narrow-terminal degradation and jq-based scripting. --- CHANGELOG.md | 26 ++++++++++++++++ CONTRIBUTING.md | 15 +++++++++- README.md | 50 +++++++++++++++++++++++++++---- docs/examples/config-scenarios.md | 29 ++++++++++++++++++ docs/examples/default-output.md | 23 ++++++++++++++ docs/examples/fonts.md | 20 +++++++++++-- 6 files changed, 154 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bc42d6..98fe658 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## v1.1.0 + +### Added + +- **Width-aware rendering** - Sysgreet now detects the terminal width and guarantees the banner never wraps mid-glyph. When the configured font is too wide it steps down gracefully: drop the domain from the hostname (the full name moves to an info line), try a narrower font, and finally render a clean single-line ruled header. Narrow tmux panes and phone SSH sessions stay readable. +- **`Small` font** - A narrower FIGlet font (from the standard figlet distribution) embedded as the last art step before the plain-header fallback. +- **Truecolor gradients** - On terminals advertising `COLORTERM=truecolor`, gradient stops are interpolated into a smooth 24-bit fade. 16-color terminals keep the existing per-line cycling. +- **`--json` flag** - Emit the banner as structured JSON for scripting (section data includes raw percentages for thresholding in pipelines). Never prompts for bootstrap. +- **New flags** - `--font` (per-run font override), `--width` (assume a terminal width), `--no-color`, `--config` (explicit config path), `--list-fonts`. +- **`layout.max_width`** - Config key (and `SYSGREET_LAYOUT_MAX_WIDTH`) to cap banner width below the detected terminal size. +- **Windows legacy console support** - Virtual terminal processing is enabled before writing escapes; when the console refuses, output falls back to plain text instead of printing raw escape codes. + +### Fixed + +- **`NO_COLOR` now covers the whole banner** - Previously the hostname art kept its gradient with `NO_COLOR` set; escape sequences are now fully suppressed, including when output is piped or `TERM=dumb`. +- **Piped output is plain** - `sysgreet > motd.txt` no longer embeds ANSI codes; color is keyed off whether stdout is a terminal. +- **`--text` and `--demo` honor your config** - Both modes previously ignored the config file, so custom fonts and gradients silently didn't apply. +- **Compact mode is actually compact** - `layout.compact` now emits a true single line using the plain hostname instead of embedding the multi-line art. +- **Unknown fonts fall back deterministically** - A typo in `ascii.font` now falls back to the default font (logged under `SYSGREET_DEBUG`) instead of picking a random font every login. +- **Long body lines clip cleanly** - Info lines that exceed the terminal width are truncated with an ellipsis instead of wrapping. + +### Changed + +- **Collectors run concurrently** - All five collectors gather in parallel under a shared 250 ms deadline, so a slow metric source can never hang a login. Windows logins no longer pay the 100 ms CPU sample serially. +- **One color palette** - The ANSI color tables previously duplicated (and drifted) across packages now live in a single `internal/terminal` package alongside width and capability detection. + ## v0.9.1 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9d3a3f5..4b9b101 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -154,7 +154,8 @@ sysgreet/ │ ├── collectors/ # System data collectors │ ├── config/ # Configuration loading │ ├── network/ # Network utilities -│ └── render/ # Layout and color rendering +│ ├── render/ # Layout, clipping, and JSON rendering +│ └── terminal/ # Terminal width/color detection, ANSI palette ├── test/ │ ├── benchmarks/ # Performance benchmarks │ └── integration/ # Platform-specific integration tests @@ -199,6 +200,18 @@ Sysgreet has strict performance requirements: When adding new collectors or features, ensure they don't violate these constraints. +Collectors run concurrently inside `Providers.Gather` under a shared 250 ms +deadline, so honor the `context.Context` you receive — a collector that +ignores cancellation can still delay a login. + +## Output Rules + +- Never print wider than the terminal. The ascii renderer receives a + `MaxWidth` and walks a fallback ladder; anything you add to the banner body + is clipped by the render layer. Don't bypass either. +- All color must flow through `internal/terminal` so `NO_COLOR`, `TERM=dumb`, + piped output, and Windows console quirks stay handled in one place. + ## Documentation ### README Updates diff --git a/README.md b/README.md index 6b1f69b..e3471e5 100644 --- a/README.md +++ b/README.md @@ -38,17 +38,26 @@ network or depending on external runtimes. - **Single static binary** - Go 1.22+, no CGO, no daemons, no service dependencies. +- **Fits any terminal** - Sysgreet measures the terminal before printing and + steps the banner down gracefully (shorter hostname, then a narrower font, + then a clean one-line header) instead of wrapping ASCII art into garbage. + Split tmux panes and phone SSH sessions stay readable. - **Cross-platform parity** - Linux/macOS show load averages; Windows surfaces - CPU usage. Interface filtering avoids noisy virtual adapters everywhere. + CPU usage (and legacy consoles get plain text instead of raw escape codes). + Interface filtering avoids noisy virtual adapters everywhere. - **Configurable yet optional** - YAML or TOML profiles toggle sections, pick fonts/colors, set layout order, and cap the interface list. Defaults "just work" with zero files. - **Graceful degradation** - Missing metrics or SSH metadata simply fall back; the banner keeps rendering. -- **Performance-guarded** - Startup benchmark (<50 ms median, <80 ms p95) runs in - CI; process RSS stays <15 MB. -- **Professional aesthetics** - Unicode block fonts with gradient colors, - automatic monochrome fallback, 80-column mindful layout. +- **Performance-guarded** - Collectors run in parallel under a hard 250 ms + deadline; the startup benchmark (<50 ms median, <80 ms p95) runs in CI and + process RSS stays <15 MB. +- **Professional aesthetics** - Unicode block fonts with gradient colors — + smooth 24-bit fades on truecolor terminals — plus automatic monochrome + fallback and full `NO_COLOR` support. +- **Script-friendly** - `--json` emits the same data as structured JSON; + piped output is always plain text. --- @@ -107,6 +116,19 @@ sysgreet --disable sysgreet --text "Production DB" sysgreet --text "Coffee Break" sysgreet --text "Deploy Day" + +# JSON mode - structured output for scripts (no art, no prompts) +sysgreet --json | jq -r '.hostname' +``` + +**Useful flags:** + +```bash +sysgreet --list-fonts # Print the embedded fonts +sysgreet --font "ANSI Shadow" # One-off font override +sysgreet --width 60 # Preview how a 60-column session renders +sysgreet --no-color # Plain output (NO_COLOR works too) +sysgreet --config ~/alt.yaml # Point at a specific config file ``` > **Tip:** Use `--text` to create custom banners for different environments, reminders, or just for fun. Great for distinguishing production boxes, marking maintenance windows, or adding personality to your terminals. @@ -147,6 +169,7 @@ display: layout: compact: false + max_width: 0 # cap banner width in columns; 0 = detected terminal width sections: ["header", "network", "system", "resources"] network: @@ -182,12 +205,28 @@ Environment variables override everything (e.g. yellow, ≥90% in red). Windows surfaces realtime CPU usage; Unix hosts show load averages. +### Terminal width handling + +The hostname art is only printed when it actually fits. On narrow terminals +sysgreet steps down automatically: + +1. Full hostname in your configured font +2. Hostname without the domain (`pve1.home.lan` → `PVE1`, with the full name + kept on an info line) +3. Progressively narrower fonts (`standard`, then `Small`) +4. A single ruled line — `═════ PVE1 ═════` — that fits any width + +Set `layout.max_width` to cap the width below what the terminal reports, or +pass `--width` to preview a specific size. + --- ## Performance guarantees - **Startup** - `< 50 ms` median, `< 80 ms` p95 (validated by `go test -bench Startup ./test/benchmarks`) +- **Never hangs a login** - Collectors run concurrently under a shared 250 ms + deadline; a stuck metric source just drops its section - **Binary footprint** - `< 10 MB` for all release targets (GoReleaser checks) - **Runtime memory** - `< 15 MB` RSS for default banner - **No network activity** - All data collected locally, offline-safe @@ -237,7 +276,6 @@ platform-specific improvements before diving in. ## Roadmap -- Optional JSON output for scripting in CI/CD pipelines - Extended GPU/storage telemetry for workstation profiles - Pluggable section framework (e.g., Kubernetes context, vault status) - Prebuilt Windows installer for enterprise onboarding diff --git a/docs/examples/config-scenarios.md b/docs/examples/config-scenarios.md index f6bfd2b..4b681b5 100644 --- a/docs/examples/config-scenarios.md +++ b/docs/examples/config-scenarios.md @@ -46,6 +46,35 @@ network: max_interfaces: 5 ``` +## Narrow Terminals and tmux Panes + +Sysgreet detects the terminal width at startup and degrades the banner +automatically, so nothing is required for split panes or phone SSH clients. +To cap the width below what the terminal reports (for example to keep logs +tidy), set: + +```yaml +layout: + max_width: 80 +``` + +Useful companions: + +- `sysgreet --width 60` previews what a 60-column session will see. +- `SYSGREET_LAYOUT_MAX_WIDTH=100` does the same per environment. +- On very tight widths the hostname renders as a single ruled line instead of + multi-line art — it never wraps into garbage. + +## Scripting with JSON + +`sysgreet --json` emits the banner data as structured JSON (no art, no color, +no bootstrap prompts), with section `data` carrying raw values like +`memory_used_percent` for thresholding: + +```bash +sysgreet --json | jq -r '.sections[] | select(.key=="resources").data.memory_used_percent' +``` + ## Troubleshooting Metric Discrepancies If resource values appear inconsistent with native tools: diff --git a/docs/examples/default-output.md b/docs/examples/default-output.md index 10140e2..0db26f9 100644 --- a/docs/examples/default-output.md +++ b/docs/examples/default-output.md @@ -42,4 +42,27 @@ Resources Note: The banner uses ANSI Regular font with a blue-to-white gradient (brightblue → blue → cyan → brightcyan → white). +## Narrow Terminal Degradation + +The same banner adapts to the available width instead of wrapping. At 50 +columns the default font no longer fits, so sysgreet steps down to the +`Small` font: + +```text + _ + ___ _ _ ___ __ _ _ _ ___ ___ | |_ + (_-< | || | (_-< / _` | | '_| / -_) / -_) | _| + /__/ \_, | /__/ \__, | |_| \___| \___| \__| + |__/ |___/ +``` + +And when even that is too wide (long hostnames, very tight panes), the header +collapses to a single ruled line: + +```text +═════════════ SYSGREET ═════════════ +``` + +Preview any width with `sysgreet --width `. + Use this snapshot for QA validation and regression testing until golden files are finalized. diff --git a/docs/examples/fonts.md b/docs/examples/fonts.md index d495bb3..b798d3c 100644 --- a/docs/examples/fonts.md +++ b/docs/examples/fonts.md @@ -9,10 +9,16 @@ The Sysgreet banner embeds multiple FIGlet fonts to ensure the CLI operates offl - **`Block.flf`** - Classic blocky style - **`Blocks.flf`** - Variant of block style - **`DOS Rebel.flf`** - Modern, clean DOS-style font +- **`Banner.flf`** - Classic wide banner style - **`Basic.flf`** - Simple hash-mark style +- **`Small.flf`** - Narrow variant of standard, used automatically on tight terminals - **`standard.flf`** - Classic FIGlet standard font - **`slant.flf`** - Tall, angular slanted font +Run `sysgreet --list-fonts` to print this list from the binary itself, and +`sysgreet --text "Test" --font "ANSI Shadow"` to preview one without touching +your config. + All fonts originate from the FIGlet project () and community repositories. They are licensed under the FIGlet Font License, which permits redistribution and embedding in binaries. License text is included at the top of each font file. Fonts are stored in `assets/fonts/` and shipped with the binary via Go's `//go:embed` directive. @@ -24,11 +30,21 @@ Fonts can be configured in your config file: ```yaml ascii: font: "ANSI Regular" # Default - compact Unicode blocks - # or "ANSI Shadow", "Block", "DOS Rebel", "Basic", "standard", "slant" + # or "ANSI Shadow", "Banner", "Basic", "Block", "Blocks", "DOS Rebel", + # "Small", "standard", "slant" ``` Or test fonts quickly: ```bash -sysgreet --text "Test" --config-policy=keep +sysgreet --text "Test" --font "Small" ``` + +## Width behavior + +The configured font is a preference, not a promise. Sysgreet measures the art +against the terminal before printing; when it would overflow, it drops the +domain from the hostname, then tries `standard` and `Small`, and finally +renders a one-line ruled header. A misspelled font name falls back to the +default (`ANSI Regular`) — set `SYSGREET_DEBUG=1` to see a note when that +happens. From 4a74b74264c526dd2070ded5b0a3232cd7ab0522 Mon Sep 17 00:00:00 2001 From: VeteranBV Date: Mon, 13 Jul 2026 22:30:37 +0000 Subject: [PATCH 05/12] fix: close width and monochrome gaps flagged in review Four follow-ups to the width-aware rendering work: - Compact mode output is now clipped to the terminal width like every other line; it previously returned before the clipping path ran. - Domain shortening (pve1.home.lan -> PVE1) is now opt-in via RenderOptions.ShortenDomain and only the hostname header sets it, so --text "v2.1.0" can shrink or clip but never silently loses everything after the first dot. - layout.max_width now caps the whole banner, not just the header art: the config folds into the terminal env before the layout renderer is built (render.ApplyConfig), with --width still winning over both. - ascii.monochrome once again suppresses the resource threshold highlights; keying the colorizer off the terminal profile alone had regressed that. --- cmd/sysgreet/main.go | 8 +++-- internal/ascii/ascii.go | 4 +++ internal/ascii/ascii_test.go | 33 ++++++++++++++++++--- internal/ascii/fonts.go | 6 ++-- internal/banner/banner.go | 15 +++++----- internal/render/layout.go | 15 +++++++++- internal/render/layout_test.go | 53 ++++++++++++++++++++++++++++++++++ 7 files changed, 117 insertions(+), 17 deletions(-) diff --git a/cmd/sysgreet/main.go b/cmd/sysgreet/main.go index b8cc56d..4ec122c 100644 --- a/cmd/sysgreet/main.go +++ b/cmd/sysgreet/main.go @@ -69,14 +69,16 @@ func run() error { // to plain output rather than printing raw escapes. ansiOK := enableVirtualTerminal(os.Stdout) env := terminal.DetectEnv(os.Stdout, settings.NoColor || !ansiOK) - if settings.Width > 0 { - env.Width = settings.Width - } cfg, err := loadConfig(ctx, settings) if err != nil { return err } + env = render.ApplyConfig(env, cfg) + if settings.Width > 0 { + // The flag wins over both the detected width and layout.max_width. + env.Width = settings.Width + } if settings.Text != "" { return runTextMode(renderer, settings.Text, cfg, env) diff --git a/internal/ascii/ascii.go b/internal/ascii/ascii.go index 8b2384e..ea46640 100644 --- a/internal/ascii/ascii.go +++ b/internal/ascii/ascii.go @@ -11,6 +11,10 @@ type RenderOptions struct { Uppercase bool // MaxWidth caps the art width in columns. Zero means unconstrained. MaxWidth int + // ShortenDomain allows the width ladder to drop everything after the + // first dot (pve1.home.lan -> pve1). Only hostname rendering sets + // this; user-supplied --text must never be rewritten. + ShortenDomain bool // Profile gates color output for the target terminal. Profile terminal.Profile } diff --git a/internal/ascii/ascii_test.go b/internal/ascii/ascii_test.go index b210ba3..40ae742 100644 --- a/internal/ascii/ascii_test.go +++ b/internal/ascii/ascii_test.go @@ -104,10 +104,11 @@ func TestRendererFitsWithinMaxWidth(t *testing.T) { func TestRendererShortensDomainToFit(t *testing.T) { r := mustRenderer(t) art, err := r.Render("pve1.home.lan", RenderOptions{ - Font: "ANSI Regular", - Uppercase: true, - MaxWidth: 60, - Profile: terminal.ProfileNoColor, + Font: "ANSI Regular", + Uppercase: true, + MaxWidth: 60, + ShortenDomain: true, + Profile: terminal.ProfileNoColor, }) if err != nil { t.Fatalf("Render() error = %v", err) @@ -120,6 +121,30 @@ func TestRendererShortensDomainToFit(t *testing.T) { } } +func TestRendererNeverShortensLiteralText(t *testing.T) { + r := mustRenderer(t) + // --text content must survive verbatim: a dotted string squeezed into + // a tiny width may shrink or clip, but never lose everything after + // the first dot. + art, err := r.Render("v2.1.0-rc.1", RenderOptions{ + Font: "ANSI Regular", + MaxWidth: 30, + Profile: terminal.ProfileNoColor, + }) + if err != nil { + t.Fatalf("Render() error = %v", err) + } + if art.Shortened { + t.Fatalf("literal text was shortened at a dot: %q", art.Text) + } + if !strings.Contains(terminal.Strip(art.Text), "v2.1") { + t.Fatalf("literal text lost its dotted content: %q", art.Text) + } + if art.Width > 30 { + t.Fatalf("literal text overflows: %d columns", art.Width) + } +} + func TestRendererFallsBackToNarrowFont(t *testing.T) { r := mustRenderer(t) // Wide enough for the name in Small but not in ANSI Regular. diff --git a/internal/ascii/fonts.go b/internal/ascii/fonts.go index 6571b51..c3bb027 100644 --- a/internal/ascii/fonts.go +++ b/internal/ascii/fonts.go @@ -97,8 +97,10 @@ func (r *Renderer) Render(text string, opts RenderOptions) (Art, error) { } texts := []string{text} - if short := shortName(text); short != text { - texts = append(texts, short) + if opts.ShortenDomain { + if short := shortName(text); short != text { + texts = append(texts, short) + } } for _, font := range r.fontLadder(opts.Font) { diff --git a/internal/banner/banner.go b/internal/banner/banner.go index eba9346..be90cd1 100644 --- a/internal/banner/banner.go +++ b/internal/banner/banner.go @@ -76,13 +76,14 @@ func (b *Banner) buildHeader(snap collectors.Snapshot, cfg config.Config, env te name = "sysgreet" } art, err := b.ascii.Render(name, ascii.RenderOptions{ - Font: cfg.ASCII.Font, - Color: cfg.ASCII.Color, - Gradient: cfg.ASCII.Gradient, - Monochrome: cfg.ASCII.Monochrome, - Uppercase: true, - MaxWidth: effectiveWidth(cfg, env), - Profile: env.Profile, + Font: cfg.ASCII.Font, + Color: cfg.ASCII.Color, + Gradient: cfg.ASCII.Gradient, + Monochrome: cfg.ASCII.Monochrome, + Uppercase: true, + MaxWidth: effectiveWidth(cfg, env), + ShortenDomain: true, + Profile: env.Profile, }) if err != nil { // Fallback to plain text when ASCII rendering fails. diff --git a/internal/render/layout.go b/internal/render/layout.go index efca0f0..cc3fcdd 100644 --- a/internal/render/layout.go +++ b/internal/render/layout.go @@ -24,6 +24,19 @@ func NewRenderer(env terminal.Env) Renderer { return Renderer{colorizer: NewColorizer(env.Profile), width: env.Width} } +// ApplyConfig folds config-driven constraints into the detected terminal +// environment: layout.max_width caps the width and ascii.monochrome forces +// plain output everywhere, including resource threshold highlights. +func ApplyConfig(env terminal.Env, cfg config.Config) terminal.Env { + if max := cfg.Layout.MaxWidth; max > 0 && (env.Width == 0 || max < env.Width) { + env.Width = max + } + if cfg.ASCII.Monochrome { + env.Profile = terminal.ProfileNoColor + } + return env +} + // Render produces the final banner string. func (r Renderer) Render(out banner.Output, cfg config.Config) string { if cfg.Layout.Compact { @@ -75,7 +88,7 @@ func (r Renderer) renderCompact(out banner.Output, cfg config.Config) string { parts = append(parts, section.Title) parts = append(parts, section.Lines...) } - return strings.Join(parts, " | ") + return r.clip(strings.Join(parts, " | "), 0) } // clip truncates a line to the terminal width, accounting for indent and diff --git a/internal/render/layout_test.go b/internal/render/layout_test.go index 708ff86..aa8cf2e 100644 --- a/internal/render/layout_test.go +++ b/internal/render/layout_test.go @@ -429,3 +429,56 @@ func TestRenderJSON(t *testing.T) { t.Errorf("JSON sections not in layout order:\n%s", doc) } } + +func TestRenderer_CompactClipsToWidth(t *testing.T) { + out := banner.Output{ + Header: banner.Header{ + Hostname: "pve1", + Lines: []string{"Linux 6.8 (x86_64)"}, + }, + Sections: []banner.Section{ + {Key: "system", Title: "System", Lines: []string{ + "Uptime: 4d 12h 30m", + "Last login: Fri, 10 Oct 2025 09:45:00 PDT (203.0.113.10)", + }}, + }, + } + cfg := config.Config{Layout: config.LayoutConfig{Compact: true}} + result := NewRenderer(terminal.Env{Width: 50}).Render(out, cfg) + + if n := len([]rune(result)); n > 50 { + t.Errorf("compact line exceeds width 50 (%d): %q", n, result) + } + if !strings.HasSuffix(result, "…") { + t.Errorf("expected clipped compact line to end with ellipsis: %q", result) + } +} + +func TestApplyConfig(t *testing.T) { + base := terminal.Env{Width: 120, Profile: terminal.ProfileANSI} + + capped := ApplyConfig(base, config.Config{Layout: config.LayoutConfig{MaxWidth: 80}}) + if capped.Width != 80 { + t.Errorf("max_width should cap detected width: got %d", capped.Width) + } + + wider := ApplyConfig(base, config.Config{Layout: config.LayoutConfig{MaxWidth: 200}}) + if wider.Width != 120 { + t.Errorf("max_width above terminal width must not widen: got %d", wider.Width) + } + + unknown := ApplyConfig(terminal.Env{Profile: terminal.ProfileANSI}, config.Config{Layout: config.LayoutConfig{MaxWidth: 80}}) + if unknown.Width != 80 { + t.Errorf("max_width should apply when terminal width is unknown: got %d", unknown.Width) + } + + mono := ApplyConfig(base, config.Config{ASCII: config.ASCIIConfig{Monochrome: true}}) + if mono.Profile != terminal.ProfileNoColor { + t.Errorf("monochrome config should force ProfileNoColor, got %v", mono.Profile) + } + + untouched := ApplyConfig(base, config.Config{}) + if untouched != base { + t.Errorf("empty config should leave env unchanged: %+v", untouched) + } +} From e621775bb7c47a7669baad06c57a63ff221eb0dd Mon Sep 17 00:00:00 2001 From: VeteranBV Date: Mon, 13 Jul 2026 22:52:36 +0000 Subject: [PATCH 06/12] fix: tighten width, config, and deadline edges from review - Gather no longer waits past its deadline: collectors send apply functions over a channel and the loop selects against ctx.Done, so a collector stuck in a blocking syscall (ignoring cancellation) is abandoned at 250 ms with the finished collectors' results kept. The snapshot is only touched from the calling goroutine, so stragglers cannot race the caller. - env.Width is now the single width authority: the banner header uses it directly instead of re-applying layout.max_width, so --width 100 with max_width 40 renders one consistent width instead of a 40-column header over 100-column body lines. - Section titles are clipped like body lines; --width 8 no longer leaks a 9-column "Resources" heading. - An explicit config path (--config / SYSGREET_CONFIG) is exclusive: when the file is missing, sysgreet uses built-in defaults instead of silently falling back to a different config file on the host. --- README.md | 5 +- internal/banner/banner.go | 12 +--- internal/banner/banner_test.go | 10 ++-- internal/collectors/resources_test.go | 20 ++++--- internal/collectors/system.go | 82 +++++++++++++++++---------- internal/config/config.go | 5 +- internal/config/config_test.go | 27 +++++++++ internal/render/layout.go | 2 +- internal/render/layout_test.go | 15 +++++ 9 files changed, 120 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index e3471e5..1af5e0b 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,10 @@ sysgreet --config ~/alt.yaml # Point at a specific config file Sysgreet looks for configuration in this order: -1. `SYSGREET_CONFIG` environment variable (absolute or `~/` paths) +1. `--config` flag or `SYSGREET_CONFIG` environment variable (absolute or + `~/` paths). An explicit path is exclusive — if the file is missing, + sysgreet uses built-in defaults rather than silently reading another + config. 2. `~/.config/sysgreet/config.yaml` (or `.yml`, `.toml`) 3. `~/.sysgreet.yaml` / `.toml` diff --git a/internal/banner/banner.go b/internal/banner/banner.go index be90cd1..2db7549 100644 --- a/internal/banner/banner.go +++ b/internal/banner/banner.go @@ -81,7 +81,7 @@ func (b *Banner) buildHeader(snap collectors.Snapshot, cfg config.Config, env te Gradient: cfg.ASCII.Gradient, Monochrome: cfg.ASCII.Monochrome, Uppercase: true, - MaxWidth: effectiveWidth(cfg, env), + MaxWidth: env.Width, ShortenDomain: true, Profile: env.Profile, }) @@ -108,16 +108,6 @@ func (b *Banner) buildHeader(snap collectors.Snapshot, cfg config.Config, env te return Header{Hostname: name, Art: art.Text, Font: art.Font, Color: art.Color, Lines: lines} } -// effectiveWidth combines the detected terminal width with the configured -// layout.max_width cap; zero means unconstrained. -func effectiveWidth(cfg config.Config, env terminal.Env) int { - width := env.Width - if max := cfg.Layout.MaxWidth; max > 0 && (width == 0 || max < width) { - width = max - } - return width -} - func (b *Banner) buildSections(snap collectors.Snapshot, cfg config.Config) []Section { var sections []Section for _, builder := range b.builders { diff --git a/internal/banner/banner_test.go b/internal/banner/banner_test.go index f807b81..1aae8be 100644 --- a/internal/banner/banner_test.go +++ b/internal/banner/banner_test.go @@ -363,7 +363,7 @@ func TestBanner_buildHeaderShortensForNarrowTerminal(t *testing.T) { } } -func TestBanner_buildHeaderRespectsConfiguredMaxWidth(t *testing.T) { +func TestBanner_buildHeaderRespectsEnvWidth(t *testing.T) { b, err := New(collectors.Providers{}, mustRenderer(t), nil) if err != nil { t.Fatalf("New() error = %v", err) @@ -371,14 +371,14 @@ func TestBanner_buildHeaderRespectsConfiguredMaxWidth(t *testing.T) { snap := collectors.Snapshot{ System: collectors.SystemInfo{Hostname: "media-server-vault-01"}, } - cfg := config.Default() - cfg.Layout.MaxWidth = 40 - header := b.buildHeader(snap, cfg, terminal.Env{Width: 200}) + // env.Width is the single width authority; layout.max_width folds into + // it upstream via render.ApplyConfig. + header := b.buildHeader(snap, config.Default(), terminal.Env{Width: 40}) for _, line := range strings.Split(header.Art, "\n") { if n := len([]rune(line)); n > 40 { - t.Errorf("art line exceeds layout.max_width 40 (%d): %q", n, line) + t.Errorf("art line exceeds env width 40 (%d): %q", n, line) } } } diff --git a/internal/collectors/resources_test.go b/internal/collectors/resources_test.go index d357af4..b8e89ea 100644 --- a/internal/collectors/resources_test.go +++ b/internal/collectors/resources_test.go @@ -60,14 +60,20 @@ func TestGatherRunsCollectorsConcurrently(t *testing.T) { } func TestGatherToleratesHangingCollector(t *testing.T) { + // One collector blocks without honoring ctx; the others finish. Gather + // must return at the deadline with the finished results applied. providers := Providers{ - System: hangingSystemCollector{}, + System: hangingSystemCollector{}, + Session: slowSessionCollector{}, } start := time.Now() - providers.Gather(context.Background()) + snap := providers.Gather(context.Background()) if elapsed := time.Since(start); elapsed > gatherTimeout+150*time.Millisecond { t.Errorf("Gather took %v; timeout did not bound a hanging collector", elapsed) } + if snap.Session.RemoteAddr != "203.0.113.7" { + t.Errorf("results from finished collectors should survive a timeout, got %+v", snap.Session) + } } type slowSystemCollector struct{} @@ -105,13 +111,11 @@ func (slowLastLoginCollector) CollectLastLogin(ctx context.Context) (*LastLoginI return &LastLoginInfo{Timestamp: time.Now()}, nil } +// hangingSystemCollector ignores context cancellation entirely, modeling a +// blocking syscall; Gather must still return at its deadline. type hangingSystemCollector struct{} func (hangingSystemCollector) CollectSystem(ctx context.Context) (SystemInfo, error) { - select { - case <-ctx.Done(): - return SystemInfo{}, ctx.Err() - case <-time.After(10 * time.Second): - return SystemInfo{}, nil - } + time.Sleep(10 * time.Second) + return SystemInfo{}, nil } diff --git a/internal/collectors/system.go b/internal/collectors/system.go index 38b0fb4..a599d7f 100644 --- a/internal/collectors/system.go +++ b/internal/collectors/system.go @@ -2,7 +2,6 @@ package collectors import ( "context" - "sync" "time" ) @@ -118,69 +117,90 @@ type Providers struct { } // Gather builds a Snapshot using the configured providers. Collectors run -// concurrently — each writes a distinct Snapshot field — and share a single -// deadline. Missing collectors and failures are tolerated to allow graceful -// degradation. +// concurrently and share a single deadline; whatever has finished by then is +// used and the rest is abandoned, so a stuck collector can never delay a +// login even if it ignores context cancellation. Missing collectors and +// failures are tolerated to allow graceful degradation. func (p Providers) Gather(ctx context.Context) Snapshot { ctx, cancel := context.WithTimeout(ctx, gatherTimeout) defer cancel() - var snap Snapshot - var wg sync.WaitGroup - collect := func(fn func()) { - wg.Add(1) + // Each collector sends an apply function; the snapshot is only ever + // touched from this goroutine, so an abandoned straggler cannot race + // the caller. The channel is buffered so stragglers finish into it + // instead of leaking blocked forever. + results := make(chan func(*Snapshot), 5) + launched := 0 + launch := func(fn func() func(*Snapshot)) { + launched++ go func() { - defer wg.Done() - fn() + results <- fn() }() } if p.System != nil { - collect(func() { - if sys, err := p.System.CollectSystem(ctx); err == nil { - snap.System = sys - } else { + launch(func() func(*Snapshot) { + sys, err := p.System.CollectSystem(ctx) + if err != nil { recordError("system", err) + return nil } + return func(s *Snapshot) { s.System = sys } }) } if p.Network != nil { - collect(func() { - if netInfo, err := p.Network.CollectNetwork(ctx); err == nil { - snap.Network = netInfo - } else { + launch(func() func(*Snapshot) { + netInfo, err := p.Network.CollectNetwork(ctx) + if err != nil { recordError("network", err) + return nil } + return func(s *Snapshot) { s.Network = netInfo } }) } if p.Resources != nil { - collect(func() { - if res, err := p.Resources.CollectResources(ctx); err == nil { - snap.Resources = res - } else { + launch(func() func(*Snapshot) { + res, err := p.Resources.CollectResources(ctx) + if err != nil { recordError("resources", err) + return nil } + return func(s *Snapshot) { s.Resources = res } }) } if p.Session != nil { - collect(func() { - if session, err := p.Session.CollectSession(ctx); err == nil { - snap.Session = session - } else { + launch(func() func(*Snapshot) { + session, err := p.Session.CollectSession(ctx) + if err != nil { recordError("session", err) + return nil } + return func(s *Snapshot) { s.Session = session } }) } if p.LastLogin != nil { - collect(func() { - if last, err := p.LastLogin.CollectLastLogin(ctx); err == nil { - snap.LastLogin = last - } else { + launch(func() func(*Snapshot) { + last, err := p.LastLogin.CollectLastLogin(ctx) + if err != nil { recordError("last_login", err) + return nil } + return func(s *Snapshot) { s.LastLogin = last } }) } - wg.Wait() + + var snap Snapshot + for i := 0; i < launched; i++ { + select { + case apply := <-results: + if apply != nil { + apply(&snap) + } + case <-ctx.Done(): + recordError("gather", ctx.Err()) + return snap + } + } return snap } diff --git a/internal/config/config.go b/internal/config/config.go index 1170419..f061af6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -21,8 +21,11 @@ func Load() (Config, string, error) { cfg := Default() candidatePaths := defaultConfigPaths() + // An explicit config path (--config or SYSGREET_CONFIG) is exclusive: + // if that file is absent we fall back to built-in defaults, never to a + // different config file the user didn't ask for. if custom := os.Getenv("SYSGREET_CONFIG"); custom != "" { - candidatePaths = append([]string{custom}, candidatePaths...) + candidatePaths = []string{custom} } var usedPath string diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 0a3a3be..e2dde60 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -177,3 +177,30 @@ func TestLoad_MaxWidthRejectsNegative(t *testing.T) { t.Fatalf("negative max_width should be ignored, got %d", cfg.Layout.MaxWidth) } } + +func TestLoad_ExplicitConfigPathIsExclusive(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + cfgDir := dir + "/.config/sysgreet" + if err := os.MkdirAll(cfgDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + // A default-path config that must NOT be picked up when an explicit + // path is requested but missing. + decoy := []byte("ascii:\n font: \"slant\"\n") + if err := os.WriteFile(cfgDir+"/config.yaml", decoy, 0o644); err != nil { + t.Fatalf("write decoy config: %v", err) + } + t.Setenv("SYSGREET_CONFIG", dir+"/does-not-exist.yaml") + + cfg, used, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if used != "" { + t.Fatalf("no config should have been loaded, got %q", used) + } + if cfg.ASCII.Font != Default().ASCII.Font { + t.Fatalf("expected built-in defaults, got font %q from decoy config", cfg.ASCII.Font) + } +} diff --git a/internal/render/layout.go b/internal/render/layout.go index cc3fcdd..f5ffcdb 100644 --- a/internal/render/layout.go +++ b/internal/render/layout.go @@ -60,7 +60,7 @@ func (r Renderer) Render(out banner.Output, cfg config.Config) string { continue } builder.WriteString("\n") - builder.WriteString(section.Title) + builder.WriteString(r.clip(section.Title, 0)) builder.WriteString("\n") for _, line := range section.Lines { formatted := r.clip(line, len(bodyIndent)) diff --git a/internal/render/layout_test.go b/internal/render/layout_test.go index aa8cf2e..c7751a4 100644 --- a/internal/render/layout_test.go +++ b/internal/render/layout_test.go @@ -482,3 +482,18 @@ func TestApplyConfig(t *testing.T) { t.Errorf("empty config should leave env unchanged: %+v", untouched) } } + +func TestRenderer_ClipsSectionTitles(t *testing.T) { + out := banner.Output{ + Header: banner.Header{Hostname: "vm", Art: "VM"}, + Sections: []banner.Section{ + {Key: "resources", Title: "Resources", Lines: []string{"Mem: 4%"}}, + }, + } + result := NewRenderer(terminal.Env{Width: 8}).Render(out, config.Default()) + for _, line := range strings.Split(result, "\n") { + if n := len([]rune(line)); n > 8 { + t.Errorf("line exceeds width 8 (%d): %q", n, line) + } + } +} From 76f58143022c76550514a4b86a145ffc16be73be Mon Sep 17 00:00:00 2001 From: VeteranBV Date: Mon, 13 Jul 2026 23:13:01 +0000 Subject: [PATCH 07/12] fix: make --json output independent of terminal geometry The width ladder's hostname fallback line was leaking into the JSON header: at narrow widths buildHeader shortens the art and prepends the full hostname to Header.Lines, so header[0] flipped between the OS line and the hostname depending on terminal size. JSON mode now builds against a neutral environment, so scripted output is stable regardless of where it runs. --- cmd/sysgreet/main.go | 8 +++++++- test/integration/binary_test.go | 36 +++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/cmd/sysgreet/main.go b/cmd/sysgreet/main.go index 4ec122c..8a4fda2 100644 --- a/cmd/sysgreet/main.go +++ b/cmd/sysgreet/main.go @@ -84,7 +84,13 @@ func run() error { return runTextMode(renderer, settings.Text, cfg, env) } - output, err := buildBanner(ctx, renderer, cfg, env, settings.Demo) + buildEnv := env + if settings.JSON { + // Scripted output must not vary with terminal geometry or color + // support; build against a neutral environment. + buildEnv = terminal.Env{} + } + output, err := buildBanner(ctx, renderer, cfg, buildEnv, settings.Demo) if err != nil { return err } diff --git a/test/integration/binary_test.go b/test/integration/binary_test.go index 00b3773..e763445 100644 --- a/test/integration/binary_test.go +++ b/test/integration/binary_test.go @@ -361,3 +361,39 @@ func TestBinaryNoColorFlag(t *testing.T) { t.Errorf("--no-color output contains escape sequences") } } + +func TestBinaryJSONIsWidthIndependent(t *testing.T) { + if testing.Short() { + t.Skip("skipping binary build in short mode") + } + tmpDir := t.TempDir() + binaryPath := buildTestBinary(t, tmpDir) + + headerFor := func(args ...string) []string { + t.Helper() + cmd := exec.Command(binaryPath, append([]string{"--json"}, args...)...) + cmd.Env = append(os.Environ(), "SYSGREET_CONFIG="+filepath.Join(tmpDir, "config.yaml"), "CI=1") + output, err := cmd.Output() + if err != nil { + t.Fatalf("--json %v run failed: %v", args, err) + } + var doc struct { + Header []string `json:"header"` + } + if err := json.Unmarshal(output, &doc); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + return doc.Header + } + + wide := headerFor("--width", "200") + narrow := headerFor("--width", "20") + if len(wide) != len(narrow) { + t.Fatalf("JSON header depends on terminal width: %v vs %v", wide, narrow) + } + for i := range wide { + if wide[i] != narrow[i] { + t.Fatalf("JSON header depends on terminal width: %v vs %v", wide, narrow) + } + } +} From 2f860aacb73ba891c723b7cebb1d0b8c7a8c0f53 Mon Sep 17 00:00:00 2001 From: VeteranBV Date: Mon, 13 Jul 2026 23:30:22 +0000 Subject: [PATCH 08/12] fix: drop duplicate hostname line from compact output When the width ladder shortens a dotted hostname, buildHeader adds the full name as an info line for the art's benefit. Compact mode already leads with the full hostname, so that line rendered it twice (PVE1.HOME.LAN | pve1.home.lan | ...). Compact output now skips it. --- internal/render/layout.go | 9 ++++++++- internal/render/layout_test.go | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/internal/render/layout.go b/internal/render/layout.go index f5ffcdb..7b26984 100644 --- a/internal/render/layout.go +++ b/internal/render/layout.go @@ -79,7 +79,14 @@ func (r Renderer) Render(out banner.Output, cfg config.Config) string { // rather than the multi-line art. func (r Renderer) renderCompact(out banner.Output, cfg config.Config) string { parts := []string{strings.ToUpper(out.Header.Hostname)} - parts = append(parts, out.Header.Lines...) + for _, line := range out.Header.Lines { + // The full-hostname fallback line exists to supplement shortened + // art; compact output already leads with the full name. + if line == out.Header.Hostname { + continue + } + parts = append(parts, line) + } sections := orderSections(out.Sections, cfg.Layout.Sections) for _, section := range sections { if len(section.Lines) == 0 { diff --git a/internal/render/layout_test.go b/internal/render/layout_test.go index c7751a4..b0d44cb 100644 --- a/internal/render/layout_test.go +++ b/internal/render/layout_test.go @@ -497,3 +497,23 @@ func TestRenderer_ClipsSectionTitles(t *testing.T) { } } } + +func TestRenderer_CompactDoesNotDuplicateHostname(t *testing.T) { + out := banner.Output{ + Header: banner.Header{ + Hostname: "pve1.home.lan", + // The width ladder shortened the art, so the full hostname was + // added as an info line. + Lines: []string{"pve1.home.lan", "Linux 6.8 (x86_64)"}, + }, + } + cfg := config.Config{Layout: config.LayoutConfig{Compact: true}} + result := NewRenderer(terminal.Env{}).Render(out, cfg) + + if strings.Count(strings.ToLower(result), "pve1.home.lan") != 1 { + t.Fatalf("compact output repeats the hostname: %q", result) + } + if !strings.Contains(result, "Linux 6.8 (x86_64)") { + t.Fatalf("compact output lost the OS line: %q", result) + } +} From 8a4a910e01c2222c886a09a92f531b42bd7bf212 Mon Sep 17 00:00:00 2001 From: VeteranBV Date: Mon, 13 Jul 2026 23:31:17 +0000 Subject: [PATCH 09/12] fix: handle empty system snapshots and sub-indent widths Two edges in the width guarantee: a timed-out system collector left a blank OS header line (and an empty JSON header element), and widths of one or two columns overflowed because the fixed two-space body indent was prepended after clipping. Blank OS lines are now omitted and the indent is dropped when the width cap cannot fit it. --- internal/banner/banner.go | 6 +++++- internal/banner/banner_test.go | 17 +++++++++++++++++ internal/render/layout.go | 11 +++++++++-- internal/render/layout_test.go | 17 +++++++++++++++++ 4 files changed, 48 insertions(+), 3 deletions(-) diff --git a/internal/banner/banner.go b/internal/banner/banner.go index 2db7549..7285a16 100644 --- a/internal/banner/banner.go +++ b/internal/banner/banner.go @@ -103,7 +103,11 @@ func (b *Banner) buildHeader(snap collectors.Snapshot, cfg config.Config, env te if snap.System.Arch != "" { line += " (" + snap.System.Arch + ")" } - lines = append(lines, line) + // A timed-out or missing system collector leaves these fields + // empty; omit the line rather than render it blank. + if strings.TrimSpace(line) != "" { + lines = append(lines, line) + } } return Header{Hostname: name, Art: art.Text, Font: art.Font, Color: art.Color, Lines: lines} } diff --git a/internal/banner/banner_test.go b/internal/banner/banner_test.go index 1aae8be..dfdab40 100644 --- a/internal/banner/banner_test.go +++ b/internal/banner/banner_test.go @@ -382,3 +382,20 @@ func TestBanner_buildHeaderRespectsEnvWidth(t *testing.T) { } } } + +func TestBanner_buildHeaderOmitsBlankOSLine(t *testing.T) { + b, err := New(collectors.Providers{}, mustRenderer(t), nil) + if err != nil { + t.Fatalf("New() error = %v", err) + } + // A timed-out system collector leaves SystemInfo zero-valued. + snap := collectors.Snapshot{} + cfg := config.Default() + + header := b.buildHeader(snap, cfg, terminal.Env{}) + for _, line := range header.Lines { + if strings.TrimSpace(line) == "" { + t.Fatalf("header contains a blank line: %q", header.Lines) + } + } +} diff --git a/internal/render/layout.go b/internal/render/layout.go index 7b26984..7d2cd3e 100644 --- a/internal/render/layout.go +++ b/internal/render/layout.go @@ -54,6 +54,13 @@ func (r Renderer) Render(out banner.Output, cfg config.Config) string { } } + // The indent has to fit inside the width cap too; drop it on absurdly + // narrow terminals rather than overflow. + indent := bodyIndent + if r.width > 0 && r.width <= len(bodyIndent) { + indent = "" + } + sections := orderSections(out.Sections, cfg.Layout.Sections) for _, section := range sections { if len(section.Lines) == 0 { @@ -63,11 +70,11 @@ func (r Renderer) Render(out banner.Output, cfg config.Config) string { builder.WriteString(r.clip(section.Title, 0)) builder.WriteString("\n") for _, line := range section.Lines { - formatted := r.clip(line, len(bodyIndent)) + formatted := r.clip(line, len(indent)) if section.Key == "resources" { formatted = r.highlightResource(section, formatted) } - builder.WriteString(bodyIndent) + builder.WriteString(indent) builder.WriteString(formatted) builder.WriteString("\n") } diff --git a/internal/render/layout_test.go b/internal/render/layout_test.go index b0d44cb..bf884a4 100644 --- a/internal/render/layout_test.go +++ b/internal/render/layout_test.go @@ -517,3 +517,20 @@ func TestRenderer_CompactDoesNotDuplicateHostname(t *testing.T) { t.Fatalf("compact output lost the OS line: %q", result) } } + +func TestRenderer_TinyWidthsNeverOverflow(t *testing.T) { + out := banner.Output{ + Header: banner.Header{Hostname: "vm", Art: "…"}, + Sections: []banner.Section{ + {Key: "system", Title: "System", Lines: []string{"Uptime: 4d 12h"}}, + }, + } + for _, width := range []int{1, 2, 3, 4} { + result := NewRenderer(terminal.Env{Width: width}).Render(out, config.Default()) + for _, line := range strings.Split(result, "\n") { + if n := len([]rune(line)); n > width { + t.Errorf("width %d: line has %d columns: %q", width, n, line) + } + } + } +} From 7b623313e8467abc93b40e2babab9db10973e594 Mon Sep 17 00:00:00 2001 From: VeteranBV Date: Mon, 13 Jul 2026 23:48:05 +0000 Subject: [PATCH 10/12] fix: measure clipping in display columns and drop stale system lines Two more review findings. Clipping counted runes, so double-width characters (a CJK username or home path) could pass the check while overflowing the terminal on screen; the terminal package now owns RuneWidth/DisplayWidth/Clip covering the East Asian wide and emoji ranges, and the render and ascii layers measure with them. And when the system collector misses the gather deadline, the banner rendered Time: Mon, 01 Jan 0001 00:00:00 UTC and User: unknown from the zero-valued snapshot; those lines are now omitted when the data is absent, matching how every other missing metric degrades. --- internal/ascii/fonts.go | 21 +++---------- internal/banner/sections_system.go | 15 +++------ internal/banner/sections_test.go | 37 +++++++++------------- internal/render/layout.go | 14 +++------ internal/render/layout_test.go | 15 +++++++++ internal/terminal/terminal.go | 49 ++++++++++++++++++++++++++++++ internal/terminal/terminal_test.go | 43 ++++++++++++++++++++++++++ 7 files changed, 136 insertions(+), 58 deletions(-) diff --git a/internal/ascii/fonts.go b/internal/ascii/fonts.go index c3bb027..442b44a 100644 --- a/internal/ascii/fonts.go +++ b/internal/ascii/fonts.go @@ -164,7 +164,7 @@ func (r *Renderer) rows(text, font string) []string { func maxRowWidth(rows []string) int { widest := 0 for _, row := range rows { - if n := len([]rune(row)); n > widest { + if n := terminal.DisplayWidth(row); n > widest { widest = n } } @@ -190,11 +190,11 @@ func (r *Renderer) plainHeader(name, fullText string, opts RenderOptions) Art { } label := name - if len([]rune(label))+4 > width { - label = clipRunes(label, width) + if terminal.DisplayWidth(label)+4 > width { + label = terminal.Clip(label, width) } line := label - if fill := width - len([]rune(label)) - 2; fill >= 2 { + if fill := width - terminal.DisplayWidth(label) - 2; fill >= 2 { left := fill / 2 right := fill - left line = strings.Repeat("═", left) + " " + label + " " + strings.Repeat("═", right) @@ -209,7 +209,7 @@ func (r *Renderer) plainHeader(name, fullText string, opts RenderOptions) Art { Text: line, Font: plainFont, Color: color, - Width: len([]rune(terminal.Strip(line))), + Width: terminal.DisplayWidth(terminal.Strip(line)), Shortened: name != fullText, } } @@ -225,17 +225,6 @@ func (r *Renderer) headerColor(opts RenderOptions) string { return r.pickColor(opts.Color) } -func clipRunes(s string, width int) string { - runes := []rune(s) - if len(runes) <= width { - return s - } - if width <= 1 { - return "…" - } - return string(runes[:width-1]) + "…" -} - // colorize joins the art rows, applying gradient or single-color styling // according to the terminal profile. func (r *Renderer) colorize(rows []string, opts RenderOptions) (string, string) { diff --git a/internal/banner/sections_system.go b/internal/banner/sections_system.go index 85ba0e8..a5b1ad2 100644 --- a/internal/banner/sections_system.go +++ b/internal/banner/sections_system.go @@ -26,14 +26,16 @@ func (SystemSectionBuilder) Build(snap collectors.Snapshot, cfg config.Config) ( if cfg.Display.Uptime && snap.System.Uptime > 0 { lines = append(lines, fmt.Sprintf("Uptime: %s", humanDuration(snap.System.Uptime))) } - if cfg.Display.User { - userLine := fmt.Sprintf("User: %s", valueOrFallback(snap.System.CurrentUser, "unknown")) + if cfg.Display.User && strings.TrimSpace(snap.System.CurrentUser) != "" { + userLine := fmt.Sprintf("User: %s", snap.System.CurrentUser) if snap.System.HomeDir != "" { userLine += " " + snap.System.HomeDir } lines = append(lines, userLine) } - if cfg.Display.Datetime { + // A timed-out system collector leaves Datetime zero; omit the line + // rather than print the epoch. + if cfg.Display.Datetime && !snap.System.Datetime.IsZero() { lines = append(lines, fmt.Sprintf("Time: %s", snap.System.Datetime.Format(time.RFC1123))) } if cfg.Display.LastLogin && snap.LastLogin != nil { @@ -97,13 +99,6 @@ func humanDuration(d time.Duration) string { return strings.Join(segments, " ") } -func valueOrFallback(value, fallback string) string { - if strings.TrimSpace(value) == "" { - return fallback - } - return value -} - func formatAddress(addr collectors.Address, withInterface bool) string { if !withInterface || strings.TrimSpace(addr.Interface) == "" { return addr.IP diff --git a/internal/banner/sections_test.go b/internal/banner/sections_test.go index b36a2cf..0423739 100644 --- a/internal/banner/sections_test.go +++ b/internal/banner/sections_test.go @@ -1,6 +1,7 @@ package banner import ( + "strings" "testing" "time" @@ -345,28 +346,6 @@ func TestHumanDuration(t *testing.T) { } } -func TestValueOrFallback(t *testing.T) { - tests := []struct { - name string - value string - fallback string - want string - }{ - {"non-empty value", "testvalue", "fallback", "testvalue"}, - {"empty value", "", "fallback", "fallback"}, - {"whitespace only", " ", "fallback", "fallback"}, - {"whitespace with content", " test ", "fallback", " test "}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := valueOrFallback(tt.value, tt.fallback); got != tt.want { - t.Errorf("valueOrFallback(%q, %q) = %q, want %q", tt.value, tt.fallback, got, tt.want) - } - }) - } -} - func TestFormatAddress(t *testing.T) { tests := []struct { name string @@ -408,3 +387,17 @@ func TestFormatAddress(t *testing.T) { }) } } + +func TestSystemSectionOmitsUnavailableData(t *testing.T) { + // A timed-out system collector leaves the snapshot zero-valued; the + // section must not invent an epoch timestamp or unknown user. + section, ok := SystemSectionBuilder{}.Build(collectors.Snapshot{}, config.Default()) + if !ok { + return // nothing to render is acceptable + } + for _, line := range section.Lines { + if strings.Contains(line, "0001") || strings.Contains(line, "unknown") { + t.Fatalf("section renders placeholder data: %q", line) + } + } +} diff --git a/internal/render/layout.go b/internal/render/layout.go index 7d2cd3e..f6aeaa2 100644 --- a/internal/render/layout.go +++ b/internal/render/layout.go @@ -105,8 +105,9 @@ func (r Renderer) renderCompact(out banner.Output, cfg config.Config) string { return r.clip(strings.Join(parts, " | "), 0) } -// clip truncates a line to the terminal width, accounting for indent and -// marking the cut with an ellipsis. Zero width leaves the line untouched. +// clip truncates a line to the terminal width in display columns, +// accounting for indent and marking the cut with an ellipsis. Zero width +// leaves the line untouched. func (r Renderer) clip(line string, indent int) string { if r.width <= 0 { return line @@ -115,14 +116,7 @@ func (r Renderer) clip(line string, indent int) string { if limit < 1 { limit = 1 } - runes := []rune(line) - if len(runes) <= limit { - return line - } - if limit == 1 { - return "…" - } - return string(runes[:limit-1]) + "…" + return terminal.Clip(line, limit) } func orderSections(sections []banner.Section, desired []string) []banner.Section { diff --git a/internal/render/layout_test.go b/internal/render/layout_test.go index bf884a4..575785b 100644 --- a/internal/render/layout_test.go +++ b/internal/render/layout_test.go @@ -534,3 +534,18 @@ func TestRenderer_TinyWidthsNeverOverflow(t *testing.T) { } } } + +func TestRenderer_ClipsWideRunesByColumns(t *testing.T) { + out := banner.Output{ + Header: banner.Header{Hostname: "vm", Art: "VM"}, + Sections: []banner.Section{ + {Key: "system", Title: "System", Lines: []string{"User: 田中太郎 /home/田中太郎"}}, + }, + } + result := NewRenderer(terminal.Env{Width: 20}).Render(out, config.Default()) + for _, line := range strings.Split(result, "\n") { + if n := terminal.DisplayWidth(line); n > 20 { + t.Errorf("line occupies %d columns at width 20: %q", n, line) + } + } +} diff --git a/internal/terminal/terminal.go b/internal/terminal/terminal.go index 765e51d..0e6d83e 100644 --- a/internal/terminal/terminal.go +++ b/internal/terminal/terminal.go @@ -145,6 +145,55 @@ func Wrap(p Profile, color, text string) string { return code + text + Reset } +// RuneWidth returns the terminal column width of r: 2 for the common East +// Asian wide/fullwidth and emoji ranges, 1 otherwise. Zero-width combining +// marks are rare in banner content and treated as width 1. +func RuneWidth(r rune) int { + switch { + case r >= 0x1100 && r <= 0x115F, // Hangul Jamo + r >= 0x2E80 && r <= 0xA4CF, // CJK radicals through Yi + r >= 0xAC00 && r <= 0xD7A3, // Hangul syllables + r >= 0xF900 && r <= 0xFAFF, // CJK compatibility ideographs + r >= 0xFE30 && r <= 0xFE4F, // CJK compatibility forms + r >= 0xFF00 && r <= 0xFF60, // fullwidth forms + r >= 0xFFE0 && r <= 0xFFE6, + r >= 0x1F300 && r <= 0x1FAFF, // emoji + r >= 0x20000 && r <= 0x3FFFD: // CJK extensions + return 2 + } + return 1 +} + +// DisplayWidth returns the terminal column count of s, which must not +// contain escape sequences (Strip first if it might). +func DisplayWidth(s string) int { + width := 0 + for _, r := range s { + width += RuneWidth(r) + } + return width +} + +// Clip truncates s to at most width display columns, marking dropped +// content with an ellipsis. s must not contain escape sequences. +func Clip(s string, width int) string { + if width <= 0 || DisplayWidth(s) <= width { + return s + } + if width == 1 { + return "…" + } + cols := 0 + for i, r := range s { + rw := RuneWidth(r) + if cols+rw > width-1 { + return s[:i] + "…" + } + cols += rw + } + return s +} + // Strip removes ANSI CSI escape sequences from a string. func Strip(input string) string { var b strings.Builder diff --git a/internal/terminal/terminal_test.go b/internal/terminal/terminal_test.go index 8d2bb67..f5ae909 100644 --- a/internal/terminal/terminal_test.go +++ b/internal/terminal/terminal_test.go @@ -133,3 +133,46 @@ func TestCodeAndRGBCoverSameNames(t *testing.T) { } } } + +func TestDisplayWidth(t *testing.T) { + tests := []struct { + in string + want int + }{ + {"hello", 5}, + {"", 0}, + {"日本語", 6}, + {"héllo", 5}, + {"user-日本", 9}, + } + for _, tt := range tests { + if got := DisplayWidth(tt.in); got != tt.want { + t.Errorf("DisplayWidth(%q) = %d, want %d", tt.in, got, tt.want) + } + } +} + +func TestClip(t *testing.T) { + tests := []struct { + in string + width int + want string + }{ + {"hello", 10, "hello"}, + {"hello", 5, "hello"}, + {"hello", 4, "hel…"}, + {"hello", 1, "…"}, + {"hello", 0, "hello"}, // zero width = unconstrained + {"日本語です", 4, "日…"}, + {"日本語です", 5, "日本…"}, + } + for _, tt := range tests { + got := Clip(tt.in, tt.width) + if got != tt.want { + t.Errorf("Clip(%q, %d) = %q, want %q", tt.in, tt.width, got, tt.want) + } + if tt.width > 0 && DisplayWidth(got) > tt.width { + t.Errorf("Clip(%q, %d) = %q occupies %d columns", tt.in, tt.width, got, DisplayWidth(got)) + } + } +} From f830a532da4107fc9c2c50bb1d1cb79b00a3cb82 Mon Sep 17 00:00:00 2001 From: VeteranBV Date: Mon, 13 Jul 2026 23:49:36 +0000 Subject: [PATCH 11/12] fix: render non-ASCII text via the plain header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit go-figure's strict mode kills the process with log.Fatal on non-ASCII input, so --text "日本語" died before printing anything; non-strict mode instead drops uncovered characters, which silently mangles mixed text like web-サーバ. Rendering is now non-strict as a backstop, and any text outside printable ASCII skips the FIGlet ladder entirely in favor of the plain ruled header, which preserves it verbatim and clips by display columns. --- internal/ascii/ascii_test.go | 26 ++++++++++++++++++++++++++ internal/ascii/fonts.go | 28 +++++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/internal/ascii/ascii_test.go b/internal/ascii/ascii_test.go index 40ae742..93ca44f 100644 --- a/internal/ascii/ascii_test.go +++ b/internal/ascii/ascii_test.go @@ -283,3 +283,29 @@ func TestLerpStops(t *testing.T) { t.Errorf("t=1 should equal last stop, got %v want %v", end, white) } } + +func TestRendererNonASCIITextUsesPlainHeader(t *testing.T) { + r := mustRenderer(t) + // go-figure's strict mode log.Fatals on non-ASCII, and non-strict mode + // silently drops characters; both are wrong. Non-ASCII text must render + // via the plain header with its content intact. + for _, text := range []string{"日本語のホスト", "web-サーバ", "café-host"} { + art, err := r.Render(text, RenderOptions{ + Font: "ANSI Regular", + MaxWidth: 40, + Profile: terminal.ProfileNoColor, + }) + if err != nil { + t.Fatalf("Render(%q) error = %v", text, err) + } + if art.Font != plainFont { + t.Errorf("Render(%q): expected plain header, got font %q", text, art.Font) + } + if !strings.Contains(art.Text, text) { + t.Errorf("Render(%q): text not preserved verbatim: %q", text, art.Text) + } + if art.Width > 40 { + t.Errorf("Render(%q): overflows at %d columns", text, art.Width) + } + } +} diff --git a/internal/ascii/fonts.go b/internal/ascii/fonts.go index 442b44a..1fa2ec2 100644 --- a/internal/ascii/fonts.go +++ b/internal/ascii/fonts.go @@ -96,6 +96,13 @@ func (r *Renderer) Render(text string, opts RenderOptions) (Art, error) { text = strings.ToUpper(text) } + // FIGlet fonts cover printable ASCII; rendering anything else would + // silently drop characters, so such text goes straight to the plain + // header, which preserves it verbatim. + if !fontRenderable(text) { + return r.plainHeader(text, text, opts), nil + } + texts := []string{text} if opts.ShortenDomain { if short := shortName(text); short != text { @@ -107,6 +114,11 @@ func (r *Renderer) Render(text string, opts RenderOptions) (Art, error) { for _, candidate := range texts { rows := r.rows(candidate, font) width := maxRowWidth(rows) + if width == 0 { + // FIGlet fonts only cover ASCII; text that renders to + // nothing (e.g. CJK) falls through to the plain header. + continue + } if opts.MaxWidth > 0 && width > opts.MaxWidth { continue } @@ -156,8 +168,11 @@ func (r *Renderer) resolveFont(name string) string { return r.order[0] } +// rows renders text with a font in non-strict mode: characters the font +// does not cover are dropped rather than aborting the process (go-figure's +// strict mode calls log.Fatal on non-ASCII input). func (r *Renderer) rows(text, font string) []string { - fig := figure.NewFigureWithFont(text, bytes.NewReader(r.fonts[font]), true) + fig := figure.NewFigureWithFont(text, bytes.NewReader(r.fonts[font]), false) return fig.Slicify() } @@ -171,6 +186,17 @@ func maxRowWidth(rows []string) int { return widest } +// fontRenderable reports whether every rune falls in the printable ASCII +// range the embedded FIGlet fonts cover. +func fontRenderable(text string) bool { + for _, r := range text { + if r < ' ' || r > '~' { + return false + } + } + return true +} + // shortName drops everything after the first dot, so pve1.home.lan renders // as pve1. The full name stays available for an info line. func shortName(text string) string { From 22fa00de53d039eb465b55b1cd2447a3c71c5a9c Mon Sep 17 00:00:00 2001 From: VeteranBV Date: Tue, 14 Jul 2026 00:15:11 +0000 Subject: [PATCH 12/12] fix: widen plain headers to the terminal and bootstrap TOML as TOML The plain-header fallback capped its rule at 60 columns even when the terminal was wider, clipping a 70-column name that a 100-column pane could show in full; 60 is now just the preferred rule length and long names widen the rule up to the available width instead of losing characters. And bootstrapping --config /path/config.toml wrote YAML into the .toml file, which the loader then failed to parse; the default config is now marshaled in the format the target extension implies. --- internal/ascii/ascii_test.go | 22 ++++++++++++++++++++++ internal/ascii/fonts.go | 23 ++++++++++++++++------- internal/bootstrap/bootstrap.go | 4 ++-- internal/bootstrap/bootstrap_test.go | 24 ++++++++++++++++++++++++ internal/bootstrap/default_profile.go | 12 ++++++++++-- 5 files changed, 74 insertions(+), 11 deletions(-) diff --git a/internal/ascii/ascii_test.go b/internal/ascii/ascii_test.go index 93ca44f..d7836f8 100644 --- a/internal/ascii/ascii_test.go +++ b/internal/ascii/ascii_test.go @@ -309,3 +309,25 @@ func TestRendererNonASCIITextUsesPlainHeader(t *testing.T) { } } } + +func TestPlainHeaderUsesAvailableWidth(t *testing.T) { + r := mustRenderer(t) + // 70 columns of hostname in a 100-column terminal: the ruled header + // must widen to fit the name rather than clip it at the preferred + // 60-column rule length. + name := strings.Repeat("a", 70) + art, err := r.Render(name+".home.lan", RenderOptions{ + Font: "ANSI Regular", + MaxWidth: 100, + Profile: terminal.ProfileNoColor, + }) + if err != nil { + t.Fatalf("Render() error = %v", err) + } + if !strings.Contains(art.Text, name) { + t.Fatalf("plain header clipped a name that fits the terminal: %q", art.Text) + } + if art.Width > 100 { + t.Fatalf("plain header overflows: %d columns", art.Width) + } +} diff --git a/internal/ascii/fonts.go b/internal/ascii/fonts.go index 1fa2ec2..b244bef 100644 --- a/internal/ascii/fonts.go +++ b/internal/ascii/fonts.go @@ -209,18 +209,27 @@ func shortName(text string) string { // plainHeader is the bottom of the ladder: a one-line ruled header that fits // any terminal. Something like ═════ PVE1 ═════. func (r *Renderer) plainHeader(name, fullText string, opts RenderOptions) Art { + // Preferred rule length; long names widen it rather than get clipped, + // as long as the terminal allows. const maxRule = 60 - width := opts.MaxWidth - if width <= 0 || width > maxRule { - width = maxRule - } + avail := opts.MaxWidth label := name - if terminal.DisplayWidth(label)+4 > width { - label = terminal.Clip(label, width) + if avail > 0 { + label = terminal.Clip(label, avail) + } + labelWidth := terminal.DisplayWidth(label) + + target := maxRule + if labelWidth+4 > target { + target = labelWidth + 4 } + if avail > 0 && target > avail { + target = avail + } + line := label - if fill := width - terminal.DisplayWidth(label) - 2; fill >= 2 { + if fill := target - labelWidth - 2; fill >= 2 { left := fill / 2 right := fill - left line = strings.Repeat("═", left) + " " + label + " " + strings.Repeat("═", right) diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index 95172cf..ed03555 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -122,7 +122,7 @@ func overwriteConfig(ctx context.Context, cfgPath string, stderr io.Writer, resu if err := ctx.Err(); err != nil { return result, err } - data, err := renderDefaultConfig(now) + data, err := renderDefaultConfig(now, cfgPath) if err != nil { return result, fmt.Errorf("bootstrap: render default config: %w", err) } @@ -174,7 +174,7 @@ func createNewConfig(ctx context.Context, cfgPath string, stderr io.Writer, resu return result, nil } - data, err := renderDefaultConfig(now) + data, err := renderDefaultConfig(now, cfgPath) if err != nil { return result, fmt.Errorf("bootstrap: render default config: %w", err) } diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go index edc7981..7cfb680 100644 --- a/internal/bootstrap/bootstrap_test.go +++ b/internal/bootstrap/bootstrap_test.go @@ -9,6 +9,8 @@ import ( "time" "gopkg.in/yaml.v3" + + "github.com/veteranbv/sysgreet/internal/config" ) type configYAML struct { @@ -80,3 +82,25 @@ func TestBootstrapCreatesDefaultConfig(t *testing.T) { t.Fatalf("created_at not RFC3339: %v", err) } } + +func TestBootstrapWritesTOMLForTOMLPath(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.toml") + + _, err := Bootstrap(context.Background(), cfgPath, IO{}, Options{FlagPolicy: "overwrite", Interactive: false}) + if err != nil { + t.Fatalf("Bootstrap: %v", err) + } + + t.Setenv("SYSGREET_CONFIG", cfgPath) + cfg, used, err := config.Load() + if err != nil { + t.Fatalf("bootstrapped .toml config does not parse: %v", err) + } + if used != cfgPath { + t.Fatalf("expected %s to be loaded, got %q", cfgPath, used) + } + if cfg.ASCII.Font != config.Default().ASCII.Font { + t.Fatalf("bootstrapped config lost defaults: %+v", cfg.ASCII) + } +} diff --git a/internal/bootstrap/default_profile.go b/internal/bootstrap/default_profile.go index c812b8a..4274c4e 100644 --- a/internal/bootstrap/default_profile.go +++ b/internal/bootstrap/default_profile.go @@ -1,17 +1,25 @@ package bootstrap import ( + "path/filepath" + "strings" "time" + "github.com/pelletier/go-toml/v2" "gopkg.in/yaml.v3" "github.com/veteranbv/sysgreet/internal/config" ) -// renderDefaultConfig marshals the default sysgreet configuration into YAML with metadata. -func renderDefaultConfig(now time.Time) ([]byte, error) { +// renderDefaultConfig marshals the default sysgreet configuration with +// metadata, in the format implied by the target path's extension so that a +// bootstrapped .toml config parses as TOML. +func renderDefaultConfig(now time.Time, path string) ([]byte, error) { cfg := config.Default() cfg.Version = config.SchemaVersion cfg.CreatedAt = now.UTC().Format(time.RFC3339) + if strings.EqualFold(filepath.Ext(path), ".toml") { + return toml.Marshal(cfg) + } return yaml.Marshal(cfg) }