// 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 } 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 } 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) } func (s *smartStatusOutput) updateTermSize() { 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 == 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 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(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) }