diff --git a/ui/terminal/smart_status.go b/ui/terminal/smart_status.go index 82c04d46c..9638cdf74 100644 --- a/ui/terminal/smart_status.go +++ b/ui/terminal/smart_status.go @@ -19,13 +19,22 @@ import ( "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 @@ -34,7 +43,14 @@ type smartStatusOutput struct { haveBlankLine bool - termWidth int + tableMode bool + tableHeight int + requestedTableHeight int + termWidth, termHeight int + + runningActions []actionTableEntry + ticker *time.Ticker + done chan bool sigwinch chan os.Signal sigwinchHandled chan bool } @@ -43,17 +59,41 @@ type smartStatusOutput struct { // current build status similarly to Ninja's built-in terminal // output. func NewSmartStatusOutput(w io.Writer, formatter formatter) status.StatusOutput { + tableHeight, _ := strconv.Atoi(os.Getenv(tableHeightEnVar)) + s := &smartStatusOutput{ writer: w, formatter: formatter, haveBlankLine: true, + tableMode: tableHeight > 0, + requestedTableHeight: tableHeight, + + done: make(chan bool), sigwinch: make(chan os.Signal), } s.updateTermSize() + 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 @@ -77,6 +117,8 @@ func (s *smartStatusOutput) Message(level status.MsgLevel, message string) { } func (s *smartStatusOutput) StartAction(action *status.Action, counts status.Counts) { + startTime := time.Now() + str := action.Description if str == "" { str = action.Command @@ -87,6 +129,11 @@ func (s *smartStatusOutput) StartAction(action *status.Action, counts status.Cou s.lock.Lock() defer s.lock.Unlock() + s.runningActions = append(s.runningActions, actionTableEntry{ + action: action, + startTime: startTime, + }) + s.statusLine(progress + str) } @@ -103,6 +150,13 @@ func (s *smartStatusOutput) FinishAction(result status.ActionResult, counts stat 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() @@ -119,6 +173,23 @@ func (s *smartStatusOutput) Flush() { s.stopSigwinch() s.requestLine() + + s.runningActions = nil + + if s.tableMode { + s.stopActionTableTick() + + // 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, 0)) + // Turn the cursor back on + fmt.Fprintf(s.writer, ansi.showCursor()) + } } func (s *smartStatusOutput) Write(p []byte) (int, error) { @@ -137,7 +208,7 @@ func (s *smartStatusOutput) requestLine() { func (s *smartStatusOutput) print(str string) { if !s.haveBlankLine { - fmt.Fprint(s.writer, "\r", "\x1b[K") + fmt.Fprint(s.writer, "\r", ansi.clearToEndOfLine()) s.haveBlankLine = true } fmt.Fprint(s.writer, str) @@ -160,8 +231,8 @@ func (s *smartStatusOutput) statusLine(str string) { // 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\x1b[1m" - end := "\x1b[0m\x1b[K" + start := "\r" + ansi.bold() + end := ansi.regular() + ansi.clearToEndOfLine() fmt.Fprint(s.writer, start, str, end) s.haveBlankLine = false } @@ -176,12 +247,36 @@ func (s *smartStatusOutput) elide(str string) string { 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 @@ -196,7 +291,122 @@ func (s *smartStatusOutput) stopSigwinch() { } func (s *smartStatusOutput) updateTermSize() { - if w, ok := termWidth(s.writer); ok { - s.termWidth = w + if w, h, ok := termSize(s.writer); ok { + firstUpdate := s.termHeight == 0 && s.termWidth == 0 + oldScrollingHeight := s.termHeight - s.tableHeight + + s.termWidth, s.termHeight = w, h + + if s.tableMode { + tableHeight := s.requestedTableHeight + if tableHeight > s.termHeight-1 { + tableHeight = s.termHeight - 1 + } + s.tableHeight = tableHeight + + scrollingHeight := s.termHeight - s.tableHeight + + if !firstUpdate { + // 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(0, scrollingHeight)) + // Move the cursor to the first line of the non-scrolling region + fmt.Fprint(s.writer, ansi.setCursor(scrollingHeight+1, 0)) + + // Write as many status lines as fit in the table + var tableLine int + var runningAction actionTableEntry + for tableLine, runningAction = range s.runningActions { + if tableLine >= s.tableHeight { + break + } + + seconds := int(time.Since(runningAction.startTime).Round(time.Second).Seconds()) + + desc := runningAction.action.Description + if desc == "" { + desc = runningAction.action.Command + } + + str := fmt.Sprintf(" %2d:%02d %s", seconds/60, seconds%60, desc) + str = s.elide(str) + fmt.Fprint(s.writer, str, ansi.clearToEndOfLine()) + if tableLine < s.tableHeight-1 { + fmt.Fprint(s.writer, "\n") + } + } + + // Clear any remaining lines in the table + for ; tableLine < s.tableHeight; tableLine++ { + fmt.Fprint(s.writer, ansi.clearToEndOfLine()) + if tableLine < s.tableHeight-1 { + fmt.Fprint(s.writer, "\n") + } + } + + // Move the cursor back to the last line of the scrolling region + fmt.Fprint(s.writer, ansi.setCursor(scrollingHeight, 0)) +} + +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) 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) +} diff --git a/ui/terminal/status_test.go b/ui/terminal/status_test.go index 106d6515f..81aa238b8 100644 --- a/ui/terminal/status_test.go +++ b/ui/terminal/status_test.go @@ -17,6 +17,7 @@ package terminal import ( "bytes" "fmt" + "os" "syscall" "testing" @@ -86,8 +87,11 @@ func TestStatusOutput(t *testing.T) { }, } + os.Setenv(tableHeightEnVar, "") + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Run("smart", func(t *testing.T) { smart := &fakeSmartTerminal{termWidth: 40} stat := NewStatusOutput(smart, "", false) @@ -251,6 +255,8 @@ func actionWithOuptutWithAnsiCodes(stat status.StatusOutput) { } func TestSmartStatusOutputWidthChange(t *testing.T) { + os.Setenv(tableHeightEnVar, "") + smart := &fakeSmartTerminal{termWidth: 40} stat := NewStatusOutput(smart, "", false) smartStat := stat.(*smartStatusOutput) diff --git a/ui/terminal/util.go b/ui/terminal/util.go index 3a11b79bb..c9377f156 100644 --- a/ui/terminal/util.go +++ b/ui/terminal/util.go @@ -35,7 +35,7 @@ func isSmartTerminal(w io.Writer) bool { return false } -func termWidth(w io.Writer) (int, bool) { +func termSize(w io.Writer) (width int, height int, ok bool) { if f, ok := w.(*os.File); ok { var winsize struct { ws_row, ws_column uint16 @@ -44,11 +44,11 @@ func termWidth(w io.Writer) (int, bool) { _, _, err := syscall.Syscall6(syscall.SYS_IOCTL, f.Fd(), syscall.TIOCGWINSZ, uintptr(unsafe.Pointer(&winsize)), 0, 0, 0) - return int(winsize.ws_column), err == 0 + return int(winsize.ws_column), int(winsize.ws_row), err == 0 } else if f, ok := w.(*fakeSmartTerminal); ok { - return f.termWidth, true + return f.termWidth, f.termHeight, true } - return 0, false + return 0, 0, false } // stripAnsiEscapes strips ANSI control codes from a byte array in place. @@ -106,5 +106,5 @@ func stripAnsiEscapes(input []byte) []byte { type fakeSmartTerminal struct { bytes.Buffer - termWidth int + termWidth, termHeight int }