package bonfire // Live views ported from SSU's terminal UI (internal/cli/tui spinner.go, // process.go and progress.go) to bubbletea v2, without SSU's git and // engine types. Each runs as a bubbletea program with input off (stdin // stays in cooked mode, so Ctrl+C reaches the command's own signal // handling), no signal handler of its own and the command's context. import ( "context" "fmt" "io" "strings" "sync" "charm.land/bubbles/v2/progress" "charm.land/bubbles/v2/spinner" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" ) // Progress bar colors: the cyan to green gradient of the earlier bonfire // bar (256-color 37 and 34). var ( barFrom = lipgloss.Color("#00afaf") barTo = lipgloss.Color("#00af00") ) const barWidth = 40 // Messages sent to the live models from the command's goroutine. type ( // itemMsg reports progress: done of total, the current item, and a // failure of the current item when err is set. itemMsg struct { done, total int item string err error } // stepMsg reports one item of the process view changing state. stepMsg struct { item string state itemState err error done int } // finishMsg ends a live view; err is the result of the work. finishMsg struct{ err error } // printAboveMsg is a line to print above the view. printAboveMsg string ) // itemState is the display state of one item in the process view. type itemState int const ( itemWaiting itemState = iota itemRunning itemDone itemFailed ) const symWaiting = "○" // newSpinner is SSU's MiniDot spinner in the current (cyan) style. func newSpinner(p Palette) spinner.Model { return spinner.New(spinner.WithSpinner(spinner.MiniDot), spinner.WithStyle(p.Current)) } // newBar is the gradient progress bar without its own percentage. func newBar() progress.Model { bar := progress.New(progress.WithColors(barFrom, barTo), progress.WithoutPercentage()) bar.SetWidth(barWidth) return bar } // spinnerModel shows a spinner, the label and, once known, a done/total // counter with the current item; it ends with ✓ or ✗ and the label. type spinnerModel struct { palette Palette spin spinner.Model label string done int total int current string failed int finished bool err error } func newSpinnerModel(p Palette, label string) spinnerModel { return spinnerModel{palette: p, spin: newSpinner(p), label: label} } func (m spinnerModel) Init() tea.Cmd { return m.spin.Tick } func (m spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case itemMsg: m.done, m.total, m.current = msg.done, msg.total, msg.item if msg.err != nil { m.failed++ } return m, nil case finishMsg: m.finished, m.err = true, msg.err return m, tea.Quit case spinner.TickMsg: var cmd tea.Cmd m.spin, cmd = m.spin.Update(msg) return m, cmd } return m, nil } func (m spinnerModel) View() tea.View { p := m.palette if m.finished { if m.err != nil { return tea.NewView(p.Error.Render(symCross) + " " + m.label + "\n") } return tea.NewView(p.Success.Render(symCheck) + " " + m.label + "\n") } line := m.spin.View() + " " + m.label if m.total > 0 { line += fmt.Sprintf(" %d/%d", m.done, m.total) } if m.current != "" { line += " " + p.Muted.Render(m.current+"…") } if m.failed > 0 { line += " " + p.ErrorBold.Render(fmt.Sprintf("! %d failed", m.failed)) } return tea.NewView(line) } // progressModel shows the gradient bar, done/total and the current item. type progressModel struct { palette Palette bar progress.Model done int total int current string failed int finished bool } func newProgressModel(p Palette, total int) progressModel { return progressModel{palette: p, bar: newBar(), total: total} } func (m progressModel) Init() tea.Cmd { return nil } func (m progressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.bar.SetWidth(min(max(msg.Width-30, 10), barWidth)) return m, nil case itemMsg: m.done, m.total = msg.done, msg.total if msg.item != "" { m.current = msg.item } if msg.err != nil { m.failed++ } return m, nil case finishMsg: m.finished = true return m, tea.Quit } return m, nil } func (m progressModel) View() tea.View { pct := 0.0 if m.total > 0 { pct = float64(m.done) / float64(m.total) } line := " " + m.bar.ViewAs(pct) + m.palette.Muted.Render(fmt.Sprintf(" %d/%d", m.done, m.total)) if m.current != "" && !m.finished { line += " " + m.palette.Muted.Render(m.current) } if m.failed > 0 { line += " " + m.palette.ErrorBold.Render(fmt.Sprintf("! %d failed", m.failed)) } if m.finished { line += "\n" } return tea.NewView(line) } // processItem is one row of the process view. type processItem struct { name string state itemState err error } // processModel is SSU's multi-row view: one row per item (waiting, running // with a spinner, done or failed with its error) and the bar below. type processModel struct { palette Palette items []processItem spin spinner.Model bar progress.Model operation string done int height int finished bool } func newProcessModel(p Palette, operation string, items []string) processModel { rows := make([]processItem, len(items)) for i, name := range items { rows[i] = processItem{name: name} } return processModel{palette: p, items: rows, spin: newSpinner(p), bar: newBar(), operation: operation} } func (m processModel) Init() tea.Cmd { return m.spin.Tick } func (m processModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.height = msg.Height return m, nil case stepMsg: m.done = msg.done for i := range m.items { if m.items[i].name == msg.item && m.items[i].state < msg.state { m.items[i].state, m.items[i].err = msg.state, msg.err break } } return m, nil case finishMsg: m.finished = true return m, tea.Quit case spinner.TickMsg: var cmd tea.Cmd m.spin, cmd = m.spin.Update(msg) return m, cmd } return m, nil } // window is the range of rows that fits the terminal: around the running // item, or the last finished ones. func (m processModel) window() (int, int) { visible := len(m.items) if m.height > 0 && visible > m.height-3 { visible = max(m.height-3, 3) } start := 0 if visible < len(m.items) { active := -1 for i, it := range m.items { if it.state == itemRunning { active = i break } } if active >= 0 { start = min(max(active-visible/2, 0), len(m.items)-visible) } else { start = max(m.done-visible, 0) } } return start, min(start+visible, len(m.items)) } func (m processModel) View() tea.View { p := m.palette var b strings.Builder start, end := m.window() for _, it := range m.items[start:end] { switch it.state { case itemWaiting: b.WriteString(p.Muted.Render(" "+symWaiting) + " " + p.Muted.Render(it.name)) case itemRunning: b.WriteString(" " + m.spin.View() + " " + it.name) case itemDone: b.WriteString(p.Success.Render(" "+symCheck) + " " + it.name) case itemFailed: b.WriteString(p.Error.Render(" "+symCross) + " " + it.name) if it.err != nil { b.WriteString(" " + p.Error.Render(it.err.Error())) } } b.WriteString("\n") } total := len(m.items) pct := 0.0 if total > 0 { pct = float64(m.done) / float64(total) } op := m.operation if op == "" { op = "Processing" } b.WriteString(fmt.Sprintf("\n %s %d/%d %s %s", op, m.done, total, m.bar.ViewAs(pct), p.Muted.Render(fmt.Sprintf("%d%%", int(pct*100))))) if m.finished { b.WriteString("\n") } return tea.NewView(b.String()) } // liveModel wraps a view so lines from PrintAbove print above it. type liveModel struct { inner tea.Model } func (m liveModel) Init() tea.Cmd { return m.inner.Init() } func (m liveModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if line, ok := msg.(printAboveMsg); ok { return m, tea.Println(string(line)) } inner, cmd := m.inner.Update(msg) m.inner = inner return m, cmd } func (m liveModel) View() tea.View { return m.inner.View() } // The running live program, for PrintAbove. Only one live view runs at a // time; widgets started while one runs print plain lines. var ( liveMu sync.Mutex activeProg *tea.Program ) // liveActive reports whether a live view is running. func liveActive() bool { liveMu.Lock() defer liveMu.Unlock() return activeProg != nil } // runLive runs model as a bubbletea program on out while work runs on the // calling goroutine. work gets a send function for progress messages. When // work returns, the program is unregistered (so PrintAbove stops sending to // it), told to finish and waited for; it never receives lines after that. func runLive(ctx context.Context, out io.Writer, c *console, model tea.Model, work func(send func(tea.Msg)) error) error { if ctx == nil { ctx = context.Background() } prog := tea.NewProgram(liveModel{inner: model}, tea.WithInput(nil), tea.WithOutput(out), tea.WithoutSignalHandler(), tea.WithContext(ctx), tea.WithColorProfile(c.profile()), ) finished := make(chan struct{}) go func() { defer close(finished) _, _ = prog.Run() }() liveMu.Lock() activeProg = prog liveMu.Unlock() err := work(prog.Send) liveMu.Lock() activeProg = nil liveMu.Unlock() prog.Send(finishMsg{err: err}) <-finished return err } // printAboveLines prints s, one or more lines, above the running live view. // It reports false when no view runs. func printAboveLines(s string) bool { liveMu.Lock() defer liveMu.Unlock() if activeProg == nil { return false } for _, line := range strings.Split(strings.TrimSuffix(s, "\n"), "\n") { activeProg.Send(printAboveMsg(line)) } return true }