1// 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" 19 "io" 20 "os" 21 "os/signal" 22 "strconv" 23 "strings" 24 "sync" 25 "syscall" 26 "time" 27 28 "android/soong/ui/status" 29) 30 31const tableHeightEnVar = "SOONG_UI_TABLE_HEIGHT" 32 33type actionTableEntry struct { 34 action *status.Action 35 startTime time.Time 36} 37 38type smartStatusOutput struct { 39 writer io.Writer 40 formatter formatter 41 42 lock sync.Mutex 43 44 haveBlankLine bool 45 46 tableMode bool 47 tableHeight int 48 requestedTableHeight int 49 termWidth, termHeight int 50 51 runningActions []actionTableEntry 52 ticker *time.Ticker 53 done chan bool 54 sigwinch chan os.Signal 55 sigwinchHandled chan bool 56} 57 58// NewSmartStatusOutput returns a StatusOutput that represents the 59// current build status similarly to Ninja's built-in terminal 60// output. 61func NewSmartStatusOutput(w io.Writer, formatter formatter) status.StatusOutput { 62 s := &smartStatusOutput{ 63 writer: w, 64 formatter: formatter, 65 66 haveBlankLine: true, 67 68 tableMode: true, 69 70 done: make(chan bool), 71 sigwinch: make(chan os.Signal), 72 } 73 74 if env, ok := os.LookupEnv(tableHeightEnVar); ok { 75 h, _ := strconv.Atoi(env) 76 s.tableMode = h > 0 77 s.requestedTableHeight = h 78 } 79 80 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 } 86 87 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 105 s.startSigwinch() 106 107 return s 108} 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) { 128 startTime := time.Now() 129 130 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 140 s.runningActions = append(s.runningActions, actionTableEntry{ 141 action: action, 142 startTime: startTime, 143 }) 144 145 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 161 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 168 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() { 178 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 185 s.lock.Lock() 186 defer s.lock.Unlock() 187 188 s.stopSigwinch() 189 190 s.requestLine() 191 192 s.runningActions = nil 193 194 if s.tableMode { 195 // 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 202 fmt.Fprintf(s.writer, ansi.setCursor(height-s.tableHeight, 1)) 203 // Turn the cursor back on 204 fmt.Fprintf(s.writer, ansi.showCursor()) 205 } 206} 207 208func (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 215func (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 { 224 fmt.Fprint(s.writer, "\r", ansi.clearToEndOfLine()) 225 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. 241 str = elide(str, s.termWidth) 242 243 // Move to the beginning on the line, turn on bold, print the output, 244 // turn off bold, then clear the rest of the line. 245 start := "\r" + ansi.bold() 246 end := ansi.regular() + ansi.clearToEndOfLine() 247 fmt.Fprint(s.writer, start, str, end) 248 s.haveBlankLine = false 249} 250 251func elide(str string, width int) string { 252 if width > 0 && len(str) > width { 253 // TODO: Just do a max. Ninja elides the middle, but that's 254 // more complicated and these lines aren't that important. 255 str = str[:width] 256 } 257 258 return str 259} 260 261func (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 282func (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() 288 if s.tableMode { 289 s.actionTable() 290 } 291 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 304// 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. 323func (s *smartStatusOutput) updateTermSize() { 324 if w, h, ok := termSize(s.writer); ok { 325 oldScrollingHeight := s.termHeight - s.tableHeight 326 327 s.termWidth, s.termHeight = w, h 328 329 if s.tableMode { 330 s.computeTableHeight() 331 332 scrollingHeight := s.termHeight - s.tableHeight 333 334 // 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 340 } 341 fmt.Fprint(s.writer, ansi.panDown(pan)) 342 } 343 } 344 } 345} 346 347func (s *smartStatusOutput) actionTable() { 348 scrollingHeight := s.termHeight - s.tableHeight 349 350 // Update the scrolling region in case the height of the terminal changed 351 352 fmt.Fprint(s.writer, ansi.setScrollingMargins(1, scrollingHeight)) 353 354 // Write as many status lines as fit in the table 355 for tableLine := 0; tableLine < s.tableHeight; tableLine++ { 356 if tableLine >= s.tableHeight { 357 break 358 } 359 // Move the cursor to the correct line of the non-scrolling region 360 fmt.Fprint(s.writer, ansi.setCursor(scrollingHeight+1+tableLine, 1)) 361 362 if tableLine < len(s.runningActions) { 363 runningAction := s.runningActions[tableLine] 364 365 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) 383 } 384 fmt.Fprint(s.writer, ansi.clearToEndOfLine()) 385 } 386 387 // Move the cursor back to the last line of the scrolling region 388 fmt.Fprint(s.writer, ansi.setCursor(scrollingHeight, 1)) 389} 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 414func (ansiImpl) red() string { 415 return "\x1b[31m" 416} 417 418func (ansiImpl) yellow() string { 419 return "\x1b[33m" 420} 421 422func (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} 445