diff --git a/examples/package-manager/main.go b/examples/package-manager/main.go new file mode 100644 index 0000000..e6f20ec --- /dev/null +++ b/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) + } +} diff --git a/examples/package-manager/packages.go b/examples/package-manager/packages.go new file mode 100644 index 0000000..7ed8478 --- /dev/null +++ b/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 +} diff --git a/standard_renderer.go b/standard_renderer.go index 4e75403..2d12271 100644 --- a/standard_renderer.go +++ b/standard_renderer.go @@ -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...), + } + } +} diff --git a/tea.go b/tea.go index fcbf822..ff58746 100644 --- a/tea.go +++ b/tea.go @@ -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...), + } +}