platform_build_soong/ui/terminal/smart_status.go
Colin Cross 72a28a6a13 Disable table mode in smart status if window size is not available
Make smart status output automatically disable table mode when the
window size is not available.

Bug: 147310922
Test: status_test.go
Change-Id: Ic57e1efd742e869bb84b6a86b3014335f5a65d39
2021-09-21 12:36:22 -07:00

444 lines
10 KiB
Go

// Copyright 2019 Google Inc. All rights reserved.
//
// 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 terminal
import (
"fmt"
"io"
"os"
"os/signal"
"strconv"
"strings"
"sync"
"syscall"
"time"
"android/soong/ui/status"
)
const tableHeightEnVar = "SOONG_UI_TABLE_HEIGHT"
type actionTableEntry struct {
action *status.Action
startTime time.Time
}
type smartStatusOutput struct {
writer io.Writer
formatter formatter
lock sync.Mutex
haveBlankLine bool
tableMode bool
tableHeight int
requestedTableHeight int
termWidth, termHeight int
runningActions []actionTableEntry
ticker *time.Ticker
done chan bool
sigwinch chan os.Signal
sigwinchHandled chan bool
}
// NewSmartStatusOutput returns a StatusOutput that represents the
// current build status similarly to Ninja's built-in terminal
// output.
func NewSmartStatusOutput(w io.Writer, formatter formatter) status.StatusOutput {
s := &smartStatusOutput{
writer: w,
formatter: formatter,
haveBlankLine: true,
tableMode: true,
done: make(chan bool),
sigwinch: make(chan os.Signal),
}
if env, ok := os.LookupEnv(tableHeightEnVar); ok {
h, _ := strconv.Atoi(env)
s.tableMode = h > 0
s.requestedTableHeight = h
}
if w, h, ok := termSize(s.writer); ok {
s.termWidth, s.termHeight = w, h
s.computeTableHeight()
} else {
s.tableMode = false
}
if s.tableMode {
// Add empty lines at the bottom of the screen to scroll back the existing history
// and make room for the action table.
// TODO: read the cursor position to see if the empty lines are necessary?
for i := 0; i < s.tableHeight; i++ {
fmt.Fprintln(w)
}
// Hide the cursor to prevent seeing it bouncing around
fmt.Fprintf(s.writer, ansi.hideCursor())
// Configure the empty action table
s.actionTable()
// Start a tick to update the action table periodically
s.startActionTableTick()
}
s.startSigwinch()
return s
}
func (s *smartStatusOutput) Message(level status.MsgLevel, message string) {
if level < status.StatusLvl {
return
}
str := s.formatter.message(level, message)
s.lock.Lock()
defer s.lock.Unlock()
if level > status.StatusLvl {
s.print(str)
} else {
s.statusLine(str)
}
}
func (s *smartStatusOutput) StartAction(action *status.Action, counts status.Counts) {
startTime := time.Now()
str := action.Description
if str == "" {
str = action.Command
}
progress := s.formatter.progress(counts)
s.lock.Lock()
defer s.lock.Unlock()
s.runningActions = append(s.runningActions, actionTableEntry{
action: action,
startTime: startTime,
})
s.statusLine(progress + str)
}
func (s *smartStatusOutput) FinishAction(result status.ActionResult, counts status.Counts) {
str := result.Description
if str == "" {
str = result.Command
}
progress := s.formatter.progress(counts) + str
output := s.formatter.result(result)
s.lock.Lock()
defer s.lock.Unlock()
for i, runningAction := range s.runningActions {
if runningAction.action == result.Action {
s.runningActions = append(s.runningActions[:i], s.runningActions[i+1:]...)
break
}
}
if output != "" {
s.statusLine(progress)
s.requestLine()
s.print(output)
} else {
s.statusLine(progress)
}
}
func (s *smartStatusOutput) Flush() {
if s.tableMode {
// Stop the action table tick outside of the lock to avoid lock ordering issues between s.done and
// s.lock, the goroutine in startActionTableTick can get blocked on the lock and be unable to read
// from the channel.
s.stopActionTableTick()
}
s.lock.Lock()
defer s.lock.Unlock()
s.stopSigwinch()
s.requestLine()
s.runningActions = nil
if s.tableMode {
// Update the table after clearing runningActions to clear it
s.actionTable()
// Reset the scrolling region to the whole terminal
fmt.Fprintf(s.writer, ansi.resetScrollingMargins())
_, height, _ := termSize(s.writer)
// Move the cursor to the top of the now-blank, previously non-scrolling region
fmt.Fprintf(s.writer, ansi.setCursor(height-s.tableHeight, 1))
// Turn the cursor back on
fmt.Fprintf(s.writer, ansi.showCursor())
}
}
func (s *smartStatusOutput) Write(p []byte) (int, error) {
s.lock.Lock()
defer s.lock.Unlock()
s.print(string(p))
return len(p), nil
}
func (s *smartStatusOutput) requestLine() {
if !s.haveBlankLine {
fmt.Fprintln(s.writer)
s.haveBlankLine = true
}
}
func (s *smartStatusOutput) print(str string) {
if !s.haveBlankLine {
fmt.Fprint(s.writer, "\r", ansi.clearToEndOfLine())
s.haveBlankLine = true
}
fmt.Fprint(s.writer, str)
if len(str) == 0 || str[len(str)-1] != '\n' {
fmt.Fprint(s.writer, "\n")
}
}
func (s *smartStatusOutput) statusLine(str string) {
idx := strings.IndexRune(str, '\n')
if idx != -1 {
str = str[0:idx]
}
// Limit line width to the terminal width, otherwise we'll wrap onto
// another line and we won't delete the previous line.
str = elide(str, s.termWidth)
// Move to the beginning on the line, turn on bold, print the output,
// turn off bold, then clear the rest of the line.
start := "\r" + ansi.bold()
end := ansi.regular() + ansi.clearToEndOfLine()
fmt.Fprint(s.writer, start, str, end)
s.haveBlankLine = false
}
func elide(str string, width int) string {
if width > 0 && len(str) > width {
// TODO: Just do a max. Ninja elides the middle, but that's
// more complicated and these lines aren't that important.
str = str[:width]
}
return str
}
func (s *smartStatusOutput) startActionTableTick() {
s.ticker = time.NewTicker(time.Second)
go func() {
for {
select {
case <-s.ticker.C:
s.lock.Lock()
s.actionTable()
s.lock.Unlock()
case <-s.done:
return
}
}
}()
}
func (s *smartStatusOutput) stopActionTableTick() {
s.ticker.Stop()
s.done <- true
}
func (s *smartStatusOutput) startSigwinch() {
signal.Notify(s.sigwinch, syscall.SIGWINCH)
go func() {
for _ = range s.sigwinch {
s.lock.Lock()
s.updateTermSize()
if s.tableMode {
s.actionTable()
}
s.lock.Unlock()
if s.sigwinchHandled != nil {
s.sigwinchHandled <- true
}
}
}()
}
func (s *smartStatusOutput) stopSigwinch() {
signal.Stop(s.sigwinch)
close(s.sigwinch)
}
// computeTableHeight recomputes s.tableHeight based on s.termHeight and s.requestedTableHeight.
func (s *smartStatusOutput) computeTableHeight() {
tableHeight := s.requestedTableHeight
if tableHeight == 0 {
tableHeight = s.termHeight / 4
if tableHeight < 1 {
tableHeight = 1
} else if tableHeight > 10 {
tableHeight = 10
}
}
if tableHeight > s.termHeight-1 {
tableHeight = s.termHeight - 1
}
s.tableHeight = tableHeight
}
// updateTermSize recomputes the table height after a SIGWINCH and pans any existing text if
// necessary.
func (s *smartStatusOutput) updateTermSize() {
if w, h, ok := termSize(s.writer); ok {
oldScrollingHeight := s.termHeight - s.tableHeight
s.termWidth, s.termHeight = w, h
if s.tableMode {
s.computeTableHeight()
scrollingHeight := s.termHeight - s.tableHeight
// If the scrolling region has changed, attempt to pan the existing text so that it is
// not overwritten by the table.
if scrollingHeight < oldScrollingHeight {
pan := oldScrollingHeight - scrollingHeight
if pan > s.tableHeight {
pan = s.tableHeight
}
fmt.Fprint(s.writer, ansi.panDown(pan))
}
}
}
}
func (s *smartStatusOutput) actionTable() {
scrollingHeight := s.termHeight - s.tableHeight
// Update the scrolling region in case the height of the terminal changed
fmt.Fprint(s.writer, ansi.setScrollingMargins(1, scrollingHeight))
// Write as many status lines as fit in the table
for tableLine := 0; tableLine < s.tableHeight; tableLine++ {
if tableLine >= s.tableHeight {
break
}
// Move the cursor to the correct line of the non-scrolling region
fmt.Fprint(s.writer, ansi.setCursor(scrollingHeight+1+tableLine, 1))
if tableLine < len(s.runningActions) {
runningAction := s.runningActions[tableLine]
seconds := int(time.Since(runningAction.startTime).Round(time.Second).Seconds())
desc := runningAction.action.Description
if desc == "" {
desc = runningAction.action.Command
}
color := ""
if seconds >= 60 {
color = ansi.red() + ansi.bold()
} else if seconds >= 30 {
color = ansi.yellow() + ansi.bold()
}
durationStr := fmt.Sprintf(" %2d:%02d ", seconds/60, seconds%60)
desc = elide(desc, s.termWidth-len(durationStr))
durationStr = color + durationStr + ansi.regular()
fmt.Fprint(s.writer, durationStr, desc)
}
fmt.Fprint(s.writer, ansi.clearToEndOfLine())
}
// Move the cursor back to the last line of the scrolling region
fmt.Fprint(s.writer, ansi.setCursor(scrollingHeight, 1))
}
var ansi = ansiImpl{}
type ansiImpl struct{}
func (ansiImpl) clearToEndOfLine() string {
return "\x1b[K"
}
func (ansiImpl) setCursor(row, column int) string {
// Direct cursor address
return fmt.Sprintf("\x1b[%d;%dH", row, column)
}
func (ansiImpl) setScrollingMargins(top, bottom int) string {
// Set Top and Bottom Margins DECSTBM
return fmt.Sprintf("\x1b[%d;%dr", top, bottom)
}
func (ansiImpl) resetScrollingMargins() string {
// Set Top and Bottom Margins DECSTBM
return fmt.Sprintf("\x1b[r")
}
func (ansiImpl) red() string {
return "\x1b[31m"
}
func (ansiImpl) yellow() string {
return "\x1b[33m"
}
func (ansiImpl) bold() string {
return "\x1b[1m"
}
func (ansiImpl) regular() string {
return "\x1b[0m"
}
func (ansiImpl) showCursor() string {
return "\x1b[?25h"
}
func (ansiImpl) hideCursor() string {
return "\x1b[?25l"
}
func (ansiImpl) panDown(lines int) string {
return fmt.Sprintf("\x1b[%dS", lines)
}
func (ansiImpl) panUp(lines int) string {
return fmt.Sprintf("\x1b[%dT", lines)
}