Skip to content

Commit ebabec7

Browse files
Adjective-ObjectMaxwell Huang-Hobbsmeowgorithm
authored
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>
1 parent a2d0ac9 commit ebabec7

4 files changed

Lines changed: 289 additions & 18 deletions

File tree

examples/package-manager/main.go

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"math/rand"
6+
"os"
7+
"strings"
8+
"time"
9+
10+
"github.com/charmbracelet/bubbles/progress"
11+
"github.com/charmbracelet/bubbles/spinner"
12+
tea "github.com/charmbracelet/bubbletea"
13+
"github.com/charmbracelet/lipgloss"
14+
)
15+
16+
type model struct {
17+
packages []string
18+
index int
19+
width int
20+
height int
21+
spinner spinner.Model
22+
progress progress.Model
23+
done bool
24+
}
25+
26+
var (
27+
currentPkgNameStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("211"))
28+
subtleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("239"))
29+
doneStyle = lipgloss.NewStyle().Margin(1, 2)
30+
checkMark = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).SetString("✓")
31+
)
32+
33+
func newModel() model {
34+
p := progress.New(
35+
progress.WithDefaultGradient(),
36+
progress.WithWidth(40),
37+
progress.WithoutPercentage(),
38+
)
39+
s := spinner.New()
40+
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("63"))
41+
return model{
42+
packages: getPackages(),
43+
spinner: s,
44+
progress: p,
45+
}
46+
}
47+
48+
func (m model) Init() tea.Cmd {
49+
return tea.Batch(downloadAndInstall(m.packages[m.index]), m.spinner.Tick)
50+
}
51+
52+
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
53+
switch msg := msg.(type) {
54+
case tea.WindowSizeMsg:
55+
m.width, m.height = msg.Width, msg.Height
56+
case tea.KeyMsg:
57+
switch msg.String() {
58+
case "ctrl+c", "esc", "q":
59+
return m, tea.Quit
60+
}
61+
case installedPkgMsg:
62+
if m.index >= len(m.packages)-1 {
63+
// Everything's been installed. We're done!
64+
m.done = true
65+
return m, tea.Quit
66+
}
67+
68+
// Update progress bar
69+
progressCmd := m.progress.SetPercent(float64(m.index) / float64(len(m.packages)-1))
70+
71+
m.index++
72+
return m, tea.Batch(
73+
progressCmd,
74+
tea.Printf("%s %s", checkMark, m.packages[m.index]), // print success message above our program
75+
downloadAndInstall(m.packages[m.index]), // download the next package
76+
)
77+
case spinner.TickMsg:
78+
var cmd tea.Cmd
79+
m.spinner, cmd = m.spinner.Update(msg)
80+
return m, cmd
81+
case progress.FrameMsg:
82+
newModel, cmd := m.progress.Update(msg)
83+
if newModel, ok := newModel.(progress.Model); ok {
84+
m.progress = newModel
85+
}
86+
return m, cmd
87+
}
88+
return m, nil
89+
}
90+
91+
func (m model) View() string {
92+
n := len(m.packages)
93+
w := lipgloss.Width(fmt.Sprintf("%d", n))
94+
95+
if m.done {
96+
return doneStyle.Render(fmt.Sprintf("Done! Installed %d packages.\n", n))
97+
}
98+
99+
pkgCount := fmt.Sprintf(" %*d/%*d", w, m.index, w, n-1)
100+
101+
spin := m.spinner.View() + " "
102+
prog := m.progress.View()
103+
cellsAvail := max(0, m.width-lipgloss.Width(spin+prog+pkgCount))
104+
105+
pkgName := currentPkgNameStyle.Render(m.packages[m.index])
106+
info := lipgloss.NewStyle().MaxWidth(cellsAvail).Render("Installing " + pkgName)
107+
108+
cellsRemaining := max(0, m.width-lipgloss.Width(spin+info+prog+pkgCount))
109+
gap := strings.Repeat(" ", cellsRemaining)
110+
111+
return spin + info + gap + prog + pkgCount
112+
}
113+
114+
type installedPkgMsg string
115+
116+
func downloadAndInstall(pkg string) tea.Cmd {
117+
// This is where you'd do i/o stuff to download and install packages. In
118+
// our case we're just pausing for a moment to simulate the process.
119+
d := time.Millisecond * time.Duration(rand.Intn(500))
120+
return tea.Tick(d, func(t time.Time) tea.Msg {
121+
return installedPkgMsg(pkg)
122+
})
123+
}
124+
125+
func max(a, b int) int {
126+
if a > b {
127+
return a
128+
}
129+
return b
130+
}
131+
132+
func main() {
133+
rand.Seed(time.Now().Unix())
134+
135+
if err := tea.NewProgram(newModel()).Start(); err != nil {
136+
fmt.Println("Error running program:", err)
137+
os.Exit(1)
138+
}
139+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"math/rand"
6+
)
7+
8+
var packages = []string{
9+
"vegeutils",
10+
"libgardening",
11+
"currykit",
12+
"spicerack",
13+
"fullenglish",
14+
"eggy",
15+
"bad-kitty",
16+
"chai",
17+
"hojicha",
18+
"libtacos",
19+
"babys-monads",
20+
"libpurring",
21+
"currywurst-devel",
22+
"xmodmeow",
23+
"licorice-utils",
24+
"cashew-apple",
25+
"rock-lobster",
26+
"standmixer",
27+
"coffee-CUPS",
28+
"libesszet",
29+
"zeichenorientierte-benutzerschnittstellen",
30+
"schnurrkit",
31+
"old-socks-devel",
32+
"jalapeño",
33+
"molasses-utils",
34+
"xkohlrabi",
35+
"party-gherkin",
36+
"snow-peas",
37+
"libyuzu",
38+
}
39+
40+
func getPackages() []string {
41+
pkgs := packages
42+
copy(pkgs, packages)
43+
44+
rand.Shuffle(len(pkgs), func(i, j int) {
45+
pkgs[i], pkgs[j] = pkgs[j], pkgs[i]
46+
})
47+
48+
for k := range pkgs {
49+
pkgs[k] += fmt.Sprintf("-%d.%d.%d", rand.Intn(10), rand.Intn(10), rand.Intn(10))
50+
}
51+
return pkgs
52+
}

standard_renderer.go

Lines changed: 71 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package tea
22

33
import (
44
"bytes"
5+
"fmt"
56
"io"
67
"strings"
78
"sync"
@@ -23,16 +24,17 @@ const (
2324
// In cases where very high performance is needed the renderer can be told
2425
// to exclude ranges of lines, allowing them to be written to directly.
2526
type standardRenderer struct {
26-
out io.Writer
27-
buf bytes.Buffer
28-
framerate time.Duration
29-
ticker *time.Ticker
30-
mtx *sync.Mutex
31-
done chan struct{}
32-
lastRender string
33-
linesRendered int
34-
useANSICompressor bool
35-
once sync.Once
27+
out io.Writer
28+
buf bytes.Buffer
29+
queuedMessageLines []string
30+
framerate time.Duration
31+
ticker *time.Ticker
32+
mtx *sync.Mutex
33+
done chan struct{}
34+
lastRender string
35+
linesRendered int
36+
useANSICompressor bool
37+
once sync.Once
3638

3739
// essentially whether or not we're using the full size of the terminal
3840
altScreenActive bool
@@ -49,10 +51,11 @@ type standardRenderer struct {
4951
// with os.Stdout as the first argument.
5052
func newRenderer(out io.Writer, mtx *sync.Mutex, useANSICompressor bool) renderer {
5153
r := &standardRenderer{
52-
out: out,
53-
mtx: mtx,
54-
framerate: defaultFramerate,
55-
useANSICompressor: useANSICompressor,
54+
out: out,
55+
mtx: mtx,
56+
framerate: defaultFramerate,
57+
useANSICompressor: useANSICompressor,
58+
queuedMessageLines: []string{},
5659
}
5760
if r.useANSICompressor {
5861
r.out = &compressor.Writer{Forward: out}
@@ -122,8 +125,16 @@ func (r *standardRenderer) flush() {
122125
out := new(bytes.Buffer)
123126

124127
newLines := strings.Split(r.buf.String(), "\n")
128+
numLinesThisFlush := len(newLines)
125129
oldLines := strings.Split(r.lastRender, "\n")
126130
skipLines := make(map[int]struct{})
131+
flushQueuedMessages := len(r.queuedMessageLines) > 0 && !r.altScreenActive
132+
133+
// Add any queued messages to this render
134+
if flushQueuedMessages {
135+
newLines = append(r.queuedMessageLines, newLines...)
136+
r.queuedMessageLines = []string{}
137+
}
127138

128139
// Clear any lines we painted in the last render.
129140
if r.linesRendered > 0 {
@@ -163,11 +174,9 @@ func (r *standardRenderer) flush() {
163174
}
164175
}
165176

166-
r.linesRendered = 0
167-
168177
// Paint new lines
169178
for i := 0; i < len(newLines); i++ {
170-
if _, skip := skipLines[r.linesRendered]; skip {
179+
if _, skip := skipLines[i]; skip {
171180
// Unless this is the last line, move the cursor down.
172181
if i < len(newLines)-1 {
173182
cursorDown(out)
@@ -192,8 +201,8 @@ func (r *standardRenderer) flush() {
192201
_, _ = io.WriteString(out, "\r\n")
193202
}
194203
}
195-
r.linesRendered++
196204
}
205+
r.linesRendered = numLinesThisFlush
197206

198207
// Make sure the cursor is at the start of the last line to keep rendering
199208
// behavior consistent.
@@ -383,6 +392,15 @@ func (r *standardRenderer) handleMessages(msg Msg) {
383392

384393
case scrollDownMsg:
385394
r.insertBottom(msg.lines, msg.topBoundary, msg.bottomBoundary)
395+
396+
case printLineMessage:
397+
if !r.altScreenActive {
398+
lines := strings.Split(msg.messageBody, "\n")
399+
r.mtx.Lock()
400+
r.queuedMessageLines = append(r.queuedMessageLines, lines...)
401+
r.repaint()
402+
r.mtx.Unlock()
403+
}
386404
}
387405
}
388406

@@ -460,3 +478,38 @@ func ScrollDown(newLines []string, topBoundary, bottomBoundary int) Cmd {
460478
}
461479
}
462480
}
481+
482+
type printLineMessage struct {
483+
messageBody string
484+
}
485+
486+
// Printf prints above the Program. This output is unmanaged by the program and
487+
// will persist across renders by the Program.
488+
//
489+
// Unlike fmt.Printf (but similar to log.Printf) the message will be print on
490+
// its own line.
491+
//
492+
// If the altscreen is active no output will be printed.
493+
func Println(args ...interface{}) Cmd {
494+
return func() Msg {
495+
return printLineMessage{
496+
messageBody: fmt.Sprint(args...),
497+
}
498+
}
499+
}
500+
501+
// Printf prints above the Program. It takes a format template followed by
502+
// values similar to fmt.Printf. This output is unmanaged by the program and
503+
// will persist across renders by the Program.
504+
//
505+
// Unlike fmt.Printf (but similar to log.Printf) the message will be print on
506+
// its own line.
507+
//
508+
// If the altscreen is active no output will be printed.
509+
func Printf(template string, args ...interface{}) Cmd {
510+
return func() Msg {
511+
return printLineMessage{
512+
messageBody: fmt.Sprintf(template, args...),
513+
}
514+
}
515+
}

tea.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -709,3 +709,30 @@ func (p *Program) RestoreTerminal() error {
709709

710710
return nil
711711
}
712+
713+
// Printf prints above the Program. This output is unmanaged by the program and
714+
// will persist across renders by the Program.
715+
//
716+
// Unlike fmt.Printf (but similar to log.Printf) the message will be print on
717+
// its own line.
718+
//
719+
// If the altscreen is active no output will be printed.
720+
func (p *Program) Println(args ...interface{}) {
721+
p.msgs <- printLineMessage{
722+
messageBody: fmt.Sprint(args...),
723+
}
724+
}
725+
726+
// Printf prints above the Program. It takes a format template followed by
727+
// values similar to fmt.Printf. This output is unmanaged by the program and
728+
// will persist across renders by the Program.
729+
//
730+
// Unlike fmt.Printf (but similar to log.Printf) the message will be print on
731+
// its own line.
732+
//
733+
// If the altscreen is active no output will be printed.
734+
func (p *Program) Printf(template string, args ...interface{}) {
735+
p.msgs <- printLineMessage{
736+
messageBody: fmt.Sprintf(template, args...),
737+
}
738+
}

0 commit comments

Comments
 (0)