blob: 06a4064ff2f212dfa7653c90ed90a686ae7f6b77 [file] [log] [blame]
Colin Crossce525352019-06-08 18:58:00 -07001// Copyright 2019 Google Inc. All rights reserved.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15package terminal
16
17import (
18 "fmt"
Colin Cross097ed2a2019-06-08 21:48:58 -070019 "io"
Colin Cross4355ee62019-06-11 23:01:36 -070020 "os"
21 "os/signal"
Colin Cross3dac80e2019-06-11 11:19:06 -070022 "strconv"
Colin Crossce525352019-06-08 18:58:00 -070023 "strings"
24 "sync"
Colin Cross4355ee62019-06-11 23:01:36 -070025 "syscall"
Colin Cross3dac80e2019-06-11 11:19:06 -070026 "time"
Colin Crossce525352019-06-08 18:58:00 -070027
28 "android/soong/ui/status"
29)
30
Colin Cross3dac80e2019-06-11 11:19:06 -070031const tableHeightEnVar = "SOONG_UI_TABLE_HEIGHT"
32
33type actionTableEntry struct {
34 action *status.Action
35 startTime time.Time
36}
37
Colin Crossce525352019-06-08 18:58:00 -070038type smartStatusOutput struct {
Colin Cross097ed2a2019-06-08 21:48:58 -070039 writer io.Writer
Colin Crossce525352019-06-08 18:58:00 -070040 formatter formatter
41
42 lock sync.Mutex
43
44 haveBlankLine bool
Colin Cross4355ee62019-06-11 23:01:36 -070045
Colin Cross3dac80e2019-06-11 11:19:06 -070046 tableMode bool
47 tableHeight int
48 requestedTableHeight int
49 termWidth, termHeight int
50
51 runningActions []actionTableEntry
52 ticker *time.Ticker
53 done chan bool
Colin Cross4355ee62019-06-11 23:01:36 -070054 sigwinch chan os.Signal
55 sigwinchHandled chan bool
Colin Crossce525352019-06-08 18:58:00 -070056}
57
58// NewSmartStatusOutput returns a StatusOutput that represents the
59// current build status similarly to Ninja's built-in terminal
60// output.
Colin Cross097ed2a2019-06-08 21:48:58 -070061func NewSmartStatusOutput(w io.Writer, formatter formatter) status.StatusOutput {
Colin Cross4355ee62019-06-11 23:01:36 -070062 s := &smartStatusOutput{
Colin Crossce525352019-06-08 18:58:00 -070063 writer: w,
64 formatter: formatter,
65
66 haveBlankLine: true,
Colin Cross4355ee62019-06-11 23:01:36 -070067
Dan Willemsen1eee1542019-07-30 13:44:03 -070068 tableMode: true,
Colin Cross3dac80e2019-06-11 11:19:06 -070069
70 done: make(chan bool),
Colin Cross4355ee62019-06-11 23:01:36 -070071 sigwinch: make(chan os.Signal),
Colin Crossce525352019-06-08 18:58:00 -070072 }
Colin Cross4355ee62019-06-11 23:01:36 -070073
Dan Willemsen1eee1542019-07-30 13:44:03 -070074 if env, ok := os.LookupEnv(tableHeightEnVar); ok {
75 h, _ := strconv.Atoi(env)
76 s.tableMode = h > 0
77 s.requestedTableHeight = h
78 }
79
Colin Cross72a28a62021-02-10 13:12:41 -080080 if w, h, ok := termSize(s.writer); ok {
81 s.termWidth, s.termHeight = w, h
82 s.computeTableHeight()
83 } else {
84 s.tableMode = false
85 }
Colin Cross4355ee62019-06-11 23:01:36 -070086
Colin Cross3dac80e2019-06-11 11:19:06 -070087 if s.tableMode {
88 // Add empty lines at the bottom of the screen to scroll back the existing history
89 // and make room for the action table.
90 // TODO: read the cursor position to see if the empty lines are necessary?
91 for i := 0; i < s.tableHeight; i++ {
92 fmt.Fprintln(w)
93 }
94
95 // Hide the cursor to prevent seeing it bouncing around
96 fmt.Fprintf(s.writer, ansi.hideCursor())
97
98 // Configure the empty action table
99 s.actionTable()
100
101 // Start a tick to update the action table periodically
102 s.startActionTableTick()
103 }
104
Colin Cross4355ee62019-06-11 23:01:36 -0700105 s.startSigwinch()
106
107 return s
Colin Crossce525352019-06-08 18:58:00 -0700108}
109
110func (s *smartStatusOutput) Message(level status.MsgLevel, message string) {
111 if level < status.StatusLvl {
112 return
113 }
114
115 str := s.formatter.message(level, message)
116
117 s.lock.Lock()
118 defer s.lock.Unlock()
119
120 if level > status.StatusLvl {
121 s.print(str)
122 } else {
123 s.statusLine(str)
124 }
125}
126
127func (s *smartStatusOutput) StartAction(action *status.Action, counts status.Counts) {
Colin Cross3dac80e2019-06-11 11:19:06 -0700128 startTime := time.Now()
129
Colin Crossce525352019-06-08 18:58:00 -0700130 str := action.Description
131 if str == "" {
132 str = action.Command
133 }
134
135 progress := s.formatter.progress(counts)
136
137 s.lock.Lock()
138 defer s.lock.Unlock()
139
Colin Cross3dac80e2019-06-11 11:19:06 -0700140 s.runningActions = append(s.runningActions, actionTableEntry{
141 action: action,
142 startTime: startTime,
143 })
144
Colin Crossce525352019-06-08 18:58:00 -0700145 s.statusLine(progress + str)
146}
147
148func (s *smartStatusOutput) FinishAction(result status.ActionResult, counts status.Counts) {
149 str := result.Description
150 if str == "" {
151 str = result.Command
152 }
153
154 progress := s.formatter.progress(counts) + str
155
156 output := s.formatter.result(result)
157
158 s.lock.Lock()
159 defer s.lock.Unlock()
160
Colin Cross3dac80e2019-06-11 11:19:06 -0700161 for i, runningAction := range s.runningActions {
162 if runningAction.action == result.Action {
163 s.runningActions = append(s.runningActions[:i], s.runningActions[i+1:]...)
164 break
165 }
166 }
167
Colin Crossce525352019-06-08 18:58:00 -0700168 if output != "" {
169 s.statusLine(progress)
170 s.requestLine()
171 s.print(output)
172 } else {
173 s.statusLine(progress)
174 }
175}
176
177func (s *smartStatusOutput) Flush() {
Colin Cross8cc19912019-10-29 15:22:04 -0700178 if s.tableMode {
179 // Stop the action table tick outside of the lock to avoid lock ordering issues between s.done and
180 // s.lock, the goroutine in startActionTableTick can get blocked on the lock and be unable to read
181 // from the channel.
182 s.stopActionTableTick()
183 }
184
Colin Crossce525352019-06-08 18:58:00 -0700185 s.lock.Lock()
186 defer s.lock.Unlock()
187
Colin Cross4355ee62019-06-11 23:01:36 -0700188 s.stopSigwinch()
189
Colin Crossce525352019-06-08 18:58:00 -0700190 s.requestLine()
Colin Cross3dac80e2019-06-11 11:19:06 -0700191
192 s.runningActions = nil
193
194 if s.tableMode {
Colin Cross3dac80e2019-06-11 11:19:06 -0700195 // Update the table after clearing runningActions to clear it
196 s.actionTable()
197
198 // Reset the scrolling region to the whole terminal
199 fmt.Fprintf(s.writer, ansi.resetScrollingMargins())
200 _, height, _ := termSize(s.writer)
201 // Move the cursor to the top of the now-blank, previously non-scrolling region
Colin Crossbf8f57e2019-09-20 15:00:22 -0700202 fmt.Fprintf(s.writer, ansi.setCursor(height-s.tableHeight, 1))
Colin Cross3dac80e2019-06-11 11:19:06 -0700203 // Turn the cursor back on
204 fmt.Fprintf(s.writer, ansi.showCursor())
205 }
Colin Crossce525352019-06-08 18:58:00 -0700206}
207
Colin Crosse0df1a32019-06-09 19:40:08 -0700208func (s *smartStatusOutput) Write(p []byte) (int, error) {
209 s.lock.Lock()
210 defer s.lock.Unlock()
211 s.print(string(p))
212 return len(p), nil
213}
214
Colin Crossce525352019-06-08 18:58:00 -0700215func (s *smartStatusOutput) requestLine() {
216 if !s.haveBlankLine {
217 fmt.Fprintln(s.writer)
218 s.haveBlankLine = true
219 }
220}
221
222func (s *smartStatusOutput) print(str string) {
223 if !s.haveBlankLine {
Colin Cross3dac80e2019-06-11 11:19:06 -0700224 fmt.Fprint(s.writer, "\r", ansi.clearToEndOfLine())
Colin Crossce525352019-06-08 18:58:00 -0700225 s.haveBlankLine = true
226 }
227 fmt.Fprint(s.writer, str)
228 if len(str) == 0 || str[len(str)-1] != '\n' {
229 fmt.Fprint(s.writer, "\n")
230 }
231}
232
233func (s *smartStatusOutput) statusLine(str string) {
234 idx := strings.IndexRune(str, '\n')
235 if idx != -1 {
236 str = str[0:idx]
237 }
238
239 // Limit line width to the terminal width, otherwise we'll wrap onto
240 // another line and we won't delete the previous line.
Colin Cross5137de02019-06-20 15:22:50 -0700241 str = elide(str, s.termWidth)
Colin Crossce525352019-06-08 18:58:00 -0700242
Colin Cross00bdfd82019-06-11 11:16:23 -0700243 // Move to the beginning on the line, turn on bold, print the output,
244 // turn off bold, then clear the rest of the line.
Colin Cross3dac80e2019-06-11 11:19:06 -0700245 start := "\r" + ansi.bold()
246 end := ansi.regular() + ansi.clearToEndOfLine()
Colin Cross00bdfd82019-06-11 11:16:23 -0700247 fmt.Fprint(s.writer, start, str, end)
Colin Crossce525352019-06-08 18:58:00 -0700248 s.haveBlankLine = false
249}
Colin Cross4355ee62019-06-11 23:01:36 -0700250
Colin Cross5137de02019-06-20 15:22:50 -0700251func elide(str string, width int) string {
252 if width > 0 && len(str) > width {
Colin Cross4355ee62019-06-11 23:01:36 -0700253 // TODO: Just do a max. Ninja elides the middle, but that's
254 // more complicated and these lines aren't that important.
Colin Cross5137de02019-06-20 15:22:50 -0700255 str = str[:width]
Colin Cross4355ee62019-06-11 23:01:36 -0700256 }
257
258 return str
259}
260
Colin Cross3dac80e2019-06-11 11:19:06 -0700261func (s *smartStatusOutput) startActionTableTick() {
262 s.ticker = time.NewTicker(time.Second)
263 go func() {
264 for {
265 select {
266 case <-s.ticker.C:
267 s.lock.Lock()
268 s.actionTable()
269 s.lock.Unlock()
270 case <-s.done:
271 return
272 }
273 }
274 }()
275}
276
277func (s *smartStatusOutput) stopActionTableTick() {
278 s.ticker.Stop()
279 s.done <- true
280}
281
Colin Cross4355ee62019-06-11 23:01:36 -0700282func (s *smartStatusOutput) startSigwinch() {
283 signal.Notify(s.sigwinch, syscall.SIGWINCH)
284 go func() {
285 for _ = range s.sigwinch {
286 s.lock.Lock()
287 s.updateTermSize()
Colin Cross3dac80e2019-06-11 11:19:06 -0700288 if s.tableMode {
289 s.actionTable()
290 }
Colin Cross4355ee62019-06-11 23:01:36 -0700291 s.lock.Unlock()
292 if s.sigwinchHandled != nil {
293 s.sigwinchHandled <- true
294 }
295 }
296 }()
297}
298
299func (s *smartStatusOutput) stopSigwinch() {
300 signal.Stop(s.sigwinch)
301 close(s.sigwinch)
302}
303
Colin Cross72a28a62021-02-10 13:12:41 -0800304// computeTableHeight recomputes s.tableHeight based on s.termHeight and s.requestedTableHeight.
305func (s *smartStatusOutput) computeTableHeight() {
306 tableHeight := s.requestedTableHeight
307 if tableHeight == 0 {
308 tableHeight = s.termHeight / 4
309 if tableHeight < 1 {
310 tableHeight = 1
311 } else if tableHeight > 10 {
312 tableHeight = 10
313 }
314 }
315 if tableHeight > s.termHeight-1 {
316 tableHeight = s.termHeight - 1
317 }
318 s.tableHeight = tableHeight
319}
320
321// updateTermSize recomputes the table height after a SIGWINCH and pans any existing text if
322// necessary.
Colin Cross4355ee62019-06-11 23:01:36 -0700323func (s *smartStatusOutput) updateTermSize() {
Colin Cross3dac80e2019-06-11 11:19:06 -0700324 if w, h, ok := termSize(s.writer); ok {
Colin Cross3dac80e2019-06-11 11:19:06 -0700325 oldScrollingHeight := s.termHeight - s.tableHeight
326
327 s.termWidth, s.termHeight = w, h
328
329 if s.tableMode {
Colin Cross72a28a62021-02-10 13:12:41 -0800330 s.computeTableHeight()
Colin Cross3dac80e2019-06-11 11:19:06 -0700331
332 scrollingHeight := s.termHeight - s.tableHeight
333
Colin Cross72a28a62021-02-10 13:12:41 -0800334 // If the scrolling region has changed, attempt to pan the existing text so that it is
335 // not overwritten by the table.
336 if scrollingHeight < oldScrollingHeight {
337 pan := oldScrollingHeight - scrollingHeight
338 if pan > s.tableHeight {
339 pan = s.tableHeight
Colin Cross3dac80e2019-06-11 11:19:06 -0700340 }
Colin Cross72a28a62021-02-10 13:12:41 -0800341 fmt.Fprint(s.writer, ansi.panDown(pan))
Colin Cross3dac80e2019-06-11 11:19:06 -0700342 }
343 }
Colin Cross4355ee62019-06-11 23:01:36 -0700344 }
345}
Colin Cross3dac80e2019-06-11 11:19:06 -0700346
347func (s *smartStatusOutput) actionTable() {
348 scrollingHeight := s.termHeight - s.tableHeight
349
350 // Update the scrolling region in case the height of the terminal changed
Colin Crossf0b987e2019-09-20 15:01:51 -0700351
Colin Crossbf8f57e2019-09-20 15:00:22 -0700352 fmt.Fprint(s.writer, ansi.setScrollingMargins(1, scrollingHeight))
Colin Cross3dac80e2019-06-11 11:19:06 -0700353
354 // Write as many status lines as fit in the table
Colin Crossf0b987e2019-09-20 15:01:51 -0700355 for tableLine := 0; tableLine < s.tableHeight; tableLine++ {
Colin Cross3dac80e2019-06-11 11:19:06 -0700356 if tableLine >= s.tableHeight {
357 break
358 }
Colin Crossf0b987e2019-09-20 15:01:51 -0700359 // Move the cursor to the correct line of the non-scrolling region
360 fmt.Fprint(s.writer, ansi.setCursor(scrollingHeight+1+tableLine, 1))
Colin Cross3dac80e2019-06-11 11:19:06 -0700361
Colin Crossf0b987e2019-09-20 15:01:51 -0700362 if tableLine < len(s.runningActions) {
363 runningAction := s.runningActions[tableLine]
Colin Cross3dac80e2019-06-11 11:19:06 -0700364
Colin Crossf0b987e2019-09-20 15:01:51 -0700365 seconds := int(time.Since(runningAction.startTime).Round(time.Second).Seconds())
366
367 desc := runningAction.action.Description
368 if desc == "" {
369 desc = runningAction.action.Command
370 }
371
372 color := ""
373 if seconds >= 60 {
374 color = ansi.red() + ansi.bold()
375 } else if seconds >= 30 {
376 color = ansi.yellow() + ansi.bold()
377 }
378
379 durationStr := fmt.Sprintf(" %2d:%02d ", seconds/60, seconds%60)
380 desc = elide(desc, s.termWidth-len(durationStr))
381 durationStr = color + durationStr + ansi.regular()
382 fmt.Fprint(s.writer, durationStr, desc)
Colin Cross3dac80e2019-06-11 11:19:06 -0700383 }
Colin Cross3dac80e2019-06-11 11:19:06 -0700384 fmt.Fprint(s.writer, ansi.clearToEndOfLine())
Colin Cross3dac80e2019-06-11 11:19:06 -0700385 }
386
387 // Move the cursor back to the last line of the scrolling region
Colin Crossbf8f57e2019-09-20 15:00:22 -0700388 fmt.Fprint(s.writer, ansi.setCursor(scrollingHeight, 1))
Colin Cross3dac80e2019-06-11 11:19:06 -0700389}
390
391var ansi = ansiImpl{}
392
393type ansiImpl struct{}
394
395func (ansiImpl) clearToEndOfLine() string {
396 return "\x1b[K"
397}
398
399func (ansiImpl) setCursor(row, column int) string {
400 // Direct cursor address
401 return fmt.Sprintf("\x1b[%d;%dH", row, column)
402}
403
404func (ansiImpl) setScrollingMargins(top, bottom int) string {
405 // Set Top and Bottom Margins DECSTBM
406 return fmt.Sprintf("\x1b[%d;%dr", top, bottom)
407}
408
409func (ansiImpl) resetScrollingMargins() string {
410 // Set Top and Bottom Margins DECSTBM
411 return fmt.Sprintf("\x1b[r")
412}
413
Colin Cross5137de02019-06-20 15:22:50 -0700414func (ansiImpl) red() string {
415 return "\x1b[31m"
416}
417
418func (ansiImpl) yellow() string {
419 return "\x1b[33m"
420}
421
Colin Cross3dac80e2019-06-11 11:19:06 -0700422func (ansiImpl) bold() string {
423 return "\x1b[1m"
424}
425
426func (ansiImpl) regular() string {
427 return "\x1b[0m"
428}
429
430func (ansiImpl) showCursor() string {
431 return "\x1b[?25h"
432}
433
434func (ansiImpl) hideCursor() string {
435 return "\x1b[?25l"
436}
437
438func (ansiImpl) panDown(lines int) string {
439 return fmt.Sprintf("\x1b[%dS", lines)
440}
441
442func (ansiImpl) panUp(lines int) string {
443 return fmt.Sprintf("\x1b[%dT", lines)
444}