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") }