mirror of
https://github.com/BigJk/crt.git
synced 2026-02-06 10:47:25 +00:00
Initial version.
This commit is contained in:
parent
77574097d5
commit
4d4dfb7da3
3
.gitignore
vendored
3
.gitignore
vendored
@ -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
50
adapter.go
Normal 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
27
adapter_empty.go
Normal 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) {
|
||||
|
||||
}
|
||||
127
bubbletea/adapter_bubbletea.go
Normal file
127
bubbletea/adapter_bubbletea.go
Normal 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
61
bubbletea/bubbletea.go
Normal 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
25
cell.go
Normal 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
493
crt.go
Normal 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
81
crt_shader.go
Normal 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
191
csi.go
Normal 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
40
csi_test.go
Normal 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)
|
||||
}
|
||||
203
examples/package-manager/main.go
Normal file
203
examples/package-manager/main.go
Normal 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
45
examples/simple/main.go
Normal 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
64
font.go
Normal 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
|
||||
}
|
||||
BIN
fonts/IosevkaTermNerdFontMono-Bold.ttf
Normal file
BIN
fonts/IosevkaTermNerdFontMono-Bold.ttf
Normal file
Binary file not shown.
BIN
fonts/IosevkaTermNerdFontMono-Italic.ttf
Normal file
BIN
fonts/IosevkaTermNerdFontMono-Italic.ttf
Normal file
Binary file not shown.
BIN
fonts/IosevkaTermNerdFontMono-Regular.ttf
Normal file
BIN
fonts/IosevkaTermNerdFontMono-Regular.ttf
Normal file
Binary file not shown.
39
go.mod
Normal file
39
go.mod
Normal 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
128
go.sum
Normal 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
56
read_writer.go
Normal 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
115
sgr.go
Normal 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
36
sgr_test.go
Normal 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)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user