Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow unmanaged output above the app in standard renderer #249

Merged
merged 12 commits into from Jun 22, 2022
139 changes: 139 additions & 0 deletions examples/package-manager/main.go
@@ -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)
}
}
52 changes: 52 additions & 0 deletions examples/package-manager/packages.go
@@ -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
}
89 changes: 71 additions & 18 deletions standard_renderer.go
Expand Up @@ -2,6 +2,7 @@ package tea

import (
"bytes"
"fmt"
"io"
"strings"
"sync"
Expand All @@ -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
Expand All @@ -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}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -192,8 +201,8 @@ func (r *standardRenderer) flush() {
_, _ = io.WriteString(out, "\r\n")
}
}
r.linesRendered++
}
r.linesRendered = numLinesThisFlush
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

newLines might be greater than the size of the appliation, so we can't use it as a loop tracker anymore for the above loop.

Might be worth renaming linesRendered to something like "linesInAppOnLastFlush", or "appHeight" or something similar to avoid confusing the size of the current render with the size we're considering the bubbletea-controlled area of the terminal.


// Make sure the cursor is at the start of the last line to keep rendering
// behavior consistent.
Expand Down Expand Up @@ -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()
}
}
}

Expand Down Expand Up @@ -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 changes: 27 additions & 0 deletions tea.go
Expand Up @@ -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...),
}
}