Skip to content

Commit

Permalink
chore: merge: feat: add Frame type and remove cursor commands (#1295)
Browse files Browse the repository at this point in the history
This commit introduces a new `Frame` type that represents a single frame
of the program's output. The `Frame` type contains the frame's content
and cursor settings. The cursor settings include the cursor's position,
style, blink, and visibility.

It also removes the cursor commands from the `tea` package. The cursor
commands are now part of the `Frame` type. The cursor commands were
removed because they were not idiomatic and were not flexible enough to
support additional cursor settings.

API:
```go
// Model contains the program's state as well as its core functions.
type Model interface {
	// Init is the first function that will be called. It returns an optional
	// initial command. To not perform an initial command return nil.
	Init() (Model, Cmd)

	// Update is called when a message is received. Use it to inspect messages
	// and, in response, update the model and/or send a command.
	Update(Msg) (Model, Cmd)

	// View renders the program's UI, which is just a [fmt.Stringer]. The view
	// is rendered after every Update.
	// The main model can return a [Frame] to set the cursor position and
	// style.
	View() fmt.Stringer
}

// Cursor represents a cursor on the terminal screen.
type Cursor struct {
	// Position is a [Position] that determines the cursor's position on the
	// screen relative to the top left corner of the frame.
	Position Position

	// Color is a [color.Color] that determines the cursor's color.
	Color color.Color

	// Shape is a [CursorShape] that determines the cursor's style.
	Shape CursorShape

	// Blink is a boolean that determines whether the cursor should blink.
	Blink bool
}

// NewCursor returns a new cursor with the default settings and the given
// position.
func NewCursor(x, y int) *Cursor {
	return &Cursor{
		Position: Position{X: x, Y: y},
		Color:    nil,
		Shape:    CursorBlock,
		Blink:    true,
	}
}

// Frame represents a single frame of the program's output.
type Frame struct {
	// Content contains the frame's content. This is the only required field.
	// It should be a string of text and ANSI escape codes.
	Content string

	// Cursor contains cursor settings for the frame. If nil, the cursor will
	// be hidden.
	Cursor *Cursor
}

// NewFrame creates a new frame with the given content.
func NewFrame(content string) Frame {
	return Frame{Content: content}
}

// String implements the fmt.Stringer interface for [Frame].
func (f Frame) String() string {
	return f.Content
}

```

Supersedes: #1293
  • Loading branch information
aymanbagabas authored Jan 23, 2025
2 parents e753b6a + 99e3bbf commit 443afa6
Show file tree
Hide file tree
Showing 64 changed files with 358 additions and 260 deletions.
70 changes: 54 additions & 16 deletions cursed_renderer.go
Original file line number Diff line number Diff line change
@@ -1,22 +1,25 @@
package tea

import (
"fmt"
"io"
"strings"
"sync"

"github.com/charmbracelet/colorprofile"
"github.com/charmbracelet/x/ansi"
"github.com/charmbracelet/x/cellbuf"
)

type cursedRenderer struct {
w io.Writer
scr *cellbuf.Screen
lastFrame *string
lastFrame *Frame
term string // the terminal type $TERM
width, height int
mu sync.Mutex
profile colorprofile.Profile
cursor Cursor
altScreen bool
cursorHidden bool
hardTabs bool // whether to use hard tabs to optimize cursor movements
Expand Down Expand Up @@ -44,33 +47,67 @@ func (s *cursedRenderer) close() (err error) {
func (s *cursedRenderer) flush() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.lastFrame != nil && s.lastFrame.Cursor != nil {
cur := s.lastFrame.Cursor
s.scr.MoveTo(cur.Position.X, cur.Position.Y)
s.cursor.Position = cur.Position

if cur.Shape != s.cursor.Shape || cur.Blink != s.cursor.Blink {
cursorStyle := encodeCursorStyle(cur.Shape, cur.Blink)
io.WriteString(s.w, ansi.SetCursorStyle(cursorStyle)) //nolint:errcheck
s.cursor.Shape = cur.Shape
s.cursor.Blink = cur.Blink
}
if cur.Color != s.cursor.Color {
seq := ansi.ResetCursorColor
if cur.Color != nil {
seq = ansi.SetCursorColor(cur.Color)
}
io.WriteString(s.w, seq) //nolint:errcheck
s.cursor.Color = cur.Color
}
}
s.scr.Render()
return nil
}

// render implements renderer.
func (s *cursedRenderer) render(frame string) {
func (s *cursedRenderer) render(frame fmt.Stringer) {
s.mu.Lock()
defer s.mu.Unlock()

if s.lastFrame != nil && frame == *s.lastFrame {
var f Frame
switch frame := frame.(type) {
case Frame:
f = frame
default:
f.Content = frame.String()
}

if s.lastFrame != nil && f == *s.lastFrame {
return
}

s.lastFrame = &frame
s.lastFrame = &f
if !s.altScreen {
// Inline mode resizes the screen based on the frame height and
// terminal width. This is because the frame height can change based on
// the content of the frame. For example, if the frame contains a list
// of items, the height of the frame will be the number of items in the
// list. This is different from the alt screen buffer, which has a
// fixed height and width.
frameHeight := strings.Count(frame, "\n") + 1
frameHeight := strings.Count(f.Content, "\n") + 1
s.scr.Resize(s.width, frameHeight)
}

if ctx := s.scr.DefaultWindow(); ctx != nil {
ctx.SetContent(frame)
ctx.SetContent(f.Content)
}

if f.Cursor == nil {
hideCursor(s)
} else {
showCursor(s)
}
}

Expand Down Expand Up @@ -138,25 +175,33 @@ func (s *cursedRenderer) exitAltScreen() {
s.altScreen = false
s.scr.ExitAltScreen()
s.scr.SetRelativeCursor(!s.altScreen)
s.scr.Resize(s.width, strings.Count(*s.lastFrame, "\n")+1)
s.scr.Resize(s.width, strings.Count((*s.lastFrame).Content, "\n")+1)
repaint(s)
s.mu.Unlock()
}

// showCursor implements renderer.
func (s *cursedRenderer) showCursor() {
s.mu.Lock()
showCursor(s)
s.mu.Unlock()
}

func showCursor(s *cursedRenderer) {
s.cursorHidden = false
s.scr.ShowCursor()
s.mu.Unlock()
}

// hideCursor implements renderer.
func (s *cursedRenderer) hideCursor() {
s.mu.Lock()
hideCursor(s)
s.mu.Unlock()
}

func hideCursor(s *cursedRenderer) {
s.cursorHidden = true
s.scr.HideCursor()
s.mu.Unlock()
}

// insertAbove implements renderer.
Expand All @@ -166,13 +211,6 @@ func (s *cursedRenderer) insertAbove(lines string) {
s.mu.Unlock()
}

// moveTo implements renderer.
func (s *cursedRenderer) moveTo(x, y int) {
s.mu.Lock()
s.scr.MoveTo(x, y)
s.mu.Unlock()
}

func (s *cursedRenderer) repaint() {
s.mu.Lock()
repaint(s)
Expand Down
51 changes: 8 additions & 43 deletions cursor.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,57 +2,22 @@ package tea

import "image"

// Position represents a position in the terminal.
type Position image.Point

// CursorPositionMsg is a message that represents the terminal cursor position.
type CursorPositionMsg image.Point
type CursorPositionMsg Position

// CursorStyle is a style that represents the terminal cursor.
type CursorStyle int
// CursorShape represents a terminal cursor shape.
type CursorShape int

// Cursor styles.
// Cursor shapes.
const (
CursorBlock CursorStyle = iota
CursorBlock CursorShape = iota
CursorUnderline
CursorBar
)

// setCursorStyle is an internal message that sets the cursor style. This matches the
// ANSI escape sequence values for cursor styles. This includes:
//
// 0: Blinking block
// 1: Blinking block (default)
// 2: Steady block
// 3: Blinking underline
// 4: Steady underline
// 5: Blinking bar (xterm)
// 6: Steady bar (xterm)
type setCursorStyle int

// SetCursorStyle is a command that sets the terminal cursor style. Steady
// determines if the cursor should blink or not.
func SetCursorStyle(style CursorStyle, blink bool) Cmd {
// We're using the ANSI escape sequence values for cursor styles.
// We need to map both [style] and [steady] to the correct value.
style = (style * 2) + 1
if !blink {
style++
}
return func() Msg {
return setCursorStyle(style)
}
}

// setCursorPosMsg represents a message to set the cursor position.
type setCursorPosMsg image.Point

// SetCursorPosition sets the cursor position to the specified relative
// coordinates. Using -1 for either x or y will not change the cursor position
// for that axis.
func SetCursorPosition(x, y int) Cmd {
return func() Msg {
return setCursorPosMsg{x, y}
}
}

// requestCursorPosMsg is a message that requests the cursor position.
type requestCursorPosMsg struct{}

Expand Down
11 changes: 7 additions & 4 deletions examples/altscreen-toggle/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,15 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}

func (m model) View() string {
func (m model) View() fmt.Stringer {
f := tea.NewFrame("")
if m.suspending {
return ""
return f
}

if m.quitting {
return "Bye!\n"
f.Content = "Bye!\n"
return f
}

const (
Expand All @@ -71,8 +73,9 @@ func (m model) View() string {
mode = inlineMode
}

return fmt.Sprintf("\n\n You're in %s\n\n\n", keywordStyle.Render(mode)) +
f.Content = fmt.Sprintf("\n\n You're in %s\n\n\n", keywordStyle.Render(mode)) +
helpStyle.Render(" space: switch modes • ctrl-z: suspend • q: exit\n")
return f
}

func main() {
Expand Down
6 changes: 3 additions & 3 deletions examples/autocomplete/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,10 +125,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, cmd
}

func (m model) View() string {
return fmt.Sprintf(
func (m model) View() fmt.Stringer {
return tea.NewFrame(fmt.Sprintf(
"Pick a Charm™ repo:\n\n %s\n\n%s\n\n",
m.textInput.View(),
m.help.View(m.keymap),
)
))
}
7 changes: 4 additions & 3 deletions examples/capability/main.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"fmt"
"log"

"github.com/charmbracelet/bubbles/v2/textinput"
Expand Down Expand Up @@ -45,16 +46,16 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}

// View implements tea.Model.
func (m model) View() string {
func (m model) View() fmt.Stringer {
w := min(m.width, 60)

instructions := lipgloss.NewStyle().
Width(w).
Render("Query for terminal capabilities. You can enter things like 'TN', 'RGB', 'cols', and so on. This will not work in all terminals and multiplexers.")

return "\n" + instructions + "\n\n" +
return tea.NewFrame("\n" + instructions + "\n\n" +
m.input.View() +
"\n\nPress enter to request capability, or ctrl+c to quit."
"\n\nPress enter to request capability, or ctrl+c to quit.")
}

func main() {
Expand Down
4 changes: 2 additions & 2 deletions examples/cellbuffer/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,8 +184,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}

func (m model) View() string {
return m.cells.String()
func (m model) View() fmt.Stringer {
return m.cells
}

func main() {
Expand Down
6 changes: 3 additions & 3 deletions examples/chat/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,11 +107,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}

func (m model) View() string {
return fmt.Sprintf(
func (m model) View() fmt.Stringer {
return tea.NewFrame(fmt.Sprintf(
"%s%s%s",
m.viewport.View(),
gap,
m.textarea.View(),
)
))
}
7 changes: 4 additions & 3 deletions examples/colorprofile/main.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"fmt"
"image/color"
"log"

Expand Down Expand Up @@ -36,11 +37,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}

// View implements tea.Model.
func (m model) View() string {
return "This will produce the wrong colors on Apple Terminal :)\n\n" +
func (m model) View() fmt.Stringer {
return tea.NewFrame("This will produce the wrong colors on Apple Terminal :)\n\n" +
ansi.Style{}.ForegroundColor(myFancyColor).Styled("Howdy!") +
"\n\n" +
"Press any key to exit."
"Press any key to exit.")
}

func main() {
Expand Down
13 changes: 7 additions & 6 deletions examples/composable-views/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"fmt"
"log"
"strings"
"time"

"github.com/charmbracelet/bubbles/v2/spinner"
Expand Down Expand Up @@ -123,16 +124,16 @@ func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...)
}

func (m mainModel) View() string {
var s string
func (m mainModel) View() fmt.Stringer {
var s strings.Builder
model := m.currentFocusedModel()
if m.state == timerView {
s += lipgloss.JoinHorizontal(lipgloss.Top, focusedModelStyle.Render(fmt.Sprintf("%4s", m.timer.View())), modelStyle.Render(m.spinner.View()))
s.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, focusedModelStyle.Render(fmt.Sprintf("%4s", m.timer.View())), modelStyle.Render(m.spinner.View())))
} else {
s += lipgloss.JoinHorizontal(lipgloss.Top, modelStyle.Render(fmt.Sprintf("%4s", m.timer.View())), focusedModelStyle.Render(m.spinner.View()))
s.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, modelStyle.Render(fmt.Sprintf("%4s", m.timer.View())), focusedModelStyle.Render(m.spinner.View())))
}
s += helpStyle.Render(fmt.Sprintf("\ntab: focus next • n: new %s • q: exit\n", model))
return s
s.WriteString(helpStyle.Render(fmt.Sprintf("\ntab: focus next • n: new %s • q: exit\n", model)))
return &s
}

func (m mainModel) currentFocusedModel() string {
Expand Down
Loading

0 comments on commit 443afa6

Please sign in to comment.