Loading ui/terminal/smart_status.go +216 −6 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 } Loading @@ -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 Loading @@ -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 Loading @@ -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) } Loading @@ -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() Loading @@ -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) { Loading @@ -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) Loading @@ -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 } Loading @@ -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 Loading @@ -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) } ui/terminal/status_test.go +6 −0 Original line number Diff line number Diff line Loading @@ -17,6 +17,7 @@ package terminal import ( "bytes" "fmt" "os" "syscall" "testing" Loading Loading @@ -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) Loading Loading @@ -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) Loading ui/terminal/util.go +5 −5 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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. Loading Loading @@ -106,5 +106,5 @@ func stripAnsiEscapes(input []byte) []byte { type fakeSmartTerminal struct { bytes.Buffer termWidth int termWidth, termHeight int } Loading
ui/terminal/smart_status.go +216 −6 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 } Loading @@ -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 Loading @@ -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 Loading @@ -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) } Loading @@ -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() Loading @@ -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) { Loading @@ -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) Loading @@ -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 } Loading @@ -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 Loading @@ -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) }
ui/terminal/status_test.go +6 −0 Original line number Diff line number Diff line Loading @@ -17,6 +17,7 @@ package terminal import ( "bytes" "fmt" "os" "syscall" "testing" Loading Loading @@ -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) Loading Loading @@ -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) Loading
ui/terminal/util.go +5 −5 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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. Loading Loading @@ -106,5 +106,5 @@ func stripAnsiEscapes(input []byte) []byte { type fakeSmartTerminal struct { bytes.Buffer termWidth int termWidth, termHeight int }