package ui
import (
"fmt"
"github.com/charmbracelet/lipgloss"
"math"
"sort"
"strings"
"time"
"github.com/charmbracelet/x/ansi"
"updater-tui/internal/plan"
)
// feedEntry is one package that has finished, as it appears in the live feed.
// overlayKind says what the full-screen overlay is showing. One field, one
// meaning: a bool could only say "the log or not", which stopped being true
// once the finished list wanted the same scrolling and search.
type overlayKind int
const (
overlayNone overlayKind = iota
overlayLog
overlayFinished
overlayMessage
)
type feedEntry struct {
name string
version string
elapsed float64
state string
shared bool
//: why it failed, when it did
note string
//: what this package is worth on the progress line; 1 unless it builds
weight int
}
// BuildView is the live state of one apply run.
//
// It is fed by the backend's event stream and owns no logic of its own: every
// number here came from a measurement on the other side of the pipe.
type BuildView struct {
Phases []string
Phase string
Command string
Total int
Done int
Running []plan.BuildState
Feed []feedEntry
Downloads int64
Rate float64
Started time.Time
Elapsed float64
Finished bool
Return int
Failed []string
Message string
//: things worth saying, each with the detail behind it. Kept as a list of
//: their own rather than bare strings so one can be opened, and dismissed
//: for the session once it has been read.
Messages []BuildMessage
//: cursor over Messages, so a warning can be reached and dealt with
Cursor int
//: everything the running command has printed, in order
Log []string
LogOpen bool
//: the path of the log being followed, so the whole of it can be opened
LogPath string
//: a modal opened over the dashboard: either the build's output in full or
//: one warning. The same modal the drill-down uses, so scrolling and search
//: behave the way they already do elsewhere.
Output *textModal
//: whether Output is the live log. Only the log is refreshed as the build
//: runs; refreshing a warning replaced its text with the build's output.
OutputKind overlayKind
//: the run's total in work, and what each building package is worth
Weight int
Weights map[string]int
//: advances on every tick, so the display can move
Frame int
//: a command that can finish what the unattended run could not, because
//: the question it hit needs a person rather than a default
Recovery *Recovery
Dashboard bool
}
// BuildMessage is something the run wants to say.
type BuildMessage struct {
Text string
Details []string
}
// NewBuildView starts a live view for the phases the backend announced.
func NewBuildView(phases []string, total int) *BuildView {
return &BuildView{
Phases: phases, Total: total, Started: time.Now(), Dashboard: true,
}
}
// Apply folds one protocol event into the view.
func (b *BuildView) Apply(event plan.Event) {
switch event.Event {
case "start":
b.Phases = event.Phases
b.Weights = event.Weights
if event.Total > 0 {
b.Total = event.Total
}
if event.Weight > 0 {
b.Weight = event.Weight
}
case "phase_start":
b.Phase = event.Phase
b.Command = event.Command
if event.Log != "" {
b.LogPath = event.Log
}
// The phase's own count is deliberately not taken as the total.
// `start` gives the run's total, and the finished list accumulates
// across phases -- so a phase total smaller than what has already
// finished made the bar read full while a whole phase was still to
// come, and left nothing in "waiting".
if b.Total == 0 && event.Total > 0 {
b.Total = event.Total
}
case "build":
b.Running = event.Packages
b.Done = event.Done
b.Downloads = event.DownloadBytes
b.Rate = event.DownloadRate
b.absorbLog(event.LogLines)
b.Frame++
case "finished":
// the feed is append-only and ordered: newest lands at the bottom, which
// is what makes it readable as a record rather than a status
b.Feed = append(b.Feed, feedEntry{
name: event.Name, version: event.Version, elapsed: event.Elapsed,
state: event.State, shared: event.Shared,
weight: b.weightOf(event.Name),
})
case "failed":
b.markFailed(event.Name, event.Elapsed, event.FailureReason)
case "skipped":
b.markSkipped(event.Name)
case "phase_end":
b.Elapsed = event.Elapsed
case "error":
b.AddMessage(event.Message, event.Details...)
case "recovery":
b.Recovery = &Recovery{
Label: event.RecoveryLabel, Command: event.RecoveryCommand,
Note: event.RecoveryNote,
}
case "done":
b.Finished = true
b.Return = event.Returncode
b.Failed = event.Failed
if event.Elapsed > 0 {
b.Elapsed = event.Elapsed
}
}
// One place refreshes an open overlay. It used to hang off the build
// event alone, so a list showing finished packages was a tick behind
// whatever had just finished.
b.refreshOutput()
}
// markFailed flips a package the watcher had already filed as finished.
//
// The interface watches processes, so it sees a makepkg exit and has no way to
// read the exit code -- paru's own result is the only thing that knows. This
// updates the entry that already exists rather than appending a second one,
// which would make done+failed exceed the total and leave the counter lying.
func (b *BuildView) markFailed(name string, elapsed float64, note string) {
if name == "" {
return
}
for i := range b.Running {
if b.Running[i].Name == name {
b.Running = append(b.Running[:i], b.Running[i+1:]...)
break
}
}
for i := range b.Feed {
if b.Feed[i].name == name {
b.Feed[i].state = "fail"
b.Feed[i].note = note
if elapsed > 0 {
b.Feed[i].elapsed = elapsed
}
return
}
}
b.Feed = append(b.Feed, feedEntry{name: name, state: "fail",
elapsed: elapsed, note: note, weight: b.weightOf(name)})
}
// weightOf is what a package is worth on the progress line.
//
// One unless the backend said otherwise, which it does for the packages that
// actually build. Counting a minute-long compile and a one-second download
// alike is what made the bar empty itself during the repository phase.
func (b *BuildView) weightOf(name string) int {
if w, ok := b.Weights[name]; ok && w > 0 {
return w
}
return 1
}
// resolvedWeight is how much of the run's work is settled, one way or another.
//
// A failure counts: the package is not going to be built, so the run has moved
// past it. Whether it went well is the colour's job, not the bar's -- an empty
// bar on a run where everything failed would say the run had not started.
func (b *BuildView) resolvedWeight() int {
n := 0
for _, entry := range b.Feed {
n += entry.weight
}
return n
}
// markSkipped records that the reader asked for this one to be left.
//
// Not a failure: nothing went wrong, and it is not something to offer back.
// The entry stays in the list so the run still accounts for it, with a mark of
// its own.
func (b *BuildView) markSkipped(name string) {
if name == "" {
return
}
for i := range b.Running {
if b.Running[i].Name == name {
b.Running = append(b.Running[:i], b.Running[i+1:]...)
break
}
}
for i := range b.Feed {
if b.Feed[i].name == name {
b.Feed[i].state = "skip"
return
}
}
b.Feed = append(b.Feed, feedEntry{name: name, state: "skip",
weight: b.weightOf(name)})
}
// CanSkip is whether there is a package in progress to leave behind. Only the
// AUR phase has one: the repository and flatpak phases are single transactions,
// and a package cannot be lifted out of one without unpicking what the
// database has already been told.
func (b *BuildView) CanSkip() bool {
return b.Phase == "aur" && len(b.Running) > 0
}
// Recovery is a way out of a run that stopped on a question only a person can
// answer -- a package conflict pacman must be asked about, or a build that
// wants input. It runs in the real terminal, where prompts work.
type Recovery struct {
Label string
Command []string
Note string
}
// maxLog is how much of a build's output is kept. Past this the beginning is
// dropped: a build that prints more than this is not one anybody reads back
// line by line, and the full log is on disk.
const maxLog = 4000
// absorbLog appends the lines the command has printed since the last tick.
//
// The interface keeps the whole stream rather than a window of it, so the
// output can be opened and read back, not just glanced at.
func (b *BuildView) absorbLog(lines []string) {
for _, line := range lines {
// collapse the repeated blank-ish chatter a build emits
if line == "" && len(b.Log) > 0 && b.Log[len(b.Log)-1] == "" {
continue
}
b.Log = append(b.Log, line)
}
if len(b.Log) > maxLog {
b.Log = b.Log[len(b.Log)-maxLog:]
}
}
// OpenOutput fills the screen with the build's output.
func (b *BuildView) OpenOutput() {
if b.Output != nil {
b.Output, b.OutputKind = nil, overlayNone
return
}
if len(b.Log) == 0 {
return
}
title := "output"
if b.Command != "" {
title = b.Command
}
modal := newText(title)
modal.logMode = true
// the log is the one view opened to see the end of it
modal.follow = true
modal.Update(textBodyMsg{title: title, lines: b.Log, foot: b.LogPath})
b.Output = modal
b.OutputKind = overlayLog
}
// OpenFinished fills the screen with everything that has finished.
//
// The dashboard can only ever show the tail of the list, and "… N earlier" is
// not a thing a reader can look at. The same overlay the log uses gives the
// whole run with scrolling and search, and follows the end while it grows.
func (b *BuildView) OpenFinished() {
if b.Output != nil {
b.Output, b.OutputKind = nil, overlayNone
return
}
if len(b.Feed) == 0 {
return
}
done, failed := b.counts()
title := fmt.Sprintf("finished · %d done", done)
if failed > 0 {
title += fmt.Sprintf(", %d failed", failed)
}
modal := newText(title)
modal.follow = true
modal.Update(textBodyMsg{title: title, lines: b.finishedLines(),
foot: b.phaseLine()})
b.Output = modal
b.OutputKind = overlayFinished
}
// finishedLines is the whole feed, oldest first, in the same shape the
// dashboard uses -- minus the terminal padding, which the overlay supplies.
func (b *BuildView) finishedLines() []string {
out := make([]string, 0, len(b.Feed))
for _, entry := range b.Feed {
// Laid out left to right, not spread to a fixed column: the overlay
// decides its own width, and a right-aligned trailing field landed
// past that width and was truncated to an ellipsis.
mark, style := "+", Incoming
switch entry.state {
case "fail":
mark, style = "x", Outgoing
case "skip":
mark, style = "-", Warn
}
line := " " + style.Render(mark) + " " + Text.Render(entry.name)
if entry.version != "" {
line += " " + Ver.Render(entry.version)
}
if entry.elapsed > 0 {
line += " " + Muted.Render(seconds(entry.elapsed))
}
if entry.shared {
line += " " + Muted.Render("↻ shared")
}
if entry.state == "skip" {
line += " " + Muted.Render("skipped at your request")
}
if entry.state == "fail" && entry.note != "" {
line += " " + Muted.Render(entry.note)
}
out = append(out, line)
}
if len(out) == 0 {
out = append(out, Muted.Render("nothing finished yet"))
}
return out
}
// OpenMessage shows the selected warning in full.
//
// The dashboard has one line per warning so the list stays readable, which
// means anything longer is cut off with an ellipsis. This is where the rest
// of it lives.
func (b *BuildView) OpenMessage() *textModal {
if b.Cursor < 0 || b.Cursor >= len(b.Messages) {
return nil
}
message := b.Messages[b.Cursor]
lines := wrapText(message.Text, 76)
if len(message.Details) > 0 {
lines = append(lines, "")
lines = append(lines, message.Details...)
}
title := shortTitle(message.Text)
modal := newText(title)
modal.Update(textBodyMsg{title: title, lines: lines})
return modal
}
// wrapText folds prose to a width so a long sentence can actually be read.
// The line renderers truncate rather than wrap, which is right for a log and
// wrong for a paragraph.
func wrapText(text string, width int) []string {
words := strings.Fields(text)
if len(words) == 0 {
return nil
}
var out []string
line := words[0]
for _, word := range words[1:] {
if len(line)+1+len(word) > width {
out = append(out, line)
line = word
continue
}
line += " " + word
}
return append(out, line)
}
// shortTitle is the headline of a message, for the modal's title bar.
//
// A message is a sentence with its explanation attached after a dash or a
// colon. Using all of it as the title repeats the first line of the body,
// which is what the body is for.
func shortTitle(text string) string {
text = firstLine(text)
// Stop at the first separator that fits: "pkg: failed (exit 1) — error: …"
// is about the package failing, so "pkg: failed (exit 1)" is the headline.
// Applying every separator in turn reduced it to the package name alone.
for _, sep := range []string{" — ", ": "} {
if i := strings.LastIndex(text, sep); i > 12 {
text = text[:i]
break
}
}
if len(text) > 60 {
text = text[:59] + "…"
}
return text
}
func firstLine(text string) string {
if i := strings.IndexByte(text, '\n'); i >= 0 {
return text[:i]
}
return text
}
// refreshOutput keeps an open view of the output current as more arrives.
//
// Only the log view: a warning is a fixed piece of text, and refreshing it
// replaced what the reader had opened with the build's output instead.
func (b *BuildView) refreshOutput() {
if b.Output == nil {
return
}
if b.OutputKind != overlayLog && b.OutputKind != overlayFinished {
return
}
// hold the reader's place: new lines land at the bottom, and the scroll
// position is measured from the top
lines, foot := b.Log, b.LogPath
if b.OutputKind == overlayFinished {
lines, foot = b.finishedLines(), b.phaseLine()
}
b.Output.Update(textBodyMsg{title: b.Output.title, lines: lines, foot: foot})
}
// tailLog is the last few lines, for the inline panel.
func (b *BuildView) tailLog() []string {
if len(b.Log) <= 8 {
return b.Log
}
return b.Log[len(b.Log)-8:]
}
// AddProtocolError records a line the reader could not parse.
func (b *BuildView) AddProtocolError(err error) {
b.AddMessage(err.Error())
}
// AddMessage records something worth saying, once.
//
// The same line arrives more than once in practice -- a signal reaches the
// backend and its child, each reports the interrupt -- and two identical
// warnings stacked on top of each other is how the bottom of the screen
// became a wall of text nobody reads.
func (b *BuildView) AddMessage(text string, details ...string) {
if text == "" {
return
}
for _, existing := range b.Messages {
if existing.Text == text {
return
}
}
if len(b.Messages) >= 8 {
// older ones scroll off the top; dismissal is how the list shortens
b.Messages = b.Messages[1:]
if b.Cursor > 0 {
b.Cursor--
}
}
b.Messages = append(b.Messages, BuildMessage{Text: text, Details: details})
// the cursor follows the newest, so the bar marks what just happened
// rather than the oldest warning on screen
b.Cursor = len(b.Messages) - 1
}
// Dismiss removes the message under the cursor, for this session only.
//
// A warning that cannot be got rid of is one people learn to read past, and
// these sit on the same lines as the progress they are covering.
func (b *BuildView) Dismiss() {
if b.Cursor < 0 || b.Cursor >= len(b.Messages) {
return
}
b.Messages = append(b.Messages[:b.Cursor], b.Messages[b.Cursor+1:]...)
if b.Cursor >= len(b.Messages) {
b.Cursor = max(0, len(b.Messages)-1)
}
}
// MoveCursor walks the messages.
func (b *BuildView) MoveCursor(step int) {
if len(b.Messages) == 0 {
b.Cursor = 0
return
}
b.Cursor += step
if b.Cursor < 0 {
b.Cursor = 0
}
if b.Cursor >= len(b.Messages) {
b.Cursor = len(b.Messages) - 1
}
}
// clock is how long the run has been going.
//
// The wall clock is the primary source because it ticks continuously. The
// backend reports an elapsed figure only when a phase ends, so that figure
// alone would freeze the display at whatever the last phase took. It still wins
// when it is larger, which covers attaching to a run already in progress.
func (b *BuildView) clock() float64 {
return math.Max(time.Since(b.Started).Seconds(), b.Elapsed)
}
func seconds(v float64) string {
total := int(v)
switch {
case total < 60:
return fmt.Sprintf("%ds", total)
case total < 3600:
return fmt.Sprintf("%dm%02ds", total/60, total%60)
default:
return fmt.Sprintf("%dh%02dm", total/3600, (total%3600)/60)
}
}
func bytes(n int64) string {
if n <= 0 {
return "—"
}
units := []string{"B", "KiB", "MiB", "GiB", "TiB"}
v := float64(n)
i := 0
for v >= 1024 && i < len(units)-1 {
v /= 1024
i++
}
if i == 0 {
return fmt.Sprintf("%d B", n)
}
return fmt.Sprintf("%.1f %s", v, units[i])
}
// View draws the live run. `dashboard` off leaves only the feed, which is the
// same toggle the rest of the tool uses.
func (b *BuildView) View(width int, hideHints bool, height int) string {
if b.Output != nil {
return b.Output.View(width, height)
}
if !b.Dashboard {
return b.renderFeedOnly(width, height)
}
// Blocks are rendered first and then trimmed, because predicting their
// heights meant accounting for every placeholder, blank and cursor shift
// by hand -- and getting it wrong by one line pushed the controls off the
// bottom, which is exactly the thing this is here to prevent.
head := []string{
spread(Accent.Render("updater"), Muted.Render(b.phaseLine()), width),
b.progressLine(width),
}
done := []string{Title.Render("DONE"), ""}
switch {
case len(b.Feed) == 0:
done = append(done, " "+Muted.Render("nothing finished yet"))
default:
done = append(done, b.feedLines(width)...)
}
building := []string{"", Title.Render("BUILDING"), ""}
if len(b.Running) == 0 {
building = append(building, " "+Muted.Render("nothing building"))
}
for _, state := range b.Running {
building = append(building, b.runningLine(state, width))
}
messages := b.messageLines(width)
output := []string{}
if b.LogOpen {
output = append(output, "", Title.Render("OUTPUT"))
if len(b.Log) == 0 {
output = append(output, " "+Muted.Render("nothing yet"))
}
for _, line := range b.tailLog() {
output = append(output, " "+Muted.Render(
ansi.Truncate(line, max(8, width-6), "…")))
}
}
// Split, not a single element: footer() returns five lines as one string,
// and a budget that counts elements rather than lines comes up four short
// -- which is exactly how the controls ended up off the bottom.
tail := append([]string{""}, strings.Split(b.footer(width), "\n")...)
if !hideHints {
tail = append(tail, "", b.hintLine(width))
}
if b.Recovery != nil {
hint := b.Recovery.Note
if hint == "" {
hint = strings.Join(b.Recovery.Command, " ")
}
leader := Accent.Render("R") + " "
room := max(4, width-ansi.StringWidth(leader)-3)
tail = append([]string{
leader + Text.Render(ansi.Truncate(b.Recovery.Label, room, "…")) +
" " + Muted.Render(ansi.Truncate(hint, room, "…")),
}, tail...)
}
if height <= 0 {
return clipLines(strings.Join(
concat(head, done, building, output, messages, tail), "\n"), height)
}
// Problems do not get to crowd out the work they are about. Left alone,
// half a dozen warnings filled the screen and no finished package was
// visible at all; dismissing them is how the list gets shorter.
fixed := len(head) + len(building) + len(output) + len(tail)
if cap := max(0, (height-fixed)/2); len(messages) > cap {
messages = messages[len(messages)-cap:]
// cut at a message boundary so a warning does not start mid-sentence
for len(messages) > 0 && messages[0] != "" {
messages = messages[1:]
}
}
// Everything except the finished list is fixed. What is left goes to the
// feed, newest first, and whatever does not fit is named rather than
// silently dropped.
room := height - len(head) - len(building) - len(messages) - len(output) - len(tail)
if room < len(done) {
// not even the "DONE" heading fits; drop entries until it does
keep := max(0, room-3) // heading, its blank, and room for the note
if keep == 0 {
done = nil
} else {
entries := done[2:]
if len(entries) > keep {
dropped := len(entries) - keep
done = append(done[:2],
" "+Muted.Render(fmt.Sprintf("… %d earlier", dropped)))
done = append(done, entries[dropped:]...)
}
}
}
trim:
for len(head)+len(done)+len(building)+len(output)+len(messages)+len(tail) > height {
// shed whatever is least useful, oldest first
switch {
case len(done) > 2:
done = append(done[:2], done[3:]...)
case len(output) > 0:
output = output[:len(output)-1]
case len(messages) > 0:
messages = messages[1:]
case len(building) > 3:
building = building[:len(building)-1]
default:
// Nothing left to give: the controls stay, and the backstop below
// trims whatever is still over. This is a labelled break because
// an unlabelled one breaks the switch, not the loop -- which is a
// spin, not a fix.
break trim
}
}
return clipLines(strings.Join(
concat(head, done, building, output, messages, tail), "\n"), height)
}
// messageLines renders the warnings, with the selected one opened out.
func (b *BuildView) messageLines(width int) []string {
out := make([]string, 0, len(b.Messages)*2)
for i, message := range b.Messages {
if i == b.Cursor {
// The same treatment the list gives a selected row: a filled bar,
// not just a different glyph. A marker alone was too quiet to tell
// apart from the warning's own styling.
out = append(out, "",
Selected.Render(fit(ansi.Strip("▸ "+message.Text), width)))
for _, line := range message.Details {
out = append(out, " "+Muted.Render(
ansi.Truncate(line, width-6, "…")))
}
continue
}
out = append(out, "", Warn.Render("! "+
ansi.Truncate(message.Text, width-4, "…")))
}
return out
}
// feedLines renders the finished list, oldest first.
func (b *BuildView) feedLines(width int) []string {
out := make([]string, 0, len(b.Feed))
for _, entry := range b.Feed {
out = append(out, b.feedLine(entry, width))
}
return out
}
func concat(parts ...[]string) []string {
n := 0
for _, part := range parts {
n += len(part)
}
out := make([]string, 0, n)
for _, part := range parts {
out = append(out, part...)
}
return out
}
func (b *BuildView) phaseLine() string {
if b.Finished {
status := "done"
if b.Return != 0 {
status = fmt.Sprintf("exited %d", b.Return)
}
return fmt.Sprintf("%s · %s", status, seconds(b.clock()))
}
label := b.Phase
if label == "" && len(b.Phases) > 0 {
label = b.Phases[0]
}
where := ""
for i, phase := range b.Phases {
if phase == label {
where = fmt.Sprintf("%d/%d", i+1, len(b.Phases))
}
}
// The count lives on the progress line below; repeating it here was the
// third place the same two numbers appeared.
if where == "" {
return seconds(b.clock())
}
return fmt.Sprintf("%s %s · %s", label, where, seconds(b.clock()))
}
func (b *BuildView) feedLine(entry feedEntry, width int) string {
mark, style := "+", Incoming
switch entry.state {
case "fail":
mark, style = "x", Outgoing
case "skip":
mark, style = "-", Warn
}
left := " " + style.Render(mark) + " " + Text.Render(entry.name)
if entry.version != "" {
left += " " + Ver.Render(entry.version)
}
if entry.shared {
left += " " + Muted.Render("↻ shared")
}
return spread(left, Muted.Render(seconds(entry.elapsed)), width)
}
// minEstimateProgress is how far a run must have got before an estimate means
// anything. The arithmetic divides by progress, so near the start it is both
// unstable and enormous.
const minEstimateProgress = 0.05
// spinner cycles with the frame counter, which the build events advance.
var buildSpinner = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
// pulse returns the indicator for this frame. A build that takes minutes and
// looks identical every second is indistinguishable from one that has hung.
func (b *BuildView) pulse() string {
return Accent.Render(buildSpinner[b.Frame%len(buildSpinner)])
}
func (b *BuildView) runningLine(state plan.BuildState, width int) string {
left := " " + b.pulse() + " " + Text.Render(state.Name)
if state.Phase != "" {
left += " " + Muted.Render(state.Phase)
}
if state.Workers != "" {
left += " " + Muted.Render(state.Workers)
} else if state.Jobs > 1 {
left += " " + Muted.Render(fmt.Sprintf("%d jobs", state.Jobs))
}
return spread(left, Muted.Render(seconds(state.Elapsed)), width)
}
// fit pads or truncates to an exact visible width, so columns line up whatever
// the content.
func fit(s string, width int) string {
w := ansi.StringWidth(s)
if w == width {
return s
}
if w < width {
return s + strings.Repeat(" ", width-w)
}
return ansi.Truncate(s, width, "…")
}
// counts is the single answer to "how many are done" and "how many failed".
//
// One source, counted the same way everywhere: the header must agree with the
// list beneath it, and two tallies that can disagree eventually will.
func (b *BuildView) counts() (done, failed int) {
for _, entry := range b.Feed {
if entry.state == "fail" {
failed++
} else {
done++
}
}
return done, failed
}
func (b *BuildView) footer(width int) string {
done, failed := b.counts()
waiting := max(0, b.Total-done-failed-len(b.Running))
failStyle := Muted
if failed > 0 {
failStyle = Err
}
// One row of counts, leaving the progress line as the only thing that says
// how far the run has got.
parts := []string{
Accent.Render(fmt.Sprintf("▶ %d running", len(b.Running))),
OK.Render(fmt.Sprintf("✓ %d done", done)),
failStyle.Render(fmt.Sprintf("✗ %d failed", failed)),
Muted.Render(fmt.Sprintf("· %d waiting", waiting)),
}
line := strings.Join(parts, Desc.Render(" "))
// Downloads are only known for the repository phase; paru does not report
// what it fetches, so there is nothing to say during the AUR one.
if b.Downloads > 0 {
detail := bytes(b.Downloads)
if rate := rateLabel(b.Rate); rate != "—" {
detail += " " + rate
}
return Title.Render("BUILDS") + "\n" +
spread(line, Muted.Render(detail), width)
}
// The one row with no spread, so it must be fitted to the width itself.
return Title.Render("BUILDS") + "\n" +
ansi.Truncate(line, width, "…")
}
// progressLine is the bar, drawn across the whole width.
//
// The full width is what makes movement legible; the footer stays a table of
// counts rather than a second place to read the same numbers.
func (b *BuildView) progressLine(width int) string {
done, failed := b.counts()
// Two totals, because they answer different questions: the footer counts
// packages, and the bar measures work. A run of forty one-second downloads
// and one four-minute build is nearly over when the downloads are done.
// The package count is the fallback: a run whose weights were never sent,
// or a stream being replayed, still has to show something sensible.
total := max(b.Weight, b.Total, b.resolvedWeight())
worked := min(b.resolvedWeight(), total)
// Where the run stands, in the fewest words that say it.
status := fmt.Sprintf("%d/%d", worked, total)
if failed > 0 {
status += fmt.Sprintf(" · %d failed", failed)
}
if left, ok := b.eta(); ok {
status += " · ~" + seconds(left) + " left"
}
if b.Finished {
status = fmt.Sprintf("%d/%d · %s", done+failed, total,
map[bool]string{true: "complete", false: "stopped"}[failed == 0])
if failed > 0 {
status = fmt.Sprintf("%d/%d · %d failed", done+failed, total, failed)
}
}
// nothing has succeeded: the bar should not read as progress
style := Accent
if failed > 0 && done == 0 {
style = Err
}
info := Muted.Render(status)
room := width - ansi.StringWidth(info) - 3
if room < 10 {
return fit(info, width)
}
return styleBarBar(worked, total, room, style) + " " + info
}
// styleBarBar draws the filled part in the given style.
func styleBarBar(done, total, width int, style lipgloss.Style) string {
if total <= 0 {
return Muted.Render(strings.Repeat("─", width))
}
filled := min(width, max(0, done*width/total))
if filled == width {
return style.Render(strings.Repeat("━", width))
}
return style.Render(strings.Repeat("━", filled)) +
Rule.Render(strings.Repeat("─", width-filled))
}
// progressBar draws completed work against the whole.
func progressBar(done, total, width int) string {
if width <= 0 {
return ""
}
if total <= 0 {
return Muted.Render(strings.Repeat("░", width))
}
filled := done * width / total
filled = min(width, max(0, filled))
if filled == width {
return OK.Render(strings.Repeat("█", width))
}
return OK.Render(strings.Repeat("█", filled)) +
Muted.Render(strings.Repeat("░", width-filled))
}
// eta estimates what is left from the average of what has finished.
//
// Only offered once two packages are done: one sample is not an average, and
// showing an estimate derived from it would be inventing a number.
// eta is what is left, from how long the run has taken against how far it has
// got.
//
// Linear: half way in the same time again, a quarter of the way in three times
// as long. It uses everything the run has shown rather than a subset, so it is
// available from the first moments and corrects itself as the run goes -- a
// mean over finished packages could not say anything until several had.
//
// It inherits the weight model: "how far it has got" is measured in work, so
// the estimate is only as good as the idea that the remaining work is
// proportional to the remaining weight.
func (b *BuildView) eta() (float64, bool) {
worked := b.resolvedWeight()
total := max(b.Weight, b.Total, worked)
if worked <= 0 || total <= 0 {
return 0, false
}
elapsed := b.clock()
if elapsed <= 0 {
return 0, false
}
progress := float64(worked) / float64(total)
if progress < minEstimateProgress {
// a couple of per cent of a run says nothing about the other
// ninety-eight, and the arithmetic divides by it
return 0, false
}
return elapsed * (1 - progress) / progress, true
}
func rateLabel(rate float64) string {
if rate <= 0 {
return "—"
}
return bytes(int64(rate)) + "/s"
}
func (b *BuildView) hintLine(width int) string {
// Only what applies right now, and only the few that matter. Everything
// else is behind ?, because a bar of eight bindings is one nobody reads --
// the same reason the main list keeps its own help behind a key.
type hint struct {
key, desc string
weight int
}
all := []hint{}
if b.Finished {
all = append(all, hint{"q", "quit", 100}, hint{"esc", "back to the list", 95})
if len(b.Messages) == 0 {
all = append(all, hint{"enter", "back to the list", 90})
}
} else {
all = append(all, hint{"ctrl+c", "abort", 100})
if b.CanSkip() {
all = append(all, hint{"s", "skip this package", 96})
}
}
if b.Recovery != nil {
all = append(all, hint{"R", b.Recovery.Label, 98})
}
if len(b.Messages) > 0 {
all = append(all,
hint{"enter", "read", 92},
hint{"j/k", "warnings", 70},
hint{"d", "dismiss", 65})
}
all = append(all,
hint{"o", "output", 60},
hint{"D", "finished", 58})
// last, and never the one dropped
all = append(all, hint{"?", "help", 1})
// Bounded twice, because either bound alone gets it wrong. A ceiling keeps
// the bar from growing with the terminal -- on a wide screen it reached
// nine bindings simply because there was room. Fitting to the width keeps
// the important keys on a narrow one, where a fixed ceiling pushed them
// past the edge and the tail was cut instead.
const ceiling = 6
order := make([]int, len(all))
for i := range order {
order[i] = i
}
sort.SliceStable(order, func(a, c int) bool {
return all[order[a]].weight > all[order[c]].weight
})
sep := Desc.Render(" · ")
part := func(h hint) string {
return Key.Render(h.key) + " " + Desc.Render(h.desc)
}
// The help key is never dropped, so its room is reserved first: fitting
// the others and appending it anyway overflowed a narrow terminal, and
// truncating instead cut the one hint that explains the rest.
helpIndex := len(all) - 1
help := part(all[helpIndex])
budget := width - ansi.StringWidth(help) - ansi.StringWidth(sep)
keep := map[int]bool{helpIndex: true}
used := 0
for i, index := range order {
if i >= ceiling || index == helpIndex {
continue
}
cost := ansi.StringWidth(part(all[index]))
if used > 0 {
cost += ansi.StringWidth(sep)
}
if used+cost > budget {
continue
}
keep[index] = true
used += cost
}
parts := make([]string, 0, len(keep))
for i, h := range all {
if keep[i] {
parts = append(parts, part(h))
}
}
return strings.Join(parts, sep)
}
// renderFeedOnly is the ledger form: finished packages and nothing else, which
// is what you want when you would rather watch a log than a dashboard.
func (b *BuildView) renderFeedOnly(width, height int) string {
lines := []string{}
for _, entry := range b.Feed {
mark, style := "+", Incoming
if entry.state == "fail" {
mark, style = "x", Outgoing
}
line := style.Render(mark) + " " + Text.Render(entry.name)
if entry.version != "" {
line += " " + Ver.Render(entry.version)
}
if entry.elapsed > 0 {
line += " " + Muted.Render(seconds(entry.elapsed))
}
lines = append(lines, ansi.Truncate(line, width, "…"))
}
for _, state := range b.Running {
line := Accent.Render("▶ ") + Text.Render(state.Name) + " " +
Muted.Render(state.Phase)
lines = append(lines, spread(line, Muted.Render(seconds(state.Elapsed)), width))
}
if len(lines) == 0 {
lines = append(lines, Muted.Render("waiting…"))
}
// Show the end: a few thousand finished packages is a megabyte a frame at
// five frames a second when every entry is rendered.
if height > 0 && len(lines) > height {
lines = lines[len(lines)-height:]
}
return strings.Join(lines, "\n")
}