feat: print unmanaged output above the application (#249)

* merge Adjective-Object/tea_log_renderer into standard renderer

* rename queuedMessages -> queuedMessageLines & break apart strings during message processing

* delete cursorDownBy

* += 1 -> ++ to make the linter happy

* add skipLines[] tracking back to standard renderer, and add rename skippedLines local to jumpedLines to clarify they are separate comments

* request repaint when a message is recieved

* Convert Println and Printf to commands

* Add package manager example demonstrating tea.Printf

* Use Unix instead of UnixMicro for Go 1.13 support in CI

* fix off by one in std renderer

* add Printf/Println to tea.go

* revert attempt at sequence compression + cursorUpBy

Co-authored-by: Maxwell Huang-Hobbs <mahuangh@microsoft.com>
Co-authored-by: Christian Rocha <christian@rocha.is>
This commit is contained in:
Max 2022-06-22 12:53:02 -04:00 committed by GitHub
parent a2d0ac9d38
commit ebabec7008
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 289 additions and 18 deletions

View File

@ -0,0 +1,139 @@
package main
import (
"fmt"
"math/rand"
"os"
"strings"
"time"
"github.com/charmbracelet/bubbles/progress"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type model struct {
packages []string
index int
width int
height int
spinner spinner.Model
progress progress.Model
done bool
}
var (
currentPkgNameStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("211"))
subtleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("239"))
doneStyle = lipgloss.NewStyle().Margin(1, 2)
checkMark = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).SetString("✓")
)
func newModel() model {
p := progress.New(
progress.WithDefaultGradient(),
progress.WithWidth(40),
progress.WithoutPercentage(),
)
s := spinner.New()
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("63"))
return model{
packages: getPackages(),
spinner: s,
progress: p,
}
}
func (m model) Init() tea.Cmd {
return tea.Batch(downloadAndInstall(m.packages[m.index]), m.spinner.Tick)
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "esc", "q":
return m, tea.Quit
}
case installedPkgMsg:
if m.index >= len(m.packages)-1 {
// Everything's been installed. We're done!
m.done = true
return m, tea.Quit
}
// Update progress bar
progressCmd := m.progress.SetPercent(float64(m.index) / float64(len(m.packages)-1))
m.index++
return m, tea.Batch(
progressCmd,
tea.Printf("%s %s", checkMark, m.packages[m.index]), // print success message above our program
downloadAndInstall(m.packages[m.index]), // download the next package
)
case spinner.TickMsg:
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
case progress.FrameMsg:
newModel, cmd := m.progress.Update(msg)
if newModel, ok := newModel.(progress.Model); ok {
m.progress = newModel
}
return m, cmd
}
return m, nil
}
func (m model) View() string {
n := len(m.packages)
w := lipgloss.Width(fmt.Sprintf("%d", n))
if m.done {
return doneStyle.Render(fmt.Sprintf("Done! Installed %d packages.\n", n))
}
pkgCount := fmt.Sprintf(" %*d/%*d", w, m.index, w, n-1)
spin := m.spinner.View() + " "
prog := m.progress.View()
cellsAvail := max(0, m.width-lipgloss.Width(spin+prog+pkgCount))
pkgName := currentPkgNameStyle.Render(m.packages[m.index])
info := lipgloss.NewStyle().MaxWidth(cellsAvail).Render("Installing " + pkgName)
cellsRemaining := max(0, m.width-lipgloss.Width(spin+info+prog+pkgCount))
gap := strings.Repeat(" ", cellsRemaining)
return spin + info + gap + prog + pkgCount
}
type installedPkgMsg string
func downloadAndInstall(pkg string) tea.Cmd {
// This is where you'd do i/o stuff to download and install packages. In
// our case we're just pausing for a moment to simulate the process.
d := time.Millisecond * time.Duration(rand.Intn(500))
return tea.Tick(d, func(t time.Time) tea.Msg {
return installedPkgMsg(pkg)
})
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
func main() {
rand.Seed(time.Now().Unix())
if err := tea.NewProgram(newModel()).Start(); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
}

View File

@ -0,0 +1,52 @@
package main
import (
"fmt"
"math/rand"
)
var packages = []string{
"vegeutils",
"libgardening",
"currykit",
"spicerack",
"fullenglish",
"eggy",
"bad-kitty",
"chai",
"hojicha",
"libtacos",
"babys-monads",
"libpurring",
"currywurst-devel",
"xmodmeow",
"licorice-utils",
"cashew-apple",
"rock-lobster",
"standmixer",
"coffee-CUPS",
"libesszet",
"zeichenorientierte-benutzerschnittstellen",
"schnurrkit",
"old-socks-devel",
"jalapeño",
"molasses-utils",
"xkohlrabi",
"party-gherkin",
"snow-peas",
"libyuzu",
}
func getPackages() []string {
pkgs := packages
copy(pkgs, packages)
rand.Shuffle(len(pkgs), func(i, j int) {
pkgs[i], pkgs[j] = pkgs[j], pkgs[i]
})
for k := range pkgs {
pkgs[k] += fmt.Sprintf("-%d.%d.%d", rand.Intn(10), rand.Intn(10), rand.Intn(10))
}
return pkgs
}

View File

@ -2,6 +2,7 @@ package tea
import (
"bytes"
"fmt"
"io"
"strings"
"sync"
@ -23,16 +24,17 @@ const (
// In cases where very high performance is needed the renderer can be told
// to exclude ranges of lines, allowing them to be written to directly.
type standardRenderer struct {
out io.Writer
buf bytes.Buffer
framerate time.Duration
ticker *time.Ticker
mtx *sync.Mutex
done chan struct{}
lastRender string
linesRendered int
useANSICompressor bool
once sync.Once
out io.Writer
buf bytes.Buffer
queuedMessageLines []string
framerate time.Duration
ticker *time.Ticker
mtx *sync.Mutex
done chan struct{}
lastRender string
linesRendered int
useANSICompressor bool
once sync.Once
// essentially whether or not we're using the full size of the terminal
altScreenActive bool
@ -49,10 +51,11 @@ type standardRenderer struct {
// with os.Stdout as the first argument.
func newRenderer(out io.Writer, mtx *sync.Mutex, useANSICompressor bool) renderer {
r := &standardRenderer{
out: out,
mtx: mtx,
framerate: defaultFramerate,
useANSICompressor: useANSICompressor,
out: out,
mtx: mtx,
framerate: defaultFramerate,
useANSICompressor: useANSICompressor,
queuedMessageLines: []string{},
}
if r.useANSICompressor {
r.out = &compressor.Writer{Forward: out}
@ -122,8 +125,16 @@ func (r *standardRenderer) flush() {
out := new(bytes.Buffer)
newLines := strings.Split(r.buf.String(), "\n")
numLinesThisFlush := len(newLines)
oldLines := strings.Split(r.lastRender, "\n")
skipLines := make(map[int]struct{})
flushQueuedMessages := len(r.queuedMessageLines) > 0 && !r.altScreenActive
// Add any queued messages to this render
if flushQueuedMessages {
newLines = append(r.queuedMessageLines, newLines...)
r.queuedMessageLines = []string{}
}
// Clear any lines we painted in the last render.
if r.linesRendered > 0 {
@ -163,11 +174,9 @@ func (r *standardRenderer) flush() {
}
}
r.linesRendered = 0
// Paint new lines
for i := 0; i < len(newLines); i++ {
if _, skip := skipLines[r.linesRendered]; skip {
if _, skip := skipLines[i]; skip {
// Unless this is the last line, move the cursor down.
if i < len(newLines)-1 {
cursorDown(out)
@ -192,8 +201,8 @@ func (r *standardRenderer) flush() {
_, _ = io.WriteString(out, "\r\n")
}
}
r.linesRendered++
}
r.linesRendered = numLinesThisFlush
// Make sure the cursor is at the start of the last line to keep rendering
// behavior consistent.
@ -383,6 +392,15 @@ func (r *standardRenderer) handleMessages(msg Msg) {
case scrollDownMsg:
r.insertBottom(msg.lines, msg.topBoundary, msg.bottomBoundary)
case printLineMessage:
if !r.altScreenActive {
lines := strings.Split(msg.messageBody, "\n")
r.mtx.Lock()
r.queuedMessageLines = append(r.queuedMessageLines, lines...)
r.repaint()
r.mtx.Unlock()
}
}
}
@ -460,3 +478,38 @@ func ScrollDown(newLines []string, topBoundary, bottomBoundary int) Cmd {
}
}
}
type printLineMessage struct {
messageBody string
}
// Printf prints above the Program. This output is unmanaged by the program and
// will persist across renders by the Program.
//
// Unlike fmt.Printf (but similar to log.Printf) the message will be print on
// its own line.
//
// If the altscreen is active no output will be printed.
func Println(args ...interface{}) Cmd {
return func() Msg {
return printLineMessage{
messageBody: fmt.Sprint(args...),
}
}
}
// Printf prints above the Program. It takes a format template followed by
// values similar to fmt.Printf. This output is unmanaged by the program and
// will persist across renders by the Program.
//
// Unlike fmt.Printf (but similar to log.Printf) the message will be print on
// its own line.
//
// If the altscreen is active no output will be printed.
func Printf(template string, args ...interface{}) Cmd {
return func() Msg {
return printLineMessage{
messageBody: fmt.Sprintf(template, args...),
}
}
}

27
tea.go
View File

@ -709,3 +709,30 @@ func (p *Program) RestoreTerminal() error {
return nil
}
// Printf prints above the Program. This output is unmanaged by the program and
// will persist across renders by the Program.
//
// Unlike fmt.Printf (but similar to log.Printf) the message will be print on
// its own line.
//
// If the altscreen is active no output will be printed.
func (p *Program) Println(args ...interface{}) {
p.msgs <- printLineMessage{
messageBody: fmt.Sprint(args...),
}
}
// Printf prints above the Program. It takes a format template followed by
// values similar to fmt.Printf. This output is unmanaged by the program and
// will persist across renders by the Program.
//
// Unlike fmt.Printf (but similar to log.Printf) the message will be print on
// its own line.
//
// If the altscreen is active no output will be printed.
func (p *Program) Printf(template string, args ...interface{}) {
p.msgs <- printLineMessage{
messageBody: fmt.Sprintf(template, args...),
}
}