├── .github └── workflows │ └── go.yml ├── .gitignore ├── .tool-versions ├── LICENSE ├── README.md ├── buffer ├── buffer.go ├── buffer_test.go └── color.go ├── controller ├── ascii.go ├── controller.go ├── controller_test.go ├── csi_translator.go └── key_encoder.go ├── doc └── gritty-color-2.gif ├── go.mod ├── go.sum ├── gui.go ├── label.go ├── main.go ├── parser ├── pty_parser.go └── pty_parser_test.go └── testdata └── fuzz └── FuzzController ├── 582528ddfad69eb5 ├── b2832d8fad322369 ├── cfbc295f9a308092 └── da8ecd33b539681c /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Go 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | - name: Set up Go 20 | uses: actions/setup-go@v4 21 | with: 22 | go-version: '1.21.1' 23 | 24 | - name: Build 25 | run: go build -v ./buffer ./controller ./parser 26 | 27 | - name: Test 28 | run: go test -v ./buffer ./controller ./parser 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | gritty 2 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | golang 1.21.1 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright © 2023 Tomas Vik 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gritty 2 | 3 | Gritty is a terminal emulator written in Go and Gio UI Framework, focusing on readability and documentation of the code. 4 | 5 | You could use this emulator for normal work, but I created it as a reference implementation of vt100-ish terminal emulator. (I use [kitty](https://sw.kovidgoyal.net/kitty/), and I'm happy with it. I only wanted to understand how terminals work.) 6 | 7 | ![Gritty GIF](https://github.com/viktomas/gritty/blob/391dd1ea57866de05d118fdd08204f514087c157/doc/gritty-color-2.gif?raw=true) 8 | 9 | ## Using Gritty 10 | 11 | Ensure that [Gio is installed on your system](https://gioui.org/doc/install). Run with `go run .`, test with `go test .`. Gritty starts `/bin/sh`. 12 | 13 | ## Architecture 14 | 15 | ```mermaid 16 | graph LR; 17 | subgraph Controller 18 | E 19 | C 20 | I 21 | end 22 | C[Controller] --> B[Buffer] 23 | C[Controller] --> E[EncodeKeys] 24 | E --> PTY 25 | PTY --> P[Parse input] 26 | P --> I[Interpret control sequences] 27 | I --> C 28 | G[GUI] --"send keys"--> C 29 | G --"read buffer"--> C 30 | ``` 31 | 32 | ### Packages 33 | 34 | - `buffer` - Buffer is the model that contains a grid of characters, it also handles actions like "clear line" or "write rune". 35 | - `parser` - Parser is a control-sequence parser implemented based on the [excellent state diagram by Paul Williams](https://www.vt100.net/emu/dec_ansi_parser). 36 | - `controller` - Controller connects PTY and buffer. 37 | - It gives GUI the grid of runes to render and signal when to re-render. 38 | - It receives key events from GUI. 39 | - `main` - Main package contains the GUI code and starts the terminal emulator. 40 | 41 | ### Code walkthrough 42 | 43 | 1. Start by understanding the [controller.Start method](https://github.com/viktomas/gritty/blob/6e545ec8c234bccabcd47d09fe3af0ee70138ebc/controller/controller.go#L31). 44 | - it starts the shell command and starts processing the parsed PTY operations (`c.handleOp`) 45 | 1. run the code with `gritty_debug=1 go run .` in the `main` package. This also enables extended debug logging. 46 | 1. watch the log output when you interact with the terminal and find the log statements using a full-text search. 47 | 48 | ## Resources 49 | 50 | - [VT510 user manual - Contents](https://vt100.net/docs/vt510-rm/contents.html) 51 | - [ANSI Control Functions Summary](https://vt100.net/docs/vt510-rm/chapter4.html#S4.1) 52 | - [Digital VT100 User Guide: Programmer Information - chapter 3](https://vt100.net/docs/vt100-ug/chapter3.html) 53 | - [asciinema/avt](https://github.com/asciinema/avt/blob/main/src/vt.rs) - extremely clear implementation of vt100-ish terminal emulator in rust 54 | - [ANSI escape code - Wikipedia](https://en.wikipedia.org/wiki/ANSI_escape_code) 55 | - [ASCII - Wikipedia](https://en.wikipedia.org/wiki/ASCII) 56 | - `man ascii` 57 | - [XtermJS - Supported Terminal Sequences](http://xtermjs.org/docs/api/vtfeatures/) - helpful overview of escape sequences 58 | - [XTerm Control Sequences](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html) 59 | -------------------------------------------------------------------------------- /buffer/buffer.go: -------------------------------------------------------------------------------- 1 | package buffer 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | type Cursor struct { 9 | X, Y int 10 | } 11 | 12 | type Brush struct { 13 | FG Color 14 | BG Color 15 | Blink bool 16 | Invert bool 17 | Bold bool 18 | } 19 | 20 | type BrushedRune struct { 21 | R rune 22 | Brush Brush 23 | } 24 | 25 | type bufferType int 26 | 27 | const ( 28 | bufPrimary = iota 29 | bufAlternate 30 | ) 31 | 32 | type Buffer struct { 33 | lines [][]BrushedRune 34 | alternateLines [][]BrushedRune 35 | bufferType bufferType 36 | size BufferSize 37 | cursor Cursor 38 | savedCursor Cursor 39 | // nextWriteWraps indicates whether the next WriteRune will start on the new line. 40 | // if true, then before writing the next rune, we'll execute CR LF 41 | // 42 | // Without this field, we would have to CR LF straight after 43 | // writing the last rune in the row. But LF causes screen to scroll on the last line 44 | // which would make it impossible to write the last character on the screen 45 | nextWriteWraps bool 46 | // scrollAreaStart is the index of the first line that will scroll 47 | scrollAreaStart int 48 | // scrollAreaEnd is the index of the last line + 1 that will scroll (it's one larger, same as len(slice)) 49 | scrollAreaEnd int 50 | // originMode controls where the cursor can be placed with relationship to the scrolling region (margins) 51 | // false - the origin is at the upper-left character position on the screen. Line and column numbers are, therefore, independent of current margin settings. The cursor may be positioned outside the margins with a cursor position (CUP) or horizontal and vertical position (HVP) control. 52 | // 53 | // true - the origin is at the upper-left character position within the margins. Line and column numbers are therefore relative to the current margin settings. The cursor is not allowed to be positioned outside the margins. 54 | // described in https://vt100.net/docs/vt100-ug/chapter3.html 55 | originMode bool 56 | brush Brush 57 | } 58 | 59 | type BufferSize struct { 60 | Rows int 61 | Cols int 62 | } 63 | 64 | func New(cols, rows int) *Buffer { 65 | size := BufferSize{Rows: rows, Cols: cols} 66 | buffer := &Buffer{size: size} 67 | buffer.ResetBrush() 68 | buffer.lines = buffer.makeNewLines(size) 69 | buffer.alternateLines = buffer.makeNewLines(size) 70 | buffer.resetScrollArea() 71 | return buffer 72 | } 73 | 74 | func (b *Buffer) CR() { 75 | b.SetCursor(0, b.cursor.Y) 76 | } 77 | func (b *Buffer) LF() { 78 | b.nextWriteWraps = false 79 | b.cursor.Y++ 80 | if b.cursor.Y >= b.scrollAreaEnd { 81 | b.ScrollUp(1) 82 | b.cursor.Y-- 83 | } 84 | } 85 | 86 | func (b *Buffer) ScrollUp(n int) { 87 | for i := b.scrollAreaStart + n; i < b.scrollAreaEnd; i++ { 88 | b.lines[i-n] = b.lines[i] 89 | } 90 | for i := b.scrollAreaEnd - n; i < b.scrollAreaEnd; i++ { 91 | b.lines[i] = b.newLine(b.size.Cols) 92 | } 93 | } 94 | 95 | // TODO maybe remove in favour of SetBrush(Brush{}) 96 | func (b *Buffer) ResetBrush() { 97 | b.brush = Brush{FG: DefaultFG, BG: DefaultBG} 98 | } 99 | 100 | func (b *Buffer) Brush() Brush { 101 | return b.brush 102 | } 103 | 104 | func (b *Buffer) SetBrush(br Brush) { 105 | b.brush = br 106 | } 107 | 108 | func (b *Buffer) newLine(cols int) []BrushedRune { 109 | line := make([]BrushedRune, cols) 110 | for c := range line { 111 | line[c] = b.MakeRune(' ') 112 | } 113 | return line 114 | } 115 | 116 | func (b *Buffer) SetScrollArea(start, end int) { 117 | b.scrollAreaStart = clamp(start, 0, b.size.Rows-1) 118 | b.scrollAreaEnd = clamp(end, b.scrollAreaStart+1, b.size.Rows) 119 | b.cursor = Cursor{X: 0, Y: b.scrollAreaStart} 120 | } 121 | 122 | func (b *Buffer) resetScrollArea() { 123 | b.scrollAreaStart = 0 124 | b.scrollAreaEnd = len(b.lines) 125 | 126 | } 127 | 128 | func (b *Buffer) MakeRune(r rune) BrushedRune { 129 | return BrushedRune{ 130 | R: r, 131 | Brush: b.brush, 132 | } 133 | } 134 | 135 | func (b *Buffer) WriteRune(r rune) { 136 | if b.nextWriteWraps == true { 137 | b.nextWriteWraps = false 138 | // soft wrap 139 | b.CR() 140 | b.LF() 141 | } 142 | b.lines[b.cursor.Y][b.cursor.X] = b.MakeRune(r) 143 | b.cursor.X++ 144 | if b.cursor.X >= b.size.Cols { 145 | b.nextWriteWraps = true 146 | } 147 | } 148 | 149 | func (b *Buffer) Runes() []BrushedRune { 150 | out := make([]BrushedRune, 0, b.size.Rows*b.size.Cols) // extra space for new lines 151 | for ri, r := range b.lines { 152 | for ci, c := range r { 153 | // invert cursor every odd interval 154 | if (b.cursor.X == ci) && b.cursor.Y == ri { 155 | br := c.Brush 156 | br.Blink = true 157 | out = append(out, BrushedRune{ 158 | R: c.R, 159 | Brush: br, 160 | }) 161 | } else { 162 | out = append(out, c) 163 | } 164 | } 165 | } 166 | 167 | return out 168 | } 169 | 170 | func (b *Buffer) Cursor() Cursor { 171 | return b.cursor 172 | } 173 | 174 | func (b *Buffer) Size() BufferSize { 175 | return b.size 176 | } 177 | 178 | func (b *Buffer) String() string { 179 | var sb strings.Builder 180 | for _, r := range b.lines { 181 | for _, c := range r { 182 | sb.WriteRune(c.R) 183 | } 184 | sb.WriteRune('\n') 185 | } 186 | return sb.String() 187 | } 188 | 189 | func (b *Buffer) ClearLines(start, end int) { 190 | // sanitize parameters 191 | s := clamp(start, 0, b.size.Rows) 192 | e := clamp(end, 0, b.size.Rows) 193 | 194 | toClean := b.lines[s:e] 195 | for r := range toClean { 196 | for c := range toClean[r] { 197 | toClean[r][c] = b.MakeRune(' ') 198 | } 199 | } 200 | } 201 | 202 | // DeleteCharacter removes n characters from cursor onwards (including the character under cursor) 203 | // the characters after the deleted gap are then shifted to the cursor position 204 | func (b *Buffer) DeleteCharacter(n int) { 205 | p := clamp(n, 1, b.size.Cols-b.cursor.X) 206 | line := b.lines[b.cursor.Y] 207 | copy(line[b.cursor.X:], line[b.cursor.X+p:]) 208 | for i := len(line) - p; i < len(line); i++ { 209 | line[i] = b.MakeRune(' ') 210 | } 211 | } 212 | 213 | func (b *Buffer) DeleteLine(n int) { 214 | m := clamp(n, 1, b.scrollAreaEnd-b.cursor.Y) 215 | copy(b.lines[b.cursor.Y:], b.lines[b.cursor.Y+m:b.scrollAreaEnd]) 216 | for i := b.scrollAreaEnd - m; i < b.scrollAreaEnd; i++ { 217 | b.lines[i] = b.newLine(b.size.Cols) 218 | } 219 | } 220 | 221 | func (b *Buffer) InsertLine(n int) { 222 | m := clamp(n, 1, b.scrollAreaEnd-b.cursor.Y) 223 | copy(b.lines[b.cursor.Y+m:b.scrollAreaEnd], b.lines[b.cursor.Y:]) 224 | for i := b.cursor.Y; i < b.cursor.Y+m; i++ { 225 | b.lines[i] = b.newLine(b.size.Cols) 226 | } 227 | } 228 | 229 | // ClearCurrentLine replaces the characters between start (inclusive) and end (exclusive) with spaces 230 | func (b *Buffer) ClearCurrentLine(start, end int) { 231 | // sanitize parameters 232 | s := clamp(start, 0, b.size.Cols) 233 | e := clamp(end, s, b.size.Cols) 234 | 235 | currentLineToClean := b.lines[b.cursor.Y][s:e] 236 | for i := range currentLineToClean { 237 | currentLineToClean[i] = b.MakeRune(' ') 238 | } 239 | } 240 | 241 | func (b *Buffer) Tab() { 242 | newX := (b.cursor.X / 8 * 8) + 8 243 | if newX < b.size.Cols { 244 | b.cursor.X = newX 245 | } else { 246 | b.cursor.X = b.size.Cols - 1 // if the tab can't be fully added, lets move the cursor to the last column 247 | } 248 | } 249 | 250 | func (b *Buffer) makeNewLines(size BufferSize) [][]BrushedRune { 251 | newLines := make([][]BrushedRune, size.Rows) 252 | for r := range newLines { 253 | newLines[r] = b.newLine(size.Cols) 254 | } 255 | return newLines 256 | } 257 | 258 | // Resize changes ensures that the dimensions are rows x cols 259 | // returns true if the dimensions changed, otherwise returns false 260 | func (b *Buffer) Resize(size BufferSize) bool { 261 | if b.size == size { 262 | fmt.Println("ignoring resize") 263 | return false 264 | } 265 | b.size = size 266 | b.lines = b.makeNewLines(size) 267 | b.alternateLines = b.makeNewLines(size) 268 | b.resetScrollArea() 269 | fmt.Printf("buffer resized rows: %v, cols: %v\n", b.size.Rows, b.size.Cols) 270 | return true 271 | } 272 | 273 | func (b *Buffer) Backspace() { 274 | x := b.cursor.X 275 | if x == 0 { 276 | return 277 | } 278 | b.cursor.X = x - 1 279 | } 280 | 281 | func (b *Buffer) MoveCursorRelative(dx, dy int) { 282 | b.SetCursor( 283 | b.cursor.X+dx, 284 | clamp(b.cursor.Y+dy, b.scrollAreaStart, b.scrollAreaEnd), 285 | ) 286 | } 287 | 288 | func (b *Buffer) SaveCursor() { 289 | b.savedCursor = b.cursor 290 | } 291 | 292 | func (b *Buffer) SwitchToAlternateBuffer() { 293 | if b.bufferType == bufAlternate { 294 | return 295 | } 296 | primaryLines := b.lines 297 | b.lines = b.alternateLines 298 | b.alternateLines = primaryLines 299 | b.bufferType = bufAlternate 300 | b.ClearLines(0, b.size.Rows) 301 | b.SetCursor(0, 0) 302 | } 303 | 304 | // minY returns the index of the first row, this can be larger than 0 if the 305 | // scroll area is reduced and the origin mode is enabled 306 | func (b *Buffer) minY() int { 307 | if b.originMode { 308 | return b.scrollAreaStart 309 | } 310 | return 0 311 | } 312 | 313 | // maxY returns the index of the last row + 1, this can be smaller than b.size.rows if the 314 | // scroll area is reduced and the origin mode is enabled 315 | func (b *Buffer) maxY() int { 316 | if b.originMode { 317 | return b.scrollAreaEnd 318 | } 319 | return b.size.Rows 320 | } 321 | 322 | func (b *Buffer) SetCursor(x, y int) { 323 | b.cursor = Cursor{ 324 | X: clamp(x, 0, b.size.Cols-1), 325 | Y: clamp(y, b.minY(), b.maxY()-1), 326 | } 327 | b.nextWriteWraps = false 328 | } 329 | 330 | func (b *Buffer) SwitchToPrimaryBuffer() { 331 | if b.bufferType == bufPrimary { 332 | return 333 | } 334 | alternateLines := b.lines 335 | b.lines = b.alternateLines 336 | b.alternateLines = alternateLines 337 | b.bufferType = bufPrimary 338 | } 339 | 340 | func (b *Buffer) RestoreCursor() { 341 | b.cursor = b.savedCursor 342 | } 343 | 344 | // ReverseIndex Moves the active position to the same horizontal position on the preceding line. If the active position is at the top margin, a scroll down is performed. Format Effector 345 | // [docs}(https://vt100.net/docs/vt100-ug/chapter3.html) 346 | func (b *Buffer) ReverseIndex() { 347 | if b.cursor.Y == b.scrollAreaStart { 348 | b.scrollDown(1) 349 | } else { 350 | // TODO this can be probably written nicer 351 | // I actually don't know what is the reverse index cursor up, should it 352 | // be like relative cursor movement (clamped by scrolling area) or should 353 | // U allow it to move outside of margins??? 354 | b.cursor.Y = clamp(b.cursor.Y-1, 0, b.cursor.Y) 355 | } 356 | } 357 | 358 | func (b *Buffer) scrollDown(lines int) { 359 | for i := b.scrollAreaEnd - lines - 1; i >= b.scrollAreaStart; i-- { 360 | b.lines[i+lines] = b.lines[i] 361 | } 362 | for i := b.scrollAreaStart; i < b.scrollAreaStart+lines; i++ { 363 | b.lines[i] = b.newLine(b.size.Cols) 364 | } 365 | } 366 | 367 | func (b *Buffer) SetOriginMode(enabled bool) { 368 | b.originMode = true 369 | b.SetCursor(0, 0) 370 | } 371 | 372 | // clamp returns n if fits into the range set by min and max, otherwise it 373 | // returns min or max depending on the n being smaller or larger respectively 374 | func clamp(value, min, max int) int { 375 | if value < min { 376 | return min 377 | } 378 | if value > max { 379 | return max 380 | } 381 | return value 382 | } 383 | -------------------------------------------------------------------------------- /buffer/buffer_test.go: -------------------------------------------------------------------------------- 1 | package buffer 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func TestMakeTestBuffer(t *testing.T) { 10 | b := makeTestBuffer(t, ` 11 | a__ 12 | _b_ 13 | __c 14 | `, 0, 0) 15 | expected := trimExpectation(t, ` 16 | a__ 17 | _b_ 18 | __c 19 | `) 20 | if b.String() != expected { 21 | t.Fatalf("Buffer wasn't created successfully\nExpected:\n%s\nGot:\n%s", expected, b.String()) 22 | } 23 | 24 | } 25 | 26 | func TestNewBuffer(t *testing.T) { 27 | s := New(5, 2) 28 | if s.String() != " \n \n" { 29 | t.Fatalf("the buffer string is not equal to empty buffer 5x2:\n%q", s.String()) 30 | } 31 | } 32 | 33 | func FuzzWriteRune(f *testing.F) { 34 | b := New(20, 10) 35 | f.Add('A') 36 | f.Fuzz(func(t *testing.T, r rune) { 37 | b.WriteRune(r) 38 | }) 39 | } 40 | 41 | func TestClearLines(t *testing.T) { 42 | t.Run("full clear", func(t *testing.T) { 43 | b := makeTestBuffer(t, ` 44 | a__ 45 | _b_ 46 | __c 47 | `, 0, 0) 48 | expected := trimExpectation(t, ` 49 | ___ 50 | ___ 51 | ___ 52 | `) 53 | b.ClearLines(0, b.size.Rows) 54 | if b.String() != expected { 55 | t.Fatalf("Buffer wasn't cleared successfully\nExpected:\n%s\nGot:\n%s", expected, b.String()) 56 | } 57 | }) 58 | 59 | t.Run("partial clear", func(t *testing.T) { 60 | b := makeTestBuffer(t, ` 61 | a___ 62 | _b__ 63 | __c_ 64 | ___d 65 | `, 0, 0) 66 | expected := trimExpectation(t, ` 67 | a___ 68 | ____ 69 | ____ 70 | ___d 71 | `) 72 | b.ClearLines(1, 3) 73 | if b.String() != expected { 74 | t.Fatalf("Buffer wasn't cleared successfully\nExpected:\n%s\nGot:\n%s", expected, b.String()) 75 | } 76 | }) 77 | 78 | t.Run("clear with range too large", func(t *testing.T) { 79 | b := makeTestBuffer(t, ` 80 | a_ 81 | _b 82 | `, 0, 0) 83 | expected := trimExpectation(t, ` 84 | __ 85 | __ 86 | `) 87 | b.ClearLines(-11, 33) 88 | if b.String() != expected { 89 | t.Fatalf("Buffer wasn't cleared successfully\nExpected:\n%s\nGot:\n%s", expected, b.String()) 90 | } 91 | }) 92 | 93 | t.Run("clear with range too small", func(t *testing.T) { 94 | b := makeTestBuffer(t, ` 95 | a_ 96 | _b 97 | `, 0, 0) 98 | expected := trimExpectation(t, ` 99 | a_ 100 | _b 101 | `) 102 | b.ClearLines(4, 3) 103 | if b.String() != expected { 104 | t.Fatalf("Buffer wasn't cleared successfully\nExpected:\n%s\nGot:\n%s", expected, b.String()) 105 | } 106 | }) 107 | } 108 | 109 | func TestClearCurrentLine(t *testing.T) { 110 | t.Run("clears full line", func(t *testing.T) { 111 | b := makeTestBuffer(t, ` 112 | a___ 113 | _b__ 114 | __c_ 115 | ___d 116 | `, 0, 1) 117 | expected := trimExpectation(t, ` 118 | a___ 119 | ____ 120 | __c_ 121 | ___d 122 | `) 123 | b.ClearCurrentLine(0, b.Size().Cols) 124 | if b.String() != expected { 125 | t.Fatalf("Line was not fully cleared:\nExpected:\n%s\nGot:\n%s", expected, b.String()) 126 | } 127 | }) 128 | t.Run("clears part of the line", func(t *testing.T) { 129 | b := makeTestBuffer(t, `12345`, 0, 0) 130 | expected := trimExpectation(t, `1___5`) 131 | b.ClearCurrentLine(1, 4) 132 | if b.String() != expected { 133 | t.Fatalf("Line was not cleared from 2 to 4:\nExpected:\n%s\nGot:\n%s", expected, b.String()) 134 | } 135 | }) 136 | t.Run("handles range out of bounds", func(t *testing.T) { 137 | b := makeTestBuffer(t, `12345`, 0, 0) 138 | expected := trimExpectation(t, `_____`) 139 | b.ClearCurrentLine(-10, 33) 140 | if b.String() != expected { 141 | t.Fatalf("Line was not fully cleared:\nExpected:\n%s\nGot:\n%s", expected, b.String()) 142 | } 143 | }) 144 | t.Run("handles too small range", func(t *testing.T) { 145 | b := makeTestBuffer(t, `12345`, 0, 0) 146 | expected := trimExpectation(t, `12345`) 147 | b.ClearCurrentLine(4, 3) 148 | if b.String() != expected { 149 | t.Fatalf("Line was changed but it shouldn't have:\nExpected:\n%s\nGot:\n%s", expected, b.String()) 150 | } 151 | }) 152 | } 153 | 154 | func TestScrollUp(t *testing.T) { 155 | t.Run("without margins", func(t *testing.T) { 156 | b := makeTestBuffer(t, ` 157 | ab 158 | cd 159 | `, 0, 0) 160 | expected := trimExpectation(t, ` 161 | cd 162 | __ 163 | `) 164 | b.ScrollUp(1) 165 | if b.String() != expected { 166 | t.Fatalf("Buffer didn't scroll up\nExpected:\n%s\nGot:\n%s", expected, b.String()) 167 | } 168 | }) 169 | 170 | t.Run("with margins", func(t *testing.T) { 171 | b := makeTestBuffer(t, ` 172 | a 173 | b 174 | c 175 | d 176 | e 177 | `, 0, 0) 178 | expected := trimExpectation(t, ` 179 | a 180 | d 181 | _ 182 | _ 183 | e 184 | `) 185 | b.SetScrollArea(1, 4) 186 | b.ScrollUp(2) 187 | if b.String() != expected { 188 | t.Fatalf("Buffer didn't scroll up within margin\nExpected:\n%s\nGot:\n%s", expected, b.String()) 189 | } 190 | }) 191 | } 192 | 193 | func TestSetScrollArea(t *testing.T) { 194 | t.Run("sets scroll area within the buffer", func(t *testing.T) { 195 | b := New(2, 5) 196 | b.WriteRune('a') 197 | b.SetScrollArea(1, 3) 198 | 199 | if b.Cursor() != (Cursor{X: 0, Y: 1}) { 200 | t.Fatalf("Cursor should be set to the top of the scroll region (0,1), but is (%d,%d)", b.cursor.X, b.cursor.Y) 201 | } 202 | }) 203 | 204 | t.Run("clamps parameters so that the range is always within the buffer", func(t *testing.T) { 205 | b := makeTestBuffer(t, ` 206 | a 207 | b 208 | c 209 | `, 0, 0) 210 | expected := trimExpectation(t, ` 211 | b 212 | c 213 | _ 214 | `) 215 | b.SetScrollArea(-10, 20) 216 | 217 | if b.Cursor() != (Cursor{X: 0, Y: 0}) { 218 | t.Fatalf("Cursor should be set to the top of the scroll region (0,0), but is (%d,%d)", b.cursor.X, b.cursor.Y) 219 | } 220 | 221 | b.ScrollUp(1) 222 | if b.String() != expected { 223 | t.Fatalf("The whole buffer should have scrolled but it didn't:\n%s\nGot:\n%s", expected, b.String()) 224 | } 225 | }) 226 | } 227 | 228 | func TestWriteRune(t *testing.T) { 229 | t.Run("auto wraps", func(t *testing.T) { 230 | b := New(2, 2) 231 | b.WriteRune('a') 232 | b.WriteRune('a') 233 | b.WriteRune('a') 234 | if b.String() != "aa\na \n" { 235 | t.Fatalf("the character didn't auto wrap:\n%q", b.String()) 236 | } 237 | }) 238 | 239 | t.Run("wraps only with next write (doesn't wrap when EOL is reached)", func(t *testing.T) { 240 | b := New(2, 2) 241 | b.WriteRune('a') 242 | b.WriteRune('a') 243 | b.WriteRune('a') 244 | b.WriteRune('a') 245 | expected := "aa\naa\n" 246 | if b.String() != expected { 247 | t.Fatalf("buffer was supposed to be filled with a's:\nexpected:%s\ngot:\n%s", expected, b.String()) 248 | } 249 | b.WriteRune('b') 250 | expected = "aa\nb \n" 251 | if b.String() != expected { 252 | t.Fatalf("next rune (b) was supposed to trigger autowrap (and scroll):\nexpected:%s\ngot:\n%s", expected, b.String()) 253 | } 254 | 255 | }) 256 | } 257 | 258 | func TestReverseIndex(t *testing.T) { 259 | t.Run("auto wraps", func(t *testing.T) { 260 | b := makeTestBuffer(t, ` 261 | aa 262 | bb 263 | `, 0, 0) 264 | expected := trimExpectation(t, ` 265 | __ 266 | aa 267 | `) 268 | b.ReverseIndex() 269 | if b.String() != expected { 270 | t.Fatalf("Buffer didn't scroll down\nExpected:\n%s\nGot:\n%s", expected, b.String()) 271 | } 272 | }) 273 | 274 | t.Run("works with scroll region", func(t *testing.T) { 275 | b := makeTestBuffer(t, ` 276 | a 277 | b 278 | c 279 | d 280 | e 281 | `, 0, 0) 282 | expected := trimExpectation(t, ` 283 | a 284 | _ 285 | b 286 | c 287 | e 288 | `) 289 | b.SetScrollArea(1, 4) 290 | b.ReverseIndex() 291 | if b.String() != expected { 292 | t.Fatalf("Buffer didn't scroll down within the scroll region\nExpected:\n%s\nGot:\n%s", expected, b.String()) 293 | } 294 | }) 295 | } 296 | 297 | func TestLFResetsWrappingNextLine(t *testing.T) { 298 | b := makeTestBuffer(t, ` 299 | ___ 300 | ___ 301 | ___ 302 | `, 0, 0) 303 | expected := trimExpectation(t, ` 304 | xxx 305 | z__ 306 | ___ 307 | `) 308 | b.WriteRune('x') 309 | b.WriteRune('x') 310 | b.WriteRune('x') 311 | b.CR() 312 | b.LF() 313 | b.WriteRune('z') 314 | if b.String() != expected { 315 | t.Fatalf("The line feed didn't reset the line wrapping\nExpected:\n%s\nGot:\n%s", expected, b.String()) 316 | } 317 | } 318 | 319 | func TestDeleteCharacter(t *testing.T) { 320 | t.Run("deletes from a middle of the line", func(t *testing.T) { 321 | b := makeTestBuffer(t, ` 322 | hello_world 323 | ___________ 324 | `, 1, 0) 325 | expected := trimExpectation(t, ` 326 | ho_world___ 327 | ___________ 328 | `) 329 | b.DeleteCharacter(3) 330 | if b.String() != expected { 331 | t.Fatalf("DeleteCharacter didn't remove 3 characters from the first e\nExpected:\n%s\nGot:\n%s", expected, b.String()) 332 | } 333 | }) 334 | 335 | t.Run("handles when the parameter is too large", func(t *testing.T) { 336 | b := makeTestBuffer(t, ` 337 | hello_world 338 | ___________ 339 | `, 6, 0) 340 | expected := trimExpectation(t, ` 341 | hello______ 342 | ___________ 343 | `) 344 | b.DeleteCharacter(10) 345 | if b.String() != expected { 346 | t.Fatalf("We didn't remove the 'world' correctly\nExpected:\n%s\nGot:\n%s", expected, b.String()) 347 | } 348 | }) 349 | 350 | t.Run("can delete only one character", func(t *testing.T) { 351 | b := makeTestBuffer(t, ` 352 | hello_world 353 | ___________ 354 | `, 1, 0) 355 | expected := trimExpectation(t, ` 356 | hllo_world_ 357 | ___________ 358 | `) 359 | b.DeleteCharacter(1) 360 | if b.String() != expected { 361 | t.Fatalf("DeleteCharacter didn't remove 3 characters from the first e\nExpected:\n%s\nGot:\n%s", expected, b.String()) 362 | } 363 | }) 364 | } 365 | 366 | func TestDeleteLine(t *testing.T) { 367 | t.Run("deletes lines in the middle", func(t *testing.T) { 368 | b := makeTestBuffer(t, ` 369 | a 370 | b 371 | c 372 | d 373 | e 374 | `, 0, 1) 375 | expected := trimExpectation(t, ` 376 | a 377 | d 378 | e 379 | _ 380 | _ 381 | `) 382 | b.DeleteLine(2) 383 | if b.String() != expected { 384 | t.Fatalf("DeleteLine didn't remove 2 lines after 'a'\nExpected:\n%s\nGot:\n%s", expected, b.String()) 385 | } 386 | }) 387 | 388 | t.Run("deletes only one line", func(t *testing.T) { 389 | b := makeTestBuffer(t, ` 390 | a 391 | b 392 | c 393 | d 394 | e 395 | `, 0, 1) 396 | expected := trimExpectation(t, ` 397 | a 398 | c 399 | d 400 | e 401 | _ 402 | `) 403 | b.DeleteLine(1) 404 | if b.String() != expected { 405 | t.Fatalf("DeleteLine didn't remove the line after 'a'\nExpected:\n%s\nGot:\n%s", expected, b.String()) 406 | } 407 | }) 408 | 409 | t.Run("deletes lines when the parameter is too large", func(t *testing.T) { 410 | b := makeTestBuffer(t, ` 411 | a 412 | b 413 | c 414 | d 415 | e 416 | `, 0, 3) 417 | expected := trimExpectation(t, ` 418 | a 419 | b 420 | c 421 | _ 422 | _ 423 | `) 424 | b.DeleteLine(20) 425 | if b.String() != expected { 426 | t.Fatalf("DeleteLine didn't remove the last 2 lines\nExpected:\n%s\nGot:\n%s", expected, b.String()) 427 | } 428 | }) 429 | 430 | t.Run("doesn't write over the scroll area", func(t *testing.T) { 431 | b := makeTestBuffer(t, ` 432 | a 433 | b 434 | c 435 | d 436 | e 437 | `, 0, 0) 438 | expected := trimExpectation(t, ` 439 | a 440 | c 441 | _ 442 | d 443 | e 444 | `) 445 | b.SetScrollArea(0, 3) 446 | b.SetCursor(0, 1) 447 | b.DeleteLine(1) 448 | if b.String() != expected { 449 | t.Fatalf("DeleteLine should have not affected the scroll area\nExpected:\n%s\nGot:\n%s", expected, b.String()) 450 | } 451 | }) 452 | } 453 | 454 | func TestInsertLine(t *testing.T) { 455 | t.Run("inserts lines in the middle", func(t *testing.T) { 456 | b := makeTestBuffer(t, ` 457 | a 458 | b 459 | c 460 | d 461 | e 462 | `, 0, 1) 463 | expected := trimExpectation(t, ` 464 | a 465 | _ 466 | _ 467 | b 468 | c 469 | `) 470 | b.InsertLine(2) 471 | if b.String() != expected { 472 | t.Fatalf("InsertLine didn't insert 2 lines after 'a'\nExpected:\n%s\nGot:\n%s", expected, b.String()) 473 | } 474 | }) 475 | 476 | t.Run("inserts only one line", func(t *testing.T) { 477 | b := makeTestBuffer(t, ` 478 | a 479 | b 480 | c 481 | d 482 | e 483 | `, 0, 1) 484 | expected := trimExpectation(t, ` 485 | a 486 | _ 487 | b 488 | c 489 | d 490 | `) 491 | b.InsertLine(1) 492 | if b.String() != expected { 493 | t.Fatalf("InsertLine didn't insert the line after 'a'\nExpected:\n%s\nGot:\n%s", expected, b.String()) 494 | } 495 | }) 496 | 497 | t.Run("inserts lines when the parameter is too large", func(t *testing.T) { 498 | b := makeTestBuffer(t, ` 499 | a 500 | b 501 | c 502 | d 503 | e 504 | `, 0, 3) 505 | expected := trimExpectation(t, ` 506 | a 507 | b 508 | c 509 | _ 510 | _ 511 | `) 512 | b.InsertLine(20) 513 | if b.String() != expected { 514 | t.Fatalf("InsertLine didn't insert the last 2 lines\nExpected:\n%s\nGot:\n%s", expected, b.String()) 515 | } 516 | }) 517 | 518 | t.Run("doesn't write over the scroll area", func(t *testing.T) { 519 | b := makeTestBuffer(t, ` 520 | a 521 | b 522 | c 523 | d 524 | e 525 | `, 0, 0) 526 | expected := trimExpectation(t, ` 527 | a 528 | _ 529 | b 530 | d 531 | e 532 | `) 533 | b.SetScrollArea(0, 3) 534 | b.SetCursor(0, 1) 535 | b.InsertLine(1) 536 | if b.String() != expected { 537 | t.Fatalf("InsertLine should have not affected the scroll area\nExpected:\n%s\nGot:\n%s", expected, b.String()) 538 | } 539 | }) 540 | } 541 | 542 | func makeTestBuffer(t testing.TB, content string, x, y int) *Buffer { 543 | t.Helper() 544 | rows := strings.Split(content, "\n") 545 | trimmedRows := make([]string, 0, len(rows)) 546 | for _, r := range rows { 547 | line := strings.TrimSpace(r) 548 | if line != "" { 549 | trimmedRows = append(trimmedRows, line) 550 | } 551 | } 552 | if len(trimmedRows) == 0 { 553 | t.Fatal("the make test buffer input is empty") 554 | } 555 | for _, r := range trimmedRows { 556 | if len(r) != len(trimmedRows[0]) { 557 | t.Fatal("test buffer has lines with different length") 558 | } 559 | } 560 | b := New(len(trimmedRows[0]), len(trimmedRows)) 561 | for _, r := range trimmedRows { 562 | for _, c := range r { 563 | if c == '_' { 564 | c = ' ' 565 | } 566 | b.WriteRune(c) 567 | } 568 | } 569 | b.SetCursor(x, y) 570 | return b 571 | } 572 | 573 | func trimExpectation(t testing.TB, expected string) string { 574 | rows := strings.Split(expected, "\n") 575 | trimmedRows := make([]string, 0, len(rows)) 576 | for _, r := range rows { 577 | line := strings.TrimSpace(r) 578 | if line != "" { 579 | trimmedRows = append( 580 | trimmedRows, 581 | strings.ReplaceAll(line, "_", " "), 582 | ) 583 | } 584 | } 585 | // adds trailing new line because that's what the buffer.String() method does 586 | return fmt.Sprintf("%s\n", strings.Join(trimmedRows, "\n")) 587 | } 588 | -------------------------------------------------------------------------------- /buffer/color.go: -------------------------------------------------------------------------------- 1 | package buffer 2 | 3 | import "fmt" 4 | 5 | // FIXME: Ideally, the buffer would represent the colors the same way that the 6 | // SGR instruction does and the GUI layer would then translate them 7 | // that would allow for easy changes to the color theme 8 | var ( 9 | DefaultFG = NewColor(0xeb, 0xdb, 0xb2) 10 | DefaultBG = NewColor(0x28, 0x28, 0x28) 11 | ) 12 | 13 | type Color struct { 14 | R uint8 15 | G uint8 16 | B uint8 17 | } 18 | 19 | func NewColor(r, g, b uint8) Color { 20 | return Color{R: r, G: g, B: b} 21 | } 22 | 23 | func (c Color) String() string { 24 | return fmt.Sprintf("#%x%x%x", c.R, c.G, c.B) 25 | } 26 | -------------------------------------------------------------------------------- /controller/ascii.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | const ( 4 | asciiNUL = 0x00 // Null 5 | asciiSOH = 0x01 // Start of Heading 6 | asciiSTX = 0x02 // Start of Text 7 | asciiETX = 0x03 // End of Text 8 | asciiEOT = 0x04 // End of Transmission 9 | asciiENQ = 0x05 // Enquiry 10 | asciiACK = 0x06 // Acknowledgment 11 | asciiBEL = 0x07 // Bell 12 | asciiBS = 0x08 // Backspace 13 | asciiHT = 0x09 // Horizontal Tab 14 | asciiLF = 0x0A // Line Feed 15 | asciiVT = 0x0B // Vertical Tab 16 | asciiFF = 0x0C // Form Feed 17 | asciiCR = 0x0D // Carriage Return 18 | asciiSO = 0x0E // Shift Out 19 | asciiSI = 0x0F // Shift In 20 | asciiDLE = 0x10 // Data Link Escape 21 | asciiDC1 = 0x11 // Device Control 1 (XON) 22 | asciiDC2 = 0x12 // Device Control 2 23 | asciiDC3 = 0x13 // Device Control 3 (XOFF) 24 | asciiDC4 = 0x14 // Device Control 4 25 | asciiNAK = 0x15 // Negative Acknowledgment 26 | asciiSYN = 0x16 // Synchronous Idle 27 | asciiETB = 0x17 // End of Transmission Block 28 | asciiCAN = 0x18 // Cancel 29 | asciiEM = 0x19 // End of Medium 30 | asciiSUB = 0x1A // Substitute 31 | asciiESC = 0x1B // Escape 32 | asciiFS = 0x1C // File Separator 33 | asciiGS = 0x1D // Group Separator 34 | asciiRS = 0x1E // Record Separator 35 | asciiUS = 0x1F // Unit Separator 36 | asciiDEL = 0x7F // Delete 37 | ) 38 | -------------------------------------------------------------------------------- /controller/controller.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "log" 8 | "os" 9 | "os/exec" 10 | "sync" 11 | 12 | "gioui.org/io/key" 13 | "github.com/creack/pty" 14 | "github.com/viktomas/gritty/buffer" 15 | "github.com/viktomas/gritty/parser" 16 | ) 17 | 18 | type Controller struct { 19 | buffer *buffer.Buffer 20 | ptmx *os.File 21 | mu sync.RWMutex 22 | render chan struct{} 23 | in chan []byte 24 | Done chan struct{} 25 | } 26 | 27 | func (c *Controller) Started() bool { 28 | return c.buffer != nil 29 | } 30 | 31 | func (c *Controller) Start(shell string, cols, rows int) error { 32 | cmd := exec.Command(shell) 33 | cmd.Env = append(cmd.Env, "TERM=xterm-256color") 34 | c.buffer = buffer.New(cols, rows) 35 | ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: uint16(cols), Rows: uint16(rows)}) 36 | if err != nil { 37 | return fmt.Errorf("failed to start PTY %w", err) 38 | } 39 | render := make(chan struct{}) 40 | c.render = render 41 | c.ptmx = ptmx 42 | c.Done = make(chan struct{}) 43 | ops := processPTY(c.ptmx) 44 | go func() { 45 | for op := range ops { 46 | c.handleOp(op) 47 | c.render <- struct{}{} 48 | } 49 | close(c.Done) 50 | }() 51 | return nil 52 | 53 | } 54 | 55 | func (c *Controller) Resize(cols, rows int) { 56 | c.mu.Lock() 57 | c.buffer.Resize(buffer.BufferSize{Cols: cols, Rows: rows}) 58 | c.mu.Unlock() 59 | pty.Setsize(c.ptmx, &pty.Winsize{Rows: uint16(rows), Cols: uint16(cols)}) 60 | 61 | } 62 | 63 | func (c *Controller) KeyPressed(name string, mod key.Modifiers) { 64 | logDebug("key pressed %v, modifiers: %v\n", name, mod) 65 | _, err := c.ptmx.Write(keyToBytes(name, mod)) 66 | if err != nil { 67 | log.Fatalf("writing key into PTY failed with error: %v", err) 68 | return 69 | } 70 | } 71 | 72 | func (c *Controller) Runes() []buffer.BrushedRune { 73 | c.mu.RLock() 74 | defer c.mu.RUnlock() 75 | return c.buffer.Runes() 76 | } 77 | 78 | // Render returns a channel that will get signal every time we need to 79 | // redraw the terminal GUI 80 | func (c *Controller) Render() <-chan struct{} { 81 | return c.render 82 | } 83 | 84 | func (c *Controller) executeOp(r rune) { 85 | switch r { 86 | case asciiHT: 87 | c.buffer.Tab() 88 | case asciiBS: 89 | c.buffer.Backspace() 90 | case asciiCR: 91 | c.buffer.CR() 92 | case asciiLF: 93 | c.buffer.LF() 94 | case 0x8d: // this is coming from ESC M https://vt100.net/docs/vt100-ug/chapter3.html 95 | c.buffer.ReverseIndex() 96 | default: 97 | fmt.Printf("Unknown control character 0x%x\n", r) 98 | } 99 | } 100 | 101 | func (c *Controller) handleOp(op parser.Operation) { 102 | c.mu.Lock() 103 | defer c.mu.Unlock() 104 | 105 | logDebug("%v\n", op) 106 | switch op.T { 107 | case parser.OpExecute: 108 | c.executeOp(op.R) 109 | case parser.OpPrint: 110 | c.buffer.WriteRune(op.R) 111 | case parser.OpCSI: 112 | translateCSI(op, c.buffer, c.ptmx) 113 | case parser.OpOSC: 114 | fmt.Println("unhandled OSC instruction: ", op) 115 | case parser.OpESC: 116 | if op.R >= '@' && op.R <= '_' && op.Intermediate == "" { 117 | c.executeOp(op.R + 0x40) 118 | } else { 119 | fmt.Println("Unknown ESC op: ", op) 120 | } 121 | default: 122 | fmt.Printf("unhandled op type %v\n", op) 123 | } 124 | 125 | } 126 | 127 | func processPTY(ptmx *os.File) <-chan parser.Operation { 128 | out := make(chan parser.Operation) 129 | buf := make([]byte, 1024) 130 | parser := parser.New() 131 | go func() { 132 | defer func() { 133 | close(out) 134 | ptmx.Close() 135 | }() 136 | for { 137 | n, err := ptmx.Read(buf) 138 | if err != nil { 139 | // if the error is io.EOF, then the PTY got closed and that most likely means that the shell exited 140 | if !errors.Is(io.EOF, err) { 141 | log.Printf("exiting copyAndHandleControlSequences because reader error %v", err) 142 | } 143 | return 144 | } 145 | for _, op := range parser.Parse(buf[:n]) { 146 | out <- op 147 | } 148 | } 149 | }() 150 | return out 151 | } 152 | 153 | func logDebug(f string, vars ...any) { 154 | if os.Getenv("gritty_debug") != "" { 155 | fmt.Printf(f, vars...) 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /controller/controller_test.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/viktomas/gritty/buffer" 7 | "github.com/viktomas/gritty/parser" 8 | ) 9 | 10 | func FuzzController(f *testing.F) { 11 | f.Add([]byte{0x00, 0x01, 0x02, 0x04, 0x05, 0x06, 0x07, 0x08}) 12 | f.Add([]byte("\x1b[2r\x1b[A\x8d0")) 13 | f.Fuzz(func(t *testing.T, in []byte) { 14 | c := &Controller{buffer: buffer.New(10, 10)} 15 | ops := parser.New().Parse(in) 16 | for _, op := range ops { 17 | c.handleOp(op) 18 | } 19 | }) 20 | } 21 | -------------------------------------------------------------------------------- /controller/csi_translator.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log" 7 | 8 | "github.com/viktomas/gritty/buffer" 9 | "github.com/viktomas/gritty/parser" 10 | ) 11 | 12 | // translateCSI will get a CSI (Control Sequence Introducer) sequence (operation) and enact it on the buffer 13 | func translateCSI(op parser.Operation, b *buffer.Buffer, pty io.Writer) { 14 | if op.T != parser.OpCSI { 15 | log.Printf("operation %v is not CSI but it was passed to CSI translator.\n", op) 16 | return 17 | } 18 | 19 | // handle sequences that have the intermediate character (mostly private sequences) 20 | if op.Intermediate != "" { 21 | switch op.R { 22 | case 'c': 23 | if op.Intermediate == ">" { 24 | // inspired by https://github.com/liamg/darktile/blob/159932ff3ecdc9f7d30ac026480587b84edb895b/internal/app/darktile/termutil/csi.go#L305 25 | // we are VT100 26 | // for DA2 we'll respond >0;0;0 27 | _, err := pty.Write([]byte("\x1b[>0;0;0c")) 28 | if err != nil { 29 | log.Printf("Error when writing device information to PTY: %v", err) 30 | } 31 | } 32 | 33 | case 'h': 34 | // DEC Private Mode Set (DECSET). 35 | // source https://invisible-island.net/xterm/ctlseqs/ctlseqs.html 36 | if op.Intermediate == "?" { 37 | switch op.Param(0, 0) { 38 | // Origin Mode (DECOM), VT100. 39 | case 6: 40 | b.SetOriginMode(true) 41 | // Save cursor as in DECSC, After saving the cursor, switch to the Alternate Screen Buffer, 42 | case 1049: 43 | b.SaveCursor() 44 | b.SwitchToAlternateBuffer() 45 | default: 46 | log.Println("unknown DEC Private mode set parameter: ", op) 47 | } 48 | } 49 | case 'l': 50 | if op.Intermediate == "?" { 51 | switch op.Param(0, 0) { 52 | // Normal Cursor Mode (DECOM) 53 | case 6: 54 | b.SetOriginMode(false) 55 | // Use Normal Screen Buffer and restore cursor as in DECRC 56 | case 1049: 57 | b.SwitchToPrimaryBuffer() 58 | b.RestoreCursor() 59 | default: 60 | log.Println("unknown DEC Private mode set parameter: ", op) 61 | } 62 | } 63 | default: 64 | fmt.Printf("unknown CSI sequence with intermediate char %v\n", op) 65 | } 66 | return 67 | } 68 | 69 | switch op.R { 70 | // CUU - Cursor up 71 | case 'A': 72 | dy := op.Param(0, 1) 73 | b.MoveCursorRelative(0, -dy) 74 | case 'e': 75 | fallthrough 76 | // CUD - Cursor down 77 | case 'B': 78 | dy := op.Param(0, 1) 79 | b.MoveCursorRelative(0, dy) 80 | case 'a': // a is also CUF 81 | fallthrough 82 | // CUF - cursor forward 83 | case 'C': 84 | dx := op.Param(0, 1) 85 | b.MoveCursorRelative(dx, 0) 86 | // CUB - Cursor bacward 87 | case 'D': 88 | dx := op.Param(0, 1) 89 | b.MoveCursorRelative(-dx, 0) 90 | case 'J': 91 | switch op.Param(0, 0) { 92 | case 0: 93 | b.ClearCurrentLine(b.Cursor().X, b.Size().Cols) 94 | b.ClearLines(b.Cursor().Y+1, b.Size().Rows) 95 | case 1: 96 | b.ClearCurrentLine(0, b.Cursor().X+1) 97 | b.ClearLines(0, b.Cursor().Y-1) 98 | case 2: 99 | b.ClearLines(0, b.Size().Rows) 100 | b.SetCursor(0, 0) 101 | default: 102 | log.Println("unknown CSI [J parameter: ", op.Params[0]) 103 | } 104 | case 'K': 105 | switch op.Param(0, 0) { 106 | case 0: 107 | b.ClearCurrentLine(b.Cursor().X, b.Size().Cols) 108 | case 1: 109 | b.ClearCurrentLine(0, b.Cursor().X+1) 110 | case 2: 111 | b.ClearCurrentLine(0, b.Size().Cols) 112 | default: 113 | log.Println("unknown CSI K parameter: ", op.Params[0]) 114 | } 115 | case 'L': // IL - Insert Line - https://vt100.net/docs/vt510-rm/IL.html 116 | b.InsertLine(op.Param(0, 1)) 117 | case 'M': // DL - Delete Line - https://vt100.net/docs/vt510-rm/DL.html 118 | b.DeleteLine(op.Param(0, 1)) 119 | case 'f': // Horizontal and Vertical Position [row;column] (default = [1,1]) (HVP). 120 | fallthrough 121 | case 'H': // Cursor Position [row;column] (default = [1,1]) (CUP). 122 | b.SetCursor(op.Param(1, 1)-1, op.Param(0, 1)-1) 123 | case 'P': // DCH - Delete character - https://vt100.net/docs/vt510-rm/DCH.html 124 | b.DeleteCharacter(op.Param(0, 1)) 125 | case 'X': //ECH—Erase Character https://vt100.net/docs/vt510-rm/ECH.html 126 | b.ClearCurrentLine(b.Cursor().X, b.Cursor().X+op.Param(0, 1)) 127 | case 'r': 128 | start := op.Param(0, 1) 129 | end := op.Param(1, b.Size().Rows) 130 | // the DECSTBM docs https://vt100.net/docs/vt510-rm/DECSTBM.html 131 | // say that the index you get starts with 1 (first line) 132 | // and ends with len(lines)-1 (last line) 133 | // but the scroll area takes the index of the first line (starts with 0) 134 | // and index (starting from zero) of the last line + 1 135 | b.SetScrollArea(start-1, end) 136 | case 's': 137 | b.SaveCursor() 138 | case 'u': 139 | b.RestoreCursor() 140 | case 'c': 141 | // inspired by https://github.com/liamg/darktile/blob/159932ff3ecdc9f7d30ac026480587b84edb895b/internal/app/darktile/termutil/csi.go#L305 142 | // we are VT100 143 | // for DA1 we'll respond ?1;2 144 | _, err := pty.Write([]byte("\x1b[?1;2c")) 145 | if err != nil { 146 | log.Printf("Error when writing device information to PTY: %v", err) 147 | } 148 | // SGR https://vt100.net/docs/vt510-rm/SGR.html 149 | case 'm': 150 | ps := op.Param(0, 0) 151 | // 4bit color 152 | if ps >= 30 && ps <= 37 { 153 | color := get3bitNormalColor(uint8(ps - 30)) 154 | br := b.Brush() 155 | br.FG = color 156 | b.SetBrush(br) 157 | return 158 | } 159 | if ps >= 90 && ps <= 97 { 160 | color := get3bitBrightColor(uint8(ps - 90)) 161 | br := b.Brush() 162 | br.FG = color 163 | b.SetBrush(br) 164 | return 165 | } 166 | if ps >= 40 && ps <= 47 { 167 | color := get3bitNormalColor(uint8(ps - 40)) 168 | br := b.Brush() 169 | br.BG = color 170 | b.SetBrush(br) 171 | return 172 | } 173 | if ps >= 100 && ps <= 107 { 174 | color := get3bitNormalColor(uint8(ps - 100)) 175 | br := b.Brush() 176 | br.BG = color 177 | b.SetBrush(br) 178 | return 179 | } 180 | switch ps { 181 | case 0: 182 | b.ResetBrush() 183 | case 1: 184 | br := b.Brush() 185 | br.Bold = true 186 | b.SetBrush(br) 187 | case 7: 188 | br := b.Brush() 189 | br.Invert = true 190 | b.SetBrush(br) 191 | case 27: 192 | br := b.Brush() 193 | br.Invert = false 194 | b.SetBrush(br) 195 | case 38: 196 | switch op.Param(1, 0) { 197 | case 5: 198 | color := get256Color(uint8(op.Param(2, 0))) 199 | br := b.Brush() 200 | br.FG = color 201 | b.SetBrush(br) 202 | case 2: 203 | color := buffer.NewColor(uint8(op.Param(2, 0)), uint8(op.Param(3, 0)), uint8(op.Param(4, 0))) 204 | br := b.Brush() 205 | br.FG = color 206 | b.SetBrush(br) 207 | 208 | default: 209 | log.Printf("unknown SGR instruction %v\n", op) 210 | } 211 | 212 | case 39: 213 | br := b.Brush() 214 | br.FG = buffer.DefaultFG 215 | b.SetBrush(br) 216 | case 48: 217 | switch op.Param(1, 0) { 218 | case 5: 219 | color := get256Color(uint8(op.Param(2, 0))) 220 | br := b.Brush() 221 | br.BG = color 222 | b.SetBrush(br) 223 | case 2: 224 | color := buffer.NewColor(uint8(op.Param(2, 0)), uint8(op.Param(3, 0)), uint8(op.Param(4, 0))) 225 | br := b.Brush() 226 | br.BG = color 227 | b.SetBrush(br) 228 | default: 229 | log.Printf("unknown SGR instruction %v\n", op) 230 | } 231 | case 49: 232 | br := b.Brush() 233 | br.BG = buffer.DefaultBG 234 | b.SetBrush(br) 235 | default: 236 | log.Printf("unknown SGR instruction %v\n", op) 237 | } 238 | default: 239 | log.Printf("Unknown CSI instruction %v", op) 240 | } 241 | } 242 | 243 | // get3bitNormalColor returns a color based on the SGR 30-37 244 | // I used the VS Code color palette 245 | // more info here https://en.wikipedia.org/wiki/ANSI_escape_code#3-bit_and_4-bit 246 | func get3bitNormalColor(n uint8) buffer.Color { 247 | if n > 7 { 248 | log.Printf("get3BitNormalColor was invoked with incorrect number %d\n", n) 249 | n = 0 250 | } 251 | colors := []buffer.Color{ 252 | {R: 0, G: 0, B: 0}, // black 253 | {R: 205, G: 49, B: 49}, // red 254 | {R: 13, G: 188, B: 121}, // green 255 | {R: 229, G: 229, B: 16}, // yellow 256 | {R: 36, G: 114, B: 200}, // blue 257 | {R: 188, G: 63, B: 188}, // magenta 258 | {R: 17, G: 168, B: 205}, // cyan 259 | {R: 229, G: 229, B: 229}, // white 260 | } 261 | return colors[n] 262 | } 263 | 264 | // get3bitBrightColor returns a color based on the SGR 30-37 and 90-97 scale 265 | // I used the VS Code color palette 266 | // more info here https://en.wikipedia.org/wiki/ANSI_escape_code#3-bit_and_4-bit 267 | func get3bitBrightColor(n uint8) buffer.Color { 268 | if n > 7 { 269 | log.Printf("get3BitBrightColor was invoked with incorrect number %d\n", n) 270 | n = 0 271 | } 272 | colors := []buffer.Color{ 273 | {R: 102, G: 102, B: 102}, // bright black 274 | {R: 241, G: 76, B: 76}, // bright red 275 | {R: 35, G: 209, B: 139}, // bright green 276 | {R: 245, G: 245, B: 67}, // bright yellow 277 | {R: 59, G: 142, B: 234}, // bright blue 278 | {R: 214, G: 112, B: 214}, // bright magenta 279 | {R: 41, G: 184, B: 219}, // bright cyan 280 | {R: 229, G: 229, B: 229}, // bright white 281 | } 282 | return colors[n] 283 | } 284 | 285 | func get256Color(n uint8) buffer.Color { 286 | switch { 287 | case n < 8: 288 | return get3bitNormalColor(n) 289 | case n < 16: 290 | return get3bitBrightColor(n - 8) 291 | case n < 232: 292 | // 6x6x6 color cube 293 | n -= 16 294 | levels := []uint8{0, 95, 135, 175, 215, 255} 295 | return buffer.NewColor(levels[(n/36)%6], levels[(n/6)%6], levels[n%6]) 296 | default: 297 | // grayscale 298 | level := 8 + (n-232)*10 299 | return buffer.NewColor(level, level, level) 300 | } 301 | } 302 | -------------------------------------------------------------------------------- /controller/key_encoder.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "strings" 5 | 6 | "gioui.org/io/key" 7 | ) 8 | 9 | func keyToBytes(name string, mod key.Modifiers) []byte { 10 | if mod.Contain(key.ModCtrl) { 11 | if len(name) == 1 && name[0] >= 0x40 && name[0] <= 0x5f { 12 | return []byte{name[0] - 0x40} 13 | } 14 | } 15 | switch name { 16 | // Handle ANSI escape sequence for Enter key 17 | case key.NameReturn: 18 | return []byte("\r") 19 | case key.NameDeleteBackward: 20 | return []byte{asciiDEL} 21 | case key.NameSpace: 22 | return []byte(" ") 23 | case key.NameEscape: 24 | return []byte{asciiESC} 25 | case key.NameTab: 26 | return []byte{asciiHT} 27 | case key.NameUpArrow: 28 | return []byte("\x1b[A") 29 | case key.NameDownArrow: 30 | return []byte("\x1b[B") 31 | case key.NameRightArrow: 32 | return []byte("\x1b[C") 33 | case key.NameLeftArrow: 34 | return []byte("\x1b[D") 35 | default: 36 | // For normal characters, pass them through. 37 | var character string 38 | if mod.Contain(key.ModShift) { 39 | character = strings.ToUpper(name) 40 | } else { 41 | character = strings.ToLower(name) 42 | } 43 | return []byte(character) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /doc/gritty-color-2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viktomas/gritty/e77d027024e351f5e59d6577dac0b1fb4492d02e/doc/gritty-color-2.gif -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/viktomas/gritty 2 | 3 | go 1.21.1 4 | 5 | require ( 6 | gioui.org v0.2.0 7 | github.com/creack/pty v1.1.18 8 | golang.org/x/image v0.5.0 9 | ) 10 | 11 | require ( 12 | gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2 // indirect 13 | gioui.org/shader v1.0.6 // indirect 14 | github.com/go-text/typesetting v0.0.0-20230803102845-24e03d8b5372 // indirect 15 | golang.org/x/exp v0.0.0-20221012211006-4de253d81b95 // indirect 16 | golang.org/x/exp/shiny v0.0.0-20220827204233-334a2380cb91 // indirect 17 | golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64 // indirect 18 | golang.org/x/text v0.7.0 // indirect 19 | ) 20 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY= 2 | eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA= 3 | gioui.org v0.2.0 h1:RbzDn1h/pCVf/q44ImQSa/J3MIFpY3OWphzT/Tyei+w= 4 | gioui.org v0.2.0/go.mod h1:1H72sKEk/fNFV+l0JNeM2Dt3co3Y4uaQcD+I+/GQ0e4= 5 | gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ= 6 | gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2 h1:AGDDxsJE1RpcXTAxPG2B4jrwVUJGFDjINIPi1jtO6pc= 7 | gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ= 8 | gioui.org/shader v1.0.6 h1:cvZmU+eODFR2545X+/8XucgZdTtEjR3QWW6W65b0q5Y= 9 | gioui.org/shader v1.0.6/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM= 10 | github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= 11 | github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= 12 | github.com/go-text/typesetting v0.0.0-20230803102845-24e03d8b5372 h1:FQivqchis6bE2/9uF70M2gmmLpe82esEm2QadL0TEJo= 13 | github.com/go-text/typesetting v0.0.0-20230803102845-24e03d8b5372/go.mod h1:evDBbvNR/KaVFZ2ZlDSOWWXIUKq0wCOEtzLxRM8SG3k= 14 | github.com/go-text/typesetting-utils v0.0.0-20230616150549-2a7df14b6a22 h1:LBQTFxP2MfsyEDqSKmUBZaDuDHN1vpqDyOZjcqS7MYI= 15 | github.com/go-text/typesetting-utils v0.0.0-20230616150549-2a7df14b6a22/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o= 16 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 17 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 18 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 19 | golang.org/x/exp v0.0.0-20221012211006-4de253d81b95 h1:sBdrWpxhGDdTAYNqbgBLAR+ULAPPhfgncLr1X0lyWtg= 20 | golang.org/x/exp v0.0.0-20221012211006-4de253d81b95/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= 21 | golang.org/x/exp/shiny v0.0.0-20220827204233-334a2380cb91 h1:ryT6Nf0R83ZgD8WnFFdfI8wCeyqgdXWN4+CkFVNPAT0= 22 | golang.org/x/exp/shiny v0.0.0-20220827204233-334a2380cb91/go.mod h1:VjAR7z0ngyATZTELrBSkxOOHhhlnVUxDye4mcjx5h/8= 23 | golang.org/x/image v0.5.0 h1:5JMiNunQeQw++mMOz48/ISeNu3Iweh/JaZU8ZLqHRrI= 24 | golang.org/x/image v0.5.0/go.mod h1:FVC7BI/5Ym8R25iw5OLsgshdUBbT1h5jZTpA+mvAdZ4= 25 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 26 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 27 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 28 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 29 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 30 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 31 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 32 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 33 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 34 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 35 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 36 | golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64 h1:UiNENfZ8gDvpiWw7IpOMQ27spWmThO1RwwdQVbJahJM= 37 | golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 38 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 39 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 40 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 41 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 42 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 43 | golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo= 44 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 45 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 46 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 47 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 48 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 49 | -------------------------------------------------------------------------------- /gui.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "image" 6 | "log" 7 | "os" 8 | "strings" 9 | "time" 10 | 11 | "gioui.org/app" 12 | "gioui.org/f32" 13 | "gioui.org/font" 14 | "gioui.org/font/gofont" 15 | "gioui.org/io/key" 16 | "gioui.org/io/system" 17 | "gioui.org/layout" 18 | "gioui.org/op" 19 | "gioui.org/op/paint" 20 | "gioui.org/text" 21 | "gioui.org/unit" 22 | "github.com/viktomas/gritty/buffer" 23 | "github.com/viktomas/gritty/controller" 24 | "golang.org/x/image/math/fixed" 25 | ) 26 | 27 | const monoTypeface = "go mono, monospaced" 28 | const fontSize = 16 29 | 30 | func StartGui(shell string, controller *controller.Controller) { 31 | go func() { 32 | w := app.NewWindow(app.Title("Gritty")) 33 | if err := loop(w, shell, controller); err != nil { 34 | log.Fatal(err) 35 | } 36 | os.Exit(0) 37 | }() 38 | app.Main() 39 | } 40 | 41 | func loop(w *app.Window, sh string, controller *controller.Controller) error { 42 | 43 | shaper := text.NewShaper(text.WithCollection(gofont.Collection())) 44 | 45 | var ops op.Ops 46 | 47 | var location = f32.Pt(300, 300) 48 | 49 | var windowSize image.Point 50 | 51 | cursorBlinkTicker := time.NewTicker(500 * time.Millisecond) 52 | 53 | for { 54 | select { 55 | case <-controller.Done: 56 | return nil 57 | case <-cursorBlinkTicker.C: 58 | w.Invalidate() 59 | case e := <-w.Events(): 60 | switch e := e.(type) { 61 | case system.DestroyEvent: 62 | return e.Err 63 | case system.FrameEvent: 64 | gtx := layout.NewContext(&ops, e) 65 | // paint the whole window with the background color 66 | // FIXME: This is a temporary heck, the ideal solution would be to 67 | // shrink the window to the exact character grid after each resize 68 | // (with some debouncing) 69 | paint.ColorOp{Color: convertColor(buffer.DefaultBG)}.Add(gtx.Ops) 70 | paint.PaintOp{}.Add(gtx.Ops) 71 | 72 | if e.Size != windowSize { 73 | windowSize = e.Size // make sure this code doesn't run until we resized again 74 | bufferSize := getBufferSize(gtx, fontSize, e.Size, shaper) 75 | if !controller.Started() { 76 | 77 | var err error 78 | err = controller.Start(sh, bufferSize.Cols, bufferSize.Rows) 79 | if err != nil { 80 | log.Fatalf("can't initialize PTY controller %v", err) 81 | } 82 | go func() { 83 | for range controller.Render() { 84 | w.Invalidate() 85 | } 86 | }() 87 | } else { 88 | controller.Resize(bufferSize.Cols, bufferSize.Rows) 89 | } 90 | } 91 | // keep the focus, since only one thing can 92 | key.FocusOp{Tag: &location}.Add(&ops) 93 | // register tag &location as reading input 94 | key.InputOp{ 95 | Tag: &location, 96 | // Keys: arrowKeys, 97 | }.Add(&ops) 98 | 99 | // Capture and handle keyboard input 100 | for _, ev := range gtx.Events(&location) { 101 | if ke, ok := ev.(key.Event); ok { 102 | if ke.State == key.Press { 103 | controller.KeyPressed(ke.Name, ke.Modifiers) 104 | } 105 | } 106 | } 107 | // inset := layout.UniformInset(5) 108 | layout.Flex{Axis: layout.Vertical}.Layout(gtx, 109 | layout.Rigid(func(gtx layout.Context) layout.Dimensions { 110 | params := text.Parameters{ 111 | Font: font.Font{ 112 | Typeface: font.Typeface(monoTypeface), 113 | }, 114 | PxPerEm: fixed.I(gtx.Sp(fontSize)), 115 | } 116 | shaper.LayoutString(params, "Hello") 117 | l := Label{ 118 | // we don't put new lines at the end of the line 119 | // so we need the layout mechanism to use a policy 120 | // to maximize the number of characters printed per line 121 | WrapPolicy: text.WrapGraphemes, 122 | } 123 | font := font.Font{ 124 | Typeface: font.Typeface(monoTypeface), 125 | } 126 | 127 | return l.Layout(gtx, shaper, font, fontSize, controller.Runes()) 128 | // screenSize := getScreenSize(gtx, fontSize, e.Size, th) 129 | // return l.Layout(gtx, th.Shaper, font, fontSize, generateTestContent(screenSize.rows, screenSize.cols)) 130 | }), 131 | ) 132 | e.Frame(gtx.Ops) 133 | } 134 | } 135 | 136 | } 137 | } 138 | 139 | func generateTestContent(rows, cols int) []buffer.BrushedRune { 140 | var screen []buffer.BrushedRune 141 | for r := 0; r < rows; r++ { 142 | ch := fmt.Sprintf("%d", r) 143 | for c := 0; c < cols; c++ { 144 | r := rune(ch[len(ch)-1]) 145 | if c%4 == 0 { 146 | r = ' ' 147 | } 148 | br := buffer.BrushedRune{ 149 | R: r, 150 | } 151 | if c == 0 { 152 | br = buffer.BrushedRune{ 153 | R: br.R, 154 | Brush: buffer.Brush{ 155 | Invert: true, 156 | }, 157 | } 158 | } 159 | if c == cols-2 { 160 | br = buffer.BrushedRune{ 161 | R: br.R, 162 | Brush: buffer.Brush{ 163 | Bold: true, 164 | }, 165 | } 166 | } 167 | screen = append(screen, br) 168 | } 169 | } 170 | return screen 171 | } 172 | 173 | // div divides two int26_6 numberes 174 | func div(a, b fixed.Int26_6) fixed.Int26_6 { 175 | return (a * (1 << 6)) / b 176 | } 177 | 178 | func getBufferSize(gtx layout.Context, textSize unit.Sp, windowSize image.Point, sh *text.Shaper) buffer.BufferSize { 179 | params := text.Parameters{ 180 | Font: font.Font{ 181 | Typeface: font.Typeface(monoTypeface), 182 | }, 183 | PxPerEm: fixed.I(gtx.Sp(fontSize)), 184 | } 185 | sh.Layout(params, strings.NewReader("A")) 186 | g, ok := sh.NextGlyph() 187 | if !ok { 188 | log.Println("ok is false for the next glyph") 189 | } 190 | glyphWidth := g.Advance 191 | glyphHeight := g.Ascent + g.Descent + 1<<6 // TODO find out why the line height is higher than the glyph 192 | cols := div(fixed.I(windowSize.X), glyphWidth).Floor() 193 | rows := div(fixed.I(windowSize.Y), glyphHeight).Floor() 194 | return buffer.BufferSize{Rows: rows, Cols: cols} 195 | } 196 | -------------------------------------------------------------------------------- /label.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | package main 4 | 5 | import ( 6 | "image" 7 | "image/color" 8 | "strings" 9 | "time" 10 | 11 | "gioui.org/f32" 12 | "gioui.org/font" 13 | "gioui.org/io/semantic" 14 | "gioui.org/layout" 15 | "gioui.org/op" 16 | "gioui.org/op/clip" 17 | "gioui.org/op/paint" 18 | "gioui.org/text" 19 | "gioui.org/unit" 20 | "github.com/viktomas/gritty/buffer" 21 | 22 | "golang.org/x/image/math/fixed" 23 | ) 24 | 25 | // Label is a widget code copied from https://git.sr.ht/~eliasnaur/gio/tree/313c488ec356872a14dab0c0ac0fd73b45a596cf/item/widget/label.go 26 | // and changed so it can render grid of characters. Each character can have 27 | // different background and foreground colors and a few other attributes 28 | // see (buffer.BrushedRune) 29 | type Label struct { 30 | // Alignment specifies the text alignment. 31 | Alignment text.Alignment 32 | // MaxLines limits the number of lines. Zero means no limit. 33 | MaxLines int 34 | // Truncator is the text that will be shown at the end of the final 35 | // line if MaxLines is exceeded. Defaults to "…" if empty. 36 | Truncator string 37 | // WrapPolicy configures how displayed text will be broken into lines. 38 | WrapPolicy text.WrapPolicy 39 | // LineHeight controls the distance between the baselines of lines of text. 40 | // If zero, a sensible default will be used. 41 | LineHeight unit.Sp 42 | // LineHeightScale applies a scaling factor to the LineHeight. If zero, a 43 | // sensible default will be used. 44 | LineHeightScale float32 45 | } 46 | 47 | type paintedGlyph struct { 48 | g text.Glyph 49 | r rune 50 | fg, bg color.NRGBA 51 | } 52 | 53 | // Layout the label with the given shaper, font, size, text, and material. 54 | func (l Label) Layout(gtx layout.Context, lt *text.Shaper, font font.Font, size unit.Sp, txt []buffer.BrushedRune) layout.Dimensions { 55 | dims, _ := l.LayoutDetailed(gtx, lt, font, size, txt) 56 | return dims 57 | } 58 | 59 | // TextInfo provides metadata about shaped text. 60 | type TextInfo struct { 61 | // Truncated contains the number of runes of text that are represented by a truncator 62 | // symbol in the text. If zero, there is no truncator symbol. 63 | Truncated int 64 | } 65 | 66 | // Layout the label with the given shaper, font, size, text, and material, returning metadata about the shaped text. 67 | func (l Label) LayoutDetailed(gtx layout.Context, lt *text.Shaper, font font.Font, size unit.Sp, txt []buffer.BrushedRune) (layout.Dimensions, TextInfo) { 68 | cs := gtx.Constraints 69 | textSize := fixed.I(gtx.Sp(size)) 70 | lineHeight := fixed.I(gtx.Sp(l.LineHeight)) 71 | var str strings.Builder 72 | for _, pr := range txt { 73 | str.WriteRune(pr.R) 74 | } 75 | lt.LayoutString(text.Parameters{ 76 | Font: font, 77 | PxPerEm: textSize, 78 | MaxLines: l.MaxLines, 79 | Truncator: l.Truncator, 80 | Alignment: l.Alignment, 81 | WrapPolicy: l.WrapPolicy, 82 | MaxWidth: cs.Max.X, 83 | MinWidth: cs.Min.X, 84 | Locale: gtx.Locale, 85 | LineHeight: lineHeight, 86 | LineHeightScale: l.LineHeightScale, 87 | }, str.String()) 88 | m := op.Record(gtx.Ops) 89 | viewport := image.Rectangle{Max: cs.Max} 90 | it := textIterator{ 91 | viewport: viewport, 92 | maxLines: l.MaxLines, 93 | } 94 | semantic.LabelOp(str.String()).Add(gtx.Ops) 95 | var paintedGlyphs [32]paintedGlyph 96 | line := paintedGlyphs[:0] 97 | pos := 0 98 | for g, ok := lt.NextGlyph(); ok; g, ok = lt.NextGlyph() { 99 | // if txt[pos].r == '\n' { 100 | // pos++ 101 | // } 102 | var ok bool 103 | if line, ok = it.paintGlyph(gtx, lt, g, line, txt[pos]); !ok { 104 | break 105 | } 106 | if pos+1 >= len(txt) { 107 | // log.Printf("incrementing pos will cause problems, txt length is %v, position is %v", len(txt), pos) 108 | } else { 109 | pos++ 110 | } 111 | } 112 | call := m.Stop() 113 | viewport.Min = viewport.Min.Add(it.padding.Min) 114 | viewport.Max = viewport.Max.Add(it.padding.Max) 115 | clipStack := clip.Rect(viewport).Push(gtx.Ops) 116 | call.Add(gtx.Ops) 117 | dims := layout.Dimensions{Size: it.bounds.Size()} 118 | dims.Size = cs.Constrain(dims.Size) 119 | dims.Baseline = dims.Size.Y - it.baseline 120 | clipStack.Pop() 121 | return dims, TextInfo{Truncated: it.truncated} 122 | } 123 | 124 | func r2p(r clip.Rect) clip.Op { 125 | return clip.Stroke{Path: r.Path(), Width: 1}.Op() 126 | } 127 | 128 | // textIterator computes the bounding box of and paints text. 129 | type textIterator struct { 130 | // viewport is the rectangle of document coordinates that the iterator is 131 | // trying to fill with text. 132 | viewport image.Rectangle 133 | // maxLines is the maximum number of text lines that should be displayed. 134 | maxLines int 135 | // truncated tracks the count of truncated runes in the text. 136 | truncated int 137 | // linesSeen tracks the quantity of line endings this iterator has seen. 138 | linesSeen int 139 | // lineOff tracks the origin for the glyphs in the current line. 140 | lineOff f32.Point 141 | // padding is the space needed outside of the bounds of the text to ensure no 142 | // part of a glyph is clipped. 143 | padding image.Rectangle 144 | // bounds is the logical bounding box of the text. 145 | bounds image.Rectangle 146 | // visible tracks whether the most recently iterated glyph is visible within 147 | // the viewport. 148 | visible bool 149 | // first tracks whether the iterator has processed a glyph yet. 150 | first bool 151 | // baseline tracks the location of the first line of text's baseline. 152 | baseline int 153 | } 154 | 155 | // processGlyph checks whether the glyph is visible within the iterator's configured 156 | // viewport and (if so) updates the iterator's text dimensions to include the glyph. 157 | func (it *textIterator) processGlyph(g text.Glyph, ok bool) (_ text.Glyph, visibleOrBefore bool) { 158 | if it.maxLines > 0 { 159 | if g.Flags&text.FlagTruncator != 0 && g.Flags&text.FlagClusterBreak != 0 { 160 | // A glyph carrying both of these flags provides the count of truncated runes. 161 | it.truncated = g.Runes 162 | } 163 | if g.Flags&text.FlagLineBreak != 0 { 164 | it.linesSeen++ 165 | } 166 | if it.linesSeen == it.maxLines && g.Flags&text.FlagParagraphBreak != 0 { 167 | return g, false 168 | } 169 | } 170 | // Compute the maximum extent to which glyphs overhang on the horizontal 171 | // axis. 172 | if d := g.Bounds.Min.X.Floor(); d < it.padding.Min.X { 173 | // If the distance between the dot and the left edge of this glyph is 174 | // less than the current padding, increase the left padding. 175 | it.padding.Min.X = d 176 | } 177 | if d := (g.Bounds.Max.X - g.Advance).Ceil(); d > it.padding.Max.X { 178 | // If the distance between the dot and the right edge of this glyph 179 | // minus the logical advance of this glyph is greater than the current 180 | // padding, increase the right padding. 181 | it.padding.Max.X = d 182 | } 183 | if d := (g.Bounds.Min.Y + g.Ascent).Floor(); d < it.padding.Min.Y { 184 | // If the distance between the dot and the top of this glyph is greater 185 | // than the ascent of the glyph, increase the top padding. 186 | it.padding.Min.Y = d 187 | } 188 | if d := (g.Bounds.Max.Y - g.Descent).Ceil(); d > it.padding.Max.Y { 189 | // If the distance between the dot and the bottom of this glyph is greater 190 | // than the descent of the glyph, increase the bottom padding. 191 | it.padding.Max.Y = d 192 | } 193 | logicalBounds := image.Rectangle{ 194 | Min: image.Pt(g.X.Floor(), int(g.Y)-g.Ascent.Ceil()), 195 | Max: image.Pt((g.X + g.Advance).Ceil(), int(g.Y)+g.Descent.Ceil()), 196 | } 197 | if !it.first { 198 | it.first = true 199 | it.baseline = int(g.Y) 200 | it.bounds = logicalBounds 201 | } 202 | 203 | above := logicalBounds.Max.Y < it.viewport.Min.Y 204 | below := logicalBounds.Min.Y > it.viewport.Max.Y 205 | left := logicalBounds.Max.X < it.viewport.Min.X 206 | right := logicalBounds.Min.X > it.viewport.Max.X 207 | it.visible = !above && !below && !left && !right 208 | if it.visible { 209 | it.bounds.Min.X = min(it.bounds.Min.X, logicalBounds.Min.X) 210 | it.bounds.Min.Y = min(it.bounds.Min.Y, logicalBounds.Min.Y) 211 | it.bounds.Max.X = max(it.bounds.Max.X, logicalBounds.Max.X) 212 | it.bounds.Max.Y = max(it.bounds.Max.Y, logicalBounds.Max.Y) 213 | } 214 | return g, ok && !below 215 | } 216 | 217 | func fixedToFloat(i fixed.Int26_6) float32 { 218 | return float32(i) / 64.0 219 | } 220 | 221 | func shouldBlinkInvert() bool { 222 | currentTime := time.Now() 223 | return (currentTime.UnixNano()/int64(time.Millisecond)/500)%2 == 0 224 | } 225 | 226 | func convertColor(c buffer.Color) color.NRGBA { 227 | return color.NRGBA{A: 0xff, R: c.R, G: c.G, B: c.B} 228 | } 229 | 230 | // toPaintedGlyph transfers GUI-agnostic BrushedRune into a specific way 231 | // we render the characters in Gio 232 | func toPaintedGlyph(g text.Glyph, br buffer.BrushedRune) paintedGlyph { 233 | defaultGlyph := paintedGlyph{ 234 | r: br.R, 235 | g: g, 236 | fg: convertColor(br.Brush.FG), 237 | bg: convertColor(br.Brush.BG), 238 | } 239 | 240 | if br.Brush.Bold { 241 | defaultGlyph.bg = color.NRGBA{A: 255, R: 0, G: 0, B: 0} 242 | } 243 | 244 | if br.Brush.Invert { 245 | fg := defaultGlyph.fg 246 | defaultGlyph.fg = defaultGlyph.bg 247 | defaultGlyph.bg = fg 248 | } 249 | 250 | if br.Brush.Blink && shouldBlinkInvert() { 251 | fg := defaultGlyph.fg 252 | defaultGlyph.fg = defaultGlyph.bg 253 | defaultGlyph.bg = fg 254 | } 255 | 256 | return defaultGlyph 257 | } 258 | 259 | // paintGlyph buffers up and paints text glyphs. It should be invoked iteratively upon each glyph 260 | // until it returns false. The line parameter should be a slice with 261 | // a backing array of sufficient size to buffer multiple glyphs. 262 | // A modified slice will be returned with each invocation, and is 263 | // expected to be passed back in on the following invocation. 264 | // This design is awkward, but prevents the line slice from escaping 265 | // to the heap. 266 | // 267 | // this function has been heavily modified from the original in 268 | // https://git.sr.ht/~eliasnaur/gio/tree/313c488ec356872a14dab0c0ac0fd73b45a596cf/item/widget/label.go 269 | // to render grid of characters where each character can have a different color 270 | func (it *textIterator) paintGlyph(gtx layout.Context, shaper *text.Shaper, glyph text.Glyph, line []paintedGlyph, br buffer.BrushedRune) ([]paintedGlyph, bool) { 271 | _, visibleOrBefore := it.processGlyph(glyph, true) 272 | if it.visible { 273 | if len(line) == 0 { 274 | it.lineOff = f32.Point{X: fixedToFloat(glyph.X), Y: float32(glyph.Y)}.Sub(layout.FPt(it.viewport.Min)) 275 | } 276 | 277 | // we processed the glyph and now we take parameters from the brushed rune 278 | // these parameters are then used in the next step (after we processed the whole line) 279 | line = append(line, toPaintedGlyph(glyph, br)) 280 | } 281 | // this section gets executed only at the end, after we filled our line with glyphs 282 | // by repeatedly calling the it.ProcessGlyph 283 | if glyph.Flags&text.FlagLineBreak != 0 || cap(line)-len(line) == 0 || !visibleOrBefore { 284 | t := op.Affine(f32.Affine2D{}.Offset(it.lineOff)).Push(gtx.Ops) 285 | var glyphLine []text.Glyph 286 | for _, pg := range line { 287 | // minX is where the glyph character starts 288 | // thanks to setting an offset, the rectangle and the glyph can be drawn from X: 0 289 | minX := pg.g.X.Floor() - it.lineOff.Round().X 290 | glyphOffset := op.Affine(f32.Affine2D{}.Offset(f32.Point{X: float32(minX)})).Push(gtx.Ops) 291 | 292 | // draw background 293 | rect := clip.Rect{ 294 | Min: image.Point{X: 0, Y: 0 - glyph.Ascent.Ceil()}, 295 | Max: image.Point{X: pg.g.X.Floor() + pg.g.Advance.Ceil() - it.lineOff.Round().X, Y: 0 + glyph.Descent.Ceil()}, 296 | } 297 | paint.FillShape( 298 | gtx.Ops, 299 | pg.bg, 300 | rect.Op(), 301 | ) 302 | 303 | // draw glyph 304 | path := shaper.Shape([]text.Glyph{pg.g}) 305 | outline := clip.Outline{Path: path}.Op().Push(gtx.Ops) 306 | paint.ColorOp{Color: pg.fg}.Add(gtx.Ops) 307 | paint.PaintOp{}.Add(gtx.Ops) 308 | outline.Pop() 309 | if call := shaper.Bitmaps(glyphLine); call != (op.CallOp{}) { 310 | call.Add(gtx.Ops) 311 | } 312 | 313 | glyphOffset.Pop() 314 | } 315 | t.Pop() 316 | line = line[:0] 317 | } 318 | return line, visibleOrBefore 319 | } 320 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/viktomas/gritty/controller" 4 | 5 | func main() { 6 | shell := "/bin/sh" 7 | controller := &controller.Controller{} 8 | StartGui(shell, controller) 9 | } 10 | -------------------------------------------------------------------------------- /parser/pty_parser.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "encoding/hex" 5 | "fmt" 6 | "log" 7 | "strconv" 8 | "strings" 9 | ) 10 | 11 | type Parser struct { 12 | state parserState 13 | privateFlag int 14 | buf []byte 15 | intermediate []byte 16 | params []byte 17 | osc []byte 18 | } 19 | 20 | type OperationType uint32 21 | 22 | type Operation struct { 23 | T OperationType 24 | R rune 25 | Intermediate string 26 | Params []int 27 | Osc string 28 | // Raw is the sequence of bytes that the parser processed to make this operation 29 | Raw []byte 30 | } 31 | 32 | // Param returns parameter on the i index or def(ault) value if the Param is missing or 0 33 | func (o Operation) Param(i int, def int) int { 34 | if len(o.Params) == 0 || len(o.Params) <= i { 35 | return def 36 | } 37 | if o.Params[i] == 0 { 38 | return def 39 | } 40 | return o.Params[i] 41 | } 42 | 43 | // padding defines how many characters we want to ensure before the Raw: in the output 44 | const padding = 40 45 | 46 | func (o Operation) String() string { 47 | var opString string 48 | switch o.T { 49 | case OpOSC: 50 | opString = fmt.Sprintf("OSC: %q", o.Osc) 51 | case OpPrint: 52 | opString = fmt.Sprintf("print: %q", string(o.R)) 53 | case OpExecute: 54 | opString = fmt.Sprintf("execute: %q", string(o.R)) 55 | case OpESC: 56 | opString = fmt.Sprintf("ESC: %s %q", o.Intermediate, string(o.R)) 57 | case OpCSI: 58 | opString = fmt.Sprintf("CSI: %s %v %q", o.Intermediate, o.Params, string(o.R)) 59 | default: 60 | log.Fatalln("Unknown operation type: ", o.T) 61 | return "" 62 | } 63 | return fmt.Sprintf("%-*sRaw: %s", padding, opString, hex.EncodeToString(o.Raw)) 64 | } 65 | 66 | const ( 67 | OpExecute OperationType = iota 68 | OpPrint 69 | OpESC 70 | OpCSI 71 | OpOSC 72 | ) 73 | 74 | type parserState int 75 | 76 | const ( 77 | sGround parserState = iota 78 | sEscape 79 | sEscapeIntermediate 80 | sCSIEntry 81 | sCSIParam 82 | sCSIIgnore 83 | sCSIIntermediate 84 | sOSC 85 | ) 86 | 87 | func New() *Parser { 88 | return &Parser{ 89 | state: sGround, // technically, this is not necessary because the sGround is 0 90 | } 91 | } 92 | 93 | func (d *Parser) pExecute(b byte) Operation { 94 | op := Operation{T: OpExecute, R: rune(b), Raw: d.buf} 95 | d.buf = nil 96 | return op 97 | } 98 | 99 | func (d *Parser) pPrint(b byte) Operation { 100 | op := Operation{T: OpPrint, R: rune(b), Raw: d.buf} 101 | d.buf = nil 102 | return op 103 | } 104 | 105 | func (d *Parser) escDispatch(b byte) Operation { 106 | op := Operation{T: OpESC, R: rune(b), Intermediate: string(d.intermediate), Raw: d.buf} 107 | d.buf = nil 108 | return op 109 | } 110 | 111 | func (d *Parser) csiDispatch(b byte) Operation { 112 | var params []int 113 | if len(d.params) > 0 { 114 | stringNumbers := strings.Split(string(d.params), ";") 115 | for _, sn := range stringNumbers { 116 | i, err := strconv.ParseInt(sn, 10, 32) 117 | if err != nil { 118 | log.Printf("tried to parse params %s but it doesn't contain only numbers and ;", d.params) 119 | continue 120 | } 121 | params = append(params, int(i)) 122 | } 123 | } 124 | op := Operation{T: OpCSI, R: rune(b), Params: params, Intermediate: string(d.intermediate), Raw: d.buf} 125 | d.buf = nil 126 | return op 127 | } 128 | 129 | func (d *Parser) oscDispatch() Operation { 130 | op := Operation{T: OpOSC, Osc: string(d.osc), Raw: d.buf} 131 | d.buf = nil 132 | return op 133 | } 134 | 135 | func (d *Parser) clear() { 136 | d.privateFlag = 0 137 | d.intermediate = nil 138 | d.params = nil 139 | } 140 | 141 | func (d *Parser) collect(b byte) { 142 | d.intermediate = append(d.intermediate, b) 143 | } 144 | 145 | func (d *Parser) param(b byte) { 146 | d.params = append(d.params, b) 147 | } 148 | 149 | // btw (between) returns true if b >= start && b <= end 150 | // in other words it checks whether b is in the boundaries set by Start and End *inclusive* 151 | func btw(b, start, end byte) bool { 152 | return b >= start && b <= end 153 | } 154 | 155 | // in checks if the byte is in the set of values given by vals 156 | func in(b byte, vals ...byte) bool { 157 | for _, v := range vals { 158 | if v == b { 159 | return true 160 | } 161 | } 162 | return false 163 | } 164 | 165 | func isControlChar(b byte) bool { 166 | return btw(b, 0x00, 0x17) || b == 0x19 || btw(b, 0x1c, 0x1f) 167 | } 168 | 169 | // Parse parses bytes received from PTY based on the excellent state diagram by Paul Williams https://www.vt100.net/emu/dec_ansi_parser 170 | func (d *Parser) Parse(p []byte) []Operation { 171 | var result []Operation 172 | for i := 0; i < len(p); i++ { 173 | b := p[i] 174 | d.buf = append(d.buf, b) 175 | // Anywhere 176 | if b == 0x1b { 177 | d.state = sEscape 178 | d.clear() 179 | continue 180 | } 181 | if b == 0x18 || b == 0x1a || btw(b, 0x80, 0x8F) || btw(b, 0x91, 0x97) || b == 0x99 || b == 0x9a { 182 | d.state = sGround 183 | result = append(result, d.pExecute(b)) 184 | continue 185 | 186 | } 187 | if b == 0x9D { 188 | d.state = sOSC 189 | d.osc = nil 190 | continue 191 | } 192 | switch d.state { 193 | case sGround: 194 | if isControlChar(b) { 195 | result = append(result, d.pExecute(b)) 196 | } 197 | if b >= 0x20 && b <= 0x7f { 198 | result = append(result, d.pPrint(b)) 199 | } 200 | case sEscape: 201 | if isControlChar(b) { 202 | result = append(result, d.pExecute(b)) 203 | } 204 | if btw(b, 0x30, 0x4f) || btw(b, 0x51, 0x57) || in(b, 0x59, 0x5a, 0x5C) || btw(b, 0x60, 0x7e) { 205 | result = append(result, d.escDispatch(b)) 206 | d.state = sGround 207 | } 208 | if btw(b, 0x20, 0x2f) { 209 | d.collect(b) 210 | d.state = sEscapeIntermediate 211 | } 212 | if b == 0x5b { 213 | d.clear() 214 | d.state = sCSIEntry 215 | } 216 | if b == 0x5d { 217 | d.osc = nil 218 | d.state = sOSC 219 | } 220 | // 7f ignore 221 | case sEscapeIntermediate: 222 | if isControlChar(b) { 223 | result = append(result, d.pExecute(b)) 224 | } 225 | if btw(b, 0x20, 0x2f) { 226 | d.collect(b) 227 | } 228 | if btw(b, 0x30, 0x7e) { 229 | result = append(result, d.escDispatch(b)) 230 | d.state = sGround 231 | } 232 | // 7f ignore 233 | case sCSIEntry: 234 | if isControlChar(b) { 235 | result = append(result, d.pExecute(b)) 236 | } 237 | if btw(b, 0x40, 0x7e) { 238 | result = append(result, d.csiDispatch(b)) 239 | d.state = sGround 240 | } 241 | if btw(b, 0x30, 0x39) || b == 0x3b { 242 | d.param(b) 243 | d.state = sCSIParam 244 | } 245 | if btw(b, 0x3c, 0x3f) { 246 | d.collect(b) 247 | d.state = sCSIParam 248 | } 249 | if b == 0x3a { 250 | d.state = sCSIIgnore 251 | } 252 | // 7f ignore 253 | case sCSIParam: 254 | if isControlChar(b) { 255 | result = append(result, d.pExecute(b)) 256 | } 257 | if btw(b, 0x30, 0x39) || b == 0x3b { 258 | d.param(b) 259 | } 260 | if btw(b, 0x40, 0x7e) { 261 | result = append(result, d.csiDispatch(b)) 262 | d.state = sGround 263 | } 264 | if btw(b, 0x20, 0x2f) { 265 | d.collect(b) 266 | d.state = sCSIIntermediate 267 | } 268 | if b == 0x3a || btw(b, 0x3c, 0x3f) { 269 | d.state = sCSIIgnore 270 | } 271 | // 7f ignore 272 | case sCSIIntermediate: 273 | if isControlChar(b) { 274 | result = append(result, d.pExecute(b)) 275 | } 276 | if btw(b, 0x20, 0x2f) { 277 | d.collect(b) 278 | } 279 | if btw(b, 0x40, 0x7e) { 280 | result = append(result, d.csiDispatch(b)) 281 | d.state = sGround 282 | } 283 | if btw(b, 0x30, 0x3f) { 284 | d.state = sCSIIgnore 285 | } 286 | // 7f ignore 287 | case sCSIIgnore: 288 | if isControlChar(b) { 289 | result = append(result, d.pExecute(b)) 290 | } 291 | if btw(b, 0x40, 0x7e) { 292 | d.state = sGround 293 | } 294 | // 20-3f,7f ignore 295 | case sOSC: 296 | if isControlChar(b) { 297 | // ignore 298 | } 299 | if btw(b, 0x20, 0x7f) { 300 | d.osc = append(d.osc, b) 301 | } 302 | // 0x07 is xterm non-ANSI variant of transition to ground 303 | // taken from https://github.com/asciinema/avt/blob/main/src/vt.rs#L423C17-L423C74 304 | if b == 0x07 || b == 0x9c { 305 | result = append(result, d.oscDispatch()) 306 | d.state = sGround 307 | } 308 | if b == 0x9c { 309 | d.state = sGround 310 | } 311 | } 312 | } 313 | return result 314 | } 315 | -------------------------------------------------------------------------------- /parser/pty_parser_test.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | func TestParse(t *testing.T) { 10 | t.Run("it parses control characters", func(t *testing.T) { 11 | for i := byte(0x00); i < 0x20; i++ { 12 | if i <= 0x17 || i == 0x19 || (i >= 0x1c && i <= 0x1f) { 13 | instructions := New().Parse([]byte{i}) 14 | if len(instructions) != 1 { 15 | t.Fatalf("The parser should have returned 1 instruction but returned %d for byte 0x%x", len(instructions), i) 16 | } 17 | if instructions[0].T != OpExecute { 18 | t.Fatalf("The type of the instruction was supposed to be iexecute(%d), but was %d", OpExecute, instructions[0].T) 19 | } 20 | if instructions[0].R != rune(i) { 21 | t.Fatalf("The rune in the instruction was supposed to be 0x%x, but was 0x%x", i, instructions[0].R) 22 | } 23 | } 24 | } 25 | }) 26 | 27 | } 28 | 29 | func compInst(t testing.TB, expected, actual Operation) { 30 | if expected.T != actual.T { 31 | t.Fatalf("instruction type is different, expected: %v, actual: %v", expected.T, actual.T) 32 | } 33 | if expected.R != actual.R { 34 | t.Fatalf("instruction final character is different, expected: %c, actual: %c", expected.R, actual.R) 35 | } 36 | if !reflect.DeepEqual(expected.Params, actual.Params) { 37 | t.Fatalf("instruction params are different, expected: %v, actual: %v", expected.Params, actual.Params) 38 | } 39 | if !reflect.DeepEqual(expected.Intermediate, actual.Intermediate) { 40 | t.Fatalf("instruction intermediate chars are different, expected: %s, actual: %s", expected.Intermediate, actual.Intermediate) 41 | } 42 | 43 | } 44 | func TestParseCSI(t *testing.T) { 45 | t.Run("parses cursor movements", func(t *testing.T) { 46 | testCases := []struct { 47 | desc string 48 | input rune 49 | }{ 50 | {desc: "cursor up", input: 'A'}, 51 | {desc: "cursor down", input: 'B'}, 52 | {desc: "cursor forward", input: 'C'}, 53 | {desc: "cursor back", input: 'D'}, 54 | {desc: "cursor next line", input: 'E'}, 55 | {desc: "cursor previous line", input: 'F'}, 56 | {desc: "cursor horizontal absolute", input: 'G'}, 57 | } 58 | for _, tc := range testCases { 59 | t.Run(tc.desc, func(t *testing.T) { 60 | input := []byte(fmt.Sprintf("\x1b[%[1]c\x1b[39%[1]c", tc.input)) 61 | instructions := New().Parse(input) 62 | if len(instructions) != 2 { 63 | t.Fatalf("The parser should have returned 2 instruction but returned %d for byte %v", len(instructions), input) 64 | } 65 | 66 | i0, i1 := instructions[0], instructions[1] 67 | expected0 := Operation{T: OpCSI, R: rune(tc.input)} 68 | expected1 := Operation{T: OpCSI, R: rune(tc.input), Params: []int{39}} 69 | compInst(t, expected0, i0) 70 | compInst(t, expected1, i1) 71 | }) 72 | } 73 | 74 | }) 75 | 76 | t.Run("parse private sequences", func(t *testing.T) { 77 | instructions := New().Parse([]byte("\x1b[?1049h]")) 78 | compInst( 79 | t, 80 | Operation{T: OpCSI, R: 'h', Intermediate: "?", Params: []int{1049}}, 81 | instructions[0], 82 | ) 83 | }) 84 | 85 | t.Run("parse", func(t *testing.T) { 86 | tests := []struct { 87 | input []byte 88 | expected Operation 89 | }{ 90 | {[]byte("\x1b[1m"), Operation{T: OpCSI, Params: []int{1}, R: 'm'}}, // Bold 91 | {[]byte("\x1b[4m"), Operation{T: OpCSI, Params: []int{4}, R: 'm'}}, // Underline 92 | {[]byte("\x1b[H"), Operation{T: OpCSI, R: 'H'}}, // Cursor Home 93 | {[]byte("\x1b[J"), Operation{T: OpCSI, R: 'J'}}, // Erase display 94 | {[]byte("\x1b[K"), Operation{T: OpCSI, R: 'K'}}, // Erase line 95 | {[]byte("\x1b[0H"), Operation{T: OpCSI, Params: []int{0}, R: 'H'}}, // Erase line 96 | } 97 | 98 | for _, test := range tests { 99 | test.expected.Raw = test.input 100 | output := New().Parse(test.input) // Assuming `parse` is your parsing function 101 | if !reflect.DeepEqual(test.expected, output[0]) { 102 | t.Fatalf("parsed as %v, but should have been %v", output[0], test.expected) 103 | } 104 | } 105 | }) 106 | 107 | t.Run("goes to ground from CSI entry", func(t *testing.T) { 108 | output := New().Parse([]byte{0x1b, 0x5b, 0x4b, 0x61}) 109 | if len(output) != 2 { 110 | t.Fatalf("the input should have been parsed into 2 operations") 111 | } 112 | expected1 := Operation{T: OpCSI, R: 'K', Raw: []byte("\x1b[K")} 113 | if !reflect.DeepEqual(expected1, output[0]) { 114 | t.Fatalf("first operation should have been %v, but was %v", expected1, output[0]) 115 | } 116 | expected2 := Operation{T: OpPrint, R: 'a', Raw: []byte("a")} 117 | if !reflect.DeepEqual(expected2, output[1]) { 118 | t.Fatalf("second operation should have been %v, but was %v", expected2, output[1]) 119 | } 120 | }) 121 | } 122 | 123 | func TestParam(t *testing.T) { 124 | testCases := []struct { 125 | desc string 126 | params []int 127 | index int 128 | dflt int 129 | expected int 130 | }{ 131 | { 132 | desc: "returns default if params is empty", 133 | params: []int{}, 134 | index: 0, 135 | dflt: 10, 136 | expected: 10, 137 | }, 138 | { 139 | desc: "returns default if params is too short", 140 | params: []int{1}, 141 | index: 1, 142 | dflt: 10, 143 | expected: 10, 144 | }, 145 | { 146 | desc: "returns default if the parsed param is 0", 147 | params: []int{0}, 148 | index: 0, 149 | dflt: 10, 150 | expected: 10, 151 | }, 152 | { 153 | desc: "returns the value on the index", 154 | params: []int{1, 2}, 155 | index: 1, 156 | dflt: 10, 157 | expected: 2, 158 | }, 159 | } 160 | for _, tc := range testCases { 161 | t.Run(tc.desc, func(t *testing.T) { 162 | op := Operation{T: OpCSI, Params: tc.params, R: 'A'} 163 | result := op.Param(tc.index, tc.dflt) 164 | if result != tc.expected { 165 | t.Fatalf("the result should have been %d, but was %d", tc.expected, result) 166 | } 167 | }) 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /testdata/fuzz/FuzzController/582528ddfad69eb5: -------------------------------------------------------------------------------- 1 | go test fuzz v1 2 | []byte("0") 3 | -------------------------------------------------------------------------------- /testdata/fuzz/FuzzController/b2832d8fad322369: -------------------------------------------------------------------------------- 1 | go test fuzz v1 2 | []byte("\x1b[0H") 3 | -------------------------------------------------------------------------------- /testdata/fuzz/FuzzController/cfbc295f9a308092: -------------------------------------------------------------------------------- 1 | go test fuzz v1 2 | []byte("\x1b[11r0") 3 | -------------------------------------------------------------------------------- /testdata/fuzz/FuzzController/da8ecd33b539681c: -------------------------------------------------------------------------------- 1 | go test fuzz v1 2 | []byte("\x1b[2r\x1b[A\x8d0") 3 | --------------------------------------------------------------------------------