forked from Mirrors/bubbletea
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:
parent
a2d0ac9d38
commit
ebabec7008
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -2,6 +2,7 @@ package tea
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
@ -23,16 +24,17 @@ const (
|
||||||
// In cases where very high performance is needed the renderer can be told
|
// 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.
|
// to exclude ranges of lines, allowing them to be written to directly.
|
||||||
type standardRenderer struct {
|
type standardRenderer struct {
|
||||||
out io.Writer
|
out io.Writer
|
||||||
buf bytes.Buffer
|
buf bytes.Buffer
|
||||||
framerate time.Duration
|
queuedMessageLines []string
|
||||||
ticker *time.Ticker
|
framerate time.Duration
|
||||||
mtx *sync.Mutex
|
ticker *time.Ticker
|
||||||
done chan struct{}
|
mtx *sync.Mutex
|
||||||
lastRender string
|
done chan struct{}
|
||||||
linesRendered int
|
lastRender string
|
||||||
useANSICompressor bool
|
linesRendered int
|
||||||
once sync.Once
|
useANSICompressor bool
|
||||||
|
once sync.Once
|
||||||
|
|
||||||
// essentially whether or not we're using the full size of the terminal
|
// essentially whether or not we're using the full size of the terminal
|
||||||
altScreenActive bool
|
altScreenActive bool
|
||||||
|
@ -49,10 +51,11 @@ type standardRenderer struct {
|
||||||
// with os.Stdout as the first argument.
|
// with os.Stdout as the first argument.
|
||||||
func newRenderer(out io.Writer, mtx *sync.Mutex, useANSICompressor bool) renderer {
|
func newRenderer(out io.Writer, mtx *sync.Mutex, useANSICompressor bool) renderer {
|
||||||
r := &standardRenderer{
|
r := &standardRenderer{
|
||||||
out: out,
|
out: out,
|
||||||
mtx: mtx,
|
mtx: mtx,
|
||||||
framerate: defaultFramerate,
|
framerate: defaultFramerate,
|
||||||
useANSICompressor: useANSICompressor,
|
useANSICompressor: useANSICompressor,
|
||||||
|
queuedMessageLines: []string{},
|
||||||
}
|
}
|
||||||
if r.useANSICompressor {
|
if r.useANSICompressor {
|
||||||
r.out = &compressor.Writer{Forward: out}
|
r.out = &compressor.Writer{Forward: out}
|
||||||
|
@ -122,8 +125,16 @@ func (r *standardRenderer) flush() {
|
||||||
out := new(bytes.Buffer)
|
out := new(bytes.Buffer)
|
||||||
|
|
||||||
newLines := strings.Split(r.buf.String(), "\n")
|
newLines := strings.Split(r.buf.String(), "\n")
|
||||||
|
numLinesThisFlush := len(newLines)
|
||||||
oldLines := strings.Split(r.lastRender, "\n")
|
oldLines := strings.Split(r.lastRender, "\n")
|
||||||
skipLines := make(map[int]struct{})
|
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.
|
// Clear any lines we painted in the last render.
|
||||||
if r.linesRendered > 0 {
|
if r.linesRendered > 0 {
|
||||||
|
@ -163,11 +174,9 @@ func (r *standardRenderer) flush() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
r.linesRendered = 0
|
|
||||||
|
|
||||||
// Paint new lines
|
// Paint new lines
|
||||||
for i := 0; i < len(newLines); i++ {
|
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.
|
// Unless this is the last line, move the cursor down.
|
||||||
if i < len(newLines)-1 {
|
if i < len(newLines)-1 {
|
||||||
cursorDown(out)
|
cursorDown(out)
|
||||||
|
@ -192,8 +201,8 @@ func (r *standardRenderer) flush() {
|
||||||
_, _ = io.WriteString(out, "\r\n")
|
_, _ = 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
|
// Make sure the cursor is at the start of the last line to keep rendering
|
||||||
// behavior consistent.
|
// behavior consistent.
|
||||||
|
@ -383,6 +392,15 @@ func (r *standardRenderer) handleMessages(msg Msg) {
|
||||||
|
|
||||||
case scrollDownMsg:
|
case scrollDownMsg:
|
||||||
r.insertBottom(msg.lines, msg.topBoundary, msg.bottomBoundary)
|
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
27
tea.go
|
@ -709,3 +709,30 @@ func (p *Program) RestoreTerminal() error {
|
||||||
|
|
||||||
return nil
|
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...),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue