Files
compose/cmd/display/tty_layout.go
T
Nicolas De LoofandClaude Fable 5 1a3bb4f6f5 refactor(display): address review feedback on the term renderer
- layoutFrame clips its output to the terminal height: height==1 no
  longer overflows with header + more-marker
- the two status-to-color switches merge into eventColor(status, def)
- the clear-suspension sequence extracts into unsuspend()
- tests drive a manually-advanced fake clock through the injected
  now(), and the cancel test loses its settling sleep — Done waits for
  the refresh goroutine itself
- roots, per-parent children and the completed count are maintained
  incrementally by apply(), so a 100ms tick with an unchanged tree no
  longer rescans every task
- the frame<0 clamp in spinGlyph drops: time comes from the writer's
  single clock and never regresses; the invariant is documented on the
  model
- Start's retire-previous-cycle comment spells out that cancellation
  only guarantees the old goroutine will exit, not that it already has

Co-Authored-By: Claude Fable 5 <[email protected]>
Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-09 16:50:05 +02:00

364 lines
10 KiB
Go

/*
Copyright 2020 Docker Compose CLI authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package display
import (
"fmt"
"runtime"
"strings"
"time"
"github.com/docker/go-units"
"github.com/mattn/go-runewidth"
"github.com/docker/compose/v5/pkg/api"
)
// The layout is a pure function of (taskTree, operation, layoutOpts):
// no clocks, no terminal, no mutation. Its single hard invariant, enforced
// by renderSegs, is that every returned line occupies at most o.width
// terminal cells. This is what makes screen cursor arithmetic sound: a line
// that never wraps is a line the cursor can reliably move back over.
//
// All width computations happen on plain text, in terminal cells (via
// go-runewidth, so CJK and other wide runes count as 2); colors are applied
// only when a segment's final text is known. ANSI sequences therefore never
// enter any measurement.
// seg is a piece of a row: plain text plus the color to apply once its
// geometry is final.
type seg struct {
text string
color colorFunc
}
type layoutOpts struct {
width, height int
dryRun bool
now time.Time
}
const (
tickInterval = 100 * time.Millisecond
minIDCells = 10
// left margin: space + spinner + space
marginCells = 3
// widest status text worth reserving column space for; longer ones are
// truncated rather than allowed to squeeze the id column
maxStatusReserveCells = 20
)
// layoutFrame renders the whole progress block, one string per terminal row.
func layoutFrame(t *taskTree, operation string, o layoutOpts) []string {
done, total := t.counts()
header := fmt.Sprintf("[+] %s %d/%d", operation, done, total)
lines := []string{renderSegs([]seg{{text: header}}, o.width)}
rows := buildRows(t, o)
maxRows := max(o.height-2, 1)
var more int
if len(rows) > maxRows {
more = len(rows) - (maxRows - 1)
rows = rows[:maxRows-1]
}
cols := computeColumns(rows, o.width)
for i := range rows {
lines = append(lines, renderSegs(rowSegs(&rows[i], cols, o.width), o.width))
}
if more > 0 {
lines = append(lines, renderSegs([]seg{{text: fmt.Sprintf(" ... %d more", more)}}, o.width))
}
// hard guarantee against degenerate heights (height==1 would otherwise
// still yield header + "more" = 2 lines): never return more lines than
// the terminal has rows
if o.height > 0 && len(lines) > o.height {
lines = lines[:o.height]
}
return lines
}
// row is the pre-rendered content of one line, before column fitting.
type row struct {
spin seg
prefix string // dry-run prefix
id string
barStrip string // aggregated braille strip, one glyph per child task
sizes string // droppable progress suffix appended after the bar
status string
statusColor colorFunc
details string
timer string
}
func buildRows(t *taskTree, o layoutOpts) []row {
var rows []row
for _, root := range t.roots {
rows = append(rows, makeRow(t, root, o))
}
return rows
}
func makeRow(t *taskTree, n *node, o layoutOpts) row {
r := row{
spin: spinGlyph(n, o.now),
id: n.id,
status: n.text,
statusColor: eventColor(n.status, nocolor),
details: n.details,
timer: fmt.Sprintf("%.1fs", nodeElapsed(n, o.now).Seconds()),
}
if o.dryRun {
r.prefix = DRYRUN_PREFIX
}
r.barStrip, r.sizes = aggregateProgress(t, n)
return r
}
func nodeElapsed(n *node, now time.Time) time.Duration {
switch {
case n.status == api.Working:
return now.Sub(n.startedAt)
case !n.endedAt.IsZero():
return n.endedAt.Sub(n.startedAt)
default:
return 0
}
}
// aggregateProgress compresses the children of a root task into a braille
// strip (one glyph per child) and a "current / total" size suffix.
func aggregateProgress(t *taskTree, root *node) (strip, sizes string) {
if root.status != api.Working {
return "", ""
}
var (
total, current int64
hideSizes bool
glyphs []string
)
for _, child := range t.children[root.id] {
if child.status == api.Working && child.total == 0 {
hideSizes = true
}
total += child.total
current += child.current
r := len(percentChars) - 1
p := min(child.percent, 100)
glyphs = append(glyphs, percentChars[r*p/100])
}
if len(glyphs) == 0 {
return "", ""
}
if total == 0 {
hideSizes = true
}
if !hideSizes {
sizes = fmt.Sprintf(" %7s / %-7s", units.HumanSize(float64(current)), units.HumanSize(float64(total)))
}
return strings.Join(glyphs, ""), sizes
}
// columns holds the shared geometry aligning all rows of a frame.
type columns struct {
left int // indent + prefix + id + bar + sizes
timer int
}
func computeColumns(rows []row, width int) columns {
var c columns
var status int
for i := range rows {
r := &rows[i]
c.timer = max(c.timer, runewidth.StringWidth(r.timer))
c.left = max(c.left, leftWidth(r, true))
status = max(status, runewidth.StringWidth(r.status))
}
// keep room for the widest (capped) status and the filler before the timer
reserve := min(status, maxStatusReserveCells) + 2
c.left = min(c.left, max(width-marginCells-reserve-c.timer, minIDCells))
return c
}
// leftWidth measures the left block of a row in cells.
func leftWidth(r *row, withSizes bool) int {
w := runewidth.StringWidth(r.prefix) + runewidth.StringWidth(r.id)
if r.barStrip != "" {
w += 3 + runewidth.StringWidth(r.barStrip) // " [" + strip + "]"
}
if withSizes {
w += runewidth.StringWidth(r.sizes)
}
return w
}
// rowSegs lays one row out into segments. Content degrades in a fixed order
// when space is short: drop the sizes suffix, truncate the id, truncate
// status, truncate then drop details. renderSegs is the final hard guarantee.
func rowSegs(r *row, c columns, width int) []seg {
var segs []seg
used := 0
add := func(text string, color colorFunc) {
if text == "" {
return
}
segs = append(segs, seg{text: text, color: color})
used += runewidth.StringWidth(text)
}
add(" ", nil)
add(r.spin.text, r.spin.color)
add(r.prefix, PrefixColor)
add(" ", nil)
// left block: id + bar + sizes, fitted to the shared left column
sizes := r.sizes
if leftWidth(r, true) > c.left {
sizes = "" // drop the size suffix first
}
if lw := leftWidth(r, false); lw > c.left {
idBudget := max(c.left-(lw-runewidth.StringWidth(r.id)), minIDCells-3)
r.id = truncateCells(r.id, idBudget)
}
add(r.id, nil)
if r.barStrip != "" {
add(" [", nil)
add(r.barStrip, SuccessColor)
add("]", nil)
}
add(sizes, nil)
if pad := marginCells + c.left - used; pad > 0 {
add(strings.Repeat(" ", pad), nil)
}
// status, then details, each within what remains before the timer
if budget := width - used - c.timer - 2; budget > 0 {
add(" ", nil)
add(truncateCells(r.status, budget), r.statusColor)
}
if budget := width - used - c.timer - 2; r.details != "" && budget >= 5 {
add(" ", nil)
add(truncateCells(r.details, budget-1), nil)
}
// right-aligned timer
if fill := width - used - c.timer; fill > 0 {
add(strings.Repeat(" ", fill), nil)
}
add(strings.Repeat(" ", max(c.timer-runewidth.StringWidth(r.timer), 0)), nil)
add(r.timer, TimerColor)
return segs
}
// renderSegs assembles segments into the final styled line, enforcing the
// package invariant: the rendered line occupies at most maxCells terminal
// cells. Truncation happens on plain text before coloring, so the clip is
// both ANSI-safe and UTF-8-safe.
func renderSegs(segs []seg, maxCells int) string {
var sb strings.Builder
cells := 0
for _, s := range segs {
w := runewidth.StringWidth(s.text)
if cells+w > maxCells {
s.text = runewidth.Truncate(s.text, maxCells-cells, "")
w = runewidth.StringWidth(s.text)
}
if s.text != "" {
if s.color != nil {
sb.WriteString(s.color(s.text))
} else {
sb.WriteString(s.text)
}
cells += w
}
if cells >= maxCells {
break
}
}
return sb.String()
}
// truncateCells shortens s to the given number of terminal cells, appending
// "..." when something was actually cut and space allows.
func truncateCells(s string, cells int) string {
if cells <= 0 {
return ""
}
if runewidth.StringWidth(s) <= cells {
return s
}
if cells <= 3 {
return runewidth.Truncate(s, cells, "")
}
return runewidth.Truncate(s, cells, "...")
}
var (
spinnerDone = "✔"
spinnerWarning = "!"
spinnerError = "✘"
// percentChars maps a completion ratio to a braille glyph for the
// aggregated per-image strip
percentChars = strings.Split("⠀⡀⣀⣄⣤⣦⣶⣷⣿", "")
termSpinnerFrames = spinnerFrames()
)
// eventColor maps an event status to its display color; statuses without a
// dedicated color (typically Working) fall back to def, which differs
// between the progress rows (no color) and the plain per-event lines
// (success green).
func eventColor(s api.EventStatus, def colorFunc) colorFunc {
switch s {
case api.Done:
return SuccessColor
case api.Warning:
return WarningColor
case api.Error:
return ErrorColor
default:
return def
}
}
func spinnerFrames() []string {
if runtime.GOOS == "windows" {
return []string{"-"}
}
return []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
}
// spinGlyph derives the per-row glyph from status and time. The animation
// frame is a pure function of the clock, so its speed doesn't depend on how
// often the layout runs.
func spinGlyph(n *node, now time.Time) seg {
switch n.status {
case api.Done:
return seg{text: spinnerDone, color: SuccessColor}
case api.Warning:
return seg{text: spinnerWarning, color: WarningColor}
case api.Error:
return seg{text: spinnerError, color: ErrorColor}
default:
// now and startedAt come from the writer's single clock (see
// taskTree doc), so the difference is never negative
frame := int(now.Sub(n.startedAt)/tickInterval) % len(termSpinnerFrames)
return seg{text: termSpinnerFrames[frame], color: CountColor}
}
}