github.phpd.cn/thought-machine/please@v12.2.0+incompatible/src/output/interactive_display.go (about) 1 package output 2 3 import ( 4 "bytes" 5 "fmt" 6 "os" 7 "os/signal" 8 "strings" 9 "syscall" 10 "time" 11 12 "cli" 13 "core" 14 ) 15 16 // We only set the terminal title for terminals that at least claim to be xterm 17 // (note that most terminals do for compatibility; some report as xterm-color, hence HasPrefix) 18 var terminalClaimsToBeXterm = strings.HasPrefix(os.Getenv("TERM"), "xterm") 19 20 func display(state *core.BuildState, buildingTargets *[]buildingTarget, stop <-chan struct{}, done chan<- struct{}) { 21 backend := cli.NewLogBackend(len(*buildingTargets)) 22 go func() { 23 sig := make(chan os.Signal, 10) 24 signal.Notify(sig, syscall.SIGWINCH) 25 for { 26 <-sig 27 recalcWindowSize(backend) 28 } 29 }() 30 recalcWindowSize(backend) 31 backend.SetActive() 32 33 printLines(state, *buildingTargets, backend.MaxInteractiveRows, backend.Cols) 34 outputLines := len(backend.Output) 35 ticker := time.NewTicker(50 * time.Millisecond) 36 loop: 37 for { 38 select { 39 case <-stop: 40 break loop 41 case <-ticker.C: 42 moveToFirstLine(*buildingTargets, outputLines, backend.MaxInteractiveRows, state.Config.Display.SystemStats) 43 printLines(state, *buildingTargets, backend.MaxInteractiveRows, backend.Cols) 44 for _, line := range backend.Output { 45 printf("\x1b[2K%s\n", line) // erase each line as we go 46 } 47 outputLines = len(backend.Output) 48 setWindowTitle(state, true) 49 } 50 } 51 ticker.Stop() 52 setWindowTitle(state, false) 53 // Clear it all out. 54 moveToFirstLine(*buildingTargets, outputLines, backend.MaxInteractiveRows, state.Config.Display.SystemStats) 55 printf("\x1b[0J") // Clear out to end of screen. 56 backend.Deactivate() 57 done <- struct{}{} 58 } 59 60 // moveToFirstLine resets back to the first line. Not as easy as you might think. 61 func moveToFirstLine(buildingTargets []buildingTarget, outputLines, maxInteractiveRows int, showingStats bool) { 62 if maxInteractiveRows > len(buildingTargets) { 63 maxInteractiveRows = len(buildingTargets) 64 } 65 if showingStats { 66 maxInteractiveRows++ 67 } 68 printf("\x1b[%dA", maxInteractiveRows+1+outputLines) 69 } 70 71 func printLines(state *core.BuildState, buildingTargets []buildingTarget, maxLines, cols int) { 72 now := time.Now() 73 printf("Building [%d/%d, %3.1fs]:\n", state.NumDone(), state.NumActive(), time.Since(state.StartTime).Seconds()) 74 if state.Config.Display.SystemStats { 75 printStat("CPU use", state.Stats.CPU.Used, state.Stats.CPU.Count) 76 printStat("I/O", state.Stats.CPU.IOWait, state.Stats.CPU.Count) 77 printStat("Mem use", state.Stats.Memory.UsedPercent, 1) 78 printf("${ERASE_AFTER}\n") 79 } 80 for i := 0; i < len(buildingTargets) && i < maxLines; i++ { 81 buildingTargets[i].Lock() 82 // Take a local copy of the structure, which isn't *that* big, so we don't need to retain the lock 83 // while we do potentially blocking things like printing. 84 target := buildingTargets[i].buildingTargetData 85 buildingTargets[i].Unlock() 86 label := target.Label.Parent() 87 duration := now.Sub(target.Started).Seconds() 88 if target.Active && target.Target != nil && target.Target.ShowProgress && target.Target.Progress > 0.0 { 89 if target.Target.Progress > 1.0 && target.Target.Progress < 100.0 && target.Target.Progress != target.LastProgress { 90 proportionDone := target.Target.Progress / 100.0 91 perPercent := float32(duration) / proportionDone 92 buildingTargets[i].Eta = time.Duration(perPercent * (1.0 - proportionDone) * float32(time.Second)).Truncate(time.Second) 93 buildingTargets[i].LastProgress = target.Target.Progress 94 } 95 if target.Eta > 0 { 96 lprintf(cols, "${BOLD_WHITE}=> [%4.1fs] ${RESET}%s%s ${BOLD_WHITE}%s${RESET} (%.1f%%%%, est %s remaining)${ERASE_AFTER}\n", 97 duration, target.Colour, label, target.Description, target.Target.Progress, target.Eta) 98 } else { 99 lprintf(cols, "${BOLD_WHITE}=> [%4.1fs] ${RESET}%s%s ${BOLD_WHITE}%s${RESET} (%.1f%%%% complete)${ERASE_AFTER}\n", 100 duration, target.Colour, label, target.Description, target.Target.Progress) 101 } 102 } else if target.Active { 103 lprintf(cols, "${BOLD_WHITE}=> [%4.1fs] ${RESET}%s%s ${BOLD_WHITE}%s${ERASE_AFTER}\n", 104 duration, target.Colour, label, target.Description) 105 } else if time.Since(target.Finished).Seconds() < 0.5 { 106 // Only display finished targets for half a second after they're done. 107 duration := target.Finished.Sub(target.Started).Seconds() 108 if target.Failed { 109 lprintf(cols, "${BOLD_RED}=> [%4.1fs] ${RESET}%s%s ${BOLD_RED}Failed${ERASE_AFTER}\n", 110 duration, target.Colour, label) 111 } else if target.Cached { 112 lprintf(cols, "${BOLD_WHITE}=> [%4.1fs] ${RESET}%s%s ${BOLD_GREY}%s${ERASE_AFTER}\n", 113 duration, target.Colour, label, target.Description) 114 } else { 115 lprintf(cols, "${BOLD_WHITE}=> [%4.1fs] ${RESET}%s%s ${WHITE}%s${ERASE_AFTER}\n", 116 duration, target.Colour, label, target.Description) 117 } 118 } else { 119 printf("${BOLD_GREY}=|${ERASE_AFTER}\n") 120 } 121 } 122 printf("${RESET}") 123 } 124 125 // printStat prints a single statistic with appropriate colours. 126 func printStat(caption string, stat float64, multiplier int) { 127 colour := "${BOLD_GREEN}" 128 if stat > 80.0*float64(multiplier) { 129 colour = "${BOLD_RED}" 130 } else if stat > 60.0*float64(multiplier) { 131 colour = "${BOLD_YELLOW}" 132 } 133 printf(" ${BOLD_WHITE}%s:${RESET} %s%5.1f%%${RESET}", caption, colour, stat) 134 } 135 136 func recalcWindowSize(backend *cli.LogBackend) { 137 rows, cols, _ := cli.WindowSize() 138 backend.Lock() 139 defer backend.Unlock() 140 backend.Rows = rows - 4 // Give a little space at the edge for any off-by-ones 141 backend.Cols = cols 142 backend.RecalcLines() 143 } 144 145 // Limited-length printf that respects current window width. 146 // Output is truncated at the middle to fit within 'cols'. 147 func lprintf(cols int, format string, args ...interface{}) { 148 printf(lprintfPrepare(cols, format, args...)) 149 } 150 151 func lprintfPrepare(cols int, format string, args ...interface{}) string { 152 s := fmt.Sprintf(format, args...) 153 if len(s) < cols { 154 return s // it's short enough, nice and simple 155 } 156 // Okay, it's too long. Tricky thing: ANSI escape codes don't count for width 157 // so we need to count without those. Bonus: make an effort to be unicode-aware. 158 var b bytes.Buffer 159 written := 0 160 inAnsiCode := false 161 for _, rune := range s { 162 if inAnsiCode { 163 b.WriteRune(rune) 164 if rune == 'm' { 165 inAnsiCode = false 166 } 167 } else if rune == '\x1b' { 168 b.WriteRune(rune) 169 inAnsiCode = true 170 } else if rune == '\n' { 171 b.WriteRune(rune) 172 } else if written == cols-3 { 173 b.WriteString("...") 174 written += 3 175 } else if written < cols-3 { 176 b.WriteRune(rune) 177 written++ 178 } 179 } 180 return b.String() 181 } 182 183 // setWindowTitle sets the title of the current shell window based on the current build state. 184 func setWindowTitle(state *core.BuildState, running bool) { 185 if !state.Config.Display.UpdateTitle { 186 return 187 } 188 if running { 189 SetWindowTitle("plz: finishing up") 190 } else { 191 SetWindowTitle(fmt.Sprintf("plz: %d / %d tasks, %3.1fs", state.NumDone(), state.NumActive(), time.Since(state.StartTime).Seconds())) 192 } 193 } 194 195 // SetWindowTitle sets the title of the current shell window. 196 func SetWindowTitle(title string) { 197 if cli.StdErrIsATerminal && terminalClaimsToBeXterm { 198 os.Stderr.Write([]byte(fmt.Sprintf("\033]0;%s\007", title))) 199 } 200 }