diff --git a/cellbuf/buffer.go b/cellbuf/buffer.go index 804064a0..845687d9 100644 --- a/cellbuf/buffer.go +++ b/cellbuf/buffer.go @@ -35,8 +35,8 @@ func (b *Buffer) Cell(x, y int) (Cell, bool) { return b.cells[idx], true } -// SetCell sets the cell at the given x, y position. -func (b *Buffer) SetCell(x, y int, c Cell) (v bool) { +// Draw sets the cell at the given x, y position. +func (b *Buffer) Draw(x, y int, c Cell) (v bool) { if b.width == 0 { return } @@ -55,7 +55,7 @@ func (b *Buffer) SetCell(x, y int, c Cell) (v bool) { prev := b.cells[idx] if prev.Width > 1 { // Writing to the first wide cell - for j := 0; j < prev.Width; j++ { + for j := 0; j < prev.Width && idx+j < len(b.cells); j++ { newCell := prev newCell.Content = " " newCell.Width = 1 @@ -82,7 +82,7 @@ func (b *Buffer) SetCell(x, y int, c Cell) (v bool) { // Mark wide cells with emptyCell zero width // We set the wide cell down below if c.Width > 1 { - for j := 1; j < c.Width; j++ { + for j := 1; j < c.Width && idx+j < len(b.cells); j++ { b.cells[idx+j] = emptyCell } } @@ -116,3 +116,39 @@ func (b *Buffer) Resize(width, height int) { b.cells = b.cells[:area] } } + +// Bounds returns the bounds of the buffer. +func (b *Buffer) Bounds() Rectangle { + return Rect(0, 0, b.Width(), b.Height()) +} + +// Fill fills the buffer with the given cell. If rect is not nil, it fills the +// rectangle with the cell. Otherwise, it fills the whole buffer. +func (b *Buffer) Fill(c Cell, rect *Rectangle) { + Fill(b, c, rect) +} + +// Clear clears the buffer with space cells. If rect is not nil, it clears the +// rectangle. Otherwise, it clears the whole buffer. +func (b *Buffer) Clear(rect *Rectangle) { + Clear(b, rect) +} + +// Paint writes the given data to the buffer. If rect is not nil, it writes the +// data within the rectangle. Otherwise, it writes the data to the whole +// buffer. +func (b *Buffer) Paint(m Method, data string, rect *Rectangle) []int { + return Paint(b, m, data, rect) +} + +// Render returns a string representation of the buffer with ANSI escape +// sequences. +func (b *Buffer) Render(opts ...RenderOption) string { + return Render(b, opts...) +} + +// RenderLine returns a string representation of the yth line of the buffer along +// with the width of the line. +func (b *Buffer) RenderLine(n int, opts ...RenderOption) (w int, line string) { + return RenderLine(b, n, opts...) +} diff --git a/cellbuf/geom.go b/cellbuf/geom.go new file mode 100644 index 00000000..86bd5b78 --- /dev/null +++ b/cellbuf/geom.go @@ -0,0 +1,44 @@ +package cellbuf + +import ( + "fmt" + "image" +) + +// Position represents an x, y position. +type Position image.Point + +// String returns a string representation of the position. +func (p Position) String() string { + return image.Point(p).String() +} + +// Pos is a shorthand for Position{X: x, Y: y}. +func Pos(x, y int) Position { + return Position{X: x, Y: y} +} + +// Rectange represents a rectangle. +type Rectangle struct { + X, Y, Width, Height int +} + +// String returns a string representation of the rectangle. +func (r Rectangle) String() string { + return fmt.Sprintf("(%d,%d)-(%d,%d)", r.X, r.Y, r.X+r.Width, r.Y+r.Height) +} + +// Bounds returns the rectangle as an image.Rectangle. +func (r Rectangle) Bounds() image.Rectangle { + return image.Rect(r.X, r.Y, r.X+r.Width, r.Y+r.Height) +} + +// Contains reports whether the rectangle contains the given point. +func (r Rectangle) Contains(p Position) bool { + return image.Point(p).In(r.Bounds()) +} + +// Rect is a shorthand for Rectangle. +func Rect(x, y, w, h int) Rectangle { + return Rectangle{X: x, Y: y, Width: w, Height: h} +} diff --git a/cellbuf/screen.go b/cellbuf/screen.go index 9b2ea1a2..257cacde 100644 --- a/cellbuf/screen.go +++ b/cellbuf/screen.go @@ -12,8 +12,7 @@ import ( // attributes and hyperlink. type Segment = Cell -// Screen represents an interface for a grid of cells that can be written to -// and read from. +// Screen represents a screen grid of cells. type Screen interface { // Width returns the width of the grid. Width() int @@ -21,34 +20,49 @@ type Screen interface { // Height returns the height of the grid. Height() int - // SetCell writes a cell to the grid at the given position. It returns true - // if the cell was written successfully. - SetCell(x, y int, c Cell) bool - // Cell returns the cell at the given position. Cell(x, y int) (Cell, bool) - // Resize resizes the grid to the given width and height. - Resize(width, height int) + // Draw writes a cell to the grid at the given position. It returns true if + // the cell was written successfully. + Draw(x, y int, c Cell) bool } -// SetContent writes the given data to the grid starting from the first cell. -func SetContent(d Screen, m Method, content string) []int { - return setContent(d, content, m) +// Paint writes the given data to the canvas. If rect is not nil, it only +// writes to the rectangle. Otherwise, it writes to the whole canvas. +func Paint(d Screen, m Method, content string, rect *Rectangle) []int { + if rect == nil { + rect = &Rectangle{0, 0, d.Width(), d.Height()} + } + return setContent(d, content, m, *rect) } -// Render returns a string representation of the grid with ANSI escape sequences. -func Render(d Screen) string { - return RenderWithProfile(d, colorprofile.TrueColor) +// RenderOptions represents options for rendering a canvas. +type RenderOptions struct { + // Profile is the color profile to use when rendering the canvas. + Profile colorprofile.Profile +} + +// RenderOption is a function that configures a RenderOptions. +type RenderOption func(*RenderOptions) + +// WithRenderProfile sets the color profile to use when rendering the canvas. +func WithRenderProfile(p colorprofile.Profile) RenderOption { + return func(o *RenderOptions) { + o.Profile = p + } } -// RenderWithProfile returns a string representation of the grid with ANSI escape -// sequences converting styles and colors to the given color profile. -func RenderWithProfile(d Screen, p colorprofile.Profile) string { +// Render returns a string representation of the grid with ANSI escape sequences. +func Render(d Screen, opts ...RenderOption) string { + var opt RenderOptions + for _, o := range opts { + o(&opt) + } var buf bytes.Buffer height := d.Height() for y := 0; y < height; y++ { - _, line := RenderLineWithProfile(d, y, p) + _, line := renderLine(d, y, opt) buf.WriteString(line) if y < height-1 { buf.WriteString("\r\n") @@ -59,14 +73,15 @@ func RenderWithProfile(d Screen, p colorprofile.Profile) string { // RenderLine returns a string representation of the yth line of the grid along // with the width of the line. -func RenderLine(d Screen, n int) (w int, line string) { - return RenderLineWithProfile(d, n, colorprofile.TrueColor) +func RenderLine(d Screen, n int, opts ...RenderOption) (w int, line string) { + var opt RenderOptions + for _, o := range opts { + o(&opt) + } + return renderLine(d, n, opt) } -// RenderLineWithProfile returns a string representation of the nth line of the -// grid along with the width of the line converting styles and colors to the -// given color profile. -func RenderLineWithProfile(d Screen, n int, p colorprofile.Profile) (w int, line string) { +func renderLine(d Screen, n int, opt RenderOptions) (w int, line string) { var pen Style var link Link var buf bytes.Buffer @@ -87,8 +102,8 @@ func RenderLineWithProfile(d Screen, n int, p colorprofile.Profile) (w int, line for x := 0; x < d.Width(); x++ { if cell, ok := d.Cell(x, n); ok && cell.Width > 0 { // Convert the cell's style and link to the given color profile. - cellStyle := cell.Style.Convert(p) - cellLink := cell.Link.Convert(p) + cellStyle := cell.Style.Convert(opt.Profile) + cellLink := cell.Link.Convert(opt.Profile) if cellStyle.Empty() && !pen.Empty() { writePending() buf.WriteString(ansi.ResetStyle) //nolint:errcheck @@ -134,15 +149,26 @@ func RenderLineWithProfile(d Screen, n int, p colorprofile.Profile) (w int, line return w, strings.TrimRight(buf.String(), " ") // Trim trailing spaces } -// Fill fills the grid with the given cell. -func Fill(d Screen, c Cell) { - for y := 0; y < d.Height(); y++ { - for x := 0; x < d.Width(); x++ { - d.SetCell(x, y, c) //nolint:errcheck +// Fill fills the canvas with the given cell. If rect is not nil, it only fills +// the rectangle. Otherwise, it fills the whole canvas. +func Fill(d Screen, c Cell, rect *Rectangle) { + if rect == nil { + rect = &Rectangle{0, 0, d.Width(), d.Height()} + } + + for y := rect.Y; y < rect.Y+rect.Height; y++ { + for x := rect.X; x < rect.X+rect.Width; x += c.Width { + d.Draw(x, y, c) //nolint:errcheck } } } +// Clear clears the canvas with space cells. If rect is not nil, it only clears +// the rectangle. Otherwise, it clears the whole canvas. +func Clear(d Screen, rect *Rectangle) { + Fill(d, spaceCell, rect) +} + // Equal returns whether two grids are equal. func Equal(a, b Screen) bool { if a.Width() != b.Width() || a.Height() != b.Height() { diff --git a/cellbuf/screen_write.go b/cellbuf/screen_write.go index a2765b55..371ea5df 100644 --- a/cellbuf/screen_write.go +++ b/cellbuf/screen_write.go @@ -12,14 +12,15 @@ import ( // setContent writes the given data to the buffer starting from the first cell. // It accepts both string and []byte data types. func setContent( - dis Screen, + d Screen, data string, method Method, + rect Rectangle, ) []int { var cell Cell var pen Style var link Link - var x, y int + x, y := rect.X, rect.Y p := ansi.GetParser() defer ansi.PutParser(p) @@ -28,7 +29,7 @@ func setContent( // linew is a slice of line widths. We use this to keep track of the // written widths of each line. We use this information later to optimize // rendering of the buffer. - linew := make([]int, dis.Height()) + linew := make([]int, rect.Height) var pendingWidth int @@ -52,18 +53,22 @@ func setContent( } fallthrough case 1: + if x >= rect.X+rect.Width || y >= rect.Y+rect.Height { + break + } + cell.Content = seq cell.Width = width cell.Style = pen cell.Link = link - dis.SetCell(x, y, cell) //nolint:errcheck + d.Draw(x, y, cell) //nolint:errcheck // Advance the cursor and line width x += cell.Width if cell.Equal(spaceCell) { pendingWidth += cell.Width - } else if y < len(linew) { + } else if y := y - rect.Y; y < len(linew) { linew[y] += cell.Width + pendingWidth pendingWidth = 0 } @@ -84,8 +89,8 @@ func setContent( } case ansi.Equal(seq, "\n"): // Reset the rest of the line - for x < dis.Width() { - dis.SetCell(x, y, spaceCell) //nolint:errcheck + for x < rect.X+rect.Width { + d.Draw(x, y, spaceCell) //nolint:errcheck x++ } @@ -102,8 +107,8 @@ func setContent( data = data[n:] } - for x < dis.Width() { - dis.SetCell(x, y, spaceCell) //nolint:errcheck + for x < rect.X+rect.Width { + d.Draw(x, y, spaceCell) //nolint:errcheck x++ } diff --git a/examples/cellbuf/main.go b/examples/cellbuf/main.go new file mode 100644 index 00000000..c19e8ec1 --- /dev/null +++ b/examples/cellbuf/main.go @@ -0,0 +1,93 @@ +package main + +import ( + "log" + "os" + "runtime" + + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/cellbuf" + "github.com/charmbracelet/x/input" + "github.com/charmbracelet/x/term" +) + +func main() { + w, h, err := term.GetSize(os.Stdout.Fd()) + if err != nil { + log.Fatalf("getting terminal size: %v", err) + } + + state, err := term.MakeRaw(os.Stdin.Fd()) + if err != nil { + log.Fatalf("making raw: %v", err) + } + + defer term.Restore(os.Stdin.Fd(), state) + + drv, err := input.NewDriver(os.Stdin, os.Getenv("TERM"), 0) + if err != nil { + log.Fatalf("creating input driver: %v", err) + } + + os.Stdout.WriteString(ansi.EnableAltScreenBuffer + ansi.EnableMouseCellMotion + ansi.EnableMouseSgrExt) + defer os.Stdout.WriteString(ansi.DisableMouseSgrExt + ansi.DisableMouseCellMotion + ansi.DisableAltScreenBuffer) + + var buf cellbuf.Buffer + var style cellbuf.Style + style.Reverse(true) + x, y := (w/2)-8, h/2 + buf.Resize(w, h) + + reset(&buf, x, y) + + if runtime.GOOS != "windows" { + // Listen for resize events + go listenForResize(func() { + updateWinsize(&buf) + reset(&buf, x, y) + }) + } + + for { + evs, err := drv.ReadEvents() + if err != nil { + log.Fatalf("reading events: %v", err) + } + + for _, ev := range evs { + switch ev := ev.(type) { + case input.WindowSizeEvent: + updateWinsize(&buf) + case input.MouseClickEvent: + x, y = ev.X, ev.Y + case input.KeyPressEvent: + switch ev.String() { + case "ctrl+c", "q": + return + case "left": + x-- + case "down": + y++ + case "up": + y-- + case "right": + x++ + } + } + } + + reset(&buf, x, y) + } +} + +func reset(buf *cellbuf.Buffer, x, y int) { + buf.Fill(cellbuf.Cell{Content: "你", Width: 2}, nil) + buf.Paint(0, "\x1b[7m !Hello, world! \x1b[m", &cellbuf.Rectangle{X: x, Y: y, Width: 16, Height: 1}) + os.Stdout.WriteString(ansi.SetCursorPosition(1, 1) + buf.Render()) +} + +func updateWinsize(buf *cellbuf.Buffer) (w, h int) { + w, h, _ = term.GetSize(os.Stdout.Fd()) + buf.Resize(w, h) + return +} diff --git a/examples/cellbuf/winsize_other.go b/examples/cellbuf/winsize_other.go new file mode 100644 index 00000000..c4977cd3 --- /dev/null +++ b/examples/cellbuf/winsize_other.go @@ -0,0 +1,19 @@ +//go:build !windows +// +build !windows + +package main + +import ( + "os" + "os/signal" + "syscall" +) + +func listenForResize(fn func()) { + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGWINCH) + + for range sig { + fn() + } +} diff --git a/examples/cellbuf/winsize_windows.go b/examples/cellbuf/winsize_windows.go new file mode 100644 index 00000000..41536f83 --- /dev/null +++ b/examples/cellbuf/winsize_windows.go @@ -0,0 +1,6 @@ +//go:build windows +// +build windows + +package main + +func listenForResize(func()) {} diff --git a/examples/go.mod b/examples/go.mod index c8b3f6cf..eb5c306e 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -2,12 +2,36 @@ module examples go 1.18 -require github.com/charmbracelet/x/ansi v0.1.4 - -require github.com/rivo/uniseg v0.4.7 // indirect +require ( + github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.1.0.20241105155825-ead55032fd81 + github.com/charmbracelet/lipgloss/v2 v2.0.0-20241105145349-c8e32d1b422c + github.com/charmbracelet/x/ansi v0.4.4 + github.com/charmbracelet/x/cellbuf v0.0.5 + github.com/lucasb-eyer/go-colorful v1.2.0 +) + +require ( + github.com/charmbracelet/colorprofile v0.1.6 // indirect + github.com/charmbracelet/x/input v0.2.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect +) + +require ( + github.com/charmbracelet/x/term v0.2.0 // indirect + github.com/charmbracelet/x/wcwidth v0.0.0-20241011142426-46044092ad91 // indirect + github.com/charmbracelet/x/windows v0.2.0 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/rivo/uniseg v0.4.7 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/sys v0.26.0 // indirect + golang.org/x/text v0.19.0 // indirect +) replace github.com/charmbracelet/x/ansi => ../ansi +replace github.com/charmbracelet/x/cellbuf => ../cellbuf + replace github.com/charmbracelet/x/term => ../term replace github.com/charmbracelet/x/input => ../input diff --git a/examples/go.sum b/examples/go.sum index 9008848b..846524ca 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -1,2 +1,26 @@ +github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.1.0.20241105155825-ead55032fd81 h1:J40lkNAii38iaDD8B/Eh2+mHL2U7DzS48RqXe0ap5OQ= +github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.1.0.20241105155825-ead55032fd81/go.mod h1:24niqT9RbtXhWg8zLRU/v/xTixlo1+DUsHQZ3+kez5Y= +github.com/charmbracelet/colorprofile v0.1.6 h1:nMMqCns0c0DfCwNGdagBh6SxutFqkltSxxKk5S9kt+Y= +github.com/charmbracelet/colorprofile v0.1.6/go.mod h1:3EMXDxwRDJl0c17eJ1jX99MhtlP9OxE/9Qw0C5lvyUg= +github.com/charmbracelet/lipgloss/v2 v2.0.0-20241105145349-c8e32d1b422c h1:rzZvGgEkGHwO34rdQuxR6yR2O/EGWw6PGV4g/w3FyPU= +github.com/charmbracelet/lipgloss/v2 v2.0.0-20241105145349-c8e32d1b422c/go.mod h1:M8oXIuQtauwIZFuFREK6cYi+2fu0YNiBFnmCP7N8c2c= +github.com/charmbracelet/x/wcwidth v0.0.0-20241011142426-46044092ad91 h1:D5OO0lVavz7A+Swdhp62F9gbkibxmz9B2hZ/jVdMPf0= +github.com/charmbracelet/x/wcwidth v0.0.0-20241011142426-46044092ad91/go.mod h1:Ey8PFmYwH+/td9bpiEx07Fdx9ZVkxfIjWXxBluxF4Nw= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= diff --git a/go.work.sum b/go.work.sum index 8e87f3b3..a02fd1fa 100644 --- a/go.work.sum +++ b/go.work.sum @@ -9,6 +9,7 @@ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5t golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk=