├── .gitignore ├── LICENSE ├── README.md ├── color.go ├── csi.go ├── csi_test.go ├── doc.go ├── go.mod ├── go.sum ├── ioctl_other.go ├── ioctl_posix.go ├── parse.go ├── state.go ├── str.go ├── str_test.go ├── vt_other.go ├── vt_posix.go └── vt_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | log 3 | boxterm 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2013 James Gray 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without liitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and thismssion notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # terminal 2 | 3 | Package terminal is a vt10x terminal emulation backend, influenced 4 | largely by st, rxvt, xterm, and iTerm as reference. Use it for terminal 5 | muxing, a terminal emulation frontend, or wherever else you need 6 | terminal emulation. 7 | 8 | ## Installation 9 | 10 | go get j4k.co/terminal 11 | 12 | New to Go? Have a look at how [import paths work](http://golang.org/doc/code.html#remote). 13 | 14 | -------------------------------------------------------------------------------- /color.go: -------------------------------------------------------------------------------- 1 | package terminal 2 | 3 | // ANSI color values 4 | const ( 5 | Black Color = iota 6 | Red 7 | Green 8 | Yellow 9 | Blue 10 | Magenta 11 | Cyan 12 | LightGrey 13 | DarkGrey 14 | LightRed 15 | LightGreen 16 | LightYellow 17 | LightBlue 18 | LightMagenta 19 | LightCyan 20 | White 21 | ) 22 | 23 | // Default colors are potentially distinct to allow for special behavior. 24 | // For example, a transparent background. Otherwise, the simple case is to 25 | // map default colors to another color. 26 | const ( 27 | DefaultFG Color = 0xff80 + iota 28 | DefaultBG 29 | ) 30 | 31 | // Color maps to the ANSI colors [0, 16) and the xterm colors [16, 256). 32 | type Color uint16 33 | 34 | // ANSI returns true if Color is within [0, 16). 35 | func (c Color) ANSI() bool { 36 | return (c < 16) 37 | } 38 | -------------------------------------------------------------------------------- /csi.go: -------------------------------------------------------------------------------- 1 | package terminal 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | ) 7 | 8 | // CSI (Control Sequence Introducer) 9 | // ESC+[ 10 | type csiEscape struct { 11 | buf []byte 12 | args []int 13 | mode byte 14 | priv bool 15 | } 16 | 17 | func (c *csiEscape) reset() { 18 | c.buf = c.buf[:0] 19 | c.args = c.args[:0] 20 | c.mode = 0 21 | c.priv = false 22 | } 23 | 24 | func (c *csiEscape) put(b byte) bool { 25 | c.buf = append(c.buf, b) 26 | if b >= 0x40 && b <= 0x7E || len(c.buf) >= 256 { 27 | c.parse() 28 | return true 29 | } 30 | return false 31 | } 32 | 33 | func (c *csiEscape) parse() { 34 | c.mode = c.buf[len(c.buf)-1] 35 | if len(c.buf) == 1 { 36 | return 37 | } 38 | s := string(c.buf) 39 | c.args = c.args[:0] 40 | if s[0] == '?' { 41 | c.priv = true 42 | s = s[1:] 43 | } 44 | s = s[:len(s)-1] 45 | ss := strings.Split(s, ";") 46 | for _, p := range ss { 47 | i, err := strconv.Atoi(p) 48 | if err != nil { 49 | //t.logf("invalid CSI arg '%s'\n", p) 50 | break 51 | } 52 | c.args = append(c.args, i) 53 | } 54 | } 55 | 56 | func (c *csiEscape) arg(i, def int) int { 57 | if i >= len(c.args) || i < 0 { 58 | return def 59 | } 60 | return c.args[i] 61 | } 62 | 63 | // maxarg takes the maximum of arg(i, def) and def 64 | func (c *csiEscape) maxarg(i, def int) int { 65 | return max(c.arg(i, def), def) 66 | } 67 | 68 | func (t *State) handleCSI() { 69 | c := &t.csi 70 | switch c.mode { 71 | default: 72 | goto unknown 73 | case '@': // ICH - insert blank char 74 | t.insertBlanks(c.arg(0, 1)) 75 | case 'A': // CUU - cursor up 76 | t.moveTo(t.cur.x, t.cur.y-c.maxarg(0, 1)) 77 | case 'B', 'e': // CUD, VPR - cursor down 78 | t.moveTo(t.cur.x, t.cur.y+c.maxarg(0, 1)) 79 | case 'c': // DA - device attributes 80 | if c.arg(0, 0) == 0 { 81 | // TODO: write vt102 id 82 | } 83 | case 'C', 'a': // CUF, HPR - cursor forward 84 | t.moveTo(t.cur.x+c.maxarg(0, 1), t.cur.y) 85 | case 'D': // CUB - cursor backward 86 | t.moveTo(t.cur.x-c.maxarg(0, 1), t.cur.y) 87 | case 'E': // CNL - cursor down and first col 88 | t.moveTo(0, t.cur.y+c.arg(0, 1)) 89 | case 'F': // CPL - cursor up and first col 90 | t.moveTo(0, t.cur.y-c.arg(0, 1)) 91 | case 'g': // TBC - tabulation clear 92 | switch c.arg(0, 0) { 93 | // clear current tab stop 94 | case 0: 95 | t.tabs[t.cur.x] = false 96 | // clear all tabs 97 | case 3: 98 | for i := range t.tabs { 99 | t.tabs[i] = false 100 | } 101 | default: 102 | goto unknown 103 | } 104 | case 'G', '`': // CHA, HPA - Move to 105 | t.moveTo(c.arg(0, 1)-1, t.cur.y) 106 | case 'H', 'f': // CUP, HVP - move to 107 | t.moveAbsTo(c.arg(1, 1)-1, c.arg(0, 1)-1) 108 | case 'I': // CHT - cursor forward tabulation tab stops 109 | n := c.arg(0, 1) 110 | for i := 0; i < n; i++ { 111 | t.putTab(true) 112 | } 113 | case 'J': // ED - clear screen 114 | // TODO: sel.ob.x = -1 115 | switch c.arg(0, 0) { 116 | case 0: // below 117 | t.clear(t.cur.x, t.cur.y, t.cols-1, t.cur.y) 118 | if t.cur.y < t.rows-1 { 119 | t.clear(0, t.cur.y+1, t.cols-1, t.rows-1) 120 | } 121 | case 1: // above 122 | if t.cur.y > 1 { 123 | t.clear(0, 0, t.cols-1, t.cur.y-1) 124 | } 125 | t.clear(0, t.cur.y, t.cur.x, t.cur.y) 126 | case 2: // all 127 | t.clear(0, 0, t.cols-1, t.rows-1) 128 | default: 129 | goto unknown 130 | } 131 | case 'K': // EL - clear line 132 | switch c.arg(0, 0) { 133 | case 0: // right 134 | t.clear(t.cur.x, t.cur.y, t.cols-1, t.cur.y) 135 | case 1: // left 136 | t.clear(0, t.cur.y, t.cur.x, t.cur.y) 137 | case 2: // all 138 | t.clear(0, t.cur.y, t.cols-1, t.cur.y) 139 | } 140 | case 'S': // SU - scroll lines up 141 | t.ScrollUp(t.top, c.arg(0, 1)) 142 | case 'T': // SD - scroll lines down 143 | t.ScrollDown(t.top, c.arg(0, 1)) 144 | case 'L': // IL - insert blank lines 145 | t.insertBlankLines(c.arg(0, 1)) 146 | case 'l': // RM - reset mode 147 | t.setMode(c.priv, false, c.args) 148 | case 'M': // DL - delete lines 149 | t.deleteLines(c.arg(0, 1)) 150 | case 'X': // ECH - erase chars 151 | t.clear(t.cur.x, t.cur.y, t.cur.x+c.arg(0, 1)-1, t.cur.y) 152 | case 'P': // DCH - delete chars 153 | t.deleteChars(c.arg(0, 1)) 154 | case 'Z': // CBT - cursor backward tabulation tab stops 155 | n := c.arg(0, 1) 156 | for i := 0; i < n; i++ { 157 | t.putTab(false) 158 | } 159 | case 'd': // VPA - move to 160 | t.moveAbsTo(t.cur.x, c.arg(0, 1)-1) 161 | case 'h': // SM - set terminal mode 162 | t.setMode(c.priv, true, c.args) 163 | case 'm': // SGR - terminal attribute (color) 164 | t.setAttr(c.args) 165 | case 'r': // DECSTBM - set scrolling region 166 | if c.priv { 167 | goto unknown 168 | } else { 169 | t.setScroll(c.arg(0, 1)-1, c.arg(1, t.rows)-1) 170 | t.moveAbsTo(0, 0) 171 | } 172 | case 's': // DECSC - save cursor position (ANSI.SYS) 173 | t.saveCursor() 174 | case 'u': // DECRC - restore cursor position (ANSI.SYS) 175 | t.restoreCursor() 176 | } 177 | return 178 | unknown: // TODO: get rid of this goto 179 | t.logf("unknown CSI sequence '%c'\n", c.mode) 180 | // TODO: c.dump() 181 | } 182 | -------------------------------------------------------------------------------- /csi_test.go: -------------------------------------------------------------------------------- 1 | package terminal 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestCSIParse(t *testing.T) { 8 | var csi csiEscape 9 | csi.reset() 10 | csi.buf = []byte("s") 11 | csi.parse() 12 | if csi.mode != 's' || csi.arg(0, 17) != 17 || len(csi.args) != 0 { 13 | t.Fatal("CSI parse mismatch") 14 | } 15 | 16 | csi.reset() 17 | csi.buf = []byte("31T") 18 | csi.parse() 19 | if csi.mode != 'T' || csi.arg(0, 0) != 31 || len(csi.args) != 1 { 20 | t.Fatal("CSI parse mismatch") 21 | } 22 | 23 | csi.reset() 24 | csi.buf = []byte("48;2f") 25 | csi.parse() 26 | if csi.mode != 'f' || csi.arg(0, 0) != 48 || csi.arg(1, 0) != 2 || len(csi.args) != 2 { 27 | t.Fatal("CSI parse mismatch") 28 | } 29 | 30 | csi.reset() 31 | csi.buf = []byte("?25l") 32 | csi.parse() 33 | if csi.mode != 'l' || csi.arg(0, 0) != 25 || csi.priv != true || len(csi.args) != 1 { 34 | t.Fatal("CSI parse mismatch") 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package terminal is a vt10x terminal emulation backend, influenced 3 | largely by st, rxvt, xterm, and iTerm as reference. Use it for terminal 4 | muxing, a terminal emulation frontend, or wherever else you need 5 | terminal emulation. 6 | 7 | In development, but very usable. 8 | */ 9 | package terminal 10 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/zyedidia/terminal 2 | 3 | go 1.19 4 | 5 | require github.com/creack/pty v1.1.18 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= 2 | github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= 3 | -------------------------------------------------------------------------------- /ioctl_other.go: -------------------------------------------------------------------------------- 1 | // +build plan9 nacl windows 2 | 3 | package terminal 4 | 5 | import ( 6 | "os" 7 | ) 8 | 9 | func ioctl(f *os.File, cmd, p uintptr) error { 10 | return nil 11 | } 12 | 13 | func (t *VT) ptyResize() error { 14 | return nil 15 | } 16 | -------------------------------------------------------------------------------- /ioctl_posix.go: -------------------------------------------------------------------------------- 1 | // +build linux darwin dragonfly solaris openbsd netbsd freebsd 2 | 3 | package terminal 4 | 5 | import ( 6 | "os" 7 | "syscall" 8 | "unsafe" 9 | ) 10 | 11 | func ioctl(f *os.File, cmd, p uintptr) error { 12 | _, _, errno := syscall.Syscall( 13 | syscall.SYS_IOCTL, 14 | f.Fd(), 15 | syscall.TIOCSWINSZ, 16 | p) 17 | if errno != 0 { 18 | return syscall.Errno(errno) 19 | } 20 | return nil 21 | } 22 | 23 | func (t *VT) ptyResize() error { 24 | if t.pty == nil { 25 | return nil 26 | } 27 | var w struct{ row, col, xpix, ypix uint16 } 28 | w.row = uint16(t.dest.rows) 29 | w.col = uint16(t.dest.cols) 30 | w.xpix = 16 * uint16(t.dest.cols) 31 | w.ypix = 16 * uint16(t.dest.rows) 32 | return ioctl(t.pty, syscall.TIOCSWINSZ, 33 | uintptr(unsafe.Pointer(&w))) 34 | } 35 | -------------------------------------------------------------------------------- /parse.go: -------------------------------------------------------------------------------- 1 | package terminal 2 | 3 | func isControlCode(c rune) bool { 4 | return c < 0x20 || c == 0177 5 | } 6 | 7 | func (t *State) parse(c rune) { 8 | if isControlCode(c) { 9 | if t.handleControlCodes(c) || t.cur.attr.mode&attrGfx == 0 { 10 | return 11 | } 12 | } 13 | // TODO: update selection; see st.c:2450 14 | 15 | if t.mode&ModeWrap != 0 && t.cur.state&cursorWrapNext != 0 { 16 | t.lines[t.cur.y][t.cur.x].mode |= attrWrap 17 | t.newline(true) 18 | } 19 | 20 | if t.mode&ModeInsert != 0 && t.cur.x+1 < t.cols { 21 | // TODO: move shiz, look at st.c:2458 22 | t.logln("insert mode not implemented") 23 | } 24 | 25 | t.setChar(c, &t.cur.attr, t.cur.x, t.cur.y) 26 | if t.cur.x+1 < t.cols { 27 | t.moveTo(t.cur.x+1, t.cur.y) 28 | } else { 29 | t.cur.state |= cursorWrapNext 30 | } 31 | } 32 | 33 | func (t *State) parseEsc(c rune) { 34 | if t.handleControlCodes(c) { 35 | return 36 | } 37 | next := t.parse 38 | switch c { 39 | case '[': 40 | next = t.parseEscCSI 41 | case '#': 42 | next = t.parseEscTest 43 | case 'P', // DCS - Device Control String 44 | '_', // APC - Application Program Command 45 | '^', // PM - Privacy Message 46 | ']', // OSC - Operating System Command 47 | 'k': // old title set compatibility 48 | t.str.reset() 49 | t.str.typ = c 50 | next = t.parseEscStr 51 | case '(': // set primary charset G0 52 | next = t.parseEscAltCharset 53 | case ')', // set secondary charset G1 (ignored) 54 | '*', // set tertiary charset G2 (ignored) 55 | '+': // set quaternary charset G3 (ignored) 56 | case 'D': // IND - linefeed 57 | if t.cur.y == t.bottom { 58 | t.ScrollUp(t.top, 1) 59 | } else { 60 | t.moveTo(t.cur.x, t.cur.y+1) 61 | } 62 | case 'E': // NEL - next line 63 | t.newline(true) 64 | case 'H': // HTS - horizontal tab stop 65 | t.tabs[t.cur.x] = true 66 | case 'M': // RI - reverse index 67 | if t.cur.y == t.top { 68 | t.ScrollDown(t.top, 1) 69 | } else { 70 | t.moveTo(t.cur.x, t.cur.y-1) 71 | } 72 | case 'Z': // DECID - identify terminal 73 | // TODO: write to our writer our id 74 | case 'c': // RIS - reset to initial state 75 | t.reset() 76 | case '=': // DECPAM - application keypad 77 | t.mode |= ModeAppKeypad 78 | case '>': // DECPNM - normal keypad 79 | t.mode &^= ModeAppKeypad 80 | case '7': // DECSC - save cursor 81 | t.saveCursor() 82 | case '8': // DECRC - restore cursor 83 | t.restoreCursor() 84 | case '\\': // ST - stop 85 | default: 86 | t.logf("unknown ESC sequence '%c'\n", c) 87 | } 88 | t.state = next 89 | } 90 | 91 | func (t *State) parseEscCSI(c rune) { 92 | if t.handleControlCodes(c) { 93 | return 94 | } 95 | if t.csi.put(byte(c)) { 96 | t.state = t.parse 97 | t.handleCSI() 98 | } 99 | } 100 | 101 | func (t *State) parseEscStr(c rune) { 102 | switch c { 103 | case '\033': 104 | t.state = t.parseEscStrEnd 105 | case '\a': // backwards compatiblity to xterm 106 | t.state = t.parse 107 | t.handleSTR() 108 | default: 109 | t.str.put(c) 110 | } 111 | } 112 | 113 | func (t *State) parseEscStrEnd(c rune) { 114 | if t.handleControlCodes(c) { 115 | return 116 | } 117 | t.state = t.parse 118 | if c == '\\' { 119 | t.handleSTR() 120 | } 121 | } 122 | 123 | func (t *State) parseEscAltCharset(c rune) { 124 | if t.handleControlCodes(c) { 125 | return 126 | } 127 | switch c { 128 | case '0': // line drawing set 129 | t.cur.attr.mode |= attrGfx 130 | case 'B': // USASCII 131 | t.cur.attr.mode &^= attrGfx 132 | case 'A', // UK (ignored) 133 | '<', // multinational (ignored) 134 | '5', // Finnish (ignored) 135 | 'C', // Finnish (ignored) 136 | 'K': // German (ignored) 137 | default: 138 | t.logf("unknown alt. charset '%c'\n", c) 139 | } 140 | t.state = t.parse 141 | } 142 | 143 | func (t *State) parseEscTest(c rune) { 144 | if t.handleControlCodes(c) { 145 | return 146 | } 147 | // DEC screen alignment test 148 | if c == '8' { 149 | for y := 0; y < t.rows; y++ { 150 | for x := 0; x < t.cols; x++ { 151 | t.setChar('E', &t.cur.attr, x, y) 152 | } 153 | } 154 | } 155 | t.state = t.parse 156 | } 157 | 158 | func (t *State) handleControlCodes(c rune) bool { 159 | if !isControlCode(c) { 160 | return false 161 | } 162 | switch c { 163 | // HT 164 | case '\t': 165 | t.putTab(true) 166 | // BS 167 | case '\b': 168 | t.moveTo(t.cur.x-1, t.cur.y) 169 | // CR 170 | case '\r': 171 | t.moveTo(0, t.cur.y) 172 | // LF, VT, LF 173 | case '\f', '\v', '\n': 174 | // go to first col if mode is set 175 | t.newline(t.mode&ModeCRLF != 0) 176 | // BEL 177 | case '\a': 178 | // TODO: emit sound 179 | // TODO: window alert if not focused 180 | // ESC 181 | case 033: 182 | t.csi.reset() 183 | t.state = t.parseEsc 184 | // SO, SI 185 | case 016, 017: 186 | // different charsets not supported. apps should use the correct 187 | // alt charset escapes, probably for line drawing 188 | // SUB, CAN 189 | case 032, 030: 190 | t.csi.reset() 191 | // ignore ENQ, NUL, XON, XOFF, DEL 192 | case 005, 000, 021, 023, 0177: 193 | default: 194 | return false 195 | } 196 | return true 197 | } 198 | -------------------------------------------------------------------------------- /state.go: -------------------------------------------------------------------------------- 1 | package terminal 2 | 3 | import ( 4 | "log" 5 | "sync" 6 | ) 7 | 8 | const ( 9 | tabspaces = 8 10 | ) 11 | 12 | const ( 13 | attrReverse = 1 << iota 14 | attrUnderline 15 | attrBold 16 | attrGfx 17 | attrItalic 18 | attrBlink 19 | attrWrap 20 | ) 21 | 22 | const ( 23 | cursorDefault = 1 << iota 24 | cursorWrapNext 25 | cursorOrigin 26 | ) 27 | 28 | // ModeFlag represents various terminal mode states. 29 | type ModeFlag uint32 30 | 31 | // Terminal modes 32 | const ( 33 | ModeWrap ModeFlag = 1 << iota 34 | ModeInsert 35 | ModeAppKeypad 36 | ModeAltScreen 37 | ModeCRLF 38 | ModeMouseButton 39 | ModeMouseMotion 40 | ModeReverse 41 | ModeKeyboardLock 42 | ModeHide 43 | ModeEcho 44 | ModeAppCursor 45 | ModeMouseSgr 46 | Mode8bit 47 | ModeBlink 48 | ModeFBlink 49 | ModeFocus 50 | ModeMouseX10 51 | ModeMouseMany 52 | ModeMouseMask = ModeMouseButton | ModeMouseMotion | ModeMouseX10 | ModeMouseMany 53 | ) 54 | 55 | // ChangeFlag represents possible state changes of the terminal. 56 | type ChangeFlag uint32 57 | 58 | // Terminal changes to occur in VT.ReadState 59 | const ( 60 | ChangedScreen ChangeFlag = 1 << iota 61 | ChangedTitle 62 | ) 63 | 64 | type glyph struct { 65 | c rune 66 | mode int16 67 | fg, bg Color 68 | } 69 | 70 | type line []glyph 71 | 72 | type cursor struct { 73 | attr glyph 74 | x, y int 75 | state uint8 76 | } 77 | 78 | type parseState func(c rune) 79 | 80 | // State represents the terminal emulation state. Use Lock/Unlock 81 | // methods to synchronize data access with VT. 82 | type State struct { 83 | DebugLogger *log.Logger 84 | 85 | mu sync.Mutex 86 | changed ChangeFlag 87 | cols, rows int 88 | lines []line 89 | altLines []line 90 | dirty []bool // line dirtiness 91 | anydirty bool 92 | cur, curSaved cursor 93 | top, bottom int // scroll limits 94 | mode ModeFlag 95 | state parseState 96 | str strEscape 97 | csi csiEscape 98 | numlock bool 99 | tabs []bool 100 | title string 101 | } 102 | 103 | func (t *State) logf(format string, args ...interface{}) { 104 | if t.DebugLogger != nil { 105 | t.DebugLogger.Printf(format, args...) 106 | } 107 | } 108 | 109 | func (t *State) logln(s string) { 110 | if t.DebugLogger != nil { 111 | t.DebugLogger.Println(s) 112 | } 113 | } 114 | 115 | func (t *State) lock() { 116 | t.mu.Lock() 117 | } 118 | 119 | func (t *State) unlock() { 120 | t.mu.Unlock() 121 | } 122 | 123 | // Lock locks the state object's mutex. 124 | func (t *State) Lock() { 125 | t.mu.Lock() 126 | } 127 | 128 | // Unlock resets change flags and unlocks the state object's mutex. 129 | func (t *State) Unlock() { 130 | t.resetChanges() 131 | t.mu.Unlock() 132 | } 133 | 134 | // Cell returns the character code, foreground color, and background 135 | // color at position (x, y) relative to the top left of the terminal. 136 | func (t *State) Cell(x, y int) (ch rune, fg Color, bg Color) { 137 | return t.lines[y][x].c, Color(t.lines[y][x].fg), Color(t.lines[y][x].bg) 138 | } 139 | 140 | // Cursor returns the current position of the cursor. 141 | func (t *State) Cursor() (int, int) { 142 | return t.cur.x, t.cur.y 143 | } 144 | 145 | // CursorVisible returns the visible state of the cursor. 146 | func (t *State) CursorVisible() bool { 147 | return t.mode&ModeHide == 0 148 | } 149 | 150 | // Mode tests if mode is currently set. 151 | func (t *State) Mode(mode ModeFlag) bool { 152 | return t.mode&mode != 0 153 | } 154 | 155 | // Title returns the current title set via the tty. 156 | func (t *State) Title() string { 157 | return t.title 158 | } 159 | 160 | /* 161 | // ChangeMask returns a bitfield of changes that have occured by VT. 162 | func (t *State) ChangeMask() ChangeFlag { 163 | return t.changed 164 | } 165 | */ 166 | 167 | // Changed returns true if change has occured. 168 | func (t *State) Changed(change ChangeFlag) bool { 169 | return t.changed&change != 0 170 | } 171 | 172 | // resetChanges resets the change mask and dirtiness. 173 | func (t *State) resetChanges() { 174 | for i := range t.dirty { 175 | t.dirty[i] = false 176 | } 177 | t.anydirty = false 178 | t.changed = 0 179 | } 180 | 181 | func (t *State) saveCursor() { 182 | t.curSaved = t.cur 183 | } 184 | 185 | func (t *State) restoreCursor() { 186 | t.cur = t.curSaved 187 | t.moveTo(t.cur.x, t.cur.y) 188 | } 189 | 190 | func (t *State) put(c rune) { 191 | t.state(c) 192 | } 193 | 194 | func (t *State) putTab(forward bool) { 195 | x := t.cur.x 196 | if forward { 197 | if x == t.cols { 198 | return 199 | } 200 | for x++; x < t.cols && !t.tabs[x]; x++ { 201 | } 202 | } else { 203 | if x == 0 { 204 | return 205 | } 206 | for x--; x > 0 && !t.tabs[x]; x-- { 207 | } 208 | } 209 | t.moveTo(x, t.cur.y) 210 | } 211 | 212 | func (t *State) newline(firstCol bool) { 213 | y := t.cur.y 214 | if y == t.bottom { 215 | cur := t.cur 216 | t.cur = t.defaultCursor() 217 | t.ScrollUp(t.top, 1) 218 | t.cur = cur 219 | } else { 220 | y++ 221 | } 222 | if firstCol { 223 | t.moveTo(0, y) 224 | } else { 225 | t.moveTo(t.cur.x, y) 226 | } 227 | } 228 | 229 | // table from st, which in turn is from rxvt :) 230 | var gfxCharTable = [62]rune{ 231 | '↑', '↓', '→', '←', '█', '▚', '☃', // A - G 232 | 0, 0, 0, 0, 0, 0, 0, 0, // H - O 233 | 0, 0, 0, 0, 0, 0, 0, 0, // P - W 234 | 0, 0, 0, 0, 0, 0, 0, ' ', // X - _ 235 | '◆', '▒', '␉', '␌', '␍', '␊', '°', '±', // ` - g 236 | '␤', '␋', '┘', '┐', '┌', '└', '┼', '⎺', // h - o 237 | '⎻', '─', '⎼', '⎽', '├', '┤', '┴', '┬', // p - w 238 | '│', '≤', '≥', 'π', '≠', '£', '·', // x - ~ 239 | } 240 | 241 | func (t *State) setChar(c rune, attr *glyph, x, y int) { 242 | if attr.mode&attrGfx != 0 { 243 | if c >= 0x41 && c <= 0x7e && gfxCharTable[c-0x41] != 0 { 244 | c = gfxCharTable[c-0x41] 245 | } 246 | } 247 | t.changed |= ChangedScreen 248 | t.dirty[y] = true 249 | t.lines[y][x] = *attr 250 | t.lines[y][x].c = c 251 | //if t.options.BrightBold && attr.mode&attrBold != 0 && attr.fg < 8 { 252 | if attr.mode&attrBold != 0 && attr.fg < 8 { 253 | t.lines[y][x].fg = attr.fg + 8 254 | } 255 | if attr.mode&attrReverse != 0 { 256 | t.lines[y][x].fg = attr.bg 257 | t.lines[y][x].bg = attr.fg 258 | } 259 | } 260 | 261 | func (t *State) defaultCursor() cursor { 262 | c := cursor{} 263 | c.attr.fg = DefaultFG 264 | c.attr.bg = DefaultBG 265 | return c 266 | } 267 | 268 | func (t *State) reset() { 269 | t.cur = t.defaultCursor() 270 | t.saveCursor() 271 | for i := range t.tabs { 272 | t.tabs[i] = false 273 | } 274 | for i := tabspaces; i < len(t.tabs); i += tabspaces { 275 | t.tabs[i] = true 276 | } 277 | t.top = 0 278 | t.bottom = t.rows - 1 279 | t.mode = ModeWrap 280 | t.clear(0, 0, t.rows-1, t.cols-1) 281 | t.moveTo(0, 0) 282 | } 283 | 284 | // TODO: definitely can improve allocs 285 | func (t *State) resize(cols, rows int) bool { 286 | if cols == t.cols && rows == t.rows { 287 | return false 288 | } 289 | if cols < 1 || rows < 1 { 290 | return false 291 | } 292 | slide := t.cur.y - rows + 1 293 | if slide > 0 { 294 | copy(t.lines, t.lines[slide:slide+rows]) 295 | copy(t.altLines, t.altLines[slide:slide+rows]) 296 | } 297 | 298 | lines, altLines, tabs := t.lines, t.altLines, t.tabs 299 | t.lines = make([]line, rows) 300 | t.altLines = make([]line, rows) 301 | t.dirty = make([]bool, rows) 302 | t.tabs = make([]bool, cols) 303 | 304 | minrows := min(rows, t.rows) 305 | mincols := min(cols, t.cols) 306 | t.changed |= ChangedScreen 307 | for i := 0; i < rows; i++ { 308 | t.dirty[i] = true 309 | t.lines[i] = make(line, cols) 310 | t.altLines[i] = make(line, cols) 311 | } 312 | for i := 0; i < minrows; i++ { 313 | copy(t.lines[i], lines[i]) 314 | copy(t.altLines[i], altLines[i]) 315 | } 316 | copy(t.tabs, tabs) 317 | if cols > t.cols { 318 | i := t.cols - 1 319 | for i > 0 && !tabs[i] { 320 | i-- 321 | } 322 | for i += tabspaces; i < len(tabs); i += tabspaces { 323 | tabs[i] = true 324 | } 325 | } 326 | 327 | t.cols = cols 328 | t.rows = rows 329 | t.setScroll(0, rows-1) 330 | t.moveTo(t.cur.x, t.cur.y) 331 | for i := 0; i < 2; i++ { 332 | if mincols < cols && minrows > 0 { 333 | t.clear(mincols, 0, cols-1, minrows-1) 334 | } 335 | if cols > 0 && minrows < rows { 336 | t.clear(0, minrows, cols-1, rows-1) 337 | } 338 | t.swapScreen() 339 | } 340 | return slide > 0 341 | } 342 | 343 | func (t *State) clear(x0, y0, x1, y1 int) { 344 | if x0 > x1 { 345 | x0, x1 = x1, x0 346 | } 347 | if y0 > y1 { 348 | y0, y1 = y1, y0 349 | } 350 | x0 = clamp(x0, 0, t.cols-1) 351 | x1 = clamp(x1, 0, t.cols-1) 352 | y0 = clamp(y0, 0, t.rows-1) 353 | y1 = clamp(y1, 0, t.rows-1) 354 | t.changed |= ChangedScreen 355 | for y := y0; y <= y1; y++ { 356 | t.dirty[y] = true 357 | for x := x0; x <= x1; x++ { 358 | t.lines[y][x] = t.cur.attr 359 | t.lines[y][x].c = ' ' 360 | } 361 | } 362 | } 363 | 364 | func (t *State) clearAll() { 365 | t.clear(0, 0, t.cols-1, t.rows-1) 366 | } 367 | 368 | func (t *State) moveAbsTo(x, y int) { 369 | if t.cur.state&cursorOrigin != 0 { 370 | y += t.top 371 | } 372 | t.moveTo(x, y) 373 | } 374 | 375 | func (t *State) moveTo(x, y int) { 376 | var miny, maxy int 377 | if t.cur.state&cursorOrigin != 0 { 378 | miny = t.top 379 | maxy = t.bottom 380 | } else { 381 | miny = 0 382 | maxy = t.rows - 1 383 | } 384 | x = clamp(x, 0, t.cols-1) 385 | y = clamp(y, miny, maxy) 386 | t.changed |= ChangedScreen 387 | t.cur.state &^= cursorWrapNext 388 | t.cur.x = x 389 | t.cur.y = y 390 | } 391 | 392 | func (t *State) swapScreen() { 393 | t.lines, t.altLines = t.altLines, t.lines 394 | t.mode ^= ModeAltScreen 395 | t.dirtyAll() 396 | } 397 | 398 | func (t *State) dirtyAll() { 399 | t.changed |= ChangedScreen 400 | for y := 0; y < t.rows; y++ { 401 | t.dirty[y] = true 402 | } 403 | } 404 | 405 | func (t *State) setScroll(top, bottom int) { 406 | top = clamp(top, 0, t.rows-1) 407 | bottom = clamp(bottom, 0, t.rows-1) 408 | if top > bottom { 409 | top, bottom = bottom, top 410 | } 411 | t.top = top 412 | t.bottom = bottom 413 | } 414 | 415 | func min(a, b int) int { 416 | if a < b { 417 | return a 418 | } 419 | return b 420 | } 421 | 422 | func max(a, b int) int { 423 | if a > b { 424 | return a 425 | } 426 | return b 427 | } 428 | 429 | func clamp(val, min, max int) int { 430 | if val < min { 431 | return min 432 | } else if val > max { 433 | return max 434 | } 435 | return val 436 | } 437 | 438 | func between(val, min, max int) bool { 439 | if val < min || val > max { 440 | return false 441 | } 442 | return true 443 | } 444 | 445 | func (t *State) ScrollDown(orig, n int) { 446 | n = clamp(n, 0, t.bottom-orig+1) 447 | t.clear(0, t.bottom-n+1, t.cols-1, t.bottom) 448 | t.changed |= ChangedScreen 449 | for i := t.bottom; i >= orig+n; i-- { 450 | t.lines[i], t.lines[i-n] = t.lines[i-n], t.lines[i] 451 | t.dirty[i] = true 452 | t.dirty[i-n] = true 453 | } 454 | 455 | // TODO: selection scroll 456 | } 457 | 458 | func (t *State) ScrollUp(orig, n int) { 459 | n = clamp(n, 0, t.bottom-orig+1) 460 | t.clear(0, orig, t.cols-1, orig+n-1) 461 | t.changed |= ChangedScreen 462 | for i := orig; i <= t.bottom-n; i++ { 463 | t.lines[i], t.lines[i+n] = t.lines[i+n], t.lines[i] 464 | t.dirty[i] = true 465 | t.dirty[i+n] = true 466 | } 467 | 468 | // TODO: selection scroll 469 | } 470 | 471 | func (t *State) modMode(set bool, bit ModeFlag) { 472 | if set { 473 | t.mode |= bit 474 | } else { 475 | t.mode &^= bit 476 | } 477 | } 478 | 479 | func (t *State) setMode(priv bool, set bool, args []int) { 480 | if priv { 481 | for _, a := range args { 482 | switch a { 483 | case 1: // DECCKM - cursor key 484 | t.modMode(set, ModeAppCursor) 485 | case 5: // DECSCNM - reverse video 486 | mode := t.mode 487 | t.modMode(set, ModeReverse) 488 | if mode != t.mode { 489 | // TODO: redraw 490 | } 491 | case 6: // DECOM - origin 492 | if set { 493 | t.cur.state |= cursorOrigin 494 | } else { 495 | t.cur.state &^= cursorOrigin 496 | } 497 | t.moveAbsTo(0, 0) 498 | case 7: // DECAWM - auto wrap 499 | t.modMode(set, ModeWrap) 500 | // IGNORED: 501 | case 0, // error 502 | 2, // DECANM - ANSI/VT52 503 | 3, // DECCOLM - column 504 | 4, // DECSCLM - scroll 505 | 8, // DECARM - auto repeat 506 | 18, // DECPFF - printer feed 507 | 19, // DECPEX - printer extent 508 | 42, // DECNRCM - national characters 509 | 12: // att610 - start blinking cursor 510 | break 511 | case 25: // DECTCEM - text cursor enable mode 512 | t.modMode(!set, ModeHide) 513 | case 9: // X10 mouse compatibility mode 514 | t.modMode(false, ModeMouseMask) 515 | t.modMode(set, ModeMouseX10) 516 | case 1000: // report button press 517 | t.modMode(false, ModeMouseMask) 518 | t.modMode(set, ModeMouseButton) 519 | case 1002: // report motion on button press 520 | t.modMode(false, ModeMouseMask) 521 | t.modMode(set, ModeMouseMotion) 522 | case 1003: // enable all mouse motions 523 | t.modMode(false, ModeMouseMask) 524 | t.modMode(set, ModeMouseMany) 525 | case 1004: // send focus events to tty 526 | t.modMode(set, ModeFocus) 527 | case 1006: // extended reporting mode 528 | t.modMode(set, ModeMouseSgr) 529 | case 1034: 530 | t.modMode(set, Mode8bit) 531 | case 1049, // = 1047 and 1048 532 | 47, 1047: 533 | alt := t.mode&ModeAltScreen != 0 534 | if alt { 535 | t.clear(0, 0, t.cols-1, t.rows-1) 536 | } 537 | if !set || !alt { 538 | t.swapScreen() 539 | } 540 | if a != 1049 { 541 | break 542 | } 543 | fallthrough 544 | case 1048: 545 | if set { 546 | t.saveCursor() 547 | } else { 548 | t.restoreCursor() 549 | } 550 | case 1001: 551 | // mouse highlight mode; can hang the terminal by design when 552 | // implemented 553 | case 1005: 554 | // utf8 mouse mode; will confuse applications not supporting 555 | // utf8 and luit 556 | case 1015: 557 | // urxvt mangled mouse mode; incompatiblt and can be mistaken 558 | // for other control codes 559 | default: 560 | t.logf("unknown private set/reset mode %d\n", a) 561 | } 562 | } 563 | } else { 564 | for _, a := range args { 565 | switch a { 566 | case 0: // Error (ignored) 567 | case 2: // KAM - keyboard action 568 | t.modMode(set, ModeKeyboardLock) 569 | case 4: // IRM - insertion-replacement 570 | t.modMode(set, ModeInsert) 571 | t.logln("insert mode not implemented") 572 | case 12: // SRM - send/receive 573 | t.modMode(set, ModeEcho) 574 | case 20: // LNM - linefeed/newline 575 | t.modMode(set, ModeCRLF) 576 | case 34: 577 | t.logln("right-to-left mode not implemented") 578 | case 96: 579 | t.logln("right-to-left copy mode not implemented") 580 | default: 581 | t.logf("unknown set/reset mode %d\n", a) 582 | } 583 | } 584 | } 585 | } 586 | 587 | func (t *State) setAttr(attr []int) { 588 | if len(attr) == 0 { 589 | attr = []int{0} 590 | } 591 | for i := 0; i < len(attr); i++ { 592 | a := attr[i] 593 | switch a { 594 | case 0: 595 | t.cur.attr.mode &^= attrReverse | attrUnderline | attrBold | attrItalic | attrBlink 596 | t.cur.attr.fg = DefaultFG 597 | t.cur.attr.bg = DefaultBG 598 | case 1: 599 | t.cur.attr.mode |= attrBold 600 | case 3: 601 | t.cur.attr.mode |= attrItalic 602 | case 4: 603 | t.cur.attr.mode |= attrUnderline 604 | case 5, 6: // slow, rapid blink 605 | t.cur.attr.mode |= attrBlink 606 | case 7: 607 | t.cur.attr.mode |= attrReverse 608 | case 21, 22: 609 | t.cur.attr.mode &^= attrBold 610 | case 23: 611 | t.cur.attr.mode &^= attrItalic 612 | case 24: 613 | t.cur.attr.mode &^= attrUnderline 614 | case 25, 26: 615 | t.cur.attr.mode &^= attrBlink 616 | case 27: 617 | t.cur.attr.mode &^= attrReverse 618 | case 38: 619 | if i+2 < len(attr) && attr[i+1] == 5 { 620 | i += 2 621 | if between(attr[i], 0, 255) { 622 | t.cur.attr.fg = Color(attr[i]) 623 | } else { 624 | t.logf("bad fgcolor %d\n", attr[i]) 625 | } 626 | } else { 627 | t.logf("gfx attr %d unknown\n", a) 628 | } 629 | case 39: 630 | t.cur.attr.fg = DefaultFG 631 | case 48: 632 | if i+2 < len(attr) && attr[i+1] == 5 { 633 | i += 2 634 | if between(attr[i], 0, 255) { 635 | t.cur.attr.bg = Color(attr[i]) 636 | } else { 637 | t.logf("bad bgcolor %d\n", attr[i]) 638 | } 639 | } else { 640 | t.logf("gfx attr %d unknown\n", a) 641 | } 642 | case 49: 643 | t.cur.attr.bg = DefaultBG 644 | default: 645 | if between(a, 30, 37) { 646 | t.cur.attr.fg = Color(a - 30) 647 | } else if between(a, 40, 47) { 648 | t.cur.attr.bg = Color(a - 40) 649 | } else if between(a, 90, 97) { 650 | t.cur.attr.fg = Color(a - 90 + 8) 651 | } else if between(a, 100, 107) { 652 | t.cur.attr.bg = Color(a - 100 + 8) 653 | } else { 654 | t.logf("gfx attr %d unknown\n", a) 655 | } 656 | } 657 | } 658 | } 659 | 660 | func (t *State) insertBlanks(n int) { 661 | src := t.cur.x 662 | dst := src + n 663 | size := t.cols - dst 664 | t.changed |= ChangedScreen 665 | t.dirty[t.cur.y] = true 666 | 667 | if dst >= t.cols { 668 | t.clear(t.cur.x, t.cur.y, t.cols-1, t.cur.y) 669 | } else { 670 | copy(t.lines[t.cur.y][dst:dst+size], t.lines[t.cur.y][src:src+size]) 671 | t.clear(src, t.cur.y, dst-1, t.cur.y) 672 | } 673 | } 674 | 675 | func (t *State) insertBlankLines(n int) { 676 | if t.cur.y < t.top || t.cur.y > t.bottom { 677 | return 678 | } 679 | t.ScrollDown(t.cur.y, n) 680 | } 681 | 682 | func (t *State) deleteLines(n int) { 683 | if t.cur.y < t.top || t.cur.y > t.bottom { 684 | return 685 | } 686 | t.ScrollUp(t.cur.y, n) 687 | } 688 | 689 | func (t *State) deleteChars(n int) { 690 | src := t.cur.x + n 691 | dst := t.cur.x 692 | size := t.cols - src 693 | t.changed |= ChangedScreen 694 | t.dirty[t.cur.y] = true 695 | 696 | if src >= t.cols { 697 | t.clear(t.cur.x, t.cur.y, t.cols-1, t.cur.y) 698 | } else { 699 | copy(t.lines[t.cur.y][dst:dst+size], t.lines[t.cur.y][src:src+size]) 700 | t.clear(t.cols-n, t.cur.y, t.cols-1, t.cur.y) 701 | } 702 | } 703 | 704 | func (t *State) setTitle(title string) { 705 | t.changed |= ChangedTitle 706 | t.title = title 707 | } 708 | -------------------------------------------------------------------------------- /str.go: -------------------------------------------------------------------------------- 1 | package terminal 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | ) 7 | 8 | // STR sequences are similar to CSI sequences, but have string arguments (and 9 | // as far as I can tell, don't really have a name; STR is the name I took from 10 | // suckless which I imagine comes from rxvt or xterm). 11 | type strEscape struct { 12 | typ rune 13 | buf []rune 14 | args []string 15 | } 16 | 17 | func (s *strEscape) reset() { 18 | s.typ = 0 19 | s.buf = s.buf[:0] 20 | s.args = nil 21 | } 22 | 23 | func (s *strEscape) put(c rune) { 24 | // TODO: improve allocs with an array backed slice; bench first 25 | if len(s.buf) < 256 { 26 | s.buf = append(s.buf, c) 27 | } 28 | // Going by st, it is better to remain silent when the STR sequence is not 29 | // ended so that it is apparent to users something is wrong. The length sanity 30 | // check ensures we don't absorb the entire stream into memory. 31 | // TODO: see what rxvt or xterm does 32 | } 33 | 34 | func (s *strEscape) parse() { 35 | s.args = strings.Split(string(s.buf), ";") 36 | } 37 | 38 | func (s *strEscape) arg(i, def int) int { 39 | if i >= len(s.args) || i < 0 { 40 | return def 41 | } 42 | i, err := strconv.Atoi(s.args[i]) 43 | if err != nil { 44 | return def 45 | } 46 | return i 47 | } 48 | 49 | func (s *strEscape) argString(i int, def string) string { 50 | if i >= len(s.args) || i < 0 { 51 | return def 52 | } 53 | return s.args[i] 54 | } 55 | 56 | func (t *State) handleSTR() { 57 | s := &t.str 58 | s.parse() 59 | 60 | switch s.typ { 61 | case ']': // OSC - operating system command 62 | switch d := s.arg(0, 0); d { 63 | case 0, 1, 2: 64 | title := s.argString(1, "") 65 | if title != "" { 66 | t.setTitle(title) 67 | } 68 | case 4: // color set 69 | if len(s.args) < 3 { 70 | break 71 | } 72 | // setcolorname(s.arg(1, 0), s.argString(2, "")) 73 | case 104: // color reset 74 | // TODO: complain about invalid color, redraw, etc. 75 | // setcolorname(s.arg(1, 0), nil) 76 | default: 77 | t.logf("unknown OSC command %d\n", d) 78 | // TODO: s.dump() 79 | } 80 | case 'k': // old title set compatibility 81 | title := s.argString(0, "") 82 | if title != "" { 83 | t.setTitle(title) 84 | } 85 | default: 86 | // TODO: Ignore these codes instead of complain? 87 | // 'P': // DSC - device control string 88 | // '_': // APC - application program command 89 | // '^': // PM - privacy message 90 | 91 | t.logf("unhandled STR sequence '%c'\n", s.typ) 92 | // t.str.dump() 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /str_test.go: -------------------------------------------------------------------------------- 1 | package terminal 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestSTRParse(t *testing.T) { 8 | var str strEscape 9 | str.reset() 10 | str.buf = []rune("0;some text") 11 | str.parse() 12 | if str.arg(0, 17) != 0 || str.argString(1, "") != "some text" { 13 | t.Fatal("STR parse mismatch") 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /vt_other.go: -------------------------------------------------------------------------------- 1 | //go:build plan9 || nacl || windows 2 | // +build plan9 nacl windows 3 | 4 | package terminal 5 | 6 | import ( 7 | "bufio" 8 | "bytes" 9 | "errors" 10 | "io" 11 | "os" 12 | "os/exec" 13 | "unicode" 14 | "unicode/utf8" 15 | ) 16 | 17 | // VT represents the virtual terminal emulator. 18 | type VT struct { 19 | dest *State 20 | rc io.ReadCloser 21 | br *bufio.Reader 22 | pty *os.File 23 | } 24 | 25 | // Start initializes a virtual terminal emulator with the target state 26 | // and a new pty file by starting the *exec.Command. The returned 27 | // *os.File is the pty file. 28 | func Start(state *State, cmd *exec.Cmd) (*VT, *os.File, error) { 29 | return nil, nil, errors.New("Unsupported operating system") 30 | } 31 | 32 | // Create initializes a virtual terminal emulator with the target state 33 | // and io.ReadCloser input. 34 | func Create(state *State, rc io.ReadCloser) (*VT, error) { 35 | t := &VT{ 36 | dest: state, 37 | rc: rc, 38 | } 39 | t.init() 40 | return t, nil 41 | } 42 | 43 | func (t *VT) init() { 44 | t.br = bufio.NewReader(t.rc) 45 | t.dest.numlock = true 46 | t.dest.state = t.dest.parse 47 | t.dest.cur.attr.fg = DefaultFG 48 | t.dest.cur.attr.bg = DefaultBG 49 | t.Resize(80, 24) 50 | t.dest.reset() 51 | } 52 | 53 | // File returns the pty file. 54 | func (t *VT) File() *os.File { 55 | return t.pty 56 | } 57 | 58 | // Write parses input and writes terminal changes to state. 59 | func (t *VT) Write(p []byte) (int, error) { 60 | var written int 61 | r := bytes.NewReader(p) 62 | t.dest.lock() 63 | defer t.dest.unlock() 64 | for { 65 | c, sz, err := r.ReadRune() 66 | if err != nil { 67 | if err == io.EOF { 68 | break 69 | } 70 | return written, err 71 | } 72 | written += sz 73 | if c == unicode.ReplacementChar && sz == 1 { 74 | if r.Len() == 0 { 75 | // not enough bytes for a full rune 76 | return written - 1, nil 77 | } 78 | t.dest.logln("invalid utf8 sequence") 79 | continue 80 | } 81 | t.dest.put(c) 82 | } 83 | return written, nil 84 | } 85 | 86 | // Close closes the pty or io.ReadCloser. 87 | func (t *VT) Close() error { 88 | return t.rc.Close() 89 | } 90 | 91 | // Parse blocks on read on pty or io.ReadCloser, then parses sequences until 92 | // buffer empties. State is locked as soon as first rune is read, and unlocked 93 | // when buffer is empty. 94 | // TODO: add tests for expected blocking behavior 95 | func (t *VT) Parse() error { 96 | var locked bool 97 | defer func() { 98 | if locked { 99 | t.dest.unlock() 100 | } 101 | }() 102 | for { 103 | c, sz, err := t.br.ReadRune() 104 | if err != nil { 105 | return err 106 | } 107 | if c == unicode.ReplacementChar && sz == 1 { 108 | t.dest.logln("invalid utf8 sequence") 109 | break 110 | } 111 | if !locked { 112 | t.dest.lock() 113 | locked = true 114 | } 115 | 116 | // put rune for parsing and update state 117 | t.dest.put(c) 118 | 119 | // break if our buffer is empty, or if buffer contains an 120 | // incomplete rune. 121 | n := t.br.Buffered() 122 | if n == 0 || (n < 4 && !fullRuneBuffered(t.br)) { 123 | break 124 | } 125 | } 126 | return nil 127 | } 128 | 129 | func fullRuneBuffered(br *bufio.Reader) bool { 130 | n := br.Buffered() 131 | buf, err := br.Peek(n) 132 | if err != nil { 133 | return false 134 | } 135 | return utf8.FullRune(buf) 136 | } 137 | 138 | // Resize reports new size to pty and updates state. 139 | func (t *VT) Resize(cols, rows int) { 140 | t.dest.lock() 141 | defer t.dest.unlock() 142 | _ = t.dest.resize(cols, rows) 143 | t.ptyResize() 144 | } 145 | -------------------------------------------------------------------------------- /vt_posix.go: -------------------------------------------------------------------------------- 1 | //go:build linux || darwin || dragonfly || solaris || openbsd || netbsd || freebsd 2 | // +build linux darwin dragonfly solaris openbsd netbsd freebsd 3 | 4 | package terminal 5 | 6 | import ( 7 | "bufio" 8 | "bytes" 9 | "io" 10 | "os" 11 | "os/exec" 12 | "unicode" 13 | "unicode/utf8" 14 | 15 | "github.com/creack/pty" 16 | ) 17 | 18 | // VT represents the virtual terminal emulator. 19 | type VT struct { 20 | dest *State 21 | rc io.ReadCloser 22 | br *bufio.Reader 23 | pty *os.File 24 | } 25 | 26 | // Start initializes a virtual terminal emulator with the target state 27 | // and a new pty file by starting the *exec.Command. The returned 28 | // *os.File is the pty file. 29 | func Start(state *State, cmd *exec.Cmd) (*VT, *os.File, error) { 30 | var err error 31 | t := &VT{ 32 | dest: state, 33 | } 34 | t.pty, err = pty.Start(cmd) 35 | if err != nil { 36 | return nil, nil, err 37 | } 38 | t.rc = t.pty 39 | t.init() 40 | return t, t.pty, nil 41 | } 42 | 43 | // Create initializes a virtual terminal emulator with the target state 44 | // and io.ReadCloser input. 45 | func Create(state *State, rc io.ReadCloser) (*VT, error) { 46 | t := &VT{ 47 | dest: state, 48 | rc: rc, 49 | } 50 | t.init() 51 | return t, nil 52 | } 53 | 54 | func (t *VT) init() { 55 | t.br = bufio.NewReader(t.rc) 56 | t.dest.numlock = true 57 | t.dest.state = t.dest.parse 58 | t.dest.cur.attr.fg = DefaultFG 59 | t.dest.cur.attr.bg = DefaultBG 60 | t.Resize(80, 24) 61 | t.dest.reset() 62 | } 63 | 64 | // File returns the pty file. 65 | func (t *VT) File() *os.File { 66 | return t.pty 67 | } 68 | 69 | // Write parses input and writes terminal changes to state. 70 | func (t *VT) Write(p []byte) (int, error) { 71 | var written int 72 | r := bytes.NewReader(p) 73 | t.dest.lock() 74 | defer t.dest.unlock() 75 | for { 76 | c, sz, err := r.ReadRune() 77 | if err != nil { 78 | if err == io.EOF { 79 | break 80 | } 81 | return written, err 82 | } 83 | written += sz 84 | if c == unicode.ReplacementChar && sz == 1 { 85 | if r.Len() == 0 { 86 | // not enough bytes for a full rune 87 | return written - 1, nil 88 | } 89 | t.dest.logln("invalid utf8 sequence") 90 | continue 91 | } 92 | t.dest.put(c) 93 | } 94 | return written, nil 95 | } 96 | 97 | // Close closes the pty or io.ReadCloser. 98 | func (t *VT) Close() error { 99 | return t.rc.Close() 100 | } 101 | 102 | // Parse blocks on read on pty or io.ReadCloser, then parses sequences until 103 | // buffer empties. State is locked as soon as first rune is read, and unlocked 104 | // when buffer is empty. 105 | // TODO: add tests for expected blocking behavior 106 | func (t *VT) Parse() error { 107 | var locked bool 108 | defer func() { 109 | if locked { 110 | t.dest.unlock() 111 | } 112 | }() 113 | for { 114 | c, sz, err := t.br.ReadRune() 115 | if err != nil { 116 | return err 117 | } 118 | if c == unicode.ReplacementChar && sz == 1 { 119 | t.dest.logln("invalid utf8 sequence") 120 | break 121 | } 122 | if !locked { 123 | t.dest.lock() 124 | locked = true 125 | } 126 | 127 | // put rune for parsing and update state 128 | t.dest.put(c) 129 | 130 | // break if our buffer is empty, or if buffer contains an 131 | // incomplete rune. 132 | n := t.br.Buffered() 133 | if n == 0 || (n < 4 && !fullRuneBuffered(t.br)) { 134 | break 135 | } 136 | } 137 | return nil 138 | } 139 | 140 | func fullRuneBuffered(br *bufio.Reader) bool { 141 | n := br.Buffered() 142 | buf, err := br.Peek(n) 143 | if err != nil { 144 | return false 145 | } 146 | return utf8.FullRune(buf) 147 | } 148 | 149 | // Resize reports new size to pty and updates state. 150 | func (t *VT) Resize(cols, rows int) { 151 | t.dest.lock() 152 | defer t.dest.unlock() 153 | _ = t.dest.resize(cols, rows) 154 | t.ptyResize() 155 | } 156 | -------------------------------------------------------------------------------- /vt_test.go: -------------------------------------------------------------------------------- 1 | package terminal 2 | 3 | import ( 4 | "io" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func extractStr(t *State, x0, x1, row int) string { 10 | var s []rune 11 | for i := x0; i <= x1; i++ { 12 | c, _, _ := t.Cell(i, row) 13 | s = append(s, c) 14 | } 15 | return string(s) 16 | } 17 | 18 | func TestPlainChars(t *testing.T) { 19 | var st State 20 | term, err := Create(&st, nil) 21 | if err != nil { 22 | t.Fatal(err) 23 | } 24 | expected := "Hello world!" 25 | _, err = term.Write([]byte(expected)) 26 | if err != nil && err != io.EOF { 27 | t.Fatal(err) 28 | } 29 | actual := extractStr(&st, 0, len(expected)-1, 0) 30 | if expected != actual { 31 | t.Fatal(actual) 32 | } 33 | } 34 | 35 | func TestNewline(t *testing.T) { 36 | var st State 37 | term, err := Create(&st, nil) 38 | if err != nil { 39 | t.Fatal(err) 40 | } 41 | expected := "Hello world!\n...and more." 42 | _, err = term.Write([]byte("\033[20h")) // set CRLF mode 43 | if err != nil && err != io.EOF { 44 | t.Fatal(err) 45 | } 46 | _, err = term.Write([]byte(expected)) 47 | if err != nil && err != io.EOF { 48 | t.Fatal(err) 49 | } 50 | 51 | split := strings.Split(expected, "\n") 52 | actual := extractStr(&st, 0, len(split[0])-1, 0) 53 | actual += "\n" 54 | actual += extractStr(&st, 0, len(split[1])-1, 1) 55 | if expected != actual { 56 | t.Fatal(actual) 57 | } 58 | 59 | // A newline with a color set should not make the next line that color, 60 | // which used to happen if it caused a scroll event. 61 | st.moveTo(0, st.rows-1) 62 | _, err = term.Write([]byte("\033[1;37m\n$ \033[m")) 63 | if err != nil && err != io.EOF { 64 | t.Fatal(err) 65 | } 66 | _, fg, bg := st.Cell(st.Cursor()) 67 | if fg != DefaultFG { 68 | t.Fatal(st.cur.x, st.cur.y, fg, bg) 69 | } 70 | } 71 | --------------------------------------------------------------------------------