diff --git a/README.md b/README.md index 61c0a7b..b05608a 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ fmt.Printf("%q\n", strings.TrimSuffix(output.String(), "\n")) - Bordered tables by default, plus one compact form, fixed widths, alignment, wrapping, and ASCII fallbacks. - Line-oriented questions, defaults, confirmation, numbered choices, and non-echoed secret input. - Concurrency-safe loaders and determinate progress that become stable semantic lines when redirected. +- Optional terminal-owned progress indicators for terminal tabs, windows, and taskbars that support OSC 9;4. - Configurable writers, input, marks, terminal hooks, environment lookup, and exit behavior. ## Design principles @@ -156,6 +157,8 @@ fmt.Print(output.String()) On a terminal a loader animates in place, while progress uses one adaptive bar with a percentage. Narrow terminals fall back to message and percentage. With automatic policy, redirected output and conventional truthy `CI` environments use only a start line and the final success or error, so captured logs stay useful. `AnimationsEnabled` can explicitly opt a terminal back into animation. +Set `TerminalProgressEnabled` to `true` to mirror loader and progress lifecycles through the OSC 9;4 terminal progress protocol. Loaders publish indeterminate activity, determinate progress publishes its current percentage, and every terminal outcome clears the indicator. Supporting terminal emulators decide whether that appears in a tab, window edge, or operating-system taskbar; unsupported terminals ignore the sequence. The option is independent of `AnimationsEnabled`, still requires terminal output with ANSI support, and never writes control sequences to redirected output. + Representative terminal snapshots are shown below; each display redraws one physical line rather than appending these frames. The last line is the exact Unicode rendering at a width of 14 cells. ```text diff --git a/console.go b/console.go index bf690bd..a32a10e 100644 --- a/console.go +++ b/console.go @@ -49,6 +49,9 @@ type Config struct { UnicodeEnabled *bool // AnimationsEnabled permits or disables transient loader and progress output. Even when true, stdout must be a terminal. AnimationsEnabled *bool + // TerminalProgressEnabled permits or disables OSC 9;4 terminal-owned progress indicators for loaders and progress displays. + // Nil and false disable indicators; true still requires terminal output with ANSI support. + TerminalProgressEnabled *bool // Width fixes the available output width. Values less than one use terminal detection and then an 80-column fallback. // Configured, detected, and environment widths are capped at 32,768 columns to keep layout allocations practical. @@ -156,11 +159,12 @@ type Console struct { stderr io.Writer stderrSharesStdout bool - colorEnabled *bool - debugEnabled *bool - interactiveEnabled *bool - unicodeEnabled bool - animationsEnabled *bool + colorEnabled *bool + debugEnabled *bool + interactiveEnabled *bool + unicodeEnabled bool + animationsEnabled *bool + terminalProgressEnabled *bool width int loaderInterval time.Duration @@ -174,13 +178,15 @@ type Console struct { readSecret func() (string, error) newTicker func(time.Duration) loaderTicker - inputMu sync.Mutex - sessionMu sync.RWMutex - outputMu sync.Mutex - transientMu sync.Mutex - active transientOwner - partialLine bool - promptActive bool + inputMu sync.Mutex + sessionMu sync.RWMutex + outputMu sync.Mutex + transientMu sync.Mutex + active transientOwner + terminalProgressMu sync.Mutex + terminalProgressOwner terminalProgressOwner + partialLine bool + promptActive bool } var defaultState = struct { @@ -259,26 +265,27 @@ func New(config Config) *Console { } return &Console{ - stdin: bufio.NewReader(stdin), - stdinSource: stdin, - stdout: stdout, - stderr: stderr, - stderrSharesStdout: sameWriter(stdout, stderr), - colorEnabled: cloneBool(config.ColorEnabled), - debugEnabled: cloneBool(config.DebugEnabled), - interactiveEnabled: cloneBool(config.InteractiveEnabled), - unicodeEnabled: unicodeEnabled, - animationsEnabled: cloneBool(config.AnimationsEnabled), - width: config.Width, - loaderInterval: loaderInterval, - marks: marks, - getenv: getenv, - isTerminal: isTerminal, - supportsANSI: supportsANSI, - getSize: getSize, - exit: exit, - readSecret: readSecret, - newTicker: newRealLoaderTicker, + stdin: bufio.NewReader(stdin), + stdinSource: stdin, + stdout: stdout, + stderr: stderr, + stderrSharesStdout: sameWriter(stdout, stderr), + colorEnabled: cloneBool(config.ColorEnabled), + debugEnabled: cloneBool(config.DebugEnabled), + interactiveEnabled: cloneBool(config.InteractiveEnabled), + unicodeEnabled: unicodeEnabled, + animationsEnabled: cloneBool(config.AnimationsEnabled), + terminalProgressEnabled: cloneBool(config.TerminalProgressEnabled), + width: config.Width, + loaderInterval: loaderInterval, + marks: marks, + getenv: getenv, + isTerminal: isTerminal, + supportsANSI: supportsANSI, + getSize: getSize, + exit: exit, + readSecret: readSecret, + newTicker: newRealLoaderTicker, } } diff --git a/console_test.go b/console_test.go index ac001d0..4852b93 100644 --- a/console_test.go +++ b/console_test.go @@ -102,6 +102,7 @@ func TestNewCopiesMutableConfiguration(t *testing.T) { interactiveEnabled := true unicodeEnabled := true animationsEnabled := true + terminalProgressEnabled := true marks := Marks{ Action: "action", Info: "info", @@ -115,16 +116,17 @@ func TestNewCopiesMutableConfiguration(t *testing.T) { } stdout := &descriptorBuffer{descriptor: 11} console := New(Config{ - Stdout: stdout, - ColorEnabled: &colorEnabled, - DebugEnabled: &debugEnabled, - InteractiveEnabled: &interactiveEnabled, - UnicodeEnabled: &unicodeEnabled, - AnimationsEnabled: &animationsEnabled, - LoaderInterval: 17 * time.Millisecond, - Marks: &marks, - Getenv: getenvFrom(nil), - IsTerminal: func(int) bool { return true }, + Stdout: stdout, + ColorEnabled: &colorEnabled, + DebugEnabled: &debugEnabled, + InteractiveEnabled: &interactiveEnabled, + UnicodeEnabled: &unicodeEnabled, + AnimationsEnabled: &animationsEnabled, + TerminalProgressEnabled: &terminalProgressEnabled, + LoaderInterval: 17 * time.Millisecond, + Marks: &marks, + Getenv: getenvFrom(nil), + IsTerminal: func(int) bool { return true }, }) colorEnabled = false @@ -132,6 +134,7 @@ func TestNewCopiesMutableConfiguration(t *testing.T) { interactiveEnabled = false unicodeEnabled = false animationsEnabled = false + terminalProgressEnabled = false marks.Action = "changed" marks.SpinnerFrames[0] = "changed" @@ -150,6 +153,9 @@ func TestNewCopiesMutableConfiguration(t *testing.T) { if !console.shouldAnimate() { t.Fatal("shouldAnimate() = false after caller mutation, want true") } + if !console.shouldRenderTerminalProgress() { + t.Fatal("shouldRenderTerminalProgress() = false after caller mutation, want true") + } if got := console.loaderInterval; got != 17*time.Millisecond { t.Fatalf("loaderInterval = %s, want %s", got, 17*time.Millisecond) } diff --git a/loader.go b/loader.go index 3f66515..ca3a86d 100644 --- a/loader.go +++ b/loader.go @@ -2,6 +2,7 @@ package console import ( "sync" + "sync/atomic" "time" ) @@ -29,13 +30,14 @@ import ( type Loader struct { console *Console - mu sync.Mutex - message string - state loaderState - dynamic bool - frame int - stop chan struct{} - done chan struct{} + mu sync.Mutex + message string + state loaderState + dynamic bool + frame int + stop chan struct{} + done chan struct{} + terminalDone atomic.Bool } // loaderState identifies the one-way loader lifecycle. @@ -138,6 +140,7 @@ func (l *Loader) Start() error { go l.animate(l.stop, l.done) l.mu.Unlock() l.console.renderTransient(l) + l.console.setTerminalProgress(l, terminalProgressStateIndeterminate, 0) return nil } @@ -145,6 +148,7 @@ func (l *Loader) Start() error { message := l.message l.console.Action(message) l.mu.Unlock() + l.console.setTerminalProgress(l, terminalProgressStateIndeterminate, 0) return nil } @@ -294,6 +298,7 @@ func (l *Loader) finish(outcome loaderFinish, message string) { message = normalizeTransientMessage(message) } l.state = loaderFinished + l.terminalDone.Store(true) l.mu.Unlock() if dynamic { @@ -301,6 +306,7 @@ func (l *Loader) finish(outcome loaderFinish, message string) { <-done l.console.releaseTransient(l, outcome != loaderFinishStop) } + l.console.clearTerminalProgress(l) switch outcome { case loaderFinishSuccess: @@ -312,6 +318,11 @@ func (l *Loader) finish(outcome loaderFinish, message string) { } } +// terminalProgressFinished lets the console reject loader updates that lost a race with completion. +func (l *Loader) terminalProgressFinished() bool { + return l.terminalDone.Load() +} + // animate advances frames until the winning terminal operation closes stop. func (l *Loader) animate(stop <-chan struct{}, done chan<- struct{}) { ticker := l.console.newTicker(l.console.loaderInterval) diff --git a/progress.go b/progress.go index a043653..387acb3 100644 --- a/progress.go +++ b/progress.go @@ -6,6 +6,7 @@ import ( "math/bits" "strings" "sync" + "sync/atomic" ) // errInvalidProgressTotal reports a total that cannot represent determinate progress. @@ -36,12 +37,13 @@ var errInvalidProgressTotal = errors.New("console: progress total must be greate type Progress struct { console *Console - mu sync.Mutex - message string - total int - current int - state progressState - dynamic bool + mu sync.Mutex + message string + total int + current int + state progressState + dynamic bool + terminalDone atomic.Bool } // progressState identifies the one-way progress lifecycle. @@ -117,6 +119,7 @@ func (p *Progress) Start() error { } p.dynamic = p.console.shouldAnimate() + percent := progressPercent(p.current, p.total) if p.dynamic { if err := p.console.acquireTransient(p); err != nil { p.mu.Unlock() @@ -125,6 +128,7 @@ func (p *Progress) Start() error { p.state = progressRunning p.mu.Unlock() p.console.renderTransient(p) + p.console.setTerminalProgress(p, terminalProgressStateDeterminate, percent) return nil } @@ -132,6 +136,7 @@ func (p *Progress) Start() error { message := p.message p.console.Action(message) p.mu.Unlock() + p.console.setTerminalProgress(p, terminalProgressStateDeterminate, percent) return nil } @@ -161,11 +166,17 @@ func (p *Progress) Set(current int) { return } p.current = clampProgressValue(current, p.total) - dynamic := p.state == progressRunning && p.dynamic + running := p.state == progressRunning + dynamic := running && p.dynamic + current = p.current + total := p.total p.mu.Unlock() if dynamic { p.console.renderTransient(p) } + if running { + p.console.setTerminalProgress(p, terminalProgressStateDeterminate, progressPercent(current, total)) + } } // Add changes the completed amount by delta and clamps it between zero and the total. @@ -204,11 +215,17 @@ func (p *Progress) Add(delta int) { } else { p.current += delta } - dynamic := p.state == progressRunning && p.dynamic + running := p.state == progressRunning + dynamic := running && p.dynamic + current := p.current + total := p.total p.mu.Unlock() if dynamic { p.console.renderTransient(p) } + if running { + p.console.setTerminalProgress(p, terminalProgressStateDeterminate, progressPercent(current, total)) + } } // Step replaces the completed amount and message in one atomic progress update. @@ -239,11 +256,17 @@ func (p *Progress) Step(current int, message string) { } p.current = clampProgressValue(current, p.total) p.message = normalizeTransientMessage(message) - dynamic := p.state == progressRunning && p.dynamic + running := p.state == progressRunning + dynamic := running && p.dynamic + current = p.current + total := p.total p.mu.Unlock() if dynamic { p.console.renderTransient(p) } + if running { + p.console.setTerminalProgress(p, terminalProgressStateDeterminate, progressPercent(current, total)) + } } // Update changes the progress message and immediately redraws a live terminal display. @@ -370,11 +393,13 @@ func (p *Progress) finish(outcome progressFinish, message string) { message = normalizeTransientMessage(message) } p.state = progressFinished + p.terminalDone.Store(true) p.mu.Unlock() if dynamic { p.console.releaseTransient(p, outcome != progressFinishStop) } + p.console.clearTerminalProgress(p) switch outcome { case progressFinishComplete: @@ -384,6 +409,11 @@ func (p *Progress) finish(outcome progressFinish, message string) { } } +// terminalProgressFinished lets the console reject progress updates that lost a race with completion. +func (p *Progress) terminalProgressFinished() bool { + return p.terminalDone.Load() +} + // renderTransient snapshots one carriage-return frame while the console owns output coordination. func (p *Progress) renderTransient() string { p.mu.Lock() diff --git a/terminal_progress.go b/terminal_progress.go new file mode 100644 index 0000000..a8a055e --- /dev/null +++ b/terminal_progress.go @@ -0,0 +1,67 @@ +package console + +import ( + "fmt" +) + +const ( + terminalProgressStateClear = 0 + terminalProgressStateDeterminate = 1 + terminalProgressStateIndeterminate = 3 +) + +// terminalProgressOwner prevents a completed concurrent display from restoring stale terminal state. +type terminalProgressOwner interface { + transientOwner + terminalProgressFinished() bool +} + +// shouldRenderTerminalProgress keeps terminal-owned indicators out of redirected output and automation logs. +func (c *Console) shouldRenderTerminalProgress() bool { + if c.terminalProgressEnabled == nil || !*c.terminalProgressEnabled { + return false + } + descriptor, ok := writerDescriptor(c.stdout) + if !ok || !c.isTerminal(descriptor) || !c.supportsANSI(descriptor) { + return false + } + return true +} + +// setTerminalProgress gives one live display ownership of the terminal's singular progress indicator. +func (c *Console) setTerminalProgress(owner terminalProgressOwner, state, progress int) { + if !c.shouldRenderTerminalProgress() { + return + } + c.terminalProgressMu.Lock() + defer c.terminalProgressMu.Unlock() + if owner.terminalProgressFinished() { + return + } + if c.terminalProgressOwner != nil && c.terminalProgressOwner != owner { + return + } + c.terminalProgressOwner = owner + c.outputMu.Lock() + _, _ = writeConsoleString(c.stdout, terminalProgressSequence(state, progress)) + c.outputMu.Unlock() +} + +// clearTerminalProgress prevents a completed owner from disturbing a newer terminal progress display. +func (c *Console) clearTerminalProgress(owner terminalProgressOwner) { + c.terminalProgressMu.Lock() + defer c.terminalProgressMu.Unlock() + if c.terminalProgressOwner != owner { + return + } + c.outputMu.Lock() + _, _ = writeConsoleString(c.stdout, terminalProgressSequence(terminalProgressStateClear, 0)) + c.outputMu.Unlock() + c.terminalProgressOwner = nil +} + +// terminalProgressSequence encodes the OSC 9;4 protocol understood by supporting terminal emulators. +func terminalProgressSequence(state, progress int) string { + progress = max(min(progress, 100), 0) + return fmt.Sprintf("\x1b]9;4;%d;%d\x07", state, progress) +} diff --git a/terminal_progress_test.go b/terminal_progress_test.go new file mode 100644 index 0000000..d1cf459 --- /dev/null +++ b/terminal_progress_test.go @@ -0,0 +1,167 @@ +package console + +import ( + "bytes" + "strings" + "testing" +) + +// newTerminalProgressTestConsole creates a static ASCII console whose terminal capabilities are deterministic. +func newTerminalProgressTestConsole(enabled *bool, terminal bool) (*Console, *loaderTestWriter, *loaderTestWriter) { + animations := false + color := false + unicode := false + stdout := newLoaderTestWriter(1) + stderr := newLoaderTestWriter(2) + commandConsole := New(Config{ + Stdout: stdout, + Stderr: stderr, + AnimationsEnabled: &animations, + ColorEnabled: &color, + UnicodeEnabled: &unicode, + TerminalProgressEnabled: enabled, + IsTerminal: func(int) bool { return terminal }, + }) + return commandConsole, stdout, stderr +} + +func TestLoaderPublishesIndeterminateTerminalProgress(t *testing.T) { + enabled := true + commandConsole, stdout, stderr := newTerminalProgressTestConsole(&enabled, true) + loader := commandConsole.Loader("Loading project") + + if err := loader.Start(); err != nil { + t.Fatalf("Start() error = %v", err) + } + loader.Success("Project ready") + loader.Fail("ignored") + + want := "- Loading project\n" + + terminalProgressSequence(terminalProgressStateIndeterminate, 0) + + terminalProgressSequence(terminalProgressStateClear, 0) + + "+ Project ready\n" + if got := stdout.String(); got != want { + t.Fatalf("loader output = %q, want %q", got, want) + } + if got := stderr.String(); got != "" { + t.Fatalf("stderr = %q, want empty", got) + } +} + +func TestProgressPublishesDeterminateTerminalProgress(t *testing.T) { + enabled := true + commandConsole, stdout, _ := newTerminalProgressTestConsole(&enabled, true) + progress := commandConsole.Progress(4, "Copying files") + progress.Set(1) + + if err := progress.Start(); err != nil { + t.Fatalf("Start() error = %v", err) + } + progress.Add(1) + progress.Step(3, "Installing files") + progress.Complete("Files installed") + progress.Set(1) + + want := "- Copying files\n" + + terminalProgressSequence(terminalProgressStateDeterminate, 25) + + terminalProgressSequence(terminalProgressStateDeterminate, 50) + + terminalProgressSequence(terminalProgressStateDeterminate, 75) + + terminalProgressSequence(terminalProgressStateClear, 0) + + "+ Files installed\n" + if got := stdout.String(); got != want { + t.Fatalf("progress output = %q, want %q", got, want) + } +} + +func TestTerminalProgressRequiresExplicitTerminalCapability(t *testing.T) { + tests := []struct { + name string + enabled *bool + terminal bool + }{ + {name: "unset", terminal: true}, + {name: "disabled", enabled: boolPointer(false), terminal: true}, + {name: "redirected", enabled: boolPointer(true)}, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + commandConsole, stdout, _ := newTerminalProgressTestConsole(test.enabled, test.terminal) + loader := commandConsole.Loader("work") + if err := loader.Start(); err != nil { + t.Fatalf("Start() error = %v", err) + } + loader.Stop() + if strings.Contains(stdout.String(), "\x1b]9;4;") { + t.Fatalf("output contains terminal progress: %q", stdout.String()) + } + }) + } + + t.Run("writer without descriptor", func(t *testing.T) { + enabled := true + output := &bytes.Buffer{} + commandConsole := New(Config{ + Stdout: output, + TerminalProgressEnabled: &enabled, + IsTerminal: func(int) bool { return true }, + }) + loader := commandConsole.Loader("work") + if err := loader.Start(); err != nil { + t.Fatalf("Start() error = %v", err) + } + loader.Stop() + if strings.Contains(output.String(), "\x1b]9;4;") { + t.Fatalf("output contains terminal progress: %q", output.String()) + } + }) + + t.Run("terminal without ANSI support", func(t *testing.T) { + enabled := true + commandConsole, stdout, _ := newTerminalProgressTestConsole(&enabled, true) + commandConsole.supportsANSI = func(int) bool { return false } + loader := commandConsole.Loader("work") + if err := loader.Start(); err != nil { + t.Fatalf("Start() error = %v", err) + } + loader.Stop() + if strings.Contains(stdout.String(), "\x1b]9;4;") { + t.Fatalf("output contains terminal progress: %q", stdout.String()) + } + }) +} + +func TestTerminalProgressOwnershipPreventsCrossLifecycleClears(t *testing.T) { + enabled := true + commandConsole, stdout, _ := newTerminalProgressTestConsole(&enabled, true) + first := commandConsole.Loader("first") + second := commandConsole.Loader("second") + + if err := first.Start(); err != nil { + t.Fatalf("first Start() error = %v", err) + } + if err := second.Start(); err != nil { + t.Fatalf("second Start() error = %v", err) + } + second.Stop() + first.Stop() + commandConsole.setTerminalProgress(first, terminalProgressStateIndeterminate, 0) + + got := stdout.String() + if count := strings.Count(got, terminalProgressSequence(terminalProgressStateIndeterminate, 0)); count != 1 { + t.Fatalf("indeterminate sequence count = %d, want 1: %q", count, got) + } + if count := strings.Count(got, terminalProgressSequence(terminalProgressStateClear, 0)); count != 1 { + t.Fatalf("clear sequence count = %d, want 1: %q", count, got) + } +} + +func TestTerminalProgressSequenceClampsPercent(t *testing.T) { + if got, want := terminalProgressSequence(terminalProgressStateDeterminate, -1), "\x1b]9;4;1;0\x07"; got != want { + t.Fatalf("negative sequence = %q, want %q", got, want) + } + if got, want := terminalProgressSequence(terminalProgressStateDeterminate, 101), "\x1b]9;4;1;100\x07"; got != want { + t.Fatalf("overflow sequence = %q, want %q", got, want) + } +}