tty.go 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426
  1. /*
  2. Copyright 2020 Docker Compose CLI authors
  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. http://www.apache.org/licenses/LICENSE-2.0
  7. Unless required by applicable law or agreed to in writing, software
  8. distributed under the License is distributed on an "AS IS" BASIS,
  9. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  10. See the License for the specific language governing permissions and
  11. limitations under the License.
  12. */
  13. package progress
  14. import (
  15. "context"
  16. "fmt"
  17. "io"
  18. "slices"
  19. "strings"
  20. "sync"
  21. "time"
  22. "github.com/docker/compose/v2/pkg/api"
  23. "github.com/buger/goterm"
  24. "github.com/docker/go-units"
  25. "github.com/morikuni/aec"
  26. )
  27. // NewTTYWriter creates an EventProcessor that render advanced UI within a terminal.
  28. // On Start, TUI lists task with a progress timer
  29. func NewTTYWriter(out io.Writer) EventProcessor {
  30. return &ttyWriter{
  31. out: out,
  32. tasks: map[string]task{},
  33. ids: []string{},
  34. done: make(chan bool),
  35. mtx: &sync.Mutex{},
  36. }
  37. }
  38. type ttyWriter struct {
  39. out io.Writer
  40. tasks map[string]task
  41. ids []string
  42. repeated bool
  43. numLines int
  44. done chan bool
  45. mtx *sync.Mutex
  46. dryRun bool // FIXME(ndeloof) (re)implement support for dry-run
  47. skipChildEvents bool
  48. title string
  49. ticker *time.Ticker
  50. }
  51. type task struct {
  52. ID string
  53. parentID string
  54. startTime time.Time
  55. endTime time.Time
  56. text string
  57. status EventStatus
  58. statusText string
  59. current int64
  60. percent int
  61. total int64
  62. spinner *Spinner
  63. }
  64. func (t *task) stop() {
  65. t.endTime = time.Now()
  66. t.spinner.Stop()
  67. }
  68. func (t *task) hasMore() {
  69. t.spinner.Restart()
  70. }
  71. func (w *ttyWriter) Start(ctx context.Context, operation string) {
  72. w.ticker = time.NewTicker(100 * time.Millisecond)
  73. w.title = operation
  74. go func() {
  75. for {
  76. select {
  77. case <-ctx.Done():
  78. // interrupted
  79. w.ticker.Stop()
  80. return
  81. case <-w.done:
  82. w.print()
  83. w.mtx.Lock()
  84. w.ticker.Stop()
  85. w.title = ""
  86. w.mtx.Unlock()
  87. return
  88. case <-w.ticker.C:
  89. w.print()
  90. }
  91. }
  92. }()
  93. }
  94. func (w *ttyWriter) Done(operation string, success bool) {
  95. w.done <- true
  96. }
  97. func (w *ttyWriter) On(events ...Event) {
  98. w.mtx.Lock()
  99. defer w.mtx.Unlock()
  100. for _, e := range events {
  101. w.event(e)
  102. }
  103. }
  104. func (w *ttyWriter) event(e Event) {
  105. if !slices.Contains(w.ids, e.ID) {
  106. w.ids = append(w.ids, e.ID)
  107. }
  108. if _, ok := w.tasks[e.ID]; ok {
  109. last := w.tasks[e.ID]
  110. switch e.Status {
  111. case Done, Error, Warning:
  112. if last.status != e.Status {
  113. last.stop()
  114. }
  115. case Working:
  116. last.hasMore()
  117. }
  118. last.status = e.Status
  119. last.text = e.Text
  120. last.statusText = e.StatusText
  121. // progress can only go up
  122. if e.Total > last.total {
  123. last.total = e.Total
  124. }
  125. if e.Current > last.current {
  126. last.current = e.Current
  127. }
  128. if e.Percent > last.percent {
  129. last.percent = e.Percent
  130. }
  131. // allow set/unset of parent, but not swapping otherwise prompt is flickering
  132. if last.parentID == "" || e.ParentID == "" {
  133. last.parentID = e.ParentID
  134. }
  135. w.tasks[e.ID] = last
  136. } else {
  137. t := task{
  138. ID: e.ID,
  139. parentID: e.ParentID,
  140. startTime: time.Now(),
  141. text: e.Text,
  142. status: e.Status,
  143. statusText: e.StatusText,
  144. current: e.Current,
  145. percent: e.Percent,
  146. total: e.Total,
  147. spinner: NewSpinner(),
  148. }
  149. if e.Status == Done || e.Status == Error {
  150. t.stop()
  151. }
  152. w.tasks[e.ID] = t
  153. }
  154. w.printEvent(e)
  155. }
  156. func (w *ttyWriter) printEvent(e Event) {
  157. if w.title != "" {
  158. // event will be displayed by progress UI on ticker's ticks
  159. return
  160. }
  161. var color colorFunc
  162. switch e.Status {
  163. case Working:
  164. color = SuccessColor
  165. case Done:
  166. color = SuccessColor
  167. case Warning:
  168. color = WarningColor
  169. case Error:
  170. color = ErrorColor
  171. }
  172. _, _ = fmt.Fprintf(w.out, "%s %s %s\n", e.ID, e.Text, color(e.StatusText))
  173. }
  174. func (w *ttyWriter) print() { //nolint:gocyclo
  175. w.mtx.Lock()
  176. defer w.mtx.Unlock()
  177. if len(w.ids) == 0 {
  178. return
  179. }
  180. terminalWidth := goterm.Width()
  181. b := aec.EmptyBuilder
  182. for i := 0; i <= w.numLines; i++ {
  183. b = b.Up(1)
  184. }
  185. if !w.repeated {
  186. b = b.Down(1)
  187. }
  188. w.repeated = true
  189. _, _ = fmt.Fprint(w.out, b.Column(0).ANSI)
  190. // Hide the cursor while we are printing
  191. _, _ = fmt.Fprint(w.out, aec.Hide)
  192. defer func() {
  193. _, _ = fmt.Fprint(w.out, aec.Show)
  194. }()
  195. firstLine := fmt.Sprintf("[+] %s %d/%d", w.title, numDone(w.tasks), len(w.tasks))
  196. if w.numLines != 0 && numDone(w.tasks) == w.numLines {
  197. firstLine = DoneColor(firstLine)
  198. }
  199. _, _ = fmt.Fprintln(w.out, firstLine)
  200. var statusPadding int
  201. for _, v := range w.ids {
  202. t := w.tasks[v]
  203. l := len(fmt.Sprintf("%s %s", t.ID, t.text))
  204. if statusPadding < l {
  205. statusPadding = l
  206. }
  207. if t.parentID != "" {
  208. statusPadding -= 2
  209. }
  210. }
  211. if len(w.ids) > goterm.Height()-2 {
  212. w.skipChildEvents = true
  213. }
  214. numLines := 0
  215. for _, v := range w.ids {
  216. t := w.tasks[v]
  217. if t.parentID != "" {
  218. continue
  219. }
  220. line := w.lineText(t, "", terminalWidth, statusPadding, w.dryRun)
  221. _, _ = fmt.Fprint(w.out, line)
  222. numLines++
  223. for _, v := range w.ids {
  224. t := w.tasks[v]
  225. if t.parentID == t.ID {
  226. if w.skipChildEvents {
  227. continue
  228. }
  229. line := w.lineText(t, " ", terminalWidth, statusPadding, w.dryRun)
  230. _, _ = fmt.Fprint(w.out, line)
  231. numLines++
  232. }
  233. }
  234. }
  235. for i := numLines; i < w.numLines; i++ {
  236. if numLines < goterm.Height()-2 {
  237. _, _ = fmt.Fprintln(w.out, strings.Repeat(" ", terminalWidth))
  238. numLines++
  239. }
  240. }
  241. w.numLines = numLines
  242. }
  243. func (w *ttyWriter) lineText(t task, pad string, terminalWidth, statusPadding int, dryRun bool) string {
  244. endTime := time.Now()
  245. if t.status != Working {
  246. endTime = t.startTime
  247. if (t.endTime != time.Time{}) {
  248. endTime = t.endTime
  249. }
  250. }
  251. prefix := ""
  252. if dryRun {
  253. prefix = PrefixColor(api.DRYRUN_PREFIX)
  254. }
  255. elapsed := endTime.Sub(t.startTime).Seconds()
  256. var (
  257. hideDetails bool
  258. total int64
  259. current int64
  260. completion []string
  261. )
  262. // only show the aggregated progress while the root operation is in-progress
  263. if parent := t; parent.status == Working {
  264. for _, v := range w.ids {
  265. child := w.tasks[v]
  266. if child.parentID == parent.ID {
  267. if child.status == Working && child.total == 0 {
  268. // we don't have totals available for all the child events
  269. // so don't show the total progress yet
  270. hideDetails = true
  271. }
  272. total += child.total
  273. current += child.current
  274. completion = append(completion, percentChars[(len(percentChars)-1)*child.percent/100])
  275. }
  276. }
  277. }
  278. // don't try to show detailed progress if we don't have any idea
  279. if total == 0 {
  280. hideDetails = true
  281. }
  282. var txt string
  283. if len(completion) > 0 {
  284. var details string
  285. if !hideDetails {
  286. details = fmt.Sprintf(" %7s / %-7s", units.HumanSize(float64(current)), units.HumanSize(float64(total)))
  287. }
  288. txt = fmt.Sprintf("%s [%s]%s %s",
  289. t.ID,
  290. SuccessColor(strings.Join(completion, "")),
  291. details,
  292. t.text,
  293. )
  294. } else {
  295. txt = fmt.Sprintf("%s %s", t.ID, t.text)
  296. }
  297. textLen := len(txt)
  298. padding := statusPadding - textLen
  299. if padding < 0 {
  300. padding = 0
  301. }
  302. // calculate the max length for the status text, on errors it
  303. // is 2-3 lines long and breaks the line formatting
  304. maxStatusLen := terminalWidth - textLen - statusPadding - 15
  305. status := t.statusText
  306. // in some cases (debugging under VS Code), terminalWidth is set to zero by goterm.Width() ; ensuring we don't tweak strings with negative char index
  307. if maxStatusLen > 0 && len(status) > maxStatusLen {
  308. status = status[:maxStatusLen] + "..."
  309. }
  310. text := fmt.Sprintf("%s %s%s %s%s %s",
  311. pad,
  312. spinner(t),
  313. prefix,
  314. txt,
  315. strings.Repeat(" ", padding),
  316. colorFn(t.status)(status),
  317. )
  318. timer := fmt.Sprintf("%.1fs ", elapsed)
  319. o := align(text, TimerColor(timer), terminalWidth)
  320. return o
  321. }
  322. var (
  323. spinnerDone = "✔"
  324. spinnerWarning = "!"
  325. spinnerError = "✘"
  326. )
  327. func spinner(t task) string {
  328. switch t.status {
  329. case Done:
  330. return SuccessColor(spinnerDone)
  331. case Warning:
  332. return WarningColor(spinnerWarning)
  333. case Error:
  334. return ErrorColor(spinnerError)
  335. default:
  336. return CountColor(t.spinner.String())
  337. }
  338. }
  339. func colorFn(s EventStatus) colorFunc {
  340. switch s {
  341. case Done:
  342. return SuccessColor
  343. case Warning:
  344. return WarningColor
  345. case Error:
  346. return ErrorColor
  347. default:
  348. return nocolor
  349. }
  350. }
  351. func numDone(tasks map[string]task) int {
  352. i := 0
  353. for _, t := range tasks {
  354. if t.status != Working {
  355. i++
  356. }
  357. }
  358. return i
  359. }
  360. func align(l, r string, w int) string {
  361. ll := lenAnsi(l)
  362. lr := lenAnsi(r)
  363. pad := ""
  364. count := w - ll - lr
  365. if count > 0 {
  366. pad = strings.Repeat(" ", count)
  367. }
  368. return fmt.Sprintf("%s%s%s\n", l, pad, r)
  369. }
  370. // lenAnsi count of user-perceived characters in ANSI string.
  371. func lenAnsi(s string) int {
  372. length := 0
  373. ansiCode := false
  374. for _, r := range s {
  375. if r == '\x1b' {
  376. ansiCode = true
  377. continue
  378. }
  379. if ansiCode && r == 'm' {
  380. ansiCode = false
  381. continue
  382. }
  383. if !ansiCode {
  384. length++
  385. }
  386. }
  387. return length
  388. }
  389. var percentChars = strings.Split("⠀⡀⣀⣄⣤⣦⣶⣷⣿", "")