Initial version.

This commit is contained in:
Daniel Schmidt 2023-05-12 09:53:45 +02:00
parent 77574097d5
commit 4d4dfb7da3
21 changed files with 1784 additions and 0 deletions

3
.gitignore vendored
View File

@ -1,3 +1,6 @@
# Ide
.idea
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#

50
adapter.go Normal file
View File

@ -0,0 +1,50 @@
package crt
import "github.com/hajimehoshi/ebiten/v2"
type WindowSize struct {
Width int
Height int
}
type MouseButton struct {
Button ebiten.MouseButton
X int
Y int
Shift bool
Alt bool
Ctrl bool
JustPressed bool
JustReleased bool
}
type MouseMotion struct {
X int
Y int
}
type MouseWheel struct {
X int
Y int
DX float64
DY float64
Shift bool
Alt bool
Ctrl bool
}
type KeyPress struct {
Key ebiten.Key
Runes []rune
Shift bool
Alt bool
Ctrl bool
}
type InputAdapter interface {
HandleMouseButton(button MouseButton)
HandleMouseMotion(motion MouseMotion)
HandleMouseWheel(wheel MouseWheel)
HandleKeyPress()
HandleWindowSize(size WindowSize)
}

27
adapter_empty.go Normal file
View File

@ -0,0 +1,27 @@
package crt
type EmptyAdapter struct{}
func NewEmptyAdapter() *EmptyAdapter {
return &EmptyAdapter{}
}
func (e *EmptyAdapter) HandleMouseButton(button MouseButton) {
}
func (e *EmptyAdapter) HandleMouseMotion(motion MouseMotion) {
}
func (e *EmptyAdapter) HandleMouseWheel(wheel MouseWheel) {
}
func (e *EmptyAdapter) HandleKeyPress() {
}
func (e *EmptyAdapter) HandleWindowSize(size WindowSize) {
}

View File

@ -0,0 +1,127 @@
package bubbletea
import (
"github.com/BigJk/crt"
tea "github.com/charmbracelet/bubbletea"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/inpututil"
"strings"
)
var ebitenToTeaKeys = map[ebiten.Key]tea.KeyType{
ebiten.KeyEnter: tea.KeyEnter,
ebiten.KeyTab: tea.KeyTab,
ebiten.KeySpace: tea.KeySpace,
ebiten.KeyBackspace: tea.KeyBackspace,
ebiten.KeyDelete: tea.KeyDelete,
ebiten.KeyHome: tea.KeyHome,
ebiten.KeyEnd: tea.KeyEnd,
ebiten.KeyPageUp: tea.KeyPgUp,
ebiten.KeyArrowUp: tea.KeyUp,
ebiten.KeyArrowDown: tea.KeyDown,
ebiten.KeyArrowLeft: tea.KeyLeft,
ebiten.KeyArrowRight: tea.KeyRight,
ebiten.KeyEscape: tea.KeyEscape,
}
var ebitenToTeaRunes = map[ebiten.Key][]rune{
ebiten.Key1: {'1'},
ebiten.Key2: {'2'},
ebiten.Key3: {'3'},
ebiten.Key4: {'4'},
ebiten.Key5: {'5'},
ebiten.Key6: {'6'},
ebiten.Key7: {'7'},
ebiten.Key8: {'8'},
ebiten.Key9: {'9'},
ebiten.Key0: {'0'},
}
var ebitenToTeaMouse = map[ebiten.MouseButton]tea.MouseEventType{
ebiten.MouseButtonLeft: tea.MouseLeft,
ebiten.MouseButtonMiddle: tea.MouseMiddle,
ebiten.MouseButtonRight: tea.MouseRight,
}
type BubbleTeaAdapter struct {
prog *tea.Program
}
func NewBubbleTeaAdapter(prog *tea.Program) *BubbleTeaAdapter {
return &BubbleTeaAdapter{prog: prog}
}
func (b *BubbleTeaAdapter) HandleMouseMotion(motion crt.MouseMotion) {
b.prog.Send(tea.MouseMsg{
X: motion.X,
Y: motion.Y,
Alt: false,
Ctrl: false,
Type: tea.MouseMotion,
})
}
func (b *BubbleTeaAdapter) HandleMouseButton(button crt.MouseButton) {
b.prog.Send(tea.MouseMsg{
X: button.X,
Y: button.Y,
Alt: ebiten.IsKeyPressed(ebiten.KeyAlt),
Ctrl: ebiten.IsKeyPressed(ebiten.KeyControl),
Type: ebitenToTeaMouse[button.Button],
})
}
func (b *BubbleTeaAdapter) HandleMouseWheel(wheel crt.MouseWheel) {
if wheel.DY > 0 {
b.prog.Send(tea.MouseMsg{
X: wheel.X,
Y: wheel.Y,
Alt: ebiten.IsKeyPressed(ebiten.KeyAlt),
Ctrl: ebiten.IsKeyPressed(ebiten.KeyControl),
Type: tea.MouseWheelUp,
})
} else if wheel.DY < 0 {
b.prog.Send(tea.MouseMsg{
X: wheel.X,
Y: wheel.Y,
Alt: ebiten.IsKeyPressed(ebiten.KeyAlt),
Ctrl: ebiten.IsKeyPressed(ebiten.KeyControl),
Type: tea.MouseWheelDown,
})
}
}
func (b *BubbleTeaAdapter) HandleKeyPress() {
var keys []ebiten.Key
keys = inpututil.AppendJustReleasedKeys(keys)
for _, k := range keys {
runes := []rune(strings.ToLower(k.String()))
if val, ok := ebitenToTeaRunes[k]; ok {
runes = val
}
b.prog.Send(tea.KeyMsg{
Type: tea.KeyRunes,
Runes: runes,
Alt: ebiten.IsKeyPressed(ebiten.KeyAlt),
})
}
for k, v := range ebitenToTeaKeys {
if inpututil.IsKeyJustReleased(k) {
runes := []rune(strings.ToLower(k.String()))
b.prog.Send(tea.KeyMsg{
Type: v,
Runes: runes,
Alt: ebiten.IsKeyPressed(ebiten.KeyAlt),
})
}
}
}
func (b *BubbleTeaAdapter) HandleWindowSize(size crt.WindowSize) {
b.prog.Send(tea.WindowSizeMsg{
Width: size.Width,
Height: size.Height,
})
}

61
bubbletea/bubbletea.go Normal file
View File

@ -0,0 +1,61 @@
package bubbletea
import (
"fmt"
"github.com/BigJk/crt"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/muesli/termenv"
"image/color"
"os"
"syscall"
)
func init() {
lipgloss.SetColorProfile(termenv.TrueColor)
}
type fakeEnviron struct{}
func (f fakeEnviron) Environ() []string {
return []string{"TERM", "COLORTERM"}
}
func (f fakeEnviron) Getenv(s string) string {
switch s {
case "TERM":
return "xterm-256color"
case "COLORTERM":
return "truecolor"
}
return ""
}
func Window(width int, height int, fonts crt.Fonts, model tea.Model, defaultBg color.Color, options ...tea.ProgramOption) (*crt.Window, error) {
gameInput := crt.NewConcurrentRW()
gameOutput := crt.NewConcurrentRW()
go gameInput.Run()
go gameOutput.Run()
prog := tea.NewProgram(
model,
append([]tea.ProgramOption{
tea.WithMouseAllMotion(),
tea.WithInput(gameInput),
tea.WithOutput(termenv.NewOutput(gameOutput, termenv.WithEnvironment(fakeEnviron{}), termenv.WithTTY(true), termenv.WithProfile(termenv.TrueColor), termenv.WithColorCache(true))),
tea.WithANSICompressor(),
}, options...)...,
)
go func() {
if _, err := prog.Run(); err != nil {
fmt.Printf("Alas, there's been an error: %v", err)
os.Exit(1)
}
_ = syscall.Kill(syscall.Getpid(), syscall.SIGINT)
}()
return crt.NewGame(width, height, fonts, gameOutput, NewBubbleTeaAdapter(prog), defaultBg)
}

25
cell.go Normal file
View File

@ -0,0 +1,25 @@
package crt
import "image/color"
// FontWeight is the weight of a font at a certain terminal cell.
type FontWeight byte
const (
// FontWeightNormal is the default font weight.
FontWeightNormal FontWeight = iota
// FontWeightBold is a bold font weight.
FontWeightBold
// FontWeightItalic is an italic font weight.
FontWeightItalic
)
// GridCell is a single cell in the terminal grid.
type GridCell struct {
Char rune
Fg color.Color
Bg color.Color
Weight FontWeight
}

493
crt.go Normal file
View File

@ -0,0 +1,493 @@
package crt
import (
"fmt"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
"github.com/hajimehoshi/ebiten/v2/inpututil"
"github.com/hajimehoshi/ebiten/v2/text"
"github.com/muesli/ansi"
"image"
"image/color"
"io"
"math/rand"
"sync"
"syscall"
)
type Window struct {
sync.Mutex
// Terminal dimensions and grid.
grid [][]GridCell
cellsWidth int
cellsHeight int
cellWidth int
cellHeight int
cellOffsetY int
// Input and output.
inputAdapter InputAdapter
tty io.Reader
// Terminal cursor and color states.
cursorX int
cursorY int
mouseCellX int
mouseCellY int
defaultBg color.Color
curFg color.Color
curBg color.Color
curWeight FontWeight
// Other
crtShader bool
showTps bool
fonts Fonts
bgColors *image.RGBA
shader *ebiten.Shader
routine sync.Once
tick float64
}
// NewGame creates a new terminal game with the given dimensions and font faces.
func NewGame(width int, height int, fonts Fonts, tty io.Reader, adapter InputAdapter, defaultBg color.Color) (*Window, error) {
if defaultBg == nil {
defaultBg = color.Black
}
bounds, _, _ := fonts.Normal.GlyphBounds([]rune("█")[0])
size := bounds.Max.Sub(bounds.Min)
cellWidth := size.X.Round()
cellHeight := size.Y.Round()
cellOffsetY := -bounds.Min.Y.Round()
cellsWidth := width / cellWidth
cellsHeight := height / cellHeight
grid := make([][]GridCell, cellsHeight)
for y := 0; y < cellsHeight; y++ {
grid[y] = make([]GridCell, cellsWidth)
for x := 0; x < cellsWidth; x++ {
grid[y][x] = GridCell{
Char: ' ',
Fg: color.White,
Bg: defaultBg,
Weight: FontWeightNormal,
}
}
}
shader, err := crtShader()
if err != nil {
return nil, err
}
game := &Window{
inputAdapter: adapter,
cellsWidth: cellsWidth,
cellsHeight: cellsHeight,
cellWidth: cellWidth,
cellHeight: cellHeight,
cellOffsetY: cellOffsetY,
fonts: fonts,
defaultBg: defaultBg,
grid: grid,
tty: tty,
bgColors: image.NewRGBA(image.Rect(0, 0, cellsWidth*cellWidth, cellsHeight*cellHeight)),
shader: shader,
}
game.inputAdapter.HandleWindowSize(WindowSize{
Width: cellsWidth - 1,
Height: cellsHeight,
})
game.ResetSGR()
return game, nil
}
// CRTShader enables or disables the CRT shader. This is just a visual effect.
func (g *Window) CRTShader(val bool) {
g.crtShader = val
}
// ShowTPS enables or disables the TPS counter on the top left.
func (g *Window) ShowTPS(val bool) {
g.showTps = val
}
// ResetSGR resets the SGR attributes to their default values.
func (g *Window) ResetSGR() {
g.curFg = color.White
g.curBg = g.defaultBg
g.curWeight = FontWeightNormal
}
// SetBgPixels sets a chunk of background pixels in the size of the cell.
func (g *Window) SetBgPixels(x, y int, c color.Color) {
for i := 0; i < g.cellWidth; i++ {
for j := 0; j < g.cellHeight; j++ {
g.bgColors.Set(x*g.cellWidth+i, y*g.cellHeight+j, c)
}
}
}
func (g *Window) handleCSI(csi any) {
switch seq := csi.(type) {
case CursorUpSeq:
g.cursorY -= seq.Count
if g.cursorY < 0 {
g.cursorY = 0
}
case CursorDownSeq:
g.cursorY += seq.Count
if g.cursorY >= g.cellsHeight {
g.cursorY = g.cellsHeight - 1
}
case CursorForwardSeq:
g.cursorX += seq.Count
if g.cursorX >= g.cellsWidth {
g.cursorX = g.cellsWidth - 1
}
case CursorBackSeq:
g.cursorX -= seq.Count
if g.cursorX < 0 {
g.cursorX = 0
}
case CursorNextLineSeq:
g.cursorY += seq.Count
if g.cursorY >= g.cellsHeight {
g.cursorY = g.cellsHeight - 1
}
g.cursorX = 0
case CursorPreviousLineSeq:
g.cursorY -= seq.Count
if g.cursorY < 0 {
g.cursorY = 0
}
g.cursorX = 0
case CursorHorizontalSeq:
g.cursorX = seq.Count - 1
case CursorPositionSeq:
g.cursorX = seq.Col - 1
g.cursorY = seq.Row - 1
if g.cursorX < 0 {
g.cursorX = 0
} else if g.cursorX >= g.cellsWidth {
g.cursorX = g.cellsWidth - 1
}
if g.cursorY < 0 {
g.cursorY = 0
} else if g.cursorY >= g.cellsHeight {
g.cursorY = g.cellsHeight - 1
}
case EraseDisplaySeq:
if seq.Type != 2 {
return // only support 2 (erase entire display)
}
for i := 0; i < g.cellsWidth; i++ {
for j := 0; j < g.cellsHeight; j++ {
g.grid[j][i].Char = ' '
g.grid[j][i].Fg = color.White
g.grid[j][i].Bg = g.defaultBg
}
}
case EraseLineSeq:
switch seq.Type {
case 0: // erase from cursor to end of line
for i := g.cursorX; i < g.cellsWidth-g.cursorX; i++ {
g.grid[g.cursorY][g.cursorX+i].Char = ' '
g.grid[g.cursorY][g.cursorX+i].Fg = color.White
g.grid[g.cursorY][g.cursorX+i].Bg = g.defaultBg
g.SetBgPixels(g.cursorX+i, g.cursorY, g.defaultBg)
}
case 1: // erase from start of line to cursor
for i := 0; i < g.cursorX; i++ {
g.grid[g.cursorY][i].Char = ' '
g.grid[g.cursorY][i].Fg = color.White
g.grid[g.cursorY][i].Bg = g.defaultBg
g.SetBgPixels(i, g.cursorY, g.defaultBg)
}
case 2: // erase entire line
for i := 0; i < g.cellsWidth; i++ {
g.grid[g.cursorY][i].Char = ' '
g.grid[g.cursorY][i].Fg = color.White
g.grid[g.cursorY][i].Bg = g.defaultBg
g.SetBgPixels(i, g.cursorY, g.defaultBg)
}
}
case ScrollUpSeq:
fmt.Println("UNSUPPORTED: ScrollUpSeq", seq.Count)
case ScrollDownSeq:
fmt.Println("UNSUPPORTED: ScrollDownSeq", seq.Count)
case SaveCursorPositionSeq:
fmt.Println("UNSUPPORTED: SaveCursorPositionSeq")
case RestoreCursorPositionSeq:
fmt.Println("UNSUPPORTED: RestoreCursorPositionSeq")
case ChangeScrollingRegionSeq:
fmt.Println("UNSUPPORTED: ChangeScrollingRegionSeq")
case InsertLineSeq:
fmt.Println("UNSUPPORTED: InsertLineSeq")
case DeleteLineSeq:
fmt.Println("UNSUPPORTED: DeleteLineSeq")
}
}
func (g *Window) handleSGR(sgr any) {
switch seq := sgr.(type) {
case SGRReset:
g.ResetSGR()
case SGRBold:
g.curWeight = FontWeightBold
case SGRItalic:
g.curWeight = FontWeightItalic
case SGRUnsetBold:
g.curWeight = FontWeightNormal
case SGRUnsetItalic:
g.curWeight = FontWeightNormal
case SGRFgTrueColor:
g.curFg = color.RGBA{seq.R, seq.G, seq.B, 255}
case SGRBgTrueColor:
g.curBg = color.RGBA{seq.R, seq.G, seq.B, 255}
}
}
func (g *Window) parseSequences(str string, printExtra bool) int {
runes := []rune(str)
lastFound := 0
for i := 0; i < len(runes); i++ {
if sgr, ok := extractSGR(string(runes[i:])); ok {
i += len(sgr) - 1
if sgr, ok := parseSGR(sgr); ok {
lastFound = i
for i := range sgr {
g.handleSGR(sgr[i])
}
}
} else if csi, ok := extractCSI(string(runes[i:])); ok {
i += len(csi) - 1
if csi, ok := parseCSI(csi); ok {
lastFound = i
g.handleCSI(csi)
}
} else if printExtra {
g.PrintChar(runes[i], g.curFg, g.curBg, g.curWeight)
}
}
return lastFound
}
func (g *Window) RecalculateBackgrounds() {
for i := 0; i < g.cellsWidth; i++ {
for j := 0; j < g.cellsHeight; j++ {
g.SetBgPixels(i, j, g.grid[j][i].Bg)
}
}
}
// PrintChar prints a character to the screen.
func (g *Window) PrintChar(r rune, fg, bg color.Color, weight FontWeight) {
if r == '\n' {
g.cursorX = 0
g.cursorY++
return
}
if ansi.PrintableRuneWidth(string(r)) == 0 {
return
}
// Wrap around if we're at the end of the line.
if g.cursorX >= g.cellsWidth {
g.cursorX = 0
g.cursorY++
}
// Scroll down if we're at the bottom and add a new line.
if g.cursorY >= g.cellsHeight {
diff := g.cursorY - g.cellsHeight + 1
g.grid = g.grid[diff:]
for i := 0; i < diff; i++ {
g.grid = append(g.grid, make([]GridCell, g.cellsWidth))
for i := 0; i < g.cellsWidth; i++ {
g.grid[len(g.grid)-1][i].Char = ' '
g.grid[len(g.grid)-1][i].Fg = color.White
g.grid[len(g.grid)-1][i].Bg = g.defaultBg
}
}
g.cursorY = g.cellsHeight - 1
g.RecalculateBackgrounds()
}
// Set the cell.
g.grid[g.cursorY][g.cursorX].Char = r
g.grid[g.cursorY][g.cursorX].Fg = fg
g.grid[g.cursorY][g.cursorX].Bg = bg
g.grid[g.cursorY][g.cursorX].Weight = weight
// Set the pixels.
g.SetBgPixels(g.cursorX, g.cursorY, g.grid[g.cursorY][g.cursorX].Bg)
// Move the cursor.
g.cursorX++
}
func (g *Window) Update() error {
g.routine.Do(func() {
go func() {
buf := make([]byte, 1024)
for {
n, err := g.tty.Read(buf)
if err != nil {
fmt.Println("ERROR: ", err)
continue
}
if n == 0 {
continue
}
g.Lock()
{
line := string(buf[:n])
g.parseSequences(line, true)
}
g.Unlock()
}
}()
})
mx, my := ebiten.CursorPosition()
mcx, mcy := mx/g.cellWidth, my/g.cellHeight
if mcx != g.mouseCellX || mcy != g.mouseCellY {
g.mouseCellX = mcx
g.mouseCellY = mcy
g.inputAdapter.HandleMouseMotion(MouseMotion{
X: g.mouseCellX,
Y: g.mouseCellY,
})
}
// Mouse buttons.
if inpututil.IsMouseButtonJustReleased(ebiten.MouseButtonLeft) {
g.inputAdapter.HandleMouseButton(MouseButton{
X: g.mouseCellX,
Y: g.mouseCellY,
Shift: ebiten.IsKeyPressed(ebiten.KeyShift),
Alt: ebiten.IsKeyPressed(ebiten.KeyAlt),
Ctrl: ebiten.IsKeyPressed(ebiten.KeyControl),
Button: ebiten.MouseButtonLeft,
JustPressed: false,
JustReleased: true,
})
} else if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) {
g.inputAdapter.HandleMouseButton(MouseButton{
X: g.mouseCellX,
Y: g.mouseCellY,
Shift: ebiten.IsKeyPressed(ebiten.KeyShift),
Alt: ebiten.IsKeyPressed(ebiten.KeyAlt),
Ctrl: ebiten.IsKeyPressed(ebiten.KeyControl),
Button: ebiten.MouseButtonLeft,
JustPressed: true,
JustReleased: false,
})
}
// Mouse wheel.
_, wy := ebiten.Wheel()
if wy > 0 || wy < 0 {
g.inputAdapter.HandleMouseWheel(MouseWheel{
X: g.mouseCellX,
Y: g.mouseCellY,
Shift: ebiten.IsKeyPressed(ebiten.KeyShift),
Alt: ebiten.IsKeyPressed(ebiten.KeyAlt),
Ctrl: ebiten.IsKeyPressed(ebiten.KeyControl),
DX: 0,
DY: wy,
})
}
// Keyboard.
g.inputAdapter.HandleKeyPress()
return nil
}
func (g *Window) Draw(screen *ebiten.Image) {
g.Lock()
defer g.Unlock()
bufImager := ebiten.NewImage(g.cellsWidth*g.cellWidth, g.cellsHeight*g.cellHeight)
// Draw background
bufImager.WritePixels(g.bgColors.Pix)
// Draw text
for y := 0; y < g.cellsHeight; y++ {
for x := 0; x < g.cellsWidth; x++ {
if g.grid[y][x].Char == ' ' {
continue
}
switch g.grid[y][x].Weight {
case FontWeightNormal:
text.Draw(bufImager, string(g.grid[y][x].Char), g.fonts.Normal, x*g.cellWidth, y*g.cellHeight+g.cellOffsetY, g.grid[y][x].Fg)
case FontWeightBold:
text.Draw(bufImager, string(g.grid[y][x].Char), g.fonts.Bold, x*g.cellWidth, y*g.cellHeight+g.cellOffsetY, g.grid[y][x].Fg)
case FontWeightItalic:
text.Draw(bufImager, string(g.grid[y][x].Char), g.fonts.Italic, x*g.cellWidth, y*g.cellHeight+g.cellOffsetY, g.grid[y][x].Fg)
}
}
}
g.tick += 1 / 60.0
if g.crtShader {
var options ebiten.DrawRectShaderOptions
options.GeoM.Translate(0, 0)
options.Images[0] = bufImager
options.Uniforms = map[string]any{
"Seed": rand.Float64() * 10000,
"Tick": g.tick,
}
screen.DrawRectShader(g.cellsWidth*g.cellWidth, g.cellsHeight*g.cellHeight, g.shader, &options)
} else {
screen.DrawImage(bufImager, nil)
}
if g.showTps {
ebitenutil.DebugPrint(screen, fmt.Sprintf("TPS: %0.2f", ebiten.CurrentTPS()))
}
}
func (g *Window) Layout(outsideWidth, outsideHeight int) (int, int) {
return g.cellsWidth * g.cellWidth, g.cellsHeight * g.cellHeight
}
func (g *Window) Run(title string) error {
sw, sh := g.Layout(0, 0)
ebiten.SetScreenFilterEnabled(false)
ebiten.SetWindowSize(sw, sh)
ebiten.SetWindowTitle(title)
if err := ebiten.RunGame(g); err != nil {
return err
}
return nil
}
func (g *Window) Kill() {
_ = syscall.Kill(syscall.Getpid(), syscall.SIGINT)
}

81
crt_shader.go Normal file
View File

@ -0,0 +1,81 @@
package crt
import "github.com/hajimehoshi/ebiten/v2"
// crtShader returns a shader that simulates a CRT display in Kage language.
//
// Credits: https://quasilyte.dev/blog/post/ebitengine-shaders/#round-3-the-crt-display-effect
func crtShader() (*ebiten.Shader, error) {
return ebiten.NewShader([]byte(`
package main
var Seed float
var Tick float
func tex2pixCoord(texCoord vec2) vec2 {
pixSize := imageSrcTextureSize()
originTexCoord, _ := imageSrcRegionOnTexture()
actualTexCoord := texCoord - originTexCoord
actualPixCoord := actualTexCoord * pixSize
return actualPixCoord
}
func pix2texCoord(actualPixCoord vec2) vec2 {
pixSize := imageSrcTextureSize()
actualTexCoord := actualPixCoord / pixSize
originTexCoord, _ := imageSrcRegionOnTexture()
texCoord := actualTexCoord + originTexCoord
return texCoord
}
func applyPixPick(pixCoord vec2, dist float, m, hash int) vec2 {
dir := hash % m
if dir == int(0) {
pixCoord.x += dist
} else if dir == int(1) {
pixCoord.x -= dist
} else if dir == int(2) {
pixCoord.y += dist
} else if dir == int(3) {
pixCoord.y -= dist
}
// Otherwise, don't move it anywhere.
return pixCoord
}
func shaderRand(pixCoord vec2) (seedMod, randValue int) {
pixSize := imageSrcTextureSize()
pixelOffset := int(pixCoord.x) + int(pixCoord.y*pixSize.x)
seedMod = pixelOffset % int(Seed)
pixelOffset += seedMod
return seedMod, pixelOffset + int(Seed)
}
func applyVideoDegradation(y float, c vec4) vec4 {
if c.a != 0.0 {
// Every 4th pixel on the Y axis will be darkened.
if int(y+Tick)%4 != int(0) {
return c * 0.8
}
}
return c
}
func Fragment(pos vec4, texCoord vec2, _ vec4) vec4 {
c := imageSrc0At(texCoord)
actualPixCoord := tex2pixCoord(texCoord)
if c.a != 0.0 {
seedMod, h := shaderRand(actualPixCoord)
dist := 1.0
if seedMod == int(0) {
dist = 2.0
}
p := applyPixPick(actualPixCoord, dist, 10, h)
return applyVideoDegradation(pos.y, imageSrc0At(pix2texCoord(p)))
}
return c
}
`))
}

191
csi.go Normal file
View File

@ -0,0 +1,191 @@
package crt
import (
"github.com/muesli/termenv"
"strconv"
"strings"
)
type CursorUpSeq struct {
Count int
}
type CursorDownSeq struct {
Count int
}
type CursorForwardSeq struct {
Count int
}
type CursorBackSeq struct {
Count int
}
type CursorNextLineSeq struct {
Count int
}
type CursorPreviousLineSeq struct {
Count int
}
type CursorHorizontalSeq struct {
Count int
}
type CursorPositionSeq struct {
Row int
Col int
}
type EraseDisplaySeq struct {
Type int
}
type EraseLineSeq struct {
Type int
}
type ScrollUpSeq struct {
Count int
}
type ScrollDownSeq struct {
Count int
}
type SaveCursorPositionSeq struct{}
type RestoreCursorPositionSeq struct{}
type ChangeScrollingRegionSeq struct {
Top int
Bottom int
}
type InsertLineSeq struct {
Count int
}
type DeleteLineSeq struct {
Count int
}
// extractCSI extracts a CSI sequence from the beginning of a string.
// It returns the sequence without any suffix, and a boolean indicating
// whether a sequence was found.
func extractCSI(s string) (string, bool) {
if !strings.HasPrefix(s, termenv.CSI) {
return "", false
}
s = s[len(termenv.CSI):]
if len(s) == 0 {
return "", false
}
for i, c := range s {
if c >= '@' && c <= '~' {
return termenv.CSI + s[:i+1], true
}
}
return "", false
}
// parseCSI parses a CSI sequence and returns a struct representing the sequence.
func parseCSI(s string) (any, bool) {
if !strings.HasPrefix(s, termenv.CSI) {
return nil, false
}
s = s[len(termenv.CSI):]
if len(s) == 0 {
return nil, false
}
switch s[len(s)-1] {
case 'A':
if count, err := strconv.Atoi(s[:len(s)-1]); err == nil {
return CursorUpSeq{Count: count}, true
}
case 'B':
if count, err := strconv.Atoi(s[:len(s)-1]); err == nil {
return CursorDownSeq{Count: count}, true
}
case 'C':
if count, err := strconv.Atoi(s[:len(s)-1]); err == nil {
return CursorForwardSeq{Count: count}, true
}
case 'D':
if count, err := strconv.Atoi(s[:len(s)-1]); err == nil {
return CursorBackSeq{Count: count}, true
}
case 'E':
if count, err := strconv.Atoi(s[:len(s)-1]); err == nil {
return CursorNextLineSeq{Count: count}, true
}
case 'F':
if count, err := strconv.Atoi(s[:len(s)-1]); err == nil {
return CursorPreviousLineSeq{Count: count}, true
}
case 'G':
if count, err := strconv.Atoi(s[:len(s)-1]); err == nil {
return CursorHorizontalSeq{Count: count}, true
}
case 'H':
if strings.Contains(s, ";") {
parts := strings.Split(s[:len(s)-1], ";")
if len(parts) != 2 {
return nil, false
}
row, err := strconv.Atoi(parts[0])
if err != nil {
return nil, false
}
col, err := strconv.Atoi(parts[1])
if err != nil {
return nil, false
}
return CursorPositionSeq{Row: row, Col: col}, true
}
return nil, false
case 'J':
if t, err := strconv.Atoi(s[:len(s)-1]); err == nil {
return EraseDisplaySeq{Type: t}, true
}
case 'K':
if t, err := strconv.Atoi(s[:len(s)-1]); err == nil {
return EraseLineSeq{Type: t}, true
}
case 'S':
if count, err := strconv.Atoi(s[:len(s)-1]); err == nil {
return ScrollUpSeq{Count: count}, true
}
case 'T':
if count, err := strconv.Atoi(s[:len(s)-1]); err == nil {
return ScrollDownSeq{Count: count}, true
}
case 's':
if len(s) == 1 {
return SaveCursorPositionSeq{}, true
}
case 'u':
if len(s) == 1 {
return RestoreCursorPositionSeq{}, true
}
case 'r':
// TODO: implement
case 'L':
if count, err := strconv.Atoi(s[:len(s)-1]); err == nil {
return InsertLineSeq{Count: count}, true
}
case 'M':
if count, err := strconv.Atoi(s[:len(s)-1]); err == nil {
return DeleteLineSeq{Count: count}, true
}
}
return nil, false
}

40
csi_test.go Normal file
View File

@ -0,0 +1,40 @@
package crt
import (
"fmt"
"github.com/muesli/termenv"
"github.com/stretchr/testify/assert"
"testing"
)
func TestCSI(t *testing.T) {
var testString string
testString += fmt.Sprintf(termenv.CSI+termenv.EraseDisplaySeq, 20)
testString += "HELLO WORLD"
testString += fmt.Sprintf(termenv.CSI+termenv.CursorPositionSeq, 1, 2)
testString += fmt.Sprintf(termenv.CSI+termenv.CursorPositionSeq, 1, 2)
testString += "HELLO WORLD"
testString += fmt.Sprintf(termenv.CSI+termenv.CursorPositionSeq, 1, 2)
testString += fmt.Sprintf(termenv.CSI+termenv.CursorBackSeq, 5)
var sequences []any
for i := 0; i < len(testString); i++ {
csi, ok := extractCSI(testString[i:])
if ok {
i += len(csi) - 1
if res, ok := parseCSI(csi); ok {
sequences = append(sequences, res)
}
}
}
assert.Equal(t, []any{
EraseDisplaySeq{Type: 20},
CursorPositionSeq{Row: 1, Col: 2},
CursorPositionSeq{Row: 1, Col: 2},
CursorPositionSeq{Row: 1, Col: 2},
CursorBackSeq{Count: 5},
}, sequences)
}

View File

@ -0,0 +1,203 @@
package main
import (
"fmt"
"github.com/BigJk/crt"
bubbleadapter "github.com/BigJk/crt/bubbletea"
"github.com/muesli/termenv"
"image/color"
"math/rand"
"strings"
"time"
"github.com/charmbracelet/bubbles/progress"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
const (
Width = 1000
Height = 600
)
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
}
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("#e7c6ff"))
doneStyle = lipgloss.NewStyle().Margin(1, 2)
checkMark = lipgloss.NewStyle().Foreground(lipgloss.Color("#8ac926")).SetString("✓")
)
func newModel() model {
p := progress.New(
progress.WithDefaultGradient(),
progress.WithWidth(40),
progress.WithoutPercentage(),
progress.WithColorProfile(termenv.TrueColor),
progress.WithGradient("#231942", "#be95c4"),
)
s := spinner.New()
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#669bbc"))
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())
fonts, err := crt.LoadFaces("./fonts/IosevkaTermNerdFontMono-Regular.ttf", "./fonts/IosevkaTermNerdFontMono-Bold.ttf", "./fonts/IosevkaTermNerdFontMono-Italic.ttf", 72.0, 16.0)
if err != nil {
panic(err)
}
win, err := bubbleadapter.Window(Width, Height, fonts, newModel(), color.Black)
if err != nil {
panic(err)
}
if err := win.Run("Simple"); err != nil {
panic(err)
}
}

45
examples/simple/main.go Normal file
View File

@ -0,0 +1,45 @@
package main
import (
"github.com/BigJk/crt"
bubbleadapter "github.com/BigJk/crt/bubbletea"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"image/color"
)
const (
Width = 1000
Height = 600
)
type model struct {
}
func (m model) Init() tea.Cmd {
return nil
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
func (m model) View() string {
return lipgloss.NewStyle().Margin(5).Padding(5).Border(lipgloss.ThickBorder(), true).Foreground(lipgloss.Color("#ff00ff")).Render("Hello World!")
}
func main() {
fonts, err := crt.LoadFaces("./fonts/IosevkaTermNerdFontMono-Regular.ttf", "./fonts/IosevkaTermNerdFontMono-Bold.ttf", "./fonts/IosevkaTermNerdFontMono-Italic.ttf", 72.0, 16.0)
if err != nil {
panic(err)
}
win, err := bubbleadapter.Window(Width, Height, fonts, model{}, color.Black, tea.WithAltScreen())
if err != nil {
panic(err)
}
if err := win.Run("Simple"); err != nil {
panic(err)
}
}

64
font.go Normal file
View File

@ -0,0 +1,64 @@
package crt
import (
"golang.org/x/image/font"
"golang.org/x/image/font/opentype"
"os"
)
type Fonts struct {
Normal font.Face
Bold font.Face
Italic font.Face
}
// LoadFace loads a font face from a file. The dpi and size are used to generate the font face.
//
// Example: LoadFace("./fonts/Mono-Regular.ttf", 72.0, 16.0)
func LoadFace(file string, dpi float64, size float64) (font.Face, error) {
data, err := os.ReadFile(file)
if err != nil {
panic(err)
}
tt, err := opentype.Parse(data)
if err != nil {
panic(err)
}
face, err := opentype.NewFace(tt, &opentype.FaceOptions{
Size: size,
DPI: dpi,
Hinting: font.HintingNone,
})
if err != nil {
return nil, err
}
return face, nil
}
// LoadFaces loads a set of fonts from files. The normal, bold, and italic files
// must be provided. The dpi and size are used to generate the font faces.
func LoadFaces(normal string, bold string, italic string, dpi float64, size float64) (Fonts, error) {
normalFace, err := LoadFace(normal, dpi, size)
if err != nil {
return Fonts{}, err
}
boldFace, err := LoadFace(bold, dpi, size)
if err != nil {
return Fonts{}, err
}
italicFace, err := LoadFace(italic, dpi, size)
if err != nil {
return Fonts{}, err
}
return Fonts{
Normal: normalFace,
Bold: boldFace,
Italic: italicFace,
}, nil
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

39
go.mod Normal file
View File

@ -0,0 +1,39 @@
module github.com/BigJk/crt
go 1.20
require (
github.com/charmbracelet/bubbletea v0.24.0
github.com/hajimehoshi/ebiten/v2 v2.5.4
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
github.com/muesli/termenv v0.15.1
golang.org/x/image v0.7.0
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/bubbles v0.15.0 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/lipgloss v0.7.1 // indirect
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/ebitengine/purego v0.3.0 // indirect
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20221017161538-93cebf72946b // indirect
github.com/jezek/xgb v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.18 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.14 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/stretchr/testify v1.8.2 // indirect
golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56 // indirect
golang.org/x/mobile v0.0.0-20230301163155-e0f57694e12c // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/sys v0.6.0 // indirect
golang.org/x/term v0.6.0 // indirect
golang.org/x/text v0.9.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

128
go.sum Normal file
View File

@ -0,0 +1,128 @@
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v0.15.0 h1:c5vZ3woHV5W2b8YZI1q7v4ZNQaPetfHuoHzx+56Z6TI=
github.com/charmbracelet/bubbles v0.15.0/go.mod h1:Y7gSFbBzlMpUDR/XM9MhZI374Q+1p1kluf1uLl8iK74=
github.com/charmbracelet/bubbletea v0.23.1/go.mod h1:JAfGK/3/pPKHTnAS8JIE2u9f61BjWTQY57RbT25aMXU=
github.com/charmbracelet/bubbletea v0.24.0 h1:l8PHrft/GIeikDPCUhQe53AJrDD8xGSn0Agirh8xbe8=
github.com/charmbracelet/bubbletea v0.24.0/go.mod h1:rK3g/2+T8vOSEkNHvtq40umJpeVYDn6bLaqbgzhL/hg=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v0.6.0/go.mod h1:tHh2wr34xcHjC2HCXIlGSG1jaDF0S0atAUvBMP6Ppuk=
github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E=
github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c=
github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY=
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/ebitengine/purego v0.3.0 h1:BDv9pD98k6AuGNQf3IF41dDppGBOe0F4AofvhFtBXF4=
github.com/ebitengine/purego v0.3.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20221017161538-93cebf72946b h1:GgabKamyOYguHqHjSkDACcgoPIz3w0Dis/zJ1wyHHHU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20221017161538-93cebf72946b/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/hajimehoshi/ebiten/v2 v2.5.4 h1:NvUU6LvVc6oc+u+rD9KfHMjruRdpNwbpalVUINNXufU=
github.com/hajimehoshi/ebiten/v2 v2.5.4/go.mod h1:mnHSOVysTr/nUZrN1lBTRqhK4NG+T9NR3JsJP2rCppk=
github.com/jezek/xgb v1.1.0 h1:wnpxJzP1+rkbGclEkmwpVFQWpuE2PUGNUzP8SbfFobk=
github.com/jezek/xgb v1.1.0/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
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/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98=
github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
github.com/muesli/termenv v0.13.0/go.mod h1:sP1+uffeLaEYpyOTb8pLCUctGcGLnoFjSn4YJK5e2bc=
github.com/muesli/termenv v0.15.1 h1:UzuTb/+hhlBugQz28rpzey4ZuKcZ03MeKsoG7IJZIxs=
github.com/muesli/termenv v0.15.1/go.mod h1:HeAQPTzpfs016yGtA4g00CsdYnVLJvxsS4ANqrZs2sQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56 h1:estk1glOnSVeJ9tdEZZc5mAMDZk5lNJNyJ6DvrBkTEU=
golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.7.0 h1:gzS29xtG1J5ybQlv0PuyfE3nmc6R4qB73m6LUUmvFuw=
golang.org/x/image v0.7.0/go.mod h1:nd/q4ef1AKKYl/4kft7g+6UyGbdiqWqTP1ZAbRoV7Rg=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20230301163155-e0f57694e12c h1:Gk61ECugwEHL6IiyyNLXNzmu8XslmRP2dS0xjIYhbb4=
golang.org/x/mobile v0.0.0-20230301163155-e0f57694e12c/go.mod h1:aAjjkJNdrh3PMckS4B10TGS2nag27cbKR1y2BpUxsiY=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

56
read_writer.go Normal file
View File

@ -0,0 +1,56 @@
package crt
import "io"
type ConcurrentRW struct {
input chan []byte
output chan []byte
}
func NewConcurrentRW() *ConcurrentRW {
return &ConcurrentRW{
input: make(chan []byte, 10),
output: make(chan []byte),
}
}
func (rw *ConcurrentRW) Write(p []byte) (n int, err error) {
data := make([]byte, len(p))
copy(data, p)
rw.input <- data
return len(data), nil
}
func (rw *ConcurrentRW) Read(p []byte) (n int, err error) {
data, ok := <-rw.output
if !ok {
return 0, io.EOF
}
n = copy(p, data)
return n, nil
}
func (rw *ConcurrentRW) Run() {
const bufferSize = 1024
buf := make([]byte, 0, bufferSize)
for {
select {
case data, ok := <-rw.input:
if !ok {
close(rw.output)
return
}
buf = append(buf, data...)
for len(buf) > 0 {
n := len(buf)
if n > bufferSize {
n = bufferSize
}
p := make([]byte, n)
copy(p, buf[:n])
buf = buf[n:]
rw.output <- p
}
}
}
}

115
sgr.go Normal file
View File

@ -0,0 +1,115 @@
package crt
import (
"fmt"
"github.com/muesli/termenv"
"strings"
)
// extractSGR extracts an SGR ansi sequence from the beginning of the string.
func extractSGR(s string) (string, bool) {
if len(s) < 2 {
return "", false
}
if !strings.HasPrefix(s, termenv.CSI) {
return "", false
}
for i := 2; i < len(s); i++ {
if s[i] == 'm' {
return s[:i+1], true
}
}
return "", false
}
type SGRReset struct{}
type SGRBold struct{}
type SGRUnsetBold struct{}
type SGRItalic struct{}
type SGRUnsetItalic struct{}
type SGRFgTrueColor struct {
R, G, B byte
}
type SGRBgTrueColor struct {
R, G, B byte
}
// parseSGR parses a single SGR ansi sequence and returns a struct representing the sequence.
func parseSGR(s string) ([]any, bool) {
if !strings.HasPrefix(s, termenv.CSI) {
return nil, false
}
s = s[len(termenv.CSI):]
if len(s) == 0 {
return nil, false
}
if !strings.HasSuffix(s, "m") {
return nil, false
}
s = s[:len(s)-1]
if len(s) == 0 {
return nil, false
}
var skips int
var res []any
for len(s) > 0 {
code := strings.SplitN(s, ";", 2)[0]
if skips > 0 {
skips--
} else {
switch code {
case "0":
res = append(res, SGRReset{})
case "1":
res = append(res, SGRBold{})
case "22":
res = append(res, SGRUnsetBold{})
case "3":
res = append(res, SGRItalic{})
case "23":
res = append(res, SGRUnsetItalic{})
default:
// TODO: Only true color is supported for now.
if strings.HasPrefix(s, "38;2;") {
var r, g, b byte
_, err := fmt.Sscanf(s, "38;2;%d;%d;%d", &r, &g, &b)
if err == nil {
skips = 4
res = append(res, SGRFgTrueColor{r, g, b})
continue
}
} else if strings.HasPrefix(s, "48;2;") {
var r, g, b byte
_, err := fmt.Sscanf(s, "48;2;%d;%d;%d", &r, &g, &b)
if err == nil {
skips = 4
res = append(res, SGRBgTrueColor{r, g, b})
continue
}
}
}
}
if len(code) >= len(s) {
break
}
s = s[len(code)+1:]
}
return res, len(res) > 0
}

36
sgr_test.go Normal file
View File

@ -0,0 +1,36 @@
package crt
import (
"bytes"
"github.com/charmbracelet/lipgloss"
"github.com/muesli/termenv"
"github.com/stretchr/testify/assert"
"testing"
)
func TestSGR(t *testing.T) {
buf := &bytes.Buffer{}
lip := lipgloss.NewRenderer(buf, termenv.WithProfile(termenv.TrueColor))
testString := lip.NewStyle().Bold(true).Foreground(lipgloss.Color("#ff00ff")).Render("Hello World") + "asdasdasdasdasd" + lip.NewStyle().Italic(true).Background(lipgloss.Color("#ff00ff")).Render("Hello World")
var sequences []any
for i := 0; i < len(testString); i++ {
sgr, ok := extractSGR(testString[i:])
if ok {
i += len(sgr) - 1
if res, ok := parseSGR(sgr); ok {
sequences = append(sequences, res...)
}
}
}
assert.Equal(t, []any{
SGRBold{},
SGRFgTrueColor{R: 255, G: 0, B: 255},
SGRReset{},
SGRItalic{},
SGRBgTrueColor{R: 255, G: 0, B: 255},
SGRReset{},
}, sequences)
}