Files
zzet--gortex/internal/progress/tracker.go
T
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

923 lines
24 KiB
Go

package progress
import (
"fmt"
"io"
"strings"
"sync"
"time"
"github.com/charmbracelet/lipgloss"
)
// Tracker is the CLI's live progress surface: a flicker-free animated region
// on a capable terminal, and clean line-oriented text everywhere else (pipes,
// CI, NO_COLOR, TERM=dumb, --no-progress, a Windows console without VT).
//
// The animated region is composed of an optional brand header (the gortex
// mark lighting up with overall progress plus a title and a live status
// line) followed by one row per step — pending, active (spinner, counters,
// progress bar), done (check, final count, duration), failed, or skipped.
// The whole region repaints at a fixed cadence inside terminal synchronized-
// update brackets with every line hard-clamped to the terminal width, so the
// frame arithmetic can never desync — the classic source of spinner ghosting.
//
// A Tracker is safe for concurrent use. Hot loops may call Report thousands
// of times per second: updates only mutate state under the lock, and the
// render goroutine samples that state at the frame rate.
type Tracker struct {
mu sync.Mutex
w io.Writer
st styleSet
animated bool
logo bool
fps time.Duration
title string
status string
steps []*Step
hasExplicit bool
started bool
finished bool
failed bool
finErr error
finHead string
finSummary string
startAt time.Time
stopAt time.Time
tick int
lastLines int
flushQueue []string
stopCh chan struct{}
loopDone chan struct{}
// Test seams.
sizeFn func() (int, int)
now func() time.Time
}
// stepStatus is the lifecycle of a single tracked step.
type stepStatus int
const (
stepPending stepStatus = iota
stepActive
stepDone
stepFailed
stepSkipped
)
// Step is one row of the tracker: a phase of the overall run. Handles are
// returned by AddStep / StartStep and stay valid for the tracker's lifetime.
type Step struct {
t *Tracker
label string
doneLabel string
status stepStatus
cur int64
total int64
unit string
note string
err error
skipNote string
auto bool
started time.Time
stopped time.Time
lastBeat time.Time
}
// TrackerOption customizes a Tracker at construction.
type TrackerOption func(*Tracker)
// WithLogo selects the banner preset: the animated region opens with the
// gortex mark, the title, and a live status line. Flagship multi-phase flows
// (init, install) use it; short single-op commands keep the compact preset.
func WithLogo() TrackerOption {
return func(t *Tracker) { t.logo = true }
}
// WithoutAnimation forces plain line output regardless of terminal
// capabilities — the hook for the global --no-progress flag.
func WithoutAnimation() TrackerOption {
return func(t *Tracker) { t.animated = false }
}
// NewTracker builds a tracker bound to w. Animation is auto-detected from the
// writer and environment (see animationAllowed); pass WithoutAnimation to
// force plain output.
func NewTracker(w io.Writer, opts ...TrackerOption) *Tracker {
t := &Tracker{
w: w,
fps: 80 * time.Millisecond,
now: time.Now,
}
t.sizeFn = func() (int, int) { return termSize(w) }
t.animated = animationAllowed(w)
for _, o := range opts {
o(t)
}
t.st = newStyleSet(w)
return t
}
// Animated reports whether the tracker renders the live animated region.
func (t *Tracker) Animated() bool {
t.mu.Lock()
defer t.mu.Unlock()
return t.animated
}
// disable forces plain mode; effective only before Start.
func (t *Tracker) disable() {
t.mu.Lock()
defer t.mu.Unlock()
if !t.started {
t.animated = false
}
}
// Start opens the surface with the given title and, on animated terminals,
// begins the render loop. Idempotent.
func (t *Tracker) Start(title string) {
t.mu.Lock()
defer t.mu.Unlock()
if t.started {
return
}
t.started = true
t.title = title
t.startAt = t.now()
if !t.animated {
if title != "" {
fmt.Fprintf(t.w, " %s\n", title)
}
return
}
t.stopCh = make(chan struct{})
t.loopDone = make(chan struct{})
_, _ = io.WriteString(t.w, ansiHideCursor)
markCursorHidden(t.w)
t.paintLocked(false)
go t.loop()
}
// SetTitle replaces the header title mid-run.
func (t *Tracker) SetTitle(title string) {
if title == "" {
return
}
t.mu.Lock()
defer t.mu.Unlock()
t.title = title
}
// SetStatus replaces the live status line (banner preset) or the inline
// detail after the title (compact preset). Plain mode records it silently —
// it surfaces on the finish line.
func (t *Tracker) SetStatus(status string) {
t.mu.Lock()
defer t.mu.Unlock()
t.status = status
}
// AddStep appends a pending step row — the design's "planned but not yet
// running" state. Planned steps also make overall progress computable, which
// drives the logo fill.
func (t *Tracker) AddStep(label string) *Step {
t.mu.Lock()
defer t.mu.Unlock()
t.hasExplicit = true
return t.newStepLocked(label, false)
}
// StartStep activates the pending step with the given label, creating it
// first when it was never planned. Returns the step handle.
func (t *Tracker) StartStep(label string) *Step {
t.mu.Lock()
defer t.mu.Unlock()
t.hasExplicit = true
for _, s := range t.steps {
if s.status == stepPending && s.label == label {
t.activateStepLocked(s)
return s
}
}
s := t.newStepLocked(label, false)
t.activateStepLocked(s)
return s
}
func (t *Tracker) newStepLocked(label string, auto bool) *Step {
s := &Step{t: t, label: label, auto: auto}
t.steps = append(t.steps, s)
return s
}
func (t *Tracker) activateStepLocked(s *Step) {
if s.status != stepPending {
return
}
s.status = stepActive
s.started = t.now()
s.lastBeat = s.started
if !t.animated && !t.finished {
fmt.Fprintf(t.w, " %s ...\n", s.label)
}
}
// lastActiveLocked returns the most recently activated still-active step.
func (t *Tracker) lastActiveLocked() *Step {
for i := len(t.steps) - 1; i >= 0; i-- {
if t.steps[i].status == stepActive {
return t.steps[i]
}
}
return nil
}
// Progress updates the step's counters. total may be 0 when unknown.
func (s *Step) Progress(cur, total int64) {
t := s.t
t.mu.Lock()
defer t.mu.Unlock()
s.cur, s.total = cur, total
s.plainBeatLocked()
}
// SetUnit sets the dim unit suffix rendered after the counter ("files",
// "symbols", "edges").
func (s *Step) SetUnit(unit string) {
s.t.mu.Lock()
defer s.t.mu.Unlock()
s.unit = unit
}
// Note sets a dim annotation rendered after the counters — a sub-stage name,
// the language mix, the current file.
func (s *Step) Note(note string) {
s.t.mu.Lock()
defer s.t.mu.Unlock()
s.note = note
}
// Done completes the step, keeping its running label.
func (s *Step) Done() { s.DoneAs("") }
// DoneAs completes the step and swaps the label for its past-tense form
// ("parsing sources" → "symbols extracted"), matching the design language.
func (s *Step) DoneAs(doneLabel string) {
t := s.t
t.mu.Lock()
defer t.mu.Unlock()
t.completeStepLocked(s, doneLabel, nil)
}
// Skip marks a planned step as intentionally not run.
func (s *Step) Skip(reason string) {
t := s.t
t.mu.Lock()
defer t.mu.Unlock()
if s.status == stepDone || s.status == stepFailed || s.status == stepSkipped {
return
}
s.status = stepSkipped
s.skipNote = reason
s.stopped = t.now()
if !t.animated && !t.finished {
note := ""
if reason != "" {
note = ": " + reason
}
fmt.Fprintf(t.w, " %s %s %s skipped%s\n", t.st.g.Pending, s.label, t.st.g.Dash, note)
}
}
// Fail marks the step failed with err.
func (s *Step) Fail(err error) {
t := s.t
t.mu.Lock()
defer t.mu.Unlock()
t.completeStepLocked(s, "", err)
}
func (t *Tracker) completeStepLocked(s *Step, doneLabel string, err error) {
if s.status == stepDone || s.status == stepFailed || s.status == stepSkipped {
return
}
if s.status == stepPending {
s.started = t.now()
}
s.stopped = t.now()
if err != nil {
s.status = stepFailed
s.err = err
if !t.animated && !t.finished {
fmt.Fprintf(t.w, " %s %s: %v\n", t.st.g.Fail, s.label, err)
}
return
}
s.status = stepDone
if doneLabel != "" {
s.doneLabel = doneLabel
}
if !t.animated && !t.finished {
fmt.Fprintf(t.w, " %s %s%s%s\n",
t.st.g.OK, s.finalLabel(), t.plainCounts(s), t.plainDuration(s.stopped.Sub(s.started)))
}
}
func (s *Step) finalLabel() string {
if s.doneLabel != "" {
return s.doneLabel
}
return s.label
}
// plainBeatLocked emits a throttled heartbeat line for a long-running step in
// plain mode, so CI logs show liveness (and where a run hangs) without being
// flooded by per-item ticks.
const plainHeartbeatEvery = 10 * time.Second
func (s *Step) plainBeatLocked() {
t := s.t
if t.animated || t.finished || s.status != stepActive {
return
}
now := t.now()
if now.Sub(s.lastBeat) < plainHeartbeatEvery {
return
}
s.lastBeat = now
fmt.Fprintf(t.w, " %s%s (%s)\n", s.label, t.plainCounts(s), fmtDurationCompact(now.Sub(s.started)))
}
// Report implements Reporter. Without explicit steps, every distinct stage
// label materializes as its own step row — the previous stage completes with
// its final counters and duration, the new one starts spinning. With explicit
// steps (an orchestrator owns the checklist), stage ticks feed the active
// step's note and counters instead.
func (t *Tracker) Report(stage string, current, total int) {
if stage == "" {
return
}
t.mu.Lock()
defer t.mu.Unlock()
if t.finished {
return
}
if t.hasExplicit {
if s := t.lastActiveLocked(); s != nil {
s.note = stage
s.cur, s.total = int64(current), int64(total)
s.plainBeatLocked()
return
}
t.status = stage
return
}
if s := t.lastActiveLocked(); s != nil && s.auto {
if s.label == stage {
s.cur, s.total = int64(current), int64(total)
s.plainBeatLocked()
return
}
t.completeStepLocked(s, "", nil)
}
s := t.newStepLocked(stage, true)
t.activateStepLocked(s)
s.cur, s.total = int64(current), int64(total)
}
// Println writes a permanent line above the live region (or straight through
// in plain mode). Use it for warnings and notes that must survive the
// animation instead of writing to the tracker's writer directly.
func (t *Tracker) Println(a ...any) {
t.Logf("%s", strings.TrimRight(fmt.Sprintln(a...), "\n"))
}
// Logf is Println with formatting.
func (t *Tracker) Logf(format string, a ...any) {
msg := fmt.Sprintf(format, a...)
t.mu.Lock()
defer t.mu.Unlock()
if !t.animated || !t.started || t.finished {
fmt.Fprintln(t.w, msg)
return
}
t.flushQueue = append(t.flushQueue, strings.Split(msg, "\n")...)
t.paintLocked(false)
}
// Done finishes the run successfully. headline defaults to the title
// (compact) or "ready" (banner); summary, when non-empty, is rendered as the
// closing line ("indexed 2,431 files · 48,210 symbols"). Idempotent, and a
// later Fail is ignored.
func (t *Tracker) Done(headline, summary string) { t.finish(headline, summary, nil) }
// Fail finishes the run with an error: the active step (if any) is marked
// failed and the header switches to the failure state. Idempotent.
func (t *Tracker) Fail(err error) { t.finish("", "", err) }
func (t *Tracker) finish(headline, summary string, err error) {
t.mu.Lock()
if t.finished || !t.started {
t.mu.Unlock()
return
}
t.finished = true
t.failed = err != nil
t.finErr = err
t.finHead = headline
t.finSummary = summary
t.stopAt = t.now()
// Close out the still-running step so the final frame has no orphan
// spinner rows: success completes it, failure pins the error on it.
if s := t.lastActiveLocked(); s != nil {
wasAnimated := t.animated
if err != nil {
// Attribute the error to the step row; the header carries it too.
s.status = stepFailed
s.err = err
s.stopped = t.stopAt
} else if s.auto {
s.status = stepDone
s.stopped = t.stopAt
if !wasAnimated {
fmt.Fprintf(t.w, " %s %s%s%s\n",
t.st.g.OK, s.finalLabel(), t.plainCounts(s), t.plainDuration(t.stopAt.Sub(s.started)))
}
} else {
s.status = stepDone
s.stopped = t.stopAt
}
}
if !t.animated {
t.plainFinishLocked()
t.mu.Unlock()
return
}
t.paintLocked(true)
markCursorRestored(t.w)
stop := t.stopCh
done := t.loopDone
t.mu.Unlock()
if stop != nil {
close(stop)
<-done
}
}
func (t *Tracker) plainFinishLocked() {
elapsed := t.plainDuration(t.stopAt.Sub(t.startAt))
if t.failed {
if t.finErr != nil {
fmt.Fprintf(t.w, " %s %s: %v\n", t.st.g.Fail, t.title, t.finErr)
} else {
fmt.Fprintf(t.w, " %s %s\n", t.st.g.Fail, t.title)
}
return
}
head := t.finHead
if head == "" {
head = t.title
}
summary := t.finSummary
if summary == "" {
summary = t.status
}
line := " " + t.st.g.OK + " " + head
if summary != "" {
line += " " + t.st.g.Dash + " " + summary
}
fmt.Fprintln(t.w, line+elapsed)
}
// plainDuration renders " (dur)" for durations worth mentioning; sub-100ms
// operations stay clean (and unit-test output deterministic).
func (t *Tracker) plainDuration(d time.Duration) string {
if d < 100*time.Millisecond {
return ""
}
return " (" + fmtDurationCompact(d) + ")"
}
func (t *Tracker) plainCounts(s *Step) string {
if s.cur <= 0 && (s.status != stepActive || s.total <= 0) {
return ""
}
out := " " + t.st.g.Sep + " " + fmtCount(s.cur)
if s.status == stepActive && s.total > 0 {
out += " / " + fmtCount(s.total)
}
if s.unit != "" {
out += " " + s.unit
}
return out
}
// ---- animated rendering ---------------------------------------------------
func (t *Tracker) loop() {
defer close(t.loopDone)
defer func() {
if r := recover(); r != nil {
_, _ = io.WriteString(t.w, ansiShowCursor+ansiReset)
markCursorRestored(t.w)
panic(r)
}
}()
tk := time.NewTicker(t.fps)
defer tk.Stop()
for {
select {
case <-t.stopCh:
return
case <-tk.C:
t.mu.Lock()
if t.started && !t.finished {
t.tick++
t.paintLocked(false)
}
t.mu.Unlock()
}
}
}
// paintLocked repaints the live region. Protocol invariant: after every
// paint the cursor parks at column 0 on the line just below the region, so
// the next paint reaches the region top with a bare CR + cursor-up. Frames
// are wrapped in synchronized-update brackets and every line is width-clamped
// (a soft-wrapped line would corrupt the cursor arithmetic for all later
// frames — the failure mode this renderer exists to eliminate).
func (t *Tracker) paintLocked(final bool) {
width, height := t.sizeFn()
if width < 20 {
width = 20
}
maxRegion := height - 2
if maxRegion < 4 {
maxRegion = 4
}
lines := t.buildLinesLocked(width)
for len(lines) > maxRegion {
if s := t.oldestFinishedStepLocked(); s != nil {
// Retire the oldest finished row to permanent output above the
// region so the live region always fits the terminal.
t.flushQueue = append(t.flushQueue, t.stepRowLocked(s, width))
t.removeStepLocked(s)
lines = t.buildLinesLocked(width)
continue
}
lines = lines[len(lines)-maxRegion:]
break
}
var b strings.Builder
b.WriteString(ansiSyncStart)
b.WriteString("\r")
b.WriteString(ansiUp(t.lastLines))
if len(t.flushQueue) > 0 {
b.WriteString(ansiClearBelow)
for _, fl := range t.flushQueue {
b.WriteString(clampLine(fl, width-1, t.st.g.Ellipsis))
b.WriteString("\r\n")
}
t.flushQueue = nil
t.lastLines = 0
}
for _, ln := range lines {
b.WriteString(clampLine(ln, width-1, t.st.g.Ellipsis))
b.WriteString(ansiClearEOL)
b.WriteString("\r\n")
}
if len(lines) < t.lastLines {
b.WriteString(ansiClearBelow)
}
t.lastLines = len(lines)
if final {
b.WriteString(ansiShowCursor)
b.WriteString(ansiReset)
}
b.WriteString(ansiSyncEnd)
_, _ = io.WriteString(t.w, b.String())
}
func (t *Tracker) oldestFinishedStepLocked() *Step {
for _, s := range t.steps {
if s.status == stepDone || s.status == stepSkipped || s.status == stepFailed {
return s
}
}
return nil
}
func (t *Tracker) removeStepLocked(target *Step) {
for i, s := range t.steps {
if s == target {
t.steps = append(t.steps[:i], t.steps[i+1:]...)
return
}
}
}
func (t *Tracker) buildLinesLocked(width int) []string {
var lines []string
if t.logo {
frac := t.logoFracLocked()
logo := logoLines(t.st.logoLit, t.st.logoDim, t.st.accent, t.st.g, frac, t.tick, -1)
title := ""
if t.title != "" {
title = " " + t.st.title.Render(t.title)
}
lines = append(lines,
" "+logo[0],
" "+logo[1]+title,
" "+logo[2]+" "+t.statusRowLocked(),
" "+logo[3],
" "+logo[4],
)
if len(t.steps) > 0 {
lines = append(lines, "")
}
} else {
lines = append(lines, t.compactHeaderLocked())
}
for _, s := range t.steps {
lines = append(lines, t.stepRowLocked(s, width))
}
if t.logo && t.finished && !t.failed && t.finSummary != "" {
lines = append(lines, "", " "+t.st.accent.Render(t.st.g.OK)+" "+t.st.dim.Render(t.finSummary))
}
if t.logo && t.finished && t.failed && t.finErr != nil && t.lastFailedStepLocked() == nil {
lines = append(lines, "", " "+t.st.err.Render(t.st.g.Fail+" "+t.finErr.Error()))
}
return lines
}
func (t *Tracker) lastFailedStepLocked() *Step {
for i := len(t.steps) - 1; i >= 0; i-- {
if t.steps[i].status == stepFailed {
return t.steps[i]
}
}
return nil
}
// logoFracLocked derives the logo fill fraction. Planned checklists light the
// mark progressively (done steps plus the active step's own fraction);
// unplanned auto-step runs use the indeterminate marquee until they finish.
func (t *Tracker) logoFracLocked() float64 {
if t.finished && !t.failed {
return 1
}
if !t.hasExplicit || len(t.steps) == 0 {
if t.finished {
return 0 // failed with no checklist: dim mark, the ✗ carries the news
}
return -1
}
var done, active float64
for _, s := range t.steps {
switch s.status {
case stepDone, stepSkipped, stepFailed:
done++
case stepActive:
if s.total > 0 {
f := float64(s.cur) / float64(s.total)
if f > 1 {
f = 1
}
if f > active {
active = f
}
}
}
}
return (done + active) / float64(len(t.steps))
}
func (t *Tracker) elapsedLocked() string {
end := t.now()
if t.finished {
end = t.stopAt
}
return fmtDurationCompact(end.Sub(t.startAt))
}
func (t *Tracker) statusRowLocked() string {
elapsed := t.st.dimmer.Render(" " + t.st.g.Sep + " " + t.elapsedLocked())
switch {
case t.finished && t.failed:
return t.st.err.Render(t.st.g.Fail+" failed") + elapsed
case t.finished:
head := t.finHead
if head == "" || head == t.title {
head = "ready"
}
return t.st.accent.Render(t.st.g.StatusReady) + " " + t.st.accentBold.Render(head) + elapsed
default:
text := t.status
if text == "" {
text = t.defaultStatusLocked()
}
spin := t.st.spin[t.tick%len(t.st.spin)]
return t.st.accentDim.Render(spin) + " " + t.st.dim.Render(text) + elapsed
}
}
// defaultStatusLocked derives a status when the caller set none: the phase
// position for a planned checklist ("phase 3 / 5"), the active stage label
// for reporter-driven runs, "working" as the last resort.
func (t *Tracker) defaultStatusLocked() string {
if t.hasExplicit && len(t.steps) > 0 {
done := 0
anyActive := false
for _, s := range t.steps {
switch s.status {
case stepDone, stepSkipped, stepFailed:
done++
case stepActive:
anyActive = true
}
}
cur := done
if anyActive && cur < len(t.steps) {
cur++
}
if cur == 0 {
cur = 1
}
return fmt.Sprintf("phase %d / %d", cur, len(t.steps))
}
if s := t.lastActiveLocked(); s != nil {
return s.label
}
return "working"
}
func (t *Tracker) compactHeaderLocked() string {
elapsed := t.st.dimmer.Render(" " + t.st.g.Sep + " " + t.elapsedLocked())
switch {
case t.finished && t.failed:
line := " " + t.st.err.Render(t.st.g.Fail) + " " + t.st.title.Render(t.title)
if t.finErr != nil {
line += t.st.err.Render(": " + t.finErr.Error())
}
return line + elapsed
case t.finished:
head := t.finHead
if head == "" {
head = t.title
}
line := " " + t.st.accent.Render(t.st.g.OK) + " " + t.st.title.Render(head)
summary := t.finSummary
if summary == "" {
summary = t.status
}
if summary != "" {
line += t.st.dim.Render(" " + t.st.g.Dash + " " + summary)
}
return line + elapsed
default:
mark := t.st.accentDim.Render(t.st.spin[t.tick%len(t.st.spin)])
if len(t.steps) > 0 {
mark = t.st.dimmer.Render(t.st.g.StatusBusy)
}
line := " " + mark + " " + t.st.title.Render(t.title)
if t.status != "" {
line += t.st.dim.Render(" " + t.st.g.Dash + " " + t.status)
}
return line + elapsed
}
}
// barCells is the progress bar width, matching the design's 22-cell bar.
const barCells = 22
func (t *Tracker) stepRowLocked(s *Step, width int) string {
switch s.status {
case stepPending:
return " " + t.st.dimmer.Render(t.st.g.Pending) + " " + t.st.dimmer.Render(s.label)
case stepSkipped:
note := ""
if s.skipNote != "" {
note = ": " + s.skipNote
}
return " " + t.st.dimmer.Render(t.st.g.Pending) + " " +
t.st.dimmer.Render(s.label+" "+t.st.g.Dash+" skipped"+note)
case stepFailed:
msg := ""
if s.err != nil {
msg = t.st.err.Render(" " + t.st.g.Dash + " " + s.err.Error())
}
return " " + t.st.err.Render(t.st.g.Fail) + " " + t.st.fg.Render(s.label) + msg
case stepDone:
row := " " + t.st.accent.Render(t.st.g.OK) + " " + t.st.fg.Render(s.finalLabel()) + t.countsLocked(s, true)
if d := s.stopped.Sub(s.started); d >= 100*time.Millisecond {
row += t.st.dimmer.Render(" (" + fmtDurationCompact(d) + ")")
}
return row
default: // active
spin := t.st.spin[t.tick%len(t.st.spin)]
base := " " + t.st.accentDim.Render(spin) + " " + t.st.fg.Render(s.label) + t.countsLocked(s, false)
note := ""
if s.note != "" {
note = t.st.dimmer.Render(" " + s.note)
}
bar := ""
if s.total > 0 {
bar = t.barLocked(s)
}
// Fit by priority instead of mid-bar truncation: full row, then drop
// the note, then drop the bar. The label and counters always stay.
for _, row := range []string{base + note + bar, base + bar, base + note} {
if visibleWidth(row) <= width-1 {
return row
}
}
return base
}
}
func (t *Tracker) countsLocked(s *Step, done bool) string {
if s.cur <= 0 && (done || s.total <= 0) {
return ""
}
out := t.st.dim.Render(" "+t.st.g.Sep+" ") + t.st.fg.Render(fmtCount(s.cur))
if !done && s.total > 0 {
out += t.st.dim.Render(" / " + fmtCount(s.total))
}
if s.unit != "" {
out += t.st.dim.Render(" " + s.unit)
}
return out
}
func (t *Tracker) barLocked(s *Step) string {
frac := float64(s.cur) / float64(s.total)
if frac < 0 {
frac = 0
}
if frac > 1 {
frac = 1
}
fill := int(frac*barCells + 0.5)
pct := int(frac*100 + 0.5)
return " " + t.st.accent.Render(strings.Repeat(t.st.g.BarFull, fill)) +
t.st.dimmer.Render(strings.Repeat(t.st.g.BarEmpty, barCells-fill)) +
t.st.dim.Render(fmt.Sprintf(" %3d%%", pct))
}
// ---- styles ---------------------------------------------------------------
// styleSet bundles the glyphs, spinner frames, and writer-bound styles one
// tracker instance renders with. Styles are bound to the tracker's own writer
// (not the process stdout) so `gortex … | tee log` keeps colors on the
// animated stderr region.
type styleSet struct {
g glyphSet
spin []string
fg lipgloss.Style
dim lipgloss.Style
dimmer lipgloss.Style
accent lipgloss.Style
accentBold lipgloss.Style
accentDim lipgloss.Style
err lipgloss.Style
title lipgloss.Style
logoLit lipgloss.Style
logoDim lipgloss.Style
}
func newStyleSet(w io.Writer) styleSet {
r := lipgloss.NewRenderer(w)
r.SetColorProfile(colorProfileFor(w))
mk := func(c lipgloss.Color) lipgloss.Style { return r.NewStyle().Foreground(c) }
return styleSet{
g: activeGlyphs(),
spin: spinFrames(),
fg: mk(colFg),
dim: mk(colFgDim),
dimmer: mk(colMuted),
accent: mk(colAccent),
accentBold: r.NewStyle().Foreground(colAccent).Bold(true),
accentDim: mk(colAccentDim),
err: mk(colErr),
title: r.NewStyle().Foreground(colFg).Bold(true),
logoLit: mk(colPerim),
logoDim: mk(colInner),
}
}