Skip to content

Commit 0974167

Browse files
committed
Fixed progress UI to adapt to terminal width
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
1 parent ef14cfc commit 0974167

2 files changed

Lines changed: 660 additions & 73 deletions

File tree

cmd/display/tty.go

Lines changed: 236 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"fmt"
2222
"io"
2323
"iter"
24+
"slices"
2425
"strings"
2526
"sync"
2627
"time"
@@ -258,13 +259,39 @@ func (w *ttyWriter) childrenTasks(parent string) iter.Seq[*task] {
258259
}
259260
}
260261

262+
// lineData holds pre-computed formatting for a task line
263+
type lineData struct {
264+
spinner string // rendered spinner with color
265+
prefix string // dry-run prefix if any
266+
taskID string // possibly abbreviated
267+
progress string // progress bar and size info
268+
status string // rendered status with color
269+
details string // possibly abbreviated
270+
timer string // rendered timer with color
271+
statusPad int // padding before status to align
272+
timerPad int // padding before timer to align
273+
statusColor colorFunc
274+
}
275+
261276
func (w *ttyWriter) print() {
277+
terminalWidth := goterm.Width()
278+
terminalHeight := goterm.Height()
279+
if terminalWidth <= 0 {
280+
terminalWidth = 80
281+
}
282+
if terminalHeight <= 0 {
283+
terminalHeight = 24
284+
}
285+
w.printWithDimensions(terminalWidth, terminalHeight)
286+
}
287+
288+
func (w *ttyWriter) printWithDimensions(terminalWidth, terminalHeight int) {
262289
w.mtx.Lock()
263290
defer w.mtx.Unlock()
264291
if len(w.tasks) == 0 {
265292
return
266293
}
267-
terminalWidth := goterm.Width()
294+
268295
up := w.numLines + 1
269296
if !w.repeated {
270297
up--
@@ -283,48 +310,197 @@ func (w *ttyWriter) print() {
283310
firstLine := fmt.Sprintf("[+] %s %d/%d", w.operation, numDone(w.tasks), len(w.tasks))
284311
_, _ = fmt.Fprintln(w.out, firstLine)
285312

286-
var statusPadding int
287-
for _, t := range w.tasks {
288-
l := len(t.ID)
289-
if len(t.parents) == 0 && statusPadding < l {
290-
statusPadding = l
313+
// Collect parent tasks in original order
314+
allTasks := slices.Collect(w.parentTasks())
315+
316+
// Available lines: terminal height - 2 (header line + potential "more" line)
317+
maxLines := terminalHeight - 2
318+
if maxLines < 1 {
319+
maxLines = 1
320+
}
321+
322+
showMore := len(allTasks) > maxLines
323+
tasksToShow := allTasks
324+
if showMore {
325+
tasksToShow = allTasks[:maxLines-1] // Reserve one line for "more" message
326+
}
327+
328+
// collect line data and compute timerLen
329+
lines := make([]lineData, len(tasksToShow))
330+
var timerLen int
331+
for i, t := range tasksToShow {
332+
lines[i] = w.prepareLineData(t)
333+
if len(lines[i].timer) > timerLen {
334+
timerLen = len(lines[i].timer)
291335
}
292336
}
293337

294-
skipChildEvents := len(w.tasks) > goterm.Height()-2
338+
// shorten details/taskID to fit terminal width
339+
w.adjustLineWidth(lines, timerLen, terminalWidth)
340+
341+
// compute padding
342+
w.applyPadding(lines, terminalWidth, timerLen)
343+
344+
// Render lines
295345
numLines := 0
296-
for t := range w.parentTasks() {
297-
line := w.lineText(t, "", terminalWidth, statusPadding, w.dryRun)
298-
_, _ = fmt.Fprint(w.out, line)
346+
for _, l := range lines {
347+
_, _ = fmt.Fprint(w.out, lineText(l))
299348
numLines++
300-
if skipChildEvents {
301-
continue
302-
}
303-
for child := range w.childrenTasks(t.ID) {
304-
line := w.lineText(child, " ", terminalWidth, statusPadding-2, w.dryRun)
305-
_, _ = fmt.Fprint(w.out, line)
306-
numLines++
349+
}
350+
351+
if showMore {
352+
moreCount := len(allTasks) - len(tasksToShow)
353+
moreText := fmt.Sprintf(" ... %d more", moreCount)
354+
pad := terminalWidth - len(moreText)
355+
if pad < 0 {
356+
pad = 0
307357
}
358+
_, _ = fmt.Fprintf(w.out, "%s%s\n", moreText, strings.Repeat(" ", pad))
359+
numLines++
308360
}
361+
362+
// Clear any remaining lines from previous render
309363
for i := numLines; i < w.numLines; i++ {
310-
if numLines < goterm.Height()-2 {
311-
_, _ = fmt.Fprintln(w.out, strings.Repeat(" ", terminalWidth))
312-
numLines++
313-
}
364+
_, _ = fmt.Fprintln(w.out, strings.Repeat(" ", terminalWidth))
365+
numLines++
314366
}
315367
w.numLines = numLines
316368
}
317369

318-
func (w *ttyWriter) lineText(t *task, pad string, terminalWidth, statusPadding int, dryRun bool) string {
370+
func (w *ttyWriter) applyPadding(lines []lineData, terminalWidth int, timerLen int) {
371+
var maxBeforeStatus int
372+
for i := range lines {
373+
l := &lines[i]
374+
// Width before status: space(1) + spinner(1) + prefix + space(1) + taskID + [progress] + space(1)
375+
beforeStatus := 4 + lenAnsi(l.prefix) + len(l.taskID) + lenAnsi(l.progress)
376+
if beforeStatus > maxBeforeStatus {
377+
maxBeforeStatus = beforeStatus
378+
}
379+
}
380+
381+
for i, l := range lines {
382+
// Position before statusPad: space(1) + spinner(1) + prefix + space(1) + taskID + progress + space(1)
383+
beforeStatus := 4 + lenAnsi(l.prefix) + len(l.taskID) + lenAnsi(l.progress)
384+
// statusPad aligns status; lineText adds 1 more space after statusPad
385+
l.statusPad = maxBeforeStatus - beforeStatus
386+
387+
// Format: space(1) + spinner(1) + prefix + space(1) + taskID + progress + statusPad + space(1) + status
388+
lineLen := beforeStatus + l.statusPad + 1 + len(l.status)
389+
if l.details != "" {
390+
lineLen += 1 + len(l.details)
391+
}
392+
l.timerPad = terminalWidth - lineLen - timerLen
393+
if l.timerPad < 1 {
394+
l.timerPad = 1
395+
}
396+
lines[i] = l
397+
398+
}
399+
}
400+
401+
func (w *ttyWriter) adjustLineWidth(lines []lineData, timerLen int, terminalWidth int) {
402+
const minIDLen = 10
403+
404+
// Find the maximum status length for proper width calculation
405+
var maxStatusLen int
406+
for i := range lines {
407+
if len(lines[i].status) > maxStatusLen {
408+
maxStatusLen = len(lines[i].status)
409+
}
410+
}
411+
412+
// Iteratively truncate until all lines fit
413+
for iteration := 0; iteration < 100; iteration++ { // safety limit
414+
// Compute maxBeforeStatus
415+
var maxBeforeStatus int
416+
for i := range lines {
417+
l := &lines[i]
418+
beforeStatus := 4 + lenAnsi(l.prefix) + len(l.taskID) + lenAnsi(l.progress)
419+
if beforeStatus > maxBeforeStatus {
420+
maxBeforeStatus = beforeStatus
421+
}
422+
}
423+
424+
// Check if any line overflows and find max overflow
425+
maxOverflow := 0
426+
for i := range lines {
427+
l := &lines[i]
428+
detailsLen := len(l.details)
429+
if detailsLen > 0 {
430+
detailsLen++
431+
}
432+
// Line width: maxBeforeStatus + 1 (space) + status + details + 1 (min timerPad) + timer
433+
lineWidth := maxBeforeStatus + 1 + maxStatusLen + detailsLen + 1 + timerLen
434+
overflow := lineWidth - terminalWidth
435+
if overflow > maxOverflow {
436+
maxOverflow = overflow
437+
}
438+
}
439+
440+
if maxOverflow <= 0 {
441+
break // All lines fit
442+
}
443+
444+
// First try to truncate details
445+
truncated := false
446+
for i := range lines {
447+
l := &lines[i]
448+
if len(l.details) > 3 {
449+
reduction := maxOverflow
450+
if reduction > len(l.details)-3 {
451+
reduction = len(l.details) - 3
452+
}
453+
l.details = l.details[:len(l.details)-reduction-3] + "..."
454+
truncated = true
455+
break
456+
} else if len(l.details) > 0 {
457+
l.details = ""
458+
truncated = true
459+
break
460+
}
461+
}
462+
463+
if truncated {
464+
continue
465+
}
466+
467+
// No details left to truncate, find the line with longest taskID and truncate it
468+
longestIdx := -1
469+
longestLen := minIDLen
470+
for i := range lines {
471+
if len(lines[i].taskID) > longestLen {
472+
longestLen = len(lines[i].taskID)
473+
longestIdx = i
474+
}
475+
}
476+
477+
if longestIdx >= 0 {
478+
l := &lines[longestIdx]
479+
reduction := maxOverflow + 3 // account for "..."
480+
newLen := len(l.taskID) - reduction
481+
if newLen < minIDLen-3 {
482+
newLen = minIDLen - 3
483+
}
484+
if newLen > 0 {
485+
l.taskID = l.taskID[:newLen] + "..."
486+
}
487+
} else {
488+
break // Can't truncate further
489+
}
490+
}
491+
}
492+
493+
func (w *ttyWriter) prepareLineData(t *task) lineData {
319494
endTime := time.Now()
320495
if t.status != api.Working {
321496
endTime = t.startTime
322497
if (t.endTime != time.Time{}) {
323498
endTime = t.endTime
324499
}
325500
}
501+
326502
prefix := ""
327-
if dryRun {
503+
if w.dryRun {
328504
prefix = PrefixColor(DRYRUN_PREFIX)
329505
}
330506

@@ -338,11 +514,9 @@ func (w *ttyWriter) lineText(t *task, pad string, terminalWidth, statusPadding i
338514
)
339515

340516
// only show the aggregated progress while the root operation is in-progress
341-
if parent := t; parent.status == api.Working {
342-
for child := range w.childrenTasks(parent.ID) {
517+
if t.status == api.Working {
518+
for child := range w.childrenTasks(t.ID) {
343519
if child.status == api.Working && child.total == 0 {
344-
// we don't have totals available for all the child events
345-
// so don't show the total progress yet
346520
hideDetails = true
347521
}
348522
total += child.total
@@ -356,49 +530,49 @@ func (w *ttyWriter) lineText(t *task, pad string, terminalWidth, statusPadding i
356530
}
357531
}
358532

359-
// don't try to show detailed progress if we don't have any idea
360533
if total == 0 {
361534
hideDetails = true
362535
}
363536

364-
txt := t.ID
537+
var progress string
365538
if len(completion) > 0 {
366-
var progress string
539+
progress = " [" + SuccessColor(strings.Join(completion, "")) + "]"
367540
if !hideDetails {
368-
progress = fmt.Sprintf(" %7s / %-7s", units.HumanSize(float64(current)), units.HumanSize(float64(total)))
541+
progress += fmt.Sprintf(" %7s / %-7s", units.HumanSize(float64(current)), units.HumanSize(float64(total)))
369542
}
370-
txt = fmt.Sprintf("%s [%s]%s",
371-
t.ID,
372-
SuccessColor(strings.Join(completion, "")),
373-
progress,
374-
)
375-
}
376-
textLen := len(txt)
377-
padding := statusPadding - textLen
378-
if padding < 0 {
379-
padding = 0
380-
}
381-
// calculate the max length for the status text, on errors it
382-
// is 2-3 lines long and breaks the line formatting
383-
maxDetailsLen := terminalWidth - textLen - statusPadding - 15
384-
details := t.details
385-
// in some cases (debugging under VS Code), terminalWidth is set to zero by goterm.Width() ; ensuring we don't tweak strings with negative char index
386-
if maxDetailsLen > 0 && len(details) > maxDetailsLen {
387-
details = details[:maxDetailsLen] + "..."
388-
}
389-
text := fmt.Sprintf("%s %s%s %s %s%s %s",
390-
pad,
391-
spinner(t),
392-
prefix,
393-
txt,
394-
strings.Repeat(" ", padding),
395-
colorFn(t.status)(t.text),
396-
details,
397-
)
398-
timer := fmt.Sprintf("%.1fs ", elapsed)
399-
o := align(text, TimerColor(timer), terminalWidth)
543+
}
400544

401-
return o
545+
return lineData{
546+
spinner: spinner(t),
547+
prefix: prefix,
548+
taskID: t.ID,
549+
progress: progress,
550+
status: t.text,
551+
statusColor: colorFn(t.status),
552+
details: t.details,
553+
timer: fmt.Sprintf("%.1fs", elapsed),
554+
}
555+
}
556+
557+
func lineText(l lineData) string {
558+
var sb strings.Builder
559+
sb.WriteString(" ")
560+
sb.WriteString(l.spinner)
561+
sb.WriteString(l.prefix)
562+
sb.WriteString(" ")
563+
sb.WriteString(l.taskID)
564+
sb.WriteString(l.progress)
565+
sb.WriteString(strings.Repeat(" ", l.statusPad))
566+
sb.WriteString(" ")
567+
sb.WriteString(l.statusColor(l.status))
568+
if l.details != "" {
569+
sb.WriteString(" ")
570+
sb.WriteString(l.details)
571+
}
572+
sb.WriteString(strings.Repeat(" ", l.timerPad))
573+
sb.WriteString(TimerColor(l.timer))
574+
sb.WriteString("\n")
575+
return sb.String()
402576
}
403577

404578
var (
@@ -443,17 +617,6 @@ func numDone(tasks map[string]*task) int {
443617
return i
444618
}
445619

446-
func align(l, r string, w int) string {
447-
ll := lenAnsi(l)
448-
lr := lenAnsi(r)
449-
pad := ""
450-
count := w - ll - lr
451-
if count > 0 {
452-
pad = strings.Repeat(" ", count)
453-
}
454-
return fmt.Sprintf("%s%s%s\n", l, pad, r)
455-
}
456-
457620
// lenAnsi count of user-perceived characters in ANSI string.
458621
func lenAnsi(s string) int {
459622
length := 0

0 commit comments

Comments
 (0)