From 2db75d7fcfbaa5669df6af9f27a1c5b76bab632c Mon Sep 17 00:00:00 2001 From: Dibbayajyoti Date: Tue, 27 Oct 2026 12:25:02 +0530 Subject: [PATCH 1/2] cli: terminal UI polish (capability detection, grouped help, error blocks, progress bar) Everything here is for a person at a terminal. Piped, redirected, in CI, under NO_COLOR/CLICOLOR=0/TERM=dumb, and with --json or -q, stdout and stderr are byte-for-byte what they were: the golden tests pass unchanged, and 51 piped --help screens are identical to main's. internal/ui - Caps/Detect: 16 / 256 / 24-bit colour, UTF-8 locale, OSC 8 hyperlinks and a light background, read from the environment. Force keeps the old 16-colour UTF-8 profile, so existing tests and escape codes are unchanged. - Semantic roles (Accent, Success, Warn, Danger, Muted, Strong) instead of colour names. The signals stay in the terminal's own palette so a theme tunes them; only the accent (teal) and the amber leave it, to colours with contrast on black and on white. - Glyph sets with an ASCII fallback (+ x ! > ... and a | / - \ spinner). - Width, Truncate, Wrap and StripANSI measure terminal cells (wide characters, combining marks, escapes). Wrap keeps a long URL whole and keeps indentation. - Error block: the ERR:: code is muted so the sentence leads, the hint is the accent, keys align, long text wraps under its own column, URLs are linked. - The spinner measures the terminal every frame, so it follows a resize. New Spinner.Progress(done, total) draws a determinate bar with a count and an ETA, dropping the ETA, then the bar, then the label's tail as the line narrows. - Elapsed formats hours (1h05m). internal/cmd - help.go: spicrawl --help and --help on a live terminal get grouped commands, coloured headings and names, muted defaults and API routes, wrapped descriptions and the docs link. Installed with SetHelpFunc from init(), so no command file changes. Anything not live gets cobra's help. - view.go: rows wrap under their value column and link URLs on a terminal, marks use the glyph set (warn is amber), tables measure cells. --- CHANGELOG.md | 1 + DESIGN.md | 10 +- README.md | 4 +- internal/cmd/help.go | 311 +++++++++++++++++++++++++++++++++++ internal/cmd/help_test.go | 273 ++++++++++++++++++++++++++++++ internal/cmd/view.go | 57 ++++--- internal/cmd/view_ui_test.go | 88 ++++++++++ internal/ui/caps.go | 121 ++++++++++++++ internal/ui/caps_test.go | 111 +++++++++++++ internal/ui/progress.go | 108 ++++++++++++ internal/ui/progress_test.go | 181 ++++++++++++++++++++ internal/ui/style.go | 146 ++++++++++++++++ internal/ui/style_test.go | 104 ++++++++++++ internal/ui/text.go | 163 ++++++++++++++++++ internal/ui/text_test.go | 86 ++++++++++ internal/ui/ui.go | 203 ++++++++++++++++------- internal/ui/ui_test.go | 22 ++- 17 files changed, 1902 insertions(+), 87 deletions(-) create mode 100644 internal/cmd/help.go create mode 100644 internal/cmd/help_test.go create mode 100644 internal/cmd/view_ui_test.go create mode 100644 internal/ui/caps.go create mode 100644 internal/ui/caps_test.go create mode 100644 internal/ui/progress.go create mode 100644 internal/ui/progress_test.go create mode 100644 internal/ui/style.go create mode 100644 internal/ui/style_test.go create mode 100644 internal/ui/text.go create mode 100644 internal/ui/text_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index e843539..a0bebab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Bug fixes from a review of 0.1.3, and live progress on a terminal. No new commands; the behaviour changes are marked. - **Live progress on a terminal.** A spinner with a label and elapsed time covers every wait over about 300 ms (`scrape`, `scrape -`, `batch submit`/`wait`, and any other API call), and `scrape`, `scrape -` and `batch wait` end with a `✓`/`✗` summary line (page, status, credits, duration) on stderr. API errors keep their `ERR::` code and hint in a short block. Everything is on stderr, only when stderr is a terminal, and off with `--json`, `-q`, `NO_COLOR`, `TERM=dumb` or `CI`: stdout and the plain output are unchanged. New dependency: `golang.org/x/term`, for the terminal width. +- **A polished terminal UI.** On a terminal only; piped, redirected, in CI, under `NO_COLOR`/`CLICOLOR=0`/`TERM=dumb`, and with `--json` or `-q`, every byte is what it was. `spicrawl --help` groups the commands by purpose (extract data, set up, monitor, reference), colours headings and the names you type, mutes defaults and API routes, wraps long descriptions under their own column and ends with the docs link; `spicrawl x --help` gets the same layout. Colour adapts to the terminal: the 16-colour palette (which follows your theme) by default, a 256-colour or 24-bit accent where `TERM`/`COLORTERM` say it is there, and a deeper accent on a light background (`COLORFGBG`). Outside a UTF-8 locale the glyphs fall back to ASCII (`+ x ! >` and a `| / - \` spinner). The error block mutes the `ERR::` code so the sentence leads, accents the hint, aligns its keys, wraps long text under its own column and makes URLs clickable (OSC 8) in iTerm2, WezTerm, kitty, Windows Terminal, VS Code, GNOME Terminal and other terminals that have them (`FORCE_HYPERLINK=0/1` overrides). The spinner measures the terminal on every frame, so it follows a resize and never wraps. New in `internal/ui`, not yet used by a command: a determinate progress bar with a count and an ETA (`Spinner.Progress(done, total)`). - **Retries are safe.** A connection that drops after the request was sent now exits 12 ("may have run and been billed"), not 10 ("nothing was sent, safe to retry"). A gateway 5xx without an API problem document is retried only for GET/HEAD, and is no longer mistaken for the target's status under `--original-status`. A 301/302/303 on a POST is an error naming the Location instead of a silent GET. `Retry-After` waits are capped at 60 s and announced on stderr. - **Ctrl-C exits 130** with "interrupted", including while `scrape -` waits on stdin. A mistyped subcommand (`spicrawl batch wiat`) is a usage error with a suggestion, not help and exit 0. - **JSON mode keeps stderr clean** (**behaviour change**). With `--json`, or whenever stdout is not a terminal, stderr is now the problem document of a failure and nothing else; it is empty on success. Warnings and status lines ("wrote N bytes", "more results: --cursor ...", the `--engine` deprecation notice, the `ws://` warning) used to print there as prose, sometimes before the problem document, so `cmd --json 2>err.json; jq . err.json` failed. They still print in human mode. The API's `warnings` field is still in the JSON on stdout. diff --git a/DESIGN.md b/DESIGN.md index 74b7fe4..5a1d77d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -28,6 +28,14 @@ never invent a field the API does not accept (the API rejects unknown fields). stdout and its one-line message ("no sessions") on stderr, so `spicrawl sessions list | wc -l` is 0 and `-q` silences the message. JSON mode prints the empty collection on stdout. + The same gate (plus `CLICOLOR=0`) covers the grouped, coloured `--help`, + which is written to stdout and so tests stdout; piped, the help is cobra's own. + Inside the gate the UI adapts to what the terminal can do (`ui.Caps`): colour + is a semantic role (accent, success, warn, danger, muted) resolved to the + 16-colour palette, the 256-colour cube or 24-bit colour; glyphs fall back to + ASCII outside a UTF-8 locale; hyperlinks are OSC 8 only on terminals known to + have them. Commands never write escape codes themselves, they go through + `ui.UI` (or `internal/cmd/view.go`), so a new surface gets all of that. 2. **JSON when not a terminal.** `--json` forces it. Human mode prints tables or the document itself. 3. **Never prompts without a TTY.** Anything interactive has a flag form. @@ -116,7 +124,7 @@ internal/api HTTP client, Problem, DoWithRetry internal/config key/base resolution, config file internal/exitcode exit codes internal/output Printer (JSON/human/JSONL, stderr info and warnings in human mode only) -internal/ui stderr spinner, summary line, error block, colour (off unless a terminal) +internal/ui terminal output: spinner and progress bar, summary line, error block, colour roles, glyphs, width-aware text, capability detection (off unless a terminal) internal/cmd one file per command group; each registers in init() ``` diff --git a/README.md b/README.md index ee627db..c00e757 100644 --- a/README.md +++ b/README.md @@ -205,7 +205,9 @@ The key is passed as an argument, so other users on the machine can see it in th When stderr is a terminal, a wait longer than about 300 ms shows a spinner with what the CLI is doing and how long it has taken (`⠹ Scraping example.com/pricing (rendering JavaScript) · 4.2s`, `⠸ Batch job 3/10 done · 12.1s`). It is cleared before the result prints, and `scrape`, `scrape -` and `batch wait` end with a `✓` or `✗` line on stderr: page, target status, credits charged, duration. API errors keep their `ERR::` code and hint in a short block. -All of it is on stderr and stdout is byte-for-byte unchanged. It switches itself off, and the CLI prints exactly what it printed before, when stderr is not a terminal (a pipe, a file, an agent or CI log), with `--json` or `-q/--quiet`, with `NO_COLOR` set, with `TERM=dumb`, or when `CI` is set. +On a terminal `spicrawl --help` lists the commands grouped by purpose, with examples and the docs link; piped, the help is the plain text it always was. Colour follows your terminal: your theme's own palette by default, richer where the terminal advertises it, ASCII symbols outside a UTF-8 locale. + +All of it is on stderr and stdout is byte-for-byte unchanged. It switches itself off, and the CLI prints exactly what it printed before, when stderr is not a terminal (a pipe, a file, an agent or CI log), with `--json` or `-q/--quiet`, with `NO_COLOR` set (or `CLICOLOR=0`), with `TERM=dumb`, or when `CI` is set. ### How to convert a web page to Markdown from the command line diff --git a/internal/cmd/help.go b/internal/cmd/help.go new file mode 100644 index 0000000..5cf8393 --- /dev/null +++ b/internal/cmd/help.go @@ -0,0 +1,311 @@ +package cmd + +// The --help a person sees on a terminal. Cobra's own help is plain and +// alphabetical; here, when stdout is a live terminal (ui.Live: a TTY, and no +// NO_COLOR, TERM=dumb or CI), the root's commands are grouped by what they are +// for, headings and the names you type are coloured, and long descriptions +// wrap under their own column. Everywhere else, piped, redirected, in CI, for +// an agent, the help is cobra's, byte for byte. +// +// It is installed with SetHelpFunc from this init, so no command file changes: +// every command's help (spicrawl x --help, spicrawl help x, a bare group) goes +// through the root's help func. + +import ( + "fmt" + "io" + "regexp" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + + "github.com/OfficialSpicrawl/cli/internal/ui" +) + +const helpDocsURL = "https://docs.spicrawl.com" + +// helpMaxWidth caps the line length: a 250-column terminal does not get 250 +// columns of prose. +const helpMaxWidth = 100 + +func init() { + plain := rootCmd.HelpFunc() // cobra's own, kept for everything that is not a live terminal + rootCmd.SetHelpFunc(func(c *cobra.Command, args []string) { + if u := helpUI(c.OutOrStdout()); u.On() { + io.WriteString(c.OutOrStdout(), helpRender(u, c)) + return + } + plain(c, args) + }) +} + +// helpUI is the live terminal for a help written to w: on only when w is a +// terminal and nothing asked for plain output: NO_COLOR, TERM=dumb, CI (see +// ui.Live), and --json or --quiet, which promise no escape codes at all. Tests +// force it. +func helpUI(w io.Writer) *ui.UI { + switch { + case flagJSON || flagQuiet: + return nil + case viewForce: + return ui.Force(w) + } + return ui.New(w) +} + +// helpGroups orders the root's commands. A command in none of them lands in a +// last "More commands" group, so a new command shows up without touching this. +var helpGroups = []struct { + title string + names []string +}{ + {"Extract data", []string{"scrape", "batch", "sessions", "browser"}}, + {"Set up", []string{"login", "logout", "auth", "init", "mcp", "skill", "config"}}, + {"Monitor", []string{"status", "logs", "usage"}}, + {"Reference", []string{"docs", "schema", "exit-codes", "version", "completion", "help"}}, +} + +// helpRootExamples are the first things to try. Every line parses (see +// TestHelpRootExamplesAreRealCommands), so they cannot rot. +const helpRootExamples = ` # Save your API key once + spicrawl login + + # A page as clean markdown + spicrawl scrape https://example.com --format markdown + + # A JavaScript-rendered page, waiting for the data to appear + spicrawl scrape https://example.com/pricing --render --wait-for '.plans' --format markdown + + # Many URLs as one job, followed until it finishes + spicrawl batch submit urls.txt --render --wait + + # Is everything reachable, and does my key work? + spicrawl status` + +// apiRoute matches the "(GET /v1/requests)" some Short texts end with: useful +// to a script author, noise to a reader, so the screen mutes it. +var apiRoute = regexp.MustCompile(`\s*\(((?:GET|POST|PUT|PATCH|DELETE) /v1/[^)]*)\)\s*$`) + +func helpRender(u *ui.UI, c *cobra.Command) string { + width := helpMaxWidth + if cols := u.Cols(); cols > 0 { + width = min(cols-1, helpMaxWidth) // one short of the row: see ui.Fail + } + var b strings.Builder + section := func(title string) { b.WriteString("\n" + u.Bold(strings.ToUpper(title)) + "\n") } + + if text := strings.TrimRight(firstNonEmpty(c.Long, c.Short), " \n"); text != "" { + b.WriteString(text + "\n") + } + + section("Usage") + switch { + case c.HasAvailableSubCommands(): + fmt.Fprintf(&b, " %s %s\n", c.CommandPath(), u.Dim("[command]")) + case c.Runnable(): + b.WriteString(" " + c.UseLine() + "\n") + } + if len(c.Aliases) > 0 { + fmt.Fprintf(&b, " %s %s\n", u.Dim("aliases:"), strings.Join(c.Aliases, ", ")) + } + + var subs []*cobra.Command + for _, s := range c.Commands() { + if s.IsAvailableCommand() || s.Name() == "help" { + subs = append(subs, s) + } + } + pad := 0 // one name column for every group, so they line up + for _, s := range subs { + pad = max(pad, ui.Width(s.Name())) + } + if c.Parent() == nil { + placed := map[string]bool{} + for _, g := range helpGroups { + var in []*cobra.Command + for _, n := range g.names { + for _, s := range subs { + if s.Name() == n { + in = append(in, s) + placed[n] = true + } + } + } + helpCommands(&b, u, section, g.title, in, pad, width) + } + var rest []*cobra.Command + for _, s := range subs { + if !placed[s.Name()] { + rest = append(rest, s) + } + } + helpCommands(&b, u, section, "More commands", rest, pad, width) + } else { + helpCommands(&b, u, section, "Commands", subs, pad, width) + } + + ex := c.Example + if c.Parent() == nil { + ex = helpRootExamples + } + if ex != "" { + section("Examples") + for _, l := range strings.Split(strings.TrimRight(ex, "\n"), "\n") { + if strings.HasPrefix(strings.TrimSpace(l), "#") { + l = u.Dim(l) + } + b.WriteString(l + "\n") + } + } + + if s := helpFlags(u, c.LocalFlags(), width); s != "" { + section("Flags") + b.WriteString(s) + } + if s := helpFlags(u, c.InheritedFlags(), width); s != "" { + section("Global flags") + b.WriteString(s) + } + + if c.Parent() == nil { + section("Learn more") + b.WriteString(" " + u.Dim("Docs") + " " + u.Link(helpDocsURL, helpDocsURL) + "\n") + b.WriteString(" " + u.Dim("Exit codes") + " spicrawl exit-codes\n") + } + if c.HasAvailableSubCommands() { + fmt.Fprintf(&b, "\nUse %s for more information about a command.\n", + u.Accent(fmt.Sprintf("%q", c.CommandPath()+" [command] --help"))) + } + return b.String() +} + +// helpCommands writes one titled list of commands: names in the accent colour, +// descriptions wrapped under their own column. +func helpCommands(b *strings.Builder, u *ui.UI, section func(string), title string, cmds []*cobra.Command, pad, width int) { + if len(cmds) == 0 { + return + } + section(title) + for _, s := range cmds { + short, route := s.Short, "" + if m := apiRoute.FindStringSubmatchIndex(short); m != nil { + short, route = short[:m[0]], short[m[2]:m[3]] + } + room := width - 2 - pad - 2 + lines := ui.Wrap(short, room) + if route != "" { + r := "(" + route + ")" + if last := len(lines) - 1; ui.Width(lines[last])+1+ui.Width(r) <= room { + lines[last] += " " + u.Dim(r) + } else { + lines = append(lines, u.Dim(r)) + } + } + for i, l := range lines { + name := strings.Repeat(" ", pad) + if i == 0 { + name = u.Accent(s.Name()) + strings.Repeat(" ", pad-ui.Width(s.Name())) + } + b.WriteString(" " + name + " " + l + "\n") + } + } +} + +// helpFlags lays out a flag set as a table: "-q, --quiet", a muted value type, +// then the description wrapped under its own column with a muted default. +func helpFlags(u *ui.UI, fs *pflag.FlagSet, width int) string { + type row struct{ plain, left, desc string } + var rows []row + left := 0 + fs.VisitAll(func(f *pflag.Flag) { + if f.Hidden { + return + } + name, usage := pflag.UnquoteUsage(f) + plain, styled := " --"+f.Name, " "+u.Accent("--"+f.Name) + if f.Shorthand != "" { + plain, styled = "-"+f.Shorthand+", --"+f.Name, u.Accent("-"+f.Shorthand)+", "+u.Accent("--"+f.Name) + } + if name != "" { + plain += " " + name + styled += " " + u.Dim(name) + } + if !zeroDefault(f) { + d := f.DefValue + if f.Value.Type() == "string" { + d = fmt.Sprintf("%q", d) + } + // Joined with a private-use rune, which Wrap does not break at and + // measures as one cell; swapped back for a space below. + usage += " " + strings.ReplaceAll("(default "+d+")", " ", "\ue000") + } + rows = append(rows, row{plain, styled, usage}) + left = max(left, ui.Width(plain)) + }) + if len(rows) == 0 { + return "" + } + // A very long flag name must not squeeze every description into a sliver. + left = min(left, 34) + room := width - 2 - left - 2 + var b strings.Builder + for _, r := range rows { + if room < 24 || ui.Width(r.plain) > left { // narrow terminal or long name: description below + b.WriteString(" " + r.left + "\n") + for _, l := range ui.Wrap(r.desc, max(width-8, 20)) { + b.WriteString(" " + strings.ReplaceAll(l, "\ue000", " ") + "\n") + } + continue + } + for i, l := range ui.Wrap(r.desc, room) { + l = strings.ReplaceAll(l, "\ue000", " ") + lead := strings.Repeat(" ", left) + if i == 0 { + lead = r.left + strings.Repeat(" ", left-ui.Width(r.plain)) + } + b.WriteString(" " + lead + " " + helpMuteDefault(u, l) + "\n") + } + } + return b.String() +} + +// helpMuteDefault mutes a trailing "(default x)", which is never split across +// lines (see helpFlags). +func helpMuteDefault(u *ui.UI, l string) string { + if i := strings.LastIndex(l, "(default "); i >= 0 && strings.HasSuffix(l, ")") { + return l[:i] + u.Dim(l[i:]) + } + return l +} + +// zeroDefault mirrors pflag: a flag whose default is its type's zero value +// shows no "(default ...)". +func zeroDefault(f *pflag.Flag) bool { + switch f.Value.Type() { + case "bool": + return f.DefValue == "false" + case "duration": + return f.DefValue == "0" || f.DefValue == "0s" + case "int", "int8", "int32", "int64", "uint", "uint8", "uint16", "uint32", "uint64", "count", "float32", "float64": + return f.DefValue == "0" + case "string": + return f.DefValue == "" + case "stringSlice", "stringArray", "intSlice": + return f.DefValue == "[]" + } + switch f.Value.String() { + case "false", "", "", "0": + return true + } + return false +} + +func firstNonEmpty(ss ...string) string { + for _, s := range ss { + if s != "" { + return s + } + } + return "" +} diff --git a/internal/cmd/help_test.go b/internal/cmd/help_test.go new file mode 100644 index 0000000..f7939b5 --- /dev/null +++ b/internal/cmd/help_test.go @@ -0,0 +1,273 @@ +package cmd + +import ( + "bytes" + "regexp" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/OfficialSpicrawl/cli/internal/ui" +) + +// helpOut runs `spicrawl args... --help` into a buffer, as cobra would write +// it to stdout. +func helpOut(t *testing.T, args ...string) string { + t.Helper() + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs(append(args, "--help")) + defer func() { + rootCmd.SetOut(nil) + rootCmd.SetErr(nil) + resetFlags(rootCmd) + }() + if code := Execute(); code != 0 { + t.Fatalf("--help exited %d: %s", code, buf.String()) + } + return buf.String() +} + +func TestHelpIsCobrasOwnUnlessLive(t *testing.T) { + for _, args := range [][]string{nil, {"scrape"}, {"batch"}, {"batch", "submit"}} { + out := helpOut(t, args...) + noEscapes(t, "piped help", out) + for _, want := range []string{"Usage:", "Flags:"} { + if !strings.Contains(out, want) { + t.Errorf("%v: piped help lost cobra's %q:\n%s", args, want, out) + } + } + if strings.Contains(out, "USAGE") || strings.Contains(out, "EXTRACT DATA") { + t.Errorf("%v: the terminal layout leaked into piped help:\n%s", args, out) + } + } + root := helpOut(t) + if !strings.Contains(root, "Available Commands:\n auth ") || !strings.Contains(root, "\nUse \"spicrawl [command] --help\" for more information about a command.\n") { + t.Errorf("piped root help is not cobra's:\n%s", root) + } + + viewForce = true + defer func() { viewForce = false }() + live := helpOut(t) + if !strings.Contains(live, "\x1b[") || !strings.Contains(stripANSI(live), "EXTRACT DATA") { + t.Errorf("forced live help is not the terminal layout:\n%s", live) + } + // --json and --quiet promise no escape codes, even on a terminal. + for _, flag := range []string{"--json", "--quiet"} { + out := helpOut(t, flag) + noEscapes(t, flag+" help", out) + if !strings.Contains(out, "Available Commands:") { + t.Errorf("%s: help is not cobra's:\n%s", flag, out) + } + } +} + +func TestHelpLiveRootListsEveryCommandOnceInItsGroup(t *testing.T) { + rootCmd.InitDefaultHelpCmd() + rootCmd.InitDefaultCompletionCmd() + out := stripANSI(helpRender(ui.Force(&bytes.Buffer{}), rootCmd)) + noEscapes(t, "stripped help", out) + + for _, sub := range rootCmd.Commands() { + if !sub.IsAvailableCommand() && sub.Name() != "help" { + continue + } + n := len(regexp.MustCompile(`(?m)^ `+regexp.QuoteMeta(sub.Name())+` {2,}\S`).FindAllString(out, -1)) + if n != 1 { + t.Errorf("command %q is listed %d times in the live help:\n%s", sub.Name(), n, out) + } + } + last := -1 + for _, h := range []string{"USAGE", "EXTRACT DATA", "SET UP", "MONITOR", "REFERENCE", "EXAMPLES", "FLAGS", "LEARN MORE"} { + i := strings.Index(out, "\n"+h+"\n") + if i < 0 || i < last { + t.Fatalf("section %q missing or out of order (at %d, previous at %d):\n%s", h, i, last, out) + } + last = i + } + if !strings.Contains(out, "https://docs.spicrawl.com") { + t.Error("the live help does not point to the docs") + } + if m := internalNames.FindString(out); m != "" { + t.Errorf("the live help names an internal engine (%q):\n%s", m, out) + } + // One name column for every group. + cols := map[int]bool{} + for _, l := range strings.Split(out, "\n") { + if m := regexp.MustCompile(`^ (scrape|login|status|docs|completion)( +)`).FindStringSubmatch(l); m != nil { + cols[len(m[0])] = true + } + } + if len(cols) != 1 { + t.Errorf("groups do not share a name column: %v", cols) + } +} + +func TestHelpGroupsNameRealCommands(t *testing.T) { + rootCmd.InitDefaultHelpCmd() + rootCmd.InitDefaultCompletionCmd() + have := map[string]bool{} + for _, c := range rootCmd.Commands() { + have[c.Name()] = true + } + seen := map[string]bool{} + for _, g := range helpGroups { + for _, n := range g.names { + if !have[n] { + t.Errorf("help group %q lists %q, which is not a command: update helpGroups in help.go", g.title, n) + } + if seen[n] { + t.Errorf("%q is in two help groups", n) + } + seen[n] = true + } + } +} + +// The examples on the root help are typed by people; every one must parse. +func TestHelpRootExamplesAreRealCommands(t *testing.T) { + n := 0 + for _, line := range strings.Split(helpRootExamples, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + args := splitShell(line) + if args[0] != "spicrawl" { + t.Errorf("example does not start with the program name: %q", line) + continue + } + c, rest, err := rootCmd.Find(args[1:]) + if err != nil || c == rootCmd { + t.Errorf("example %q names no command: %v", line, err) + continue + } + if err := c.ParseFlags(rest); err != nil { + t.Errorf("example %q does not parse: %v", line, err) + } + resetFlags(rootCmd) + n++ + } + if n < 4 { + t.Errorf("only %d examples checked", n) + } +} + +// splitShell splits on spaces, keeping single-quoted text together. +func splitShell(s string) []string { + var out []string + var cur strings.Builder + quoted := false + for _, r := range s { + switch { + case r == '\'': + quoted = !quoted + case r == ' ' && !quoted: + if cur.Len() > 0 { + out = append(out, cur.String()) + cur.Reset() + } + default: + cur.WriteRune(r) + } + } + if cur.Len() > 0 { + out = append(out, cur.String()) + } + return out +} + +func TestHelpFlagsWrapUnderTheirColumnAndMuteDefaults(t *testing.T) { + c := &cobra.Command{Use: "x"} + c.Flags().BoolP("quiet", "q", false, "suppress progress messages on stderr") + c.Flags().String("base-url", "https://api.spicrawl.com", "API base URL, from the environment or the config file or this flag") + c.Flags().Int("concurrency", 4, "with '-': parallel requests") + c.Flags().Duration("timeout", 0, "HTTP timeout per API call") + c.Flags().String("hidden", "", "never shown") + _ = c.Flags().MarkHidden("hidden") + + u := ui.Force(&bytes.Buffer{}) + out := helpFlags(u, c.Flags(), 100) + plain := stripANSI(out) + if strings.Contains(plain, "hidden") { + t.Errorf("a hidden flag is listed:\n%s", plain) + } + for _, want := range []*regexp.Regexp{ + regexp.MustCompile(`(?m)^ -q, --quiet {13}suppress progress messages on stderr$`), + regexp.MustCompile(`\(default "https://api.spicrawl.com"\)`), + regexp.MustCompile(`(?m)with '-': parallel requests \(default 4\)$`), + } { + if !want.MatchString(plain) { + t.Errorf("flags lack %v:\n%s", want, plain) + } + } + if strings.Contains(plain, "(default 0") || strings.Contains(plain, "(default false") || strings.Contains(plain, "\ue000") { + t.Errorf("a zero default is shown, or a placeholder leaked:\n%s", plain) + } + if !strings.Contains(out, "\x1b[2m(default 4)\x1b[0m") { + t.Errorf("the default is not muted: %q", out) + } + + // Tight: lines wrap under the description column, and a "(default ...)" is + // never split. + tight := stripANSI(helpFlags(u, c.Flags(), 60)) + for _, l := range strings.Split(strings.TrimRight(tight, "\n"), "\n") { + if ui.Width(l) > 60 && !strings.Contains(l, "https://") { + t.Errorf("line %q is %d cells in a 60-cell help", l, ui.Width(l)) + } + } + for _, want := range []string{"(default 4)", `(default "https://api.spicrawl.com")`} { + if !strings.Contains(tight, want) { + t.Errorf("a default was split across lines (%q):\n%s", want, tight) + } + } + + // Narrow: the description drops below the flag instead of into a sliver. + narrow := stripANSI(helpFlags(u, c.Flags(), 30)) + if !strings.Contains(narrow, " -q, --quiet\n suppress progress") { + t.Errorf("narrow layout:\n%s", narrow) + } +} + +func TestHelpMutesTheAPIRouteAndKeepsIt(t *testing.T) { + sub := &cobra.Command{Use: "logs", Short: "Recent requests in the key's project (GET /v1/requests)", Run: func(*cobra.Command, []string) {}} + var b strings.Builder + u := ui.Force(&bytes.Buffer{}) + helpCommands(&b, u, func(string) {}, "x", []*cobra.Command{sub}, 4, 100) + if !strings.Contains(b.String(), "\x1b[2m(GET /v1/requests)\x1b[0m") { + t.Errorf("route not muted: %q", b.String()) + } + if got := stripANSI(b.String()); got != " logs Recent requests in the key's project (GET /v1/requests)\n" { + t.Errorf("route lost or moved: %q", got) + } +} + +// The live layout reuses every command's own text, so the engine-name rule that +// locks the plain help (engine_names_test.go) must hold for it too, on every +// command. +func TestHelpLiveNeverNamesAnEngine(t *testing.T) { + rootCmd.InitDefaultHelpCmd() + rootCmd.InitDefaultCompletionCmd() + u := ui.Force(&bytes.Buffer{}) + n := 0 + var walk func(c *cobra.Command) + walk = func(c *cobra.Command) { + out := stripANSI(helpRender(u, c)) + n++ + if m := internalNames.FindString(out); m != "" { + t.Errorf("%s: the live help names an internal engine (%q)", c.CommandPath(), m) + } + if strings.Contains(out, "\x1b") { + t.Errorf("%s: escape codes left after stripping", c.CommandPath()) + } + for _, sub := range c.Commands() { + walk(sub) + } + } + walk(rootCmd) + if n < 30 { + t.Errorf("only %d help screens walked", n) + } +} diff --git a/internal/cmd/view.go b/internal/cmd/view.go index b8348a3..4ef40b4 100644 --- a/internal/cmd/view.go +++ b/internal/cmd/view.go @@ -9,10 +9,8 @@ package cmd // text) when it is nil or off. A nil *ui.UI and a nil *ui.Spinner are valid. import ( - "fmt" "io" "strings" - "unicode/utf8" "github.com/OfficialSpicrawl/cli/internal/output" "github.com/OfficialSpicrawl/cli/internal/ui" @@ -74,33 +72,50 @@ func viewNext(p *output.Printer, steps ...string) { var b strings.Builder b.WriteString("\n" + u.Bold("next") + "\n") for _, s := range steps { - b.WriteString(" " + u.Cyan("›") + " " + s + "\n") + b.WriteString(" " + u.Accent(u.Glyphs().Bullet) + " " + s + "\n") } u.Printf("%s", b.String()) } -// viewRows renders key/value rows with dimmed, aligned keys. Rows with an -// empty value are skipped. +// viewRows renders key/value rows with muted, aligned keys. Rows with an empty +// value are skipped. On a terminal a value longer than the line wraps under +// its own column, not under the key, and URLs in it become links. func viewRows(u *ui.UI, indent string, rows []ui.Row) string { pad := 0 for _, r := range rows { - if r.Val != "" && utf8.RuneCountInString(r.Key) > pad { - pad = utf8.RuneCountInString(r.Key) + if r.Val != "" { + pad = max(pad, ui.Width(r.Key)) } } + // 0 while u is off or its width is unknown: the value is printed as it is. + room := 0 + if u.On() && u.Cols() > 0 { + room = u.Cols() - 1 - ui.Width(indent) - pad - 2 // one short of the row: see ui.Fail + } var b strings.Builder for _, r := range rows { - if r.Val != "" { - b.WriteString(indent + viewKey(u, pad, r.Key) + " " + r.Val + "\n") + if r.Val == "" { + continue + } + lines := []string{r.Val} + if room > 0 { + lines = ui.Wrap(r.Val, room) + } + for i, l := range lines { + key := viewKey(u, pad, r.Key) + if i > 0 { + key = strings.Repeat(" ", pad) + } + b.WriteString(indent + key + " " + u.Linkify(l) + "\n") } } return b.String() } -// viewKey is a key padded to width, dimmed when u is on. With u off it is -// exactly fmt's %-*s. +// viewKey is a key padded to width cells, muted when u is on. With u off it is +// the same text, padded. func viewKey(u *ui.UI, width int, key string) string { - return u.Dim(fmt.Sprintf("%-*s", width, key)) + return u.Dim(key + strings.Repeat(" ", max(width-ui.Width(key), 0))) } // Marks for a check list. Neutral is for "not applicable" or "unknown". @@ -112,15 +127,16 @@ const ( ) func viewMark(u *ui.UI, m int) string { + g := u.Glyphs() switch m { case markOK: - return u.Green("✓") + return u.Green(g.OK) case markBad: - return u.Red("✗") + return u.Red(g.Bad) case markWarn: - return u.Bold("!") + return u.Amber(g.Warn) } - return u.Dim("–") + return u.Dim(g.Neutral) } // viewLead is a mark and a space to put before a line, or "" while u is off, @@ -134,7 +150,8 @@ func viewLead(u *ui.UI, m int) string { // viewTable is p.Table, and exactly that while u is off. When it is on the // header is bold and style, if given, colours each body cell. Padding is -// added around the plain text, so colour never skews the columns. +// measured on the plain text, in terminal cells, so neither colour nor a wide +// character skews the columns. func viewTable(p *output.Printer, u *ui.UI, headers []string, rows [][]string, style func(col int, cell string) string) { if !u.On() { p.Table(headers, rows) @@ -142,12 +159,12 @@ func viewTable(p *output.Printer, u *ui.UI, headers []string, rows [][]string, s } widths := make([]int, len(headers)) for i, h := range headers { - widths[i] = utf8.RuneCountInString(h) + widths[i] = ui.Width(h) } for _, r := range rows { for i, c := range r { if i < len(widths) { - widths[i] = max(widths[i], utf8.RuneCountInString(c)) + widths[i] = max(widths[i], ui.Width(c)) } } } @@ -157,7 +174,7 @@ func viewTable(p *output.Printer, u *ui.UI, headers []string, rows [][]string, s shown := paint(i, c) b.WriteString(shown) if i < len(cells)-1 && i < len(widths) { - b.WriteString(strings.Repeat(" ", max(widths[i]-utf8.RuneCountInString(c), 0)+2)) + b.WriteString(strings.Repeat(" ", max(widths[i]-ui.Width(c), 0)+2)) } } b.WriteString("\n") diff --git a/internal/cmd/view_ui_test.go b/internal/cmd/view_ui_test.go new file mode 100644 index 0000000..5648ebe --- /dev/null +++ b/internal/cmd/view_ui_test.go @@ -0,0 +1,88 @@ +package cmd + +import ( + "bytes" + "strings" + "testing" + + "github.com/OfficialSpicrawl/cli/internal/output" + "github.com/OfficialSpicrawl/cli/internal/ui" +) + +func TestViewRowsWrapUnderTheirColumnOnlyOnATerminal(t *testing.T) { + rows := []ui.Row{ + {Key: "key", Val: "the saved key, checked against the API a moment ago and still valid for every project"}, + {Key: "base url", Val: "https://api.spicrawl.com"}, + {Key: "empty", Val: ""}, + } + want := " key the saved key, checked against\n" + + " the API a moment ago and still\n" + + " valid for every project\n" + + " base url https://api.spicrawl.com\n" + u := ui.ForceCaps(&bytes.Buffer{}, ui.Caps{Unicode: true, Width: 46}) + if got := stripANSI(viewRows(u, " ", rows)); got != want { + t.Errorf("wrapped rows:\n%q\nwant\n%q", got, want) + } + + // Width unknown (a buffer, not a terminal): the value is printed whole, as + // it always was, so a test or a log never sees a surprise line break. + whole := stripANSI(viewRows(ui.Force(&bytes.Buffer{}), " ", rows)) + if !strings.Contains(whole, " key the saved key, checked against the API a moment ago and still valid for every project\n") { + t.Errorf("unknown width must not wrap:\n%s", whole) + } + // Off: the plain padded rows, no escapes. + var off *ui.UI + if got := viewRows(off, " ", rows); got != whole { + t.Errorf("off rows differ from live rows without colour:\n%q\n%q", got, whole) + } + // Links on a terminal that has them; the text is unchanged. + linked := ui.ForceCaps(&bytes.Buffer{}, ui.Caps{Unicode: true, Links: true}) + got := viewRows(linked, "", []ui.Row{{Key: "docs", Val: "https://docs.spicrawl.com"}}) + if !strings.Contains(got, "\x1b]8;;https://docs.spicrawl.com\x1b\\") || ui.StripANSI(got) != "docs https://docs.spicrawl.com\n" { + t.Errorf("link row: %q", got) + } +} + +func TestViewMarksDegradeToASCII(t *testing.T) { + unicode, ascii := ui.Force(&bytes.Buffer{}), ui.ForceCaps(&bytes.Buffer{}, ui.Caps{}) + for m, want := range map[int][2]string{ + markOK: {"✓", "+"}, + markBad: {"✗", "x"}, + markWarn: {"!", "!"}, + markNeutral: {"–", "-"}, + } { + if got := stripANSI(viewMark(unicode, m)); got != want[0] { + t.Errorf("mark %d = %q, want %q", m, got, want[0]) + } + if got := stripANSI(viewMark(ascii, m)); got != want[1] { + t.Errorf("ASCII mark %d = %q, want %q", m, got, want[1]) + } + } + if !strings.Contains(viewMark(unicode, markWarn), "\x1b[33m") { + t.Errorf("a warning is amber, not just bold: %q", viewMark(unicode, markWarn)) + } + if got := viewLead(nil, markOK); got != "" { + t.Errorf("off lead = %q", got) + } +} + +func TestViewTableAlignsWideCharacters(t *testing.T) { + headers := []string{"NAME", "NOTE"} + rows := [][]string{{"日本語", "x"}, {"abcdef", "y"}} + var buf bytes.Buffer + p := &output.Printer{Out: &buf} + viewTable(p, ui.Force(&buf), headers, rows, nil) + lines := strings.Split(strings.TrimRight(stripANSI(buf.String()), "\n"), "\n") + // The second column starts at the same cell on every row. + starts := map[int]bool{} + for _, l := range lines { + for _, marker := range []string{"NOTE", "x", "y"} { + if i := strings.Index(l, marker); i >= 0 && (marker == "NOTE" || len(l) > 3) { + starts[ui.Width(l[:i])] = true + } + } + } + if len(starts) != 1 { + t.Errorf("columns are ragged (%v):\n%s", starts, strings.Join(lines, "\n")) + } +} diff --git a/internal/ui/caps.go b/internal/ui/caps.go new file mode 100644 index 0000000..a43d4fe --- /dev/null +++ b/internal/ui/caps.go @@ -0,0 +1,121 @@ +package ui + +import ( + "os" + "runtime" + "strconv" + "strings" +) + +// Color is how many colours the terminal can show. The zero value is the +// basic 16, which every terminal that passes Live has. +type Color int + +const ( + Color16 Color = iota // the terminal's own palette: follows the user's theme + Color256 // xterm 256-colour cube + ColorTrue // 24-bit +) + +// Caps is what the terminal can draw. Detect fills it from the environment; +// the zero value is the most conservative terminal: 16 colours, ASCII glyphs, +// no hyperlinks. +type Caps struct { + Color Color + Unicode bool // the locale is UTF-8 and the console has the glyphs + Links bool // OSC 8 hyperlinks are understood + Light bool // the background is light (COLORFGBG says so); dark or unknown otherwise + Width int // fixed column count, for tests; 0 asks the terminal +} + +// Detect reads the terminal's capabilities from the environment. It does not +// decide whether the output is live (that is Live); it only says what a live +// terminal can do. +func Detect() Caps { return detect(os.Getenv, runtime.GOOS) } + +func detect(getenv func(string) string, goos string) Caps { + return Caps{ + Color: colorLevel(getenv), + Unicode: unicodeOK(getenv, goos), + Links: linksOK(getenv), + Light: lightBackground(getenv), + } +} + +func colorLevel(getenv func(string) string) Color { + term := strings.ToLower(getenv("TERM")) + ct := strings.ToLower(getenv("COLORTERM")) + prog := getenv("TERM_PROGRAM") + switch { + case ct == "truecolor" || ct == "24bit", + strings.Contains(term, "truecolor"), strings.Contains(term, "24bit"), strings.Contains(term, "direct"), + term == "xterm-kitty", term == "alacritty", strings.HasPrefix(term, "foot"), + getenv("WT_SESSION") != "", getenv("KITTY_WINDOW_ID") != "", + prog == "iTerm.app", prog == "vscode", prog == "WezTerm", prog == "ghostty", prog == "Hyper", + vteVersion(getenv) >= 3600: + return ColorTrue + case strings.Contains(term, "256"), prog == "Apple_Terminal": + return Color256 + } + return Color16 +} + +// vteVersion is $VTE_VERSION as a number (GNOME Terminal and friends), or 0. +func vteVersion(getenv func(string) string) int { + n, _ := strconv.Atoi(getenv("VTE_VERSION")) + return n +} + +// unicodeOK is true when the glyphs (✓ ✗ ⠋ ━) will not turn into boxes or +// mojibake: the first of LC_ALL, LC_CTYPE and LANG that is set must name UTF-8 +// (C and POSIX do not). With no locale at all a modern terminal is assumed, +// except the kernel console, which has no such glyphs. Windows Terminal draws +// them; the legacy console, with its raster fonts, does not. +func unicodeOK(getenv func(string) string, goos string) bool { + if goos == "windows" { + return getenv("WT_SESSION") != "" + } + if getenv("TERM") == "linux" { + return false + } + for _, k := range []string{"LC_ALL", "LC_CTYPE", "LANG"} { + if v := strings.ToLower(getenv(k)); v != "" { + return strings.Contains(v, "utf-8") || strings.Contains(v, "utf8") + } + } + return true +} + +// linksOK is true for terminals known to understand OSC 8 hyperlinks. An +// unknown terminal gets plain text, because one that does not understand the +// sequence may print its payload. FORCE_HYPERLINK=1/0 overrides the guess. +// tmux and screen are not trusted: what they pass through varies by version. +func linksOK(getenv func(string) string) bool { + if v := getenv("FORCE_HYPERLINK"); v != "" { + return v != "0" && strings.ToLower(v) != "false" + } + if getenv("TMUX") != "" || getenv("STY") != "" { + return false + } + term := strings.ToLower(getenv("TERM")) + switch getenv("TERM_PROGRAM") { + case "iTerm.app", "WezTerm", "vscode", "ghostty", "Hyper": + return true + } + switch { + case getenv("WT_SESSION") != "", getenv("KITTY_WINDOW_ID") != "", getenv("KONSOLE_VERSION") != "", + getenv("DOMTERM") != "", vteVersion(getenv) >= 5000, + term == "xterm-kitty", term == "alacritty", strings.HasPrefix(term, "foot"): + return true + } + return false +} + +// lightBackground reads $COLORFGBG ("15;0", or "15;default;0"), which rxvt, +// Konsole and some others set to the foreground;background palette indexes. +// Index 7 and 15 are the light greys and whites. +func lightBackground(getenv func(string) string) bool { + parts := strings.Split(getenv("COLORFGBG"), ";") + bg, err := strconv.Atoi(parts[len(parts)-1]) + return err == nil && (bg == 7 || bg == 15) +} diff --git a/internal/ui/caps_test.go b/internal/ui/caps_test.go new file mode 100644 index 0000000..01e465d --- /dev/null +++ b/internal/ui/caps_test.go @@ -0,0 +1,111 @@ +package ui + +import "testing" + +func envOf(kv ...string) func(string) string { + m := map[string]string{} + for i := 0; i < len(kv); i += 2 { + m[kv[i]] = kv[i+1] + } + return func(k string) string { return m[k] } +} + +func TestColorLevel(t *testing.T) { + cases := []struct { + name string + env []string + want Color + }{ + {"nothing says 16", nil, Color16}, + {"TERM=xterm is 16", []string{"TERM", "xterm"}, Color16}, + {"TERM=linux is 16", []string{"TERM", "linux"}, Color16}, + {"256color", []string{"TERM", "xterm-256color"}, Color256}, + {"screen-256color", []string{"TERM", "screen-256color"}, Color256}, + {"Apple Terminal is 256 even with a plain TERM", []string{"TERM", "xterm", "TERM_PROGRAM", "Apple_Terminal"}, Color256}, + {"COLORTERM=truecolor", []string{"TERM", "xterm-256color", "COLORTERM", "truecolor"}, ColorTrue}, + {"COLORTERM=24bit", []string{"COLORTERM", "24bit"}, ColorTrue}, + {"COLORTERM=yes is no promise of 24 bit", []string{"TERM", "xterm", "COLORTERM", "yes"}, Color16}, + {"TERM=xterm-direct", []string{"TERM", "xterm-direct"}, ColorTrue}, + {"kitty", []string{"TERM", "xterm-kitty"}, ColorTrue}, + {"Windows Terminal", []string{"WT_SESSION", "x"}, ColorTrue}, + {"iTerm", []string{"TERM_PROGRAM", "iTerm.app", "TERM", "xterm-256color"}, ColorTrue}, + {"new VTE", []string{"VTE_VERSION", "6800"}, ColorTrue}, + {"old VTE", []string{"VTE_VERSION", "3000", "TERM", "xterm-256color"}, Color256}, + } + for _, c := range cases { + if got := colorLevel(envOf(c.env...)); got != c.want { + t.Errorf("%s: colorLevel = %d, want %d", c.name, got, c.want) + } + } +} + +func TestUnicodeNeedsAUTF8Locale(t *testing.T) { + cases := []struct { + name string + env []string + goos string + want bool + }{ + {"UTF-8 LANG", []string{"LANG", "en_US.UTF-8"}, "linux", true}, + {"utf8 spelling", []string{"LANG", "en_GB.utf8"}, "linux", true}, + {"LANG=C", []string{"LANG", "C"}, "linux", false}, + {"POSIX", []string{"LC_ALL", "POSIX"}, "linux", false}, + {"latin-1", []string{"LANG", "de_DE.ISO-8859-1"}, "linux", false}, + {"LC_ALL beats LANG", []string{"LC_ALL", "C", "LANG", "en_US.UTF-8"}, "linux", false}, + {"LC_ALL beats LANG the other way", []string{"LC_ALL", "C.UTF-8", "LANG", "C"}, "linux", true}, + {"LC_CTYPE beats LANG", []string{"LC_CTYPE", "en_US.UTF-8", "LANG", "C"}, "linux", true}, + {"no locale at all is assumed modern", nil, "darwin", true}, + {"the kernel console has no such glyphs", []string{"TERM", "linux", "LANG", "en_US.UTF-8"}, "linux", false}, + {"Windows Terminal", []string{"WT_SESSION", "x"}, "windows", true}, + {"legacy Windows console, whatever LANG says", []string{"LANG", "en_US.UTF-8"}, "windows", false}, + } + for _, c := range cases { + if got := unicodeOK(envOf(c.env...), c.goos); got != c.want { + t.Errorf("%s: unicodeOK = %v, want %v", c.name, got, c.want) + } + } +} + +func TestLinksOnlyWhereKnown(t *testing.T) { + cases := []struct { + name string + env []string + want bool + }{ + {"unknown terminal", []string{"TERM", "xterm-256color"}, false}, + {"Apple Terminal does not", []string{"TERM_PROGRAM", "Apple_Terminal"}, false}, + {"iTerm", []string{"TERM_PROGRAM", "iTerm.app"}, true}, + {"VS Code", []string{"TERM_PROGRAM", "vscode"}, true}, + {"Windows Terminal", []string{"WT_SESSION", "x"}, true}, + {"kitty", []string{"KITTY_WINDOW_ID", "1"}, true}, + {"VTE 5000", []string{"VTE_VERSION", "5000"}, true}, + {"VTE 4999", []string{"VTE_VERSION", "4999"}, false}, + {"inside tmux, even in iTerm", []string{"TERM_PROGRAM", "iTerm.app", "TMUX", "/tmp/tmux"}, false}, + {"FORCE_HYPERLINK=1", []string{"FORCE_HYPERLINK", "1"}, true}, + {"FORCE_HYPERLINK=1 inside tmux", []string{"FORCE_HYPERLINK", "1", "TMUX", "x"}, true}, + {"FORCE_HYPERLINK=0 beats the terminal", []string{"FORCE_HYPERLINK", "0", "TERM_PROGRAM", "iTerm.app"}, false}, + } + for _, c := range cases { + if got := linksOK(envOf(c.env...)); got != c.want { + t.Errorf("%s: linksOK = %v, want %v", c.name, got, c.want) + } + } +} + +func TestLightBackground(t *testing.T) { + for v, want := range map[string]bool{"": false, "15;0": false, "0;15": true, "0;7": true, "15;default;0": false, "0;default;15": true, "garbage": false, "0;8": false} { + if got := lightBackground(envOf("COLORFGBG", v)); got != want { + t.Errorf("COLORFGBG=%q: light = %v, want %v", v, got, want) + } + } +} + +func TestDetectComposes(t *testing.T) { + c := detect(envOf("TERM", "xterm-256color", "COLORTERM", "truecolor", "LANG", "C", "TERM_PROGRAM", "WezTerm"), "linux") + if c.Color != ColorTrue || c.Unicode || !c.Links { + t.Errorf("detect = %+v", c) + } + if z := (Caps{}); z.Color != Color16 || z.Unicode || z.Links { + t.Errorf("the zero Caps must be the most conservative terminal: %+v", z) + } +} diff --git a/internal/ui/progress.go b/internal/ui/progress.go new file mode 100644 index 0000000..8ddcc69 --- /dev/null +++ b/internal/ui/progress.go @@ -0,0 +1,108 @@ +package ui + +import ( + "fmt" + "strings" + "time" +) + +// Bar draws a determinate progress bar exactly width cells wide: +// +// ━━━━━━━━╸─────────── +// +// The filled part is the accent, the rest muted, and a ╸ marks a half cell so +// the bar moves twice as smoothly as its width. Where Unicode is not safe it +// is ####------. done is clamped to 0..total. +func (u *UI) Bar(width, done, total int) string { + if width < 1 { + return "" + } + g := u.Glyphs() + done = max(0, min(done, total)) + half := 0 // filled, in half cells + if total > 0 { + half = done * width * 2 / total + } + full, part := half/2, half%2 + if g.BarHalf == "" { // ASCII: no half cells + part = 0 + } + rest := width - full - part + return u.Accent(strings.Repeat(g.BarFull, full)+strings.Repeat(g.BarHalf, part)) + + u.Dim(strings.Repeat(g.BarEmpty, rest)) +} + +// ETA estimates the time left from the rate so far: linear, so it is wrong +// when items vary a lot in cost, and not offered until something finished. +// ponytail: a moving average would steady it on long runs of uneven items. +func ETA(elapsed time.Duration, done, total int) (time.Duration, bool) { + if done <= 0 || done >= total || elapsed <= 0 { + return 0, false + } + return elapsed * time.Duration(total-done) / time.Duration(done), true +} + +// Span formats a duration to whole seconds for an estimate: 22s, 1m04s, 1h05m. +func Span(d time.Duration) string { + d = d.Round(time.Second) + switch { + case d < time.Minute: + return fmt.Sprintf("%ds", int(d/time.Second)) + case d < time.Hour: + return fmt.Sprintf("%dm%02ds", int(d/time.Minute), int(d%time.Minute/time.Second)) + } + return fmt.Sprintf("%dh%02dm", int(d/time.Hour), int(d%time.Hour/time.Minute)) +} + +// progressLine lays out one spinner line with a bar inside cols cells: +// +// ⠹ Batch job ━━━━━━━━╸──────────── 7/20 · 12.1s · ETA 22s +// +// It gives up the least important parts first: the ETA, then the bar (down to +// 10 cells), then the label's tail. The result is plain text styled by u and +// never wider than cols. +func (u *UI) progressLine(frame, label string, done, total int, elapsed time.Duration, cols int) string { + g := u.Glyphs() + counts := fmt.Sprintf("%d/%d", done, total) + if total <= 0 { + counts = fmt.Sprintf("%d", done) + } + facts := []string{counts, Elapsed(elapsed)} + eta := "" + if d, ok := ETA(elapsed, done, total); ok && elapsed >= 2*time.Second { // sooner it is a guess from one item + eta = "ETA " + Span(d) + } + stats := func(withETA bool) string { + f := facts + if withETA && eta != "" { + f = append(append([]string{}, f...), eta) + } + return strings.Join(f, " "+g.Dot+" ") + } + const minLabel, minBar, maxBar = 12, 10, 28 + lead := Width(frame) + 1 + withETA := true + barW := 0 + if total > 0 { + // Room after the glyph, the shortest label and the two-cell gaps. + room := cols - 1 - lead - minLabel - 2*2 - Width(stats(true)) + if room < minBar { + withETA = false + room = cols - 1 - lead - minLabel - 2*2 - Width(stats(false)) + } + if room >= minBar { + barW = min(room, maxBar) + } + } + tail := stats(withETA) + if barW > 0 { + tail = u.Bar(barW, done, total) + " " + u.Dim(tail) + } else { + tail = u.Dim(tail) + } + labelW := cols - 1 - lead - Width(tail) - 2 + if labelW < 4 { // not even a short label fits beside the numbers: the label wins + return u.Accent(frame) + " " + Truncate(label, max(cols-1-lead, 1), g.Ellipsis) + } + return u.Accent(frame) + " " + Truncate(label, labelW, g.Ellipsis) + " " + tail +} diff --git a/internal/ui/progress_test.go b/internal/ui/progress_test.go new file mode 100644 index 0000000..13e2cdf --- /dev/null +++ b/internal/ui/progress_test.go @@ -0,0 +1,181 @@ +package ui + +import ( + "bytes" + "strings" + "testing" + "time" +) + +func TestBar(t *testing.T) { + u := Force(&bytes.Buffer{}) + for _, c := range []struct { + width, done, total int + want string + }{ + {10, 0, 20, "──────────"}, + {10, 10, 20, "━━━━━─────"}, + {10, 11, 20, "━━━━━╸────"}, // a half cell: the bar moves twice as finely as it is wide + {10, 20, 20, "━━━━━━━━━━"}, + {10, 99, 20, "━━━━━━━━━━"}, // never past the end + {10, -3, 20, "──────────"}, + {10, 5, 0, "──────────"}, // no total: empty, never a division by zero + {4, 1, 4, "━───"}, + } { + got := StripANSI(u.Bar(c.width, c.done, c.total)) + if got != c.want { + t.Errorf("Bar(%d, %d, %d) = %q, want %q", c.width, c.done, c.total, got, c.want) + } + if Width(got) != c.width { + t.Errorf("Bar(%d, %d, %d) is %d cells", c.width, c.done, c.total, Width(got)) + } + } + if u.Bar(0, 1, 2) != "" { + t.Error("a zero-width bar must be empty") + } + ascii := ForceCaps(&bytes.Buffer{}, Caps{}) + if got := StripANSI(ascii.Bar(10, 5, 10)); got != "#####-----" { + t.Errorf("ASCII bar = %q", got) + } +} + +func TestETA(t *testing.T) { + if d, ok := ETA(10*time.Second, 5, 20); !ok || d != 30*time.Second { + t.Errorf("ETA = %s, %v", d, ok) + } + for _, c := range [][3]int{{0, 20, 10}, {20, 20, 10}, {25, 20, 10}, {5, 20, 0}} { + if _, ok := ETA(time.Duration(c[2])*time.Second, c[0], c[1]); ok { + t.Errorf("ETA offered for done=%d total=%d elapsed=%ds", c[0], c[1], c[2]) + } + } +} + +func TestProgressLineFitsEveryWidth(t *testing.T) { + u := Force(&bytes.Buffer{}) + label := "Batch job 01J9Z6V0Q8M4K2T7R3N5B1C9XA running on a very long label indeed" + for cols := 12; cols <= 200; cols++ { + line := u.progressLine("⠹", label, 7, 20, 12*time.Second, cols) + if w := Width(line); w > cols-1 { + t.Fatalf("cols=%d: line is %d cells (%q); it must stay one short of the terminal", cols, w, StripANSI(line)) + } + } + // Wide: everything is there. + full := StripANSI(u.progressLine("⠹", "Batch job", 7, 20, 12*time.Second, 100)) + for _, want := range []string{"⠹ Batch job", "━", "7/20", "12.0s", "ETA 22s"} { + if !strings.Contains(full, want) { + t.Errorf("wide line lacks %q: %q", want, full) + } + } + // Too early to guess: one item's rate is not an estimate. + if early := StripANSI(u.progressLine("⠹", "Batch job", 1, 20, 900*time.Millisecond, 100)); strings.Contains(early, "ETA") { + t.Errorf("an ETA after 0.9 s: %q", early) + } + // Narrower: the ETA goes first, the bar next, and the label is kept. + mid := StripANSI(u.progressLine("⠹", "Batch job", 7, 20, 12*time.Second, 50)) + if strings.Contains(mid, "ETA") || !strings.Contains(mid, "━") || !strings.Contains(mid, "Batch job") { + t.Errorf("50 cols should drop the ETA but keep the bar: %q", mid) + } + tight := StripANSI(u.progressLine("⠹", "Batch job", 7, 20, 12*time.Second, 28)) + if strings.Contains(tight, "━") || !strings.Contains(tight, "7/20") || !strings.Contains(tight, "Batch") { + t.Errorf("28 cols should drop the bar and keep the count: %q", tight) + } + // No total: a count, no bar. + open := StripANSI(u.progressLine("⠹", "Crawling", 7, 0, 3*time.Second, 80)) + if strings.Contains(open, "━") || strings.Contains(open, "/") || !strings.Contains(open, " 7 ") { + t.Errorf("an open-ended count: %q", open) + } +} + +func TestSpinnerProgressAndResize(t *testing.T) { + d := Delay + Delay = time.Hour // draw by hand, so the test controls every frame + defer func() { Delay = d }() + var buf syncBuf + u := ForceCaps(&buf, Caps{Unicode: true, Width: 80}) + s := u.Spin("Batch job 01J9Z6V0Q8M4K2T7R3N5B1C9XA") + s.Progress(3, 10) + draw := func() string { + before := len(buf.String()) + s.mu.Lock() + s.draw() + s.mu.Unlock() + return StripANSI(buf.String()[before:]) + } + wide := draw() + if !strings.Contains(wide, "3/10") || !strings.Contains(wide, "━") { + t.Errorf("progress not drawn: %q", wide) + } + // The terminal shrinks mid-run: the very next frame fits it. + u.caps.Width = 30 + narrow := strings.TrimPrefix(draw(), "\r") + if Width(narrow) > 29 || !strings.Contains(narrow, "3/10") { + t.Errorf("after a resize to 30 columns the line is %d cells: %q", Width(narrow), narrow) + } + // Taking the total away takes the bar away. + u.caps.Width = 80 + s.Progress(0, 0) + if plain := draw(); strings.Contains(plain, "━") || strings.Contains(plain, "/10") { + t.Errorf("bar still drawn: %q", plain) + } + s.Stop() + (*Spinner)(nil).Progress(1, 2) // nil-safe +} + +func TestSpinnerASCIIFrames(t *testing.T) { + d := Delay + Delay = time.Hour + defer func() { Delay = d }() + var buf syncBuf + s := ForceCaps(&buf, Caps{}).Spin("work") + for range 4 { + s.mu.Lock() + s.draw() + s.mu.Unlock() + } + s.Stop() + out := StripANSI(buf.String()) + for _, r := range out { + if r > 0x7e { + t.Fatalf("non-ASCII %q on an ASCII terminal: %q", r, out) + } + } + if !strings.Contains(out, "| work - 0.0s") || !strings.Contains(out, "/ work") || !strings.Contains(out, "\\ work") { + t.Errorf("ASCII spinner frames: %q", out) + } +} + +func TestFailWrapsUnderItsOwnColumn(t *testing.T) { + var buf bytes.Buffer + u := ForceCaps(&buf, Caps{Unicode: true, Width: 50, Links: true}) + u.Fail("ERR::TARGET::BLOCKED: the site refused the request after three attempts through the pool", + Row{"hint", "retry with --render to run the page's JavaScript, or add --country to change the exit"}, + Row{"docs", "https://docs.spicrawl.com/errors/target-blocked"}, + Row{"request", "01J9Z6V0Q8M4K2T7R3N5B1C9XA"}) + out := StripANSI(buf.String()) + for _, l := range strings.Split(strings.TrimRight(out, "\n"), "\n") { + if Width(l) > 50 && !strings.Contains(l, "https://") { + t.Errorf("line %q is %d cells wide", l, Width(l)) + } + } + if !strings.HasPrefix(out, "✗ ERR::TARGET::BLOCKED: the site refused the\n request after three attempts through the pool\n") { + t.Errorf("headline did not wrap under itself: %q", out) + } + if !strings.Contains(out, " hint retry with --render to run the page's\n JavaScript, or add --country to change\n the exit\n") { + t.Errorf("hint did not wrap under its value column: %q", out) + } + if !strings.Contains(buf.String(), "\x1b]8;;https://docs.spicrawl.com/errors/target-blocked\x1b\\") { + t.Error("the docs URL is not a hyperlink on a terminal with them") + } + // A message with its own lines keeps them, and their indentation. + buf.Reset() + u.Fail("cannot read the config file\n line 3: bad value", Row{"file", "/home/me/.config/spicrawl/config.json"}) + if got := StripANSI(buf.String()); !strings.HasPrefix(got, "✗ cannot read the config file\n line 3: bad value\n file /home/me") { + t.Errorf("multi-line headline: %q", got) + } + // Without hyperlinks the URL is plain text, nothing else. + var plain bytes.Buffer + ForceCaps(&plain, Caps{Unicode: true}).Fail("boom", Row{"docs", "https://docs.spicrawl.com/x"}) + if strings.Contains(plain.String(), "\x1b]") { + t.Errorf("OSC 8 on a terminal without hyperlinks: %q", plain.String()) + } +} diff --git a/internal/ui/style.go b/internal/ui/style.go new file mode 100644 index 0000000..5e70c5c --- /dev/null +++ b/internal/ui/style.go @@ -0,0 +1,146 @@ +package ui + +import "strings" + +// Role is a meaning, not a colour. Callers say "this failed" or "this is the +// one thing to look at"; the terminal's capabilities decide how that looks. +// +// One accent (teal), three signals (green, amber, red) and a muted grey. The +// signals use the terminal's own palette wherever it is the safe choice, so a +// Solarized, Dracula or light theme tunes them for its own background. Only +// the accent and the amber leave the 16-colour palette, and only to colours +// that keep contrast on both a black and a white background. +type Role int + +const ( + Accent Role = iota // the one brand colour: spinner, progress, names to type + Success // green + Warn // amber + Danger // red + Muted // secondary text: keys, facts, elapsed time + Strong // headings and headlines +) + +// sgr is the SGR parameter string for r on a terminal with these caps. +func (c Caps) sgr(r Role) string { + switch r { + case Success: + return "32" + case Danger: + return "31" + case Muted: + return "2" // faint: relative to the user's own foreground, so it works on any theme + case Strong: + return "1" + case Warn: + switch c.Color { + case ColorTrue: + return "38;2;217;119;6" // amber-600: 3.2:1 on white, 6.5:1 on black + case Color256: + return "38;5;172" + } + return "33" + } + switch c.Color { // Accent + case ColorTrue: + if c.Light { + return "38;2;15;118;110" // teal-700 + } + return "38;2;13;148;136" // teal-600: 3.7:1 on white, 6:1 on black + case Color256: + return "38;5;30" + } + return "36" +} + +// Glyphs is every symbol the output draws, so a locale or console that cannot +// show them gets ASCII with the same shape. +type Glyphs struct { + OK, Bad, Warn, Neutral string // ✓ ✗ ! – + Bullet, Dot string // › · + Ellipsis, Rule string // … ─ + BarFull, BarHalf, BarEmpty string // ━ ╸ ─ + Frames []string +} + +var ( + unicodeGlyphs = Glyphs{ + OK: "✓", Bad: "✗", Warn: "!", Neutral: "–", + Bullet: "›", Dot: "·", Ellipsis: "…", Rule: "─", + BarFull: "━", BarHalf: "╸", BarEmpty: "─", + Frames: []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}, + } + asciiGlyphs = Glyphs{ + OK: "+", Bad: "x", Warn: "!", Neutral: "-", + Bullet: ">", Dot: "-", Ellipsis: "...", Rule: "-", + BarFull: "#", BarHalf: "", BarEmpty: "-", + Frames: []string{"|", "/", "-", "\\"}, + } +) + +// Glyphs returns the symbol set this UI draws with. +func (u *UI) Glyphs() Glyphs { + if u != nil && u.caps.Unicode { + return unicodeGlyphs + } + return asciiGlyphs +} + +// Paint wraps s in the style for r. It is s unchanged while the UI is off. +func (u *UI) Paint(r Role, s string) string { + if !u.On() || s == "" { + return s + } + return "\x1b[" + u.caps.sgr(r) + "m" + s + "\x1b[0m" +} + +func (u *UI) Bold(s string) string { return u.Paint(Strong, s) } +func (u *UI) Dim(s string) string { return u.Paint(Muted, s) } +func (u *UI) Red(s string) string { return u.Paint(Danger, s) } +func (u *UI) Green(s string) string { return u.Paint(Success, s) } +func (u *UI) Amber(s string) string { return u.Paint(Warn, s) } +func (u *UI) Accent(s string) string { return u.Paint(Accent, s) } +func (u *UI) Cyan(s string) string { return u.Paint(Accent, s) } // the old name for Accent + +// Link is s as a clickable hyperlink to url (OSC 8) on a terminal that has +// them. Elsewhere it is the text alone, or "text (url)" when the text is not +// the URL, so the address is never lost. An empty text means the URL itself. +func (u *UI) Link(url, text string) string { + if text == "" { + text = url + } + switch { + case !u.On() || url == "": + return text + case !u.caps.Links: + if text == url { + return text + } + return text + " (" + url + ")" + } + return "\x1b]8;;" + url + "\x1b\\" + text + "\x1b]8;;\x1b\\" +} + +// Linkify turns each http(s) URL in s into a hyperlink, if the terminal has +// them; with no hyperlinks s is returned untouched. +func (u *UI) Linkify(s string) string { + if !u.On() || !u.caps.Links || !strings.Contains(s, "http") { + return s + } + var b strings.Builder + for _, w := range strings.SplitAfter(s, " ") { + word := strings.TrimRight(w, " ") + if strings.HasPrefix(word, "https://") || strings.HasPrefix(word, "http://") { + // trailing punctuation belongs to the sentence, not the address + tail := "" + for len(word) > 0 && strings.ContainsRune(".,;:)", rune(word[len(word)-1])) { + tail = word[len(word)-1:] + tail + word = word[:len(word)-1] + } + b.WriteString(u.Link(word, word) + tail + w[len(strings.TrimRight(w, " ")):]) + continue + } + b.WriteString(w) + } + return b.String() +} diff --git a/internal/ui/style_test.go b/internal/ui/style_test.go new file mode 100644 index 0000000..efddc99 --- /dev/null +++ b/internal/ui/style_test.go @@ -0,0 +1,104 @@ +package ui + +import ( + "bytes" + "strings" + "testing" +) + +func TestRolesResolvePerColourLevel(t *testing.T) { + cases := []struct { + caps Caps + role Role + want string + }{ + {Caps{}, Accent, "36"}, + {Caps{}, Success, "32"}, + {Caps{}, Danger, "31"}, + {Caps{}, Warn, "33"}, + {Caps{}, Muted, "2"}, + {Caps{}, Strong, "1"}, + {Caps{Color: Color256}, Accent, "38;5;30"}, + {Caps{Color: Color256}, Warn, "38;5;172"}, + {Caps{Color: ColorTrue}, Accent, "38;2;13;148;136"}, + {Caps{Color: ColorTrue, Light: true}, Accent, "38;2;15;118;110"}, + {Caps{Color: ColorTrue}, Warn, "38;2;217;119;6"}, + // The signals stay in the terminal's own palette at every level, so a + // theme tunes them for its background. + {Caps{Color: ColorTrue}, Danger, "31"}, + {Caps{Color: Color256}, Success, "32"}, + {Caps{Color: ColorTrue}, Muted, "2"}, + } + for _, c := range cases { + if got := c.caps.sgr(c.role); got != c.want { + t.Errorf("sgr(%+v, %d) = %q, want %q", c.caps, c.role, got, c.want) + } + } + u := ForceCaps(&bytes.Buffer{}, Caps{Color: ColorTrue}) + if got := u.Accent("x"); got != "\x1b[38;2;13;148;136mx\x1b[0m" { + t.Errorf("Accent = %q", got) + } + if u.Cyan("x") != u.Accent("x") || u.Red("x") != u.Paint(Danger, "x") { + t.Error("the old names must keep resolving to their roles") + } +} + +func TestGlyphsFallBackToASCII(t *testing.T) { + var buf bytes.Buffer + u := ForceCaps(&buf, Caps{}) // not a UTF-8 terminal + u.Done(true, "https://example.com", "200", "1 credit") + u.Done(false, "boom") + u.Fail("ERR::X::Y: broke", Row{"hint", "try again"}) + out := StripANSI(buf.String()) + for _, want := range []string{"+ https://example.com 200 - 1 credit\n", "x boom\n", "x ERR::X::Y: broke\n", " hint try again\n"} { + if !strings.Contains(out, want) { + t.Errorf("ASCII output lacks %q: %q", want, out) + } + } + for _, r := range out { + if r > 0x7e { + t.Fatalf("a non-ASCII rune %q on an ASCII terminal: %q", r, out) + } + } + if g := (*UI)(nil).Glyphs(); g.OK != "+" { + t.Errorf("a nil UI must have the safe glyphs, got %+v", g) + } + if g := Force(&buf).Glyphs(); g.OK != "✓" || len(g.Frames) != 10 { + t.Errorf("unicode glyphs = %+v", g) + } +} + +func TestLinks(t *testing.T) { + const url = "https://docs.spicrawl.com/errors/target-blocked" + on := ForceCaps(&bytes.Buffer{}, Caps{Links: true}) + off := ForceCaps(&bytes.Buffer{}, Caps{}) + if got := on.Link(url, "docs"); got != "\x1b]8;;"+url+"\x1b\\docs\x1b]8;;\x1b\\" { + t.Errorf("Link = %q", got) + } + if got := on.Link(url, ""); !strings.HasSuffix(got, url+"\x1b]8;;\x1b\\") { + t.Errorf("an empty text means the URL: %q", got) + } + if got := off.Link(url, url); got != url { + t.Errorf("no hyperlinks: %q", got) + } + if got := off.Link(url, "docs"); got != "docs ("+url+")" { + t.Errorf("a label must not swallow the address: %q", got) + } + var nilUI *UI + if got := nilUI.Link(url, "docs"); got != "docs" { + t.Errorf("off UI: %q", got) + } + + // Linkify wraps whole URLs and leaves the sentence's punctuation outside. + got := on.Linkify("see https://a.example/x, or https://b.example.") + want := "see \x1b]8;;https://a.example/x\x1b\\https://a.example/x\x1b]8;;\x1b\\, or \x1b]8;;https://b.example\x1b\\https://b.example\x1b]8;;\x1b\\." + if got != want { + t.Errorf("Linkify = %q\nwant %q", got, want) + } + if StripANSI(got) != "see https://a.example/x, or https://b.example." { + t.Errorf("stripping the escapes must leave the text: %q", StripANSI(got)) + } + if got := off.Linkify("see https://a.example/x"); got != "see https://a.example/x" { + t.Errorf("no hyperlinks, no change: %q", got) + } +} diff --git a/internal/ui/text.go b/internal/ui/text.go new file mode 100644 index 0000000..7b20500 --- /dev/null +++ b/internal/ui/text.go @@ -0,0 +1,163 @@ +package ui + +import ( + "strings" + "unicode/utf8" +) + +// Width is how many terminal cells s takes: escape sequences (colour, OSC 8 +// links) take none, East Asian wide characters and emoji take two, combining +// marks none. Tables, wrapping and the spinner line all measure with it. +func Width(s string) int { + n := 0 + for i := 0; i < len(s); { + if s[i] == 0x1b { + i += escLen(s[i:]) + continue + } + r, size := utf8.DecodeRuneInString(s[i:]) + n += runeWidth(r) + i += size + } + return n +} + +// escLen is the length of the escape sequence at the start of s: a CSI +// (ESC [ ... final byte) or an OSC (ESC ] ... BEL or ESC \). Anything else is +// two bytes. +func escLen(s string) int { + if len(s) < 2 { + return len(s) + } + switch s[1] { + case '[': + for i := 2; i < len(s); i++ { + if s[i] >= 0x40 && s[i] <= 0x7e { + return i + 1 + } + } + case ']': + for i := 2; i < len(s); i++ { + if s[i] == 0x07 { + return i + 1 + } + if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '\\' { + return i + 2 + } + } + default: + return 2 + } + return len(s) +} + +// StripANSI removes colour, cursor and hyperlink escape sequences. +func StripANSI(s string) string { + var b strings.Builder + for i := 0; i < len(s); { + if s[i] == 0x1b { + i += escLen(s[i:]) + continue + } + b.WriteByte(s[i]) + i++ + } + return b.String() +} + +// runeWidth is the cell width of one rune. ponytail: the tables cover CJK, +// Hangul, fullwidth forms, emoji and the common combining marks, not all of +// UAX #11; an exotic script may be off by a cell. Swap in x/text/width if a +// reader of such a script complains. +func runeWidth(r rune) int { + switch { + case r < 0x20, r >= 0x7f && r < 0xa0: + return 0 + case r < 0x300: + return 1 + } + for _, t := range zeroWidth { + if r >= t[0] && r <= t[1] { + return 0 + } + } + for _, t := range wide { + if r >= t[0] && r <= t[1] { + return 2 + } + } + return 1 +} + +var zeroWidth = [][2]rune{ + {0x0300, 0x036f}, {0x1ab0, 0x1aff}, {0x1dc0, 0x1dff}, {0x200b, 0x200f}, {0x2028, 0x202e}, + {0x2060, 0x2064}, {0x20d0, 0x20ff}, {0xfe00, 0xfe0f}, {0xfe20, 0xfe2f}, {0xfeff, 0xfeff}, +} + +var wide = [][2]rune{ + {0x1100, 0x115f}, {0x231a, 0x231b}, {0x2e80, 0x303e}, {0x3041, 0x33ff}, {0x3400, 0x4dbf}, + {0x4e00, 0x9fff}, {0xa000, 0xa4cf}, {0xac00, 0xd7a3}, {0xf900, 0xfaff}, {0xfe30, 0xfe6f}, + {0xff00, 0xff60}, {0xffe0, 0xffe6}, {0x1f300, 0x1f64f}, {0x1f680, 0x1f6ff}, {0x1f900, 0x1faff}, + {0x20000, 0x3fffd}, +} + +// Truncate shortens plain text s to n cells, ending in ell when it cuts. +func Truncate(s string, n int, ell string) string { + if Width(s) <= n { + return s + } + n -= Width(ell) + if n < 0 { + n = 0 + } + var b strings.Builder + used := 0 + for _, r := range s { + w := runeWidth(r) + if used+w > n { + break + } + b.WriteRune(r) + used += w + } + return b.String() + ell +} + +// Wrap breaks plain text into lines of at most width cells at spaces. Existing +// newlines are kept, and so is each line's own indentation (every wrapped +// piece of it gets the same). A word wider than the line (a URL, an id) stays +// whole on a line of its own: the terminal soft-wraps it, which keeps it +// copyable. A width under 1 means unknown and returns the text as it is. +func Wrap(s string, width int) []string { + if width < 1 { + return strings.Split(s, "\n") + } + var lines []string + for _, para := range strings.Split(s, "\n") { + indent := para[:len(para)-len(strings.TrimLeft(para, " \t"))] + var cur strings.Builder + cur.WriteString(indent) + w, started := Width(indent), false + for _, word := range strings.Fields(para) { + ww := Width(word) + if started && w+1+ww > width { + lines = append(lines, cur.String()) + cur.Reset() + cur.WriteString(indent) + w, started = Width(indent), false + } + if started { + cur.WriteByte(' ') + w++ + } + cur.WriteString(word) + w += ww + started = true + } + if !started { + cur.Reset() // a blank line stays blank + } + lines = append(lines, cur.String()) + } + return lines +} diff --git a/internal/ui/text_test.go b/internal/ui/text_test.go new file mode 100644 index 0000000..6ab47fd --- /dev/null +++ b/internal/ui/text_test.go @@ -0,0 +1,86 @@ +package ui + +import ( + "strings" + "testing" +) + +func TestWidth(t *testing.T) { + for s, want := range map[string]int{ + "": 0, + "hello": 5, + "✓ ok": 4, + "⠹ wait": 6, + "日本語": 6, + "a日b": 4, + "é": 1, // e + combining acute + "\x1b[31mred\x1b[0m": 3, + "\x1b[38;2;1;2;3mx\x1b[0m": 1, + "\x1b]8;;https://x.example\x1b\\link\x1b]8;;\x1b\\": 4, + "\x1b]8;;https://x.example\x07link\x1b]8;;\x07": 4, + "🚀 go": 5, + } { + if got := Width(s); got != want { + t.Errorf("Width(%q) = %d, want %d", s, got, want) + } + } + if got := StripANSI("\x1b[1mbold\x1b[0m \x1b]8;;u\x1b\\l\x1b]8;;\x1b\\"); got != "bold l" { + t.Errorf("StripANSI = %q", got) + } +} + +func TestTruncateCountsCells(t *testing.T) { + for _, c := range []struct { + in string + n int + ell string + want string + }{ + {"https://example.com/a/very/long/path", 12, "…", "https://exa…"}, + {"short", 12, "…", "short"}, + {"exactly12chr", 12, "…", "exactly12chr"}, + {"日本語のテキスト", 7, "…", "日本語…"}, // 3 wide runes = 6 cells + the ellipsis = 7, never 8 + {"日本語のテキスト", 8, "…", "日本語…"}, + {"https://example.com/long", 10, "...", "https:/..."}, + {"anything", 0, "…", "…"}, + } { + got := Truncate(c.in, c.n, c.ell) + if got != c.want { + t.Errorf("Truncate(%q, %d, %q) = %q, want %q", c.in, c.n, c.ell, got, c.want) + } + if c.n > 0 && Width(got) > c.n { + t.Errorf("Truncate(%q, %d) is %d cells wide", c.in, c.n, Width(got)) + } + } +} + +func TestWrap(t *testing.T) { + got := Wrap("the site refused the request after three attempts", 20) + want := []string{"the site refused the", "request after three", "attempts"} + if strings.Join(got, "|") != strings.Join(want, "|") { + t.Errorf("Wrap = %q", got) + } + // A word wider than the line stays whole, on its own line. + got = Wrap("see https://example.com/a/very/long/url/that/does/not/fit now", 20) + if len(got) != 3 || got[1] != "https://example.com/a/very/long/url/that/does/not/fit" { + t.Errorf("a long word was split: %q", got) + } + // Paragraphs survive; an empty line stays one. + if got := Wrap("a b\n\nc", 10); strings.Join(got, "|") != "a b||c" { + t.Errorf("Wrap paragraphs = %q", got) + } + // A line keeps its own indentation, on every piece of it. + if got := Wrap(" one two three four", 12); strings.Join(got, "|") != " one two| three four" { + t.Errorf("Wrap indent = %q", got) + } + // Unknown width: untouched. + if got := Wrap("a long line that would wrap", 0); len(got) != 1 { + t.Errorf("unknown width must not wrap: %q", got) + } + // Wide characters count two cells. + for _, l := range Wrap("日本語 日本語 日本語", 7) { + if Width(l) > 7 { + t.Errorf("line %q is %d cells", l, Width(l)) + } + } +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 7b7aa0b..68e9e40 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -8,6 +8,11 @@ // caller never branches on it and the plain output stays byte-for-byte what it // was. A nil *UI and a nil *Spinner are valid and off. // +// When it is on it adapts to the terminal (Caps): colours are semantic roles +// (Accent, Success, Warn, Danger, Muted) that resolve to the 16-colour palette, +// the 256-colour cube or 24-bit colour; glyphs fall back to ASCII outside a +// UTF-8 locale; links are OSC 8 only where the terminal has them. +// // Callers that print to the same stream while a spinner runs must go through // Fprintf (output.Printer.Info and Warn already do), so the line is not // written into the middle of a frame. @@ -22,15 +27,14 @@ import ( "strings" "sync" "time" - "unicode/utf8" "github.com/mattn/go-isatty" "golang.org/x/term" ) // Live reports whether w should get a spinner and colour: w is a terminal and -// nothing asked for plain output. NO_COLOR (any non-empty value), TERM=dumb -// and CI (anything but empty, 0 or false) all turn it off. +// nothing asked for plain output. NO_COLOR (any non-empty value), CLICOLOR=0, +// TERM=dumb and CI (anything but empty, 0 or false) all turn it off. func Live(w io.Writer) bool { return live(w, os.Getenv, runtime.GOOS, isTTY) } func isTTY(f *os.File) bool { @@ -42,7 +46,7 @@ func live(w io.Writer, getenv func(string) string, goos string, tty func(*os.Fil if !ok || !tty(f) { return false } - if getenv("NO_COLOR") != "" || getenv("TERM") == "dumb" { + if getenv("NO_COLOR") != "" || getenv("CLICOLOR") == "0" || getenv("TERM") == "dumb" { return false } switch strings.ToLower(getenv("CI")) { @@ -57,37 +61,50 @@ func live(w io.Writer, getenv func(string) string, goos string, tty func(*os.Fil // UI draws on one stream. The zero value and nil are off. type UI struct { - w io.Writer - on bool + w io.Writer + on bool + caps Caps } -// New returns a UI for w that is on only when Live(w). -func New(w io.Writer) *UI { return &UI{w: w, on: Live(w)} } +// New returns a UI for w that is on only when Live(w), drawing for whatever the +// terminal in the environment can do (Detect). +func New(w io.Writer) *UI { + u := &UI{w: w, on: Live(w)} + if u.on { + u.caps = Detect() + } + return u +} -// Force returns a UI that is always on, whatever w is. For tests. -func Force(w io.Writer) *UI { return &UI{w: w, on: true} } +// Force returns a UI that is always on, whatever w is, drawing for a plain +// UTF-8 terminal with 16 colours. For tests. +func Force(w io.Writer) *UI { return ForceCaps(w, Caps{Unicode: true}) } + +// ForceCaps is Force for a terminal with the given capabilities. +func ForceCaps(w io.Writer, c Caps) *UI { return &UI{w: w, on: true, caps: c} } // On reports whether this UI draws anything. func (u *UI) On() bool { return u != nil && u.on } -// ---- colour ---------------------------------------------------------------- - -// The palette is the terminal's own 8 colours plus bold and faint, so it -// follows the user's theme and stays readable on light and dark backgrounds. -// No white, black or yellow: those vanish on one of the two. - -func (u *UI) style(code, s string) string { - if !u.On() || s == "" { - return s +// Caps is what the terminal this UI draws on can do. +func (u *UI) Caps() Caps { + if u == nil { + return Caps{} } - return "\x1b[" + code + "m" + s + "\x1b[0m" + return u.caps } -func (u *UI) Bold(s string) string { return u.style("1", s) } -func (u *UI) Dim(s string) string { return u.style("2", s) } -func (u *UI) Red(s string) string { return u.style("31", s) } -func (u *UI) Green(s string) string { return u.style("32", s) } -func (u *UI) Cyan(s string) string { return u.style("36", s) } +// Cols is the width of the terminal in cells, or 0 when it is not known (the +// stream is not a terminal). It is read every call, so it follows a resize. +func (u *UI) Cols() int { + if u == nil { + return 0 + } + if u.caps.Width > 0 { + return u.caps.Width + } + return termCols(u.w) +} // ---- lines ----------------------------------------------------------------- @@ -104,19 +121,20 @@ func (u *UI) Printf(format string, a ...any) { // // ✓ https://example.com 200 · 1 credit · 4.2s // -// parts[0] is the subject (a URL); the rest are the facts, dimmed. ok picks a +// parts[0] is the subject (a URL); the rest are the facts, muted. ok picks a // green ✓ or a red ✗. func (u *UI) Done(ok bool, parts ...string) { if !u.On() || len(parts) == 0 { return } - mark := u.Red("✗") + g := u.Glyphs() + mark := u.Red(g.Bad) if ok { - mark = u.Green("✓") + mark = u.Green(g.OK) } line := mark + " " + parts[0] if len(parts) > 1 { - line += " " + u.Dim(strings.Join(parts[1:], " · ")) + line += " " + u.Dim(strings.Join(parts[1:], " "+g.Dot+" ")) } u.Printf("%s\n", line) } @@ -129,36 +147,85 @@ type Row struct{ Key, Val string } // ✗ ERR::TARGET::BLOCKED: the site refused the request // hint retry with --render // request 01J9Z... (spicrawl logs get 01J9Z...) +// docs https://docs.spicrawl.com/errors/target-blocked // -// Rows with an empty value are skipped. +// The ERR:: code is muted so the sentence leads; the hint is the accent, since +// it is the line to act on; the keys line up; long text wraps under its own +// column instead of under the key; URLs become links where the terminal has +// them. Rows with an empty value are skipped. func (u *UI) Fail(headline string, rows ...Row) { if !u.On() { return } + cols := u.Cols() + if cols > 0 { + cols-- // a line that fills the row exactly can leave a blank one after it on some terminals + } var b strings.Builder - b.WriteString(u.Red("✗") + " " + u.Bold(headline) + "\n") + var code string + if i := strings.Index(headline, ": "); i > 0 && strings.HasPrefix(headline, "ERR::") && !strings.ContainsAny(headline[:i], " \t") { + code, headline = headline[:i+1], headline[i+2:] // "ERR::X::Y:" and the sentence + } + lead := u.Red(u.Glyphs().Bad) + " " + // The first line shares its row with the code; the rest have the full width. + var text []string + for i, para := range strings.Split(headline, "\n") { + if i > 0 { + text = append(text, Wrap(para, cols-2)...) + continue + } + ls := Wrap(para, cols-2-Width(code)-1) + if len(ls) > 1 { + rest := strings.Fields(para)[len(strings.Fields(ls[0])):] + ls = append(ls[:1], Wrap(strings.Join(rest, " "), cols-2)...) + } + text = append(text, ls...) + } + if code != "" { + text[0] = u.Dim(code) + " " + u.Bold(text[0]) + } else { + text[0] = u.Bold(text[0]) + } + for i, l := range text { + if i > 0 { + l = u.Bold(l) + } + b.WriteString(lead + l + "\n") + lead = " " + } pad := 0 for _, r := range rows { - if r.Val != "" && len(r.Key) > pad { - pad = len(r.Key) + if r.Val != "" { + pad = max(pad, Width(r.Key)) } } for _, r := range rows { - if r.Val != "" { - b.WriteString(" " + u.Dim(fmt.Sprintf("%-*s", pad, r.Key)) + " " + r.Val + "\n") + if r.Val == "" { + continue + } + key := r.Key + strings.Repeat(" ", pad-Width(r.Key)) + if r.Key == "hint" { + key = u.Accent(key) + } else { + key = u.Dim(key) + } + for i, l := range Wrap(r.Val, cols-2-pad-2) { + if i > 0 { + key = strings.Repeat(" ", pad) + } + b.WriteString(" " + key + " " + u.Linkify(l) + "\n") } } u.Printf("%s", b.String()) } // Elapsed formats a duration for humans: 840ms is "0.8s", 12.14s is "12.1s", -// 64s is "1m04s". +// 64s is "1m04s", 3900s is "1h05m". func Elapsed(d time.Duration) string { if d < time.Minute { return fmt.Sprintf("%.1fs", d.Seconds()) } - d = d.Round(time.Second) - return fmt.Sprintf("%dm%02ds", int(d/time.Minute), int(d%time.Minute/time.Second)) + return Span(d) } // ---- spinner --------------------------------------------------------------- @@ -170,11 +237,13 @@ var ( Interval = 80 * time.Millisecond ) -var frames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} - // Spinner is one live "⠹ label · 4.2s" line. At most one runs at a time: Spin // while another is active returns nil, so the outermost operation (a whole // batch wait) keeps its label over the calls inside it. +// +// Progress turns it into a determinate bar: "⠹ label ━━━╸── 7/20 · 12.1s · +// ETA 22s". The line is measured against the terminal on every frame, so a +// resize is picked up within one Interval and the label shortens to fit. type Spinner struct { u *UI start time.Time @@ -184,6 +253,8 @@ type Spinner struct { mu sync.Mutex label string frame int + items int // finished, of total; total 0 means no bar + total int drawn bool // something is on the line and the cursor is hidden stopped bool elapsed time.Duration @@ -224,6 +295,18 @@ func (s *Spinner) Label(format string, a ...any) { s.mu.Unlock() } +// Progress shows "done of total" as a bar with a count and an ETA next to the +// label. Call it as often as the count changes; it is cheap. A total of 0 (or +// less) takes the bar away again. +func (s *Spinner) Progress(done, total int) { + if s == nil { + return + } + s.mu.Lock() + s.items, s.total = done, total + s.mu.Unlock() +} + // Around runs fn with the spinner line cleared, then redraws it. Use it for // writes to another stream that shares the terminal (results on stdout). func (s *Spinner) Around(fn func()) { @@ -305,39 +388,41 @@ func (s *Spinner) draw() { if s.stopped { return } - suffix := " · " + Elapsed(time.Since(s.start)) - // The visible line is 2 cells of glyph, the label and the suffix, and must - // stay one short of the width: a line that wraps cannot be cleared by \r. - label := truncate(s.label, width(s.u.w)-1-2-utf8.RuneCountInString(suffix)) + g := s.u.Glyphs() + frame := g.Frames[s.frame%len(g.Frames)] + // The visible line must stay one cell short of the width: a line that + // wraps cannot be cleared by \r. + cols := s.u.Cols() + if cols == 0 { + cols = 80 + } + elapsed := time.Since(s.start) + var line string + if s.total > 0 { + line = s.u.progressLine(frame, s.label, s.items, s.total, elapsed, cols) + } else { + suffix := " " + g.Dot + " " + Elapsed(elapsed) + label := Truncate(s.label, max(cols-1-2-Width(suffix), 4), g.Ellipsis) + line = s.u.Accent(frame) + " " + label + s.u.Dim(suffix) + } var b bytes.Buffer if !s.drawn { b.WriteString("\x1b[?25l") // hide the cursor; Stop shows it again s.drawn = true } - b.WriteString("\r\x1b[K" + s.u.Cyan(frames[s.frame%len(frames)]) + " " + label + s.u.Dim(suffix)) + b.WriteString("\r\x1b[K" + line) s.frame++ s.u.w.Write(b.Bytes()) } -// width is the terminal's column count, 80 when it cannot be read. -func width(w io.Writer) int { +// termCols is the terminal's column count, 0 when w is not a terminal. +func termCols(w io.Writer) int { if f, ok := w.(*os.File); ok { if cols, _, err := term.GetSize(int(f.Fd())); err == nil && cols > 0 { return cols } } - return 80 -} - -// truncate shortens s to n cells with a trailing …. A rune is one cell; wide -// East Asian characters are not special-cased (ponytail: they may overshoot). -func truncate(s string, n int) string { - n = max(n, 4) - if utf8.RuneCountInString(s) <= n { - return s - } - r := []rune(s) - return string(r[:n-1]) + "…" + return 0 } // Fprintf writes to w like fmt.Fprintf, but when a spinner is drawing on w it diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go index d6da4e1..528bf85 100644 --- a/internal/ui/ui_test.go +++ b/internal/ui/ui_test.go @@ -56,6 +56,8 @@ func TestLiveGating(t *testing.T) { {"not a terminal", os.Stderr, env(), "linux", notTTY, false}, {"buffer is never live", &bytes.Buffer{}, env(), "linux", tty, false}, {"NO_COLOR", os.Stderr, env("NO_COLOR", "1"), "linux", tty, false}, + {"CLICOLOR=0", os.Stderr, env("CLICOLOR", "0"), "linux", tty, false}, + {"CLICOLOR=1", os.Stderr, env("CLICOLOR", "1"), "linux", tty, true}, {"empty NO_COLOR is ignored", os.Stderr, env("NO_COLOR", ""), "linux", tty, true}, {"TERM=dumb", os.Stderr, env("TERM", "dumb"), "linux", tty, false}, {"TERM=xterm-256color", os.Stderr, env("TERM", "xterm-256color"), "linux", tty, true}, @@ -193,8 +195,8 @@ func TestDoneAndFail(t *testing.T) { for _, want := range []string{ "\x1b[32m✓\x1b[0m https://example.com \x1b[2m200 · 1 credit · 4.2s\x1b[0m\n", "\x1b[31m✗\x1b[0m https://example.com \x1b[2m503\x1b[0m\n", - "\x1b[31m✗\x1b[0m \x1b[1mERR::TARGET::BLOCKED: refused\x1b[0m\n", - " \x1b[2mhint \x1b[0m use --render\n", + "\x1b[31m✗\x1b[0m \x1b[2mERR::TARGET::BLOCKED:\x1b[0m \x1b[1mrefused\x1b[0m\n", + " \x1b[36mhint \x1b[0m use --render\n", " \x1b[2mrequest\x1b[0m 01J9\n", } { if !strings.Contains(out, want) { @@ -217,10 +219,18 @@ func TestElapsedAndTruncate(t *testing.T) { t.Errorf("Elapsed(%s) = %q, want %q", d, got, want) } } - if got := truncate("https://example.com/a/very/long/path", 12); got != "https://exa…" { - t.Errorf("truncate = %q", got) + if got := Truncate("https://example.com/a/very/long/path", 12, "…"); got != "https://exa…" { + t.Errorf("Truncate = %q", got) } - if got := truncate("short", 12); got != "short" { - t.Errorf("truncate = %q", got) + if got := Truncate("short", 12, "…"); got != "short" { + t.Errorf("Truncate = %q", got) + } + for d, want := range map[time.Duration]string{ + 3900 * time.Second: "1h05m", + 22 * time.Second: "22s", + } { + if got := Span(d); got != want { + t.Errorf("Span(%s) = %q, want %q", d, got, want) + } } } From 48cd12256959d66dd32f5f3e98c292a2fb728a35 Mon Sep 17 00:00:00 2001 From: Dibbayajyoti Date: Tue, 27 Oct 2026 12:27:57 +0530 Subject: [PATCH 2/2] ui: correct the accent contrast ratio in a comment --- internal/ui/style.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/ui/style.go b/internal/ui/style.go index 5e70c5c..d8f1b38 100644 --- a/internal/ui/style.go +++ b/internal/ui/style.go @@ -46,7 +46,7 @@ func (c Caps) sgr(r Role) string { if c.Light { return "38;2;15;118;110" // teal-700 } - return "38;2;13;148;136" // teal-600: 3.7:1 on white, 6:1 on black + return "38;2;13;148;136" // teal-600: 3.7:1 on white, 5.7:1 on black case Color256: return "38;5;30" }