├── .github └── screenshot.png ├── .gitignore ├── LICENSE ├── README.md ├── adapter.go ├── adapter_empty.go ├── bubbletea ├── adapter_bubbletea.go └── bubbletea.go ├── cell.go ├── crt.go ├── csi.go ├── csi_test.go ├── dpi.go ├── examples ├── benchmark │ └── main.go ├── glamour │ └── main.go ├── keys │ └── main.go ├── package-manager │ └── main.go ├── shader │ └── main.go ├── simple │ └── main.go └── split-editor │ └── main.go ├── font.go ├── fonts ├── IosevkaTermNerdFontMono-Bold.ttf ├── IosevkaTermNerdFontMono-Italic.ttf └── IosevkaTermNerdFontMono-Regular.ttf ├── go.mod ├── go.sum ├── kill.go ├── kill_js.go ├── kill_windows.go ├── read_writer.go ├── sgr.go ├── sgr_test.go └── shader ├── crt_basic.go ├── crt_lotte.go └── shader.go /.github/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BigJk/crt/7710fdc88d6e48163e2d3649d35ab45d00266772/.github/screenshot.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ide 2 | .idea 3 | 4 | # If you prefer the allow list template instead of the deny list, see community template: 5 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 6 | # 7 | # Binaries for programs and plugins 8 | *.exe 9 | *.exe~ 10 | *.dll 11 | *.so 12 | *.dylib 13 | 14 | # Test binary, built with `go test -c` 15 | *.test 16 | 17 | # Output of the go coverage tool, specifically when used with LiteIDE 18 | *.out 19 | 20 | # Dependency directories (remove the comment below to include it) 21 | # vendor/ 22 | 23 | # Go workspace file 24 | go.work 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Daniel S. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # crt — *cathode-ray tube* 2 | 3 | ![Screenshot](./.github/screenshot.png) 4 | 5 | ## About 6 | 7 | CRT is a library to provide a simple terminal emulator that can be attached to a ``tea.Program``. It uses ``ebitengine`` to render a terminal. It supports TrueColor, Mouse and Keyboard input. It interprets the CSI escape sequences coming from bubbletea and renders them to the terminal. 8 | 9 | This started as a simple proof of concept for the game I'm writing with the help of bubbletea, called [End Of Eden](https://github.com/BigJk/end_of_eden). I wanted to give people who have no clue about the terminal a simple option to play the game without interacting with the terminal directly. It's also possible to apply shaders to the terminal to give it a more retro look which is a nice side effect. 10 | 11 | ## Usage 12 | 13 | ``` 14 | go get github.com/BigJk/crt@latest 15 | ``` 16 | 17 | 18 | ```go 19 | package main 20 | 21 | import ( 22 | "github.com/BigJk/crt" 23 | bubbleadapter "github.com/BigJk/crt/bubbletea" 24 | tea "github.com/charmbracelet/bubbletea" 25 | "github.com/charmbracelet/lipgloss" 26 | "image/color" 27 | ) 28 | 29 | // Some tea.Model ... 30 | 31 | func main() { 32 | // Load fonts for normal, bold and italic text styles. 33 | fonts, err := crt.LoadFaces("./fonts/SomeFont-Regular.ttf", "./fonts/SomeFont-Bold.ttf", "./fonts/SomeFont-Italic.ttf", crt.GetFontDPI(), 16.0) 34 | if err != nil { 35 | panic(err) 36 | } 37 | 38 | // Just pass your tea.Model to the bubbleadapter, and it will render it to the terminal. 39 | win, _, err := bubbleadapter.Window(1000, 600, fonts, someModel{}, color.Black, tea.WithAltScreen()) 40 | if err != nil { 41 | panic(err) 42 | } 43 | 44 | // Star the terminal with the given title. 45 | if err := win.Run("Simple"); err != nil { 46 | panic(err) 47 | } 48 | } 49 | ``` 50 | 51 | See more examples in the ``/examples`` folder! 52 | 53 | ## Limitations 54 | 55 | - ~~Only supports TrueColor at the moment (no 256 color support) so you need to use TrueColor colors in lipgloss (e.g. ``lipgloss.Color("#ff0000")``)~~ **Now supported.** 56 | - Not all CSI escape sequences are implemented but the ones that are used by bubbletea are implemented 57 | - Key handling is a bit quirky atm. Ebiten to bubbletea key mapping is not perfect and some keys are not handled correctly yet. 58 | - A lot of testing still needs to be done and there are probably edge cases that are not handled correctly yet 59 | 60 | ## Credits 61 | 62 | - Basic CRT Shader ``./shader/crt_basic``: https://quasilyte.dev/blog/post/ebitengine-shaders/ 63 | - Lottes CRT Shader ``./shader/crt_lotte``: Elias Daler https://github.com/eliasdaler/crten and Timothy Lottes. 64 | -------------------------------------------------------------------------------- /adapter.go: -------------------------------------------------------------------------------- 1 | package crt 2 | 3 | import "github.com/hajimehoshi/ebiten/v2" 4 | 5 | type WindowSize struct { 6 | Width int 7 | Height int 8 | } 9 | 10 | type MouseButton struct { 11 | Button ebiten.MouseButton 12 | X int 13 | Y int 14 | Shift bool 15 | Alt bool 16 | Ctrl bool 17 | JustPressed bool 18 | JustReleased bool 19 | } 20 | 21 | type MouseMotion struct { 22 | X int 23 | Y int 24 | } 25 | 26 | type MouseWheel struct { 27 | X int 28 | Y int 29 | DX float64 30 | DY float64 31 | Shift bool 32 | Alt bool 33 | Ctrl bool 34 | } 35 | 36 | type KeyPress struct { 37 | Key ebiten.Key 38 | Runes []rune 39 | Shift bool 40 | Alt bool 41 | Ctrl bool 42 | } 43 | 44 | type InputAdapter interface { 45 | HandleMouseButton(button MouseButton) 46 | HandleMouseMotion(motion MouseMotion) 47 | HandleMouseWheel(wheel MouseWheel) 48 | HandleKeyPress() 49 | HandleWindowSize(size WindowSize) 50 | } 51 | -------------------------------------------------------------------------------- /adapter_empty.go: -------------------------------------------------------------------------------- 1 | package crt 2 | 3 | type EmptyAdapter struct{} 4 | 5 | func NewEmptyAdapter() *EmptyAdapter { 6 | return &EmptyAdapter{} 7 | } 8 | 9 | func (e *EmptyAdapter) HandleMouseButton(button MouseButton) { 10 | 11 | } 12 | 13 | func (e *EmptyAdapter) HandleMouseMotion(motion MouseMotion) { 14 | 15 | } 16 | 17 | func (e *EmptyAdapter) HandleMouseWheel(wheel MouseWheel) { 18 | 19 | } 20 | 21 | func (e *EmptyAdapter) HandleKeyPress() { 22 | 23 | } 24 | 25 | func (e *EmptyAdapter) HandleWindowSize(size WindowSize) { 26 | 27 | } 28 | -------------------------------------------------------------------------------- /bubbletea/adapter_bubbletea.go: -------------------------------------------------------------------------------- 1 | package bubbletea 2 | 3 | import ( 4 | "github.com/BigJk/crt" 5 | tea "github.com/charmbracelet/bubbletea" 6 | "github.com/hajimehoshi/ebiten/v2" 7 | "github.com/hajimehoshi/ebiten/v2/inpututil" 8 | "unicode" 9 | ) 10 | 11 | type teaKey struct { 12 | key tea.KeyType 13 | rune []rune 14 | } 15 | 16 | func repeatingKeyPressed(key ebiten.Key) bool { 17 | const ( 18 | delay = 30 19 | interval = 3 20 | ) 21 | d := inpututil.KeyPressDuration(key) 22 | if d == 1 { 23 | return true 24 | } 25 | if d >= delay && (d-delay)%interval == 0 { 26 | return true 27 | } 28 | return false 29 | } 30 | 31 | var ebitenToTeaKeys = map[ebiten.Key]teaKey{ 32 | ebiten.KeyEnter: {tea.KeyEnter, []rune{'\n'}}, 33 | ebiten.KeyTab: {tea.KeyTab, []rune{}}, 34 | ebiten.KeyBackspace: {tea.KeyBackspace, []rune{}}, 35 | ebiten.KeyDelete: {tea.KeyDelete, []rune{}}, 36 | ebiten.KeyHome: {tea.KeyHome, []rune{}}, 37 | ebiten.KeyEnd: {tea.KeyEnd, []rune{}}, 38 | ebiten.KeyPageUp: {tea.KeyPgUp, []rune{}}, 39 | ebiten.KeyArrowUp: {tea.KeyUp, []rune{}}, 40 | ebiten.KeyArrowDown: {tea.KeyDown, []rune{}}, 41 | ebiten.KeyArrowLeft: {tea.KeyLeft, []rune{}}, 42 | ebiten.KeyArrowRight: {tea.KeyRight, []rune{}}, 43 | ebiten.KeyEscape: {tea.KeyEscape, []rune{}}, 44 | ebiten.KeyF1: {tea.KeyF1, []rune{}}, 45 | ebiten.KeyF2: {tea.KeyF2, []rune{}}, 46 | ebiten.KeyF3: {tea.KeyF3, []rune{}}, 47 | ebiten.KeyF4: {tea.KeyF4, []rune{}}, 48 | ebiten.KeyF5: {tea.KeyF5, []rune{}}, 49 | ebiten.KeyF6: {tea.KeyF6, []rune{}}, 50 | ebiten.KeyF7: {tea.KeyF7, []rune{}}, 51 | ebiten.KeyF8: {tea.KeyF8, []rune{}}, 52 | ebiten.KeyF9: {tea.KeyF9, []rune{}}, 53 | ebiten.KeyF10: {tea.KeyF10, []rune{}}, 54 | ebiten.KeyF11: {tea.KeyF11, []rune{}}, 55 | ebiten.KeyF12: {tea.KeyF12, []rune{}}, 56 | ebiten.KeyShift: {tea.KeyShiftLeft, []rune{}}, 57 | } 58 | 59 | var ebitenToCtrlKeys = map[ebiten.Key]tea.KeyType{ 60 | ebiten.KeyA: tea.KeyCtrlA, 61 | ebiten.KeyB: tea.KeyCtrlB, 62 | ebiten.KeyC: tea.KeyCtrlC, 63 | ebiten.KeyD: tea.KeyCtrlD, 64 | ebiten.KeyE: tea.KeyCtrlE, 65 | ebiten.KeyF: tea.KeyCtrlF, 66 | ebiten.KeyG: tea.KeyCtrlG, 67 | ebiten.KeyH: tea.KeyCtrlH, 68 | ebiten.KeyI: tea.KeyCtrlI, 69 | ebiten.KeyJ: tea.KeyCtrlJ, 70 | ebiten.KeyK: tea.KeyCtrlK, 71 | ebiten.KeyL: tea.KeyCtrlL, 72 | ebiten.KeyM: tea.KeyCtrlM, 73 | ebiten.KeyN: tea.KeyCtrlN, 74 | ebiten.KeyO: tea.KeyCtrlO, 75 | ebiten.KeyP: tea.KeyCtrlP, 76 | ebiten.KeyQ: tea.KeyCtrlQ, 77 | ebiten.KeyR: tea.KeyCtrlR, 78 | ebiten.KeyS: tea.KeyCtrlS, 79 | ebiten.KeyT: tea.KeyCtrlT, 80 | ebiten.KeyU: tea.KeyCtrlU, 81 | ebiten.KeyV: tea.KeyCtrlV, 82 | ebiten.KeyW: tea.KeyCtrlW, 83 | ebiten.KeyX: tea.KeyCtrlX, 84 | ebiten.KeyY: tea.KeyCtrlY, 85 | ebiten.KeyZ: tea.KeyCtrlZ, 86 | ebiten.KeyLeftBracket: tea.KeyCtrlOpenBracket, 87 | ebiten.KeyBackslash: tea.KeyCtrlBackslash, 88 | ebiten.KeyRightBracket: tea.KeyCtrlCloseBracket, 89 | ebiten.KeyApostrophe: tea.KeyCtrlCaret, 90 | } 91 | 92 | var ebitenToTeaMouse = map[ebiten.MouseButton]tea.MouseEventType{ 93 | ebiten.MouseButtonLeft: tea.MouseLeft, 94 | ebiten.MouseButtonMiddle: tea.MouseMiddle, 95 | ebiten.MouseButtonRight: tea.MouseRight, 96 | } 97 | 98 | var ebitenToTeaMouseNew = map[ebiten.MouseButton]tea.MouseButton{ 99 | ebiten.MouseButtonLeft: tea.MouseButtonLeft, 100 | ebiten.MouseButtonMiddle: tea.MouseButtonMiddle, 101 | ebiten.MouseButtonRight: tea.MouseButtonRight, 102 | 103 | // TODO: is this right? 104 | ebiten.MouseButton3: tea.MouseButtonBackward, 105 | ebiten.MouseButton4: tea.MouseButtonForward, 106 | } 107 | 108 | // Options are used to configure the adapter. 109 | type Options func(*Adapter) 110 | 111 | // WithFilterMousePressed filters the MousePressed event and only emits MouseReleased events. 112 | func WithFilterMousePressed(filter bool) Options { 113 | return func(b *Adapter) { 114 | b.filterMousePressed = filter 115 | } 116 | } 117 | 118 | // Adapter represents a bubbletea adapter for the crt package. 119 | type Adapter struct { 120 | prog *tea.Program 121 | filterMousePressed bool 122 | } 123 | 124 | // NewAdapter creates a new bubbletea adapter. 125 | func NewAdapter(prog *tea.Program, options ...Options) *Adapter { 126 | b := &Adapter{prog: prog, filterMousePressed: true} 127 | 128 | for i := range options { 129 | options[i](b) 130 | } 131 | 132 | return b 133 | } 134 | 135 | func (b *Adapter) HandleMouseMotion(motion crt.MouseMotion) { 136 | b.prog.Send(tea.MouseMsg{ 137 | X: motion.X, 138 | Y: motion.Y, 139 | Alt: false, 140 | Ctrl: false, 141 | Type: tea.MouseMotion, 142 | Action: tea.MouseActionMotion, 143 | }) 144 | } 145 | 146 | func (b *Adapter) HandleMouseButton(button crt.MouseButton) { 147 | // Filter this event or two events will be sent for one click in the current bubbletea version. 148 | if b.filterMousePressed && button.JustPressed { 149 | return 150 | } 151 | 152 | msg := tea.MouseMsg{ 153 | X: button.X, 154 | Y: button.Y, 155 | Alt: ebiten.IsKeyPressed(ebiten.KeyAlt), 156 | Ctrl: ebiten.IsKeyPressed(ebiten.KeyControl), 157 | Type: ebitenToTeaMouse[button.Button], 158 | Button: ebitenToTeaMouseNew[button.Button], 159 | } 160 | 161 | if button.JustReleased { 162 | msg.Action = tea.MouseActionRelease 163 | } else if button.JustPressed { 164 | msg.Action = tea.MouseActionPress 165 | } 166 | 167 | b.prog.Send(msg) 168 | } 169 | 170 | func (b *Adapter) HandleMouseWheel(wheel crt.MouseWheel) { 171 | if wheel.DY > 0 { 172 | b.prog.Send(tea.MouseMsg{ 173 | X: wheel.X, 174 | Y: wheel.Y, 175 | Alt: ebiten.IsKeyPressed(ebiten.KeyAlt), 176 | Ctrl: ebiten.IsKeyPressed(ebiten.KeyControl), 177 | Type: tea.MouseWheelUp, 178 | }) 179 | } else if wheel.DY < 0 { 180 | b.prog.Send(tea.MouseMsg{ 181 | X: wheel.X, 182 | Y: wheel.Y, 183 | Alt: ebiten.IsKeyPressed(ebiten.KeyAlt), 184 | Ctrl: ebiten.IsKeyPressed(ebiten.KeyControl), 185 | Type: tea.MouseWheelDown, 186 | }) 187 | } 188 | } 189 | 190 | func (b *Adapter) HandleKeyPress() { 191 | newInputs := ebiten.AppendInputChars([]rune{}) 192 | for _, v := range newInputs { 193 | switch v { 194 | case ' ': 195 | b.prog.Send(tea.KeyMsg{ 196 | Type: tea.KeySpace, 197 | Runes: []rune{v}, 198 | Alt: ebiten.IsKeyPressed(ebiten.KeyAlt), 199 | }) 200 | default: 201 | b.prog.Send(tea.KeyMsg{ 202 | Type: tea.KeyRunes, 203 | Runes: []rune{v}, 204 | Alt: ebiten.IsKeyPressed(ebiten.KeyAlt), 205 | }) 206 | } 207 | } 208 | 209 | var keys []ebiten.Key 210 | keys = inpututil.AppendJustPressedKeys(keys) 211 | repeatedBackspace := repeatingKeyPressed(ebiten.KeyBackspace) 212 | 213 | if repeatedBackspace { 214 | b.prog.Send(tea.KeyMsg{ 215 | Type: tea.KeyBackspace, 216 | Runes: []rune{}, 217 | Alt: false, 218 | }) 219 | } 220 | 221 | for _, k := range keys { 222 | if ebiten.IsKeyPressed(ebiten.KeyControl) { 223 | if tk, ok := ebitenToCtrlKeys[k]; ok { 224 | b.prog.Send(tea.KeyMsg{ 225 | Type: tk, 226 | Runes: []rune{}, 227 | Alt: false, 228 | }) 229 | continue 230 | } 231 | } 232 | 233 | if repeatedBackspace && k == ebiten.KeyBackspace { 234 | continue 235 | } 236 | 237 | if val, ok := ebitenToTeaKeys[k]; ok { 238 | runes := make([]rune, len(val.rune)) 239 | copy(runes, val.rune) 240 | 241 | if ebiten.IsKeyPressed(ebiten.KeyShift) { 242 | for i := range runes { 243 | runes[i] = unicode.ToUpper(runes[i]) 244 | } 245 | } 246 | 247 | b.prog.Send(tea.KeyMsg{ 248 | Type: val.key, 249 | Runes: runes, 250 | Alt: ebiten.IsKeyPressed(ebiten.KeyAlt), 251 | }) 252 | } 253 | } 254 | } 255 | 256 | func (b *Adapter) HandleWindowSize(size crt.WindowSize) { 257 | b.prog.Send(tea.WindowSizeMsg{ 258 | Width: size.Width, 259 | Height: size.Height, 260 | }) 261 | } 262 | -------------------------------------------------------------------------------- /bubbletea/bubbletea.go: -------------------------------------------------------------------------------- 1 | package bubbletea 2 | 3 | import ( 4 | "fmt" 5 | "github.com/BigJk/crt" 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/charmbracelet/lipgloss" 8 | "github.com/muesli/termenv" 9 | "image/color" 10 | ) 11 | 12 | func init() { 13 | lipgloss.SetColorProfile(termenv.TrueColor) 14 | } 15 | 16 | // Window creates a new crt based bubbletea window with the given width, height, fonts, model and default background color. 17 | // Additional options can be passed to the bubbletea program. 18 | func Window(width int, height int, fonts crt.Fonts, model tea.Model, defaultBg color.Color, options ...tea.ProgramOption) (*crt.Window, *tea.Program, error) { 19 | gameInput := crt.NewConcurrentRW() 20 | gameOutput := crt.NewConcurrentRW() 21 | 22 | go gameInput.Run() 23 | go gameOutput.Run() 24 | 25 | prog := tea.NewProgram( 26 | model, 27 | append([]tea.ProgramOption{ 28 | tea.WithMouseAllMotion(), 29 | tea.WithInput(gameInput), 30 | tea.WithOutput(gameOutput), 31 | tea.WithANSICompressor(), 32 | }, options...)..., 33 | ) 34 | 35 | go func() { 36 | if _, err := prog.Run(); err != nil { 37 | fmt.Printf("Alas, there's been an error: %v", err) 38 | } 39 | 40 | crt.SysKill() 41 | }() 42 | 43 | win, err := crt.NewGame(width, height, fonts, gameOutput, NewAdapter(prog), defaultBg) 44 | return win, prog, err 45 | } 46 | -------------------------------------------------------------------------------- /cell.go: -------------------------------------------------------------------------------- 1 | package crt 2 | 3 | import "image/color" 4 | 5 | // FontWeight is the weight of a font at a certain terminal cell. 6 | type FontWeight byte 7 | 8 | const ( 9 | // FontWeightNormal is the default font weight. 10 | FontWeightNormal FontWeight = iota 11 | 12 | // FontWeightBold is a bold font weight. 13 | FontWeightBold 14 | 15 | // FontWeightItalic is an italic font weight. 16 | FontWeightItalic 17 | ) 18 | 19 | // GridCell is a single cell in the terminal grid. 20 | type GridCell struct { 21 | Char rune 22 | Fg color.Color 23 | Bg color.Color 24 | Weight FontWeight 25 | } 26 | -------------------------------------------------------------------------------- /crt.go: -------------------------------------------------------------------------------- 1 | package crt 2 | 3 | import ( 4 | "fmt" 5 | "github.com/BigJk/crt/shader" 6 | "github.com/hajimehoshi/ebiten/v2" 7 | "github.com/hajimehoshi/ebiten/v2/ebitenutil" 8 | "github.com/hajimehoshi/ebiten/v2/inpututil" 9 | "github.com/hajimehoshi/ebiten/v2/text" 10 | "github.com/lucasb-eyer/go-colorful" 11 | "github.com/muesli/ansi" 12 | "github.com/muesli/termenv" 13 | "image" 14 | "image/color" 15 | "io" 16 | "sync" 17 | "unicode/utf8" 18 | ) 19 | 20 | // colorCache is the ansi color cache. 21 | var colorCache = map[int]color.Color{} 22 | 23 | type Window struct { 24 | sync.Mutex 25 | 26 | // Terminal dimensions and grid. 27 | grid [][]GridCell 28 | cellsWidth int 29 | cellsHeight int 30 | cellWidth int 31 | cellHeight int 32 | cellOffsetY int 33 | 34 | // Input and output. 35 | inputAdapter InputAdapter 36 | tty io.Reader 37 | 38 | // Terminal cursor and color states. 39 | cursorChar string 40 | cursorColor color.Color 41 | showCursor bool 42 | cursorX int 43 | cursorY int 44 | mouseCellX int 45 | mouseCellY int 46 | defaultBg color.Color 47 | curFg color.Color 48 | curBg color.Color 49 | curWeight FontWeight 50 | 51 | // Callbacks 52 | onUpdate func() 53 | onPreDraw func(screen *ebiten.Image) 54 | onPostDraw func(screen *ebiten.Image) 55 | 56 | // Other 57 | seqBuffer []byte 58 | showTps bool 59 | fonts Fonts 60 | bgColors *image.RGBA 61 | shader []shader.Shader 62 | routine sync.Once 63 | shaderByteBuffer []byte 64 | shaderBuffer *ebiten.Image 65 | lastBuffer *ebiten.Image 66 | invalidateBuffer bool 67 | } 68 | 69 | type WindowOption func(window *Window) 70 | 71 | // NewGame creates a new terminal game with the given dimensions and font faces. 72 | func NewGame(width int, height int, fonts Fonts, tty io.Reader, adapter InputAdapter, defaultBg color.Color) (*Window, error) { 73 | if defaultBg == nil { 74 | defaultBg = color.Black 75 | } 76 | 77 | bounds, _, _ := fonts.Normal.GlyphBounds([]rune("█")[0]) 78 | size := bounds.Max.Sub(bounds.Min) 79 | 80 | cellWidth := size.X.Ceil() 81 | cellHeight := size.Y.Ceil() 82 | cellOffsetY := -bounds.Min.Y.Ceil() 83 | 84 | cellsWidth := int(float64(width)*DeviceScale()) / cellWidth 85 | cellsHeight := int(float64(height)*DeviceScale()) / cellHeight 86 | 87 | grid := make([][]GridCell, cellsHeight) 88 | for y := 0; y < cellsHeight; y++ { 89 | grid[y] = make([]GridCell, cellsWidth) 90 | for x := 0; x < cellsWidth; x++ { 91 | grid[y][x] = GridCell{ 92 | Char: ' ', 93 | Fg: color.White, 94 | Bg: defaultBg, 95 | Weight: FontWeightNormal, 96 | } 97 | } 98 | } 99 | 100 | game := &Window{ 101 | inputAdapter: adapter, 102 | cellsWidth: cellsWidth, 103 | cellsHeight: cellsHeight, 104 | cellWidth: cellWidth, 105 | cellHeight: cellHeight, 106 | cellOffsetY: cellOffsetY, 107 | fonts: fonts, 108 | defaultBg: defaultBg, 109 | grid: grid, 110 | tty: tty, 111 | bgColors: image.NewRGBA(image.Rect(0, 0, cellsWidth*cellWidth, cellsHeight*cellHeight)), 112 | lastBuffer: ebiten.NewImage(cellsWidth*cellWidth, cellsHeight*cellHeight), 113 | cursorChar: "█", 114 | cursorColor: color.RGBA{R: 255, G: 255, B: 255, A: 100}, 115 | onUpdate: func() {}, 116 | onPreDraw: func(screen *ebiten.Image) {}, 117 | onPostDraw: func(screen *ebiten.Image) {}, 118 | invalidateBuffer: true, 119 | seqBuffer: make([]byte, 0, 2^12), 120 | } 121 | 122 | game.inputAdapter.HandleWindowSize(WindowSize{ 123 | Width: cellsWidth - 1, 124 | Height: cellsHeight, 125 | }) 126 | 127 | game.ResetSGR() 128 | game.RecalculateBackgrounds() 129 | 130 | return game, nil 131 | } 132 | 133 | // SetShowCursor enables or disables the cursor. 134 | func (g *Window) SetShowCursor(val bool) { 135 | g.showCursor = val 136 | g.InvalidateBuffer() 137 | } 138 | 139 | // SetCursorChar sets the character that is used for the cursor. 140 | func (g *Window) SetCursorChar(char string) { 141 | g.cursorChar = char 142 | g.InvalidateBuffer() 143 | } 144 | 145 | // SetCursorColor sets the color of the cursor. 146 | func (g *Window) SetCursorColor(color color.Color) { 147 | g.cursorColor = color 148 | g.InvalidateBuffer() 149 | } 150 | 151 | // SetShader sets a shader that is applied to the whole screen. 152 | func (g *Window) SetShader(shader ...shader.Shader) { 153 | g.shader = shader 154 | } 155 | 156 | // SetOnUpdate sets a function that is called every frame. 157 | func (g *Window) SetOnUpdate(fn func()) { 158 | g.onUpdate = fn 159 | } 160 | 161 | // SetOnPreDraw sets a function that is called before the screen is drawn. 162 | func (g *Window) SetOnPreDraw(fn func(screen *ebiten.Image)) { 163 | g.onPreDraw = fn 164 | } 165 | 166 | // SetOnPostDraw sets a function that is called after the screen is drawn. 167 | func (g *Window) SetOnPostDraw(fn func(screen *ebiten.Image)) { 168 | g.onPostDraw = fn 169 | } 170 | 171 | // ShowTPS enables or disables the TPS counter on the top left. 172 | func (g *Window) ShowTPS(val bool) { 173 | g.showTps = val 174 | } 175 | 176 | // InvalidateBuffer forces the buffer to be redrawn. 177 | func (g *Window) InvalidateBuffer() { 178 | g.invalidateBuffer = true 179 | } 180 | 181 | // ResetSGR resets the SGR attributes to their default values. 182 | func (g *Window) ResetSGR() { 183 | g.curFg = color.White 184 | g.curBg = g.defaultBg 185 | g.curWeight = FontWeightNormal 186 | } 187 | 188 | // SetBgPixels sets a chunk of background pixels in the size of the cell. 189 | func (g *Window) SetBgPixels(x, y int, c color.Color) { 190 | for i := 0; i < g.cellWidth; i++ { 191 | for j := 0; j < g.cellHeight; j++ { 192 | g.bgColors.Set(x*g.cellWidth+i, y*g.cellHeight+j, c) 193 | } 194 | } 195 | g.InvalidateBuffer() 196 | } 197 | 198 | // SetBg sets the background color of a cell and checks if it needs to be redrawn. 199 | func (g *Window) SetBg(x, y int, c color.Color) { 200 | ra, rg, rb, _ := g.grid[y][x].Bg.RGBA() 201 | ca, cg, cb, _ := c.RGBA() 202 | if ra == ca && rg == cg && rb == cb { 203 | return 204 | } 205 | 206 | g.SetBgPixels(x, y, c) 207 | g.grid[y][x].Bg = c 208 | } 209 | 210 | // GetCellsWidth returns the number of cells in the x direction. 211 | func (g *Window) GetCellsWidth() int { 212 | return g.cellsWidth 213 | } 214 | 215 | // GetCellsHeight returns the number of cells in the y direction. 216 | func (g *Window) GetCellsHeight() int { 217 | return g.cellsHeight 218 | } 219 | 220 | func (g *Window) handleCSI(csi any) { 221 | switch seq := csi.(type) { 222 | case CursorUpSeq: 223 | g.cursorY -= seq.Count 224 | if g.cursorY < 0 { 225 | g.cursorY = 0 226 | } 227 | case CursorDownSeq: 228 | g.cursorY += seq.Count 229 | if g.cursorY >= g.cellsHeight { 230 | g.cursorY = g.cellsHeight - 1 231 | } 232 | case CursorForwardSeq: 233 | g.cursorX += seq.Count 234 | if g.cursorX >= g.cellsWidth { 235 | g.cursorX = g.cellsWidth - 1 236 | } 237 | case CursorBackSeq: 238 | g.cursorX -= seq.Count 239 | if g.cursorX < 0 { 240 | g.cursorX = 0 241 | } 242 | case CursorNextLineSeq: 243 | g.cursorY += seq.Count 244 | if g.cursorY >= g.cellsHeight { 245 | g.cursorY = g.cellsHeight - 1 246 | } 247 | g.cursorX = 0 248 | case CursorPreviousLineSeq: 249 | g.cursorY -= seq.Count 250 | if g.cursorY < 0 { 251 | g.cursorY = 0 252 | } 253 | g.cursorX = 0 254 | case CursorHorizontalSeq: 255 | g.cursorX = seq.Count - 1 256 | case CursorPositionSeq: 257 | g.cursorX = seq.Col - 1 258 | g.cursorY = seq.Row - 1 259 | 260 | if g.cursorX < 0 { 261 | g.cursorX = 0 262 | } else if g.cursorX >= g.cellsWidth { 263 | g.cursorX = g.cellsWidth - 1 264 | } 265 | 266 | if g.cursorY < 0 { 267 | g.cursorY = 0 268 | } else if g.cursorY >= g.cellsHeight { 269 | g.cursorY = g.cellsHeight - 1 270 | } 271 | case EraseDisplaySeq: 272 | if seq.Type != 2 { 273 | return // only support 2 (erase entire display) 274 | } 275 | 276 | for i := 0; i < g.cellsWidth; i++ { 277 | for j := 0; j < g.cellsHeight; j++ { 278 | g.grid[j][i].Char = ' ' 279 | g.grid[j][i].Fg = color.White 280 | g.grid[j][i].Bg = g.defaultBg 281 | } 282 | } 283 | case EraseLineSeq: 284 | switch seq.Type { 285 | case 0: // erase from cursor to end of line 286 | for i := g.cursorX; i < g.cellsWidth-g.cursorX; i++ { 287 | g.grid[g.cursorY][g.cursorX+i].Char = ' ' 288 | g.grid[g.cursorY][g.cursorX+i].Fg = color.White 289 | g.SetBg(g.cursorX+i, g.cursorY, g.defaultBg) 290 | } 291 | case 1: // erase from start of line to cursor 292 | for i := 0; i < g.cursorX; i++ { 293 | g.grid[g.cursorY][i].Char = ' ' 294 | g.grid[g.cursorY][i].Fg = color.White 295 | g.SetBg(i, g.cursorY, g.defaultBg) 296 | } 297 | case 2: // erase entire line 298 | for i := 0; i < g.cellsWidth; i++ { 299 | g.grid[g.cursorY][i].Char = ' ' 300 | g.grid[g.cursorY][i].Fg = color.White 301 | g.SetBg(i, g.cursorY, g.defaultBg) 302 | } 303 | } 304 | case CursorShowSeq: 305 | g.SetShowCursor(true) 306 | case CursorHideSeq: 307 | g.SetShowCursor(false) 308 | case ScrollUpSeq: 309 | fmt.Println("UNSUPPORTED: ScrollUpSeq", seq.Count) 310 | case ScrollDownSeq: 311 | fmt.Println("UNSUPPORTED: ScrollDownSeq", seq.Count) 312 | case SaveCursorPositionSeq: 313 | fmt.Println("UNSUPPORTED: SaveCursorPositionSeq") 314 | case RestoreCursorPositionSeq: 315 | fmt.Println("UNSUPPORTED: RestoreCursorPositionSeq") 316 | case ChangeScrollingRegionSeq: 317 | fmt.Println("UNSUPPORTED: ChangeScrollingRegionSeq") 318 | case InsertLineSeq: 319 | fmt.Println("UNSUPPORTED: InsertLineSeq") 320 | case DeleteLineSeq: 321 | fmt.Println("UNSUPPORTED: DeleteLineSeq") 322 | } 323 | } 324 | 325 | func (g *Window) handleSGR(sgr any) { 326 | switch seq := sgr.(type) { 327 | case SGRReset: 328 | g.ResetSGR() 329 | case SGRBold: 330 | g.curWeight = FontWeightBold 331 | case SGRItalic: 332 | g.curWeight = FontWeightItalic 333 | case SGRUnsetBold: 334 | g.curWeight = FontWeightNormal 335 | case SGRUnsetItalic: 336 | g.curWeight = FontWeightNormal 337 | case SGRFgTrueColor: 338 | g.curFg = color.RGBA{R: seq.R, G: seq.G, B: seq.B, A: 255} 339 | case SGRBgTrueColor: 340 | g.curBg = color.RGBA{R: seq.R, G: seq.G, B: seq.B, A: 255} 341 | case SGRFgColor: 342 | if val, ok := colorCache[seq.Id]; ok { 343 | g.curFg = val 344 | } else { 345 | if col, err := colorful.Hex(termenv.ANSI256Color(seq.Id).String()); err == nil { 346 | g.curFg = col 347 | colorCache[seq.Id] = col 348 | } 349 | } 350 | case SGRBgColor: 351 | if val, ok := colorCache[seq.Id]; ok { 352 | g.curBg = val 353 | } else { 354 | if col, err := colorful.Hex(termenv.ANSI256Color(seq.Id).String()); err == nil { 355 | g.curBg = col 356 | colorCache[seq.Id] = col 357 | } 358 | } 359 | } 360 | } 361 | 362 | func (g *Window) parseSequences(str string, printExtra bool) int { 363 | lastFound := 0 364 | for i := 0; i < len(str); i++ { 365 | if sgr, ok := extractSGR(str[i:]); ok { 366 | i += len(sgr) - 1 367 | 368 | if sgr, ok := parseSGR(sgr); ok { 369 | lastFound = i 370 | for i := range sgr { 371 | g.handleSGR(sgr[i]) 372 | g.InvalidateBuffer() 373 | } 374 | } 375 | } else if csi, ok := extractCSI(str[i:]); ok { 376 | i += len(csi) - 1 377 | 378 | if csi, ok := parseCSI(csi); ok { 379 | lastFound = i 380 | g.handleCSI(csi) 381 | g.InvalidateBuffer() 382 | } 383 | } else if printExtra { 384 | if r, size := utf8.DecodeRuneInString(str[i:]); r != utf8.RuneError { 385 | g.PrintChar(r, g.curFg, g.curBg, g.curWeight) 386 | i += size - 1 387 | } 388 | } 389 | } 390 | 391 | return lastFound 392 | } 393 | 394 | func (g *Window) drainSequence() { 395 | if len(g.seqBuffer) > 0 { 396 | g.parseSequences(string(g.seqBuffer), true) 397 | g.seqBuffer = g.seqBuffer[:0] 398 | } 399 | } 400 | 401 | // RecalculateBackgrounds syncs the background colors to the background pixels. 402 | func (g *Window) RecalculateBackgrounds() { 403 | for i := 0; i < g.cellsWidth; i++ { 404 | for j := 0; j < g.cellsHeight; j++ { 405 | g.SetBgPixels(i, j, g.grid[j][i].Bg) 406 | } 407 | } 408 | } 409 | 410 | // PrintChar prints a character to the screen. 411 | func (g *Window) PrintChar(r rune, fg, bg color.Color, weight FontWeight) { 412 | if r == '\n' { 413 | g.cursorX = 0 414 | g.cursorY++ 415 | return 416 | } 417 | 418 | if ansi.PrintableRuneWidth(string(r)) == 0 { 419 | return 420 | } 421 | 422 | // Wrap around if we're at the end of the line. 423 | if g.cursorX >= g.cellsWidth { 424 | g.cursorX = 0 425 | g.cursorY++ 426 | } 427 | 428 | // Scroll down if we're at the bottom and add a new line. 429 | if g.cursorY >= g.cellsHeight { 430 | diff := g.cursorY - g.cellsHeight + 1 431 | g.grid = g.grid[diff:] 432 | for i := 0; i < diff; i++ { 433 | g.grid = append(g.grid, make([]GridCell, g.cellsWidth)) 434 | for i := 0; i < g.cellsWidth; i++ { 435 | g.grid[len(g.grid)-1][i].Char = ' ' 436 | g.grid[len(g.grid)-1][i].Fg = color.White 437 | g.grid[len(g.grid)-1][i].Bg = g.defaultBg 438 | } 439 | } 440 | g.cursorY = g.cellsHeight - 1 441 | g.RecalculateBackgrounds() 442 | } 443 | 444 | // Set the cell. 445 | g.grid[g.cursorY][g.cursorX].Char = r 446 | g.grid[g.cursorY][g.cursorX].Fg = fg 447 | g.grid[g.cursorY][g.cursorX].Bg = bg 448 | g.grid[g.cursorY][g.cursorX].Weight = weight 449 | 450 | // Set the pixels. 451 | g.SetBgPixels(g.cursorX, g.cursorY, g.grid[g.cursorY][g.cursorX].Bg) 452 | 453 | // Move the cursor. 454 | g.cursorX++ 455 | 456 | g.InvalidateBuffer() 457 | } 458 | 459 | func (g *Window) Update() error { 460 | g.routine.Do(func() { 461 | go func() { 462 | buf := make([]byte, 1024) 463 | for { 464 | n, err := g.tty.Read(buf) 465 | if err != nil { 466 | fmt.Println("ERROR: ", err) 467 | continue 468 | } 469 | 470 | if n == 0 { 471 | continue 472 | } 473 | 474 | g.Lock() 475 | { 476 | g.seqBuffer = append(g.seqBuffer, buf[:n]...) 477 | } 478 | g.Unlock() 479 | } 480 | }() 481 | }) 482 | 483 | mx, my := ebiten.CursorPosition() 484 | mcx, mcy := mx/g.cellWidth, my/g.cellHeight 485 | 486 | if mcx != g.mouseCellX || mcy != g.mouseCellY { 487 | g.mouseCellX = mcx 488 | g.mouseCellY = mcy 489 | 490 | g.inputAdapter.HandleMouseMotion(MouseMotion{ 491 | X: g.mouseCellX, 492 | Y: g.mouseCellY, 493 | }) 494 | } 495 | 496 | // Mouse buttons. 497 | if inpututil.IsMouseButtonJustReleased(ebiten.MouseButtonLeft) { 498 | g.inputAdapter.HandleMouseButton(MouseButton{ 499 | X: g.mouseCellX, 500 | Y: g.mouseCellY, 501 | Shift: ebiten.IsKeyPressed(ebiten.KeyShift), 502 | Alt: ebiten.IsKeyPressed(ebiten.KeyAlt), 503 | Ctrl: ebiten.IsKeyPressed(ebiten.KeyControl), 504 | Button: ebiten.MouseButtonLeft, 505 | JustPressed: false, 506 | JustReleased: true, 507 | }) 508 | } else if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) { 509 | g.inputAdapter.HandleMouseButton(MouseButton{ 510 | X: g.mouseCellX, 511 | Y: g.mouseCellY, 512 | Shift: ebiten.IsKeyPressed(ebiten.KeyShift), 513 | Alt: ebiten.IsKeyPressed(ebiten.KeyAlt), 514 | Ctrl: ebiten.IsKeyPressed(ebiten.KeyControl), 515 | Button: ebiten.MouseButtonLeft, 516 | JustPressed: true, 517 | JustReleased: false, 518 | }) 519 | } 520 | 521 | // Mouse wheel. 522 | _, wy := ebiten.Wheel() 523 | if wy > 0 || wy < 0 { 524 | g.inputAdapter.HandleMouseWheel(MouseWheel{ 525 | X: g.mouseCellX, 526 | Y: g.mouseCellY, 527 | Shift: ebiten.IsKeyPressed(ebiten.KeyShift), 528 | Alt: ebiten.IsKeyPressed(ebiten.KeyAlt), 529 | Ctrl: ebiten.IsKeyPressed(ebiten.KeyControl), 530 | DX: 0, 531 | DY: wy, 532 | }) 533 | } 534 | 535 | // Keyboard. 536 | g.inputAdapter.HandleKeyPress() 537 | 538 | g.onUpdate() 539 | 540 | return nil 541 | } 542 | 543 | func (g *Window) Draw(screen *ebiten.Image) { 544 | g.Lock() 545 | defer g.Unlock() 546 | 547 | g.onPreDraw(screen) 548 | 549 | // We process the sequence buffer here so that we don't get flickering 550 | g.drainSequence() 551 | 552 | screen.Fill(g.defaultBg) 553 | 554 | // Get current buffer 555 | bufferImage := g.lastBuffer 556 | 557 | // Only draw the buffer if it's invalid 558 | if g.invalidateBuffer { 559 | // Draw background 560 | bufferImage.WritePixels(g.bgColors.Pix) 561 | 562 | // Draw text 563 | for y := 0; y < g.cellsHeight; y++ { 564 | for x := 0; x < g.cellsWidth; x++ { 565 | if g.grid[y][x].Char == ' ' { 566 | continue 567 | } 568 | 569 | switch g.grid[y][x].Weight { 570 | case FontWeightNormal: 571 | text.Draw(bufferImage, string(g.grid[y][x].Char), g.fonts.Normal, x*g.cellWidth, y*g.cellHeight+g.cellOffsetY, g.grid[y][x].Fg) 572 | case FontWeightBold: 573 | text.Draw(bufferImage, string(g.grid[y][x].Char), g.fonts.Bold, x*g.cellWidth, y*g.cellHeight+g.cellOffsetY, g.grid[y][x].Fg) 574 | case FontWeightItalic: 575 | text.Draw(bufferImage, string(g.grid[y][x].Char), g.fonts.Italic, x*g.cellWidth, y*g.cellHeight+g.cellOffsetY, g.grid[y][x].Fg) 576 | } 577 | } 578 | } 579 | 580 | // Draw cursor 581 | if g.showCursor { 582 | text.Draw(bufferImage, g.cursorChar, g.fonts.Normal, g.cursorX*g.cellWidth, g.cursorY*g.cellHeight+g.cellOffsetY, g.cursorColor) 583 | } 584 | 585 | g.lastBuffer = bufferImage 586 | g.invalidateBuffer = false 587 | } 588 | 589 | // Draw shader 590 | if g.shader != nil { 591 | if g.shaderBuffer == nil { 592 | g.shaderBuffer = ebiten.NewImageFromImage(bufferImage) 593 | } else { 594 | bounds := g.shaderBuffer.Bounds() 595 | if len(g.shaderByteBuffer) < 4*bounds.Dx()*bounds.Dy() { 596 | g.shaderByteBuffer = make([]byte, 4*bounds.Dx()*bounds.Dy()) 597 | } 598 | bufferImage.ReadPixels(g.shaderByteBuffer) 599 | g.shaderBuffer.WritePixels(g.shaderByteBuffer) 600 | } 601 | 602 | for i := range g.shader { 603 | _ = g.shader[i].Apply(screen, g.shaderBuffer) 604 | 605 | if len(g.shader) > 0 { 606 | g.shaderBuffer.DrawImage(screen, nil) 607 | } 608 | } 609 | } else { 610 | screen.DrawImage(bufferImage, nil) 611 | } 612 | 613 | if g.showTps { 614 | ebitenutil.DebugPrint(screen, fmt.Sprintf("TPS: %0.2f", ebiten.CurrentTPS())) 615 | } 616 | 617 | g.onPostDraw(screen) 618 | } 619 | 620 | func (g *Window) Layout(outsideWidth, outsideHeight int) (int, int) { 621 | s := DeviceScale() 622 | return int(float64(outsideWidth) * s), int(float64(outsideHeight) * s) 623 | } 624 | 625 | func (g *Window) Run(title string) error { 626 | ebiten.SetScreenFilterEnabled(false) 627 | ebiten.SetWindowSize(int(float64(g.cellsWidth*g.cellWidth)/DeviceScale()), int(float64(g.cellsHeight*g.cellHeight)/DeviceScale())) 628 | ebiten.SetWindowTitle(title) 629 | if err := ebiten.RunGame(g); err != nil { 630 | return err 631 | } 632 | 633 | return nil 634 | } 635 | 636 | func (g *Window) RunWithOptions(options ...WindowOption) error { 637 | ebiten.SetWindowSize(int(float64(g.cellsWidth*g.cellWidth)/DeviceScale()), int(float64(g.cellsHeight*g.cellHeight)/DeviceScale())) 638 | 639 | for _, opt := range options { 640 | opt(g) 641 | } 642 | 643 | if err := ebiten.RunGame(g); err != nil { 644 | return err 645 | } 646 | 647 | return nil 648 | } 649 | 650 | func (g *Window) Kill() { 651 | SysKill() 652 | } 653 | -------------------------------------------------------------------------------- /csi.go: -------------------------------------------------------------------------------- 1 | package crt 2 | 3 | import ( 4 | "github.com/muesli/termenv" 5 | "strconv" 6 | "strings" 7 | "sync" 8 | ) 9 | 10 | var csiMtx = &sync.Mutex{} 11 | var csiCache = map[string]any{} 12 | 13 | type CursorUpSeq struct { 14 | Count int 15 | } 16 | 17 | type CursorDownSeq struct { 18 | Count int 19 | } 20 | 21 | type CursorForwardSeq struct { 22 | Count int 23 | } 24 | 25 | type CursorBackSeq struct { 26 | Count int 27 | } 28 | 29 | type CursorNextLineSeq struct { 30 | Count int 31 | } 32 | 33 | type CursorPreviousLineSeq struct { 34 | Count int 35 | } 36 | 37 | type CursorHorizontalSeq struct { 38 | Count int 39 | } 40 | 41 | type CursorPositionSeq struct { 42 | Row int 43 | Col int 44 | } 45 | 46 | type EraseDisplaySeq struct { 47 | Type int 48 | } 49 | 50 | type EraseLineSeq struct { 51 | Type int 52 | } 53 | 54 | type ScrollUpSeq struct { 55 | Count int 56 | } 57 | 58 | type ScrollDownSeq struct { 59 | Count int 60 | } 61 | 62 | type SaveCursorPositionSeq struct{} 63 | 64 | type RestoreCursorPositionSeq struct{} 65 | 66 | type ChangeScrollingRegionSeq struct { 67 | Top int 68 | Bottom int 69 | } 70 | 71 | type InsertLineSeq struct { 72 | Count int 73 | } 74 | 75 | type DeleteLineSeq struct { 76 | Count int 77 | } 78 | 79 | type CursorShowSeq struct{} 80 | 81 | type CursorHideSeq struct{} 82 | 83 | // extractCSI extracts a CSI sequence from the beginning of a string. 84 | // It returns the sequence without any suffix, and a boolean indicating 85 | // whether a sequence was found. 86 | func extractCSI(s string) (string, bool) { 87 | if !strings.HasPrefix(s, termenv.CSI) { 88 | return "", false 89 | } 90 | 91 | s = s[len(termenv.CSI):] 92 | if len(s) == 0 { 93 | return "", false 94 | } 95 | 96 | for i, c := range s { 97 | if c >= '@' && c <= '~' { 98 | return termenv.CSI + s[:i+1], true 99 | } 100 | } 101 | 102 | return "", false 103 | } 104 | 105 | // parseCSI parses a CSI sequence and returns a struct representing the sequence. 106 | func parseCSI(s string) (any, bool) { 107 | if !strings.HasPrefix(s, termenv.CSI) { 108 | return nil, false 109 | } 110 | 111 | s = s[len(termenv.CSI):] 112 | if len(s) == 0 { 113 | return nil, false 114 | } 115 | 116 | csiMtx.Lock() 117 | if cached, ok := csiCache[s]; ok { 118 | csiMtx.Unlock() 119 | return cached, true 120 | } 121 | csiMtx.Unlock() 122 | 123 | if val, ok := parseCSIStruct(s); ok { 124 | csiMtx.Lock() 125 | csiCache[s] = val 126 | csiMtx.Unlock() 127 | 128 | return val, true 129 | } 130 | 131 | return nil, false 132 | } 133 | 134 | func parseCSIStruct(s string) (any, bool) { 135 | switch s { 136 | case termenv.ShowCursorSeq: 137 | return CursorShowSeq{}, true 138 | case termenv.HideCursorSeq: 139 | return CursorHideSeq{}, true 140 | } 141 | 142 | switch s[len(s)-1] { 143 | case 'A': 144 | if count, err := strconv.Atoi(s[:len(s)-1]); err == nil { 145 | return CursorUpSeq{Count: count}, true 146 | } 147 | case 'B': 148 | if count, err := strconv.Atoi(s[:len(s)-1]); err == nil { 149 | return CursorDownSeq{Count: count}, true 150 | } 151 | case 'C': 152 | if count, err := strconv.Atoi(s[:len(s)-1]); err == nil { 153 | return CursorForwardSeq{Count: count}, true 154 | } 155 | case 'D': 156 | if count, err := strconv.Atoi(s[:len(s)-1]); err == nil { 157 | return CursorBackSeq{Count: count}, true 158 | } 159 | case 'E': 160 | if count, err := strconv.Atoi(s[:len(s)-1]); err == nil { 161 | return CursorNextLineSeq{Count: count}, true 162 | } 163 | case 'F': 164 | if count, err := strconv.Atoi(s[:len(s)-1]); err == nil { 165 | return CursorPreviousLineSeq{Count: count}, true 166 | } 167 | case 'G': 168 | if count, err := strconv.Atoi(s[:len(s)-1]); err == nil { 169 | return CursorHorizontalSeq{Count: count}, true 170 | } 171 | case 'H': 172 | if strings.Contains(s, ";") { 173 | parts := strings.Split(s[:len(s)-1], ";") 174 | if len(parts) != 2 { 175 | return nil, false 176 | } 177 | row, err := strconv.Atoi(parts[0]) 178 | if err != nil { 179 | return nil, false 180 | } 181 | col, err := strconv.Atoi(parts[1]) 182 | if err != nil { 183 | return nil, false 184 | } 185 | return CursorPositionSeq{Row: row, Col: col}, true 186 | } 187 | return nil, false 188 | case 'J': 189 | if t, err := strconv.Atoi(s[:len(s)-1]); err == nil { 190 | return EraseDisplaySeq{Type: t}, true 191 | } 192 | case 'K': 193 | if t, err := strconv.Atoi(s[:len(s)-1]); err == nil { 194 | return EraseLineSeq{Type: t}, true 195 | } 196 | case 'S': 197 | if count, err := strconv.Atoi(s[:len(s)-1]); err == nil { 198 | return ScrollUpSeq{Count: count}, true 199 | } 200 | case 'T': 201 | if count, err := strconv.Atoi(s[:len(s)-1]); err == nil { 202 | return ScrollDownSeq{Count: count}, true 203 | } 204 | case 's': 205 | if len(s) == 1 { 206 | return SaveCursorPositionSeq{}, true 207 | } 208 | case 'u': 209 | if len(s) == 1 { 210 | return RestoreCursorPositionSeq{}, true 211 | } 212 | case 'r': 213 | // TODO: implement 214 | case 'L': 215 | if count, err := strconv.Atoi(s[:len(s)-1]); err == nil { 216 | return InsertLineSeq{Count: count}, true 217 | } 218 | case 'M': 219 | if count, err := strconv.Atoi(s[:len(s)-1]); err == nil { 220 | return DeleteLineSeq{Count: count}, true 221 | } 222 | } 223 | 224 | return nil, false 225 | } 226 | -------------------------------------------------------------------------------- /csi_test.go: -------------------------------------------------------------------------------- 1 | package crt 2 | 3 | import ( 4 | "fmt" 5 | "github.com/muesli/termenv" 6 | "github.com/stretchr/testify/assert" 7 | "testing" 8 | ) 9 | 10 | func TestCSI(t *testing.T) { 11 | var testString string 12 | 13 | testString += fmt.Sprintf(termenv.CSI+termenv.EraseDisplaySeq, 20) 14 | testString += "HELLO WORLD" 15 | testString += fmt.Sprintf(termenv.CSI+termenv.CursorPositionSeq, 1, 2) 16 | testString += fmt.Sprintf(termenv.CSI+termenv.CursorPositionSeq, 1, 2) 17 | testString += "HELLO WORLD" 18 | testString += termenv.CSI + termenv.ShowCursorSeq 19 | testString += fmt.Sprintf(termenv.CSI+termenv.CursorPositionSeq, 1, 2) 20 | testString += fmt.Sprintf(termenv.CSI+termenv.CursorBackSeq, 5) 21 | 22 | var sequences []any 23 | for i := 0; i < len(testString); i++ { 24 | csi, ok := extractCSI(testString[i:]) 25 | if ok { 26 | i += len(csi) - 1 27 | 28 | if res, ok := parseCSI(csi); ok { 29 | sequences = append(sequences, res) 30 | } 31 | } 32 | } 33 | 34 | assert.Equal(t, []any{ 35 | EraseDisplaySeq{Type: 20}, 36 | CursorPositionSeq{Row: 1, Col: 2}, 37 | CursorPositionSeq{Row: 1, Col: 2}, 38 | CursorShowSeq{}, 39 | CursorPositionSeq{Row: 1, Col: 2}, 40 | CursorBackSeq{Count: 5}, 41 | }, sequences) 42 | } 43 | -------------------------------------------------------------------------------- /dpi.go: -------------------------------------------------------------------------------- 1 | package crt 2 | 3 | import ( 4 | "github.com/hajimehoshi/ebiten/v2" 5 | "os" 6 | "strconv" 7 | ) 8 | 9 | // DeviceScale returns the current device scale factor. 10 | // 11 | // If the environment variable CRT_DEVICE_SCALE is set, it will be used instead. 12 | func DeviceScale() float64 { 13 | if os.Getenv("CRT_DEVICE_SCALE") != "" { 14 | s, err := strconv.ParseFloat(os.Getenv("CRT_DEVICE_SCALE"), 64) 15 | if err == nil { 16 | return s 17 | } 18 | } 19 | 20 | return ebiten.DeviceScaleFactor() 21 | } 22 | 23 | // GetFontDPI returns the recommended font DPI for the current device. 24 | func GetFontDPI() float64 { 25 | return 72.0 * DeviceScale() 26 | } 27 | -------------------------------------------------------------------------------- /examples/benchmark/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "github.com/BigJk/crt" 7 | bubbleadapter "github.com/BigJk/crt/bubbletea" 8 | "github.com/BigJk/crt/shader" 9 | tea "github.com/charmbracelet/bubbletea" 10 | "github.com/charmbracelet/lipgloss" 11 | "github.com/hajimehoshi/ebiten/v2" 12 | "image/color" 13 | "math/rand" 14 | "net/http" 15 | _ "net/http/pprof" 16 | "time" 17 | ) 18 | 19 | const ( 20 | Width = 1000 21 | Height = 600 22 | ) 23 | 24 | type model struct { 25 | X, Y int 26 | } 27 | 28 | func (m *model) Init() tea.Cmd { 29 | return nil 30 | } 31 | 32 | func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 33 | return m, nil 34 | } 35 | 36 | func (m *model) View() string { 37 | return lipgloss.NewStyle().Margin(m.X, 0, 0, m.Y).Padding(5).Border(lipgloss.ThickBorder(), true).Background(lipgloss.Color("#fc2022")).Foreground(lipgloss.Color("#ff00ff")).Render("Hello World!") 38 | } 39 | 40 | func main() { 41 | go func() { 42 | fmt.Println(http.ListenAndServe("localhost:6060", nil)) 43 | }() 44 | 45 | rand.Seed(0) 46 | 47 | enableShader := flag.Bool("shader", false, "Enable shader") 48 | flag.Parse() 49 | 50 | fonts, err := crt.LoadFaces("./fonts/IosevkaTermNerdFontMono-Regular.ttf", "./fonts/IosevkaTermNerdFontMono-Bold.ttf", "./fonts/IosevkaTermNerdFontMono-Italic.ttf", crt.GetFontDPI(), 9.0) 51 | if err != nil { 52 | panic(err) 53 | } 54 | 55 | mod := &model{} 56 | win, prog, err := bubbleadapter.Window(Width, Height, fonts, mod, color.Black, tea.WithAltScreen()) 57 | if err != nil { 58 | panic(err) 59 | } 60 | 61 | go func() { 62 | for { 63 | mod.X = rand.Intn(win.GetCellsWidth()) 64 | mod.Y = rand.Intn(win.GetCellsHeight()) 65 | prog.Send(time.Now()) 66 | time.Sleep(time.Second) 67 | } 68 | }() 69 | 70 | var lastStart int64 71 | win.SetOnPreDraw(func(screen *ebiten.Image) { 72 | lastStart = time.Now().UnixMicro() 73 | }) 74 | win.SetOnPostDraw(func(screen *ebiten.Image) { 75 | elapsed := time.Now().UnixMicro() - lastStart 76 | if (1000 / (float64(elapsed) * 0.001)) > 500 { 77 | return 78 | } 79 | 80 | fmt.Printf("Frame took %d micro seconds FPS=%.2f\n", elapsed, 1000/(float64(elapsed)*0.001)) 81 | }) 82 | 83 | if *enableShader { 84 | lotte, err := shader.NewCrtLotte() 85 | if err != nil { 86 | panic(err) 87 | } 88 | 89 | win.SetShader(lotte) 90 | } 91 | 92 | win.ShowTPS(true) 93 | 94 | if err := win.Run("Simple"); err != nil { 95 | panic(err) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /examples/glamour/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/BigJk/crt" 5 | bubbleadapter "github.com/BigJk/crt/bubbletea" 6 | "github.com/charmbracelet/bubbles/viewport" 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/charmbracelet/glamour" 9 | "github.com/charmbracelet/lipgloss" 10 | "image/color" 11 | "os" 12 | "strings" 13 | ) 14 | 15 | const ( 16 | Width = 700 17 | Height = 900 18 | ) 19 | 20 | var ( 21 | helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render 22 | readme = "" 23 | ) 24 | 25 | type example struct { 26 | viewport viewport.Model 27 | } 28 | 29 | func newExample() *example { 30 | vp := viewport.New(20, 20) 31 | vp.Style = lipgloss.NewStyle(). 32 | BorderStyle(lipgloss.RoundedBorder()). 33 | BorderForeground(lipgloss.Color("62")). 34 | PaddingRight(2) 35 | 36 | return &example{ 37 | viewport: vp, 38 | } 39 | } 40 | 41 | func (e example) Init() tea.Cmd { 42 | return nil 43 | } 44 | 45 | func (e example) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 46 | switch msg := msg.(type) { 47 | case tea.KeyMsg: 48 | switch msg.String() { 49 | case "q", "ctrl+c", "esc": 50 | return e, tea.Quit 51 | default: 52 | var cmd tea.Cmd 53 | e.viewport, cmd = e.viewport.Update(msg) 54 | return e, cmd 55 | } 56 | case tea.WindowSizeMsg: 57 | e.viewport.Width = msg.Width 58 | e.viewport.Height = msg.Height - 3 59 | 60 | renderer, err := glamour.NewTermRenderer( 61 | glamour.WithAutoStyle(), 62 | glamour.WithWordWrap(msg.Width), 63 | ) 64 | if err != nil { 65 | panic(err) 66 | } 67 | 68 | str, err := renderer.Render(readme) 69 | if err != nil { 70 | panic(err) 71 | } 72 | 73 | e.viewport.SetContent(str) 74 | } 75 | return e, nil 76 | } 77 | 78 | func (e example) View() string { 79 | return e.viewport.View() + e.helpView() 80 | } 81 | 82 | func (e example) helpView() string { 83 | return helpStyle("\n ↑/↓: Navigate • q: Quit\n") 84 | } 85 | 86 | func main() { 87 | // Read the readme from repo 88 | f, err := os.ReadFile("./README.md") 89 | if err != nil { 90 | panic(err) 91 | } 92 | readme = string(f) 93 | readme = strings.Replace(readme, "\t", " ", -1) 94 | 95 | fonts, err := crt.LoadFaces("./fonts/IosevkaTermNerdFontMono-Regular.ttf", "./fonts/IosevkaTermNerdFontMono-Bold.ttf", "./fonts/IosevkaTermNerdFontMono-Italic.ttf", crt.GetFontDPI(), 12.0) 96 | if err != nil { 97 | panic(err) 98 | } 99 | 100 | win, _, err := bubbleadapter.Window(Width, Height, fonts, newExample(), color.RGBA{ 101 | R: 30, 102 | G: 30, 103 | B: 30, 104 | A: 255, 105 | }, tea.WithAltScreen()) 106 | if err != nil { 107 | panic(err) 108 | } 109 | 110 | if err := win.Run("Glamour Markdown"); err != nil { 111 | panic(err) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /examples/keys/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/BigJk/crt" 6 | bubbleadapter "github.com/BigJk/crt/bubbletea" 7 | tea "github.com/charmbracelet/bubbletea" 8 | "image/color" 9 | ) 10 | 11 | const ( 12 | Width = 1000 13 | Height = 600 14 | ) 15 | 16 | type model struct { 17 | keys []tea.KeyMsg 18 | } 19 | 20 | func (m model) Init() tea.Cmd { 21 | return nil 22 | } 23 | 24 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 25 | switch msg := msg.(type) { 26 | case tea.KeyMsg: 27 | m.keys = append(m.keys, msg) 28 | } 29 | return m, nil 30 | } 31 | 32 | func (m model) View() string { 33 | view := "" 34 | for _, key := range m.keys { 35 | view += " " + key.String() + fmt.Sprintf(" | %v %v", key.Runes, key.Alt) + "\n" 36 | } 37 | return view 38 | } 39 | 40 | func main() { 41 | fonts, err := crt.LoadFaces("./fonts/IosevkaTermNerdFontMono-Regular.ttf", "./fonts/IosevkaTermNerdFontMono-Bold.ttf", "./fonts/IosevkaTermNerdFontMono-Italic.ttf", crt.GetFontDPI(), 16.0) 42 | if err != nil { 43 | panic(err) 44 | } 45 | 46 | win, _, err := bubbleadapter.Window(Width, Height, fonts, model{}, color.Black) 47 | if err != nil { 48 | panic(err) 49 | } 50 | 51 | if err := win.Run("Simple"); err != nil { 52 | panic(err) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /examples/package-manager/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/BigJk/crt" 6 | bubbleadapter "github.com/BigJk/crt/bubbletea" 7 | "github.com/muesli/termenv" 8 | "image/color" 9 | "math/rand" 10 | "strings" 11 | "time" 12 | 13 | "github.com/charmbracelet/bubbles/progress" 14 | "github.com/charmbracelet/bubbles/spinner" 15 | tea "github.com/charmbracelet/bubbletea" 16 | "github.com/charmbracelet/lipgloss" 17 | ) 18 | 19 | const ( 20 | Width = 1000 21 | Height = 600 22 | ) 23 | 24 | var packages = []string{ 25 | "vegeutils", 26 | "libgardening", 27 | "currykit", 28 | "spicerack", 29 | "fullenglish", 30 | "eggy", 31 | "bad-kitty", 32 | "chai", 33 | "hojicha", 34 | "libtacos", 35 | "babys-monads", 36 | "libpurring", 37 | "currywurst-devel", 38 | "xmodmeow", 39 | "licorice-utils", 40 | "cashew-apple", 41 | "rock-lobster", 42 | "standmixer", 43 | "coffee-CUPS", 44 | "libesszet", 45 | "zeichenorientierte-benutzerschnittstellen", 46 | "schnurrkit", 47 | "old-socks-devel", 48 | "jalapeño", 49 | "molasses-utils", 50 | "xkohlrabi", 51 | "party-gherkin", 52 | "snow-peas", 53 | "libyuzu", 54 | } 55 | 56 | func getPackages() []string { 57 | pkgs := packages 58 | copy(pkgs, packages) 59 | 60 | rand.Shuffle(len(pkgs), func(i, j int) { 61 | pkgs[i], pkgs[j] = pkgs[j], pkgs[i] 62 | }) 63 | 64 | for k := range pkgs { 65 | pkgs[k] += fmt.Sprintf("-%d.%d.%d", rand.Intn(10), rand.Intn(10), rand.Intn(10)) 66 | } 67 | return pkgs 68 | } 69 | 70 | type model struct { 71 | packages []string 72 | index int 73 | width int 74 | height int 75 | spinner spinner.Model 76 | progress progress.Model 77 | done bool 78 | } 79 | 80 | var ( 81 | currentPkgNameStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("211")) 82 | doneStyle = lipgloss.NewStyle().Margin(1, 2) 83 | checkMark = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).SetString("✓") 84 | ) 85 | 86 | func newModel() model { 87 | p := progress.New( 88 | progress.WithDefaultGradient(), 89 | progress.WithWidth(40), 90 | progress.WithoutPercentage(), 91 | progress.WithColorProfile(termenv.TrueColor), 92 | progress.WithGradient("#231942", "#be95c4"), 93 | ) 94 | s := spinner.New() 95 | s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#669bbc")) 96 | return model{ 97 | packages: getPackages(), 98 | spinner: s, 99 | progress: p, 100 | } 101 | } 102 | 103 | func (m model) Init() tea.Cmd { 104 | return tea.Batch(downloadAndInstall(m.packages[m.index]), m.spinner.Tick) 105 | } 106 | 107 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 108 | switch msg := msg.(type) { 109 | case tea.WindowSizeMsg: 110 | m.width, m.height = msg.Width, msg.Height 111 | case tea.KeyMsg: 112 | switch msg.String() { 113 | case "ctrl+c", "esc", "q": 114 | return m, tea.Quit 115 | } 116 | case installedPkgMsg: 117 | if m.index >= len(m.packages)-1 { 118 | // Everything's been installed. We're done! 119 | m.done = true 120 | return m, tea.Quit 121 | } 122 | 123 | // Update progress bar 124 | progressCmd := m.progress.SetPercent(float64(m.index) / float64(len(m.packages)-1)) 125 | 126 | m.index++ 127 | return m, tea.Batch( 128 | progressCmd, 129 | tea.Printf("%s %s", checkMark, m.packages[m.index]), // print success message above our program 130 | downloadAndInstall(m.packages[m.index]), // download the next package 131 | ) 132 | case spinner.TickMsg: 133 | var cmd tea.Cmd 134 | m.spinner, cmd = m.spinner.Update(msg) 135 | return m, cmd 136 | case progress.FrameMsg: 137 | newModel, cmd := m.progress.Update(msg) 138 | if newModel, ok := newModel.(progress.Model); ok { 139 | m.progress = newModel 140 | } 141 | return m, cmd 142 | } 143 | return m, nil 144 | } 145 | 146 | func (m model) View() string { 147 | n := len(m.packages) 148 | w := lipgloss.Width(fmt.Sprintf("%d", n)) 149 | 150 | if m.done { 151 | return doneStyle.Render(fmt.Sprintf("Done! Installed %d packages.\n", n)) 152 | } 153 | 154 | pkgCount := fmt.Sprintf(" %*d/%*d", w, m.index, w, n-1) 155 | 156 | spin := m.spinner.View() + " " 157 | prog := m.progress.View() 158 | cellsAvail := max(0, m.width-lipgloss.Width(spin+prog+pkgCount)) 159 | 160 | pkgName := currentPkgNameStyle.Render(m.packages[m.index]) 161 | info := lipgloss.NewStyle().MaxWidth(cellsAvail).Render("Installing " + pkgName) 162 | 163 | cellsRemaining := max(0, m.width-lipgloss.Width(spin+info+prog+pkgCount)) 164 | gap := strings.Repeat(" ", cellsRemaining) 165 | 166 | return spin + info + gap + prog + pkgCount 167 | } 168 | 169 | type installedPkgMsg string 170 | 171 | func downloadAndInstall(pkg string) tea.Cmd { 172 | // This is where you'd do i/o stuff to download and install packages. In 173 | // our case we're just pausing for a moment to simulate the process. 174 | d := time.Millisecond * time.Duration(rand.Intn(500)) 175 | return tea.Tick(d, func(t time.Time) tea.Msg { 176 | return installedPkgMsg(pkg) 177 | }) 178 | } 179 | 180 | func max(a, b int) int { 181 | if a > b { 182 | return a 183 | } 184 | return b 185 | } 186 | 187 | func main() { 188 | rand.Seed(time.Now().Unix()) 189 | 190 | fonts, err := crt.LoadFaces("./fonts/IosevkaTermNerdFontMono-Regular.ttf", "./fonts/IosevkaTermNerdFontMono-Bold.ttf", "./fonts/IosevkaTermNerdFontMono-Italic.ttf", crt.GetFontDPI(), 16.0) 191 | if err != nil { 192 | panic(err) 193 | } 194 | 195 | win, _, err := bubbleadapter.Window(Width, Height, fonts, newModel(), color.Black) 196 | if err != nil { 197 | panic(err) 198 | } 199 | 200 | if err := win.Run("Simple"); err != nil { 201 | panic(err) 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /examples/shader/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/BigJk/crt" 6 | bubbleadapter "github.com/BigJk/crt/bubbletea" 7 | "github.com/BigJk/crt/shader" 8 | "github.com/muesli/termenv" 9 | "image/color" 10 | "math/rand" 11 | "strings" 12 | "time" 13 | 14 | "github.com/charmbracelet/bubbles/progress" 15 | "github.com/charmbracelet/bubbles/spinner" 16 | tea "github.com/charmbracelet/bubbletea" 17 | "github.com/charmbracelet/lipgloss" 18 | ) 19 | 20 | const ( 21 | Width = 1000 22 | Height = 600 23 | ) 24 | 25 | var packages = []string{ 26 | "vegeutils", 27 | "libgardening", 28 | "currykit", 29 | "spicerack", 30 | "fullenglish", 31 | "eggy", 32 | "bad-kitty", 33 | "chai", 34 | "hojicha", 35 | "libtacos", 36 | "babys-monads", 37 | "libpurring", 38 | "currywurst-devel", 39 | "xmodmeow", 40 | "licorice-utils", 41 | "cashew-apple", 42 | "rock-lobster", 43 | "standmixer", 44 | "coffee-CUPS", 45 | "libesszet", 46 | "zeichenorientierte-benutzerschnittstellen", 47 | "schnurrkit", 48 | "old-socks-devel", 49 | "jalapeño", 50 | "molasses-utils", 51 | "xkohlrabi", 52 | "party-gherkin", 53 | "snow-peas", 54 | "libyuzu", 55 | } 56 | 57 | func getPackages() []string { 58 | pkgs := packages 59 | copy(pkgs, packages) 60 | 61 | rand.Shuffle(len(pkgs), func(i, j int) { 62 | pkgs[i], pkgs[j] = pkgs[j], pkgs[i] 63 | }) 64 | 65 | for k := range pkgs { 66 | pkgs[k] += fmt.Sprintf("-%d.%d.%d", rand.Intn(10), rand.Intn(10), rand.Intn(10)) 67 | } 68 | return pkgs 69 | } 70 | 71 | type model struct { 72 | packages []string 73 | index int 74 | width int 75 | height int 76 | spinner spinner.Model 77 | progress progress.Model 78 | done bool 79 | } 80 | 81 | var ( 82 | currentPkgNameStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#e7c6ff")) 83 | doneStyle = lipgloss.NewStyle().Margin(1, 2) 84 | checkMark = lipgloss.NewStyle().Foreground(lipgloss.Color("#8ac926")).SetString("✓") 85 | ) 86 | 87 | func newModel() model { 88 | p := progress.New( 89 | progress.WithDefaultGradient(), 90 | progress.WithWidth(40), 91 | progress.WithoutPercentage(), 92 | progress.WithColorProfile(termenv.TrueColor), 93 | progress.WithGradient("#231942", "#be95c4"), 94 | ) 95 | s := spinner.New() 96 | s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#669bbc")) 97 | return model{ 98 | packages: getPackages(), 99 | spinner: s, 100 | progress: p, 101 | } 102 | } 103 | 104 | func (m model) Init() tea.Cmd { 105 | return tea.Batch(downloadAndInstall(m.packages[m.index]), m.spinner.Tick) 106 | } 107 | 108 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 109 | switch msg := msg.(type) { 110 | case tea.WindowSizeMsg: 111 | m.width, m.height = msg.Width, msg.Height 112 | case tea.KeyMsg: 113 | switch msg.String() { 114 | case "ctrl+c", "esc", "q": 115 | return m, tea.Quit 116 | } 117 | case installedPkgMsg: 118 | if m.index >= len(m.packages)-1 { 119 | // Everything's been installed. We're done! 120 | m.done = true 121 | return m, tea.Quit 122 | } 123 | 124 | // Update progress bar 125 | progressCmd := m.progress.SetPercent(float64(m.index) / float64(len(m.packages)-1)) 126 | 127 | m.index++ 128 | return m, tea.Batch( 129 | progressCmd, 130 | tea.Printf("%s %s", checkMark, m.packages[m.index]), // print success message above our program 131 | downloadAndInstall(m.packages[m.index]), // download the next package 132 | ) 133 | case spinner.TickMsg: 134 | var cmd tea.Cmd 135 | m.spinner, cmd = m.spinner.Update(msg) 136 | return m, cmd 137 | case progress.FrameMsg: 138 | newModel, cmd := m.progress.Update(msg) 139 | if newModel, ok := newModel.(progress.Model); ok { 140 | m.progress = newModel 141 | } 142 | return m, cmd 143 | } 144 | return m, nil 145 | } 146 | 147 | func (m model) View() string { 148 | n := len(m.packages) 149 | w := lipgloss.Width(fmt.Sprintf("%d", n)) 150 | 151 | if m.done { 152 | return doneStyle.Render(fmt.Sprintf("Done! Installed %d packages.\n", n)) 153 | } 154 | 155 | pkgCount := fmt.Sprintf(" %*d/%*d", w, m.index, w, n-1) 156 | 157 | spin := m.spinner.View() + " " 158 | prog := m.progress.View() 159 | cellsAvail := max(0, m.width-lipgloss.Width(spin+prog+pkgCount)) 160 | 161 | pkgName := currentPkgNameStyle.Render(m.packages[m.index]) 162 | info := lipgloss.NewStyle().MaxWidth(cellsAvail).Render("Installing " + pkgName) 163 | 164 | cellsRemaining := max(0, m.width-lipgloss.Width(spin+info+prog+pkgCount)) 165 | gap := strings.Repeat(" ", cellsRemaining) 166 | 167 | return spin + info + gap + prog + pkgCount 168 | } 169 | 170 | type installedPkgMsg string 171 | 172 | func downloadAndInstall(pkg string) tea.Cmd { 173 | // This is where you'd do i/o stuff to download and install packages. In 174 | // our case we're just pausing for a moment to simulate the process. 175 | d := time.Millisecond * time.Duration(rand.Intn(500)) 176 | return tea.Tick(d, func(t time.Time) tea.Msg { 177 | return installedPkgMsg(pkg) 178 | }) 179 | } 180 | 181 | func max(a, b int) int { 182 | if a > b { 183 | return a 184 | } 185 | return b 186 | } 187 | 188 | func main() { 189 | rand.Seed(time.Now().Unix()) 190 | 191 | fonts, err := crt.LoadFaces("./fonts/IosevkaTermNerdFontMono-Regular.ttf", "./fonts/IosevkaTermNerdFontMono-Bold.ttf", "./fonts/IosevkaTermNerdFontMono-Italic.ttf", crt.GetFontDPI(), 16.0) 192 | if err != nil { 193 | panic(err) 194 | } 195 | 196 | win, _, err := bubbleadapter.Window(Width, Height, fonts, newModel(), color.Black) 197 | if err != nil { 198 | panic(err) 199 | } 200 | 201 | lotte, err := shader.NewCrtLotte() 202 | if err != nil { 203 | panic(err) 204 | } 205 | 206 | win.SetShader(lotte) 207 | 208 | if err := win.Run("Simple"); err != nil { 209 | panic(err) 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /examples/simple/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/BigJk/crt" 5 | bubbleadapter "github.com/BigJk/crt/bubbletea" 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/charmbracelet/lipgloss" 8 | "image/color" 9 | ) 10 | 11 | const ( 12 | Width = 1000 13 | Height = 600 14 | ) 15 | 16 | type model struct { 17 | } 18 | 19 | func (m model) Init() tea.Cmd { 20 | return nil 21 | } 22 | 23 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 24 | return m, nil 25 | } 26 | 27 | func (m model) View() string { 28 | return lipgloss.NewStyle().Margin(5).Padding(5).Border(lipgloss.ThickBorder(), true).Foreground(lipgloss.Color("#ff00ff")).Render("Hello World!") 29 | } 30 | 31 | func main() { 32 | fonts, err := crt.LoadFaces("./fonts/IosevkaTermNerdFontMono-Regular.ttf", "./fonts/IosevkaTermNerdFontMono-Bold.ttf", "./fonts/IosevkaTermNerdFontMono-Italic.ttf", crt.GetFontDPI(), 16.0) 33 | if err != nil { 34 | panic(err) 35 | } 36 | 37 | win, prog, err := bubbleadapter.Window(Width, Height, fonts, model{}, color.Black, tea.WithAltScreen()) 38 | if err != nil { 39 | panic(err) 40 | } 41 | 42 | prog.Send(tea.ShowCursor()) 43 | win.SetCursorChar("_") 44 | 45 | if err := win.Run("Simple"); err != nil { 46 | panic(err) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /examples/split-editor/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/BigJk/crt" 5 | bubbleadapter "github.com/BigJk/crt/bubbletea" 6 | "github.com/charmbracelet/bubbles/help" 7 | "github.com/charmbracelet/bubbles/key" 8 | "github.com/charmbracelet/bubbles/textarea" 9 | tea "github.com/charmbracelet/bubbletea" 10 | "github.com/charmbracelet/lipgloss" 11 | "image/color" 12 | ) 13 | 14 | const ( 15 | Width = 1000 16 | Height = 600 17 | ) 18 | 19 | const ( 20 | initialInputs = 2 21 | maxInputs = 6 22 | minInputs = 1 23 | helpHeight = 5 24 | ) 25 | 26 | var ( 27 | cursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("212")) 28 | 29 | cursorLineStyle = lipgloss.NewStyle(). 30 | Background(lipgloss.Color("57")). 31 | Foreground(lipgloss.Color("230")) 32 | 33 | placeholderStyle = lipgloss.NewStyle(). 34 | Foreground(lipgloss.Color("238")) 35 | 36 | endOfBufferStyle = lipgloss.NewStyle(). 37 | Foreground(lipgloss.Color("235")) 38 | 39 | focusedPlaceholderStyle = lipgloss.NewStyle(). 40 | Foreground(lipgloss.Color("99")) 41 | 42 | focusedBorderStyle = lipgloss.NewStyle(). 43 | Border(lipgloss.RoundedBorder()). 44 | BorderForeground(lipgloss.Color("238")) 45 | 46 | blurredBorderStyle = lipgloss.NewStyle(). 47 | Border(lipgloss.HiddenBorder()) 48 | ) 49 | 50 | type keymap = struct { 51 | next, prev, add, remove, quit key.Binding 52 | } 53 | 54 | func newTextarea() textarea.Model { 55 | t := textarea.New() 56 | t.Prompt = "" 57 | t.Placeholder = "Type something" 58 | t.ShowLineNumbers = true 59 | t.Cursor.Style = cursorStyle 60 | t.FocusedStyle.Placeholder = focusedPlaceholderStyle 61 | t.BlurredStyle.Placeholder = placeholderStyle 62 | t.FocusedStyle.CursorLine = cursorLineStyle 63 | t.FocusedStyle.Base = focusedBorderStyle 64 | t.BlurredStyle.Base = blurredBorderStyle 65 | t.FocusedStyle.EndOfBuffer = endOfBufferStyle 66 | t.BlurredStyle.EndOfBuffer = endOfBufferStyle 67 | t.KeyMap.DeleteWordBackward.SetEnabled(false) 68 | t.KeyMap.LineNext = key.NewBinding(key.WithKeys("down")) 69 | t.KeyMap.LinePrevious = key.NewBinding(key.WithKeys("up")) 70 | t.Blur() 71 | return t 72 | } 73 | 74 | type model struct { 75 | width int 76 | height int 77 | keymap keymap 78 | help help.Model 79 | inputs []textarea.Model 80 | focus int 81 | } 82 | 83 | func newModel() model { 84 | m := model{ 85 | inputs: make([]textarea.Model, initialInputs), 86 | help: help.New(), 87 | keymap: keymap{ 88 | next: key.NewBinding( 89 | key.WithKeys("tab"), 90 | key.WithHelp("tab", "next"), 91 | ), 92 | prev: key.NewBinding( 93 | key.WithKeys("shift+tab"), 94 | key.WithHelp("shift+tab", "prev"), 95 | ), 96 | add: key.NewBinding( 97 | key.WithKeys("ctrl+n"), 98 | key.WithHelp("ctrl+n", "add an editor"), 99 | ), 100 | remove: key.NewBinding( 101 | key.WithKeys("ctrl+w"), 102 | key.WithHelp("ctrl+w", "remove an editor"), 103 | ), 104 | quit: key.NewBinding( 105 | key.WithKeys("esc", "ctrl+c"), 106 | key.WithHelp("esc", "quit"), 107 | ), 108 | }, 109 | } 110 | for i := 0; i < initialInputs; i++ { 111 | m.inputs[i] = newTextarea() 112 | } 113 | m.inputs[m.focus].Focus() 114 | m.updateKeybindings() 115 | return m 116 | } 117 | 118 | func (m model) Init() tea.Cmd { 119 | return textarea.Blink 120 | } 121 | 122 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 123 | var cmds []tea.Cmd 124 | 125 | switch msg := msg.(type) { 126 | case tea.KeyMsg: 127 | switch { 128 | case key.Matches(msg, m.keymap.quit): 129 | for i := range m.inputs { 130 | m.inputs[i].Blur() 131 | } 132 | return m, tea.Quit 133 | case key.Matches(msg, m.keymap.next): 134 | m.inputs[m.focus].Blur() 135 | m.focus++ 136 | if m.focus > len(m.inputs)-1 { 137 | m.focus = 0 138 | } 139 | cmd := m.inputs[m.focus].Focus() 140 | cmds = append(cmds, cmd) 141 | case key.Matches(msg, m.keymap.prev): 142 | m.inputs[m.focus].Blur() 143 | m.focus-- 144 | if m.focus < 0 { 145 | m.focus = len(m.inputs) - 1 146 | } 147 | cmd := m.inputs[m.focus].Focus() 148 | cmds = append(cmds, cmd) 149 | case key.Matches(msg, m.keymap.add): 150 | m.inputs = append(m.inputs, newTextarea()) 151 | case key.Matches(msg, m.keymap.remove): 152 | m.inputs = m.inputs[:len(m.inputs)-1] 153 | if m.focus > len(m.inputs)-1 { 154 | m.focus = len(m.inputs) - 1 155 | } 156 | } 157 | case tea.WindowSizeMsg: 158 | m.height = msg.Height 159 | m.width = msg.Width 160 | } 161 | 162 | m.updateKeybindings() 163 | m.sizeInputs() 164 | 165 | // Update all textareas 166 | for i := range m.inputs { 167 | newModel, cmd := m.inputs[i].Update(msg) 168 | m.inputs[i] = newModel 169 | cmds = append(cmds, cmd) 170 | } 171 | 172 | return m, tea.Batch(cmds...) 173 | } 174 | 175 | func (m *model) sizeInputs() { 176 | for i := range m.inputs { 177 | m.inputs[i].SetWidth(m.width / len(m.inputs)) 178 | m.inputs[i].SetHeight(m.height - helpHeight) 179 | } 180 | } 181 | 182 | func (m *model) updateKeybindings() { 183 | m.keymap.add.SetEnabled(len(m.inputs) < maxInputs) 184 | m.keymap.remove.SetEnabled(len(m.inputs) > minInputs) 185 | } 186 | 187 | func (m model) View() string { 188 | help := m.help.ShortHelpView([]key.Binding{ 189 | m.keymap.next, 190 | m.keymap.prev, 191 | m.keymap.add, 192 | m.keymap.remove, 193 | m.keymap.quit, 194 | }) 195 | 196 | var views []string 197 | for i := range m.inputs { 198 | views = append(views, m.inputs[i].View()) 199 | } 200 | 201 | return lipgloss.JoinHorizontal(lipgloss.Top, views...) + "\n\n" + help 202 | } 203 | 204 | func main() { 205 | fonts, err := crt.LoadFaces("./fonts/IosevkaTermNerdFontMono-Regular.ttf", "./fonts/IosevkaTermNerdFontMono-Bold.ttf", "./fonts/IosevkaTermNerdFontMono-Italic.ttf", crt.GetFontDPI(), 12.0) 206 | if err != nil { 207 | panic(err) 208 | } 209 | 210 | win, _, err := bubbleadapter.Window(Width, Height, fonts, newModel(), color.Black, tea.WithAltScreen()) 211 | if err != nil { 212 | panic(err) 213 | } 214 | 215 | if err := win.Run("Split Editor"); err != nil { 216 | panic(err) 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /font.go: -------------------------------------------------------------------------------- 1 | package crt 2 | 3 | import ( 4 | "golang.org/x/image/font" 5 | "golang.org/x/image/font/opentype" 6 | "os" 7 | ) 8 | 9 | type Fonts struct { 10 | Normal font.Face 11 | Bold font.Face 12 | Italic font.Face 13 | } 14 | 15 | // LoadFaceBytes loads a font face from bytes. The dpi and size are used to generate the font face. 16 | // The normal, bold, and italic files must be provided. Supports ttf and otf. 17 | func LoadFaceBytes(file []byte, dpi float64, size float64) (font.Face, error) { 18 | tt, err := opentype.Parse(file) 19 | if err != nil { 20 | panic(err) 21 | } 22 | 23 | face, err := opentype.NewFace(tt, &opentype.FaceOptions{ 24 | Size: size, 25 | DPI: dpi, 26 | Hinting: font.HintingNone, 27 | }) 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | return face, nil 33 | } 34 | 35 | // LoadFacesBytes loads a set of fonts from bytes. The normal, bold, and italic files 36 | // must be provided. The dpi and size are used to generate the font faces. Supports ttf and otf. 37 | func LoadFacesBytes(normal []byte, bold []byte, italic []byte, dpi float64, size float64) (Fonts, error) { 38 | normalFace, err := LoadFaceBytes(normal, dpi, size) 39 | if err != nil { 40 | return Fonts{}, err 41 | } 42 | 43 | boldFace, err := LoadFaceBytes(bold, dpi, size) 44 | if err != nil { 45 | return Fonts{}, err 46 | } 47 | 48 | italicFace, err := LoadFaceBytes(italic, dpi, size) 49 | if err != nil { 50 | return Fonts{}, err 51 | } 52 | 53 | return Fonts{ 54 | Normal: normalFace, 55 | Bold: boldFace, 56 | Italic: italicFace, 57 | }, nil 58 | } 59 | 60 | // LoadFace loads a font face from a file. The dpi and size are used to generate the font face. Supports ttf and otf. 61 | // 62 | // Example: LoadFace("./fonts/Mono-Regular.ttf", 72.0, 16.0) 63 | func LoadFace(file string, dpi float64, size float64) (font.Face, error) { 64 | data, err := os.ReadFile(file) 65 | if err != nil { 66 | panic(err) 67 | } 68 | 69 | return LoadFaceBytes(data, dpi, size) 70 | } 71 | 72 | // LoadFaces loads a set of fonts from files. The normal, bold, and italic files 73 | // must be provided. The dpi and size are used to generate the font faces. Supports ttf and otf. 74 | func LoadFaces(normal string, bold string, italic string, dpi float64, size float64) (Fonts, error) { 75 | normalFace, err := LoadFace(normal, dpi, size) 76 | if err != nil { 77 | return Fonts{}, err 78 | } 79 | 80 | boldFace, err := LoadFace(bold, dpi, size) 81 | if err != nil { 82 | return Fonts{}, err 83 | } 84 | 85 | italicFace, err := LoadFace(italic, dpi, size) 86 | if err != nil { 87 | return Fonts{}, err 88 | } 89 | 90 | return Fonts{ 91 | Normal: normalFace, 92 | Bold: boldFace, 93 | Italic: italicFace, 94 | }, nil 95 | } 96 | -------------------------------------------------------------------------------- /fonts/IosevkaTermNerdFontMono-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BigJk/crt/7710fdc88d6e48163e2d3649d35ab45d00266772/fonts/IosevkaTermNerdFontMono-Bold.ttf -------------------------------------------------------------------------------- /fonts/IosevkaTermNerdFontMono-Italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BigJk/crt/7710fdc88d6e48163e2d3649d35ab45d00266772/fonts/IosevkaTermNerdFontMono-Italic.ttf -------------------------------------------------------------------------------- /fonts/IosevkaTermNerdFontMono-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BigJk/crt/7710fdc88d6e48163e2d3649d35ab45d00266772/fonts/IosevkaTermNerdFontMono-Regular.ttf -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/BigJk/crt 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/charmbracelet/bubbles v0.15.0 7 | github.com/charmbracelet/bubbletea v0.25.0 8 | github.com/charmbracelet/glamour v0.6.0 9 | github.com/charmbracelet/lipgloss v0.7.1 10 | github.com/hajimehoshi/ebiten/v2 v2.6.3 11 | github.com/lucasb-eyer/go-colorful v1.2.0 12 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 13 | github.com/muesli/termenv v0.15.2 14 | github.com/stretchr/testify v1.8.2 15 | golang.org/x/image v0.12.0 16 | ) 17 | 18 | require ( 19 | github.com/alecthomas/chroma v0.10.0 // indirect 20 | github.com/atotto/clipboard v0.1.4 // indirect 21 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 22 | github.com/aymerick/douceur v0.2.0 // indirect 23 | github.com/charmbracelet/harmonica v0.2.0 // indirect 24 | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect 25 | github.com/davecgh/go-spew v1.1.1 // indirect 26 | github.com/dlclark/regexp2 v1.4.0 // indirect 27 | github.com/ebitengine/purego v0.5.0 // indirect 28 | github.com/gorilla/css v1.0.0 // indirect 29 | github.com/jezek/xgb v1.1.0 // indirect 30 | github.com/mattn/go-isatty v0.0.18 // indirect 31 | github.com/mattn/go-localereader v0.0.1 // indirect 32 | github.com/mattn/go-runewidth v0.0.14 // indirect 33 | github.com/microcosm-cc/bluemonday v1.0.21 // indirect 34 | github.com/muesli/cancelreader v0.2.2 // indirect 35 | github.com/muesli/reflow v0.3.0 // indirect 36 | github.com/olekukonko/tablewriter v0.0.5 // indirect 37 | github.com/pmezard/go-difflib v1.0.0 // indirect 38 | github.com/rivo/uniseg v0.2.0 // indirect 39 | github.com/yuin/goldmark v1.5.2 // indirect 40 | github.com/yuin/goldmark-emoji v1.0.1 // indirect 41 | golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56 // indirect 42 | golang.org/x/mobile v0.0.0-20230922142353-e2f452493d57 // indirect 43 | golang.org/x/net v0.6.0 // indirect 44 | golang.org/x/sync v0.3.0 // indirect 45 | golang.org/x/sys v0.12.0 // indirect 46 | golang.org/x/term v0.6.0 // indirect 47 | golang.org/x/text v0.13.0 // indirect 48 | gopkg.in/yaml.v3 v3.0.1 // indirect 49 | ) 50 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= 2 | github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek= 3 | github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s= 4 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 5 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 6 | github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4= 7 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 8 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 9 | github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 10 | github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 11 | github.com/charmbracelet/bubbles v0.15.0 h1:c5vZ3woHV5W2b8YZI1q7v4ZNQaPetfHuoHzx+56Z6TI= 12 | github.com/charmbracelet/bubbles v0.15.0/go.mod h1:Y7gSFbBzlMpUDR/XM9MhZI374Q+1p1kluf1uLl8iK74= 13 | github.com/charmbracelet/bubbletea v0.23.1/go.mod h1:JAfGK/3/pPKHTnAS8JIE2u9f61BjWTQY57RbT25aMXU= 14 | github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM= 15 | github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg= 16 | github.com/charmbracelet/glamour v0.6.0 h1:wi8fse3Y7nfcabbbDuwolqTqMQPMnVPeZhDM273bISc= 17 | github.com/charmbracelet/glamour v0.6.0/go.mod h1:taqWV4swIMMbWALc0m7AfE9JkPSU8om2538k9ITBxOc= 18 | github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= 19 | github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= 20 | github.com/charmbracelet/lipgloss v0.6.0/go.mod h1:tHh2wr34xcHjC2HCXIlGSG1jaDF0S0atAUvBMP6Ppuk= 21 | github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E= 22 | github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c= 23 | github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= 24 | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= 25 | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= 26 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 27 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 28 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 29 | github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E= 30 | github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= 31 | github.com/ebitengine/purego v0.5.0 h1:JrMGKfRIAM4/QVKaesIIT7m/UVjTj5GYhRSQYwfVdpo= 32 | github.com/ebitengine/purego v0.5.0/go.mod h1:ah1In8AOtksoNK6yk5z1HTJeUkC1Ez4Wk2idgGslMwQ= 33 | github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= 34 | github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= 35 | github.com/hajimehoshi/bitmapfont/v3 v3.0.0 h1:r2+6gYK38nfztS/et50gHAswb9hXgxXECYgE8Nczmi4= 36 | github.com/hajimehoshi/ebiten/v2 v2.6.3 h1:xJ5klESxhflZbPUx3GdIPoITzgPgamsyv8aZCVguXGI= 37 | github.com/hajimehoshi/ebiten/v2 v2.6.3/go.mod h1:TZtorL713an00UW4LyvMeKD8uXWnuIuCPtlH11b0pgI= 38 | github.com/jezek/xgb v1.1.0 h1:wnpxJzP1+rkbGclEkmwpVFQWpuE2PUGNUzP8SbfFobk= 39 | github.com/jezek/xgb v1.1.0/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk= 40 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 41 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 42 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 43 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 44 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 45 | github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= 46 | github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 47 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 48 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 49 | github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 50 | github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 51 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 52 | github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 53 | github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= 54 | github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 55 | github.com/microcosm-cc/bluemonday v1.0.21 h1:dNH3e4PSyE4vNX+KlRGHT5KrSvjeUkoNPwEORjffHJg= 56 | github.com/microcosm-cc/bluemonday v1.0.21/go.mod h1:ytNkv4RrDrLJ2pqlsSI46O6IVXmZOBBD4SaJyDwwTkM= 57 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= 58 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 59 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 60 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 61 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 62 | github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= 63 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 64 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 65 | github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= 66 | github.com/muesli/termenv v0.13.0/go.mod h1:sP1+uffeLaEYpyOTb8pLCUctGcGLnoFjSn4YJK5e2bc= 67 | github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= 68 | github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= 69 | github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= 70 | github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= 71 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 72 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 73 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 74 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= 75 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 76 | github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 77 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 78 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 79 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 80 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 81 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 82 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 83 | github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= 84 | github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 85 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 86 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 87 | github.com/yuin/goldmark v1.5.2 h1:ALmeCk/px5FSm1MAcFBAsVKZjDuMVj8Tm7FFIlMJnqU= 88 | github.com/yuin/goldmark v1.5.2/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 89 | github.com/yuin/goldmark-emoji v1.0.1 h1:ctuWEyzGBwiucEqxzwe0SOYDXPAucOrE9NQC18Wa1os= 90 | github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGjRIBbIZQFqkQ= 91 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 92 | golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 93 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 94 | golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56 h1:estk1glOnSVeJ9tdEZZc5mAMDZk5lNJNyJ6DvrBkTEU= 95 | golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4= 96 | golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= 97 | golang.org/x/image v0.12.0 h1:w13vZbU4o5rKOFFR8y7M+c4A5jXDC0uXTdHYRP8X2DQ= 98 | golang.org/x/image v0.12.0/go.mod h1:Lu90jvHG7GfemOIcldsh9A2hS01ocl6oNO7ype5mEnk= 99 | golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= 100 | golang.org/x/mobile v0.0.0-20230922142353-e2f452493d57 h1:Q6NT8ckDYNcwmi/bmxe+XbiDMXqMRW1xFBtJ+bIpie4= 101 | golang.org/x/mobile v0.0.0-20230922142353-e2f452493d57/go.mod h1:wEyOn6VvNW7tcf+bW/wBz1sehi2s2BZ4TimyR7qZen4= 102 | golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= 103 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 104 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 105 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 106 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 107 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 108 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 109 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 110 | golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= 111 | golang.org/x/net v0.6.0 h1:L4ZwwTvKW9gr0ZMS1yrHD9GZhIuVjOBBnaKH+SPQK0Q= 112 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 113 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 114 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 115 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 116 | golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= 117 | golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= 118 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 119 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 120 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 121 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 122 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 123 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 124 | golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 125 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 126 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 127 | golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 128 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 129 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 130 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 131 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 132 | golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= 133 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 134 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 135 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 136 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 137 | golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw= 138 | golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= 139 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 140 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 141 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 142 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 143 | golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= 144 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 145 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 146 | golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 147 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 148 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 149 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 150 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 151 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 152 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 153 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 154 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 155 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 156 | -------------------------------------------------------------------------------- /kill.go: -------------------------------------------------------------------------------- 1 | //go:build !windows && !js 2 | // +build !windows,!js 3 | 4 | package crt 5 | 6 | import "syscall" 7 | 8 | func SysKill() { 9 | _ = syscall.Kill(syscall.Getpid(), syscall.SIGINT) 10 | } 11 | -------------------------------------------------------------------------------- /kill_js.go: -------------------------------------------------------------------------------- 1 | package crt 2 | 3 | func SysKill() { 4 | 5 | } 6 | -------------------------------------------------------------------------------- /kill_windows.go: -------------------------------------------------------------------------------- 1 | package crt 2 | 3 | import ( 4 | "os" 5 | ) 6 | 7 | func SysKill() { 8 | os.Exit(1) 9 | } 10 | -------------------------------------------------------------------------------- /read_writer.go: -------------------------------------------------------------------------------- 1 | package crt 2 | 3 | import "io" 4 | 5 | // ConcurrentRW is a concurrent read/write buffer via channels. 6 | type ConcurrentRW struct { 7 | input chan []byte 8 | output chan []byte 9 | } 10 | 11 | // NewConcurrentRW creates a new concurrent read/write buffer. 12 | func NewConcurrentRW() *ConcurrentRW { 13 | return &ConcurrentRW{ 14 | input: make(chan []byte, 10), 15 | output: make(chan []byte), 16 | } 17 | } 18 | 19 | // Write writes data to the buffer. 20 | func (rw *ConcurrentRW) Write(p []byte) (n int, err error) { 21 | data := make([]byte, len(p)) 22 | copy(data, p) 23 | rw.input <- data 24 | return len(data), nil 25 | } 26 | 27 | // Read reads data from the buffer. 28 | func (rw *ConcurrentRW) Read(p []byte) (n int, err error) { 29 | data, ok := <-rw.output 30 | if !ok { 31 | return 0, io.EOF 32 | } 33 | n = copy(p, data) 34 | return n, nil 35 | } 36 | 37 | // Run starts the concurrent read/write buffer. 38 | func (rw *ConcurrentRW) Run() { 39 | const bufferSize = 1024 40 | buf := make([]byte, 0, bufferSize) 41 | for { 42 | select { 43 | case data, ok := <-rw.input: 44 | if !ok { 45 | close(rw.output) 46 | return 47 | } 48 | buf = append(buf, data...) 49 | for len(buf) > 0 { 50 | n := len(buf) 51 | if n > bufferSize { 52 | n = bufferSize 53 | } 54 | p := make([]byte, n) 55 | copy(p, buf[:n]) 56 | buf = buf[n:] 57 | rw.output <- p 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /sgr.go: -------------------------------------------------------------------------------- 1 | package crt 2 | 3 | import ( 4 | "fmt" 5 | "github.com/muesli/termenv" 6 | "strings" 7 | "sync" 8 | ) 9 | 10 | var sgrMtx = &sync.Mutex{} 11 | var sgrCache = map[string][]any{} 12 | 13 | // extractSGR extracts an SGR ansi sequence from the beginning of the string. 14 | func extractSGR(s string) (string, bool) { 15 | if len(s) < 2 { 16 | return "", false 17 | } 18 | 19 | if !strings.HasPrefix(s, termenv.CSI) { 20 | return "", false 21 | } 22 | 23 | for i := 2; i < len(s); i++ { 24 | if s[i] == ' ' || s[i] == termenv.CSI[0] { 25 | return "", false 26 | } 27 | 28 | if s[i] == 'm' { 29 | return s[:i+1], true 30 | } 31 | } 32 | 33 | return "", false 34 | } 35 | 36 | type SGRReset struct{} 37 | 38 | type SGRBold struct{} 39 | 40 | type SGRUnsetBold struct{} 41 | 42 | type SGRItalic struct{} 43 | 44 | type SGRUnsetItalic struct{} 45 | 46 | type SGRFgTrueColor struct { 47 | R, G, B byte 48 | } 49 | 50 | type SGRBgTrueColor struct { 51 | R, G, B byte 52 | } 53 | 54 | type SGRFgColor struct { 55 | Id int 56 | } 57 | 58 | type SGRBgColor struct { 59 | Id int 60 | } 61 | 62 | // parseSGR parses a single SGR ansi sequence and returns a struct representing the sequence. 63 | func parseSGR(s string) ([]any, bool) { 64 | if !strings.HasPrefix(s, termenv.CSI) { 65 | return nil, false 66 | } 67 | 68 | s = s[len(termenv.CSI):] 69 | if len(s) == 0 { 70 | return nil, false 71 | } 72 | 73 | sgrMtx.Lock() 74 | if cached, ok := sgrCache[s]; ok { 75 | sgrMtx.Unlock() 76 | return cached, true 77 | } 78 | sgrMtx.Unlock() 79 | 80 | full := s 81 | 82 | if !strings.HasSuffix(s, "m") { 83 | return nil, false 84 | } 85 | 86 | s = s[:len(s)-1] 87 | if len(s) == 0 { 88 | return nil, false 89 | } 90 | 91 | var skips int 92 | var res []any 93 | for len(s) > 0 { 94 | code := strings.SplitN(s, ";", 2)[0] 95 | 96 | if skips > 0 { 97 | skips-- 98 | } else { 99 | switch code { 100 | case "0": 101 | res = append(res, SGRReset{}) 102 | case "1": 103 | res = append(res, SGRBold{}) 104 | case "22": 105 | res = append(res, SGRUnsetBold{}) 106 | case "3": 107 | res = append(res, SGRItalic{}) 108 | case "23": 109 | res = append(res, SGRUnsetItalic{}) 110 | default: 111 | if strings.HasPrefix(s, "38;2;") { 112 | var r, g, b byte 113 | _, err := fmt.Sscanf(s, "38;2;%d;%d;%d", &r, &g, &b) 114 | if err == nil { 115 | skips = 4 116 | res = append(res, SGRFgTrueColor{r, g, b}) 117 | continue 118 | } 119 | } else if strings.HasPrefix(s, "48;2;") { 120 | var r, g, b byte 121 | _, err := fmt.Sscanf(s, "48;2;%d;%d;%d", &r, &g, &b) 122 | if err == nil { 123 | skips = 4 124 | res = append(res, SGRBgTrueColor{r, g, b}) 125 | continue 126 | } 127 | } else if strings.HasPrefix(s, "38;5;") { 128 | var id int 129 | _, err := fmt.Sscanf(s, "38;5;%d", &id) 130 | if err == nil { 131 | skips = 2 132 | res = append(res, SGRFgColor{id}) 133 | continue 134 | } 135 | } else if strings.HasPrefix(s, "48;5;") { 136 | var id int 137 | _, err := fmt.Sscanf(s, "48;5;%d", &id) 138 | if err == nil { 139 | skips = 2 140 | res = append(res, SGRBgColor{id}) 141 | continue 142 | } 143 | } 144 | } 145 | } 146 | 147 | if len(code) >= len(s) { 148 | break 149 | } 150 | 151 | s = s[len(code)+1:] 152 | } 153 | 154 | sgrMtx.Lock() 155 | sgrCache[full] = res 156 | sgrMtx.Unlock() 157 | 158 | return res, len(res) > 0 159 | } 160 | -------------------------------------------------------------------------------- /sgr_test.go: -------------------------------------------------------------------------------- 1 | package crt 2 | 3 | import ( 4 | "bytes" 5 | "github.com/charmbracelet/lipgloss" 6 | "github.com/muesli/termenv" 7 | "github.com/stretchr/testify/assert" 8 | "testing" 9 | ) 10 | 11 | func TestSGR(t *testing.T) { 12 | buf := &bytes.Buffer{} 13 | lip := lipgloss.NewRenderer(buf, termenv.WithProfile(termenv.TrueColor)) 14 | testString := lip.NewStyle().Bold(true).Foreground(lipgloss.Color("#ff00ff")).Render("Hello World") + "asdasdasdasdasd" + lip.NewStyle().Italic(true).Background(lipgloss.Color("#ff00ff")).Render("Hello World") 15 | 16 | var sequences []any 17 | for i := 0; i < len(testString); i++ { 18 | sgr, ok := extractSGR(testString[i:]) 19 | if ok { 20 | i += len(sgr) - 1 21 | 22 | if res, ok := parseSGR(sgr); ok { 23 | sequences = append(sequences, res...) 24 | } 25 | } 26 | } 27 | 28 | assert.Equal(t, []any{ 29 | SGRBold{}, 30 | SGRFgTrueColor{R: 255, G: 0, B: 255}, 31 | SGRReset{}, 32 | SGRItalic{}, 33 | SGRBgTrueColor{R: 255, G: 0, B: 255}, 34 | SGRReset{}, 35 | }, sequences) 36 | } 37 | -------------------------------------------------------------------------------- /shader/crt_basic.go: -------------------------------------------------------------------------------- 1 | package shader 2 | 3 | import ( 4 | "github.com/hajimehoshi/ebiten/v2" 5 | "math/rand" 6 | ) 7 | 8 | // crtBasicKage is a CRT shader that simulates a CRT monitor with a basic pixel grid. 9 | // 10 | // Credits: https://quasilyte.dev/blog/post/ebitengine-shaders/ 11 | var crtBasicKage = []byte(` 12 | package main 13 | 14 | var Seed float 15 | var Tick float 16 | 17 | func tex2pixCoord(texCoord vec2) vec2 { 18 | pixSize := imageSrcTextureSize() 19 | originTexCoord, _ := imageSrcRegionOnTexture() 20 | actualTexCoord := texCoord - originTexCoord 21 | actualPixCoord := actualTexCoord * pixSize 22 | return actualPixCoord 23 | } 24 | 25 | func pix2texCoord(actualPixCoord vec2) vec2 { 26 | pixSize := imageSrcTextureSize() 27 | actualTexCoord := actualPixCoord / pixSize 28 | originTexCoord, _ := imageSrcRegionOnTexture() 29 | texCoord := actualTexCoord + originTexCoord 30 | return texCoord 31 | } 32 | 33 | func applyPixPick(pixCoord vec2, dist float, m, hash int) vec2 { 34 | dir := hash % m 35 | if dir == int(0) { 36 | pixCoord.x += dist 37 | } else if dir == int(1) { 38 | pixCoord.x -= dist 39 | } else if dir == int(2) { 40 | pixCoord.y += dist 41 | } else if dir == int(3) { 42 | pixCoord.y -= dist 43 | } 44 | // Otherwise, don't move it anywhere. 45 | return pixCoord 46 | } 47 | 48 | func shaderRand(pixCoord vec2) (seedMod, randValue int) { 49 | pixSize := imageSrcTextureSize() 50 | pixelOffset := int(pixCoord.x) + int(pixCoord.y*pixSize.x) 51 | seedMod = pixelOffset % int(Seed) 52 | pixelOffset += seedMod 53 | return seedMod, pixelOffset + int(Seed) 54 | } 55 | 56 | func applyVideoDegradation(y float, c vec4) vec4 { 57 | if c.a != 0.0 { 58 | // Every 4th pixel on the Y axis will be darkened. 59 | if int(y+Tick)%4 != int(0) { 60 | return c * 0.8 61 | } 62 | } 63 | return c 64 | } 65 | 66 | func Fragment(pos vec4, texCoord vec2, _ vec4) vec4 { 67 | c := imageSrc0At(texCoord) 68 | 69 | actualPixCoord := tex2pixCoord(texCoord) 70 | if c.a != 0.0 { 71 | seedMod, h := shaderRand(actualPixCoord) 72 | dist := 1.0 73 | if seedMod == int(0) { 74 | dist = 2.0 75 | } 76 | p := applyPixPick(actualPixCoord, dist, 10, h) 77 | return applyVideoDegradation(pos.y, imageSrc0At(pix2texCoord(p))) 78 | } 79 | 80 | return c 81 | } 82 | `) 83 | 84 | type CrtBasic struct { 85 | BaseShader 86 | tick float64 87 | } 88 | 89 | func (b *CrtBasic) Apply(screen *ebiten.Image, buffer *ebiten.Image) error { 90 | b.Lock() 91 | defer b.Unlock() 92 | 93 | b.tick += 1 / 60.0 94 | var options ebiten.DrawRectShaderOptions 95 | options.GeoM.Translate(0, 0) 96 | options.Images[0] = buffer 97 | options.Uniforms = map[string]any{ 98 | "Seed": rand.Float64() * 10000, 99 | "Tick": b.tick, 100 | } 101 | screen.DrawRectShader(screen.Bounds().Dx(), screen.Bounds().Dy(), b.Shader, &options) 102 | return nil 103 | } 104 | 105 | func NewCrtBasic() (*CrtBasic, error) { 106 | shader, err := ebiten.NewShader([]byte(crtBasicKage)) 107 | if err != nil { 108 | return nil, err 109 | } 110 | return &CrtBasic{ 111 | BaseShader: BaseShader{ 112 | Shader: shader, 113 | }, 114 | }, nil 115 | } 116 | -------------------------------------------------------------------------------- /shader/crt_lotte.go: -------------------------------------------------------------------------------- 1 | package shader 2 | 3 | import "github.com/hajimehoshi/ebiten/v2" 4 | 5 | // CrtLotteKage is a CRT shader based on crt-lottes.glsl. 6 | // 7 | // Credits: Elias Daler https://github.com/eliasdaler/crten and Timothy Lottes. 8 | var CrtLotteKage = []byte(` 9 | //go:build ignore 10 | 11 | /* 12 | This is a port of a public domain crt-lottes shader written by Timothy Lottes. 13 | Rewritten to Kage by Elias Daler. The license is still public domain. 14 | The original source code can be found here: https://github.com/libretro/glsl-shaders/blob/master/crt/shaders/crt-lottes.glsl 15 | 16 | Changes: 17 | 18 | 1. DO_BLOOM is assumed. If you don't want bloom, set BloomAmount to 0 19 | 2. Accureate linear gamma is used because it looks better 20 | 3. Clamp fix is removed - there's no need for it, I think 21 | 22 | */ 23 | 24 | package main 25 | 26 | var TextureSize vec2 // input screen size (e.g. 256x244 for SNES) 27 | var ScreenSize vec2 // output screen size (e.g. when rendering at 4x resolution it is 1024x976) 28 | 29 | var HardScan float 30 | var HardPix float 31 | var WarpX float 32 | var WarpY float 33 | var MaskDark float 34 | var MaskLight float 35 | var ScaleInLinearGamma float 36 | var ShadowMask float 37 | var BrightBoost float 38 | var HardBloomPix float 39 | var HardBloomScan float 40 | var BloomAmount float 41 | var Shape float 42 | 43 | //use instead of uniforms if you don't need runtime modifiability 44 | 45 | /*const HardScan = -8.0 46 | const HardPix = -3.0 47 | const WarpX = 0.031 48 | const WarpY = 0.041 49 | const MaskDark = 0.5 50 | const MaskLight = 1.5 51 | const ShadowMask = 3.0 52 | const BrightBoost = 1.0 53 | const HardBloomPix = -1.5 54 | const HardBloomScan = -2.0 55 | const BloomAmount = 0.05 56 | const Shape = 2.0*/ 57 | 58 | func ToLinear1(c float) float { 59 | if c <= 0.04045 { 60 | return c / 12.92 61 | } 62 | return pow((c+0.055)/1.055, 2.4) 63 | } 64 | 65 | func ToLinear(c vec3) vec3 { 66 | return vec3(ToLinear1(c.r), ToLinear1(c.g), ToLinear1(c.b)) 67 | } 68 | 69 | // Linear to sRGB. 70 | // Assuming using sRGB typed textures this should not be needed. 71 | func ToSrgb1(c float) float { 72 | if c < 0.0031308 { 73 | return c * 12.92 74 | } 75 | return 1.055*pow(c, 0.41666) - 0.055 76 | } 77 | 78 | func ToSrgb(c vec3) vec3 { 79 | return vec3(ToSrgb1(c.r), ToSrgb1(c.g), ToSrgb1(c.b)) 80 | } 81 | 82 | // Nearest emulated sample given floating point position and texel offset. 83 | // Also zero's off screen. 84 | func Fetch(pos vec2, off vec2) vec3 { 85 | pos = (floor(pos*TextureSize.xy+off) + vec2(0.5, 0.5)) / TextureSize.xy 86 | origin, size := imageSrcRegionOnTexture() 87 | pos = pos*size + origin // IMPORTANT: go back to atlas coordinates from texture coordinates which OpenGL uses 88 | return ToLinear(BrightBoost * imageSrc0At(pos.xy).rgb) 89 | } 90 | 91 | // Distance in emulated pixels to nearest texel. 92 | func Dist(pos vec2) vec2 { 93 | pos = pos * TextureSize.xy 94 | return -((pos - floor(pos)) - vec2(0.5)) 95 | } 96 | 97 | // 1D Gaussian. 98 | func Gaus(pos float, scale float) float { 99 | return exp2(scale * pow(abs(pos), Shape)) 100 | } 101 | 102 | // 3-tap Gaussian filter along horz line. 103 | func Horz3(pos vec2, off float) vec3 { 104 | b := Fetch(pos, vec2(-1.0, off)) 105 | c := Fetch(pos, vec2(0.0, off)) 106 | d := Fetch(pos, vec2(1.0, off)) 107 | dst := Dist(pos).x 108 | 109 | // Convert distance to weight. 110 | scale := HardPix 111 | wb := Gaus(dst-1.0, scale) 112 | wc := Gaus(dst+0.0, scale) 113 | wd := Gaus(dst+1.0, scale) 114 | 115 | // Return filtered sample. 116 | return (b*wb + c*wc + d*wd) / (wb + wc + wd) 117 | } 118 | 119 | // 5-tap Gaussian filter along horz line. 120 | func Horz5(pos vec2, off float) vec3 { 121 | a := Fetch(pos, vec2(-2.0, off)) 122 | b := Fetch(pos, vec2(-1.0, off)) 123 | c := Fetch(pos, vec2(0.0, off)) 124 | d := Fetch(pos, vec2(1.0, off)) 125 | e := Fetch(pos, vec2(2.0, off)) 126 | 127 | dst := Dist(pos).x 128 | // Convert distance to weight. 129 | scale := HardPix 130 | wa := Gaus(dst-2.0, scale) 131 | wb := Gaus(dst-1.0, scale) 132 | wc := Gaus(dst+0.0, scale) 133 | wd := Gaus(dst+1.0, scale) 134 | we := Gaus(dst+2.0, scale) 135 | 136 | // Return filtered sample. 137 | return (a*wa + b*wb + c*wc + d*wd + e*we) / (wa + wb + wc + wd + we) 138 | } 139 | 140 | // 7-tap Gaussian filter along horz line. 141 | func Horz7(pos vec2, off float) vec3 { 142 | a := Fetch(pos, vec2(-3.0, off)) 143 | b := Fetch(pos, vec2(-2.0, off)) 144 | c := Fetch(pos, vec2(-1.0, off)) 145 | d := Fetch(pos, vec2(0.0, off)) 146 | e := Fetch(pos, vec2(1.0, off)) 147 | f := Fetch(pos, vec2(2.0, off)) 148 | g := Fetch(pos, vec2(3.0, off)) 149 | 150 | dst := Dist(pos).x 151 | // Convert distance to weight. 152 | scale := HardBloomPix 153 | wa := Gaus(dst-3.0, scale) 154 | wb := Gaus(dst-2.0, scale) 155 | wc := Gaus(dst-1.0, scale) 156 | wd := Gaus(dst+0.0, scale) 157 | we := Gaus(dst+1.0, scale) 158 | wf := Gaus(dst+2.0, scale) 159 | wg := Gaus(dst+3.0, scale) 160 | 161 | // Return filtered sample. 162 | return (a*wa + b*wb + c*wc + d*wd + e*we + f*wf + g*wg) / (wa + wb + wc + wd + we + wf + wg) 163 | } 164 | 165 | // Return scanline weight. 166 | func Scan(pos vec2, off float) float { 167 | dst := Dist(pos).y 168 | return Gaus(dst+off, HardScan) 169 | } 170 | 171 | // Return scanline weight for Bloom. 172 | func BloomScan(pos vec2, off float) float { 173 | dst := Dist(pos).y 174 | 175 | return Gaus(dst+off, HardBloomScan) 176 | } 177 | 178 | // Allow nearest three lines to effect pixel. 179 | func Tri(pos vec2) vec3 { 180 | a := Horz3(pos, -1.0) 181 | b := Horz5(pos, 0.0) 182 | c := Horz3(pos, 1.0) 183 | 184 | wa := Scan(pos, -1.0) 185 | wb := Scan(pos, 0.0) 186 | wc := Scan(pos, 1.0) 187 | 188 | return a*wa + b*wb + c*wc 189 | } 190 | 191 | // Small Bloom. 192 | func Bloom(pos vec2) vec3 { 193 | a := Horz5(pos, -2.0) 194 | b := Horz7(pos, -1.0) 195 | c := Horz7(pos, 0.0) 196 | d := Horz7(pos, 1.0) 197 | e := Horz5(pos, 2.0) 198 | 199 | wa := BloomScan(pos, -2.0) 200 | wb := BloomScan(pos, -1.0) 201 | wc := BloomScan(pos, 0.0) 202 | wd := BloomScan(pos, 1.0) 203 | we := BloomScan(pos, 2.0) 204 | 205 | return a*wa + b*wb + c*wc + d*wd + e*we 206 | } 207 | 208 | // Distortion of scanlines, and end of screen alpha. 209 | func Warp(pos vec2) vec2 { 210 | pos = pos*2.0 - 1.0 211 | pos *= vec2(1.0+(pos.y*pos.y)*WarpX, 1.0+(pos.x*pos.x)*WarpY) 212 | 213 | return pos*0.5 + 0.5 214 | } 215 | 216 | // Shadow mask. 217 | func Mask(pos vec2) vec3 { 218 | mask := vec3(MaskDark, MaskDark, MaskDark) 219 | 220 | if ShadowMask == 1.0 { 221 | // Very compressed TV style shadow mask. 222 | line := MaskLight 223 | odd := 0.0 224 | 225 | if fract(pos.x*0.166666666) < 0.5 { 226 | odd = 1.0 227 | } 228 | if fract((pos.y+odd)*0.5) < 0.5 { 229 | line = MaskDark 230 | } 231 | 232 | pos.x = fract(pos.x * 0.333333333) 233 | 234 | if pos.x < 0.333 { 235 | mask.r = MaskLight 236 | } else if pos.x < 0.666 { 237 | mask.g = MaskLight 238 | } else { 239 | mask.b = MaskLight 240 | } 241 | 242 | mask *= line 243 | } else if ShadowMask == 2.0 { 244 | // Aperture-grille. 245 | pos.x = fract(pos.x * 0.333333333) 246 | 247 | if pos.x < 0.333 { 248 | mask.r = MaskLight 249 | } else if pos.x < 0.666 { 250 | mask.g = MaskLight 251 | } else { 252 | mask.b = MaskLight 253 | } 254 | } else if ShadowMask == 3.0 { 255 | // Stretched VGA style shadow mask (same as prior shaders). 256 | pos.x += pos.y * 3.0 257 | pos.x = fract(pos.x * 0.166666666) 258 | 259 | if pos.x < 0.333 { 260 | mask.r = MaskLight 261 | } else if pos.x < 0.666 { 262 | mask.g = MaskLight 263 | } else { 264 | mask.b = MaskLight 265 | } 266 | } else if ShadowMask == 4.0 { 267 | // VGA style shadow mask. 268 | pos.xy = floor(pos.xy * vec2(1.0, 0.5)) 269 | pos.x += pos.y * 3.0 270 | pos.x = fract(pos.x * 0.166666666) 271 | 272 | if pos.x < 0.333 { 273 | mask.r = MaskLight 274 | } else if pos.x < 0.666 { 275 | mask.g = MaskLight 276 | } else { 277 | mask.b = MaskLight 278 | } 279 | } 280 | return mask 281 | } 282 | 283 | func Fragment(position vec4, texCoord vec2, color vec4) vec4 { 284 | // Adjust the texture position to [0, 1]. 285 | pos := texCoord 286 | origin, size := imageSrcRegionOnTexture() 287 | pos -= origin 288 | pos /= size 289 | 290 | pos = Warp(pos) 291 | outColor := Tri(pos) 292 | 293 | //Add Bloom 294 | outColor.rgb += Bloom(pos) * BloomAmount 295 | 296 | fragCoord := position.xy 297 | fragCoord.y = ScreenSize.y - fragCoord.y // in OpenGL, Y is pointing up 298 | 299 | if ShadowMask > 0.0 { 300 | outColor.rgb *= Mask(fragCoord * 1.000001) 301 | } 302 | 303 | return vec4(ToSrgb(outColor.rgb), 1.0) 304 | // return vec4(imageSrc0At(texCoord).rgb, 1.0) 305 | } 306 | `) 307 | 308 | type CrtLotte struct { 309 | BaseShader 310 | } 311 | 312 | func (s *CrtLotte) Apply(screen *ebiten.Image, buffer *ebiten.Image) error { 313 | s.Lock() 314 | defer s.Unlock() 315 | 316 | s.Uniforms["ScreenSize"] = []float32{float32(screen.Bounds().Dx()), float32(screen.Bounds().Dy())} 317 | s.Uniforms["TextureSize"] = []float32{float32(buffer.Bounds().Dx()), float32(buffer.Bounds().Dy())} 318 | 319 | sop := &ebiten.DrawRectShaderOptions{} 320 | sop.GeoM.Scale(float64(screen.Bounds().Dx())/float64(buffer.Bounds().Dx()), float64(screen.Bounds().Dy())/float64(buffer.Bounds().Dy())) 321 | sop.Uniforms = s.Uniforms 322 | sop.Images[0] = buffer 323 | 324 | screen.Clear() 325 | screen.DrawRectShader(screen.Bounds().Dx(), screen.Bounds().Dy(), s.Shader, sop) 326 | 327 | return nil 328 | } 329 | 330 | func NewCrtLotte() (*CrtLotte, error) { 331 | shader, err := ebiten.NewShader(CrtLotteKage) 332 | if err != nil { 333 | return nil, err 334 | } 335 | 336 | s := &CrtLotte{ 337 | BaseShader: BaseShader{ 338 | Shader: shader, 339 | Uniforms: map[string]interface{}{ 340 | "HardScan": float32(-8), 341 | "HardPix": float32(-3), 342 | "WarpX": float32(0.031), 343 | "WarpY": float32(0.041), 344 | "MaskDark": float32(0.5), 345 | "MaskLight": float32(1.5), 346 | "ShadowMask": float32(3.0), 347 | "BrightBoost": float32(1.0), 348 | "HardBloomPix": float32(-1.5), 349 | "HardBloomScan": float32(-2.0), 350 | "BloomAmount": float32(0.05), 351 | "Shape": float32(2.0), 352 | }, 353 | }, 354 | } 355 | 356 | return s, nil 357 | } 358 | -------------------------------------------------------------------------------- /shader/shader.go: -------------------------------------------------------------------------------- 1 | package shader 2 | 3 | import ( 4 | "github.com/hajimehoshi/ebiten/v2" 5 | "sync" 6 | ) 7 | 8 | type Shader interface { 9 | Apply(screen *ebiten.Image, buffer *ebiten.Image) error 10 | } 11 | 12 | type BaseShader struct { 13 | sync.Mutex 14 | Shader *ebiten.Shader 15 | Uniforms map[string]any 16 | } 17 | 18 | func (b *BaseShader) Apply(screen *ebiten.Image, buffer *ebiten.Image) error { 19 | b.Lock() 20 | defer b.Unlock() 21 | 22 | var options ebiten.DrawRectShaderOptions 23 | options.GeoM.Translate(0, 0) 24 | options.Images[0] = buffer 25 | options.Uniforms = b.Uniforms 26 | screen.DrawRectShader(screen.Bounds().Dx(), screen.Bounds().Dy(), b.Shader, &options) 27 | return nil 28 | } 29 | --------------------------------------------------------------------------------