github.com/Lephar/snapd@v0.0.0-20210825215435-c7fba9cef4d2/progress/ansimeter.go (about) 1 // -*- Mode: Go; indent-tabs-mode: t -*- 2 3 /* 4 * Copyright (C) 2017 Canonical Ltd 5 * 6 * This program is free software: you can redistribute it and/or modify 7 * it under the terms of the GNU General Public License version 3 as 8 * published by the Free Software Foundation. 9 * 10 * This program is distributed in the hope that it will be useful, 11 * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 * GNU General Public License for more details. 14 * 15 * You should have received a copy of the GNU General Public License 16 * along with this program. If not, see <http://www.gnu.org/licenses/>. 17 * 18 */ 19 20 package progress 21 22 import ( 23 "fmt" 24 "io" 25 "os" 26 "time" 27 "unicode" 28 29 "golang.org/x/crypto/ssh/terminal" 30 31 "github.com/snapcore/snapd/strutil/quantity" 32 ) 33 34 var stdout io.Writer = os.Stdout 35 36 // ANSIMeter is a progress.Meter that uses ANSI escape codes to make 37 // better use of the available horizontal space. 38 type ANSIMeter struct { 39 label []rune 40 total float64 41 written float64 42 spin int 43 t0 time.Time 44 } 45 46 // these are the bits of the ANSI escapes (beyond \r) that we use 47 // (names of the terminfo capabilities, see terminfo(5)) 48 var ( 49 // clear to end of line 50 clrEOL = "\033[K" 51 // make cursor invisible 52 cursorInvisible = "\033[?25l" 53 // make cursor visible 54 cursorVisible = "\033[?25h" 55 // turn on reverse video 56 enterReverseMode = "\033[7m" 57 // go back to normal video 58 exitAttributeMode = "\033[0m" 59 ) 60 61 var termWidth = func() int { 62 col, _, _ := terminal.GetSize(0) 63 if col <= 0 { 64 // give up 65 col = 80 66 } 67 return col 68 } 69 70 func (p *ANSIMeter) Start(label string, total float64) { 71 p.label = []rune(label) 72 p.total = total 73 p.t0 = time.Now().UTC() 74 fmt.Fprint(stdout, cursorInvisible) 75 } 76 77 func norm(col int, msg []rune) []rune { 78 if col <= 0 { 79 return []rune{} 80 } 81 out := make([]rune, col) 82 copy(out, msg) 83 d := col - len(msg) 84 if d < 0 { 85 out[col-1] = '…' 86 } else { 87 for i := len(msg); i < col; i++ { 88 out[i] = ' ' 89 } 90 } 91 return out 92 } 93 94 func (p *ANSIMeter) SetTotal(total float64) { 95 p.total = total 96 } 97 98 func (p *ANSIMeter) percent() string { 99 if p.total == 0. { 100 return "---%" 101 } 102 q := p.written * 100 / p.total 103 if q > 999.4 || q < 0. { 104 return "???%" 105 } 106 return fmt.Sprintf("%3.0f%%", q) 107 } 108 109 var formatDuration = quantity.FormatDuration 110 111 func (p *ANSIMeter) Set(current float64) { 112 if current < 0 { 113 current = 0 114 } 115 if current > p.total { 116 current = p.total 117 } 118 119 p.written = current 120 col := termWidth() 121 // time left: 5 122 // gutter: 1 123 // speed: 8 124 // gutter: 1 125 // percent: 4 126 // gutter: 1 127 // ===== 128 // 20 129 // and we want to leave at least 10 for the label, so: 130 // * if width <= 15, don't show any of this (progress bar is good enough) 131 // * if 15 < width <= 20, only show time left (time left + gutter = 6) 132 // * if 20 < width <= 29, also show percentage (percent + gutter = 5 133 // * if 29 < width , also show speed (speed+gutter = 9) 134 var percent, speed, timeleft string 135 if col > 15 { 136 since := time.Now().UTC().Sub(p.t0).Seconds() 137 per := since / p.written 138 left := (p.total - p.written) * per 139 // XXX: duration unit string is controlled by translations, and 140 // may carry a multibyte unit suffix 141 timeleft = " " + formatDuration(left) 142 if col > 20 { 143 percent = " " + p.percent() 144 if col > 29 { 145 speed = " " + quantity.FormatBPS(p.written, since, -1) 146 } 147 } 148 } 149 150 rpercent := []rune(percent) 151 rspeed := []rune(speed) 152 rtimeleft := []rune(timeleft) 153 msg := make([]rune, 0, col) 154 // XXX: assuming terminal can display `col` number of runes 155 msg = append(msg, norm(col-len(rpercent)-len(rspeed)-len(rtimeleft), p.label)...) 156 msg = append(msg, rpercent...) 157 msg = append(msg, rspeed...) 158 msg = append(msg, rtimeleft...) 159 i := int(current * float64(col) / p.total) 160 fmt.Fprint(stdout, "\r", enterReverseMode, string(msg[:i]), exitAttributeMode, string(msg[i:])) 161 } 162 163 var spinner = []string{"/", "-", "\\", "|"} 164 165 func (p *ANSIMeter) Spin(msgstr string) { 166 msg := []rune(msgstr) 167 col := termWidth() 168 if col-2 >= len(msg) { 169 fmt.Fprint(stdout, "\r", string(norm(col-2, msg)), " ", spinner[p.spin]) 170 p.spin++ 171 if p.spin >= len(spinner) { 172 p.spin = 0 173 } 174 } else { 175 fmt.Fprint(stdout, "\r", string(norm(col, msg))) 176 } 177 } 178 179 func (*ANSIMeter) Finished() { 180 fmt.Fprint(stdout, "\r", exitAttributeMode, cursorVisible, clrEOL) 181 } 182 183 func (*ANSIMeter) Notify(msgstr string) { 184 col := termWidth() 185 fmt.Fprint(stdout, "\r", exitAttributeMode, clrEOL) 186 187 msg := []rune(msgstr) 188 var i int 189 for len(msg) > col { 190 for i = col; i >= 0; i-- { 191 if unicode.IsSpace(msg[i]) { 192 break 193 } 194 } 195 if i < 1 { 196 // didn't find anything; print the whole thing and try again 197 fmt.Fprintln(stdout, string(msg[:col])) 198 msg = msg[col:] 199 } else { 200 // found a space; print up to but not including it, and skip it 201 fmt.Fprintln(stdout, string(msg[:i])) 202 msg = msg[i+1:] 203 } 204 } 205 fmt.Fprintln(stdout, string(msg)) 206 } 207 208 func (p *ANSIMeter) Write(bs []byte) (n int, err error) { 209 n = len(bs) 210 p.Set(p.written + float64(n)) 211 212 return 213 }