• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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