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