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 s.updateTermSize() 81 82 if s.tableMode { 83 // Add empty lines at the bottom of the screen to scroll back the existing history 84 // and make room for the action table. 85 // TODO: read the cursor position to see if the empty lines are necessary? 86 for i := 0; i < s.tableHeight; i++ { 87 fmt.Fprintln(w) 88 } 89 90 // Hide the cursor to prevent seeing it bouncing around 91 fmt.Fprintf(s.writer, ansi.hideCursor()) 92 93 // Configure the empty action table 94 s.actionTable() 95 96 // Start a tick to update the action table periodically 97 s.startActionTableTick() 98 } 99 100 s.startSigwinch() 101 102 return s 103} 104 105func (s *smartStatusOutput) Message(level status.MsgLevel, message string) { 106 if level < status.StatusLvl { 107 return 108 } 109 110 str := s.formatter.message(level, message) 111 112 s.lock.Lock() 113 defer s.lock.Unlock() 114 115 if level > status.StatusLvl { 116 s.print(str) 117 } else { 118 s.statusLine(str) 119 } 120} 121 122func (s *smartStatusOutput) StartAction(action *status.Action, counts status.Counts) { 123 startTime := time.Now() 124 125 str := action.Description 126 if str == "" { 127 str = action.Command 128 } 129 130 progress := s.formatter.progress(counts) 131 132 s.lock.Lock() 133 defer s.lock.Unlock() 134 135 s.runningActions = append(s.runningActions, actionTableEntry{ 136 action: action, 137 startTime: startTime, 138 }) 139 140 s.statusLine(progress + str) 141} 142 143func (s *smartStatusOutput) FinishAction(result status.ActionResult, counts status.Counts) { 144 str := result.Description 145 if str == "" { 146 str = result.Command 147 } 148 149 progress := s.formatter.progress(counts) + str 150 151 output := s.formatter.result(result) 152 153 s.lock.Lock() 154 defer s.lock.Unlock() 155 156 for i, runningAction := range s.runningActions { 157 if runningAction.action == result.Action { 158 s.runningActions = append(s.runningActions[:i], s.runningActions[i+1:]...) 159 break 160 } 161 } 162 163 if output != "" { 164 s.statusLine(progress) 165 s.requestLine() 166 s.print(output) 167 } else { 168 s.statusLine(progress) 169 } 170} 171 172func (s *smartStatusOutput) Flush() { 173 if s.tableMode { 174 // Stop the action table tick outside of the lock to avoid lock ordering issues between s.done and 175 // s.lock, the goroutine in startActionTableTick can get blocked on the lock and be unable to read 176 // from the channel. 177 s.stopActionTableTick() 178 } 179 180 s.lock.Lock() 181 defer s.lock.Unlock() 182 183 s.stopSigwinch() 184 185 s.requestLine() 186 187 s.runningActions = nil 188 189 if s.tableMode { 190 // Update the table after clearing runningActions to clear it 191 s.actionTable() 192 193 // Reset the scrolling region to the whole terminal 194 fmt.Fprintf(s.writer, ansi.resetScrollingMargins()) 195 _, height, _ := termSize(s.writer) 196 // Move the cursor to the top of the now-blank, previously non-scrolling region 197 fmt.Fprintf(s.writer, ansi.setCursor(height-s.tableHeight, 1)) 198 // Turn the cursor back on 199 fmt.Fprintf(s.writer, ansi.showCursor()) 200 } 201} 202 203func (s *smartStatusOutput) Write(p []byte) (int, error) { 204 s.lock.Lock() 205 defer s.lock.Unlock() 206 s.print(string(p)) 207 return len(p), nil 208} 209 210func (s *smartStatusOutput) requestLine() { 211 if !s.haveBlankLine { 212 fmt.Fprintln(s.writer) 213 s.haveBlankLine = true 214 } 215} 216 217func (s *smartStatusOutput) print(str string) { 218 if !s.haveBlankLine { 219 fmt.Fprint(s.writer, "\r", ansi.clearToEndOfLine()) 220 s.haveBlankLine = true 221 } 222 fmt.Fprint(s.writer, str) 223 if len(str) == 0 || str[len(str)-1] != '\n' { 224 fmt.Fprint(s.writer, "\n") 225 } 226} 227 228func (s *smartStatusOutput) statusLine(str string) { 229 idx := strings.IndexRune(str, '\n') 230 if idx != -1 { 231 str = str[0:idx] 232 } 233 234 // Limit line width to the terminal width, otherwise we'll wrap onto 235 // another line and we won't delete the previous line. 236 str = elide(str, s.termWidth) 237 238 // Move to the beginning on the line, turn on bold, print the output, 239 // turn off bold, then clear the rest of the line. 240 start := "\r" + ansi.bold() 241 end := ansi.regular() + ansi.clearToEndOfLine() 242 fmt.Fprint(s.writer, start, str, end) 243 s.haveBlankLine = false 244} 245 246func elide(str string, width int) string { 247 if width > 0 && len(str) > width { 248 // TODO: Just do a max. Ninja elides the middle, but that's 249 // more complicated and these lines aren't that important. 250 str = str[:width] 251 } 252 253 return str 254} 255 256func (s *smartStatusOutput) startActionTableTick() { 257 s.ticker = time.NewTicker(time.Second) 258 go func() { 259 for { 260 select { 261 case <-s.ticker.C: 262 s.lock.Lock() 263 s.actionTable() 264 s.lock.Unlock() 265 case <-s.done: 266 return 267 } 268 } 269 }() 270} 271 272func (s *smartStatusOutput) stopActionTableTick() { 273 s.ticker.Stop() 274 s.done <- true 275} 276 277func (s *smartStatusOutput) startSigwinch() { 278 signal.Notify(s.sigwinch, syscall.SIGWINCH) 279 go func() { 280 for _ = range s.sigwinch { 281 s.lock.Lock() 282 s.updateTermSize() 283 if s.tableMode { 284 s.actionTable() 285 } 286 s.lock.Unlock() 287 if s.sigwinchHandled != nil { 288 s.sigwinchHandled <- true 289 } 290 } 291 }() 292} 293 294func (s *smartStatusOutput) stopSigwinch() { 295 signal.Stop(s.sigwinch) 296 close(s.sigwinch) 297} 298 299func (s *smartStatusOutput) updateTermSize() { 300 if w, h, ok := termSize(s.writer); ok { 301 firstUpdate := s.termHeight == 0 && s.termWidth == 0 302 oldScrollingHeight := s.termHeight - s.tableHeight 303 304 s.termWidth, s.termHeight = w, h 305 306 if s.tableMode { 307 tableHeight := s.requestedTableHeight 308 if tableHeight == 0 { 309 tableHeight = s.termHeight / 4 310 if tableHeight < 1 { 311 tableHeight = 1 312 } else if tableHeight > 10 { 313 tableHeight = 10 314 } 315 } 316 if tableHeight > s.termHeight-1 { 317 tableHeight = s.termHeight - 1 318 } 319 s.tableHeight = tableHeight 320 321 scrollingHeight := s.termHeight - s.tableHeight 322 323 if !firstUpdate { 324 // If the scrolling region has changed, attempt to pan the existing text so that it is 325 // not overwritten by the table. 326 if scrollingHeight < oldScrollingHeight { 327 pan := oldScrollingHeight - scrollingHeight 328 if pan > s.tableHeight { 329 pan = s.tableHeight 330 } 331 fmt.Fprint(s.writer, ansi.panDown(pan)) 332 } 333 } 334 } 335 } 336} 337 338func (s *smartStatusOutput) actionTable() { 339 scrollingHeight := s.termHeight - s.tableHeight 340 341 // Update the scrolling region in case the height of the terminal changed 342 343 fmt.Fprint(s.writer, ansi.setScrollingMargins(1, scrollingHeight)) 344 345 // Write as many status lines as fit in the table 346 for tableLine := 0; tableLine < s.tableHeight; tableLine++ { 347 if tableLine >= s.tableHeight { 348 break 349 } 350 // Move the cursor to the correct line of the non-scrolling region 351 fmt.Fprint(s.writer, ansi.setCursor(scrollingHeight+1+tableLine, 1)) 352 353 if tableLine < len(s.runningActions) { 354 runningAction := s.runningActions[tableLine] 355 356 seconds := int(time.Since(runningAction.startTime).Round(time.Second).Seconds()) 357 358 desc := runningAction.action.Description 359 if desc == "" { 360 desc = runningAction.action.Command 361 } 362 363 color := "" 364 if seconds >= 60 { 365 color = ansi.red() + ansi.bold() 366 } else if seconds >= 30 { 367 color = ansi.yellow() + ansi.bold() 368 } 369 370 durationStr := fmt.Sprintf(" %2d:%02d ", seconds/60, seconds%60) 371 desc = elide(desc, s.termWidth-len(durationStr)) 372 durationStr = color + durationStr + ansi.regular() 373 fmt.Fprint(s.writer, durationStr, desc) 374 } 375 fmt.Fprint(s.writer, ansi.clearToEndOfLine()) 376 } 377 378 // Move the cursor back to the last line of the scrolling region 379 fmt.Fprint(s.writer, ansi.setCursor(scrollingHeight, 1)) 380} 381 382var ansi = ansiImpl{} 383 384type ansiImpl struct{} 385 386func (ansiImpl) clearToEndOfLine() string { 387 return "\x1b[K" 388} 389 390func (ansiImpl) setCursor(row, column int) string { 391 // Direct cursor address 392 return fmt.Sprintf("\x1b[%d;%dH", row, column) 393} 394 395func (ansiImpl) setScrollingMargins(top, bottom int) string { 396 // Set Top and Bottom Margins DECSTBM 397 return fmt.Sprintf("\x1b[%d;%dr", top, bottom) 398} 399 400func (ansiImpl) resetScrollingMargins() string { 401 // Set Top and Bottom Margins DECSTBM 402 return fmt.Sprintf("\x1b[r") 403} 404 405func (ansiImpl) red() string { 406 return "\x1b[31m" 407} 408 409func (ansiImpl) yellow() string { 410 return "\x1b[33m" 411} 412 413func (ansiImpl) bold() string { 414 return "\x1b[1m" 415} 416 417func (ansiImpl) regular() string { 418 return "\x1b[0m" 419} 420 421func (ansiImpl) showCursor() string { 422 return "\x1b[?25h" 423} 424 425func (ansiImpl) hideCursor() string { 426 return "\x1b[?25l" 427} 428 429func (ansiImpl) panDown(lines int) string { 430 return fmt.Sprintf("\x1b[%dS", lines) 431} 432 433func (ansiImpl) panUp(lines int) string { 434 return fmt.Sprintf("\x1b[%dT", lines) 435} 436